@deepwatch/dsh-tools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Watch capabilities as DeepSeek Harness agent tools.
3
+ *
4
+ * This is the seam that makes Watch reachable from the agent loop. Everything
5
+ * else — the inspector, the timeline, the receipts — is presentation over what
6
+ * these calls return.
7
+ *
8
+ * Two rules shape every tool here:
9
+ *
10
+ * 1. A tool never reports more certainty than Watch Core gave it. An answer
11
+ * with citations is an *evidence-linked* answer, not a verified one. Only
12
+ * `watch_verify` can produce a verdict, and only Watch Core can mint it
13
+ * (ADR-002).
14
+ * 2. A missing capability is a stated refusal with a fix, never a silent
15
+ * fallback to guessing from the conversation. The model is told, in its
16
+ * system prompt, that this is the contract.
17
+ *
18
+ * @module @deepwatch/dsh-tools
19
+ */
20
+ import { createHash, randomUUID } from 'node:crypto';
21
+ import s from '@deepseek-ai/schemastery';
22
+ import { defineTool } from '@deepseek-ai/dsh-tools';
23
+ import { applyLibrarySearch } from './library-search.js';
24
+ import { applyReadPlane } from './read-plane.js';
25
+ import { LibraryGenerations } from './library-generations.js';
26
+ import { ReceiptJournal } from './receipt-journal.js';
27
+ import { SENSORY_GUIDANCE, applySensoryTools } from './sensory.js';
28
+ import { applyMemory } from './memory.js';
29
+ import { BROWSER_GUIDANCE, applyBrowserTools } from './browser.js';
30
+ export { SENSORY_GUIDANCE, applySensoryTools } from './sensory.js';
31
+ export { applyMemory } from './memory.js';
32
+ export { LibraryGenerations } from './library-generations.js';
33
+ export { ReceiptJournal } from './receipt-journal.js';
34
+ export { BROWSER_GUIDANCE, applyBrowserTools } from './browser.js';
35
+ export const name = 'watch-tools';
36
+ /**
37
+ * How many receipts stay joinable to a late verdict.
38
+ *
39
+ * Matched to the ledger's own limit in `@deepwatch/dsh-technology`: an
40
+ * attestation is about a call from seconds ago, so an entry the ledger has
41
+ * already evicted has nothing left to join to. Three maps are bounded by it —
42
+ * what was indexed, what verdict was applied, and what verdict is waiting for
43
+ * a receipt — because each is keyed by something a session can produce without
44
+ * limit.
45
+ */
46
+ const RECEIPT_LIMIT = 500;
47
+ /**
48
+ * The addressable id for one execution receipt.
49
+ *
50
+ * A receipt's natural identity is its idempotency key, and that key is a path
51
+ * shape -- `<session>/<turn>/<call>#<n>`. `@deepwatch/dsh-contracts/query`
52
+ * refuses it: an id that carries a slash or a colon could name a location, and
53
+ * `libraryGet` validates against that grammar. So a receipt was searchable and
54
+ * could not be opened -- the Library listed rows whose sibling method answered
55
+ * `rejected`, which is the exact defect the file-derived records were fixed for
56
+ * and the receipt-derived ones inherited.
57
+ *
58
+ * Derived rather than random: the same execution is the same record after a
59
+ * restart, which is what lets a restored journal line up with a live one.
60
+ */
61
+ export function receiptRecordId(idempotencyKey) {
62
+ return `rcpt_${createHash('sha256')
63
+ .update(`watch-receipt/v1/${idempotencyKey}`, 'utf8')
64
+ .digest('hex').slice(0, 16)}`;
65
+ }
66
+ /**
67
+ * The revision id for one answer about a receipt.
68
+ *
69
+ * Distinct per answer, so two verifications of the same call are two versions
70
+ * a reader can compare rather than one row that changed behind them.
71
+ */
72
+ function verdictRevisionId(recordId, verdict, verificationId) {
73
+ return `${recordId}.${createHash('sha256')
74
+ .update(`${verdict}|${verificationId ?? ''}`, 'utf8')
75
+ .digest('hex').slice(0, 12)}`;
76
+ }
77
+ export const inject = ['tools', 'watchCore', 'systemPrompt', 'llm', 'watchProvenance'];
78
+ /** Schemastery validation for the tool-surface policy. */
79
+ export const Config = s.object({
80
+ queryTimeoutMs: s.number().step(1).min(1_000).default(120_000),
81
+ verifyTimeoutMs: s.number().step(1).min(1_000).default(60_000),
82
+ readTimeoutMs: s.number().step(1).min(1_000).default(30_000),
83
+ coldReadTimeoutMs: s.number().step(1).min(1_000).default(60_000),
84
+ liveStartTimeoutMs: s.number().step(1).min(1_000).default(75_000),
85
+ actTimeoutMs: s.number().step(1).min(1_000).default(60_000),
86
+ observeTimeoutMs: s.number().step(1).min(1_000).default(30_000),
87
+ });
88
+ /**
89
+ * What the model is told about Watch, in the system prompt.
90
+ *
91
+ * Written to close the two failure modes that matter: answering from memory
92
+ * when a source was named, and reporting success because a tool returned
93
+ * without error.
94
+ */
95
+ const GUIDANCE = `## Watch: seeing, and proving
96
+
97
+ You have senses through Watch: recorded video, live sources, browser pages, and
98
+ their transcripts, on-screen text and timing. Watch answers from what was
99
+ actually observed and returns citations you can open.
100
+
101
+ - When the user refers to a video, a stream, a screen or a page, answer through
102
+ \`watch_ask_source\` rather than from memory. If Watch cannot answer, say so
103
+ and report the fix it gave you; do not substitute a guess.
104
+ - Every claim about a source must carry the evidence ids Watch returned. An
105
+ answer without citations is not grounded, and you should say that plainly.
106
+ - A tool returning successfully means the call ran. It does not mean the thing
107
+ you did worked. To claim that something worked, run \`watch_verify\` and
108
+ report the verdict it returns.
109
+ - \`UNVERIFIED\` and \`INCONCLUSIVE\` are honest, useful answers. Report them as
110
+ they are. Never describe an unverified outcome as done, working or fixed.
111
+ - Call \`watch_capabilities\` when you are unsure whether a sense is available
112
+ here, instead of attempting a call that will be refused.`;
113
+ /** Generic pending presentation shared by the read-only Watch tools. */
114
+ function present(title, kind, rawInput) {
115
+ return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } };
116
+ }
117
+ /**
118
+ * The shared output declaration for Watch payloads.
119
+ *
120
+ * The schema is `json` on purpose: the authoritative shape of an evidence
121
+ * record or a verification outcome is Watch Core's JSON Schema, negotiated by
122
+ * digest at handshake, not a second copy maintained here that could drift.
123
+ */
124
+ const JSON_OUTPUT = {
125
+ schema: { type: 'json' },
126
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
127
+ };
128
+ /**
129
+ * Hand a typed contract value to the tool runner.
130
+ *
131
+ * `JsonValue` requires an index signature that a named contract interface
132
+ * deliberately does not have, so the conversion is asserted once here rather
133
+ * than at every call site. The values are already plain JSON: they arrived
134
+ * over the Bridge as parsed JSON and are not re-shaped on the way through.
135
+ */
136
+ function asJson(value) {
137
+ return value;
138
+ }
139
+ /**
140
+ * Convert a Bridge failure into a tool value the model can act on.
141
+ *
142
+ * Deliberately not a thrown error: a refusal carries a `fix`, and the model
143
+ * needs to read and relay it. An exception would reach the user as a generic
144
+ * tool failure with the actionable part stripped out.
145
+ */
146
+ function refusal(result) {
147
+ return {
148
+ ok: false,
149
+ error: result.error.error,
150
+ message: result.error.message,
151
+ fix: result.error.fix,
152
+ retryable: result.error.retryable,
153
+ };
154
+ }
155
+ /** Register the Watch tool surface and the guidance that governs its use. */
156
+ export function apply(ctx, config) {
157
+ ctx.systemPrompt.section({ name: 'tool:watch', order: 120, text: GUIDANCE });
158
+ // A separate section rather than one long block: the sensory rules only
159
+ // matter once the agent reaches for a source it has not identified yet, and
160
+ // splitting them keeps each part readable at the point it applies.
161
+ ctx.systemPrompt.section({ name: 'tool:watch-sensory', order: 121, text: SENSORY_GUIDANCE });
162
+ ctx.systemPrompt.section({ name: 'tool:watch-browser', order: 122, text: BROWSER_GUIDANCE });
163
+ applySensoryTools(ctx, config);
164
+ applyBrowserTools(ctx, config);
165
+ // Memory is optional, and this is how Cordis says so. A child plugin whose
166
+ // injects are unsatisfied stays pending rather than failing, and activates
167
+ // by itself if the Memory service is mounted later. Reading ctx.watchMemory
168
+ // directly would throw, because Cordis refuses a service the plugin did not
169
+ // declare; adding it to this plugin's own inject would instead make the
170
+ // whole Watch tool surface wait for a service the bundle may never mount.
171
+ ctx.plugin({
172
+ name: 'watch-memory-tools',
173
+ inject: ['watchMemory', 'tools'],
174
+ apply: applyMemory,
175
+ });
176
+ ctx.tools.register(defineTool({
177
+ name: 'watch_capabilities',
178
+ description: 'Report what Watch can actually do on this machine right now: which senses are connected, '
179
+ + 'which were tested, and what is missing. Call this before assuming a video, live, browser '
180
+ + 'or OCR capability is available. Never fails.',
181
+ parameters: {},
182
+ output: JSON_OUTPUT,
183
+ execute() {
184
+ const health = ctx.watchCore.health();
185
+ // Reported verbatim from the handshake. A capability that was declared
186
+ // but never exercised says so; the model must not read "implemented" as
187
+ // "works here".
188
+ return Promise.resolve(asJson({
189
+ connection: health.phase,
190
+ transport: health.transport,
191
+ coreVersion: health.handshake?.coreVersion ?? null,
192
+ problem: health.error === null ? null : {
193
+ error: health.error.error,
194
+ message: health.error.message,
195
+ fix: health.error.fix,
196
+ },
197
+ capabilities: ctx.watchCore.capabilities().map(capability => ({
198
+ id: capability.capabilityId,
199
+ status: capability.status,
200
+ usable: ctx.watchCore.isCapable(capability.capabilityId),
201
+ missing: capability.missing,
202
+ fixes: capability.fixes,
203
+ })),
204
+ }));
205
+ },
206
+ presentCall: () => present('Check Watch capabilities', 'read'),
207
+ }));
208
+ ctx.tools.register(defineTool({
209
+ name: 'watch_ask_source',
210
+ description: 'Ask a question about an indexed source — a recorded video, a live session, a browser page '
211
+ + 'or a screen capture — and receive an answer grounded in timestamped evidence. Returns the '
212
+ + 'evidence records behind the answer so every claim can be opened at the moment it came from. '
213
+ + 'This observes; it does not verify. Use watch_verify to establish that something worked.',
214
+ parameters: {
215
+ source_id: {
216
+ type: 'string',
217
+ required: true,
218
+ description: 'Id of an indexed source. Use watch_list_sources when you do not have one.',
219
+ },
220
+ question: {
221
+ type: 'string',
222
+ required: true,
223
+ description: 'The question to answer from the source.',
224
+ },
225
+ start_ms: {
226
+ type: 'number',
227
+ description: 'Optional start of the time range to search, in milliseconds.',
228
+ },
229
+ end_ms: {
230
+ type: 'number',
231
+ description: 'Optional end of the time range to search, in milliseconds.',
232
+ },
233
+ },
234
+ output: JSON_OUTPUT,
235
+ async execute(args, exec) {
236
+ const range = args.start_ms === undefined && args.end_ms === undefined
237
+ ? undefined
238
+ : { startMs: args.start_ms ?? 0, endMs: args.end_ms ?? Number.MAX_SAFE_INTEGER };
239
+ const result = await ctx.watchCore.request('watch.source.ask', { sourceId: args.source_id, question: args.question, ...range === undefined ? {} : { range } }, { deadlineMs: config.queryTimeoutMs, ...abortOf(exec) });
240
+ if (!result.ok)
241
+ return asJson(refusal(result));
242
+ const answer = {
243
+ ok: true,
244
+ answer: result.value.answer,
245
+ evidence: result.value.evidence,
246
+ verification: null,
247
+ };
248
+ return asJson(answer);
249
+ },
250
+ presentCall: args => present('Ask a source', 'read', args.question),
251
+ }));
252
+ ctx.tools.register(defineTool({
253
+ name: 'watch_list_sources',
254
+ description: 'List the sources Watch has indexed in this workspace, with their ids, kinds and durations. '
255
+ + 'Use this to find the source_id for watch_ask_source.',
256
+ parameters: {
257
+ query: { type: 'string', description: 'Optional text filter over source titles and paths.' },
258
+ limit: { type: 'number', description: 'Maximum sources to return. Defaults to 20.' },
259
+ },
260
+ output: JSON_OUTPUT,
261
+ async execute(args, exec) {
262
+ const result = await ctx.watchCore.request('watch.library.list', { query: args.query ?? null, limit: args.limit ?? 20 }, abortOf(exec));
263
+ return asJson(result.ok ? result.value : refusal(result));
264
+ },
265
+ presentCall: args => present('List Watch sources', 'read', args.query),
266
+ }));
267
+ ctx.tools.register(defineTool({
268
+ name: 'watch_get_evidence',
269
+ description: 'Resolve one evidence id returned by another Watch tool into its full record: the source '
270
+ + 'revision it came from, its time range and region, how it was produced, and whether it is '
271
+ + 'still current. Use this to check that a citation is fresh before relying on it.',
272
+ parameters: {
273
+ evidence_id: { type: 'string', required: true, description: 'An evidence id from a prior Watch result.' },
274
+ },
275
+ output: JSON_OUTPUT,
276
+ async execute(args, exec) {
277
+ const result = await ctx.watchCore.request('watch.evidence.get', { evidenceId: args.evidence_id }, abortOf(exec));
278
+ return asJson(result.ok ? result.value : refusal(result));
279
+ },
280
+ presentCall: args => present('Open evidence', 'read', args.evidence_id),
281
+ }));
282
+ // Library search runs on the host because that is the only place that can
283
+ // read the evidence store: a client plugin gets no config, and ctx.remote
284
+ // is an event bus rather than a query client.
285
+ const roots = config.libraryRoots ?? [];
286
+ // One owner for the index, and it is the thing that can replace it. The
287
+ // tool and the read plane both read through it, so a refresh either side
288
+ // asks for is the same refresh and there is never a second index to drift.
289
+ const generations = new LibraryGenerations({ roots });
290
+ const library = applyLibrarySearch(ctx, { roots, generations });
291
+ /**
292
+ * The durable half of the Library.
293
+ *
294
+ * Indexed sources are Core's and are on disk already. Receipts were not, so
295
+ * a restart lost them and `Refresh` could not bring them back. The journal
296
+ * is this plugin's own: a receipt is the Host's observation of its own tool
297
+ * calls, and sending it across the Bridge to be stored would put Core's name
298
+ * on a record it did not make.
299
+ */
300
+ const journal = config.receiptsDirectory === undefined
301
+ ? null
302
+ : new ReceiptJournal(config.receiptsDirectory);
303
+ /**
304
+ * File a record in the Library and, if there is one, in the journal.
305
+ *
306
+ * A failed append does not fail the call it describes -- the work already
307
+ * happened, and saying otherwise would be a lie about it. But it is said
308
+ * out loud, once per degradation, because a product that offers durable
309
+ * evidence has to report when it is not storing any.
310
+ */
311
+ let announcedDegradation = null;
312
+ const file = (record) => {
313
+ generations.addLive(record);
314
+ if (journal === null)
315
+ return;
316
+ if (!journal.append(record)) {
317
+ const reason = journal.degradedReason() ?? 'unknown';
318
+ if (announcedDegradation !== reason) {
319
+ announcedDegradation = reason;
320
+ process.stderr.write(`watch-tools: receipts are NOT being saved to ${journal.path} — ${reason}. `
321
+ + 'Work still runs and is still indexed for this session, but the record '
322
+ + 'will not survive a restart. Check the directory exists and is writable.\n');
323
+ }
324
+ return;
325
+ }
326
+ announcedDegradation = null;
327
+ };
328
+ // Restore what a previous run recorded, before anything new is filed.
329
+ //
330
+ // Through the same `addLive` the live path uses, so a restored receipt is
331
+ // indistinguishable from one minted a moment ago -- which is the property
332
+ // that makes a restart invisible to a reader. Not re-journalled: it is
333
+ // already in the file it came from, and appending it again would grow the
334
+ // journal by its own contents on every boot.
335
+ if (journal !== null) {
336
+ const restored = journal.load();
337
+ for (const record of restored.records)
338
+ generations.addLive(record);
339
+ if (restored.status === 'unreadable') {
340
+ // An unreadable store is not an empty one. Returning silently here is
341
+ // how a permissions problem looked exactly like a first run.
342
+ process.stderr.write(`watch-tools: the receipt journal at ${journal.path} exists and could not be `
343
+ + `read — ${restored.reason ?? 'unknown'}. Previous receipts are not available `
344
+ + 'in this session.\n');
345
+ }
346
+ if (restored.repairedBytes > 0) {
347
+ process.stderr.write(`watch-tools: removed ${String(restored.repairedBytes)} byte(s) of incomplete `
348
+ + `tail from ${journal.path} — a write was interrupted. Earlier records were `
349
+ + 'kept.\n');
350
+ }
351
+ if (restored.damaged > 0) {
352
+ // Said out loud rather than swallowed. A torn last line is the ordinary
353
+ // consequence of a process dying mid-append and costs one record; a
354
+ // larger count is a corrupted file and somebody should know.
355
+ process.stderr.write(`watch-tools: ${String(restored.damaged)} of ${String(restored.lines)} `
356
+ + `journal line(s) could not be read and were skipped (${journal.path})\n`);
357
+ }
358
+ }
359
+ // The same index, reachable by the surfaces as well as by the agent. A
360
+ // `conversation.view` entry is handed `{ inspect, onInspectDone }` and
361
+ // nothing else, so without this the Library mode has no way to obtain the
362
+ // records it renders and defaults to an empty array.
363
+ applyReadPlane(ctx, {
364
+ index: library.index,
365
+ scope: config.workspaceScope ?? 'default',
366
+ generations,
367
+ });
368
+ /**
369
+ * Index an execution receipt the moment the Host records one.
370
+ *
371
+ * The gap this closes: the evaluation produced 76 tool actions and a Library
372
+ * with nothing in it. Not because indexing failed — because nothing indexed a
373
+ * receipt until somebody pressed Refresh, and Refresh reads evidence roots on
374
+ * disk that did not contain an in-memory receipt from four seconds ago. A
375
+ * feature reachable only by winning that race is not reachable.
376
+ *
377
+ * A receipt is filed as a `document`, which is the least-wrong kind the
378
+ * source vocabulary already has for something textual, and tagged so it stays
379
+ * filterable without widening a closed union that the client's filters and
380
+ * the wire contract both depend on.
381
+ *
382
+ * `verdict` is deliberately null. A receipt is what happened; whether it was
383
+ * right is Core's answer and arrives, if it arrives, as an attestation.
384
+ */
385
+ /**
386
+ * What each receipt was indexed as, so a later verdict can be joined to it.
387
+ *
388
+ * Bounded by the same reasoning as the ledger it mirrors: an attestation
389
+ * arrives seconds after its receipt or not at all, so an entry older than the
390
+ * ledger's own limit has nothing left to join to.
391
+ */
392
+ const indexed = new Map();
393
+ /**
394
+ * The answer already on a record, so a repeat is not a rewrite.
395
+ *
396
+ * Keyed by execution identity and holding *verification* identity as well as
397
+ * the verdict, because the verdict string alone is not an identity. Two
398
+ * different verifications of the same call can both say `VERIFIED` and be
399
+ * different answers about different questions, and comparing only the word
400
+ * would silently keep the first one's evidence link.
401
+ */
402
+ const applied = new Map();
403
+ /**
404
+ * The last answer Core gave for a call, whether or not it is on the row yet.
405
+ *
406
+ * Not "pending": it is kept after a successful join, because the row can be
407
+ * rewritten later. A receipt is re-announced when a record reconciles, when
408
+ * an event is replayed, and after a reconnect — and each of those files the
409
+ * receipt again, without a verdict. Something has to remember the answer, or
410
+ * a duplicate silently downgrades a verified row and no later attestation
411
+ * can put it back.
412
+ *
413
+ * It also covers the opposite order. `settle()` announces a receipt before
414
+ * `attest()` is started, so ordinarily the receipt is first; that is a
415
+ * sequence, not a guarantee. The two listeners are on a fork that receives
416
+ * events on its own schedule, and after a reload an attestation can name a
417
+ * call this instance never saw.
418
+ *
419
+ * Bounded by the ledger's own horizon: an attestation belongs to a call from
420
+ * seconds ago, and an entry the ledger has evicted has nothing to join to.
421
+ */
422
+ const known = new Map();
423
+ /** Keep the newest `limit` entries of an insertion-ordered map. */
424
+ const bound = (map, limit) => {
425
+ while (map.size > limit) {
426
+ const oldest = map.keys().next();
427
+ if (oldest.done === true)
428
+ return;
429
+ map.delete(oldest.value);
430
+ }
431
+ };
432
+ /**
433
+ * Put Core's verdict on the record it names, once.
434
+ *
435
+ * Re-indexing under the same `recordId` replaces the row and moves the
436
+ * `revisionId`, so a reader can tell the answered record from the one filed
437
+ * before Core replied. Doing it twice with the same verdict would file the
438
+ * same revision again, so it is done once: repeat delivery of an attestation
439
+ * is normal and must not look like a second answer.
440
+ *
441
+ * @returns true when a revision was filed.
442
+ */
443
+ const joinVerdict = (recordId, verdict, verificationId) => {
444
+ const base = indexed.get(recordId);
445
+ if (base === undefined)
446
+ return false;
447
+ const already = applied.get(recordId);
448
+ if (already !== undefined
449
+ && already.verdict === verdict
450
+ && already.verificationId === verificationId)
451
+ return false;
452
+ applied.set(recordId, { verdict, verificationId });
453
+ bound(applied, RECEIPT_LIMIT);
454
+ file({
455
+ recordId: receiptRecordId(recordId),
456
+ revisionId: verdictRevisionId(receiptRecordId(recordId), verdict, verificationId),
457
+ title: base.title,
458
+ // The verdict joins the searchable text so the Library's own filter and
459
+ // a person typing "VERIFIED" find the same rows.
460
+ text: `${base.text} ${verdict}`,
461
+ kind: 'document',
462
+ source: null,
463
+ runId: base.runId,
464
+ observedAt: base.observedAt,
465
+ verdict,
466
+ tags: [...base.tags, `verdict:${verdict}`],
467
+ evidenceIds: verificationId === null ? [] : [verificationId],
468
+ });
469
+ return true;
470
+ };
471
+ ctx
472
+ .on('watch/execution-recorded', (payload) => {
473
+ const record = payload;
474
+ const recordId = typeof record.idempotencyKey === 'string' ? record.idempotencyKey : null;
475
+ if (recordId === null)
476
+ return;
477
+ const tool = typeof record.toolName === 'string' ? record.toolName : 'tool';
478
+ const paths = Array.isArray(record.paths) ? record.paths.filter((entry) => typeof entry === 'string') : [];
479
+ file({
480
+ recordId: receiptRecordId(recordId),
481
+ revisionId: receiptRecordId(recordId),
482
+ title: paths.length === 0 ? tool : `${tool} — ${paths.join(', ')}`,
483
+ kind: 'document',
484
+ // Searchable text, already redacted and bounded by the ledger that
485
+ // produced it. Nothing is re-derived here from anything unredacted.
486
+ text: [
487
+ tool,
488
+ typeof record.inputSummary === 'string' ? record.inputSummary : '',
489
+ typeof record.outputSummary === 'string' ? record.outputSummary : '',
490
+ ...paths,
491
+ ].join(' '),
492
+ source: null,
493
+ runId: typeof record.sessionId === 'string' ? record.sessionId : null,
494
+ observedAt: typeof record.startedAt === 'string' ? record.startedAt : null,
495
+ verdict: null,
496
+ tags: [
497
+ 'execution-receipt',
498
+ `tool:${tool}`,
499
+ ...typeof record.sideEffect === 'string' ? [`effect:${record.sideEffect}`] : [],
500
+ ...typeof record.scope === 'string' ? [`scope:${record.scope}`] : [],
501
+ ...typeof record.state === 'string' ? [`state:${record.state}`] : [],
502
+ ],
503
+ evidenceIds: [],
504
+ });
505
+ indexed.set(recordId, {
506
+ title: paths.length === 0 ? tool : `${tool} — ${paths.join(', ')}`,
507
+ text: [
508
+ tool,
509
+ typeof record.inputSummary === 'string' ? record.inputSummary : '',
510
+ typeof record.outputSummary === 'string' ? record.outputSummary : '',
511
+ ...paths,
512
+ ].join(' '),
513
+ runId: typeof record.sessionId === 'string' ? record.sessionId : null,
514
+ observedAt: typeof record.startedAt === 'string' ? record.startedAt : null,
515
+ tags: [
516
+ 'execution-receipt',
517
+ `tool:${tool}`,
518
+ ...typeof record.sideEffect === 'string' ? [`effect:${record.sideEffect}`] : [],
519
+ ...typeof record.scope === 'string' ? [`scope:${record.scope}`] : [],
520
+ ...typeof record.state === 'string' ? [`state:${record.state}`] : [],
521
+ ],
522
+ });
523
+ bound(indexed, RECEIPT_LIMIT);
524
+ // The row was just written from the receipt, so it carries no verdict
525
+ // whatever it carried a moment ago. If an answer is known for this call,
526
+ // put it back.
527
+ //
528
+ // This covers three sequences with one rule. A verdict that arrived
529
+ // before its receipt has been waiting. A receipt that arrives *again* --
530
+ // a reconciled record, a replayed event, a reconnect -- is re-filed,
531
+ // which is right, because an execution state can legitimately change and
532
+ // the newer observation is the truthful one. And a verdict that arrives
533
+ // after either of those still lands.
534
+ //
535
+ // The defect this closes: receipt, then `pass`, then the same receipt
536
+ // again left the row at `verdict: null`, and repeating the attestation
537
+ // could not repair it because `applied` said it had already been
538
+ // applied. The answer was gone from the Library and from the journal,
539
+ // and nothing said so.
540
+ const answer = known.get(recordId);
541
+ if (answer !== undefined) {
542
+ // Forget what was applied: the row no longer has it, so this is a new
543
+ // write rather than a repeat of one.
544
+ applied.delete(recordId);
545
+ joinVerdict(recordId, answer.verdict, answer.verificationId);
546
+ }
547
+ });
548
+ ctx
549
+ .on('watch/attestation-recorded', (payload) => {
550
+ const attestation = payload;
551
+ const recordId = typeof attestation.idempotencyKey === 'string'
552
+ ? attestation.idempotencyKey
553
+ : null;
554
+ const verdict = typeof attestation.coreVerdict === 'string' ? attestation.coreVerdict : null;
555
+ // No verdict is not a verdict. `requested_but_not_run` and `unavailable`
556
+ // are real states, and writing one of them as a pass would be the Host
557
+ // deciding an answer only Core may give (ADR-002).
558
+ if (recordId === null || verdict === null)
559
+ return;
560
+ const verificationId = typeof attestation.verificationId === 'string'
561
+ ? attestation.verificationId
562
+ : null;
563
+ // Remembered whatever happens, so a receipt that arrives again later can
564
+ // be repaired from it. Discarding this the moment the join succeeded is
565
+ // what made the downgrade unrecoverable.
566
+ known.set(recordId, { verdict, verificationId });
567
+ bound(known, RECEIPT_LIMIT);
568
+ joinVerdict(recordId, verdict, verificationId);
569
+ });
570
+ ctx.tools.register(defineTool({
571
+ name: 'watch_verify',
572
+ description: 'Run a verification contract and return an independent verdict with a receipt. This is the '
573
+ + 'only way to establish that something actually worked. VERIFIED means an executable '
574
+ + 'expectation passed against valid evidence. UNVERIFIED means there was no executable '
575
+ + 'expectation or not enough evidence — report it as such; it is not a failure and it is not '
576
+ + 'a success. Never describe work as done on the strength of a tool returning without error.',
577
+ parameters: {
578
+ expectation: {
579
+ type: 'string',
580
+ required: true,
581
+ description: 'The concrete, checkable outcome to prove. Name what should be observable, not what you '
582
+ + 'intended: "the row for order 4182 is gone from the table", not "the delete worked".',
583
+ },
584
+ source_id: {
585
+ type: 'string',
586
+ description: 'Optional source to re-observe. Defaults to the session\'s bound source.',
587
+ },
588
+ evidence_ids: {
589
+ type: 'array',
590
+ items: { type: 'string' },
591
+ description: 'Optional prior evidence to check the expectation against.',
592
+ },
593
+ checks: {
594
+ type: 'array',
595
+ // A lossless JSON node per item rather than a spelled-out object: the
596
+ // `params` of a check vary by its type, Core owns those shapes and
597
+ // validates them, and a second schema here would be a second place for
598
+ // the two sides to disagree about what a check is.
599
+ items: { type: 'json' },
600
+ description: 'The executable checks that decide the verdict. An expectation without checks is a '
601
+ + 'sentence, and a sentence returns UNVERIFIED — which is the honest answer and not a '
602
+ + 'pass. Each check is `{ id, type, params }`. Types: file_exists `{path}`, '
603
+ + 'file_digest `{path, sha256}`, json_value `{path, pointer, equals}` with an RFC 6901 '
604
+ + 'pointer, command_exit `{command: [argv], cwd?, exit_code?}`, numeric_invariant, '
605
+ + 'directory_manifest, json_schema, sqlite_query, http_request. Paths are relative to '
606
+ + 'the workspace. Watch Core runs these itself, in its own isolated verifier — you are '
607
+ + 'naming the question, not answering it, and a check you write cannot see anything you '
608
+ + 'tell it.',
609
+ },
610
+ },
611
+ output: {
612
+ ...JSON_OUTPUT,
613
+ /**
614
+ * Project the verdict for the result card.
615
+ *
616
+ * Presentation-only. It reads the verdict Watch Core returned; it never
617
+ * derives one, and a payload without a verdict projects null rather than
618
+ * a default that would read as success.
619
+ */
620
+ presentationMeta: (_args, value) => {
621
+ const verdict = value?.verdict;
622
+ return { verdict: typeof verdict === 'string' ? verdict : null };
623
+ },
624
+ },
625
+ async execute(args, exec) {
626
+ const result = await ctx.watchCore.request('watch.verification.run', {
627
+ expectation: args.expectation,
628
+ sourceId: args.source_id ?? null,
629
+ evidenceIds: args.evidence_ids ?? [],
630
+ // The directory the checks are measured against, from the session
631
+ // the call came from.
632
+ //
633
+ // Core refuses rather than defaulting, deliberately: given no
634
+ // directory the verifier used to measure against whatever directory
635
+ // its own process was started in, and a file the agent had written
636
+ // correctly came back INCONCLUSIVE. Honest, and useless. The Host
637
+ // never sent one, so every agent verification with a relative path
638
+ // got that answer -- the whole feature, silently.
639
+ //
640
+ // Sent as null when the session cannot be resolved, so Core's own
641
+ // `verify.workspace_unresolved` reaches the model with its fix,
642
+ // rather than this guessing a directory on its behalf.
643
+ workingDir: workspaceOf(exec),
644
+ // Forwarded verbatim. Core validates the shapes, freezes the
645
+ // contract and refuses anything it cannot evaluate; a second
646
+ // validation here would be a second place for the two sides to
647
+ // disagree about what was asked.
648
+ checks: args.checks ?? [],
649
+ // Minted here so the verdict, the receipt and the Trajectory record
650
+ // all hang off one id the user can follow.
651
+ verificationId: `ver_${randomUUID()}`,
652
+ }, { deadlineMs: config.verifyTimeoutMs, ...abortOf(exec) });
653
+ return asJson(result.ok ? result.value : refusal(result));
654
+ },
655
+ presentCall: args => present('Verify', 'other', args.expectation),
656
+ presentResult: (_args, result) => {
657
+ // The card must never turn an honest non-answer green. It reports the
658
+ // verdict verbatim, including UNVERIFIED and INCONCLUSIVE, and falls
659
+ // back to the generic card when there is no verdict to report at all.
660
+ const verdict = result.meta?.verdict;
661
+ if (typeof verdict !== 'string')
662
+ return undefined;
663
+ return { card: 'generic', title: `Verification: ${verdict}` };
664
+ },
665
+ }));
666
+ }
667
+ /** Forward the tool runner's cancellation to the Bridge when one exists. */
668
+ function abortOf(exec) {
669
+ return exec.signal === undefined ? {} : { signal: exec.signal };
670
+ }
671
+ /**
672
+ * The directory the session was opened in, or null.
673
+ *
674
+ * Read structurally and from two places, the same way the ledger reads it: a
675
+ * live session carries `cwd`, and one restored from storage carries it under
676
+ * `header`. Null rather than a guess -- see the note at `workingDir`.
677
+ */
678
+ function workspaceOf(exec) {
679
+ const session = exec.agent?.session;
680
+ const direct = session?.cwd;
681
+ if (typeof direct === 'string' && direct !== '')
682
+ return direct;
683
+ const stored = session?.header?.cwd;
684
+ return typeof stored === 'string' && stored !== '' ? stored : null;
685
+ }
686
+ /**
687
+ * The plugin, as the Cordis loader resolves it.
688
+ *
689
+ * An object rather than the bare `apply` function, and that distinction is the
690
+ * whole reason this exists. The loader takes `module.default` and then reads
691
+ * `plugin.inject` off it — so a default export of the function alone leaves the
692
+ * named `inject` sitting on the module namespace where nothing looks, and the
693
+ * first `ctx.systemPrompt` access throws "cannot get property without inject"
694
+ * at boot.
695
+ *
696
+ * Nothing catches that before a real boot: the composed tree is correct, the
697
+ * install smoke passes, and the profile fails the moment it actually starts.
698
+ * `scripts/boot-smoke.mjs` is the gate that does catch it.
699
+ */
700
+ export default { name: 'watch-tools', inject, apply };
701
+ export * from './library-search.js';
702
+ /**
703
+ * The read plane's public face.
704
+ *
705
+ * Exported because Typert analyses a package's public export graph: a Remote
706
+ * that is only reachable through an internal module is not discovered, and no
707
+ * host or client artifact is emitted for it.
708
+ */
709
+ export * from './read-plane.js';
710
+ //# sourceMappingURL=index.js.map