@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,107 @@
1
+ /**
2
+ * The Library index, and the only thing allowed to replace it.
3
+ *
4
+ * The host used to build its index once per process. Records written after
5
+ * start were invisible until somebody restarted the application, and the
6
+ * surface's rebuild control could not reach the host's index at all — so it
7
+ * said "Search again", which was honest and useless. This is the capability
8
+ * that makes it a real one.
9
+ *
10
+ * Four properties, and each exists because the obvious implementation gets it
11
+ * wrong.
12
+ *
13
+ * **A rebuild is an explicit operation.** Not a flag on a search. A search that
14
+ * might re-read the corpus is a search whose cost nobody can predict, and it
15
+ * leaves a caller no way to say "answer from what you have".
16
+ *
17
+ * **One rebuild at a time, and callers join it.** Two people pressing Refresh
18
+ * must not read the directory twice. A second caller waits on the first
19
+ * rebuild and receives its outcome, which is also why a repeated request id
20
+ * returns the recorded answer rather than starting anything.
21
+ *
22
+ * **Cancellation is by reference count.** A caller that stops waiting has
23
+ * withdrawn, not cancelled — the work may still be wanted by somebody else.
24
+ * The rebuild is abandoned when the last waiter leaves, and only then.
25
+ *
26
+ * **The swap is atomic and conditional.** The new index is built beside the
27
+ * one in service and installed only when it is complete and healthy. A failed
28
+ * or abandoned rebuild leaves the previous generation searchable, which is the
29
+ * difference between a refresh that did not work and a Library that broke.
30
+ *
31
+ * @module @deepwatch/dsh-tools/library-generations
32
+ */
33
+ import type { IndexableRecord, LibraryIndex } from '@deepwatch/dsh-library';
34
+ import type { LibraryIndexState } from '@deepwatch/dsh-contracts/query/wire';
35
+ /** One built index, and what is true about it. */
36
+ export interface IndexGeneration {
37
+ /** Increments only when a healthy rebuild was swapped into service. */
38
+ readonly generation: number;
39
+ readonly startedAt: string;
40
+ readonly completedAt: string | null;
41
+ readonly sourceCount: number;
42
+ readonly recordCount: number;
43
+ readonly indexState: LibraryIndexState;
44
+ /** By filename, never by path. */
45
+ readonly skipped: readonly string[];
46
+ }
47
+ /** What a refresh did. Every one of these leaves a searchable Library. */
48
+ export type RefreshOutcome = {
49
+ readonly kind: 'refreshed';
50
+ readonly index: IndexGeneration;
51
+ } | {
52
+ readonly kind: 'cancelled';
53
+ readonly index: IndexGeneration;
54
+ } | {
55
+ readonly kind: 'failed';
56
+ readonly reason: string;
57
+ readonly index: IndexGeneration;
58
+ };
59
+ /** What the service needs to build an index and to timestamp what it built. */
60
+ export interface GenerationsConfig {
61
+ readonly roots: readonly string[];
62
+ /** Injected in tests so a generation record is comparable across runs. */
63
+ readonly now?: () => string;
64
+ /** Injected in tests to exercise failure and slowness without a filesystem. */
65
+ readonly build?: (roots: readonly string[], signal: AbortSignal) => Promise<{
66
+ readonly index: LibraryIndex | null;
67
+ readonly skipped: readonly string[];
68
+ readonly sourceCount: number;
69
+ }>;
70
+ }
71
+ /** The index in service, and the one operation that may replace it. */
72
+ export declare class LibraryGenerations {
73
+ #private;
74
+ constructor(config: GenerationsConfig);
75
+ /**
76
+ * Index one record the Host made, now, without a rebuild.
77
+ *
78
+ * The failure this closes: the evaluation produced 76 tool actions and a
79
+ * Library with nothing in it, because nothing indexed a receipt until
80
+ * somebody pressed Refresh — and Refresh read roots that did not contain
81
+ * them yet.
82
+ */
83
+ addLive(record: IndexableRecord): void;
84
+ /** How many live records are being carried across rebuilds. */
85
+ liveCount(): number;
86
+ /**
87
+ * The index searches answer from, built on first use.
88
+ *
89
+ * Lazy rather than eager: a profile that never opens the Library should not
90
+ * pay for reading its evidence roots at boot.
91
+ */
92
+ index(): LibraryIndex;
93
+ /** What is in service. Building it first if nothing is. */
94
+ generation(): IndexGeneration;
95
+ /**
96
+ * Read the roots again and, if that succeeds, put the result into service.
97
+ *
98
+ * `requestId` makes the operation idempotent: a caller that retries after a
99
+ * dropped connection gets the answer its first attempt produced rather than
100
+ * a second read of the corpus. `signal` is that caller's withdrawal, not a
101
+ * cancellation of the work — see the note on reference counting above.
102
+ */
103
+ refresh(requestId: string, signal: AbortSignal): Promise<RefreshOutcome>;
104
+ /** Whether a rebuild is running. Reported to a surface as bounded progress. */
105
+ rebuilding(): boolean;
106
+ }
107
+ //# sourceMappingURL=library-generations.d.ts.map
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The Library index, and the only thing allowed to replace it.
3
+ *
4
+ * The host used to build its index once per process. Records written after
5
+ * start were invisible until somebody restarted the application, and the
6
+ * surface's rebuild control could not reach the host's index at all — so it
7
+ * said "Search again", which was honest and useless. This is the capability
8
+ * that makes it a real one.
9
+ *
10
+ * Four properties, and each exists because the obvious implementation gets it
11
+ * wrong.
12
+ *
13
+ * **A rebuild is an explicit operation.** Not a flag on a search. A search that
14
+ * might re-read the corpus is a search whose cost nobody can predict, and it
15
+ * leaves a caller no way to say "answer from what you have".
16
+ *
17
+ * **One rebuild at a time, and callers join it.** Two people pressing Refresh
18
+ * must not read the directory twice. A second caller waits on the first
19
+ * rebuild and receives its outcome, which is also why a repeated request id
20
+ * returns the recorded answer rather than starting anything.
21
+ *
22
+ * **Cancellation is by reference count.** A caller that stops waiting has
23
+ * withdrawn, not cancelled — the work may still be wanted by somebody else.
24
+ * The rebuild is abandoned when the last waiter leaves, and only then.
25
+ *
26
+ * **The swap is atomic and conditional.** The new index is built beside the
27
+ * one in service and installed only when it is complete and healthy. A failed
28
+ * or abandoned rebuild leaves the previous generation searchable, which is the
29
+ * difference between a refresh that did not work and a Library that broke.
30
+ *
31
+ * @module @deepwatch/dsh-tools/library-generations
32
+ */
33
+ import { buildIndex, buildIndexCancellable } from './library-search.js';
34
+ import { wireIndexState } from './read-plane.js';
35
+ /** How many settled request ids are remembered for idempotency. */
36
+ const REMEMBERED_REQUESTS = 64;
37
+ /** The index in service, and the one operation that may replace it. */
38
+ export class LibraryGenerations {
39
+ #config;
40
+ #current = null;
41
+ #inFlight = null;
42
+ #settled = new Map();
43
+ #nextGeneration = 1;
44
+ constructor(config) {
45
+ this.#config = config;
46
+ }
47
+ /**
48
+ * Records the Host produced while running, kept across rebuilds.
49
+ *
50
+ * An execution receipt is minted by this Host, in memory, seconds ago; a
51
+ * rebuild reads the evidence roots on disk. Without this, indexing a receipt
52
+ * and then pressing Refresh would lose it, and the Library would go from
53
+ * "searchable" to "empty" for no reason a person could see. Re-applied after
54
+ * every build, so Refresh converges on the same answer rather than racing
55
+ * whatever arrived while it ran.
56
+ */
57
+ #live = new Map();
58
+ /**
59
+ * Index one record the Host made, now, without a rebuild.
60
+ *
61
+ * The failure this closes: the evaluation produced 76 tool actions and a
62
+ * Library with nothing in it, because nothing indexed a receipt until
63
+ * somebody pressed Refresh — and Refresh read roots that did not contain
64
+ * them yet.
65
+ */
66
+ addLive(record) {
67
+ this.#live.set(record.recordId, record);
68
+ this.index().add(record);
69
+ }
70
+ /** How many live records are being carried across rebuilds. */
71
+ liveCount() {
72
+ return this.#live.size;
73
+ }
74
+ /** Re-apply the live records to a freshly built index. */
75
+ #applyLive(index) {
76
+ for (const record of this.#live.values())
77
+ index.add(record);
78
+ }
79
+ /** The clock, injectable so a test can assert on a generation record. */
80
+ #now() {
81
+ return this.#config.now?.() ?? new Date().toISOString();
82
+ }
83
+ /**
84
+ * The index searches answer from, built on first use.
85
+ *
86
+ * Lazy rather than eager: a profile that never opens the Library should not
87
+ * pay for reading its evidence roots at boot.
88
+ */
89
+ index() {
90
+ if (this.#current === null) {
91
+ const startedAt = this.#now();
92
+ const built = buildIndex(this.#config.roots);
93
+ this.#applyLive(built.index);
94
+ this.#current = {
95
+ index: built.index,
96
+ meta: this.#describe(this.#nextGeneration, startedAt, built.index, built.skipped),
97
+ };
98
+ this.#nextGeneration += 1;
99
+ }
100
+ return this.#current.index;
101
+ }
102
+ /** What is in service. Building it first if nothing is. */
103
+ generation() {
104
+ this.index();
105
+ // `index()` above installs it, so this cannot be null. Reading through a
106
+ // local keeps that obvious to the compiler as well as to a reader.
107
+ const current = this.#current;
108
+ if (current === null)
109
+ throw new Error('library: the index did not install');
110
+ return current.meta;
111
+ }
112
+ #describe(generation, startedAt, index, skipped) {
113
+ return {
114
+ generation,
115
+ startedAt,
116
+ completedAt: this.#now(),
117
+ sourceCount: this.#config.roots.length,
118
+ recordCount: index.size,
119
+ // The index's own word for its condition, mapped to the wire's. A
120
+ // rebuild that read nothing is empty, not ready: an index nobody has
121
+ // filled is not a complete answer to anything.
122
+ indexState: wireIndexState(index.health, index.size),
123
+ skipped,
124
+ };
125
+ }
126
+ /**
127
+ * Read the roots again and, if that succeeds, put the result into service.
128
+ *
129
+ * `requestId` makes the operation idempotent: a caller that retries after a
130
+ * dropped connection gets the answer its first attempt produced rather than
131
+ * a second read of the corpus. `signal` is that caller's withdrawal, not a
132
+ * cancellation of the work — see the note on reference counting above.
133
+ */
134
+ async refresh(requestId, signal) {
135
+ const remembered = this.#settled.get(requestId);
136
+ if (remembered !== undefined)
137
+ return remembered;
138
+ const flight = this.#inFlight ?? this.#start();
139
+ flight.waiters += 1;
140
+ const withdraw = () => {
141
+ flight.waiters -= 1;
142
+ // The last waiter leaving is what ends the work. While anybody is still
143
+ // waiting, one caller's deadline is not everybody's.
144
+ if (flight.waiters <= 0)
145
+ flight.controller.abort();
146
+ };
147
+ if (signal.aborted)
148
+ withdraw();
149
+ else
150
+ signal.addEventListener('abort', withdraw, { once: true });
151
+ try {
152
+ const outcome = await flight.promise;
153
+ this.#remember(requestId, outcome);
154
+ return outcome;
155
+ }
156
+ finally {
157
+ if (!signal.aborted)
158
+ signal.removeEventListener('abort', withdraw);
159
+ }
160
+ }
161
+ /** Whether a rebuild is running. Reported to a surface as bounded progress. */
162
+ rebuilding() {
163
+ return this.#inFlight !== null;
164
+ }
165
+ #remember(requestId, outcome) {
166
+ this.#settled.set(requestId, outcome);
167
+ // Bounded: a long-lived host must not accumulate one entry per refresh
168
+ // anybody has ever asked for. Oldest first, which is insertion order.
169
+ while (this.#settled.size > REMEMBERED_REQUESTS) {
170
+ const oldest = this.#settled.keys().next();
171
+ if (oldest.done === true)
172
+ break;
173
+ this.#settled.delete(oldest.value);
174
+ }
175
+ }
176
+ #start() {
177
+ const controller = new AbortController();
178
+ const startedAt = this.#now();
179
+ const build = this.#config.build ?? buildIndexCancellable;
180
+ // The generation in service before this ran, read now rather than after
181
+ // the await. Every outcome below reports it, so a caller always learns
182
+ // what it can still search.
183
+ const previous = this.generation();
184
+ // Declared before the body so the body can compare against it, and
185
+ // assigned immediately after. `run` reads it only in its `finally`, which
186
+ // cannot be reached before the first await.
187
+ let flight = null;
188
+ const run = async () => {
189
+ try {
190
+ const built = await build(this.#config.roots, controller.signal);
191
+ if (built.index === null)
192
+ return { kind: 'cancelled', index: previous };
193
+ // Before the generation is described, so its counts include them and a
194
+ // surface comparing generations is comparing the whole answer. A
195
+ // rebuild reads the roots; the receipts this Host minted while running
196
+ // are not there yet, and dropping them would take a searchable Library
197
+ // back to empty for no reason a person could see.
198
+ this.#applyLive(built.index);
199
+ const meta = this.#describe(this.#nextGeneration, startedAt, built.index, built.skipped);
200
+ this.#nextGeneration += 1;
201
+ // The swap. One assignment, after the new index is complete, so no
202
+ // search can ever observe a half-built one.
203
+ this.#current = { index: built.index, meta };
204
+ return { kind: 'refreshed', index: meta };
205
+ }
206
+ catch (cause) {
207
+ return {
208
+ kind: 'failed',
209
+ // The message and nothing else: a stack or an errno string would
210
+ // name the host's directories.
211
+ reason: cause instanceof Error ? cause.message : 'the rebuild failed',
212
+ index: previous,
213
+ };
214
+ }
215
+ finally {
216
+ // Only if this is still the current flight. A later refresh that has
217
+ // already started must not be cleared by an earlier one finishing.
218
+ if (this.#inFlight === flight)
219
+ this.#inFlight = null;
220
+ }
221
+ };
222
+ flight = { controller, waiters: 0, promise: run() };
223
+ this.#inFlight = flight;
224
+ return flight;
225
+ }
226
+ }
227
+ //# sourceMappingURL=library-generations.js.map
@@ -0,0 +1,143 @@
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 type { Context } from '@deepseek-ai/cordis';
32
+ import { LibraryIndex } from '@deepwatch/dsh-library';
33
+ import type { IndexableRecord } from '@deepwatch/dsh-library';
34
+ /** Where the index is allowed to read from. */
35
+ export interface LibrarySearchConfig {
36
+ /**
37
+ * Directories holding evidence records.
38
+ *
39
+ * Nothing outside these is read, whatever a filename claims. An empty list
40
+ * means the tool has nothing to index and says so rather than falling back
41
+ * to somewhere convenient.
42
+ */
43
+ readonly roots: readonly string[];
44
+ /**
45
+ * Who owns the index, when somebody does.
46
+ *
47
+ * The tool used to hold its own, which was fine while it was the only
48
+ * reader. It is not: the read plane answers the same corpus for the Library
49
+ * surface, and a refresh asked for by either has to be the same refresh.
50
+ * Two caches would drift inside one release and disagree about what the
51
+ * library contains — a person searching the UI and the agent searching the
52
+ * tool getting different answers to the same question.
53
+ */
54
+ readonly generations?: LibraryGenerationsLike;
55
+ }
56
+ /**
57
+ * The part of `LibraryGenerations` this module needs.
58
+ *
59
+ * Structural rather than an import of the class, because the class imports the
60
+ * builders from here and a direct edge would close a cycle between two modules
61
+ * that are one concern.
62
+ */
63
+ export interface LibraryGenerationsLike {
64
+ index(): LibraryIndex;
65
+ refresh(requestId: string, signal: AbortSignal): Promise<unknown>;
66
+ }
67
+ /**
68
+ * Read one JSON file into a record, or nothing.
69
+ *
70
+ * Deliberately total: a malformed file, an unreadable one, or one holding
71
+ * something that is not a record yields no record rather than throwing. One bad
72
+ * file must not stop the whole library being searchable — a corpus is exactly
73
+ * where a single malformed entry is most likely and least excusable as a
74
+ * failure mode.
75
+ */
76
+ /**
77
+ * Every string in a record, at any depth.
78
+ *
79
+ * Reading only the top level looked reasonable and was wrong: a citation's
80
+ * text, a check's detail and a revision's transcript all live one or two
81
+ * levels down, so a phrase plainly present in the file returned nothing.
82
+ *
83
+ * Bounded on purpose. A record is data, not a program, so a deeply nested or
84
+ * self-referential one costs a fixed amount of work rather than a stack
85
+ * overflow — and a malformed file is exactly where that would show up.
86
+ */
87
+ export declare function gatherText(value: unknown, depth?: number, seen?: Set<unknown>): string[];
88
+ /**
89
+ * The digest of a file's bytes.
90
+ *
91
+ * Bytes, not the decoded string. A file is what is on disk, and a record whose
92
+ * identity depended on how it happened to be decoded would change identity for
93
+ * a byte-order mark nobody typed.
94
+ */
95
+ export declare function contentDigest(bytes: Uint8Array | string): string;
96
+ export declare function recordFromFile(path: string, raw: string | Uint8Array): IndexableRecord | null;
97
+ /**
98
+ * Read every record under the configured roots.
99
+ *
100
+ * Returns what it managed to read plus what it refused, because a caller that
101
+ * cannot tell "there is nothing here" from "I was not allowed to look" cannot
102
+ * report either honestly.
103
+ */
104
+ export declare function collectRecords(roots: readonly string[]): {
105
+ readonly records: readonly IndexableRecord[];
106
+ readonly skipped: readonly string[];
107
+ };
108
+ /** Build a fresh index over the roots. Cheap enough to do on demand. */
109
+ export declare function buildIndex(roots: readonly string[]): {
110
+ readonly index: LibraryIndex;
111
+ readonly skipped: readonly string[];
112
+ };
113
+ /**
114
+ * Build an index, yielding often enough that a caller can stop it.
115
+ *
116
+ * The synchronous builder above is what a tool call uses: it is one pass over
117
+ * a directory and returning a promise would buy nothing. A refresh is
118
+ * different — it is a person waiting, it has a deadline, and it has to be
119
+ * abandonable — so this one checks the signal between files and hands the
120
+ * event loop back so the check can actually fire.
121
+ *
122
+ * It builds into a *new* index. Nothing in service is touched until the caller
123
+ * decides to swap, which is what makes a failed or abandoned rebuild leave the
124
+ * previous generation exactly as it was.
125
+ */
126
+ export declare function buildIndexCancellable(roots: readonly string[], signal: AbortSignal): Promise<{
127
+ readonly index: LibraryIndex | null;
128
+ readonly skipped: readonly string[];
129
+ readonly sourceCount: number;
130
+ }>;
131
+ /**
132
+ * Register the search tool, and hand back the index it holds.
133
+ *
134
+ * The accessor is returned rather than the index itself so the read plane sees
135
+ * a rebuild the moment it happens, without either side holding a reference to
136
+ * an object the other has replaced. One index answers both the agent's tool
137
+ * and the person's surface: two would drift inside a release and disagree
138
+ * about what the library contains.
139
+ */
140
+ export declare function applyLibrarySearch(ctx: Context, config: LibrarySearchConfig): {
141
+ readonly index: () => LibraryIndex;
142
+ };
143
+ //# sourceMappingURL=library-search.d.ts.map