dsh-scout 0.9.0 → 0.9.2
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/CHANGELOG.md +91 -0
- package/README.en.md +237 -39
- package/README.md +131 -26
- package/assets/icon.svg +7 -0
- package/dist/i18n.d.ts +115 -0
- package/dist/i18n.js +299 -0
- package/dist/index.d.ts +14 -9
- package/dist/index.js +682 -206
- package/dist/model.d.ts +58 -8
- package/dist/model.js +260 -107
- package/docs/dsh-scout-product-contract.md +51 -5
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +15 -7
- package/scripts/check-release.mjs +219 -4
- package/scripts/profile-smoke.mjs +1118 -41
- package/tests/acceptance.test.mjs +1684 -0
- package/tests/model.test.mjs +496 -778
- package/tests/package.test.mjs +246 -0
- package/tests/tools.test.mjs +974 -0
package/dist/index.js
CHANGED
|
@@ -1,44 +1,239 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import z from '@deepseek-ai/schemastery';
|
|
3
|
-
import { addClaim, addSource, appendEvent, CLAIM_DIMENSIONS, CLAIM_IMPACTS, CLAIM_STATUSES, createCase, decideCase, DEFAULT_AUTHORITY_HOST_SUFFIXES, EVIDENCE_LEVELS, EXPORT_FILE_NAMES, exportCaseFiles, generateInterviewQuestions, importCaseFromFiles, inferEvidenceLevel, inferSourceType, renderComparison, renderReport, SOURCE_TYPES, verifyClaim, verifyIdentity, } from './model.js';
|
|
3
|
+
import { addClaim, addSource, appendEvent, CLAIM_DIMENSIONS, CLAIM_IMPACTS, CLAIM_STATUSES, computeCoverage, createCase, decideCase, DEFAULT_AUTHORITY_HOST_SUFFIXES, EVIDENCE_LEVELS, EXPORT_FILE_NAMES, exportCaseFiles, findSourceByUrl, generateInterviewQuestions, importCaseFromFiles, inferEvidenceLevel, inferSourceType, renderComparison, renderReport, SOURCE_TYPES, verifyClaim, verifyIdentity, } from './model.js';
|
|
4
|
+
import { formatScoutString, isScoutLanguage, normalizeScoutLanguage, scoutStrings, } from './i18n.js';
|
|
4
5
|
export const name = 'dsh-scout';
|
|
5
6
|
export const inject = ['tools'];
|
|
6
7
|
export const Config = z.object({
|
|
7
8
|
scoutDir: z.string().default('dsh-scout'),
|
|
8
9
|
autoPersist: z.boolean().default(false),
|
|
9
10
|
authorityHostSuffixes: z.array(z.string()).default([...DEFAULT_AUTHORITY_HOST_SUFFIXES]),
|
|
11
|
+
language: z.union([z.const('zh-CN'), z.const('en')]).default('zh-CN'),
|
|
12
|
+
});
|
|
13
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
14
|
+
const IMPACT_RANK = { blocking: 0, material: 1, informational: 2 };
|
|
15
|
+
/** Up to three smallest next actions for open blocking/material claims. */
|
|
16
|
+
const deriveNextActions = (scoutCase) => [...scoutCase.claims]
|
|
17
|
+
.filter(claim => claim.impact !== 'informational' && claim.status !== 'verified')
|
|
18
|
+
.sort((left, right) => IMPACT_RANK[left.impact] - IMPACT_RANK[right.impact])
|
|
19
|
+
.map(claim => claim.nextAction.trim())
|
|
20
|
+
.filter((action, index, all) => Boolean(action) && all.indexOf(action) === index)
|
|
21
|
+
.slice(0, 3);
|
|
22
|
+
const capIds = (ids, limit = 20) => ids.length > limit ? [...ids.slice(0, limit), `+${ids.length - limit} more`] : ids;
|
|
23
|
+
const buildEnvelope = (scoutCase, changed, extras = {}) => {
|
|
24
|
+
const verified = scoutCase.claims.filter(claim => claim.status === 'verified').length;
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
caseId: scoutCase.caseId,
|
|
28
|
+
decision: scoutCase.decision,
|
|
29
|
+
identity: scoutCase.subject.identityStatus,
|
|
30
|
+
counts: {
|
|
31
|
+
sources: scoutCase.sources.length,
|
|
32
|
+
claims: scoutCase.claims.length,
|
|
33
|
+
verified,
|
|
34
|
+
open: scoutCase.claims.length - verified,
|
|
35
|
+
},
|
|
36
|
+
changed: { kind: changed.kind, ids: capIds(changed.ids) },
|
|
37
|
+
nextActions: deriveNextActions(scoutCase),
|
|
38
|
+
...(extras.persist ? { persist: extras.persist } : {}),
|
|
39
|
+
...(extras.errors?.length ? { errors: extras.errors } : {}),
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
/** Model-facing render budget for one mutation summary, in UTF-8 bytes. */
|
|
43
|
+
const MAX_RENDER_BYTES = 1024;
|
|
44
|
+
/** Marks a clipped render. Its own byte cost is paid out of the same budget. */
|
|
45
|
+
const ELLIPSIS = '…';
|
|
46
|
+
/** Byte length of a JS string in UTF-8, without relying on Node globals. */
|
|
47
|
+
const utf8Length = (text) => {
|
|
48
|
+
let bytes = 0;
|
|
49
|
+
for (const character of text) {
|
|
50
|
+
const code = character.codePointAt(0) ?? 0;
|
|
51
|
+
bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
|
|
52
|
+
}
|
|
53
|
+
return bytes;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Hard cap so an oversized free-text field can never blow the render budget.
|
|
57
|
+
* The returned string — including the ellipsis, whose UTF-8 cost is derived
|
|
58
|
+
* rather than assumed — is never longer than `limit` bytes. Cutting stops on a
|
|
59
|
+
* code-unit boundary; a lone surrogate left by slicing a pair still encodes as
|
|
60
|
+
* 3 bytes, so the measured length stays the conservative one.
|
|
61
|
+
*/
|
|
62
|
+
const clampUtf8 = (text, limit) => {
|
|
63
|
+
if (utf8Length(text) <= limit)
|
|
64
|
+
return text;
|
|
65
|
+
const suffixBytes = utf8Length(ELLIPSIS);
|
|
66
|
+
let clipped = text;
|
|
67
|
+
while (clipped.length > 0 && utf8Length(clipped) > limit - suffixBytes)
|
|
68
|
+
clipped = clipped.slice(0, -1);
|
|
69
|
+
return `${clipped}${ELLIPSIS}`;
|
|
70
|
+
};
|
|
71
|
+
/** Per-field cap applied before the whole-render cap. */
|
|
72
|
+
const clipField = (text, limit) => text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
73
|
+
const formatEnvelope = (value) => {
|
|
74
|
+
const { counts, changed } = value;
|
|
75
|
+
const lines = [
|
|
76
|
+
`[${clipField(value.caseId, 80)}] ${value.decision} · identity ${value.identity} · sources ${counts.sources} / claims ${counts.claims} (verified ${counts.verified}, open ${counts.open})`,
|
|
77
|
+
`changed: ${changed.kind}${changed.ids.length ? ` (${clipField(changed.ids.join(', '), 200)})` : ''}`,
|
|
78
|
+
];
|
|
79
|
+
if (value.nextActions.length) {
|
|
80
|
+
lines.push(`next: ${value.nextActions.map(action => clipField(action, 140)).join(' | ')}`);
|
|
81
|
+
}
|
|
82
|
+
if (value.persist) {
|
|
83
|
+
lines.push(value.persist.persisted
|
|
84
|
+
? `persisted: ${clipField(value.persist.targetDir, 160)}`
|
|
85
|
+
: `PERSIST FAILED (${clipField(value.persist.targetDir, 160)}): ${clipField(value.persist.error ?? 'unknown error', 160)}`);
|
|
86
|
+
}
|
|
87
|
+
if (value.errors?.length) {
|
|
88
|
+
lines.push(`rejected: ${value.errors.length} item(s) — first: ${clipField(value.errors[0].error, 160)}`);
|
|
89
|
+
}
|
|
90
|
+
if (value.sourceDir) {
|
|
91
|
+
lines.push(`imported from ${clipField(value.sourceDir, 160)} (complete: ${value.complete === true})`);
|
|
92
|
+
}
|
|
93
|
+
// The per-field caps keep this from triggering in practice; the whole-render
|
|
94
|
+
// cap is the guarantee the gate asserts. `clampUtf8` already budgets for the
|
|
95
|
+
// ellipsis it appends, so the call passes the budget itself — no slack magic
|
|
96
|
+
// number that a later edit could shrink below the suffix cost.
|
|
97
|
+
return clampUtf8(lines.join('\n'), MAX_RENDER_BYTES);
|
|
98
|
+
};
|
|
99
|
+
/** Envelope schema shared by every mutating tool. */
|
|
100
|
+
const mutationSchema = () => ({
|
|
101
|
+
type: 'object',
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
properties: {
|
|
104
|
+
ok: { type: 'boolean', required: true },
|
|
105
|
+
caseId: { type: 'string', required: true },
|
|
106
|
+
decision: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
enum: ['PROCEED', 'VERIFY', 'STOP'],
|
|
109
|
+
required: true,
|
|
110
|
+
},
|
|
111
|
+
identity: {
|
|
112
|
+
type: 'string',
|
|
113
|
+
enum: ['verified', 'needs_verification'],
|
|
114
|
+
required: true,
|
|
115
|
+
},
|
|
116
|
+
counts: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
additionalProperties: false,
|
|
119
|
+
required: true,
|
|
120
|
+
properties: {
|
|
121
|
+
sources: { type: 'integer', required: true },
|
|
122
|
+
claims: { type: 'integer', required: true },
|
|
123
|
+
verified: { type: 'integer', required: true },
|
|
124
|
+
open: { type: 'integer', required: true },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
changed: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
required: true,
|
|
131
|
+
properties: {
|
|
132
|
+
kind: {
|
|
133
|
+
type: 'string',
|
|
134
|
+
enum: [
|
|
135
|
+
'case_started',
|
|
136
|
+
'source_added',
|
|
137
|
+
'sources_added',
|
|
138
|
+
'claim_added',
|
|
139
|
+
'claims_added',
|
|
140
|
+
'identity_verified',
|
|
141
|
+
'claim_verified',
|
|
142
|
+
'case_imported',
|
|
143
|
+
'case_rehydrated',
|
|
144
|
+
],
|
|
145
|
+
required: true,
|
|
146
|
+
},
|
|
147
|
+
ids: { type: 'array', items: { type: 'string' }, required: true },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
nextActions: { type: 'array', items: { type: 'string' }, required: true },
|
|
151
|
+
persist: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
additionalProperties: false,
|
|
154
|
+
properties: {
|
|
155
|
+
persisted: { type: 'boolean', required: true },
|
|
156
|
+
targetDir: { type: 'string', required: true },
|
|
157
|
+
error: { type: 'string' },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
errors: {
|
|
161
|
+
type: 'array',
|
|
162
|
+
items: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
additionalProperties: false,
|
|
165
|
+
properties: {
|
|
166
|
+
url: { type: 'string', required: true },
|
|
167
|
+
error: { type: 'string', required: true },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
sourceDir: { type: 'string' },
|
|
172
|
+
complete: { type: 'boolean' },
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
const mutationOutput = () => ({
|
|
176
|
+
schema: mutationSchema(),
|
|
177
|
+
render: (_args, value) => [
|
|
178
|
+
{ type: 'text', text: formatEnvelope(value) },
|
|
179
|
+
],
|
|
10
180
|
});
|
|
11
181
|
export function apply(ctx, config = {}) {
|
|
12
182
|
const cases = new Map();
|
|
13
183
|
const caseKey = (caseId, agentId) => `${String(agentId ?? 'unscoped')}::${caseId}`;
|
|
14
|
-
const
|
|
15
|
-
const scoutCase = cases.get(key);
|
|
16
|
-
if (!scoutCase)
|
|
17
|
-
throw new Error(`Unknown case: ${key.split('::').at(-1)}`);
|
|
18
|
-
return scoutCase;
|
|
19
|
-
};
|
|
20
|
-
// Optional host services: unavailable hosts degrade gracefully (tools report
|
|
21
|
-
// the missing service instead of failing to register).
|
|
22
|
-
const fs = ctx.get('fs');
|
|
23
|
-
const web = ctx.get('web');
|
|
184
|
+
const keyCaseId = (key) => key.slice(key.indexOf('::') + 2 || 0);
|
|
24
185
|
const authoritySuffixes = config.authorityHostSuffixes ?? DEFAULT_AUTHORITY_HOST_SUFFIXES;
|
|
25
186
|
const defaultScoutDir = config.scoutDir?.replace(/\/+$/, '') || 'dsh-scout';
|
|
26
187
|
const caseExportDir = (caseId) => `${defaultScoutDir}/${caseId}`;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
188
|
+
/** Resolved once from validated config; every renderer and decision follows it. */
|
|
189
|
+
const language = normalizeScoutLanguage(config.language);
|
|
190
|
+
const strings = scoutStrings(language);
|
|
191
|
+
/**
|
|
192
|
+
* Case-authored copy (objective, fit question, curated interview questions) is
|
|
193
|
+
* written in the language the case was created in. When this deployment
|
|
194
|
+
* renders in another language, the still-untouched factory defaults are
|
|
195
|
+
* swapped for this deployment's own defaults, so `case.json` never claims one
|
|
196
|
+
* language while carrying another language's prose. A value the user edited
|
|
197
|
+
* no longer equals a factory default and is deliberately left alone. Curated
|
|
198
|
+
* interview questions follow the same rule entry by entry while the set still
|
|
199
|
+
* has the factory length; a differently sized set is kept as authored.
|
|
200
|
+
*/
|
|
201
|
+
const alignCaseDefaults = (scoutCase) => {
|
|
202
|
+
const declared = isScoutLanguage(scoutCase.language) ? scoutCase.language : undefined;
|
|
203
|
+
const source = scoutStrings(declared);
|
|
204
|
+
if (source.language === strings.language)
|
|
205
|
+
return scoutCase;
|
|
206
|
+
const swap = (current, from, to) => (current === from ? to : current);
|
|
207
|
+
const curated = scoutCase.interviewQuestions ?? [];
|
|
208
|
+
const factory = source.defaults.interviewQuestions;
|
|
209
|
+
const target = strings.defaults.interviewQuestions;
|
|
210
|
+
// Entry-by-entry: a question the user edited no longer equals its factory
|
|
211
|
+
// slot and is kept verbatim, while every untouched slot aligns. A set whose
|
|
212
|
+
// length differs from the factory array is taken as fully user-authored and
|
|
213
|
+
// preserved as a whole (there is no index to compare it against).
|
|
214
|
+
const alignedQuestions = curated.length === factory.length
|
|
215
|
+
? curated.map((question, index) => (question === factory[index] ? target[index] : question))
|
|
216
|
+
: curated;
|
|
217
|
+
return {
|
|
218
|
+
...scoutCase,
|
|
219
|
+
decisionObjective: swap(scoutCase.decisionObjective, source.defaults.decisionObjective, strings.defaults.decisionObjective),
|
|
220
|
+
role: {
|
|
221
|
+
...scoutCase.role,
|
|
222
|
+
fitQuestion: swap(scoutCase.role.fitQuestion, source.defaults.fitQuestion, strings.defaults.fitQuestion),
|
|
223
|
+
},
|
|
224
|
+
interviewQuestions: alignedQuestions,
|
|
225
|
+
};
|
|
41
226
|
};
|
|
227
|
+
const decide = (scoutCase) => ({
|
|
228
|
+
...decideCase(alignCaseDefaults(scoutCase), { language }),
|
|
229
|
+
// Keep the case's own label in sync with the language this deployment renders in.
|
|
230
|
+
language,
|
|
231
|
+
});
|
|
232
|
+
const renderReportOf = (scoutCase) => renderReport(scoutCase, { language });
|
|
233
|
+
// Resolved per call so a later-mounted (or replaced) provider is honored.
|
|
234
|
+
const fsService = () => ctx.get('fs');
|
|
235
|
+
const webService = () => ctx.get('web');
|
|
236
|
+
const execCwd = (exec) => exec?.agent?.session?.header?.cwd;
|
|
42
237
|
const recordEvent = (scoutCase, type, detail) => {
|
|
43
238
|
const events = scoutCase.events ?? [];
|
|
44
239
|
let n = events.length + 1;
|
|
@@ -62,54 +257,188 @@ export function apply(ctx, config = {}) {
|
|
|
62
257
|
evidenceLevel: source.evidenceLevel,
|
|
63
258
|
ingested: true,
|
|
64
259
|
});
|
|
65
|
-
|
|
260
|
+
/* -------------------------------------------------------------- *
|
|
261
|
+
* Durable export
|
|
262
|
+
*
|
|
263
|
+
* Order is "record the event, then write": the persisted
|
|
264
|
+
* `events.jsonl` must contain the export event it belongs to.
|
|
265
|
+
* A completion marker is written last so a torn export is
|
|
266
|
+
* distinguishable from a corrupt one.
|
|
267
|
+
* -------------------------------------------------------------- */
|
|
268
|
+
const EXPORT_MARKER_FILE = 'export.complete.json';
|
|
269
|
+
const EXPORT_MARKER_SCHEMA = 'dsh-scout.export.v1';
|
|
270
|
+
const directoryOf = (displayPath) => {
|
|
271
|
+
const cut = displayPath.lastIndexOf('/');
|
|
272
|
+
return cut > 0 ? displayPath.slice(0, cut) : displayPath;
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* Write the five export files plus the completion marker.
|
|
276
|
+
*
|
|
277
|
+
* The marker is invalidated *before* the payload is touched and only
|
|
278
|
+
* re-asserted after every payload file landed, so an interrupted export can
|
|
279
|
+
* never leave a stale `complete: true` beside mixed-generation files.
|
|
280
|
+
*/
|
|
281
|
+
const persistFiles = async (scoutCase, targetDir, fs, cwd, signal) => {
|
|
282
|
+
try {
|
|
283
|
+
const files = exportCaseFiles(scoutCase, { language });
|
|
284
|
+
const marker = (complete) => `${JSON.stringify({
|
|
285
|
+
schemaVersion: EXPORT_MARKER_SCHEMA,
|
|
286
|
+
caseId: scoutCase.caseId,
|
|
287
|
+
complete,
|
|
288
|
+
files: [...EXPORT_FILE_NAMES],
|
|
289
|
+
eventCount: (scoutCase.events ?? []).length,
|
|
290
|
+
}, null, 2)}\n`;
|
|
291
|
+
let absoluteDir;
|
|
292
|
+
const write = async (fileName, content) => {
|
|
293
|
+
const target = await fs.resolve(`${targetDir}/${fileName}`, { cwd, signal });
|
|
294
|
+
if (typeof target?.displayPath === 'string')
|
|
295
|
+
absoluteDir = directoryOf(target.displayPath);
|
|
296
|
+
await fs.writeText(target, content, undefined, signal);
|
|
297
|
+
};
|
|
298
|
+
await write(EXPORT_MARKER_FILE, marker(false));
|
|
299
|
+
for (const fileName of EXPORT_FILE_NAMES) {
|
|
300
|
+
await write(fileName, files[fileName]);
|
|
301
|
+
}
|
|
302
|
+
await write(EXPORT_MARKER_FILE, marker(true));
|
|
303
|
+
return { persisted: true, targetDir: absoluteDir ?? targetDir };
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
return { persisted: false, targetDir, error: messageOf(error) };
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
/**
|
|
310
|
+
* Persist a case for one tool call. On success the returned case carries
|
|
311
|
+
* the `case_exported` event that the written `events.jsonl` also has; on
|
|
312
|
+
* failure the event is dropped and the error is surfaced to the caller.
|
|
313
|
+
*/
|
|
314
|
+
const exportCase = async (scoutCase, targetDir, fs, cwd, signal, auto) => {
|
|
315
|
+
const withEvent = recordEvent(scoutCase, 'case_exported', { targetDir, auto });
|
|
316
|
+
const persist = await persistFiles(withEvent, targetDir, fs, cwd, signal);
|
|
317
|
+
return { scoutCase: persist.persisted ? withEvent : scoutCase, persist };
|
|
318
|
+
};
|
|
319
|
+
const maybePersist = async (scoutCase, exec) => {
|
|
66
320
|
if (!config.autoPersist)
|
|
67
|
-
return scoutCase;
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
321
|
+
return { scoutCase };
|
|
322
|
+
const fs = fsService();
|
|
323
|
+
if (!fs) {
|
|
324
|
+
return {
|
|
325
|
+
scoutCase,
|
|
326
|
+
persist: {
|
|
327
|
+
persisted: false,
|
|
328
|
+
targetDir: caseExportDir(scoutCase.caseId),
|
|
329
|
+
error: 'Filesystem service is unavailable in this host',
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return exportCase(scoutCase, caseExportDir(scoutCase.caseId), fs, execCwd(exec), exec?.signal, true);
|
|
334
|
+
};
|
|
335
|
+
/* -------------------------------------------------------------- *
|
|
336
|
+
* Case lookup with lazy rehydration
|
|
337
|
+
* -------------------------------------------------------------- */
|
|
338
|
+
const readExportFiles = async (sourceDir, fs, cwd, signal) => {
|
|
339
|
+
const files = {};
|
|
340
|
+
for (const fileName of ['case.json', 'sources.json', 'claims.json', 'events.jsonl']) {
|
|
341
|
+
const target = await fs.resolve(`${sourceDir}/${fileName}`, { cwd, signal });
|
|
342
|
+
files[fileName] = await fs.readText(target, signal);
|
|
343
|
+
}
|
|
344
|
+
return files;
|
|
345
|
+
};
|
|
346
|
+
/**
|
|
347
|
+
* Read the completion marker. Existence is not enough: the marker is
|
|
348
|
+
* written with `complete: false` before an export starts, so a stale marker
|
|
349
|
+
* left by an interrupted export must not be read as a finished one.
|
|
350
|
+
*/
|
|
351
|
+
const readCompletionMarker = async (sourceDir, fs, cwd, signal) => {
|
|
352
|
+
try {
|
|
353
|
+
const raw = await fs.readText(await fs.resolve(`${sourceDir}/${EXPORT_MARKER_FILE}`, { cwd, signal }), signal);
|
|
354
|
+
const parsed = JSON.parse(raw);
|
|
355
|
+
return parsed?.complete === true;
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
/**
|
|
362
|
+
* Restore a case from `<scoutDir>/<caseId>/` when the cache misses.
|
|
363
|
+
*
|
|
364
|
+
* Only a completed export is rehydrated: an interrupted one is reported by
|
|
365
|
+
* `scout_import` (which returns `complete: false`) rather than silently
|
|
366
|
+
* becoming this session's case state. Exports written before 0.9.1 have no
|
|
367
|
+
* marker at all and must be restored explicitly with `scout_import`.
|
|
368
|
+
*/
|
|
369
|
+
const rehydrate = async (caseId, key, fs, cwd, signal) => {
|
|
370
|
+
const sourceDir = caseExportDir(caseId);
|
|
371
|
+
try {
|
|
372
|
+
if (!(await readCompletionMarker(sourceDir, fs, cwd, signal)))
|
|
373
|
+
return undefined;
|
|
374
|
+
const restored = importCaseFromFiles(await readExportFiles(sourceDir, fs, cwd, signal));
|
|
375
|
+
if (restored.caseId !== caseId)
|
|
376
|
+
return undefined;
|
|
377
|
+
const nextCase = decide(recordEvent(restored, 'case_rehydrated', { sourceDir }));
|
|
378
|
+
cases.set(key, nextCase);
|
|
379
|
+
return nextCase;
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
const requireCase = async (key, exec) => {
|
|
386
|
+
const existing = cases.get(key);
|
|
387
|
+
if (existing)
|
|
388
|
+
return existing;
|
|
389
|
+
const caseId = keyCaseId(key);
|
|
390
|
+
const fs = fsService();
|
|
391
|
+
if (fs) {
|
|
392
|
+
const restored = await rehydrate(caseId, key, fs, execCwd(exec), exec?.signal);
|
|
393
|
+
if (restored)
|
|
394
|
+
return restored;
|
|
395
|
+
}
|
|
396
|
+
const known = [...cases.keys()]
|
|
397
|
+
.filter(entry => entry.startsWith(`${String(exec?.agent?.id ?? 'unscoped')}::`))
|
|
398
|
+
.map(entry => keyCaseId(entry));
|
|
399
|
+
throw new Error(`Unknown case: ${caseId}. No in-memory case and no restorable export at ${caseExportDir(caseId)}.`
|
|
400
|
+
+ (known.length ? ` Known cases in this session: ${known.join(', ')}.` : '')
|
|
401
|
+
+ ' Use scout_start to create it or scout_import to restore it from a directory.');
|
|
74
402
|
};
|
|
403
|
+
/* -------------------------------------------------------------- *
|
|
404
|
+
* Tools
|
|
405
|
+
* -------------------------------------------------------------- */
|
|
75
406
|
ctx.effect(function* registerScoutTools() {
|
|
76
407
|
yield () => cases.clear();
|
|
77
408
|
yield ctx.tools.register(defineTool({
|
|
78
409
|
name: 'scout_start',
|
|
79
|
-
description: 'Start a company and job due-diligence case. The initial decision is VERIFY until identity evidence is confirmed.',
|
|
410
|
+
description: 'Start a company and job due-diligence case. The initial decision is VERIFY until identity evidence is confirmed. Returns a compact status summary; use scout_report for the full report.',
|
|
80
411
|
parameters: {
|
|
81
412
|
caseId: { type: 'string', required: true, description: 'Stable case identifier.' },
|
|
82
413
|
companyName: { type: 'string', required: true, description: 'Company or brand name to investigate.' },
|
|
83
414
|
roleTitle: { type: 'string', required: true, description: 'Role title to evaluate.' },
|
|
84
415
|
location: { type: 'string', required: true, description: 'Job location, or an empty string if unknown.' },
|
|
85
416
|
},
|
|
86
|
-
output:
|
|
87
|
-
schema: { type: 'string' },
|
|
88
|
-
render: (_args, value) => [{ type: 'text', text: value }],
|
|
89
|
-
},
|
|
417
|
+
output: mutationOutput(),
|
|
90
418
|
async execute(args, exec) {
|
|
91
419
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
92
420
|
if (cases.has(key))
|
|
93
421
|
throw new Error(`Case already exists: ${args.caseId}`);
|
|
94
|
-
|
|
422
|
+
let nextCase = recordEvent(createCase({
|
|
95
423
|
caseId: args.caseId,
|
|
96
424
|
companyName: args.companyName,
|
|
97
425
|
roleTitle: args.roleTitle,
|
|
98
426
|
location: args.location || undefined,
|
|
99
|
-
|
|
100
|
-
|
|
427
|
+
language,
|
|
428
|
+
}), 'case_started', {
|
|
101
429
|
companyName: args.companyName,
|
|
102
430
|
roleTitle: args.roleTitle,
|
|
103
431
|
location: args.location || null,
|
|
104
432
|
});
|
|
105
|
-
|
|
433
|
+
const persisted = await maybePersist(nextCase, exec);
|
|
434
|
+
nextCase = persisted.scoutCase;
|
|
106
435
|
cases.set(key, nextCase);
|
|
107
|
-
return
|
|
436
|
+
return buildEnvelope(nextCase, { kind: 'case_started', ids: [nextCase.caseId] }, { persist: persisted.persist });
|
|
108
437
|
},
|
|
109
438
|
}));
|
|
110
439
|
yield ctx.tools.register(defineTool({
|
|
111
440
|
name: 'scout_add_claim',
|
|
112
|
-
description: 'Add a sourced or explicitly unverified claim to an active dsh-scout case.',
|
|
441
|
+
description: 'Add a sourced or explicitly unverified claim to an active dsh-scout case. Returns a compact status summary; use scout_report for the full ledger.',
|
|
113
442
|
parameters: {
|
|
114
443
|
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
115
444
|
claimId: { type: 'string', required: true, description: 'Stable claim identifier.' },
|
|
@@ -122,14 +451,11 @@ export function apply(ctx, config = {}) {
|
|
|
122
451
|
confidenceNote: { type: 'string', required: true, description: 'Why the current evidence supports this status and level.' },
|
|
123
452
|
nextAction: { type: 'string', required: true, description: 'Smallest next action to verify or use the claim.' },
|
|
124
453
|
},
|
|
125
|
-
output:
|
|
126
|
-
schema: { type: 'string' },
|
|
127
|
-
render: (_args, value) => [{ type: 'text', text: value }],
|
|
128
|
-
},
|
|
454
|
+
output: mutationOutput(),
|
|
129
455
|
async execute(args, exec) {
|
|
130
456
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
131
|
-
const scoutCase = requireCase(key);
|
|
132
|
-
let nextCase = recordEvent(
|
|
457
|
+
const scoutCase = await requireCase(key, exec);
|
|
458
|
+
let nextCase = recordEvent(decide(addClaim(scoutCase, {
|
|
133
459
|
claimId: args.claimId,
|
|
134
460
|
text: args.text,
|
|
135
461
|
status: args.status,
|
|
@@ -146,25 +472,23 @@ export function apply(ctx, config = {}) {
|
|
|
146
472
|
dimension: args.dimension,
|
|
147
473
|
impact: args.impact,
|
|
148
474
|
});
|
|
149
|
-
|
|
475
|
+
const persisted = await maybePersist(nextCase, exec);
|
|
476
|
+
nextCase = persisted.scoutCase;
|
|
150
477
|
cases.set(key, nextCase);
|
|
151
|
-
return
|
|
478
|
+
return buildEnvelope(nextCase, { kind: 'claim_added', ids: [args.claimId] }, { persist: persisted.persist });
|
|
152
479
|
},
|
|
153
480
|
}));
|
|
154
481
|
yield ctx.tools.register(defineTool({
|
|
155
482
|
name: 'scout_ingest',
|
|
156
|
-
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.',
|
|
483
|
+
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). A URL already registered for the case is reused instead of duplicated. Individual invalid items or rejected claims are reported in errors without aborting the batch; a failed claim does not undo its already-registered source.',
|
|
157
484
|
parameters: {
|
|
158
485
|
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
159
486
|
itemsJson: { type: 'string', required: true, description: 'JSON array of collected source items.' },
|
|
160
487
|
},
|
|
161
|
-
output:
|
|
162
|
-
schema: { type: 'string' },
|
|
163
|
-
render: (_args, value) => [{ type: 'text', text: value }],
|
|
164
|
-
},
|
|
488
|
+
output: mutationOutput(),
|
|
165
489
|
async execute(args, exec) {
|
|
166
490
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
167
|
-
let scoutCase = requireCase(key);
|
|
491
|
+
let scoutCase = await requireCase(key, exec);
|
|
168
492
|
let items;
|
|
169
493
|
try {
|
|
170
494
|
const parsed = JSON.parse(args.itemsJson);
|
|
@@ -173,10 +497,10 @@ export function apply(ctx, config = {}) {
|
|
|
173
497
|
items = parsed;
|
|
174
498
|
}
|
|
175
499
|
catch (error) {
|
|
176
|
-
throw new Error(`Invalid itemsJson: ${
|
|
500
|
+
throw new Error(`Invalid itemsJson: ${messageOf(error)}`);
|
|
177
501
|
}
|
|
178
502
|
const added = [];
|
|
179
|
-
const
|
|
503
|
+
const failures = [];
|
|
180
504
|
let nextId = (scoutCase.sources.length ?? 0) + 1;
|
|
181
505
|
let nextClaimId = (scoutCase.claims.length ?? 0) + 1;
|
|
182
506
|
for (const rawItem of items) {
|
|
@@ -185,13 +509,13 @@ export function apply(ctx, config = {}) {
|
|
|
185
509
|
: String(rawItem?.url ?? rawItem ?? '');
|
|
186
510
|
try {
|
|
187
511
|
if (!rawItem || typeof rawItem !== 'object') {
|
|
188
|
-
|
|
512
|
+
failures.push({ url: String(rawItem ?? ''), error: 'item must be an object' });
|
|
189
513
|
continue;
|
|
190
514
|
}
|
|
191
515
|
const item = rawItem;
|
|
192
516
|
const url = typeof item.url === 'string' ? item.url.trim() : '';
|
|
193
517
|
if (!url) {
|
|
194
|
-
|
|
518
|
+
failures.push({ url: String(item.url ?? ''), error: 'url is required' });
|
|
195
519
|
continue;
|
|
196
520
|
}
|
|
197
521
|
const explicitType = typeof item.sourceType === 'string' && SOURCE_TYPES.includes(item.sourceType)
|
|
@@ -201,44 +525,43 @@ export function apply(ctx, config = {}) {
|
|
|
201
525
|
? item.evidenceLevel
|
|
202
526
|
: undefined;
|
|
203
527
|
if (item.sourceType !== undefined && item.sourceType !== null && !explicitType) {
|
|
204
|
-
|
|
528
|
+
failures.push({ url, error: `invalid sourceType: ${String(item.sourceType)}` });
|
|
205
529
|
continue;
|
|
206
530
|
}
|
|
207
531
|
if (item.evidenceLevel !== undefined && item.evidenceLevel !== null && !explicitLevel) {
|
|
208
|
-
|
|
532
|
+
failures.push({ url, error: `invalid evidenceLevel: ${String(item.evidenceLevel)}` });
|
|
209
533
|
continue;
|
|
210
534
|
}
|
|
211
535
|
const typeGuess = inferSourceType(url, explicitType, authoritySuffixes);
|
|
212
536
|
const levelGuess = inferEvidenceLevel(url, typeGuess.type, explicitLevel, authoritySuffixes);
|
|
213
|
-
|
|
214
|
-
|
|
537
|
+
const existing = findSourceByUrl(scoutCase, url);
|
|
538
|
+
let source;
|
|
539
|
+
if (existing) {
|
|
540
|
+
source = existing;
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
let sourceId = `src-${nextId}`;
|
|
544
|
+
while (scoutCase.sources.some(candidate => candidate.sourceId === sourceId)) {
|
|
545
|
+
nextId += 1;
|
|
546
|
+
sourceId = `src-${nextId}`;
|
|
547
|
+
}
|
|
215
548
|
nextId += 1;
|
|
216
|
-
|
|
549
|
+
source = {
|
|
550
|
+
sourceId,
|
|
551
|
+
type: typeGuess.type,
|
|
552
|
+
title: typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url,
|
|
553
|
+
url,
|
|
554
|
+
capturedAt: new Date().toISOString(),
|
|
555
|
+
evidenceLevel: levelGuess.evidenceLevel,
|
|
556
|
+
status: 'captured',
|
|
557
|
+
};
|
|
558
|
+
scoutCase = registerSource(scoutCase, source);
|
|
217
559
|
}
|
|
218
|
-
|
|
219
|
-
const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
|
|
220
|
-
const source = {
|
|
221
|
-
sourceId,
|
|
222
|
-
type: typeGuess.type,
|
|
223
|
-
title,
|
|
224
|
-
url,
|
|
225
|
-
capturedAt: new Date().toISOString(),
|
|
226
|
-
evidenceLevel: levelGuess.evidenceLevel,
|
|
227
|
-
status: 'captured',
|
|
228
|
-
};
|
|
229
|
-
scoutCase = registerSource(scoutCase, source);
|
|
230
|
-
const addedEntry = {
|
|
231
|
-
sourceId,
|
|
232
|
-
title,
|
|
233
|
-
url,
|
|
234
|
-
type: typeGuess.type,
|
|
235
|
-
evidenceLevel: levelGuess.evidenceLevel,
|
|
236
|
-
inferred: { type: typeGuess.inferred, evidenceLevel: levelGuess.inferred },
|
|
237
|
-
};
|
|
560
|
+
added.push(source.sourceId);
|
|
238
561
|
const rawClaim = item.claim;
|
|
239
562
|
if (rawClaim !== undefined && rawClaim !== null) {
|
|
240
563
|
if (typeof rawClaim !== 'object') {
|
|
241
|
-
|
|
564
|
+
failures.push({ url, error: 'claim must be an object' });
|
|
242
565
|
}
|
|
243
566
|
else {
|
|
244
567
|
const claimDraft = rawClaim;
|
|
@@ -270,11 +593,11 @@ export function apply(ctx, config = {}) {
|
|
|
270
593
|
if (!dimension)
|
|
271
594
|
claimIssues.push(`invalid claim.dimension: ${String(claimDraft.dimension)}`);
|
|
272
595
|
if (claimIssues.length) {
|
|
273
|
-
|
|
596
|
+
failures.push({ url, error: `claim rejected: ${claimIssues.join('; ')}` });
|
|
274
597
|
}
|
|
275
598
|
else {
|
|
276
599
|
let claimId = `claim-${nextClaimId}`;
|
|
277
|
-
while (scoutCase.claims.some(
|
|
600
|
+
while (scoutCase.claims.some(candidate => candidate.claimId === claimId)) {
|
|
278
601
|
nextClaimId += 1;
|
|
279
602
|
claimId = `claim-${nextClaimId}`;
|
|
280
603
|
}
|
|
@@ -288,8 +611,8 @@ export function apply(ctx, config = {}) {
|
|
|
288
611
|
dimension: dimension,
|
|
289
612
|
impact: impact ?? 'material',
|
|
290
613
|
sourceIds: [source.sourceId],
|
|
291
|
-
confidenceNote:
|
|
292
|
-
nextAction:
|
|
614
|
+
confidenceNote: formatScoutString(strings.ingest.autoConfidenceNote, { sourceId: source.sourceId }),
|
|
615
|
+
nextAction: strings.ingest.autoNextAction,
|
|
293
616
|
}), 'claim_added', {
|
|
294
617
|
claimId,
|
|
295
618
|
status: status ?? 'reported',
|
|
@@ -300,107 +623,95 @@ export function apply(ctx, config = {}) {
|
|
|
300
623
|
sourceIds: [source.sourceId],
|
|
301
624
|
ingested: true,
|
|
302
625
|
});
|
|
303
|
-
|
|
626
|
+
added.push(claimId);
|
|
304
627
|
}
|
|
305
628
|
catch (error) {
|
|
306
|
-
|
|
629
|
+
failures.push({ url, error: `claim rejected: ${messageOf(error)}` });
|
|
307
630
|
}
|
|
308
631
|
}
|
|
309
632
|
}
|
|
310
633
|
}
|
|
311
|
-
added.push(addedEntry);
|
|
312
634
|
}
|
|
313
635
|
catch (error) {
|
|
314
|
-
|
|
636
|
+
failures.push({ url: itemUrl(), error: messageOf(error) });
|
|
315
637
|
}
|
|
316
638
|
}
|
|
317
|
-
scoutCase =
|
|
318
|
-
|
|
639
|
+
scoutCase = decide(scoutCase);
|
|
640
|
+
const persisted = await maybePersist(scoutCase, exec);
|
|
641
|
+
scoutCase = persisted.scoutCase;
|
|
319
642
|
cases.set(key, scoutCase);
|
|
320
|
-
return
|
|
643
|
+
return buildEnvelope(scoutCase, { kind: 'sources_added', ids: added }, { persist: persisted.persist, errors: failures });
|
|
321
644
|
},
|
|
322
645
|
}));
|
|
323
646
|
yield ctx.tools.register(defineTool({
|
|
324
647
|
name: 'scout_search',
|
|
325
|
-
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.',
|
|
648
|
+
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; a URL already registered for the case is reused). Optionally limits how many results are registered.',
|
|
326
649
|
parameters: {
|
|
327
650
|
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
328
651
|
query: { type: 'string', required: true, description: 'Search query to run through the web provider.' },
|
|
329
652
|
limit: { type: 'number', description: 'Maximum number of search results to register (default 5, capped at 10).' },
|
|
330
653
|
},
|
|
331
|
-
output:
|
|
332
|
-
schema: { type: 'string' },
|
|
333
|
-
render: (_args, value) => [{ type: 'text', text: value }],
|
|
334
|
-
},
|
|
654
|
+
output: mutationOutput(),
|
|
335
655
|
async execute(args, exec) {
|
|
656
|
+
const web = webService();
|
|
336
657
|
if (!web)
|
|
337
658
|
throw new Error('Web search service is unavailable in this host');
|
|
338
659
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
339
|
-
let scoutCase = requireCase(key);
|
|
660
|
+
let scoutCase = await requireCase(key, exec);
|
|
340
661
|
const parsedLimit = Math.trunc(args.limit ?? 5);
|
|
341
662
|
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 10) : 5;
|
|
342
663
|
const result = await web.search({ query: args.query, maxResults: limit }, exec.signal);
|
|
343
664
|
const added = [];
|
|
344
|
-
const
|
|
665
|
+
const failures = [];
|
|
345
666
|
const items = Array.isArray(result.sources) ? result.sources : [];
|
|
346
667
|
if (!Array.isArray(result.sources)) {
|
|
347
|
-
|
|
668
|
+
failures.push({ url: '', error: 'search provider returned a non-array sources field' });
|
|
348
669
|
}
|
|
349
670
|
let nextId = (scoutCase.sources.length ?? 0) + 1;
|
|
350
671
|
items.forEach((item, index) => {
|
|
351
672
|
if (!item || typeof item !== 'object') {
|
|
352
|
-
|
|
673
|
+
failures.push({ url: '', error: `search result ${index} must be an object` });
|
|
353
674
|
return;
|
|
354
675
|
}
|
|
355
676
|
const url = typeof item.url === 'string' ? item.url.trim() : '';
|
|
356
677
|
if (!url) {
|
|
357
|
-
|
|
678
|
+
failures.push({ url: '', error: `search result ${index} missing url` });
|
|
358
679
|
return;
|
|
359
680
|
}
|
|
360
681
|
try {
|
|
682
|
+
const existing = findSourceByUrl(scoutCase, url);
|
|
683
|
+
if (existing) {
|
|
684
|
+
added.push(existing.sourceId);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
361
687
|
const typeGuess = inferSourceType(url, undefined, authoritySuffixes);
|
|
362
688
|
const levelGuess = inferEvidenceLevel(url, typeGuess.type, undefined, authoritySuffixes);
|
|
363
689
|
let sourceId = `src-${nextId}`;
|
|
364
|
-
while (scoutCase.sources.some(
|
|
690
|
+
while (scoutCase.sources.some(candidate => candidate.sourceId === sourceId)) {
|
|
365
691
|
nextId += 1;
|
|
366
692
|
sourceId = `src-${nextId}`;
|
|
367
693
|
}
|
|
368
694
|
nextId += 1;
|
|
369
|
-
const title = typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url;
|
|
370
695
|
scoutCase = registerSource(scoutCase, {
|
|
371
696
|
sourceId,
|
|
372
697
|
type: typeGuess.type,
|
|
373
|
-
title,
|
|
698
|
+
title: typeof item.title === 'string' && item.title.trim() ? item.title.trim() : url,
|
|
374
699
|
url,
|
|
375
700
|
capturedAt: new Date().toISOString(),
|
|
376
701
|
evidenceLevel: levelGuess.evidenceLevel,
|
|
377
702
|
status: 'captured',
|
|
378
703
|
});
|
|
379
|
-
added.push(
|
|
380
|
-
sourceId,
|
|
381
|
-
title,
|
|
382
|
-
url,
|
|
383
|
-
type: typeGuess.type,
|
|
384
|
-
evidenceLevel: levelGuess.evidenceLevel,
|
|
385
|
-
snippet: typeof item.snippet === 'string' ? item.snippet.slice(0, 300) : null,
|
|
386
|
-
publishedAt: typeof item.publishedAt === 'string' ? item.publishedAt : null,
|
|
387
|
-
inferred: { type: typeGuess.inferred, evidenceLevel: levelGuess.inferred },
|
|
388
|
-
});
|
|
704
|
+
added.push(sourceId);
|
|
389
705
|
}
|
|
390
706
|
catch (error) {
|
|
391
|
-
|
|
707
|
+
failures.push({ url, error: messageOf(error) });
|
|
392
708
|
}
|
|
393
709
|
});
|
|
394
|
-
scoutCase =
|
|
395
|
-
|
|
710
|
+
scoutCase = decide(scoutCase);
|
|
711
|
+
const persisted = await maybePersist(scoutCase, exec);
|
|
712
|
+
scoutCase = persisted.scoutCase;
|
|
396
713
|
cases.set(key, scoutCase);
|
|
397
|
-
return
|
|
398
|
-
query: args.query,
|
|
399
|
-
truncated: Boolean(result.truncated),
|
|
400
|
-
added,
|
|
401
|
-
errors,
|
|
402
|
-
content: typeof result.content === 'string' && result.content ? result.content.slice(0, 500) : null,
|
|
403
|
-
}, null, 2);
|
|
714
|
+
return buildEnvelope(scoutCase, { kind: 'sources_added', ids: added }, { persist: persisted.persist, errors: failures });
|
|
404
715
|
},
|
|
405
716
|
}));
|
|
406
717
|
yield ctx.tools.register(defineTool({
|
|
@@ -410,8 +721,21 @@ export function apply(ctx, config = {}) {
|
|
|
410
721
|
caseIds: { type: 'string', required: true, description: 'Comma-separated case identifiers; at least two, at most five.' },
|
|
411
722
|
},
|
|
412
723
|
output: {
|
|
413
|
-
schema: {
|
|
414
|
-
|
|
724
|
+
schema: {
|
|
725
|
+
type: 'object',
|
|
726
|
+
additionalProperties: false,
|
|
727
|
+
properties: {
|
|
728
|
+
caseIds: {
|
|
729
|
+
type: 'array',
|
|
730
|
+
items: { type: 'string' },
|
|
731
|
+
required: true,
|
|
732
|
+
},
|
|
733
|
+
report: { type: 'string', required: true },
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
render: (_args, value) => [
|
|
737
|
+
{ type: 'text', text: value.report },
|
|
738
|
+
],
|
|
415
739
|
},
|
|
416
740
|
async execute(args, exec) {
|
|
417
741
|
const agentId = exec.agent?.id;
|
|
@@ -421,60 +745,112 @@ export function apply(ctx, config = {}) {
|
|
|
421
745
|
const caseIds = [...new Set(rawIds)];
|
|
422
746
|
if (caseIds.length < 2)
|
|
423
747
|
throw new Error('Comparison requires at least two distinct case ids');
|
|
424
|
-
const compared =
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
throw new Error(`Unknown case: ${caseId} (not found in this agent session)`);
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
return renderComparison(compared);
|
|
748
|
+
const compared = [];
|
|
749
|
+
for (const caseId of caseIds) {
|
|
750
|
+
compared.push(await requireCase(caseKey(caseId, agentId), exec));
|
|
751
|
+
}
|
|
752
|
+
return { caseIds, report: renderComparison(compared, { language }) };
|
|
433
753
|
},
|
|
434
754
|
}));
|
|
435
755
|
yield ctx.tools.register(defineTool({
|
|
436
756
|
name: 'scout_report',
|
|
437
|
-
description: 'Render the
|
|
757
|
+
description: 'Render the full evidence-bounded report for a dsh-scout case. This is the only tool that returns the complete artifact; mutation tools return compact summaries.',
|
|
438
758
|
parameters: {
|
|
439
759
|
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
440
760
|
},
|
|
441
761
|
output: {
|
|
442
|
-
schema: {
|
|
443
|
-
|
|
762
|
+
schema: {
|
|
763
|
+
type: 'object',
|
|
764
|
+
additionalProperties: false,
|
|
765
|
+
properties: {
|
|
766
|
+
caseId: { type: 'string', required: true },
|
|
767
|
+
decision: {
|
|
768
|
+
type: 'string',
|
|
769
|
+
enum: ['PROCEED', 'VERIFY', 'STOP'],
|
|
770
|
+
required: true,
|
|
771
|
+
},
|
|
772
|
+
report: { type: 'string', required: true },
|
|
773
|
+
coverage: {
|
|
774
|
+
type: 'array',
|
|
775
|
+
required: true,
|
|
776
|
+
items: {
|
|
777
|
+
type: 'object',
|
|
778
|
+
additionalProperties: false,
|
|
779
|
+
properties: {
|
|
780
|
+
dimension: {
|
|
781
|
+
type: 'string',
|
|
782
|
+
enum: [...CLAIM_DIMENSIONS],
|
|
783
|
+
required: true,
|
|
784
|
+
},
|
|
785
|
+
verified: { type: 'integer', required: true },
|
|
786
|
+
open: { type: 'integer', required: true },
|
|
787
|
+
untouched: { type: 'boolean', required: true },
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
render: (_args, value) => [
|
|
794
|
+
{ type: 'text', text: value.report },
|
|
795
|
+
],
|
|
444
796
|
},
|
|
445
797
|
async execute(args, exec) {
|
|
446
798
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
447
|
-
|
|
799
|
+
const scoutCase = await requireCase(key, exec);
|
|
800
|
+
return {
|
|
801
|
+
caseId: scoutCase.caseId,
|
|
802
|
+
decision: scoutCase.decision,
|
|
803
|
+
report: renderReportOf(scoutCase),
|
|
804
|
+
coverage: computeCoverage(scoutCase),
|
|
805
|
+
};
|
|
448
806
|
},
|
|
449
807
|
}));
|
|
450
808
|
yield ctx.tools.register(defineTool({
|
|
451
809
|
name: 'scout_export',
|
|
452
|
-
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.',
|
|
810
|
+
description: 'Persist a dsh-scout case as the durable five-file export (case.json, sources.json, claims.json, events.jsonl, report.md) plus a completion marker. targetDir is optional and defaults to the configured scoutDir (or ./dsh-scout) plus the case id. The returned targetDir is the resolved absolute directory.',
|
|
453
811
|
parameters: {
|
|
454
812
|
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
455
813
|
targetDir: { type: 'string', description: 'Directory path to write the five export files into; defaults to <scoutDir>/<caseId>.' },
|
|
456
814
|
},
|
|
457
815
|
output: {
|
|
458
|
-
schema: {
|
|
459
|
-
|
|
816
|
+
schema: {
|
|
817
|
+
type: 'object',
|
|
818
|
+
additionalProperties: false,
|
|
819
|
+
properties: {
|
|
820
|
+
caseId: { type: 'string', required: true },
|
|
821
|
+
targetDir: { type: 'string', required: true },
|
|
822
|
+
files: { type: 'array', items: { type: 'string' }, required: true },
|
|
823
|
+
persisted: { type: 'boolean', required: true },
|
|
824
|
+
complete: { type: 'boolean', required: true },
|
|
825
|
+
error: { type: 'string' },
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
render: (_args, value) => [
|
|
829
|
+
{
|
|
830
|
+
type: 'text',
|
|
831
|
+
text: value.persisted
|
|
832
|
+
? `exported to ${value.targetDir} (complete: ${value.complete})`
|
|
833
|
+
: `EXPORT FAILED (${value.targetDir}): ${value.error ?? 'unknown error'}`,
|
|
834
|
+
},
|
|
835
|
+
],
|
|
460
836
|
},
|
|
461
837
|
async execute(args, exec) {
|
|
838
|
+
const fs = fsService();
|
|
839
|
+
if (!fs)
|
|
840
|
+
throw new Error('Filesystem service is unavailable in this host');
|
|
462
841
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
463
|
-
const scoutCase = requireCase(key);
|
|
842
|
+
const scoutCase = await requireCase(key, exec);
|
|
464
843
|
const targetDir = args.targetDir || caseExportDir(scoutCase.caseId);
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
persisted: result.persisted,
|
|
476
|
-
...(result.error ? { error: result.error } : {}),
|
|
477
|
-
}, null, 2);
|
|
844
|
+
const exported = await exportCase(scoutCase, targetDir, fs, execCwd(exec), exec.signal, false);
|
|
845
|
+
cases.set(key, exported.scoutCase);
|
|
846
|
+
return {
|
|
847
|
+
caseId: scoutCase.caseId,
|
|
848
|
+
targetDir: exported.persist.targetDir,
|
|
849
|
+
files: [...EXPORT_FILE_NAMES, EXPORT_MARKER_FILE],
|
|
850
|
+
persisted: exported.persist.persisted,
|
|
851
|
+
complete: exported.persist.persisted,
|
|
852
|
+
...(exported.persist.error ? { error: exported.persist.error } : {}),
|
|
853
|
+
};
|
|
478
854
|
},
|
|
479
855
|
}));
|
|
480
856
|
yield ctx.tools.register(defineTool({
|
|
@@ -484,44 +860,54 @@ export function apply(ctx, config = {}) {
|
|
|
484
860
|
caseId: { type: 'string', required: true, description: 'Existing case identifier.' },
|
|
485
861
|
},
|
|
486
862
|
output: {
|
|
487
|
-
schema: {
|
|
488
|
-
|
|
863
|
+
schema: {
|
|
864
|
+
type: 'object',
|
|
865
|
+
additionalProperties: false,
|
|
866
|
+
properties: {
|
|
867
|
+
caseId: { type: 'string', required: true },
|
|
868
|
+
questions: { type: 'array', items: { type: 'string' }, required: true },
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
render: (_args, value) => [
|
|
872
|
+
{
|
|
873
|
+
type: 'text',
|
|
874
|
+
text: value.questions.length
|
|
875
|
+
? value.questions.map((question, index) => `${index + 1}. ${question}`).join('\n')
|
|
876
|
+
: 'No interview questions derived yet.',
|
|
877
|
+
},
|
|
878
|
+
],
|
|
489
879
|
},
|
|
490
880
|
async execute(args, exec) {
|
|
491
881
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
492
|
-
|
|
882
|
+
const scoutCase = await requireCase(key, exec);
|
|
883
|
+
return { caseId: scoutCase.caseId, questions: generateInterviewQuestions(scoutCase, { language }) };
|
|
493
884
|
},
|
|
494
885
|
}));
|
|
495
886
|
yield ctx.tools.register(defineTool({
|
|
496
887
|
name: 'scout_import',
|
|
497
|
-
description: 'Restore a dsh-scout case from a five-file export directory, then recompute its decision.',
|
|
888
|
+
description: 'Restore a dsh-scout case from a five-file export directory, then recompute its decision. Exports written before 0.9.1 have no completion marker: they still import, but `complete` reports false.',
|
|
498
889
|
parameters: {
|
|
499
890
|
caseId: { type: 'string', required: true, description: 'Case identifier to restore; must match the exported case.json.' },
|
|
500
891
|
sourceDir: { type: 'string', required: true, description: 'Directory path containing the five export files.' },
|
|
501
892
|
},
|
|
502
|
-
output:
|
|
503
|
-
schema: { type: 'string' },
|
|
504
|
-
render: (_args, value) => [{ type: 'text', text: value }],
|
|
505
|
-
},
|
|
893
|
+
output: mutationOutput(),
|
|
506
894
|
async execute(args, exec) {
|
|
895
|
+
const fs = fsService();
|
|
507
896
|
if (!fs)
|
|
508
897
|
throw new Error('Filesystem service is unavailable in this host');
|
|
509
898
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
return fs.readText(target);
|
|
513
|
-
};
|
|
514
|
-
const files = {};
|
|
515
|
-
for (const name of ['case.json', 'sources.json', 'claims.json', 'events.jsonl']) {
|
|
516
|
-
files[name] = await read(name);
|
|
517
|
-
}
|
|
518
|
-
const restored = importCaseFromFiles(files);
|
|
899
|
+
const cwd = execCwd(exec);
|
|
900
|
+
const restored = importCaseFromFiles(await readExportFiles(args.sourceDir, fs, cwd, exec.signal));
|
|
519
901
|
if (restored.caseId !== args.caseId) {
|
|
520
902
|
throw new Error(`Case id mismatch: export has ${restored.caseId}, requested ${args.caseId}`);
|
|
521
903
|
}
|
|
522
|
-
const nextCase =
|
|
904
|
+
const nextCase = decide(recordEvent(restored, 'case_imported', { sourceDir: args.sourceDir }));
|
|
523
905
|
cases.set(key, nextCase);
|
|
524
|
-
return
|
|
906
|
+
return {
|
|
907
|
+
...buildEnvelope(nextCase, { kind: 'case_imported', ids: [nextCase.caseId] }),
|
|
908
|
+
sourceDir: args.sourceDir,
|
|
909
|
+
complete: await readCompletionMarker(args.sourceDir, fs, cwd, exec.signal),
|
|
910
|
+
};
|
|
525
911
|
},
|
|
526
912
|
}));
|
|
527
913
|
yield ctx.tools.register(defineTool({
|
|
@@ -535,13 +921,10 @@ export function apply(ctx, config = {}) {
|
|
|
535
921
|
url: { type: 'string', required: true, description: 'Source URL, or an empty string for local/user-provided material.' },
|
|
536
922
|
evidenceLevel: { type: 'string', enum: EVIDENCE_LEVELS, required: true, description: 'Source quality from E0 to E3.' },
|
|
537
923
|
},
|
|
538
|
-
output:
|
|
539
|
-
schema: { type: 'string' },
|
|
540
|
-
render: (_args, value) => [{ type: 'text', text: value }],
|
|
541
|
-
},
|
|
924
|
+
output: mutationOutput(),
|
|
542
925
|
async execute(args, exec) {
|
|
543
926
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
544
|
-
const scoutCase = requireCase(key);
|
|
927
|
+
const scoutCase = await requireCase(key, exec);
|
|
545
928
|
const source = {
|
|
546
929
|
sourceId: args.sourceId,
|
|
547
930
|
type: args.sourceType,
|
|
@@ -555,9 +938,10 @@ export function apply(ctx, config = {}) {
|
|
|
555
938
|
sourceId: source.sourceId,
|
|
556
939
|
evidenceLevel: source.evidenceLevel,
|
|
557
940
|
});
|
|
558
|
-
|
|
941
|
+
const persisted = await maybePersist(nextCase, exec);
|
|
942
|
+
nextCase = persisted.scoutCase;
|
|
559
943
|
cases.set(key, nextCase);
|
|
560
|
-
return
|
|
944
|
+
return buildEnvelope(nextCase, { kind: 'source_added', ids: [args.sourceId] }, { persist: persisted.persist });
|
|
561
945
|
},
|
|
562
946
|
}));
|
|
563
947
|
yield ctx.tools.register(defineTool({
|
|
@@ -572,14 +956,11 @@ export function apply(ctx, config = {}) {
|
|
|
572
956
|
brandRelationship: { type: 'string', required: true, description: 'How the investigated brand relates to the legal entity.' },
|
|
573
957
|
sourceIds: { type: 'string', required: true, description: 'Comma-separated identity sources including a linked authoritative E3 source.' },
|
|
574
958
|
},
|
|
575
|
-
output:
|
|
576
|
-
schema: { type: 'string' },
|
|
577
|
-
render: (_args, value) => [{ type: 'text', text: value }],
|
|
578
|
-
},
|
|
959
|
+
output: mutationOutput(),
|
|
579
960
|
async execute(args, exec) {
|
|
580
961
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
581
|
-
const scoutCase = requireCase(key);
|
|
582
|
-
let nextCase = recordEvent(
|
|
962
|
+
const scoutCase = await requireCase(key, exec);
|
|
963
|
+
let nextCase = recordEvent(decide(verifyIdentity(scoutCase, {
|
|
583
964
|
legalEntity: args.legalEntity,
|
|
584
965
|
registrationNumber: args.registrationNumber,
|
|
585
966
|
registeredRegion: args.registeredRegion,
|
|
@@ -590,9 +971,10 @@ export function apply(ctx, config = {}) {
|
|
|
590
971
|
legalEntity: args.legalEntity,
|
|
591
972
|
registrationNumber: args.registrationNumber,
|
|
592
973
|
});
|
|
593
|
-
|
|
974
|
+
const persisted = await maybePersist(nextCase, exec);
|
|
975
|
+
nextCase = persisted.scoutCase;
|
|
594
976
|
cases.set(key, nextCase);
|
|
595
|
-
return
|
|
977
|
+
return buildEnvelope(nextCase, { kind: 'identity_verified', ids: [args.legalEntity] }, { persist: persisted.persist });
|
|
596
978
|
},
|
|
597
979
|
}));
|
|
598
980
|
yield ctx.tools.register(defineTool({
|
|
@@ -606,14 +988,11 @@ export function apply(ctx, config = {}) {
|
|
|
606
988
|
confidenceNote: { type: 'string', required: true, description: 'Why the new sources justify verified status.' },
|
|
607
989
|
nextAction: { type: 'string', required: true, description: 'Smallest next action after verification.' },
|
|
608
990
|
},
|
|
609
|
-
output:
|
|
610
|
-
schema: { type: 'string' },
|
|
611
|
-
render: (_args, value) => [{ type: 'text', text: value }],
|
|
612
|
-
},
|
|
991
|
+
output: mutationOutput(),
|
|
613
992
|
async execute(args, exec) {
|
|
614
993
|
const key = caseKey(args.caseId, exec.agent?.id);
|
|
615
|
-
const scoutCase = requireCase(key);
|
|
616
|
-
let nextCase = recordEvent(
|
|
994
|
+
const scoutCase = await requireCase(key, exec);
|
|
995
|
+
let nextCase = recordEvent(decide(verifyClaim(scoutCase, {
|
|
617
996
|
claimId: args.claimId,
|
|
618
997
|
evidenceLevel: args.evidenceLevel,
|
|
619
998
|
sourceIds: args.sourceIds.split(',').map(value => value.trim()).filter(Boolean),
|
|
@@ -623,11 +1002,108 @@ export function apply(ctx, config = {}) {
|
|
|
623
1002
|
claimId: args.claimId,
|
|
624
1003
|
evidenceLevel: args.evidenceLevel,
|
|
625
1004
|
});
|
|
626
|
-
|
|
1005
|
+
const persisted = await maybePersist(nextCase, exec);
|
|
1006
|
+
nextCase = persisted.scoutCase;
|
|
627
1007
|
cases.set(key, nextCase);
|
|
628
|
-
return
|
|
1008
|
+
return buildEnvelope(nextCase, { kind: 'claim_verified', ids: [args.claimId] }, { persist: persisted.persist });
|
|
629
1009
|
},
|
|
630
1010
|
}));
|
|
631
1011
|
}, 'dsh-scout tools');
|
|
1012
|
+
/* -------------------------------------------------------------- *
|
|
1013
|
+
* Host entry points: a `/scout` command and a registered Skill.
|
|
1014
|
+
* Both are optional services bound through `ctx.inject`, so a host
|
|
1015
|
+
* without them simply skips the registration.
|
|
1016
|
+
* -------------------------------------------------------------- */
|
|
1017
|
+
const runScoutCommand = async (invocation) => {
|
|
1018
|
+
const raw = typeof invocation?.rawInput === 'string' ? invocation.rawInput.trim() : '';
|
|
1019
|
+
const [action = 'help', caseId = ''] = raw.split(/\s+/).filter(Boolean);
|
|
1020
|
+
if (action === 'help' || !caseId) {
|
|
1021
|
+
return {
|
|
1022
|
+
kind: 'success',
|
|
1023
|
+
text: 'usage: /scout status <caseId> | /scout questions <caseId> | /scout report <caseId> | /scout export <caseId> [targetDir]',
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
const key = caseKey(caseId, invocation?.agent?.id);
|
|
1027
|
+
const scoutCase = cases.get(key);
|
|
1028
|
+
if (!scoutCase) {
|
|
1029
|
+
return { kind: 'error', text: `Unknown case: ${caseId}. Use scout_start or scout_import first.` };
|
|
1030
|
+
}
|
|
1031
|
+
if (action === 'status') {
|
|
1032
|
+
const verified = scoutCase.claims.filter(claim => claim.status === 'verified').length;
|
|
1033
|
+
return {
|
|
1034
|
+
kind: 'success',
|
|
1035
|
+
text: `[${scoutCase.caseId}] ${scoutCase.decision} · identity ${scoutCase.subject.identityStatus}`
|
|
1036
|
+
+ ` · sources ${scoutCase.sources.length} / claims ${scoutCase.claims.length}`
|
|
1037
|
+
+ ` (verified ${verified}, open ${scoutCase.claims.length - verified})`,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
if (action === 'questions') {
|
|
1041
|
+
return { kind: 'success', text: generateInterviewQuestions(scoutCase, { language }).map((q, i) => `${i + 1}. ${q}`).join('\n') };
|
|
1042
|
+
}
|
|
1043
|
+
if (action === 'report') {
|
|
1044
|
+
return { kind: 'success', text: renderReportOf(scoutCase) };
|
|
1045
|
+
}
|
|
1046
|
+
if (action === 'export') {
|
|
1047
|
+
const fs = fsService();
|
|
1048
|
+
if (!fs)
|
|
1049
|
+
return { kind: 'error', text: 'Filesystem service is unavailable in this host' };
|
|
1050
|
+
const targetDir = caseExportDir(scoutCase.caseId);
|
|
1051
|
+
const exported = await exportCase(scoutCase, targetDir, fs, invocation?.agent?.session?.header?.cwd, invocation?.signal, false);
|
|
1052
|
+
cases.set(key, exported.scoutCase);
|
|
1053
|
+
return exported.persist.persisted
|
|
1054
|
+
? { kind: 'success', text: `exported to ${exported.persist.targetDir}` }
|
|
1055
|
+
: { kind: 'error', text: `export failed (${exported.persist.targetDir}): ${exported.persist.error ?? 'unknown error'}` };
|
|
1056
|
+
}
|
|
1057
|
+
return { kind: 'error', text: `Unknown /scout action: ${action}` };
|
|
1058
|
+
};
|
|
1059
|
+
const SCOUT_SKILL_CONTENT = [
|
|
1060
|
+
'# dsh-scout (司察) due-diligence workflow',
|
|
1061
|
+
'',
|
|
1062
|
+
'Use the scout tools when the user wants to judge whether a company and a role are worth',
|
|
1063
|
+
'pursuing, and what must be verified before deciding.',
|
|
1064
|
+
'',
|
|
1065
|
+
'## Order',
|
|
1066
|
+
'1. `scout_start` — create the case. The decision starts at VERIFY.',
|
|
1067
|
+
'2. `scout_search` / `scout_ingest` / `scout_add_source` — collect and register sources.',
|
|
1068
|
+
'3. `scout_add_claim` — attach evidence-bounded claims (batch drafts come from `scout_ingest`).',
|
|
1069
|
+
'4. `scout_verify_identity` — confirm the legal entity from an E3 registry/regulator source.',
|
|
1070
|
+
'5. `scout_verify_claim` — promote a claim once stronger sources exist.',
|
|
1071
|
+
'6. `scout_report` / `scout_questions` — the full report and the interview question list.',
|
|
1072
|
+
'7. `scout_export` — persist the five-file export; `scout_import` restores it later.',
|
|
1073
|
+
'',
|
|
1074
|
+
'## Evidence discipline (enforced by the tools, do not try to bypass)',
|
|
1075
|
+
'1. A claim may never cite a higher evidence level than its strongest source.',
|
|
1076
|
+
'2. A high-impact claim with no source must stay `unknown` or `needs_verification`.',
|
|
1077
|
+
'3. `scout_verify_identity` requires a linked E3 registry/regulator/official-filing source over HTTPS.',
|
|
1078
|
+
'4. The decision stays VERIFY while the identity is unverified or a blocking/material claim is open.',
|
|
1079
|
+
'',
|
|
1080
|
+
'## Reading tool output',
|
|
1081
|
+
'Mutation tools return a compact status summary (decision, counts, what changed, next actions).',
|
|
1082
|
+
'Call `scout_report` when the full ledger is actually needed instead of re-reading every mutation.',
|
|
1083
|
+
].join('\n');
|
|
1084
|
+
const bindOptional = (serviceName, install) => {
|
|
1085
|
+
ctx.inject([serviceName], (child) => {
|
|
1086
|
+
const service = child[serviceName];
|
|
1087
|
+
if (!service)
|
|
1088
|
+
return;
|
|
1089
|
+
child.effect(() => install(service), `dsh-scout ${serviceName}`);
|
|
1090
|
+
});
|
|
1091
|
+
};
|
|
1092
|
+
bindOptional('commands', service => service.register({
|
|
1093
|
+
name: 'scout',
|
|
1094
|
+
description: 'Inspect a dsh-scout case: /scout status|questions|report|export <caseId>',
|
|
1095
|
+
handler: runScoutCommand,
|
|
1096
|
+
}));
|
|
1097
|
+
bindOptional('skills', service => service.register({
|
|
1098
|
+
name: 'scout',
|
|
1099
|
+
description: 'Evidence-driven company and job due diligence with the dsh-scout tools.',
|
|
1100
|
+
whenToUse: 'When the user asks whether a company/role is worth pursuing, or wants a background check with sources and interview questions.',
|
|
1101
|
+
content: SCOUT_SKILL_CONTENT,
|
|
1102
|
+
// `SkillRegistration` keeps `source` required (only invocation/provider are
|
|
1103
|
+
// optional); the registry defaults provider but never source, so omitting it
|
|
1104
|
+
// yields a summary whose declared non-optional `source` reads back undefined.
|
|
1105
|
+
source: 'runtime',
|
|
1106
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
1107
|
+
}));
|
|
632
1108
|
}
|
|
633
1109
|
export * from './model.js';
|