@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/LICENSE +21 -0
- package/README.md +106 -0
- package/lib/browser.d.ts +38 -0
- package/lib/browser.js +299 -0
- package/lib/index.d.ts +129 -0
- package/lib/index.js +710 -0
- package/lib/library-generations.d.ts +107 -0
- package/lib/library-generations.js +227 -0
- package/lib/library-search.d.ts +143 -0
- package/lib/library-search.js +407 -0
- package/lib/memory.d.ts +23 -0
- package/lib/memory.js +96 -0
- package/lib/read-plane.d.ts +237 -0
- package/lib/read-plane.js +688 -0
- package/lib/receipt-journal.d.ts +101 -0
- package/lib/receipt-journal.js +246 -0
- package/lib/sensory.d.ts +48 -0
- package/lib/sensory.js +277 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +649 -0
- package/lib/typert.remote-client.d.ts +32 -0
- package/lib/typert.remote-client.d.ts.map +1 -0
- package/lib/typert.remote-client.js +405 -0
- package/package.json +83 -0
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
|