@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
|
@@ -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
|
package/lib/memory.d.ts
ADDED
|
@@ -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
|