@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.
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Library search, as a tool the host owns.
3
+ *
4
+ * The index has to live somewhere that can read the evidence store, and the
5
+ * browser cannot: a client plugin receives no config, `ctx.remote` is an event
6
+ * bus rather than a query client, and the boot graph carries no data. The host
7
+ * can read the store, so the host holds the index and the agent reaches it the
8
+ * way it reaches everything else in Watch — as a tool.
9
+ *
10
+ * It is the same `LibraryIndex` the client surface uses. One implementation,
11
+ * one set of semantics, one place where "every term must match" is decided;
12
+ * two would drift within a release and disagree about what the library
13
+ * contains.
14
+ *
15
+ * Three things it will not do.
16
+ *
17
+ * It reads only inside the roots it was configured with. `isWithinRoots`
18
+ * refuses traversal rather than normalising it, because normalising an attempt
19
+ * to escape produces a path that works.
20
+ *
21
+ * It returns no verdict. A search result is a pointer to a record, and whether
22
+ * that record's claim is true is `watch_verify`'s question. A tool that
23
+ * answered both would let a search become an assertion.
24
+ *
25
+ * And it never rebuilds silently. A stale or corrupt index is reported as such
26
+ * in the result, because a search that quietly returns less than it should is
27
+ * worse than one that says it is behind.
28
+ *
29
+ * @module @deepwatch/dsh-tools/library-search
30
+ */
31
+ import { createHash } from 'node:crypto';
32
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
33
+ import { basename, join } from 'node:path';
34
+ import { defineTool } from '@deepseek-ai/dsh-tools';
35
+ import { contentIdFor, revisionIdFor } from '@deepwatch/dsh-contracts/identity';
36
+ import { LibraryIndex, MAX_LIMIT, isWithinRoots } from '@deepwatch/dsh-library';
37
+ /**
38
+ * Read one JSON file into a record, or nothing.
39
+ *
40
+ * Deliberately total: a malformed file, an unreadable one, or one holding
41
+ * something that is not a record yields no record rather than throwing. One bad
42
+ * file must not stop the whole library being searchable — a corpus is exactly
43
+ * where a single malformed entry is most likely and least excusable as a
44
+ * failure mode.
45
+ */
46
+ /**
47
+ * Every string in a record, at any depth.
48
+ *
49
+ * Reading only the top level looked reasonable and was wrong: a citation's
50
+ * text, a check's detail and a revision's transcript all live one or two
51
+ * levels down, so a phrase plainly present in the file returned nothing.
52
+ *
53
+ * Bounded on purpose. A record is data, not a program, so a deeply nested or
54
+ * self-referential one costs a fixed amount of work rather than a stack
55
+ * overflow — and a malformed file is exactly where that would show up.
56
+ */
57
+ export function gatherText(value, depth = 0, seen = new Set()) {
58
+ if (depth > 8)
59
+ return [];
60
+ if (typeof value === 'string')
61
+ return value === '' ? [] : [value];
62
+ if (typeof value !== 'object' || value === null)
63
+ return [];
64
+ if (seen.has(value))
65
+ return [];
66
+ seen.add(value);
67
+ const out = [];
68
+ if (Array.isArray(value)) {
69
+ for (const entry of value.slice(0, 500))
70
+ out.push(...gatherText(entry, depth + 1, seen));
71
+ return out;
72
+ }
73
+ for (const [key, entry] of Object.entries(value)) {
74
+ // Digests are matched through their own fields. Folding them into the body
75
+ // would make every record match every hex-looking query.
76
+ if (/^(digest|hash|sha256|contentDigest|inputDigest)$/i.test(key))
77
+ continue;
78
+ out.push(...gatherText(entry, depth + 1, seen));
79
+ }
80
+ return out;
81
+ }
82
+ /** Correlates one tool-initiated rebuild, so a retried tool call is idempotent. */
83
+ let toolRequest = 1;
84
+ /** `sha256` hex, the one primitive both identity functions are built on. */
85
+ const sha256hex = (material) => createHash('sha256').update(material, 'utf8').digest('hex');
86
+ /**
87
+ * The digest of a file's bytes.
88
+ *
89
+ * Bytes, not the decoded string. A file is what is on disk, and a record whose
90
+ * identity depended on how it happened to be decoded would change identity for
91
+ * a byte-order mark nobody typed.
92
+ */
93
+ export function contentDigest(bytes) {
94
+ return createHash('sha256')
95
+ .update(typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : bytes)
96
+ .digest('hex');
97
+ }
98
+ export function recordFromFile(path, raw) {
99
+ const text = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8');
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(text);
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ if (typeof parsed !== 'object' || parsed === null)
108
+ return null;
109
+ const value = parsed;
110
+ const optional = (candidate) => (typeof candidate === 'string' && candidate !== '' ? candidate : null);
111
+ // The id comes from the file when it names one, and from the file's *content*
112
+ // when it does not. Identity follows bytes.
113
+ //
114
+ // Two earlier answers were both wrong, in opposite directions. The path
115
+ // itself put an absolute host location on the wire and into the browser, and
116
+ // was not even addressable — `libraryGet` validates `recordId` against the
117
+ // identifier grammar, which has no slash and no colon, so a search returned
118
+ // ids that its sibling method refused. A digest of the path fixed the
119
+ // disclosure and kept the deeper error: move byte-identical content and it
120
+ // became a different record; overwrite the bytes and it stayed the same one.
121
+ // That is the exact defect `src/watch_skill/identity.py` was written to end,
122
+ // where a video's id used to be `sha256(source_string)` and overwriting
123
+ // `demo.mp4` returned yesterday's answers for today's file.
124
+ //
125
+ // So the fallback is Core's own content identity, mirrored in
126
+ // `@deepwatch/dsh-contracts/identity` and tested against the Python. The
127
+ // same bytes are one record wherever they are read from, and different bytes
128
+ // are two.
129
+ const digest = contentDigest(raw);
130
+ const recordId = optional(value['evidenceId'])
131
+ ?? optional(value['sourceId'])
132
+ ?? optional(value['verificationId'])
133
+ ?? contentIdFor(digest, sha256hex);
134
+ // Body text is every string the record carries, at any depth.
135
+ //
136
+ // Reading only the top level looked reasonable and was wrong: a citation's
137
+ // text, a check's detail and a revision's transcript all live one or two
138
+ // levels down, so searching for a phrase plainly present in the file
139
+ // returned nothing. A record's searchable text is whatever it actually
140
+ // says, wherever it says it.
141
+ const body = gatherText(value).join('\n');
142
+ const tags = Array.isArray(value['tags'])
143
+ ? value['tags'].filter((tag) => typeof tag === 'string')
144
+ : [];
145
+ return {
146
+ recordId,
147
+ // The revision names the version of the content, so it comes from the
148
+ // content too. `identity.revision_id_for`, mirrored: changing the bytes at
149
+ // one path produces a new revision, which is the fact a surface renders as
150
+ // "this is not what you saw before".
151
+ revisionId: optional(value['sourceRevisionId']) ?? revisionIdFor(digest, sha256hex),
152
+ // A digest is an identifier, not something to read. Where the file names
153
+ // no title, the file's own name is what a person can recognise — and it is
154
+ // a name rather than a location, so it carries no directory with it.
155
+ title: optional(value['title']) ?? optional(value['expectation'])
156
+ ?? basename(path).replace(/\.json$/i, ''),
157
+ kind: (optional(value['kind']) ?? 'document'),
158
+ text: body,
159
+ source: optional(value['locator']) ?? optional(value['source']),
160
+ runId: optional(value['runId']) ?? optional(value['sessionId']),
161
+ observedAt: optional(value['observedAt']) ?? optional(value['at']),
162
+ verdict: optional(value['verdict']),
163
+ tags,
164
+ evidenceIds: Array.isArray(value['evidenceRefs'])
165
+ ? value['evidenceRefs'].filter((id) => typeof id === 'string')
166
+ : [],
167
+ };
168
+ }
169
+ /**
170
+ * Read every record under the configured roots.
171
+ *
172
+ * Returns what it managed to read plus what it refused, because a caller that
173
+ * cannot tell "there is nothing here" from "I was not allowed to look" cannot
174
+ * report either honestly.
175
+ */
176
+ export function collectRecords(roots) {
177
+ const records = [];
178
+ const listed = recordFiles(roots);
179
+ for (const file of listed.files) {
180
+ const read = readRecord(file, roots);
181
+ if (read.record === null)
182
+ listed.skipped.push(read.skipped);
183
+ else
184
+ records.push(read.record);
185
+ }
186
+ return { records, skipped: listed.skipped };
187
+ }
188
+ /**
189
+ * The files worth trying, and the roots that could not be listed.
190
+ *
191
+ * Every skip reason here is a fixed sentence. Interpolating the error, which
192
+ * this used to do, puts the absolute root into a string that reaches the wire
193
+ * through a refresh answer — the one thing the read plane promises it never
194
+ * carries. The host knows where it read; the caller learns only that it could
195
+ * not.
196
+ */
197
+ function recordFiles(roots) {
198
+ const files = [];
199
+ const skipped = [];
200
+ for (const root of roots) {
201
+ if (!existsSync(root)) {
202
+ skipped.push('a configured library root does not exist');
203
+ continue;
204
+ }
205
+ let entries;
206
+ try {
207
+ entries = readdirSync(root);
208
+ }
209
+ catch {
210
+ skipped.push('a configured library root could not be listed');
211
+ continue;
212
+ }
213
+ for (const entry of entries) {
214
+ if (!entry.endsWith('.json'))
215
+ continue;
216
+ files.push({ path: join(root, entry).replace(/\\/g, '/'), name: entry });
217
+ }
218
+ }
219
+ return { files, skipped };
220
+ }
221
+ /** Read one candidate, or say — by name, never by path — why it was skipped. */
222
+ function readRecord(file, roots) {
223
+ // The boundary check runs on the resolved path, not on the name, so a
224
+ // symlink or a crafted entry cannot walk out of the root.
225
+ if (!isWithinRoots(file.path, roots)) {
226
+ return { record: null, skipped: `${file.name}: outside the configured roots` };
227
+ }
228
+ try {
229
+ if (!statSync(file.path).isFile()) {
230
+ return { record: null, skipped: `${file.name}: not a regular file` };
231
+ }
232
+ const record = recordFromFile(file.path, readFileSync(file.path));
233
+ return record === null
234
+ ? { record: null, skipped: `${file.name}: not a readable record` }
235
+ : { record, skipped: '' };
236
+ }
237
+ catch {
238
+ // The error message would name the path. The filename is what a person
239
+ // needs to go and look at it.
240
+ return { record: null, skipped: `${file.name}: could not be read` };
241
+ }
242
+ }
243
+ /** Build a fresh index over the roots. Cheap enough to do on demand. */
244
+ export function buildIndex(roots) {
245
+ const { records, skipped } = collectRecords(roots);
246
+ const index = new LibraryIndex();
247
+ index.addAll(records);
248
+ return { index, skipped };
249
+ }
250
+ /**
251
+ * Build an index, yielding often enough that a caller can stop it.
252
+ *
253
+ * The synchronous builder above is what a tool call uses: it is one pass over
254
+ * a directory and returning a promise would buy nothing. A refresh is
255
+ * different — it is a person waiting, it has a deadline, and it has to be
256
+ * abandonable — so this one checks the signal between files and hands the
257
+ * event loop back so the check can actually fire.
258
+ *
259
+ * It builds into a *new* index. Nothing in service is touched until the caller
260
+ * decides to swap, which is what makes a failed or abandoned rebuild leave the
261
+ * previous generation exactly as it was.
262
+ */
263
+ export async function buildIndexCancellable(roots, signal) {
264
+ const listed = recordFiles(roots);
265
+ const records = [];
266
+ for (const file of listed.files) {
267
+ if (signal.aborted)
268
+ return { index: null, skipped: listed.skipped, sourceCount: roots.length };
269
+ // One turn of the loop per file. A directory of evidence records is not
270
+ // large enough for the yield to cost anything measurable, and without it
271
+ // an abort raised during the walk is not observed until the walk is over.
272
+ await Promise.resolve();
273
+ const read = readRecord(file, roots);
274
+ if (read.record === null)
275
+ listed.skipped.push(read.skipped);
276
+ else
277
+ records.push(read.record);
278
+ }
279
+ if (signal.aborted)
280
+ return { index: null, skipped: listed.skipped, sourceCount: roots.length };
281
+ const index = new LibraryIndex();
282
+ index.addAll(records);
283
+ return { index, skipped: listed.skipped, sourceCount: roots.length };
284
+ }
285
+ /** The same output shape the rest of the Watch tools use. */
286
+ const JSON_OUTPUT = {
287
+ schema: { type: 'json' },
288
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
289
+ };
290
+ /**
291
+ * Hand a plain value to the tool runner.
292
+ *
293
+ * `JsonValue` wants an index signature that a named result shape deliberately
294
+ * does not have. The value is already plain JSON, so the conversion is asserted
295
+ * once here rather than at every return.
296
+ */
297
+ function asJson(value) {
298
+ return value;
299
+ }
300
+ /**
301
+ * Register the search tool, and hand back the index it holds.
302
+ *
303
+ * The accessor is returned rather than the index itself so the read plane sees
304
+ * a rebuild the moment it happens, without either side holding a reference to
305
+ * an object the other has replaced. One index answers both the agent's tool
306
+ * and the person's surface: two would drift inside a release and disagree
307
+ * about what the library contains.
308
+ */
309
+ export function applyLibrarySearch(ctx, config) {
310
+ // Held rather than rebuilt per query: a search must not re-read the corpus on
311
+ // every keystroke. Where an owner was supplied the index is its index, so a
312
+ // refresh asked for through the surface is visible here immediately and the
313
+ // agent never searches a different corpus from the person.
314
+ let fallback = null;
315
+ let skippedFiles = [];
316
+ const indexNow = () => {
317
+ if (config.generations !== undefined)
318
+ return config.generations.index();
319
+ if (fallback === null) {
320
+ const built = buildIndex(config.roots);
321
+ fallback = built.index;
322
+ skippedFiles = built.skipped;
323
+ }
324
+ return fallback;
325
+ };
326
+ /** The tool's own `rebuild` argument, routed to the one owner of the index. */
327
+ const rebuildNow = async () => {
328
+ if (config.generations === undefined) {
329
+ const built = buildIndex(config.roots);
330
+ fallback = built.index;
331
+ skippedFiles = built.skipped;
332
+ return;
333
+ }
334
+ // A tool call is its own request. The id is what makes a retried tool call
335
+ // idempotent rather than a second read of the corpus.
336
+ await config.generations.refresh(`tool-${String(toolRequest++)}`, new AbortController().signal);
337
+ };
338
+ ctx.tools.register(defineTool({
339
+ name: 'watch_library_search',
340
+ description: 'Search the evidence and sources Watch has recorded in this workspace. Matching is lexical '
341
+ + 'and local — every word you give must appear in a record — so it works offline and needs no '
342
+ + 'embedding model. Returns pointers to records, never a verdict: use watch_verify to '
343
+ + 'establish whether a record\'s claim actually holds.',
344
+ parameters: {
345
+ query: {
346
+ type: 'string',
347
+ description: 'Words to find. Every word must appear. Leave empty to list everything the filters allow.',
348
+ },
349
+ verdict: { type: 'string', description: 'Only records with this verification state.' },
350
+ run_id: { type: 'string', description: 'Only records from this run.' },
351
+ from: { type: 'string', description: 'ISO-8601 lower bound on the observation time.' },
352
+ to: { type: 'string', description: 'ISO-8601 upper bound on the observation time.' },
353
+ sort: { type: 'string', description: 'relevance | newest | oldest | title. Defaults to relevance.' },
354
+ offset: { type: 'number', description: 'Results to skip, for paging.' },
355
+ limit: { type: 'number', description: `Maximum results. Capped at ${String(MAX_LIMIT)}.` },
356
+ rebuild: { type: 'boolean', description: 'Rebuild the index before searching. The index is derived and safe to discard.' },
357
+ },
358
+ output: JSON_OUTPUT,
359
+ async execute(rawArgs) {
360
+ const args = (rawArgs ?? {});
361
+ if (config.roots.length === 0) {
362
+ return asJson({
363
+ ok: false,
364
+ error: 'no_roots_configured',
365
+ message: 'This deployment gave the library no evidence roots, so there is nothing to search.',
366
+ fix: "Set the watch-tools row's `libraryRoots` to the directories holding evidence records.",
367
+ retryable: false,
368
+ });
369
+ }
370
+ if (args['rebuild'] === true)
371
+ await rebuildNow();
372
+ const index = indexNow();
373
+ const page = index.search({
374
+ text: typeof args['query'] === 'string' ? args['query'] : '',
375
+ ...(typeof args['verdict'] === 'string' ? { verdicts: [args['verdict']] } : {}),
376
+ ...(typeof args['run_id'] === 'string' ? { runIds: [args['run_id']] } : {}),
377
+ ...(typeof args['from'] === 'string' ? { from: args['from'] } : {}),
378
+ ...(typeof args['to'] === 'string' ? { to: args['to'] } : {}),
379
+ ...(typeof args['sort'] === 'string'
380
+ ? { sort: args['sort'] }
381
+ : {}),
382
+ ...(typeof args['offset'] === 'number' ? { offset: args['offset'] } : {}),
383
+ ...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
384
+ });
385
+ return asJson({
386
+ total: page.total,
387
+ offset: page.offset,
388
+ limit: page.limit,
389
+ indexHealth: page.health,
390
+ indexedRecords: index.size,
391
+ notes: page.notes,
392
+ // Named so nobody mistakes a search hit for a finding.
393
+ skippedFiles,
394
+ results: page.results.map(result => ({
395
+ recordId: result.sourceId,
396
+ title: result.title,
397
+ kind: result.kind,
398
+ snippets: result.hits.map(hit => hit.text),
399
+ evidenceIds: result.hits.flatMap(hit => hit.evidenceIds),
400
+ })),
401
+ });
402
+ },
403
+ }));
404
+ // The tool and the read plane share this. See the note on the signature.
405
+ return { index: () => indexNow() };
406
+ }
407
+ //# sourceMappingURL=library-search.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Mounting memory into the agent loop.
3
+ *
4
+ * `@deepwatch/dsh-memory` owns what a memory is and what may be done with
5
+ * one; this connects it to DSH — registering the tools with the real
6
+ * `defineTool`, and putting the compiled context into the system prompt.
7
+ *
8
+ * The dependency runs this way round on purpose. Memory is useful headless,
9
+ * and a memory package that could not be loaded without a tool runtime would
10
+ * not be — so it takes `defineTool` as an argument and this file supplies it.
11
+ *
12
+ * @module @deepwatch/dsh-tools/memory
13
+ */
14
+ import type { Context } from '@deepseek-ai/cordis';
15
+ /**
16
+ * Register the memory tools and the per-turn context section.
17
+ *
18
+ * The section is a function of the turn, not a fixed string: it is recompiled
19
+ * each time it is read, which is what makes a correction take effect on the
20
+ * very next turn rather than at the next restart.
21
+ */
22
+ export declare function applyMemory(ctx: Context): void;
23
+ //# sourceMappingURL=memory.d.ts.map
package/lib/memory.js ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Mounting memory into the agent loop.
3
+ *
4
+ * `@deepwatch/dsh-memory` owns what a memory is and what may be done with
5
+ * one; this connects it to DSH — registering the tools with the real
6
+ * `defineTool`, and putting the compiled context into the system prompt.
7
+ *
8
+ * The dependency runs this way round on purpose. Memory is useful headless,
9
+ * and a memory package that could not be loaded without a tool runtime would
10
+ * not be — so it takes `defineTool` as an argument and this file supplies it.
11
+ *
12
+ * @module @deepwatch/dsh-tools/memory
13
+ */
14
+ import { defineTool } from '@deepseek-ai/dsh-tools';
15
+ import { applyMemoryTools } from '@deepwatch/dsh-memory';
16
+ /**
17
+ * Read a service that may not be there, without asking Cordis for it.
18
+ *
19
+ * Cordis proxies every context property. Reading a name it knows as a
20
+ * service, from a plugin that did not declare it in `inject`, throws
21
+ * `cannot get property "x" without inject` -- and optional chaining cannot
22
+ * prevent that, because the throw happens on the access, before `?.` is
23
+ * evaluated. `host.identity?.userId` therefore threw on any profile without
24
+ * an identity provider, which is the stock web profile, and took down every
25
+ * turn that compiled the memory section.
26
+ *
27
+ * Declaring the names in `inject` is the wrong fix: `inject` is a required
28
+ * set in this Cordis, so the whole plugin would refuse to load rather than
29
+ * one scope field falling back.
30
+ *
31
+ * @param ctx - the Cordis context for this turn.
32
+ * @param name - the service to read.
33
+ * @returns the service, or undefined when it is absent or not injected.
34
+ */
35
+ function optionalService(ctx, name) {
36
+ try {
37
+ return ctx[name];
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /**
44
+ * Resolve the scope the current turn belongs to.
45
+ *
46
+ * Read from the Cordis context each time rather than captured once: a Host
47
+ * serves many sessions, and a scope resolved at plugin activation would pin
48
+ * every later turn to whichever session happened to be first — which is a
49
+ * cross-scope leak dressed up as a caching decision.
50
+ */
51
+ function resolveScope(ctx) {
52
+ const session = optionalService(ctx, 'session');
53
+ const identity = optionalService(ctx, 'identity');
54
+ const workspace = optionalService(ctx, 'workspace');
55
+ return {
56
+ // Falling back to 'local' rather than to an empty string keeps the scope
57
+ // key non-empty, so a record can never be created under a scope id that
58
+ // would match every other unset one.
59
+ userId: identity?.userId ?? 'local',
60
+ workspaceId: workspace?.id ?? session?.workspaceId ?? 'local',
61
+ projectId: workspace?.root ?? 'local',
62
+ sessionId: session?.id ?? 'local',
63
+ };
64
+ }
65
+ /**
66
+ * Register the memory tools and the per-turn context section.
67
+ *
68
+ * The section is a function of the turn, not a fixed string: it is recompiled
69
+ * each time it is read, which is what makes a correction take effect on the
70
+ * very next turn rather than at the next restart.
71
+ */
72
+ export function applyMemory(ctx) {
73
+ applyMemoryTools(ctx, {
74
+ scope: () => resolveScope(ctx),
75
+ // Widened once, here. `defineTool` infers argument and output types from a
76
+ // literal definition, and instantiating that machinery against a value
77
+ // typed `unknown` sends the checker into an unbounded recursion it reports
78
+ // as "excessive stack depth". The memory package hands over plain
79
+ // definitions and has no use for the inference anyway.
80
+ defineTool: defineTool,
81
+ });
82
+ const prompt = ctx.systemPrompt;
83
+ // Order 111 puts it directly after the memory guidance, so the rules about
84
+ // what memory *is* are read before the memories themselves.
85
+ prompt?.section({
86
+ name: 'memory:context',
87
+ order: 111,
88
+ get text() {
89
+ // Recompiled on read. Every included item is also recorded in the
90
+ // ledger with the reason it was included, so "Why remembered?" can
91
+ // answer for a turn that has already finished.
92
+ return ctx.watchMemory.render(resolveScope(ctx));
93
+ },
94
+ });
95
+ }
96
+ //# sourceMappingURL=memory.js.map