@deepwatch/dsh-library 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1882 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@deepwatch/dsh-library",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react_jsx_runtime = require("react/jsx-runtime");
8
+ let react = require("react");
9
+ //#region ../contracts/lib/presentation.js
10
+ /** The sentence to show when Watch Core supplied no reason of its own. */
11
+ const FALLBACK_REASON = {
12
+ VERIFIED: "Every required check passed against valid evidence.",
13
+ FAILED: "A required check failed.",
14
+ UNVERIFIED: "Nothing executable was checked, so nothing was established.",
15
+ INCONCLUSIVE: "The evidence conflicts, or a check could not be run.",
16
+ STALE: "The evidence no longer describes the current source.",
17
+ BLOCKED: "Policy or a missing dependency prevented verification."
18
+ };
19
+ /** Every verdict the taxonomy defines, for exhaustive validation. */
20
+ const VERDICTS = /* @__PURE__ */ new Set([
21
+ "VERIFIED",
22
+ "FAILED",
23
+ "UNVERIFIED",
24
+ "INCONCLUSIVE",
25
+ "STALE",
26
+ "BLOCKED"
27
+ ]);
28
+ /**
29
+ * Parse a tool result into a verdict.
30
+ *
31
+ * Returns null rather than guessing. A result this cannot read renders as a
32
+ * generic row, which is honest; inventing a verdict to fill a card would not
33
+ * be.
34
+ */
35
+ function parseVerdict(value) {
36
+ const record = asRecord(value);
37
+ if (record === null) return null;
38
+ const verdict = record["verdict"];
39
+ if (typeof verdict !== "string" || !VERDICTS.has(verdict)) return null;
40
+ const checks = Array.isArray(record["checks"]) ? record["checks"] : [];
41
+ const reason = record["reason"];
42
+ return {
43
+ verdict,
44
+ reason: typeof reason === "string" && reason !== "" ? reason : FALLBACK_REASON[verdict],
45
+ checks: checks.flatMap(parseCheck),
46
+ contractDigest: typeof record["contractDigest"] === "string" ? record["contractDigest"] : "",
47
+ assurance: typeof record["assurance"] === "string" ? record["assurance"] : null
48
+ };
49
+ }
50
+ function parseCheck(value) {
51
+ const record = asRecord(value);
52
+ if (record === null || typeof record["checkId"] !== "string") return [];
53
+ return [{
54
+ checkId: record["checkId"],
55
+ kind: typeof record["kind"] === "string" ? record["kind"] : "check",
56
+ description: typeof record["description"] === "string" ? record["description"] : null,
57
+ passed: typeof record["passed"] === "boolean" ? record["passed"] : null,
58
+ detail: typeof record["detail"] === "string" ? record["detail"] : null
59
+ }];
60
+ }
61
+ /** Narrow an unknown to a plain object, excluding null and arrays. */
62
+ function asRecord(value) {
63
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
64
+ return value;
65
+ }
66
+ //#endregion
67
+ //#region ../brand/lib/identity.js
68
+ /** Every status the product renders, and the tone it is permitted. */
69
+ const STATUS_TONE = {
70
+ VERIFIED: "success",
71
+ FAILED: "error",
72
+ UNVERIFIED: "caution",
73
+ INCONCLUSIVE: "caution",
74
+ STALE: "caution",
75
+ BLOCKED: "caution",
76
+ queued: "neutral",
77
+ running: "active",
78
+ completed: "info",
79
+ failed: "error",
80
+ cancelled: "neutral",
81
+ current: "neutral",
82
+ gap: "caution",
83
+ expired: "caution",
84
+ unavailable: "caution"
85
+ };
86
+ /**
87
+ * The tone one status is allowed.
88
+ *
89
+ * Anything unrecognized is `neutral`, never `success`. A new status added
90
+ * elsewhere and not registered here renders as unremarkable rather than
91
+ * accidentally as a win.
92
+ */
93
+ function toneFor(status) {
94
+ return STATUS_TONE[status] ?? "neutral";
95
+ }
96
+ /**
97
+ * The semantic token a tone maps to.
98
+ *
99
+ * Feature packages ask for a tone and get a CSS variable. They never write a
100
+ * hex value, which is what stops the palette from being re-invented slightly
101
+ * differently in every panel — and what makes a theme change one edit.
102
+ */
103
+ function tokenFor(tone) {
104
+ return `var(--watch-tone-${tone})`;
105
+ }
106
+ //#endregion
107
+ //#region ../brand/lib/mark.js
108
+ /**
109
+ * The Watch mark, inlined.
110
+ *
111
+ * Generated by `scripts/brand-assets.mjs` from `assets/watch-orca-master.png`.
112
+ * Do not edit by hand: the master is the brand source of truth and this is a
113
+ * mechanical derivation of it at 64px, which covers a 32px slot at 2x.
114
+ *
115
+ * @module @deepwatch/dsh-client-brand/mark
116
+ */
117
+ /** The orca, as a transparent PNG data URI. */
118
+ const WATCH_MARK_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAF+UlEQVR42u1Za4hUZRhezbxladcfSkmpuO7sfJf5Y5EFupDRDcK0nTnf934nJNDKrCTCROyHv6JCEaWyP9mPjESQiH4EW4iYREkZhGDQRSq7kGl7cc/7zpz23M+M67qrM7s6ngc+dvYwwznP8z7v5ftOS0uGDBkyZMiQIUOGDBnGGtKmFqaQCcDnpcF3pcFPBeAnTFNnUxPPdeIUYXC1NPi9AHSlIbdgk1sw5AqNfQJoeVMSz2saL4BWSoPHfcIhaV+A4O+XAqitKckLoFkc8LOIbFqAgqGKBNzFNU1szsgrWiqA/pRRtKvJe9feYwrHN2mRo3XSEFURtwMhpJ//eJgBTW468kx5+Y5bBZDrLWnKbsEuJ+R9AagiDN19+VVxRdcJQ23C0CIBtEQaXCxtWpgr0XxmcEauRNO5xt0+8ZC8tMPlfTaxCIcvD8LaJwzC4PsC8JeAFLnCJH+9diY0utxfVPGvx9GnkHxyzf8+0M5L28YaZ3ON2wXQ6eTBz7N0QjoWwE4LFX3PF2vfJUm8reRcxTSt54C93sNyTS5XPrmKF2EvdyOCMrZ5DUEztFDebzgQMaDVCyyacAn1a7yJa/zci1DwoOgWN1LvF99VevvR7a9U3L6/Trr9e7rKPR1PY39EPixq1bavWenrkQihUMcGlsUtGttWmLfoVq7xKA/Je6tgk/Nfr3vGHQT/nHadjjXUH5NJRz8iHre+8pCC+P9rOsI1PjBG5PFGrvFYWMSSXAZyV71a7vvtb7e/VoBvj1W6F60ip0oAU50OMiJvD+6KSKS4Zhg/5fYwwJtHuW/jHq7Q9ZZvf11VrLyBpbxiA/U8t6XcveaNcs8jL1Jfrd1FKqqxCzwBngj7v31u8rK2Zmj8lQPdOyrk5z/u5GPiis4ITQck0DtC02tC41bh93T8WgKekUNUfVlT5KpTYBDbwzlc4Kegvxym8MlRmNWxkyt0BshsYppuGKItTuGA9wuNOwXQKZkSYNBKH8366fG3tnsEXcW71ieBugP3hQ6MPgO90Fj7A84TgCtH8huucZrUuFZoPJE88NkCpNMjiXxM+ggr0VqmcI60aVx4QDKbK9wsvXOBpBNVmMKnGr5xuSDxLLxHRFHUNbZOpUVseX9ipB+ZRRaHc7c9ZuGjItWNJGCZa3y4YQIsKNFEpujBkQrRVnTGC40/i8TKZ7W6QvL5Bwn0bLs6/+6vvYgzuMZKdGIUCIwnOdAdjasFJZqTt/B1ATRtROmgcF1s19QJjwDqKhjaKgFfEkB3SXt4g868x3rGMY17Y/JRLQn2EIfaVQMnR2moPa+wSxi6cwQtdCrXeLxWBAH0EwOaOuLNV5HuixwVnyDFxdO/x8uNrQeGbuGA+4ShN+d3OrcN0wVLhcZyusL7+wgYefESQAuFRscrflUtM5kR+oRpYCqERXFc3nJWCsBTAvBtpil33t8AbpAp2wbRwm8u5P7tRWemMLRGGjzkCRFun5M5QdMHo7U52i8g2gHiQaZxbU7h3MGKpXdNAr7iRy4pfmUGJC/mGbjCgtD4Uap9hl2B+CicCdD6pB9HbQx78pZzgClnOwOn2FrC26uE0LhCGvq3ELqAA26rSzAUGqGDabQQ1IcPcyVcLA1uHkiJXdLgNqYczRRdU896MEtAuO0NT3K4pq4w4hOkTddK++wbDqTNTAm4XXoppPB43RypscMLgEy1x0H2GCeYxmV1E4Er3BFX5eAg48CwHVRyprR34qy6Hc+VcHLewv2pFKvaa8RvmAArOQufqddO8Xqhg7PA8Ka/X+jkeNHBANoRFcTIAYWaly0S6DS38CBX+FXrivL0ermASYN/hAJU+Bi8wsorbBWAFLgxHr9Phe2yWxr6WBpaxjVNakxH0DjX2xYXDHrn+ZtGXQBNG9NTYbhJWiJtmjTcKfPiRbCcCVw7nRywY9TfNBnaHXeAoBvt9eaVlisFPJwFCoH9dzbty9QhDmTeCvN/y1gV4bF2gMWVczQPdHXLlYjW5TQpV8SHWjJkyJAhQ4YMGTJkyJAhQ4YMGTIMH/8DqnZ5/XHc+csAAAAASUVORK5CYII=";
119
+ //#endregion
120
+ //#region ../workspace/lib/client/surface.js
121
+ const MODE_KICKER = {
122
+ Watch: "Trust layer",
123
+ Live: "Observation",
124
+ Memory: "Knowledge",
125
+ Library: "Evidence library",
126
+ Compare: "Change analysis"
127
+ };
128
+ /** Stable global class names; their rules live with the product theme. */
129
+ const C = {
130
+ root: "watch-mode-root",
131
+ hero: "watch-mode-hero",
132
+ markFrame: "watch-mode-mark-frame",
133
+ mark: "watch-mode-mark",
134
+ heroCopy: "watch-mode-hero-copy",
135
+ eyebrow: "watch-mode-eyebrow",
136
+ title: "watch-mode-title",
137
+ lead: "watch-mode-lead",
138
+ localBadge: "watch-mode-local-badge",
139
+ body: "watch-mode-body",
140
+ empty: "watch-empty",
141
+ emptyCopy: "watch-empty-copy",
142
+ sectionLabel: "watch-section-label",
143
+ emptyShows: "watch-empty-shows",
144
+ emptyWhy: "watch-empty-why",
145
+ nextBlock: "watch-next-block",
146
+ nextList: "watch-next-list",
147
+ panel: "watch-panel",
148
+ panelHeading: "watch-panel-heading",
149
+ facts: "watch-facts",
150
+ factRow: "watch-fact-row",
151
+ factKey: "watch-fact-key",
152
+ factValue: "watch-fact-value",
153
+ note: "watch-note",
154
+ noteMark: "watch-note-mark",
155
+ unavailable: "watch-unavailable",
156
+ unavailableHead: "watch-unavailable-head",
157
+ unavailableBadge: "watch-unavailable-badge",
158
+ unavailableBecause: "watch-unavailable-because",
159
+ requirements: "watch-requirements"
160
+ };
161
+ /** The frame: a title, one sentence of what this is, then the body. */
162
+ function ModeSurface({ title, lead, children }) {
163
+ return (0, react_jsx_runtime.jsxs)("div", {
164
+ className: C.root,
165
+ "data-watch-mode": title.toLowerCase(),
166
+ children: [(0, react_jsx_runtime.jsxs)("header", {
167
+ className: C.hero,
168
+ children: [
169
+ (0, react_jsx_runtime.jsx)("span", {
170
+ className: C.markFrame,
171
+ "aria-hidden": "true",
172
+ children: (0, react_jsx_runtime.jsx)("img", {
173
+ className: C.mark,
174
+ src: WATCH_MARK_PNG,
175
+ alt: ""
176
+ })
177
+ }),
178
+ (0, react_jsx_runtime.jsxs)("div", {
179
+ className: C.heroCopy,
180
+ children: [
181
+ (0, react_jsx_runtime.jsx)("span", {
182
+ className: C.eyebrow,
183
+ children: `DEEPWATCH / ${MODE_KICKER[title] ?? "Evidence workspace"}`
184
+ }),
185
+ (0, react_jsx_runtime.jsx)("h2", {
186
+ className: C.title,
187
+ children: title
188
+ }),
189
+ (0, react_jsx_runtime.jsx)("p", {
190
+ className: C.lead,
191
+ children: lead
192
+ })
193
+ ]
194
+ }),
195
+ (0, react_jsx_runtime.jsx)("span", {
196
+ className: C.localBadge,
197
+ children: "Local-first"
198
+ })
199
+ ]
200
+ }), (0, react_jsx_runtime.jsx)("div", {
201
+ className: C.body,
202
+ children
203
+ })]
204
+ });
205
+ }
206
+ /** A titled block of content. */
207
+ function Panel({ heading, children }) {
208
+ return (0, react_jsx_runtime.jsxs)("section", {
209
+ className: C.panel,
210
+ "data-watch-panel": "",
211
+ children: [heading === void 0 ? null : (0, react_jsx_runtime.jsx)("h3", {
212
+ className: C.panelHeading,
213
+ children: heading
214
+ }), children]
215
+ });
216
+ }
217
+ /** A key/value grid. Long values wrap rather than forcing the page sideways. */
218
+ function Facts({ rows }) {
219
+ return (0, react_jsx_runtime.jsx)("dl", {
220
+ className: C.facts,
221
+ children: rows.map(([label, value]) => (0, react_jsx_runtime.jsxs)("div", {
222
+ className: C.factRow,
223
+ children: [(0, react_jsx_runtime.jsx)("dt", {
224
+ className: C.factKey,
225
+ children: label
226
+ }), (0, react_jsx_runtime.jsx)("dd", {
227
+ className: C.factValue,
228
+ children: value
229
+ })]
230
+ }, label))
231
+ });
232
+ }
233
+ /**
234
+ * Read the JSON a Watch tool returned out of whatever DSH handed us.
235
+ *
236
+ * Returns null on anything unexpected — a running call, a failed one, a result
237
+ * that is not JSON, a shape we do not recognise. The caller then renders its
238
+ * empty state, which is the honest outcome: a surface that cannot read its
239
+ * input must not draw a card implying it did.
240
+ */
241
+ function readToolResult(value) {
242
+ if (typeof value !== "object" || value === null) return null;
243
+ const block = value;
244
+ if (!("kind" in block) || block.isError === true) return null;
245
+ if (!Array.isArray(block.content)) return null;
246
+ const text = block.content.filter((part) => typeof part === "object" && part !== null && part.type === "text" && typeof part.text === "string").map((part) => part.text).join("");
247
+ if (text === "") return null;
248
+ try {
249
+ return JSON.parse(text);
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+ /** The largest page anyone may ask for. */
255
+ const MAX_LIMIT = 200;
256
+ const DEFAULT_LIMIT = 25;
257
+ /** Han, Hiragana, Katakana — scripts written without spaces. */
258
+ const CJK = /[぀-ヿ㐀-䶿一-鿿]/u;
259
+ /**
260
+ * Split text into searchable tokens.
261
+ *
262
+ * Unicode-aware on purpose. Splitting on `[a-z0-9]+` would silently drop every
263
+ * Arabic, Chinese, Cyrillic and Greek record in the corpus — they would index
264
+ * as nothing and return nothing, and the failure would look like an empty
265
+ * library rather than a broken tokenizer.
266
+ *
267
+ * CJK has no spaces, so a run is emitted as its characters and its adjacent
268
+ * bigrams rather than whole. Keeping the run would make it a token only an
269
+ * exact repetition could match, and since every query term must be present,
270
+ * that run token would then fail a query whose characters are all indexed.
271
+ *
272
+ * Case folding is `toLowerCase`, which is a no-op for scripts without case and
273
+ * correct for those with it. Diacritics are deliberately *kept*: the original
274
+ * text is the evidence, and folding "عَلَم" into "علم" would make a citation
275
+ * resolve to something the source does not say.
276
+ *
277
+ * `\p{M}` is in the continuation class for the same reason, and its absence was
278
+ * a real bug. Arabic harakat are Unicode *Mark*, not *Letter*, so a class of
279
+ * letters and numbers alone breaks at every vowel sign: vocalised "عَلَم"
280
+ * tokenized as three separate consonants, and no query could ever match it.
281
+ */
282
+ function tokenize(text) {
283
+ if (text === "") return [];
284
+ const tokens = [];
285
+ for (const match of text.toLowerCase().matchAll(/[\p{L}\p{N}][\p{L}\p{N}\p{M}_'-]*/gu)) {
286
+ const token = match[0];
287
+ if (CJK.test(token) && token.length > 1) {
288
+ for (const character of token) tokens.push(character);
289
+ for (let at = 0; at + 1 < token.length; at += 1) tokens.push(token.slice(at, at + 2));
290
+ continue;
291
+ }
292
+ tokens.push(token);
293
+ }
294
+ return tokens;
295
+ }
296
+ /** A stable digest over the index's own contents, for corruption detection. */
297
+ function digestOf(documents, postings) {
298
+ let hash = 2166136261;
299
+ const parts = [...documents.map((document) => `${document.recordId}@${document.revisionId}`).sort(), ...[...postings.keys()].sort().map((token) => `${token}:${String(postings.get(token)?.size ?? 0)}`)];
300
+ for (const part of parts) for (let index = 0; index < part.length; index += 1) {
301
+ hash ^= part.charCodeAt(index);
302
+ hash = Math.imul(hash, 16777619) >>> 0;
303
+ }
304
+ return hash.toString(16).padStart(8, "0");
305
+ }
306
+ /** The local, derived, rebuildable search index. */
307
+ var LibraryIndex = class LibraryIndex {
308
+ #documents = /* @__PURE__ */ new Map();
309
+ #postings = /* @__PURE__ */ new Map();
310
+ #health = "empty";
311
+ #builtAt = null;
312
+ #pending = /* @__PURE__ */ new Set();
313
+ #notes = [];
314
+ get health() {
315
+ return this.#health;
316
+ }
317
+ /**
318
+ * One record by id, or undefined.
319
+ *
320
+ * A direct lookup rather than a search. The read plane's `get` was briefly
321
+ * implemented as a search with `limit: 1` whose single result was then
322
+ * compared to the requested id, which reports every record except the
323
+ * first-ranked one as missing. `#documents` is already keyed by record id;
324
+ * this is the accessor that key exists for.
325
+ */
326
+ record(recordId) {
327
+ return this.#documents.get(recordId);
328
+ }
329
+ get size() {
330
+ return this.#documents.size;
331
+ }
332
+ /** Ids indexing began but did not finish, so a resumed run knows where it was. */
333
+ get pending() {
334
+ return [...this.#pending];
335
+ }
336
+ get diagnostics() {
337
+ return [...this.#notes];
338
+ }
339
+ /**
340
+ * Add or replace one record.
341
+ *
342
+ * Idempotent by construction: the record's existing postings are removed
343
+ * before the new ones are written, so re-indexing changed text cannot leave
344
+ * the old words behind still pointing at the document. Indexing identical
345
+ * content twice is a no-op, which is what makes an interrupted run safe to
346
+ * simply repeat.
347
+ */
348
+ add(input) {
349
+ const record = normalizeRecord(input);
350
+ if (record.recordId === "") return;
351
+ this.#pending.add(record.recordId);
352
+ this.#removePostings(record.recordId);
353
+ this.#documents.set(record.recordId, record);
354
+ const haystack = [
355
+ record.title,
356
+ record.text,
357
+ record.source ?? "",
358
+ record.runId ?? "",
359
+ record.verdict ?? "",
360
+ ...record.tags
361
+ ].join(" ");
362
+ for (const token of tokenize(haystack)) {
363
+ let postings = this.#postings.get(token);
364
+ if (postings === void 0) {
365
+ postings = /* @__PURE__ */ new Set();
366
+ this.#postings.set(token, postings);
367
+ }
368
+ postings.add(record.recordId);
369
+ }
370
+ this.#pending.delete(record.recordId);
371
+ this.#health = this.#documents.size === 0 ? "empty" : "ready";
372
+ this.#builtAt = (/* @__PURE__ */ new Date()).toISOString();
373
+ }
374
+ /** Index many, reporting progress so an interrupted run can resume. */
375
+ addAll(records, signal) {
376
+ this.#health = "indexing";
377
+ let done = 0;
378
+ for (const record of records) {
379
+ if (signal?.aborted ?? false) {
380
+ this.#health = this.#documents.size === 0 ? "empty" : "stale";
381
+ this.#notes.push(`indexing cancelled after ${String(done)} of ${String(records.length)}`);
382
+ return done;
383
+ }
384
+ this.add(record);
385
+ done += 1;
386
+ }
387
+ this.#health = this.#documents.size === 0 ? "empty" : "ready";
388
+ return done;
389
+ }
390
+ /**
391
+ * Forget a record entirely.
392
+ *
393
+ * A deleted record must not survive as a search hit. Removing the document
394
+ * without its postings would leave a token pointing at an id that no longer
395
+ * resolves — a result that cannot be opened, which is worse than no result.
396
+ */
397
+ remove(recordId) {
398
+ if (!this.#documents.has(recordId)) return false;
399
+ this.#removePostings(recordId);
400
+ this.#documents.delete(recordId);
401
+ this.#pending.delete(recordId);
402
+ if (this.#documents.size === 0) this.#health = "empty";
403
+ return true;
404
+ }
405
+ /** Throw everything away. The point of a derived index. */
406
+ clear() {
407
+ this.#documents.clear();
408
+ this.#postings.clear();
409
+ this.#pending.clear();
410
+ this.#notes = [];
411
+ this.#health = "empty";
412
+ this.#builtAt = null;
413
+ }
414
+ /** Mark the index as behind the store, without discarding what it has. */
415
+ markStale(reason) {
416
+ if (this.#health === "ready") this.#health = "stale";
417
+ this.#notes.push(reason);
418
+ }
419
+ #removePostings(recordId) {
420
+ for (const [token, ids] of this.#postings) if (ids.delete(recordId) && ids.size === 0) this.#postings.delete(token);
421
+ }
422
+ /**
423
+ * Search.
424
+ *
425
+ * Every term must be present — an AND over tokens. OR would return a page of
426
+ * documents sharing one common word, which reads as the search being broken.
427
+ *
428
+ * The query string is never interpreted: it is tokenized exactly like indexed
429
+ * text, so a regular expression, a glob, a SQL fragment or a path traversal
430
+ * in the box is simply a set of words that will not be found. There is no
431
+ * escaping to get wrong because there is nothing to escape into.
432
+ */
433
+ search(query) {
434
+ const notes = [];
435
+ const limit = Math.min(Math.max(1, query.limit ?? DEFAULT_LIMIT), 200);
436
+ const offset = Math.max(0, query.offset ?? 0);
437
+ const cancelled = () => query.signal?.aborted ?? false;
438
+ if (this.#health === "corrupt") return {
439
+ results: [],
440
+ total: 0,
441
+ offset,
442
+ limit,
443
+ health: "corrupt",
444
+ notes: ["The index is unreadable and must be rebuilt.", ...this.#notes]
445
+ };
446
+ if (cancelled()) return {
447
+ results: [],
448
+ total: 0,
449
+ offset,
450
+ limit,
451
+ health: this.#health,
452
+ notes: ["Search cancelled."]
453
+ };
454
+ const terms = tokenize(query.text);
455
+ let candidates;
456
+ if (terms.length === 0) {
457
+ candidates = new Set(this.#documents.keys());
458
+ notes.push("No search terms: showing everything the filters allow.");
459
+ } else candidates = this.#intersect(terms);
460
+ const matched = [];
461
+ for (const recordId of candidates) {
462
+ if (cancelled()) return {
463
+ results: [],
464
+ total: 0,
465
+ offset,
466
+ limit,
467
+ health: this.#health,
468
+ notes: ["Search cancelled."]
469
+ };
470
+ const record = this.#documents.get(recordId);
471
+ if (record === void 0) continue;
472
+ if (!passesFilters(record, query)) continue;
473
+ matched.push({
474
+ record,
475
+ score: scoreOf(record, terms)
476
+ });
477
+ }
478
+ sortMatches(matched, query.sort ?? "relevance");
479
+ const total = matched.length;
480
+ const page = matched.slice(offset, offset + limit);
481
+ if (total > offset + page.length) notes.push(`Showing ${String(offset + 1)}–${String(offset + page.length)} of ${String(total)}.`);
482
+ if (this.#health === "stale") notes.push("The index is behind the store; some recent records may be missing.");
483
+ if (this.#health === "indexing") notes.push("Indexing is still running; this answer is partial.");
484
+ return {
485
+ results: page.map(({ record, score }) => toResult(record, terms, score)),
486
+ total,
487
+ offset,
488
+ limit,
489
+ health: this.#health,
490
+ notes
491
+ };
492
+ }
493
+ #intersect(terms) {
494
+ let smallest = null;
495
+ for (const term of terms) {
496
+ const postings = this.#postings.get(term);
497
+ if (postings === void 0) return /* @__PURE__ */ new Set();
498
+ if (smallest === null || postings.size < smallest.size) smallest = postings;
499
+ }
500
+ if (smallest === null) return /* @__PURE__ */ new Set();
501
+ const out = /* @__PURE__ */ new Set();
502
+ for (const candidate of smallest) if (terms.every((term) => this.#postings.get(term)?.has(candidate) === true)) out.add(candidate);
503
+ return out;
504
+ }
505
+ /** Serialise, with a digest so a later load can tell it was not damaged. */
506
+ serialize() {
507
+ const documents = [...this.#documents.values()];
508
+ return {
509
+ version: 1,
510
+ digest: digestOf(documents, this.#postings),
511
+ builtAt: this.#builtAt ?? (/* @__PURE__ */ new Date()).toISOString(),
512
+ documents,
513
+ postings: Object.fromEntries([...this.#postings.entries()].map(([token, ids]) => [token, [...ids].sort()]))
514
+ };
515
+ }
516
+ /**
517
+ * Load a serialised index, refusing anything it cannot trust.
518
+ *
519
+ * A wrong version, a failed digest or a malformed body all produce a
520
+ * `corrupt` index rather than a partial one. Half-loading is the failure that
521
+ * looks like success: queries answer, and they answer wrongly.
522
+ */
523
+ static load(value) {
524
+ const index = new LibraryIndex();
525
+ const fail = (reason) => {
526
+ index.#health = "corrupt";
527
+ index.#notes.push(reason);
528
+ return index;
529
+ };
530
+ if (typeof value !== "object" || value === null) return fail("The stored index is not an object.");
531
+ const stored = value;
532
+ if (stored.version !== 1) return fail(`Index version ${String(stored.version)} cannot be read by this build (expects ${String(1)}).`);
533
+ if (!Array.isArray(stored.documents) || typeof stored.postings !== "object" || stored.postings === null) return fail("The stored index is missing its documents or postings.");
534
+ const documents = [];
535
+ for (const document of stored.documents) {
536
+ if (typeof document !== "object" || document === null) return fail("A stored document is malformed.");
537
+ const record = document;
538
+ if (typeof record.recordId !== "string" || record.recordId === "") return fail("A stored document has no id.");
539
+ documents.push(normalizeRecord(record));
540
+ }
541
+ const postings = /* @__PURE__ */ new Map();
542
+ for (const [token, ids] of Object.entries(stored.postings)) {
543
+ if (!Array.isArray(ids)) return fail(`Postings for "${token}" are malformed.`);
544
+ postings.set(token, new Set(ids.filter((id) => typeof id === "string")));
545
+ }
546
+ if (digestOf(documents, postings) !== stored.digest) return fail("The stored index failed its own digest — it has been modified or truncated.");
547
+ for (const document of documents) index.#documents.set(document.recordId, document);
548
+ index.#postings = postings;
549
+ index.#builtAt = typeof stored.builtAt === "string" ? stored.builtAt : null;
550
+ index.#health = documents.length === 0 ? "empty" : "ready";
551
+ return index;
552
+ }
553
+ };
554
+ /** Fill in what a stored record may be missing, without inventing content. */
555
+ function normalizeRecord(record) {
556
+ return {
557
+ recordId: record.recordId ?? "",
558
+ revisionId: typeof record.revisionId === "string" ? record.revisionId : "",
559
+ title: typeof record.title === "string" ? record.title : "",
560
+ kind: record.kind ?? "document",
561
+ text: typeof record.text === "string" ? record.text : "",
562
+ source: typeof record.source === "string" ? record.source : null,
563
+ runId: typeof record.runId === "string" ? record.runId : null,
564
+ observedAt: typeof record.observedAt === "string" ? record.observedAt : null,
565
+ verdict: typeof record.verdict === "string" ? record.verdict : null,
566
+ tags: Array.isArray(record.tags) ? record.tags.filter((tag) => typeof tag === "string") : [],
567
+ evidenceIds: Array.isArray(record.evidenceIds) ? record.evidenceIds.filter((id) => typeof id === "string") : []
568
+ };
569
+ }
570
+ function passesFilters(record, query) {
571
+ if (query.kinds !== void 0 && query.kinds.length > 0 && !query.kinds.includes(record.kind)) return false;
572
+ if (query.runIds !== void 0 && query.runIds.length > 0) {
573
+ if (record.runId === null || !query.runIds.includes(record.runId)) return false;
574
+ }
575
+ if (query.verdicts !== void 0 && query.verdicts.length > 0) {
576
+ if (record.verdict === null || !query.verdicts.includes(record.verdict)) return false;
577
+ }
578
+ if (query.sources !== void 0 && query.sources.length > 0) {
579
+ if (record.source === null || !query.sources.includes(record.source)) return false;
580
+ }
581
+ if (query.tags !== void 0 && query.tags.length > 0) {
582
+ if (!query.tags.some((tag) => record.tags.includes(tag))) return false;
583
+ }
584
+ if (query.from !== void 0 && (record.observedAt === null || record.observedAt < query.from)) return false;
585
+ if (query.to !== void 0 && (record.observedAt === null || record.observedAt > query.to)) return false;
586
+ return true;
587
+ }
588
+ /**
589
+ * Score a match.
590
+ *
591
+ * Term frequency with a title bonus, and nothing more. A more elaborate
592
+ * relevance model would be guessing, and this one is at least explicable: a
593
+ * record whose title contains your words outranks one that merely mentions
594
+ * them, and more mentions outrank fewer.
595
+ */
596
+ function scoreOf(record, terms) {
597
+ if (terms.length === 0) return 0;
598
+ const title = new Set(tokenize(record.title));
599
+ const body = tokenize(record.text);
600
+ let score = 0;
601
+ for (const term of terms) {
602
+ if (title.has(term)) score += 5;
603
+ score += body.filter((token) => token === term).length;
604
+ }
605
+ return score;
606
+ }
607
+ function sortMatches(matched, sort) {
608
+ const time = (record) => record.observedAt ?? "";
609
+ matched.sort((left, right) => {
610
+ if (sort === "newest") return time(right.record).localeCompare(time(left.record));
611
+ if (sort === "oldest") return time(left.record).localeCompare(time(right.record));
612
+ if (sort === "title") return left.record.title.localeCompare(right.record.title);
613
+ const byScore = right.score - left.score;
614
+ return byScore !== 0 ? byScore : left.record.recordId.localeCompare(right.record.recordId);
615
+ });
616
+ }
617
+ /**
618
+ * Build the snippet a person reads, around the first match.
619
+ *
620
+ * The text is returned verbatim and un-escaped — it is evidence, and altering
621
+ * it here would make the snippet disagree with the source. Rendering is the
622
+ * caller's job, and React escapes by default; this deliberately produces no
623
+ * markup for a renderer to trust.
624
+ */
625
+ function snippetFor(text, terms, radius = 90) {
626
+ if (text === "" || terms.length === 0) return text.slice(0, radius * 2);
627
+ const lower = text.toLowerCase();
628
+ let at = -1;
629
+ for (const term of terms) {
630
+ const found = lower.indexOf(term);
631
+ if (found >= 0 && (at < 0 || found < at)) at = found;
632
+ }
633
+ if (at < 0) return text.slice(0, radius * 2);
634
+ const start = Math.max(0, at - radius);
635
+ const end = Math.min(text.length, at + radius);
636
+ return (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
637
+ }
638
+ function toResult(record, terms, score) {
639
+ const hit = {
640
+ sourceId: record.recordId,
641
+ sourceRevisionId: record.revisionId,
642
+ range: null,
643
+ text: snippetFor(record.text === "" ? record.title : record.text, terms),
644
+ path: "lexical",
645
+ score,
646
+ evidenceIds: record.evidenceIds
647
+ };
648
+ return {
649
+ sourceId: record.recordId,
650
+ title: record.title,
651
+ kind: record.kind,
652
+ hits: [hit],
653
+ current: true
654
+ };
655
+ }
656
+ //#endregion
657
+ //#region src/client/read-plane.ts
658
+ /**
659
+ * Correlation ids, from a counter rather than from randomness.
660
+ *
661
+ * The host refuses a `requestId` that is not an identifier, and a counter
662
+ * produces one by construction. It is also what makes a failing request
663
+ * quotable: `library-7` names a call somebody can find twice.
664
+ */
665
+ let sequence = 0;
666
+ /** The next correlation id for a Library read. */
667
+ function nextRequestId() {
668
+ sequence += 1;
669
+ return `library-${String(sequence)}`;
670
+ }
671
+ /** An answer that produced no rows, and says why. */
672
+ function nothing(note, health = "stale") {
673
+ return {
674
+ rows: [],
675
+ total: 0,
676
+ health,
677
+ generation: 0,
678
+ notes: [note],
679
+ pageable: false
680
+ };
681
+ }
682
+ /** The host's own vocabulary for index condition, in the surface's terms. */
683
+ function healthOf(state) {
684
+ return state === "rebuilding" ? "indexing" : state;
685
+ }
686
+ /** One wire record as a row. */
687
+ function rowOf(record) {
688
+ return {
689
+ key: `${record.recordId}@${record.revisionId}`,
690
+ recordId: record.recordId,
691
+ title: record.title,
692
+ kind: record.modality,
693
+ snippets: [],
694
+ evidenceCount: record.evidenceIds.length,
695
+ current: record.current
696
+ };
697
+ }
698
+ /**
699
+ * Ask the host, and turn whatever comes back into something renderable.
700
+ *
701
+ * Every outcome is an answer the surface shows rather than an exception it
702
+ * swallows. A refusal, an elapsed deadline and an expired cursor are different
703
+ * facts, and a person acts differently on each, so each keeps its own sentence.
704
+ */
705
+ async function readLibraryPage(reads, query, signal) {
706
+ const answer = await reads.librarySearch({
707
+ protocol: 1,
708
+ requestId: nextRequestId(),
709
+ query: query.text,
710
+ modalities: query.modality === "" ? [] : [query.modality],
711
+ limit: query.limit,
712
+ cursor: null,
713
+ deadlineMs: query.deadlineMs
714
+ }, signal);
715
+ if (!answer.ok) return nothing(`The Library host did not answer: ${answer.error.message}`, "corrupt");
716
+ const value = answer.value;
717
+ switch (value.outcome) {
718
+ case "page": {
719
+ const notes = value.records.length < value.total ? [`Showing ${String(value.records.length)} of ${String(value.total)} matches; the host answered with one page and offered no cursor.`] : [];
720
+ return {
721
+ rows: value.records.map(rowOf),
722
+ total: value.total,
723
+ health: healthOf(value.indexState),
724
+ generation: value.generation,
725
+ notes,
726
+ pageable: value.nextCursor !== null
727
+ };
728
+ }
729
+ case "rejected": return nothing(`The host refused the request (${value.reason}${value.field === null ? "" : ` at ${value.field}`}).`);
730
+ case "deadline_exceeded": return nothing(`The host did not answer within ${String(value.deadlineMs)}ms. Try a narrower query.`);
731
+ case "cursor_expired": return nothing("That page is no longer held by the host. Search again.");
732
+ }
733
+ }
734
+ /** A local index answer as the same view model, so the surface renders one shape. */
735
+ function fromIndex(result) {
736
+ return {
737
+ rows: result.results.map((entry) => ({
738
+ key: entry.sourceId,
739
+ recordId: entry.sourceId,
740
+ title: entry.title,
741
+ kind: entry.kind,
742
+ snippets: entry.hits.map((hit) => hit.text),
743
+ evidenceCount: entry.hits[0]?.evidenceIds.length ?? 0,
744
+ current: entry.current
745
+ })),
746
+ total: result.total,
747
+ health: result.health,
748
+ generation: 0,
749
+ notes: result.notes,
750
+ pageable: true
751
+ };
752
+ }
753
+ /**
754
+ * Ask the host to read its roots again.
755
+ *
756
+ * Every outcome is rendered, and none of them is an exception. A refusal, an
757
+ * elapsed deadline, an abandoned rebuild and a failed one are four different
758
+ * facts; so is a rebuild that succeeded and found nothing new. Reporting any
759
+ * of them as "refreshed" would be a control that lies about what it did.
760
+ */
761
+ async function refreshLibrary(reads, deadlineMs, signal) {
762
+ const answer = await reads.libraryRefresh({
763
+ protocol: 1,
764
+ requestId: nextRequestId(),
765
+ deadlineMs
766
+ }, signal);
767
+ if (!answer.ok) return failedRefresh(`The Library host did not answer: ${answer.error.message}`);
768
+ const value = answer.value;
769
+ switch (value.outcome) {
770
+ case "refreshed": return {
771
+ refreshed: true,
772
+ generation: value.index.generation,
773
+ recordCount: value.index.recordCount,
774
+ note: value.skipped.length === 0 ? "" : `${String(value.skipped.length)} file(s) were not readable: ` + value.skipped.slice(0, 3).join("; "),
775
+ failed: false
776
+ };
777
+ case "refresh_cancelled": return {
778
+ refreshed: false,
779
+ generation: value.index.generation,
780
+ recordCount: value.index.recordCount,
781
+ note: "The refresh was abandoned. The Library is unchanged.",
782
+ failed: false
783
+ };
784
+ case "refresh_failed": return {
785
+ refreshed: false,
786
+ generation: value.index.generation,
787
+ recordCount: value.index.recordCount,
788
+ note: `The refresh failed: ${value.reason}. The previous index is still searchable.`,
789
+ failed: true
790
+ };
791
+ case "rejected": return failedRefresh(`The host refused the refresh (${value.reason}).`);
792
+ case "deadline_exceeded": return failedRefresh(`The refresh did not finish within ${String(value.deadlineMs)}ms. It may still be running on the host.`);
793
+ }
794
+ }
795
+ /** A refresh that produced no generation, and why. */
796
+ function failedRefresh(note) {
797
+ return {
798
+ refreshed: false,
799
+ generation: 0,
800
+ recordCount: 0,
801
+ note,
802
+ failed: true
803
+ };
804
+ }
805
+ //#endregion
806
+ //#region src/client/search-view.tsx
807
+ const PAGE = 10;
808
+ /**
809
+ * How long the surface will wait for the host before it must show something.
810
+ *
811
+ * Shorter than the host's own ceiling on purpose: the host clamps to 30s, and a
812
+ * search box that can appear frozen for half a minute is a search box people
813
+ * stop trusting. The host is told this number, so an answer it cannot produce
814
+ * in time comes back as `deadline_exceeded` — a sentence on the screen — rather
815
+ * than as a request nobody ever cancelled.
816
+ */
817
+ const DEADLINE_MS = 1e4;
818
+ /**
819
+ * How long the surface will wait for a rebuild.
820
+ *
821
+ * Longer than a search, because it is one: reading a corpus is work a person
822
+ * has asked for and expects to take a moment. Still bounded, and still the
823
+ * number the host is told, so an overrun comes back as an answer rather than
824
+ * as a control that never settles.
825
+ */
826
+ const REFRESH_DEADLINE_MS = 6e4;
827
+ const S = {
828
+ root: {
829
+ display: "flex",
830
+ flexDirection: "column",
831
+ gap: "14px",
832
+ height: "100%",
833
+ minHeight: 0
834
+ },
835
+ bar: {
836
+ display: "flex",
837
+ gap: "10px",
838
+ flexWrap: "wrap",
839
+ alignItems: "flex-end"
840
+ },
841
+ field: {
842
+ display: "flex",
843
+ flexDirection: "column",
844
+ gap: "4px",
845
+ flex: "1 1 260px",
846
+ minWidth: 0
847
+ },
848
+ label: {
849
+ fontSize: "11px",
850
+ fontWeight: 600,
851
+ letterSpacing: ".05em",
852
+ textTransform: "uppercase",
853
+ color: "var(--dsw-alias-label-tertiary)"
854
+ },
855
+ input: {
856
+ background: "var(--dsw-alias-bg-layer-2)",
857
+ border: "1px solid color-mix(in srgb, var(--watch-accent) 12%, var(--dsw-alias-border-l2))",
858
+ borderRadius: "10px",
859
+ padding: "9px 11px",
860
+ fontSize: "13px",
861
+ color: "inherit",
862
+ font: "inherit",
863
+ minWidth: 0,
864
+ width: "100%"
865
+ },
866
+ select: {
867
+ background: "var(--dsw-alias-bg-layer-2)",
868
+ border: "1px solid color-mix(in srgb, var(--watch-accent) 12%, var(--dsw-alias-border-l2))",
869
+ borderRadius: "10px",
870
+ padding: "9px 11px",
871
+ fontSize: "13px",
872
+ color: "inherit"
873
+ },
874
+ button: {
875
+ background: "transparent",
876
+ border: "1px solid var(--dsw-alias-border-l2)",
877
+ borderRadius: "10px",
878
+ padding: "9px 13px",
879
+ fontSize: "13px",
880
+ color: "inherit",
881
+ cursor: "pointer"
882
+ },
883
+ status: {
884
+ fontSize: "12px",
885
+ color: "var(--dsw-alias-label-tertiary)",
886
+ margin: 0
887
+ },
888
+ list: {
889
+ display: "flex",
890
+ flexDirection: "column",
891
+ gap: "8px",
892
+ margin: 0,
893
+ padding: 0,
894
+ listStyle: "none"
895
+ },
896
+ hit: {
897
+ border: "1px solid color-mix(in srgb, var(--watch-accent) 9%, var(--dsw-alias-border-l2))",
898
+ borderRadius: "14px",
899
+ padding: "14px 16px",
900
+ background: "linear-gradient(145deg, color-mix(in srgb, var(--watch-accent) 3%, var(--dsw-alias-bg-layer-2)), var(--dsw-alias-bg-base))",
901
+ boxShadow: "0 8px 24px color-mix(in srgb, black 7%, transparent)"
902
+ },
903
+ title: {
904
+ fontSize: "13.5px",
905
+ fontWeight: 600,
906
+ margin: 0
907
+ },
908
+ snippet: {
909
+ fontSize: "12.5px",
910
+ lineHeight: 1.6,
911
+ margin: "6px 0 0",
912
+ color: "var(--dsw-alias-label-secondary)",
913
+ wordBreak: "break-word"
914
+ },
915
+ meta: {
916
+ fontSize: "11.5px",
917
+ color: "var(--dsw-alias-label-tertiary)",
918
+ marginTop: "6px",
919
+ display: "flex",
920
+ gap: "10px",
921
+ flexWrap: "wrap"
922
+ }
923
+ };
924
+ /** What the index says about itself, in words a person can act on. */
925
+ const HEALTH = {
926
+ empty: {
927
+ says: "Nothing indexed yet.",
928
+ tone: "var(--watch-tone-neutral)"
929
+ },
930
+ ready: {
931
+ says: "Index ready.",
932
+ tone: "var(--watch-tone-active)"
933
+ },
934
+ indexing: {
935
+ says: "Indexing — results are partial.",
936
+ tone: "var(--watch-tone-caution)"
937
+ },
938
+ stale: {
939
+ says: "Index is behind the store.",
940
+ tone: "var(--watch-tone-caution)"
941
+ },
942
+ corrupt: {
943
+ says: "Index unreadable. Rebuild required.",
944
+ tone: "var(--watch-tone-error)"
945
+ }
946
+ };
947
+ const KINDS = [
948
+ "video",
949
+ "audio",
950
+ "page",
951
+ "stream",
952
+ "document",
953
+ "screen_capture"
954
+ ];
955
+ /**
956
+ * Highlight matches without building markup.
957
+ *
958
+ * The snippet is evidence, so it is never altered and never handed to a
959
+ * renderer as HTML. Splitting into plain segments and marking them with React
960
+ * elements keeps escaping the renderer's job, which is the only place it is
961
+ * reliably done.
962
+ */
963
+ function Highlighted({ text, terms }) {
964
+ if (terms.length === 0 || text === "") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: text });
965
+ const pattern = terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).filter((term) => term !== "").join("|");
966
+ if (pattern === "") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: text });
967
+ const parts = text.split(new RegExp(`(${pattern})`, "giu"));
968
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: parts.map((part, index) => terms.includes(part.toLowerCase()) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("mark", {
969
+ style: {
970
+ background: "var(--watch-wash-active)",
971
+ color: "inherit"
972
+ },
973
+ children: part
974
+ }, `${part}-${String(index)}`) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: part }, `${part}-${String(index)}`)) });
975
+ }
976
+ /** The Library search workflow: query, filter, sort, page, rebuild. */
977
+ function LibrarySearch({ records = [], index: injected, reads }) {
978
+ const queryId = (0, react.useId)();
979
+ const kindId = (0, react.useId)();
980
+ const verdictId = (0, react.useId)();
981
+ const sortId = (0, react.useId)();
982
+ const [text, setText] = (0, react.useState)("");
983
+ const [kind, setKind] = (0, react.useState)("");
984
+ const [verdict, setVerdict] = (0, react.useState)("");
985
+ const [sort, setSort] = (0, react.useState)("relevance");
986
+ const [offset, setOffset] = (0, react.useState)(0);
987
+ const [generation, setGeneration] = (0, react.useState)(0);
988
+ const [state, setState] = (0, react.useState)(null);
989
+ const [refreshing, setRefreshing] = (0, react.useState)(false);
990
+ const [refreshed, setRefreshed] = (0, react.useState)(null);
991
+ const index = (0, react.useMemo)(() => {
992
+ if (injected !== void 0) return injected;
993
+ const built = new LibraryIndex();
994
+ built.addAll(records);
995
+ return built;
996
+ }, [
997
+ injected,
998
+ records,
999
+ generation
1000
+ ]);
1001
+ const inFlight = (0, react.useRef)(null);
1002
+ const run = (0, react.useCallback)((nextOffset) => {
1003
+ inFlight.current?.abort();
1004
+ const controller = new AbortController();
1005
+ inFlight.current = controller;
1006
+ setOffset(nextOffset);
1007
+ if (reads === void 0) {
1008
+ setState(fromIndex(index.search({
1009
+ text,
1010
+ ...kind === "" ? {} : { kinds: [kind] },
1011
+ ...verdict === "" ? {} : { verdicts: [verdict] },
1012
+ sort,
1013
+ offset: nextOffset,
1014
+ limit: PAGE,
1015
+ signal: controller.signal
1016
+ })));
1017
+ return;
1018
+ }
1019
+ readLibraryPage(reads, {
1020
+ text,
1021
+ modality: kind,
1022
+ limit: PAGE,
1023
+ deadlineMs: DEADLINE_MS
1024
+ }, controller.signal).then((next) => {
1025
+ if (!controller.signal.aborted) setState(next);
1026
+ });
1027
+ }, [
1028
+ reads,
1029
+ index,
1030
+ text,
1031
+ kind,
1032
+ verdict,
1033
+ sort
1034
+ ]);
1035
+ (0, react.useEffect)(() => {
1036
+ run(0);
1037
+ }, [run]);
1038
+ (0, react.useEffect)(() => () => {
1039
+ inFlight.current?.abort();
1040
+ }, []);
1041
+ /**
1042
+ * Ask the host to read its roots again, then search the result.
1043
+ *
1044
+ * Two steps rather than one, and deliberately in that order: the refresh
1045
+ * reports what the host now holds, and the search that follows is what puts
1046
+ * it on the screen. Collapsing them would leave the count and the rows
1047
+ * describing two different generations.
1048
+ */
1049
+ const refresh = (0, react.useCallback)(() => {
1050
+ if (reads === void 0) {
1051
+ setGeneration((value) => value + 1);
1052
+ return;
1053
+ }
1054
+ if (refreshing) return;
1055
+ setRefreshing(true);
1056
+ setRefreshed(null);
1057
+ const controller = new AbortController();
1058
+ refreshLibrary(reads, REFRESH_DEADLINE_MS, controller.signal).then((next) => {
1059
+ setRefreshed(next);
1060
+ setRefreshing(false);
1061
+ if (next.refreshed) setGeneration((value) => value + 1);
1062
+ });
1063
+ }, [reads, refreshing]);
1064
+ const terms = (0, react.useMemo)(() => tokenize(text), [text]);
1065
+ const health = HEALTH[state?.health ?? (reads === void 0 ? index.health : "empty")] ?? {
1066
+ says: "Index state unknown.",
1067
+ tone: "var(--watch-tone-neutral)"
1068
+ };
1069
+ const rows = state?.rows ?? [];
1070
+ const total = state?.total ?? 0;
1071
+ const shown = rows.length;
1072
+ const page = Math.floor(offset / PAGE) + 1;
1073
+ const pages = state?.pageable === true ? Math.max(1, Math.ceil(total / PAGE)) : 1;
1074
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1075
+ style: S.root,
1076
+ children: [
1077
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
1078
+ style: S.bar,
1079
+ role: "search",
1080
+ onSubmit: (event) => {
1081
+ event.preventDefault();
1082
+ run(0);
1083
+ },
1084
+ children: [
1085
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1086
+ style: S.field,
1087
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
1088
+ htmlFor: queryId,
1089
+ style: S.label,
1090
+ children: "Search evidence"
1091
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1092
+ id: queryId,
1093
+ style: S.input,
1094
+ type: "search",
1095
+ value: text,
1096
+ placeholder: "Words in a transcript, a title, a run…",
1097
+ onChange: (event) => {
1098
+ setText(event.target.value);
1099
+ }
1100
+ })]
1101
+ }),
1102
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1103
+ style: S.field,
1104
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
1105
+ htmlFor: kindId,
1106
+ style: S.label,
1107
+ children: "Type"
1108
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1109
+ id: kindId,
1110
+ style: S.select,
1111
+ value: kind,
1112
+ onChange: (event) => {
1113
+ setKind(event.target.value);
1114
+ },
1115
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1116
+ value: "",
1117
+ children: "Any type"
1118
+ }), KINDS.map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1119
+ value,
1120
+ children: value.replace("_", " ")
1121
+ }, value))]
1122
+ })]
1123
+ }),
1124
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1125
+ style: S.field,
1126
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
1127
+ htmlFor: verdictId,
1128
+ style: S.label,
1129
+ children: "Verification"
1130
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1131
+ id: verdictId,
1132
+ style: S.select,
1133
+ value: verdict,
1134
+ disabled: reads !== void 0,
1135
+ onChange: (event) => {
1136
+ setVerdict(event.target.value);
1137
+ },
1138
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1139
+ value: "",
1140
+ children: "Any state"
1141
+ }), [
1142
+ "VERIFIED",
1143
+ "FAILED",
1144
+ "UNVERIFIED",
1145
+ "INCONCLUSIVE"
1146
+ ].map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1147
+ value,
1148
+ children: value
1149
+ }, value))]
1150
+ })]
1151
+ }),
1152
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1153
+ style: S.field,
1154
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
1155
+ htmlFor: sortId,
1156
+ style: S.label,
1157
+ children: "Sort"
1158
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1159
+ id: sortId,
1160
+ style: S.select,
1161
+ value: sort,
1162
+ disabled: reads !== void 0,
1163
+ onChange: (event) => {
1164
+ setSort(event.target.value);
1165
+ },
1166
+ children: [
1167
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1168
+ value: "relevance",
1169
+ children: "Relevance"
1170
+ }),
1171
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1172
+ value: "newest",
1173
+ children: "Newest first"
1174
+ }),
1175
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1176
+ value: "oldest",
1177
+ children: "Oldest first"
1178
+ }),
1179
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1180
+ value: "title",
1181
+ children: "Title"
1182
+ })
1183
+ ]
1184
+ })]
1185
+ }),
1186
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1187
+ type: "submit",
1188
+ style: S.button,
1189
+ children: "Search"
1190
+ }),
1191
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1192
+ type: "button",
1193
+ style: S.button,
1194
+ disabled: refreshing,
1195
+ onClick: refresh,
1196
+ children: refreshing ? "Refreshing…" : reads === void 0 ? "Rebuild index" : "Refresh library"
1197
+ })
1198
+ ]
1199
+ }),
1200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1201
+ style: {
1202
+ ...S.status,
1203
+ color: health.tone
1204
+ },
1205
+ children: [
1206
+ health.says,
1207
+ " ",
1208
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1209
+ style: { color: "var(--dsw-alias-label-tertiary)" },
1210
+ children: reads === void 0 ? `${String(index.size)} record(s) indexed on this machine.` : "Answered by this workspace’s own host."
1211
+ })
1212
+ ]
1213
+ }),
1214
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1215
+ style: S.status,
1216
+ role: "status",
1217
+ "aria-live": "polite",
1218
+ children: total === 0 ? terms.length === 0 ? "No records to list." : `No matches for “${text}”.` : `${String(total)} match${total === 1 ? "" : "es"}, showing ${String(shown)} (page ${String(page)} of ${String(pages)}).`
1219
+ }),
1220
+ refreshing ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1221
+ style: S.status,
1222
+ role: "status",
1223
+ "aria-live": "polite",
1224
+ children: "Reading the library again. The results below are the previous index until it finishes."
1225
+ }) : null,
1226
+ refreshed === null || refreshing ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1227
+ style: {
1228
+ ...S.status,
1229
+ color: refreshed.failed ? "var(--watch-tone-error)" : "var(--dsw-alias-label-tertiary)"
1230
+ },
1231
+ role: "status",
1232
+ "aria-live": "polite",
1233
+ children: [refreshed.refreshed ? `Library refreshed: ${String(refreshed.recordCount)} record(s), generation ${String(refreshed.generation)}.` : refreshed.note, refreshed.refreshed && refreshed.note !== "" ? ` ${refreshed.note}` : ""]
1234
+ }),
1235
+ (state?.notes ?? []).map((note) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1236
+ style: S.status,
1237
+ children: note
1238
+ }, note)),
1239
+ total === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1240
+ style: {
1241
+ ...S.hit,
1242
+ borderStyle: "dashed"
1243
+ },
1244
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1245
+ style: {
1246
+ ...S.snippet,
1247
+ margin: 0
1248
+ },
1249
+ children: reads === void 0 && index.size === 0 ? "Nothing has been indexed yet. Evidence appears here once the workspace has recorded some — then this searches it locally, with no service and no model." : "Nothing matched. Every word has to appear in a record; try fewer words, or clear the filters."
1250
+ })
1251
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1252
+ style: S.list,
1253
+ children: rows.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1254
+ style: S.hit,
1255
+ children: [
1256
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
1257
+ style: S.title,
1258
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Highlighted, {
1259
+ text: entry.title,
1260
+ terms
1261
+ })
1262
+ }),
1263
+ entry.snippets.map((snippet, at) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1264
+ style: S.snippet,
1265
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Highlighted, {
1266
+ text: snippet,
1267
+ terms
1268
+ })
1269
+ }, `${entry.key}-${String(at)}`)),
1270
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1271
+ style: S.meta,
1272
+ children: [
1273
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: entry.kind }),
1274
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1275
+ "data-watch-ltr": true,
1276
+ children: entry.recordId
1277
+ }),
1278
+ entry.evidenceCount > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1279
+ "data-watch-ltr": true,
1280
+ children: `${String(entry.evidenceCount)} evidence ref(s)`
1281
+ }) : null
1282
+ ]
1283
+ })
1284
+ ]
1285
+ }, entry.key))
1286
+ }),
1287
+ pages > 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("nav", {
1288
+ style: {
1289
+ display: "flex",
1290
+ gap: "8px"
1291
+ },
1292
+ "aria-label": "Search results pages",
1293
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1294
+ type: "button",
1295
+ style: S.button,
1296
+ disabled: offset === 0,
1297
+ onClick: () => {
1298
+ run(Math.max(0, offset - PAGE));
1299
+ },
1300
+ children: "Previous"
1301
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1302
+ type: "button",
1303
+ style: S.button,
1304
+ disabled: offset + PAGE >= total,
1305
+ onClick: () => {
1306
+ run(Math.min(offset + PAGE, Math.max(0, total - 1)));
1307
+ },
1308
+ children: "Next"
1309
+ })]
1310
+ }) : null
1311
+ ]
1312
+ });
1313
+ }
1314
+ //#endregion
1315
+ //#region src/client/library-mode.tsx
1316
+ /** The Library mode: everything recorded, and searchable. */
1317
+ function LibraryModeView({ inspect, records = [], reads } = {}) {
1318
+ const selected = parseVerdict(readToolResult(inspect));
1319
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ModeSurface, {
1320
+ title: "Library",
1321
+ lead: reads === void 0 ? "Every source and every piece of evidence this workspace has recorded. Search runs on this machine — no service, no model, nothing leaves it." : "Every source and every piece of evidence this workspace has recorded. Search runs on this workspace’s own host — no service, no model, nothing leaves the machine it runs on.",
1322
+ children: [selected === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Panel, {
1323
+ heading: "Selected record",
1324
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Facts, { rows: [
1325
+ ["Verdict", selected.verdict],
1326
+ ["Reason", selected.reason],
1327
+ ["Checks", String(selected.checks.length)]
1328
+ ] })
1329
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LibrarySearch, {
1330
+ records,
1331
+ reads
1332
+ })]
1333
+ });
1334
+ }
1335
+ //#endregion
1336
+ //#region src/sources.ts
1337
+ /**
1338
+ * The current revision of a source.
1339
+ *
1340
+ * Highest revision number, not most recently observed — a re-index of an old
1341
+ * revision must not become "current" because it happened last.
1342
+ */
1343
+ function currentRevision(source) {
1344
+ let best = null;
1345
+ for (const revision of source.revisions) if (best === null || revision.revision > best.revision) best = revision;
1346
+ return best;
1347
+ }
1348
+ /** Find one revision by id, wherever it sits in the history. */
1349
+ function findRevision(source, sourceRevisionId) {
1350
+ return source.revisions.find((revision) => revision.sourceRevisionId === sourceRevisionId) ?? null;
1351
+ }
1352
+ /**
1353
+ * Whether a source revision is still the one a fresh observation would produce.
1354
+ *
1355
+ * Separated from freshness below because they are different questions: this is
1356
+ * about the *source*, and freshness is about one piece of evidence taken from
1357
+ * it. A source can be current while a specific observation from it is stale,
1358
+ * when the observation covers a range the new revision no longer contains.
1359
+ */
1360
+ function isCurrentRevision(source, sourceRevisionId) {
1361
+ return currentRevision(source)?.sourceRevisionId === sourceRevisionId;
1362
+ }
1363
+ /**
1364
+ * Freshness of one evidence record, given what the Library now holds.
1365
+ *
1366
+ * The rules, in order:
1367
+ *
1368
+ * - Evidence whose source the Library does not hold is `unavailable`. Not
1369
+ * `expired` — nobody knows whether it expired; it simply cannot be checked.
1370
+ * - Evidence against the current revision keeps whatever freshness the engine
1371
+ * assigned it, including `gap`. Freshness is not the Library's to upgrade.
1372
+ * - Evidence against a superseded revision is `stale`. It still resolves; it
1373
+ * no longer describes the source.
1374
+ *
1375
+ * Note what this function never returns: `current` for something it was not
1376
+ * already told was current. A Library that could promote evidence to fresh
1377
+ * would be a Library that re-validates by assertion.
1378
+ */
1379
+ function freshnessOf(evidence, sources) {
1380
+ const owner = sources.find((source) => source.revisions.some((revision) => revision.sourceRevisionId === evidence.sourceRevisionId));
1381
+ if (owner === void 0) return "unavailable";
1382
+ if (!isCurrentRevision(owner, evidence.sourceRevisionId)) return "stale";
1383
+ return evidence.freshness;
1384
+ }
1385
+ /**
1386
+ * Whether an evidence id can still be opened.
1387
+ *
1388
+ * Always true when the Library holds its revision, whatever the freshness. The
1389
+ * function exists to make that a stated guarantee rather than an accident of
1390
+ * whichever query happens to run: a stale citation that stopped opening would
1391
+ * turn every old receipt into a dead link.
1392
+ */
1393
+ function isAddressable(evidence, sources) {
1394
+ return sources.some((source) => source.revisions.some((revision) => revision.sourceRevisionId === evidence.sourceRevisionId));
1395
+ }
1396
+ /**
1397
+ * Resolve an evidence record to a place in the Library.
1398
+ *
1399
+ * Returns null only when the revision is not held. Everything else resolves,
1400
+ * including evidence from four revisions ago — with `supersededBy` naming what
1401
+ * replaced it, so the surface can offer "look at the same moment in the
1402
+ * current revision" without silently doing it.
1403
+ */
1404
+ function locate(evidence, sources) {
1405
+ for (const source of sources) {
1406
+ const revision = findRevision(source, evidence.sourceRevisionId);
1407
+ if (revision === null) continue;
1408
+ const current = currentRevision(source);
1409
+ return {
1410
+ sourceId: source.sourceId,
1411
+ sourceRevisionId: revision.sourceRevisionId,
1412
+ revision: revision.revision,
1413
+ range: evidence.temporalRange,
1414
+ freshness: freshnessOf(evidence, sources),
1415
+ supersededBy: current === null || current.sourceRevisionId === revision.sourceRevisionId ? null : current.sourceRevisionId
1416
+ };
1417
+ }
1418
+ return null;
1419
+ }
1420
+ /**
1421
+ * Record a new revision of a source.
1422
+ *
1423
+ * Old revisions are kept, and their index state is marked `stale` rather than
1424
+ * removed. That is the mechanism behind every "old evidence still opens"
1425
+ * guarantee above — there is no code path that discards a revision, so there is
1426
+ * no code path that could orphan a citation.
1427
+ */
1428
+ function withRevision(source, revision) {
1429
+ const superseded = source.revisions.filter((entry) => entry.sourceRevisionId !== revision.sourceRevisionId).map((entry) => entry.revision < revision.revision && entry.indexState === "indexed" ? {
1430
+ ...entry,
1431
+ indexState: "stale"
1432
+ } : entry);
1433
+ return {
1434
+ ...source,
1435
+ revisions: [...superseded, revision].sort((left, right) => left.revision - right.revision)
1436
+ };
1437
+ }
1438
+ //#endregion
1439
+ //#region src/search.ts
1440
+ /**
1441
+ * Decide the retrieval path.
1442
+ *
1443
+ * Semantic-only is a real state and is reported as one rather than silently
1444
+ * treated as "search works". A library where exact-phrase search is
1445
+ * unavailable behaves very differently from one where it is not, and a user
1446
+ * searching for an error code needs to know which they are in.
1447
+ */
1448
+ function searchPlan(capabilities) {
1449
+ if (capabilities.lexical && capabilities.semantic) return {
1450
+ path: "both",
1451
+ explanation: "Hybrid search: exact matches and meaning-based matches, marked separately.",
1452
+ degradedBecause: "",
1453
+ fix: ""
1454
+ };
1455
+ if (capabilities.lexical) return {
1456
+ path: "lexical",
1457
+ explanation: "Exact matching only. A paraphrase of what was said will not be found.",
1458
+ degradedBecause: "No embeddings role is bound, so semantic retrieval is unavailable.",
1459
+ fix: "Bind an embeddings role in Settings to search by meaning as well."
1460
+ };
1461
+ if (capabilities.semantic) return {
1462
+ path: "semantic",
1463
+ explanation: "Meaning-based matching only. An exact phrase may rank below a paraphrase.",
1464
+ degradedBecause: "The lexical index is unavailable.",
1465
+ fix: "Re-index the library to restore exact matching."
1466
+ };
1467
+ return {
1468
+ path: "none",
1469
+ explanation: "Search is unavailable.",
1470
+ degradedBecause: "Neither the lexical index nor an embeddings role is available.",
1471
+ fix: "Index a source, or bind an embeddings role in Settings."
1472
+ };
1473
+ }
1474
+ /** Count values, dropping the empty ones. */
1475
+ function tally(values) {
1476
+ const counts = /* @__PURE__ */ new Map();
1477
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
1478
+ return [...counts.entries()].map(([value, count]) => ({
1479
+ value,
1480
+ count
1481
+ })).sort((left, right) => {
1482
+ const byCount = right.count - left.count;
1483
+ return byCount !== 0 ? byCount : left.value.localeCompare(right.value);
1484
+ });
1485
+ }
1486
+ /**
1487
+ * Compute facets over a result set.
1488
+ *
1489
+ * Only values that actually occur. A facet list generated from the schema
1490
+ * rather than from the results offers filters that return nothing, and a
1491
+ * filter that returns nothing is indistinguishable from a broken one.
1492
+ */
1493
+ function facetsFor(results, sources) {
1494
+ const byId = new Map(sources.map((source) => [source.sourceId, source]));
1495
+ const kinds = [];
1496
+ const indexStates = [];
1497
+ const collections = [];
1498
+ const scripts = [];
1499
+ const paths = [];
1500
+ for (const result of results) {
1501
+ kinds.push(result.kind);
1502
+ const source = byId.get(result.sourceId);
1503
+ if (source !== void 0) {
1504
+ for (const collection of source.collections) collections.push(collection);
1505
+ for (const revision of source.revisions) {
1506
+ if (!result.hits.some((hit) => hit.sourceRevisionId === revision.sourceRevisionId)) continue;
1507
+ indexStates.push(revision.indexState);
1508
+ for (const script of revision.scripts) scripts.push(script);
1509
+ }
1510
+ }
1511
+ for (const hit of result.hits) paths.push(hit.path);
1512
+ }
1513
+ return {
1514
+ kind: tally(kinds),
1515
+ indexState: tally(indexStates),
1516
+ collection: tally(collections),
1517
+ script: tally(scripts),
1518
+ path: tally(paths)
1519
+ };
1520
+ }
1521
+ /** Apply filters to a result set. Pure; the engine does the retrieval. */
1522
+ function applyFilters(results, sources, filters) {
1523
+ const byId = new Map(sources.map((source) => [source.sourceId, source]));
1524
+ return results.filter((result) => {
1525
+ if (filters.kinds !== void 0 && !filters.kinds.includes(result.kind)) return false;
1526
+ if (filters.currentOnly === true && !result.current) return false;
1527
+ const source = byId.get(result.sourceId);
1528
+ if (filters.collections !== void 0) {
1529
+ if (source === void 0) return false;
1530
+ if (!filters.collections.some((collection) => source.collections.includes(collection))) return false;
1531
+ }
1532
+ if (filters.indexStates !== void 0) {
1533
+ if (source === void 0) return false;
1534
+ if (!source.revisions.filter((revision) => result.hits.some((hit) => hit.sourceRevisionId === revision.sourceRevisionId)).map((revision) => revision.indexState).some((state) => filters.indexStates?.includes(state) === true)) return false;
1535
+ }
1536
+ if (filters.scripts !== void 0) {
1537
+ if (source === void 0) return false;
1538
+ const present = new Set(source.revisions.flatMap((revision) => revision.scripts));
1539
+ if (!filters.scripts.some((script) => present.has(script))) return false;
1540
+ }
1541
+ return true;
1542
+ });
1543
+ }
1544
+ /**
1545
+ * Order results for display.
1546
+ *
1547
+ * Within a source, hits are ordered by path and then by time — not by score
1548
+ * across paths, because the scores are not comparable. Across sources, the
1549
+ * source with the strongest lexical evidence leads, because an exact match is
1550
+ * the strongest claim search can make.
1551
+ */
1552
+ function rankResults(results) {
1553
+ const lexicalWeight = (result) => result.hits.filter((hit) => hit.path === "lexical" || hit.path === "both").length;
1554
+ return [...results].sort((left, right) => {
1555
+ const byLexical = lexicalWeight(right) - lexicalWeight(left);
1556
+ if (byLexical !== 0) return byLexical;
1557
+ const byHits = right.hits.length - left.hits.length;
1558
+ if (byHits !== 0) return byHits;
1559
+ return left.sourceId.localeCompare(right.sourceId);
1560
+ });
1561
+ }
1562
+ /**
1563
+ * One line above the results, stating what was searched and how.
1564
+ *
1565
+ * Always says the path. "12 results" alone invites the reading that the library
1566
+ * was searched thoroughly, which may not be true.
1567
+ */
1568
+ function describeSearch(plan, results) {
1569
+ const hits = results.reduce((total, result) => total + result.hits.length, 0);
1570
+ const count = `${String(hits)} hit(s) in ${String(results.length)} source(s)`;
1571
+ return plan.degradedBecause === "" ? `${count} · ${plan.explanation}` : `${count} · ${plan.explanation} ${plan.degradedBecause}`;
1572
+ }
1573
+ //#endregion
1574
+ //#region src/client/components.tsx
1575
+ /** The glyph half of a freshness state, so colour is never the only signal. */
1576
+ const FRESHNESS_GLYPH = {
1577
+ current: "●",
1578
+ stale: "⌛",
1579
+ gap: "⌇",
1580
+ expired: "⊘",
1581
+ unavailable: "?"
1582
+ };
1583
+ /** Freshness as glyph, word and tone. */
1584
+ function FreshnessBadge({ freshness }) {
1585
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1586
+ "data-watch-freshness": freshness,
1587
+ style: { color: tokenFor(toneFor(freshness)) },
1588
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1589
+ "aria-hidden": "true",
1590
+ children: FRESHNESS_GLYPH[freshness]
1591
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: ` ${freshness}` })]
1592
+ });
1593
+ }
1594
+ /**
1595
+ * A source's revisions, newest last.
1596
+ *
1597
+ * Every revision is listed, including superseded ones, and every one is
1598
+ * openable. A history that showed only the current revision would make old
1599
+ * evidence unreachable through the interface even though it remains
1600
+ * addressable underneath, which is the same failure with extra steps.
1601
+ */
1602
+ function RevisionHistory({ source, onOpen }) {
1603
+ const current = currentRevision(source);
1604
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ol", {
1605
+ "data-watch-revisions": source.sourceId,
1606
+ style: {
1607
+ listStyle: "none",
1608
+ margin: 0,
1609
+ padding: 0
1610
+ },
1611
+ children: source.revisions.map((revision) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
1612
+ "data-watch-revision": revision.sourceRevisionId,
1613
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1614
+ type: "button",
1615
+ "data-watch-index-state": revision.indexState,
1616
+ "aria-current": current?.sourceRevisionId === revision.sourceRevisionId ? "true" : void 0,
1617
+ onClick: () => {
1618
+ onOpen(revision);
1619
+ },
1620
+ style: {
1621
+ font: "inherit",
1622
+ color: "inherit",
1623
+ background: "none",
1624
+ border: "none",
1625
+ cursor: "pointer"
1626
+ },
1627
+ children: [
1628
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1629
+ dir: "ltr",
1630
+ children: `r${String(revision.revision)}`
1631
+ }),
1632
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: ` ${revision.indexState}` }),
1633
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("time", {
1634
+ dateTime: revision.observedAt,
1635
+ children: ` ${revision.observedAt}`
1636
+ }),
1637
+ current?.sourceRevisionId === revision.sourceRevisionId && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: " · current" })
1638
+ ]
1639
+ }), revision.indexError !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1640
+ "data-watch-index-error": "",
1641
+ children: ` ${revision.indexError}`
1642
+ })]
1643
+ }, revision.sourceRevisionId))
1644
+ });
1645
+ }
1646
+ /**
1647
+ * One hit.
1648
+ *
1649
+ * The retrieval path is on the row, not in a legend. A person reading a
1650
+ * semantic hit needs to know it is a semantic hit at the moment they read it,
1651
+ * because that is what decides whether they should check it.
1652
+ */
1653
+ function SearchHitRow({ hit, freshness, onOpen }) {
1654
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
1655
+ "data-watch-hit": hit.sourceRevisionId,
1656
+ "data-watch-path": hit.path,
1657
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1658
+ type: "button",
1659
+ onClick: () => {
1660
+ onOpen(hit);
1661
+ },
1662
+ style: {
1663
+ font: "inherit",
1664
+ color: "inherit",
1665
+ background: "none",
1666
+ border: "none",
1667
+ cursor: "pointer",
1668
+ textAlign: "start"
1669
+ },
1670
+ children: [
1671
+ hit.range !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1672
+ dir: "ltr",
1673
+ style: { fontVariantNumeric: "tabular-nums" },
1674
+ children: `${String(Math.floor(hit.range.startMs / 1e3))}s `
1675
+ }),
1676
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1677
+ dir: "auto",
1678
+ children: hit.text
1679
+ }),
1680
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1681
+ "data-watch-hit-path": hit.path,
1682
+ children: ` (${hit.path})`
1683
+ }),
1684
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FreshnessBadge, { freshness })
1685
+ ]
1686
+ })
1687
+ });
1688
+ }
1689
+ /** The facet rail. Only values that actually occur are offered. */
1690
+ function FacetPanel({ facets, onFilter }) {
1691
+ const groups = [
1692
+ ["kind", facets.kind],
1693
+ ["indexState", facets.indexState],
1694
+ ["collection", facets.collection],
1695
+ ["script", facets.script],
1696
+ ["path", facets.path]
1697
+ ];
1698
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("aside", {
1699
+ "data-watch-facets": "",
1700
+ "aria-label": "Filters",
1701
+ children: groups.map(([name, values]) => values.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1702
+ "data-watch-facet": name,
1703
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1704
+ style: {
1705
+ font: "inherit",
1706
+ fontSize: "11px"
1707
+ },
1708
+ children: name
1709
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1710
+ style: {
1711
+ listStyle: "none",
1712
+ margin: 0,
1713
+ padding: 0
1714
+ },
1715
+ children: values.map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1716
+ type: "button",
1717
+ "data-watch-facet-value": value.value,
1718
+ onClick: () => {
1719
+ onFilter(name, value.value);
1720
+ },
1721
+ style: {
1722
+ font: "inherit",
1723
+ color: "inherit",
1724
+ background: "none",
1725
+ border: "none",
1726
+ cursor: "pointer"
1727
+ },
1728
+ children: `${value.value} (${String(value.count)})`
1729
+ }) }, value.value))
1730
+ })]
1731
+ }, name))
1732
+ });
1733
+ }
1734
+ /** The Library mode body. */
1735
+ function LibrarySurface(props) {
1736
+ const byId = new Map(props.sources.map((source) => [source.sourceId, source]));
1737
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1738
+ "data-watch-library": "",
1739
+ "aria-label": "Library",
1740
+ children: [
1741
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1742
+ "data-watch-search-plan": props.plan.path,
1743
+ children: describeSearch(props.plan, props.results)
1744
+ }),
1745
+ props.plan.fix !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1746
+ "data-watch-search-fix": "",
1747
+ children: props.plan.fix
1748
+ }),
1749
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FacetPanel, {
1750
+ facets: props.facets,
1751
+ onFilter: props.onFilter
1752
+ }),
1753
+ props.results.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1754
+ "data-watch-library-empty": "",
1755
+ children: "Nothing in the Library matches."
1756
+ }) : props.results.map((result) => {
1757
+ const source = byId.get(result.sourceId);
1758
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("article", {
1759
+ "data-watch-source": result.sourceId,
1760
+ children: [
1761
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1762
+ style: { font: "inherit" },
1763
+ dir: "auto",
1764
+ children: result.title
1765
+ }),
1766
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1767
+ "data-watch-source-kind": result.kind,
1768
+ children: result.kind
1769
+ }),
1770
+ !result.current && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1771
+ "data-watch-source-superseded": "",
1772
+ children: " The source has changed since these were observed."
1773
+ }),
1774
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
1775
+ style: {
1776
+ listStyle: "none",
1777
+ margin: 0,
1778
+ padding: 0
1779
+ },
1780
+ children: result.hits.map((hit) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SearchHitRow, {
1781
+ hit,
1782
+ freshness: props.freshnessOf(hit),
1783
+ onOpen: props.onOpenHit
1784
+ }, `${hit.sourceRevisionId}:${String(hit.range?.startMs ?? 0)}:${hit.text}`))
1785
+ }),
1786
+ source !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RevisionHistory, {
1787
+ source,
1788
+ onOpen: props.onOpenRevision
1789
+ })
1790
+ ]
1791
+ }, result.sourceId);
1792
+ })
1793
+ ]
1794
+ });
1795
+ }
1796
+ //#endregion
1797
+ //#region src/client/index.tsx
1798
+ /**
1799
+ * Services this half needs before it can register anything.
1800
+ *
1801
+ * `remote.watchQuery` as well as `remote`. The Gateway installs each mounted
1802
+ * namespace as its own cordis service under that key, so naming it is what
1803
+ * makes this plugin wait for the mount rather than load beside it: the bare
1804
+ * `remote` resolves as soon as the Gateway's browser half exists — which is
1805
+ * before any contribution is mounted — and leaves `ctx.remote` with no
1806
+ * `watchQuery` on it. Both are listed because both are read, and cordis
1807
+ * refuses a property no `inject` entry claims: reaching `ctx.remote.watchQuery`
1808
+ * on the strength of the second entry alone fails the fiber with "cannot get
1809
+ * property "remote" without inject".
1810
+ *
1811
+ * The mount itself belongs to `@deepwatch/dsh-client-remotes`. This package
1812
+ * owns the Library capability, and a package that owns a capability does not
1813
+ * also own the transport that carries it; when it did, the two depended on
1814
+ * each other.
1815
+ */
1816
+ const inject = [
1817
+ "slots",
1818
+ "remote",
1819
+ "remote.watchQuery"
1820
+ ];
1821
+ /**
1822
+ * Register the Library mode body, bound to the read plane it queries.
1823
+ *
1824
+ * A `conversation.view` entry is handed `{ inspect, onInspectDone }` and
1825
+ * nothing else, so a mode body has no way to reach a service on its own. The
1826
+ * binding happens here, where the context is: what gets registered is the mode
1827
+ * body with the mounted `watchQuery` namespace already supplied.
1828
+ *
1829
+ * Nothing here is defensive about `ctx.remote.watchQuery`. `inject` above means
1830
+ * cordis does not call `apply` until that service exists, so a profile without
1831
+ * the mount parks this plugin — no Library tab at all — rather than drawing a
1832
+ * tab whose search quietly answers from an empty local index.
1833
+ */
1834
+ function apply(ctx) {
1835
+ const slots = ctx.slots;
1836
+ const reads = ctx.remote.watchQuery;
1837
+ /** The Library body, bound to the host that answers for it. */
1838
+ const BoundLibraryModeView = (props) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LibraryModeView, {
1839
+ ...props,
1840
+ reads
1841
+ });
1842
+ slots.inject("conversation.view", () => {
1843
+ slots.register({
1844
+ name: "conversation.view",
1845
+ id: "library",
1846
+ label: "Library",
1847
+ order: 50
1848
+ }, BoundLibraryModeView);
1849
+ });
1850
+ }
1851
+ //#endregion
1852
+ exports.FacetPanel = FacetPanel;
1853
+ exports.FreshnessBadge = FreshnessBadge;
1854
+ exports.LibraryModeView = LibraryModeView;
1855
+ exports.LibrarySearch = LibrarySearch;
1856
+ exports.LibrarySurface = LibrarySurface;
1857
+ exports.MAX_LIMIT = MAX_LIMIT;
1858
+ exports.RevisionHistory = RevisionHistory;
1859
+ exports.SearchHitRow = SearchHitRow;
1860
+ exports.apply = apply;
1861
+ exports.applyFilters = applyFilters;
1862
+ exports.currentRevision = currentRevision;
1863
+ exports.describeSearch = describeSearch;
1864
+ exports.facetsFor = facetsFor;
1865
+ exports.findRevision = findRevision;
1866
+ exports.freshnessOf = freshnessOf;
1867
+ exports.fromIndex = fromIndex;
1868
+ exports.inject = inject;
1869
+ exports.isAddressable = isAddressable;
1870
+ exports.isCurrentRevision = isCurrentRevision;
1871
+ exports.locate = locate;
1872
+ exports.nextRequestId = nextRequestId;
1873
+ exports.rankResults = rankResults;
1874
+ exports.readLibraryPage = readLibraryPage;
1875
+ exports.refreshLibrary = refreshLibrary;
1876
+ exports.searchPlan = searchPlan;
1877
+ exports.withRevision = withRevision;
1878
+ return module.exports;
1879
+ }
1880
+ });
1881
+
1882
+ //# sourceMappingURL=client.js.map