@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/LICENSE +21 -0
- package/README.md +88 -0
- package/lib/client/components.d.ts +75 -0
- package/lib/client/components.js +60 -0
- package/lib/client/index.d.ts +46 -0
- package/lib/client/index.js +52 -0
- package/lib/client/library-mode.d.ts +37 -0
- package/lib/client/library-mode.js +21 -0
- package/lib/client/read-plane.d.ts +134 -0
- package/lib/client/read-plane.js +193 -0
- package/lib/client/search-view.d.ts +44 -0
- package/lib/client/search-view.js +233 -0
- package/lib/client.js +1882 -0
- package/lib/client.js.map +1 -0
- package/lib/index-store.d.ts +221 -0
- package/lib/index-store.js +570 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +20 -0
- package/lib/search.d.ts +129 -0
- package/lib/search.js +180 -0
- package/lib/sources.d.ts +146 -0
- package/lib/sources.js +135 -0
- package/package.json +101 -0
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
|