dsh-scout 0.8.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.
- package/LICENSE +21 -0
- package/README.en.md +90 -0
- package/README.md +181 -0
- package/cordis.patch.yml +3 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +624 -0
- package/dist/model.d.ts +122 -0
- package/dist/model.js +564 -0
- package/docs/dsh-scout-product-contract.md +108 -0
- package/docs/fixtures/dsh-scout/snapmaker-hr-head.json +92 -0
- package/package.json +63 -0
- package/scripts/profile-smoke.mjs +101 -0
- package/tests/model.test.mjs +1356 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
|
+
import { addClaim, addSource, appendEvent, CLAIM_DIMENSIONS, CLAIM_IMPACTS, CLAIM_STATUSES, createCase, decideCase, EVIDENCE_LEVELS, EXPORT_FILE_NAMES, exportCaseFiles, generateInterviewQuestions, importCaseFromFiles, inferEvidenceLevel, inferSourceType, renderComparison, renderReport, SOURCE_TYPES, verifyClaim, verifyIdentity, } from './model.js';
|
|
3
|
+
export const name = 'dsh-scout';
|
|
4
|
+
export const inject = ['tools', 'fs', 'web'];
|
|
5
|
+
export function apply(ctx, config = {}) {
|
|
6
|
+
const cases = new Map();
|
|
7
|
+
const caseKey = (caseId, agentId) => `${String(agentId ?? 'unscoped')}::${caseId}`;
|
|
8
|
+
const requireCase = (key) => {
|
|
9
|
+
const scoutCase = cases.get(key);
|
|
10
|
+
if (!scoutCase)
|
|
11
|
+
throw new Error(`Unknown case: ${key.split('::').at(-1)}`);
|
|
12
|
+
return scoutCase;
|
|
13
|
+
};
|
|
14
|
+
const fs = ctx.fs;
|
|
15
|
+
const web = ctx.web;
|
|
16
|
+
const defaultScoutDir = config.scoutDir?.replace(/\/+$/, '') || 'dsh-scout';
|
|
17
|
+
const caseExportDir = (caseId) => `${defaultScoutDir}/${caseId}`;
|
|
18
|
+
const persistFiles = async (scoutCase, targetDir) => {
|
|
19
|
+
if (!fs)
|
|
20
|
+
return { persisted: false, targetDir, error: 'Filesystem service is unavailable in this host' };
|
|
21
|
+
try {
|
|
22
|
+
const files = exportCaseFiles(scoutCase);
|
|
23
|
+
for (const name of EXPORT_FILE_NAMES) {
|
|
24
|
+
const target = await fs.resolve(`${targetDir}/${name}`);
|
|
25
|
+
await fs.writeText(target, files[name]);
|
|
26
|
+
}
|
|
27
|
+
return { persisted: true, targetDir };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return { persisted: false, targetDir, error: error instanceof Error ? error.message : String(error) };
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const recordEvent = (scoutCase, type, detail) => {
|
|
34
|
+
const events = scoutCase.events ?? [];
|
|
35
|
+
let n = events.length + 1;
|
|
36
|
+
let eventId = `evt-${n}`;
|
|
37
|
+
while (events.some(event => event.eventId === eventId)) {
|
|
38
|
+
n += 1;
|
|
39
|
+
eventId = `evt-${n}`;
|
|
40
|
+
}
|
|
41
|
+
return appendEvent(scoutCase, {
|
|
42
|
+
eventId,
|
|
43
|
+
type,
|
|
44
|
+
at: new Date().toISOString(),
|
|
45
|
+
detail,
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
const registerSource = (scoutCase, source) => recordEvent(addSource(scoutCase, source), 'source_added', {
|
|
49
|
+
sourceId: source.sourceId,
|
|
50
|
+
type: source.type,
|
|
51
|
+
title: source.title,
|
|
52
|
+
url: source.url,
|
|
53
|
+
evidenceLevel: source.evidenceLevel,
|
|
54
|
+
ingested: true,
|
|
55
|
+
});
|
|
56
|
+
const maybePersist = async (scoutCase) => {
|
|
57
|
+
if (!config.autoPersist)
|
|
58
|
+
return scoutCase;
|
|
59
|
+
const targetDir = caseExportDir(scoutCase.caseId);
|
|
60
|
+
const withEvent = recordEvent(scoutCase, 'case_exported', { targetDir, auto: true });
|
|
61
|
+
const result = await persistFiles(withEvent, targetDir);
|
|
62
|
+
if (!result.persisted)
|
|
63
|
+
return scoutCase;
|
|
64
|
+
return withEvent;
|
|
65
|
+
};
|
|
66
|
+
ctx.effect(function* registerScoutTools() {
|
|
67
|
+
yield () => cases.clear();
|
|
68
|
+
yield ctx.tools.register(defineTool({
|
|
69
|
+
name: 'scout_start',
|
|
70
|
+
description: 'Start a company and job due-diligence case. The initial decision is VERIFY until identity evidence is confirmed.',
|
|
71
|
+
parameters: {
|
|
72
|
+
caseId: { type: 'string', required: true, description: 'Stable case identifier.' },
|
|
73
|
+
companyName: { type: 'string', required: true, description: 'Company or brand name to investigate.' },
|
|
74
|
+
roleTitle: { type: 'string', required: true, description: 'Role title to evaluate.' },
|
|
75
|
+
location: { type: 'string', required: true, description: 'Job location, or an empty string if unknown.' },
|
|
76
|
+
},
|
|
77
|
+
output: {
|
|
78
|
+
schema: { type: 'string' },
|
|
79
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
80
|
+
},
|
|
81
|
+
async execute(args, exec) {
|
|
82
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
83
|
+
if (cases.has(key))
|
|
84
|
+
throw new Error(`Case already exists: ${args.caseId}`);
|
|
85
|
+
const scoutCase = createCase({
|
|
86
|
+
caseId: args.caseId,
|
|
87
|
+
companyName: args.companyName,
|
|
88
|
+
roleTitle: args.roleTitle,
|
|
89
|
+
location: args.location || undefined,
|
|
90
|
+
});
|
|
91
|
+
let nextCase = recordEvent(scoutCase, 'case_started', {
|
|
92
|
+
companyName: args.companyName,
|
|
93
|
+
roleTitle: args.roleTitle,
|
|
94
|
+
location: args.location || null,
|
|
95
|
+
});
|
|
96
|
+
nextCase = await maybePersist(nextCase);
|
|
97
|
+
cases.set(key, nextCase);
|
|
98
|
+
return JSON.stringify(nextCase, null, 2);
|
|
99
|
+
},
|
|
100
|
+
}));
|
|
101
|
+
yield ctx.tools.register(defineTool({
|
|
102
|
+
name: 'scout_add_claim',
|
|
103
|
+
description: 'Add a sourced or explicitly unverified claim to an active dsh-scout case.',
|
|
104
|
+
parameters: {
|
|
105
|
+
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
106
|
+
claimId: { type: 'string', required: true, description: 'Stable claim identifier.' },
|
|
107
|
+
text: { type: 'string', required: true, description: 'Claim text.' },
|
|
108
|
+
status: { type: 'string', enum: CLAIM_STATUSES, required: true, description: 'Claim verification status.' },
|
|
109
|
+
evidenceLevel: { type: 'string', enum: EVIDENCE_LEVELS, required: true, description: 'Source quality from E0 to E3.' },
|
|
110
|
+
dimension: { type: 'string', enum: CLAIM_DIMENSIONS, required: true, description: 'Due-diligence dimension this claim belongs to.' },
|
|
111
|
+
impact: { type: 'string', enum: CLAIM_IMPACTS, required: true, description: 'Decision impact: blocking, material, or informational.' },
|
|
112
|
+
sourceIds: { type: 'string', required: true, description: 'Comma-separated source identifiers; empty when there is no source.' },
|
|
113
|
+
confidenceNote: { type: 'string', required: true, description: 'Why the current evidence supports this status and level.' },
|
|
114
|
+
nextAction: { type: 'string', required: true, description: 'Smallest next action to verify or use the claim.' },
|
|
115
|
+
},
|
|
116
|
+
output: {
|
|
117
|
+
schema: { type: 'string' },
|
|
118
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
119
|
+
},
|
|
120
|
+
async execute(args, exec) {
|
|
121
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
122
|
+
const scoutCase = requireCase(key);
|
|
123
|
+
let nextCase = recordEvent(decideCase(addClaim(scoutCase, {
|
|
124
|
+
claimId: args.claimId,
|
|
125
|
+
text: args.text,
|
|
126
|
+
status: args.status,
|
|
127
|
+
evidenceLevel: args.evidenceLevel,
|
|
128
|
+
dimension: args.dimension,
|
|
129
|
+
impact: args.impact,
|
|
130
|
+
sourceIds: args.sourceIds ? args.sourceIds.split(',').map(value => value.trim()).filter(Boolean) : [],
|
|
131
|
+
confidenceNote: args.confidenceNote,
|
|
132
|
+
nextAction: args.nextAction,
|
|
133
|
+
})), 'claim_added', {
|
|
134
|
+
claimId: args.claimId,
|
|
135
|
+
status: args.status,
|
|
136
|
+
evidenceLevel: args.evidenceLevel,
|
|
137
|
+
dimension: args.dimension,
|
|
138
|
+
impact: args.impact,
|
|
139
|
+
});
|
|
140
|
+
nextCase = await maybePersist(nextCase);
|
|
141
|
+
cases.set(key, nextCase);
|
|
142
|
+
return JSON.stringify(nextCase, null, 2);
|
|
143
|
+
},
|
|
144
|
+
}));
|
|
145
|
+
yield ctx.tools.register(defineTool({
|
|
146
|
+
name: 'scout_ingest',
|
|
147
|
+
description: 'Batch-register collected research results as sources, optionally drafting claims from each item. itemsJson is a JSON array of { url, title?, sourceType?, evidenceLevel?, claim? }; claim is { text, dimension, impact?, status?, evidenceLevel? } with status defaulting to reported, impact to material, and evidence level to the source level. Source type and evidence level are inferred from the URL when omitted (official registry/regulator origins map to E3, job platforms to job_posting/E2, user-provided to E1, unknown to other/E2). Individual invalid items or rejected claims are reported in errors without aborting the batch; a failed claim does not undo its already-registered source.',
|
|
148
|
+
parameters: {
|
|
149
|
+
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
150
|
+
itemsJson: { type: 'string', required: true, description: 'JSON array of collected source items.' },
|
|
151
|
+
},
|
|
152
|
+
output: {
|
|
153
|
+
schema: { type: 'string' },
|
|
154
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
155
|
+
},
|
|
156
|
+
async execute(args, exec) {
|
|
157
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
158
|
+
let scoutCase = requireCase(key);
|
|
159
|
+
let items;
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(args.itemsJson);
|
|
162
|
+
if (!Array.isArray(parsed))
|
|
163
|
+
throw new Error('itemsJson must be a JSON array');
|
|
164
|
+
items = parsed;
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new Error(`Invalid itemsJson: ${error instanceof Error ? error.message : String(error)}`);
|
|
168
|
+
}
|
|
169
|
+
const added = [];
|
|
170
|
+
const errors = [];
|
|
171
|
+
let nextId = (scoutCase.sources.length ?? 0) + 1;
|
|
172
|
+
let nextClaimId = (scoutCase.claims.length ?? 0) + 1;
|
|
173
|
+
for (const rawItem of items) {
|
|
174
|
+
const itemUrl = () => rawItem && typeof rawItem === 'object' && typeof rawItem.url === 'string'
|
|
175
|
+
? rawItem.url
|
|
176
|
+
: String(rawItem?.url ?? rawItem ?? '');
|
|
177
|
+
try {
|
|
178
|
+
if (!rawItem || typeof rawItem !== 'object') {
|
|
179
|
+
errors.push({ url: String(rawItem ?? ''), error: 'item must be an object' });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const item = rawItem;
|
|
183
|
+
const url = typeof item.url === 'string' ? item.url.trim() : '';
|
|
184
|
+
if (!url) {
|
|
185
|
+
errors.push({ url: String(item.url ?? ''), error: 'url is required' });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const explicitType = typeof item.sourceType === 'string' && SOURCE_TYPES.includes(item.sourceType)
|
|
189
|
+
? item.sourceType
|
|
190
|
+
: undefined;
|
|
191
|
+
const explicitLevel = typeof item.evidenceLevel === 'string' && EVIDENCE_LEVELS.includes(item.evidenceLevel)
|
|
192
|
+
? item.evidenceLevel
|
|
193
|
+
: undefined;
|
|
194
|
+
if (item.sourceType !== undefined && item.sourceType !== null && !explicitType) {
|
|
195
|
+
errors.push({ url, error: `invalid sourceType: ${String(item.sourceType)}` });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (item.evidenceLevel !== undefined && item.evidenceLevel !== null && !explicitLevel) {
|
|
199
|
+
errors.push({ url, error: `invalid evidenceLevel: ${String(item.evidenceLevel)}` });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const typeGuess = inferSourceType(url, explicitType);
|
|
203
|
+
const levelGuess = inferEvidenceLevel(url, typeGuess.type, explicitLevel);
|
|
204
|
+
let sourceId = `src-${nextId}`;
|
|
205
|
+
while (scoutCase.sources.some(source => source.sourceId === sourceId)) {
|
|
206
|
+
nextId += 1;
|
|
207
|
+
sourceId = `src-${nextId}`;
|
|
208
|
+
}
|
|
209
|
+
nextId += 1;
|
|
210
|
+
const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
|
|
211
|
+
const source = {
|
|
212
|
+
sourceId,
|
|
213
|
+
type: typeGuess.type,
|
|
214
|
+
title,
|
|
215
|
+
url,
|
|
216
|
+
capturedAt: new Date().toISOString(),
|
|
217
|
+
evidenceLevel: levelGuess.evidenceLevel,
|
|
218
|
+
status: 'captured',
|
|
219
|
+
};
|
|
220
|
+
scoutCase = registerSource(scoutCase, source);
|
|
221
|
+
const addedEntry = {
|
|
222
|
+
sourceId,
|
|
223
|
+
title,
|
|
224
|
+
url,
|
|
225
|
+
type: typeGuess.type,
|
|
226
|
+
evidenceLevel: levelGuess.evidenceLevel,
|
|
227
|
+
inferred: { type: typeGuess.inferred, evidenceLevel: levelGuess.inferred },
|
|
228
|
+
};
|
|
229
|
+
const rawClaim = item.claim;
|
|
230
|
+
if (rawClaim !== undefined && rawClaim !== null) {
|
|
231
|
+
if (typeof rawClaim !== 'object') {
|
|
232
|
+
errors.push({ url, error: 'claim must be an object' });
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
const claimDraft = rawClaim;
|
|
236
|
+
const text = typeof claimDraft.text === 'string' && claimDraft.text.trim() ? claimDraft.text.trim() : '';
|
|
237
|
+
const dimension = typeof claimDraft.dimension === 'string' && CLAIM_DIMENSIONS.includes(claimDraft.dimension)
|
|
238
|
+
? claimDraft.dimension
|
|
239
|
+
: undefined;
|
|
240
|
+
const claimIssues = [];
|
|
241
|
+
const hasStatus = claimDraft.status !== undefined && claimDraft.status !== null;
|
|
242
|
+
const status = hasStatus && typeof claimDraft.status === 'string' && CLAIM_STATUSES.includes(claimDraft.status)
|
|
243
|
+
? claimDraft.status
|
|
244
|
+
: undefined;
|
|
245
|
+
if (hasStatus && !status)
|
|
246
|
+
claimIssues.push(`invalid claim.status: ${String(claimDraft.status)}`);
|
|
247
|
+
const hasImpact = claimDraft.impact !== undefined && claimDraft.impact !== null;
|
|
248
|
+
const impact = hasImpact && typeof claimDraft.impact === 'string' && CLAIM_IMPACTS.includes(claimDraft.impact)
|
|
249
|
+
? claimDraft.impact
|
|
250
|
+
: undefined;
|
|
251
|
+
if (hasImpact && !impact)
|
|
252
|
+
claimIssues.push(`invalid claim.impact: ${String(claimDraft.impact)}`);
|
|
253
|
+
const hasClaimLevel = claimDraft.evidenceLevel !== undefined && claimDraft.evidenceLevel !== null;
|
|
254
|
+
const claimLevel = hasClaimLevel && typeof claimDraft.evidenceLevel === 'string' && EVIDENCE_LEVELS.includes(claimDraft.evidenceLevel)
|
|
255
|
+
? claimDraft.evidenceLevel
|
|
256
|
+
: undefined;
|
|
257
|
+
if (hasClaimLevel && !claimLevel)
|
|
258
|
+
claimIssues.push(`invalid claim.evidenceLevel: ${String(claimDraft.evidenceLevel)}`);
|
|
259
|
+
if (!text)
|
|
260
|
+
claimIssues.push('claim.text is required');
|
|
261
|
+
if (!dimension)
|
|
262
|
+
claimIssues.push(`invalid claim.dimension: ${String(claimDraft.dimension)}`);
|
|
263
|
+
if (claimIssues.length) {
|
|
264
|
+
errors.push({ url, error: `claim rejected: ${claimIssues.join('; ')}` });
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
let claimId = `claim-${nextClaimId}`;
|
|
268
|
+
while (scoutCase.claims.some(claim => claim.claimId === claimId)) {
|
|
269
|
+
nextClaimId += 1;
|
|
270
|
+
claimId = `claim-${nextClaimId}`;
|
|
271
|
+
}
|
|
272
|
+
nextClaimId += 1;
|
|
273
|
+
try {
|
|
274
|
+
scoutCase = recordEvent(addClaim(scoutCase, {
|
|
275
|
+
claimId,
|
|
276
|
+
text,
|
|
277
|
+
status: status ?? 'reported',
|
|
278
|
+
evidenceLevel: claimLevel ?? source.evidenceLevel,
|
|
279
|
+
dimension: dimension,
|
|
280
|
+
impact: impact ?? 'material',
|
|
281
|
+
sourceIds: [source.sourceId],
|
|
282
|
+
confidenceNote: `由采集项自动登记,来源 ${source.sourceId}`,
|
|
283
|
+
nextAction: '核验该主张并补充独立来源',
|
|
284
|
+
}), 'claim_added', {
|
|
285
|
+
claimId,
|
|
286
|
+
status: status ?? 'reported',
|
|
287
|
+
evidenceLevel: claimLevel ?? source.evidenceLevel,
|
|
288
|
+
dimension: dimension,
|
|
289
|
+
impact: impact ?? 'material',
|
|
290
|
+
text,
|
|
291
|
+
sourceIds: [source.sourceId],
|
|
292
|
+
ingested: true,
|
|
293
|
+
});
|
|
294
|
+
addedEntry.claims = [claimId];
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
errors.push({ url, error: `claim rejected: ${error instanceof Error ? error.message : String(error)}` });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
added.push(addedEntry);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
errors.push({ url: itemUrl(), error: error instanceof Error ? error.message : String(error) });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
scoutCase = decideCase(scoutCase);
|
|
309
|
+
scoutCase = await maybePersist(scoutCase);
|
|
310
|
+
cases.set(key, scoutCase);
|
|
311
|
+
return JSON.stringify({ added, errors }, null, 2);
|
|
312
|
+
},
|
|
313
|
+
}));
|
|
314
|
+
yield ctx.tools.register(defineTool({
|
|
315
|
+
name: 'scout_search',
|
|
316
|
+
description: 'Run a web search through the DSH web provider and auto-register the result URLs as sources for the case (type and evidence level inferred per URL). Optionally limits how many results are registered.',
|
|
317
|
+
parameters: {
|
|
318
|
+
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
319
|
+
query: { type: 'string', required: true, description: 'Search query to run through the web provider.' },
|
|
320
|
+
limit: { type: 'number', description: 'Maximum number of search results to register (default 5, capped at 10).' },
|
|
321
|
+
},
|
|
322
|
+
output: {
|
|
323
|
+
schema: { type: 'string' },
|
|
324
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
325
|
+
},
|
|
326
|
+
async execute(args, exec) {
|
|
327
|
+
if (!web)
|
|
328
|
+
throw new Error('Web search service is unavailable in this host');
|
|
329
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
330
|
+
let scoutCase = requireCase(key);
|
|
331
|
+
const parsedLimit = Math.trunc(args.limit ?? 5);
|
|
332
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 10) : 5;
|
|
333
|
+
const result = await web.search({ query: args.query, maxResults: limit }, exec.signal);
|
|
334
|
+
const added = [];
|
|
335
|
+
const errors = [];
|
|
336
|
+
const items = Array.isArray(result.sources) ? result.sources : [];
|
|
337
|
+
if (!Array.isArray(result.sources)) {
|
|
338
|
+
errors.push({ url: '', error: 'search provider returned a non-array sources field' });
|
|
339
|
+
}
|
|
340
|
+
let nextId = (scoutCase.sources.length ?? 0) + 1;
|
|
341
|
+
items.forEach((item, index) => {
|
|
342
|
+
if (!item || typeof item !== 'object') {
|
|
343
|
+
errors.push({ url: '', error: `search result ${index} must be an object` });
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const url = typeof item.url === 'string' ? item.url.trim() : '';
|
|
347
|
+
if (!url) {
|
|
348
|
+
errors.push({ url: '', error: `search result ${index} missing url` });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const typeGuess = inferSourceType(url);
|
|
353
|
+
const levelGuess = inferEvidenceLevel(url, typeGuess.type);
|
|
354
|
+
let sourceId = `src-${nextId}`;
|
|
355
|
+
while (scoutCase.sources.some(source => source.sourceId === sourceId)) {
|
|
356
|
+
nextId += 1;
|
|
357
|
+
sourceId = `src-${nextId}`;
|
|
358
|
+
}
|
|
359
|
+
nextId += 1;
|
|
360
|
+
const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
|
|
361
|
+
scoutCase = registerSource(scoutCase, {
|
|
362
|
+
sourceId,
|
|
363
|
+
type: typeGuess.type,
|
|
364
|
+
title,
|
|
365
|
+
url,
|
|
366
|
+
capturedAt: new Date().toISOString(),
|
|
367
|
+
evidenceLevel: levelGuess.evidenceLevel,
|
|
368
|
+
status: 'captured',
|
|
369
|
+
});
|
|
370
|
+
added.push({
|
|
371
|
+
sourceId,
|
|
372
|
+
title,
|
|
373
|
+
url,
|
|
374
|
+
type: typeGuess.type,
|
|
375
|
+
evidenceLevel: levelGuess.evidenceLevel,
|
|
376
|
+
snippet: typeof item.snippet === 'string' ? item.snippet.slice(0, 300) : null,
|
|
377
|
+
publishedAt: typeof item.publishedAt === 'string' ? item.publishedAt : null,
|
|
378
|
+
inferred: { type: typeGuess.inferred, evidenceLevel: levelGuess.inferred },
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
errors.push({ url, error: error instanceof Error ? error.message : String(error) });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
scoutCase = decideCase(scoutCase);
|
|
386
|
+
scoutCase = await maybePersist(scoutCase);
|
|
387
|
+
cases.set(key, scoutCase);
|
|
388
|
+
return JSON.stringify({
|
|
389
|
+
query: args.query,
|
|
390
|
+
truncated: Boolean(result.truncated),
|
|
391
|
+
added,
|
|
392
|
+
errors,
|
|
393
|
+
content: typeof result.content === 'string' && result.content ? result.content.slice(0, 500) : null,
|
|
394
|
+
}, null, 2);
|
|
395
|
+
},
|
|
396
|
+
}));
|
|
397
|
+
yield ctx.tools.register(defineTool({
|
|
398
|
+
name: 'scout_compare',
|
|
399
|
+
description: 'Render a side-by-side comparison report for two to five existing cases (decision, identity status, verified conclusions, open risks, and merged interview questions).',
|
|
400
|
+
parameters: {
|
|
401
|
+
caseIds: { type: 'string', required: true, description: 'Comma-separated case identifiers; at least two, at most five.' },
|
|
402
|
+
},
|
|
403
|
+
output: {
|
|
404
|
+
schema: { type: 'string' },
|
|
405
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
406
|
+
},
|
|
407
|
+
async execute(args, exec) {
|
|
408
|
+
const agentId = exec.agent?.id;
|
|
409
|
+
const rawIds = args.caseIds.split(',').map(value => value.trim()).filter(Boolean);
|
|
410
|
+
if (rawIds.length === 0)
|
|
411
|
+
throw new Error('No case ids provided');
|
|
412
|
+
const caseIds = [...new Set(rawIds)];
|
|
413
|
+
if (caseIds.length < 2)
|
|
414
|
+
throw new Error('Comparison requires at least two distinct case ids');
|
|
415
|
+
const compared = caseIds.map(caseId => {
|
|
416
|
+
try {
|
|
417
|
+
return requireCase(caseKey(caseId, agentId));
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
throw new Error(`Unknown case: ${caseId} (not found in this agent session)`);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
return renderComparison(compared);
|
|
424
|
+
},
|
|
425
|
+
}));
|
|
426
|
+
yield ctx.tools.register(defineTool({
|
|
427
|
+
name: 'scout_report',
|
|
428
|
+
description: 'Render the current evidence-bounded report for a dsh-scout case.',
|
|
429
|
+
parameters: {
|
|
430
|
+
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
431
|
+
},
|
|
432
|
+
output: {
|
|
433
|
+
schema: { type: 'string' },
|
|
434
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
435
|
+
},
|
|
436
|
+
async execute(args, exec) {
|
|
437
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
438
|
+
return renderReport(requireCase(key));
|
|
439
|
+
},
|
|
440
|
+
}));
|
|
441
|
+
yield ctx.tools.register(defineTool({
|
|
442
|
+
name: 'scout_export',
|
|
443
|
+
description: 'Persist a dsh-scout case as the durable five-file export (case.json, sources.json, claims.json, events.jsonl, report.md). targetDir is optional and defaults to the configured scoutDir (or ./dsh-scout) plus the case id.',
|
|
444
|
+
parameters: {
|
|
445
|
+
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
446
|
+
targetDir: { type: 'string', description: 'Directory path to write the five export files into; defaults to <scoutDir>/<caseId>.' },
|
|
447
|
+
},
|
|
448
|
+
output: {
|
|
449
|
+
schema: { type: 'string' },
|
|
450
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
451
|
+
},
|
|
452
|
+
async execute(args, exec) {
|
|
453
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
454
|
+
const scoutCase = requireCase(key);
|
|
455
|
+
const targetDir = args.targetDir || caseExportDir(scoutCase.caseId);
|
|
456
|
+
const result = await persistFiles(scoutCase, targetDir);
|
|
457
|
+
let nextCase = scoutCase;
|
|
458
|
+
if (result.persisted) {
|
|
459
|
+
nextCase = recordEvent(scoutCase, 'case_exported', { targetDir: result.targetDir });
|
|
460
|
+
cases.set(key, nextCase);
|
|
461
|
+
}
|
|
462
|
+
return JSON.stringify({
|
|
463
|
+
caseId: args.caseId,
|
|
464
|
+
targetDir: result.targetDir,
|
|
465
|
+
files: EXPORT_FILE_NAMES,
|
|
466
|
+
persisted: result.persisted,
|
|
467
|
+
...(result.error ? { error: result.error } : {}),
|
|
468
|
+
}, null, 2);
|
|
469
|
+
},
|
|
470
|
+
}));
|
|
471
|
+
yield ctx.tools.register(defineTool({
|
|
472
|
+
name: 'scout_questions',
|
|
473
|
+
description: 'Derive a deduplicated, prioritized interview question list from the case: unverified blocking/material next actions first, then questions for missing required role dimensions, then the default interview questions.',
|
|
474
|
+
parameters: {
|
|
475
|
+
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
476
|
+
},
|
|
477
|
+
output: {
|
|
478
|
+
schema: { type: 'string' },
|
|
479
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
480
|
+
},
|
|
481
|
+
async execute(args, exec) {
|
|
482
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
483
|
+
return JSON.stringify(generateInterviewQuestions(requireCase(key)), null, 2);
|
|
484
|
+
},
|
|
485
|
+
}));
|
|
486
|
+
yield ctx.tools.register(defineTool({
|
|
487
|
+
name: 'scout_import',
|
|
488
|
+
description: 'Restore a dsh-scout case from a five-file export directory, then recompute its decision.',
|
|
489
|
+
parameters: {
|
|
490
|
+
caseId: { type: 'string', required: true, description: 'Case identifier to restore; must match the exported case.json.' },
|
|
491
|
+
sourceDir: { type: 'string', required: true, description: 'Directory path containing the five export files.' },
|
|
492
|
+
},
|
|
493
|
+
output: {
|
|
494
|
+
schema: { type: 'string' },
|
|
495
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
496
|
+
},
|
|
497
|
+
async execute(args, exec) {
|
|
498
|
+
if (!fs)
|
|
499
|
+
throw new Error('Filesystem service is unavailable in this host');
|
|
500
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
501
|
+
const read = async (name) => {
|
|
502
|
+
const target = await fs.resolve(`${args.sourceDir}/${name}`);
|
|
503
|
+
return fs.readText(target);
|
|
504
|
+
};
|
|
505
|
+
const files = {};
|
|
506
|
+
for (const name of ['case.json', 'sources.json', 'claims.json', 'events.jsonl']) {
|
|
507
|
+
files[name] = await read(name);
|
|
508
|
+
}
|
|
509
|
+
const restored = importCaseFromFiles(files);
|
|
510
|
+
if (restored.caseId !== args.caseId) {
|
|
511
|
+
throw new Error(`Case id mismatch: export has ${restored.caseId}, requested ${args.caseId}`);
|
|
512
|
+
}
|
|
513
|
+
const nextCase = decideCase(recordEvent(restored, 'case_imported', { sourceDir: args.sourceDir }));
|
|
514
|
+
cases.set(key, nextCase);
|
|
515
|
+
return JSON.stringify(nextCase, null, 2);
|
|
516
|
+
},
|
|
517
|
+
}));
|
|
518
|
+
yield ctx.tools.register(defineTool({
|
|
519
|
+
name: 'scout_add_source',
|
|
520
|
+
description: 'Register a source before attaching it to claims.',
|
|
521
|
+
parameters: {
|
|
522
|
+
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
523
|
+
sourceId: { type: 'string', required: true, description: 'Stable source identifier.' },
|
|
524
|
+
sourceType: { type: 'string', enum: SOURCE_TYPES, required: true, description: 'Closed source category used by evidence policy.' },
|
|
525
|
+
title: { type: 'string', required: true, description: 'Human-readable source title.' },
|
|
526
|
+
url: { type: 'string', required: true, description: 'Source URL, or an empty string for local/user-provided material.' },
|
|
527
|
+
evidenceLevel: { type: 'string', enum: EVIDENCE_LEVELS, required: true, description: 'Source quality from E0 to E3.' },
|
|
528
|
+
},
|
|
529
|
+
output: {
|
|
530
|
+
schema: { type: 'string' },
|
|
531
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
532
|
+
},
|
|
533
|
+
async execute(args, exec) {
|
|
534
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
535
|
+
const scoutCase = requireCase(key);
|
|
536
|
+
const source = {
|
|
537
|
+
sourceId: args.sourceId,
|
|
538
|
+
type: args.sourceType,
|
|
539
|
+
title: args.title,
|
|
540
|
+
url: args.url || null,
|
|
541
|
+
capturedAt: new Date().toISOString(),
|
|
542
|
+
evidenceLevel: args.evidenceLevel,
|
|
543
|
+
status: 'captured',
|
|
544
|
+
};
|
|
545
|
+
let nextCase = recordEvent(addSource(scoutCase, source), 'source_added', {
|
|
546
|
+
sourceId: source.sourceId,
|
|
547
|
+
evidenceLevel: source.evidenceLevel,
|
|
548
|
+
});
|
|
549
|
+
nextCase = await maybePersist(nextCase);
|
|
550
|
+
cases.set(key, nextCase);
|
|
551
|
+
return JSON.stringify(nextCase, null, 2);
|
|
552
|
+
},
|
|
553
|
+
}));
|
|
554
|
+
yield ctx.tools.register(defineTool({
|
|
555
|
+
name: 'scout_verify_identity',
|
|
556
|
+
description: 'Confirm the legal company identity using a previously registered E3 source.',
|
|
557
|
+
parameters: {
|
|
558
|
+
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
559
|
+
legalEntity: { type: 'string', required: true, description: 'Verified legal entity name.' },
|
|
560
|
+
registrationNumber: { type: 'string', required: true, description: 'Registry or unified social credit identifier.' },
|
|
561
|
+
registeredRegion: { type: 'string', required: true, description: 'Verified registration region.' },
|
|
562
|
+
legalRepresentative: { type: 'string', required: true, description: 'Verified legal representative.' },
|
|
563
|
+
brandRelationship: { type: 'string', required: true, description: 'How the investigated brand relates to the legal entity.' },
|
|
564
|
+
sourceIds: { type: 'string', required: true, description: 'Comma-separated identity sources including a linked authoritative E3 source.' },
|
|
565
|
+
},
|
|
566
|
+
output: {
|
|
567
|
+
schema: { type: 'string' },
|
|
568
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
569
|
+
},
|
|
570
|
+
async execute(args, exec) {
|
|
571
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
572
|
+
const scoutCase = requireCase(key);
|
|
573
|
+
let nextCase = recordEvent(decideCase(verifyIdentity(scoutCase, {
|
|
574
|
+
legalEntity: args.legalEntity,
|
|
575
|
+
registrationNumber: args.registrationNumber,
|
|
576
|
+
registeredRegion: args.registeredRegion,
|
|
577
|
+
legalRepresentative: args.legalRepresentative,
|
|
578
|
+
brandRelationship: args.brandRelationship,
|
|
579
|
+
sourceIds: args.sourceIds.split(',').map(value => value.trim()).filter(Boolean),
|
|
580
|
+
})), 'identity_verified', {
|
|
581
|
+
legalEntity: args.legalEntity,
|
|
582
|
+
registrationNumber: args.registrationNumber,
|
|
583
|
+
});
|
|
584
|
+
nextCase = await maybePersist(nextCase);
|
|
585
|
+
cases.set(key, nextCase);
|
|
586
|
+
return JSON.stringify(nextCase, null, 2);
|
|
587
|
+
},
|
|
588
|
+
}));
|
|
589
|
+
yield ctx.tools.register(defineTool({
|
|
590
|
+
name: 'scout_verify_claim',
|
|
591
|
+
description: 'Promote an existing claim to verified while preserving its prior evidence state.',
|
|
592
|
+
parameters: {
|
|
593
|
+
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
594
|
+
claimId: { type: 'string', required: true, description: 'Existing claim identifier.' },
|
|
595
|
+
evidenceLevel: { type: 'string', enum: EVIDENCE_LEVELS, required: true, description: 'Updated source quality from E0 to E3.' },
|
|
596
|
+
sourceIds: { type: 'string', required: true, description: 'Comma-separated registered source identifiers.' },
|
|
597
|
+
confidenceNote: { type: 'string', required: true, description: 'Why the new sources justify verified status.' },
|
|
598
|
+
nextAction: { type: 'string', required: true, description: 'Smallest next action after verification.' },
|
|
599
|
+
},
|
|
600
|
+
output: {
|
|
601
|
+
schema: { type: 'string' },
|
|
602
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
603
|
+
},
|
|
604
|
+
async execute(args, exec) {
|
|
605
|
+
const key = caseKey(args.caseId, exec.agent?.id);
|
|
606
|
+
const scoutCase = requireCase(key);
|
|
607
|
+
let nextCase = recordEvent(decideCase(verifyClaim(scoutCase, {
|
|
608
|
+
claimId: args.claimId,
|
|
609
|
+
evidenceLevel: args.evidenceLevel,
|
|
610
|
+
sourceIds: args.sourceIds.split(',').map(value => value.trim()).filter(Boolean),
|
|
611
|
+
confidenceNote: args.confidenceNote,
|
|
612
|
+
nextAction: args.nextAction,
|
|
613
|
+
})), 'claim_verified', {
|
|
614
|
+
claimId: args.claimId,
|
|
615
|
+
evidenceLevel: args.evidenceLevel,
|
|
616
|
+
});
|
|
617
|
+
nextCase = await maybePersist(nextCase);
|
|
618
|
+
cases.set(key, nextCase);
|
|
619
|
+
return JSON.stringify(nextCase, null, 2);
|
|
620
|
+
},
|
|
621
|
+
}));
|
|
622
|
+
}, 'dsh-scout tools');
|
|
623
|
+
}
|
|
624
|
+
export * from './model.js';
|