@yadsh/dsh-qa-surface 0.11.1 → 0.12.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/access/service.js +2 -1
- package/lib/access/service.js.map +1 -1
- package/lib/client/QaSessionController.js +24 -2
- package/lib/client/QaSessionController.js.map +1 -1
- package/lib/client/QaSurface.js +9 -2
- package/lib/client/QaSurface.js.map +1 -1
- package/lib/client/components/QaChangelog.js +46 -11
- package/lib/client/components/QaChangelog.js.map +1 -1
- package/lib/client/components/QaSidebar.js +13 -15
- package/lib/client/components/QaSidebar.js.map +1 -1
- package/lib/client/settings/card.js +2 -2
- package/lib/client/settings/card.js.map +1 -1
- package/lib/client/settings/sections/docs.js +41 -0
- package/lib/client/settings/sections/docs.js.map +1 -0
- package/lib/client/settings/sections.js +1 -0
- package/lib/client/settings/sections.js.map +1 -1
- package/lib/client/slash/SlashPalette.js +4 -13
- package/lib/client/slash/SlashPalette.js.map +1 -1
- package/lib/client/slash/palette-rows.js +45 -0
- package/lib/client/slash/palette-rows.js.map +1 -0
- package/lib/client/slash/useSlashNavigation.js +8 -2
- package/lib/client/slash/useSlashNavigation.js.map +1 -1
- package/lib/client/styles.js +1 -2
- package/lib/client/styles.js.map +1 -1
- package/lib/client/turn-sources.js +20 -0
- package/lib/client/turn-sources.js.map +1 -1
- package/lib/client.js +398 -206
- package/lib/client.js.map +1 -1
- package/lib/config-resolvers/defaults.js +7 -0
- package/lib/config-resolvers/defaults.js.map +1 -1
- package/lib/config-resolvers/integration.js +7 -1
- package/lib/config-resolvers/integration.js.map +1 -1
- package/lib/config-resolvers/shared.js +12 -0
- package/lib/config-resolvers/shared.js.map +1 -1
- package/lib/config-resolvers/tools.js +26 -0
- package/lib/config-resolvers/tools.js.map +1 -1
- package/lib/config.js +21 -0
- package/lib/config.js.map +1 -1
- package/lib/index.js +10 -2
- package/lib/index.js.map +1 -1
- package/lib/launch-token.js +22 -17
- package/lib/launch-token.js.map +1 -1
- package/lib/qa-tools/catalog.js +13 -2
- package/lib/qa-tools/catalog.js.map +1 -1
- package/lib/qa-tools/docs-tools.js +334 -32
- package/lib/qa-tools/docs-tools.js.map +1 -1
- package/lib/qa-tools/index.js +2 -0
- package/lib/qa-tools/index.js.map +1 -1
- package/lib/secure-session.js +8 -0
- package/lib/secure-session.js.map +1 -1
- package/lib/shared/docs-version.js +19 -0
- package/lib/shared/docs-version.js.map +1 -0
- package/lib/typert.host.js +71 -68
- package/lib/typert.remote-client.js +70 -67
- package/lib/types/client/QaSessionController.d.ts +14 -0
- package/lib/types/client/components/QaWidthHandle.d.ts +0 -1
- package/lib/types/client/settings/sections/docs.d.ts +15 -0
- package/lib/types/client/settings/sections.d.ts +1 -0
- package/lib/types/client/slash/palette-rows.d.ts +43 -0
- package/lib/types/client/turn-sources.d.ts +14 -0
- package/lib/types/config-resolvers/integration.d.ts +7 -1
- package/lib/types/config-resolvers/shared.d.ts +8 -0
- package/lib/types/config-resolvers/tools.d.ts +9 -0
- package/lib/types/launch-token.d.ts +10 -3
- package/lib/types/qa-tools/catalog.d.ts +11 -1
- package/lib/types/qa-tools/docs-tools.d.ts +38 -5
- package/lib/types/qa-tools/index.d.ts +7 -0
- package/lib/types/shared/docs-version.d.ts +15 -0
- package/lib/types/types.d.ts +34 -0
- package/package.json +5 -5
package/lib/qa-tools/catalog.js
CHANGED
|
@@ -10,6 +10,9 @@ import { createDocsReadTool, createDocsSearchTool } from "./docs-tools.js";
|
|
|
10
10
|
* Bump it whenever the catalog's tool set or their schemas change.
|
|
11
11
|
*/
|
|
12
12
|
export const QA_TOOL_CATALOG_VERSION = "3";
|
|
13
|
+
function current(value) {
|
|
14
|
+
return typeof value === "function" ? value() : (value ?? "");
|
|
15
|
+
}
|
|
13
16
|
function createSelfcheckTool(deps) {
|
|
14
17
|
return defineTool({
|
|
15
18
|
name: "qa_tools_selfcheck",
|
|
@@ -75,6 +78,14 @@ function createSelfcheckTool(deps) {
|
|
|
75
78
|
* activation manager owns every registration.
|
|
76
79
|
*/
|
|
77
80
|
export function createQaToolCatalog(deps) {
|
|
81
|
+
const docsOptions = {
|
|
82
|
+
get root() {
|
|
83
|
+
return current(deps.docsRoot);
|
|
84
|
+
},
|
|
85
|
+
get defaultVersion() {
|
|
86
|
+
return current(deps.docsDefaultVersion);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
78
89
|
return [
|
|
79
90
|
{
|
|
80
91
|
definition: createSelfcheckTool(deps),
|
|
@@ -82,12 +93,12 @@ export function createQaToolCatalog(deps) {
|
|
|
82
93
|
tags: ["activation", "diagnostics"],
|
|
83
94
|
},
|
|
84
95
|
{
|
|
85
|
-
definition: createDocsSearchTool(),
|
|
96
|
+
definition: createDocsSearchTool(docsOptions),
|
|
86
97
|
group: "documentation",
|
|
87
98
|
tags: ["docs", "search"],
|
|
88
99
|
},
|
|
89
100
|
{
|
|
90
|
-
definition: createDocsReadTool(),
|
|
101
|
+
definition: createDocsReadTool(docsOptions),
|
|
91
102
|
group: "documentation",
|
|
92
103
|
tags: ["docs", "read"],
|
|
93
104
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"catalog.js","sourceRoot":"","sources":["../../src/qa-tools/catalog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEpD,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAG3E;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAG,CAAC;
|
|
1
|
+
{"version":3,"file":"catalog.js","sourceRoot":"","sources":["../../src/qa-tools/catalog.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
2
|
-
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
4
|
+
import { isDocumentationVersion } from "../shared/docs-version.js";
|
|
4
5
|
import { canonicalCandidate, pathIsInside } from "../user-workspace.js";
|
|
5
6
|
/**
|
|
6
7
|
* `docs_search` and `docs_read` — the documentation surface of the QA catalog.
|
|
@@ -22,6 +23,14 @@ import { canonicalCandidate, pathIsInside } from "../user-workspace.js";
|
|
|
22
23
|
* names are accepted as filters. A path the convention does not describe is
|
|
23
24
|
* still searched and still read — it simply carries no identity.
|
|
24
25
|
*
|
|
26
|
+
* A real corpus also carries its own working material: the stand publishes an
|
|
27
|
+
* asset tree, inventories and triage notes beside the documents, under leading
|
|
28
|
+
* underscores. None of that is what a question is asked about, and an inventory
|
|
29
|
+
* names every module and version there is, so a walk that met it first spent the
|
|
30
|
+
* whole answer on it. The walk therefore visits the documented modules first,
|
|
31
|
+
* loose files next, and the underscored service material last — searched, but
|
|
32
|
+
* never at the expense of the documents.
|
|
33
|
+
*
|
|
25
34
|
* Nothing here is destructive and building it registers nothing: like the rest
|
|
26
35
|
* of the catalog these are side-effect-free definitions, and the workspace root
|
|
27
36
|
* is read from `exec.agent` at call time.
|
|
@@ -36,10 +45,76 @@ export const QA_DOCS_READ_DEFAULT_LINES = 120;
|
|
|
36
45
|
export const QA_DOCS_READ_MAX_LINES = 400;
|
|
37
46
|
/** One file larger than this is not documentation a chat needs to search. */
|
|
38
47
|
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
39
|
-
/** Bound on
|
|
48
|
+
/** Bound on how many documents one search reads. */
|
|
40
49
|
const MAX_FILES_SCANNED = 2_000;
|
|
50
|
+
/**
|
|
51
|
+
* Bound on how many entries one walk may look at, so a corpus with a huge asset
|
|
52
|
+
* tree cannot stall the turn even though nothing in it is read.
|
|
53
|
+
*/
|
|
54
|
+
const MAX_WALK_ENTRIES = 50_000;
|
|
41
55
|
/** A matched line is trimmed to this many characters before it is reported. */
|
|
42
56
|
const MAX_LINE_CHARS = 240;
|
|
57
|
+
/**
|
|
58
|
+
* Extensions of material that is not documentation text: pictures, audio and
|
|
59
|
+
* video, archives, office binaries, fonts. A published corpus keeps them beside
|
|
60
|
+
* its prose — four fifths of the stand's own tree is images — and learning that
|
|
61
|
+
* by reading each one spends a search's whole document budget before it reaches
|
|
62
|
+
* a document. These are counted as skipped material, never as documents read.
|
|
63
|
+
*/
|
|
64
|
+
const NON_TEXT_EXTENSIONS = new Set([
|
|
65
|
+
"avif",
|
|
66
|
+
"bmp",
|
|
67
|
+
"bz2",
|
|
68
|
+
"class",
|
|
69
|
+
"dll",
|
|
70
|
+
"doc",
|
|
71
|
+
"docx",
|
|
72
|
+
"dylib",
|
|
73
|
+
"eot",
|
|
74
|
+
"exe",
|
|
75
|
+
"flac",
|
|
76
|
+
"gif",
|
|
77
|
+
"gz",
|
|
78
|
+
"heic",
|
|
79
|
+
"ico",
|
|
80
|
+
"jar",
|
|
81
|
+
"jpeg",
|
|
82
|
+
"jfif",
|
|
83
|
+
"jpg",
|
|
84
|
+
"m4a",
|
|
85
|
+
"m4v",
|
|
86
|
+
"mkv",
|
|
87
|
+
"mov",
|
|
88
|
+
"mp3",
|
|
89
|
+
"mp4",
|
|
90
|
+
"odp",
|
|
91
|
+
"ods",
|
|
92
|
+
"odt",
|
|
93
|
+
"ogg",
|
|
94
|
+
"otf",
|
|
95
|
+
"pdf",
|
|
96
|
+
"png",
|
|
97
|
+
"ppt",
|
|
98
|
+
"pptx",
|
|
99
|
+
"rar",
|
|
100
|
+
"sqlite",
|
|
101
|
+
"svg",
|
|
102
|
+
"tar",
|
|
103
|
+
"tgz",
|
|
104
|
+
"tif",
|
|
105
|
+
"tiff",
|
|
106
|
+
"ttf",
|
|
107
|
+
"wav",
|
|
108
|
+
"wasm",
|
|
109
|
+
"webm",
|
|
110
|
+
"webp",
|
|
111
|
+
"woff",
|
|
112
|
+
"woff2",
|
|
113
|
+
"xls",
|
|
114
|
+
"xlsx",
|
|
115
|
+
"xz",
|
|
116
|
+
"zip",
|
|
117
|
+
]);
|
|
43
118
|
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
44
119
|
/**
|
|
45
120
|
* A typed refusal of one documentation call.
|
|
@@ -61,14 +136,38 @@ const DOCS_UNAVAILABLE = `this chat's workspace has no ${QA_DOCS_DIRECTORY}/ dir
|
|
|
61
136
|
const DOCS_NOT_A_DIRECTORY = `the ${QA_DOCS_DIRECTORY}/ entry in this chat's workspace is not a real directory (it is a file or a link), and documentation is never followed through a link.`;
|
|
62
137
|
const DOCS_ESCAPE = `the ${QA_DOCS_DIRECTORY}/ directory resolves outside the calling chat's workspace, so the documentation tree is refused.`;
|
|
63
138
|
const INVALID_QUERY = "no query was given: pass the phrase to look for.";
|
|
64
|
-
const OUTSIDE_DOCS = `this path resolves outside the documentation
|
|
139
|
+
const OUTSIDE_DOCS = `this path resolves outside the documentation tree (${QA_DOCS_DIRECTORY}/) this stand reads. Documentation is only read where the stand publishes it; pass a path inside ${QA_DOCS_DIRECTORY}/.`;
|
|
140
|
+
/**
|
|
141
|
+
* Refusals of a configured root. The operator named the path, so the message
|
|
142
|
+
* names it too: a typo in a deployment file is not something the model can
|
|
143
|
+
* diagnose from "documentation is unavailable".
|
|
144
|
+
*/
|
|
145
|
+
const configuredMissing = (root) => `this deployment publishes its documentation at ${root}, and nothing is there. Report that documentation is unavailable rather than guessing its contents.`;
|
|
146
|
+
const configuredNotADirectory = (root) => `this deployment publishes its documentation at ${root}, which is not a real directory (it is a file or a link), and documentation is never followed through a link.`;
|
|
147
|
+
const CONFIGURED_NOT_ABSOLUTE = "the configured documentation root is not an absolute path, so the documentation tree cannot be located. Report that documentation is unavailable rather than guessing its contents.";
|
|
65
148
|
const SYMLINK_ESCAPE = "this path leaves the documentation tree through a symbolic link, and links are not followed out of it.";
|
|
66
149
|
const NOT_FOUND = "no documentation file or directory exists at this path. Check the spelling against a path docs_search reported.";
|
|
67
150
|
const NOT_A_FILE = "this path is a directory, not a documentation file: pass the file to read, or use docs_search to find one.";
|
|
68
151
|
const NOT_TEXT = "this path does not read as UTF-8 text (or is larger than the readable limit), so it is not documentation this tool can return.";
|
|
69
|
-
|
|
152
|
+
/**
|
|
153
|
+
* The version one search runs under: the caller's own, the deployment's
|
|
154
|
+
* default, or none.
|
|
155
|
+
*
|
|
156
|
+
* An explicit `version` is the model saying which edition it means. An explicit
|
|
157
|
+
* `path` names a subtree the default has no business overriding — a corpus
|
|
158
|
+
* scoped to `platform/3.5` stays reachable on a stand whose default is `3.8` —
|
|
159
|
+
* so a call that named a path is left to that path alone.
|
|
160
|
+
*/
|
|
161
|
+
function versionFor(options, args) {
|
|
162
|
+
const asked = (args.version ?? "").trim();
|
|
163
|
+
if (asked !== "")
|
|
164
|
+
return asked;
|
|
165
|
+
if ((args.path ?? "").trim() !== "")
|
|
166
|
+
return "";
|
|
167
|
+
return (options.defaultVersion ?? "").trim();
|
|
168
|
+
}
|
|
70
169
|
function normalizeVersion(value) {
|
|
71
|
-
return value.replace(/^v/u, "")
|
|
170
|
+
return value.toLowerCase().replace(/^v/u, "");
|
|
72
171
|
}
|
|
73
172
|
/**
|
|
74
173
|
* Identity of one documentation path, read relative to the documentation root.
|
|
@@ -86,7 +185,7 @@ export function docsIdentityOf(pathFromDocsRoot) {
|
|
|
86
185
|
.filter((segment) => segment !== "");
|
|
87
186
|
if (segments.length === 0)
|
|
88
187
|
return identityOf(undefined, undefined);
|
|
89
|
-
const versionIndex = segments.findIndex((segment) =>
|
|
188
|
+
const versionIndex = segments.findIndex((segment) => isDocumentationVersion(segment));
|
|
90
189
|
if (versionIndex === -1)
|
|
91
190
|
return identityOf(undefined, segments[0]);
|
|
92
191
|
return identityOf(segments[versionIndex], versionIndex > 0 ? segments[versionIndex - 1] : undefined);
|
|
@@ -113,8 +212,15 @@ function moduleMatches(identity, wanted) {
|
|
|
113
212
|
* The root is a real directory inside the canonical workspace: a `docs` entry
|
|
114
213
|
* that is a link or a file is refused rather than followed, because the fence
|
|
115
214
|
* is the point of the tool and a link is how a fence is left.
|
|
215
|
+
*
|
|
216
|
+
* A deployment that names its corpus once (`QaDocsOptions.root`) skips the
|
|
217
|
+
* per-chat lookup entirely — that layout is exactly why it configures the root —
|
|
218
|
+
* and is held to the same rules: absolute, a real directory, no links.
|
|
116
219
|
*/
|
|
117
|
-
export async function docsRootOf(exec) {
|
|
220
|
+
export async function docsRootOf(exec, options = {}) {
|
|
221
|
+
const configured = (options.root ?? "").trim();
|
|
222
|
+
if (configured !== "")
|
|
223
|
+
return await configuredDocsRoot(configured);
|
|
118
224
|
const cwd = exec.agent?.session.header.cwd;
|
|
119
225
|
if (typeof cwd !== "string" || cwd.trim() === "") {
|
|
120
226
|
throw new QaDocsError("workspace-unavailable", WORKSPACE_UNAVAILABLE);
|
|
@@ -149,6 +255,37 @@ export async function docsRootOf(exec) {
|
|
|
149
255
|
}
|
|
150
256
|
return { workspace, docs };
|
|
151
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* The documentation root a deployment named.
|
|
260
|
+
*
|
|
261
|
+
* The parent directory plays the part the chat's workspace plays in the
|
|
262
|
+
* per-chat layout: `docs/`-relative reporting, the fence and every path rule
|
|
263
|
+
* below are unchanged, so a configured corpus is read by the same rules as a
|
|
264
|
+
* published-into-the-workspace one.
|
|
265
|
+
*/
|
|
266
|
+
async function configuredDocsRoot(configured) {
|
|
267
|
+
if (!isAbsolute(configured)) {
|
|
268
|
+
throw new QaDocsError("docs-unavailable", CONFIGURED_NOT_ABSOLUTE);
|
|
269
|
+
}
|
|
270
|
+
const entry = await lstat(configured).catch((error) => error);
|
|
271
|
+
if (entry instanceof Error) {
|
|
272
|
+
if (entry.code === "ENOENT") {
|
|
273
|
+
throw new QaDocsError("docs-unavailable", configuredMissing(configured));
|
|
274
|
+
}
|
|
275
|
+
throw new QaDocsError("workspace-unavailable", WORKSPACE_UNAVAILABLE);
|
|
276
|
+
}
|
|
277
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
278
|
+
throw new QaDocsError("docs-unavailable", configuredNotADirectory(configured));
|
|
279
|
+
}
|
|
280
|
+
let docs;
|
|
281
|
+
try {
|
|
282
|
+
docs = canonicalCandidate(configured);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
throw new QaDocsError("workspace-unavailable", WORKSPACE_UNAVAILABLE);
|
|
286
|
+
}
|
|
287
|
+
return { workspace: dirname(docs), docs };
|
|
288
|
+
}
|
|
152
289
|
/**
|
|
153
290
|
* Resolve one tool path against the documentation root.
|
|
154
291
|
*
|
|
@@ -163,11 +300,15 @@ async function docsTargetOf(root, input) {
|
|
|
163
300
|
throw new QaDocsError("outside-docs", OUTSIDE_DOCS);
|
|
164
301
|
}
|
|
165
302
|
const normalized = trimmed.replace(/\\/gu, "/").replace(/^\.\//u, "");
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
303
|
+
// `docs/` is the stable public spelling, not necessarily the configured
|
|
304
|
+
// directory's basename. Resolve both accepted spellings from the canonical
|
|
305
|
+
// root itself so `/srv/published-corpus` still reads `docs/module/file.md`.
|
|
306
|
+
const pathFromDocs = normalized === QA_DOCS_DIRECTORY
|
|
307
|
+
? ""
|
|
308
|
+
: normalized.startsWith(`${QA_DOCS_DIRECTORY}/`)
|
|
309
|
+
? normalized.slice(QA_DOCS_DIRECTORY.length + 1)
|
|
310
|
+
: normalized;
|
|
311
|
+
const spelled = resolve(root.docs, pathFromDocs);
|
|
171
312
|
if (!pathIsInside(spelled, root.docs)) {
|
|
172
313
|
throw new QaDocsError("outside-docs", OUTSIDE_DOCS);
|
|
173
314
|
}
|
|
@@ -232,20 +373,95 @@ function clamp(value, low, high) {
|
|
|
232
373
|
return Math.min(Math.max(Math.trunc(value), low), high);
|
|
233
374
|
}
|
|
234
375
|
/**
|
|
235
|
-
*
|
|
376
|
+
* Whether a name is the corpus's own working material: the asset tree, the
|
|
377
|
+
* inventories, the triage notes and anything else a publisher underscored or
|
|
378
|
+
* dotted.
|
|
379
|
+
*/
|
|
380
|
+
function isServiceName(name) {
|
|
381
|
+
return name.startsWith("_") || name.startsWith(".");
|
|
382
|
+
}
|
|
383
|
+
/** Whether any segment of a path is such material. */
|
|
384
|
+
function isServicePath(pathFromDocs) {
|
|
385
|
+
return pathFromDocs.split(/[\\/]/u).some(isServiceName);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* How the entries of one directory are ordered: documents before service
|
|
389
|
+
* material.
|
|
390
|
+
*
|
|
391
|
+
* A corpus keeps its own working material beside the documents, and the stand's
|
|
392
|
+
* names it with a leading underscore. Name order puts that material first (`_`
|
|
393
|
+
* sorts before letters), which is exactly where a bounded answer must not spend
|
|
394
|
+
* itself: an inventory names every module and version the corpus has, so it
|
|
395
|
+
* matches the question and consumes the budget before a document is opened.
|
|
396
|
+
* Module directories come first, then loose files at the same level, then
|
|
397
|
+
* everything underscored or dotted — which is still searched, just after the
|
|
398
|
+
* documentation. Material inside a service directory stays service material,
|
|
399
|
+
* however its own entries are named.
|
|
400
|
+
*/
|
|
401
|
+
function entryRank(parentIsService, entry) {
|
|
402
|
+
if (parentIsService || isServiceName(entry.name))
|
|
403
|
+
return 2;
|
|
404
|
+
return entry.isDirectory() ? 0 : 1;
|
|
405
|
+
}
|
|
406
|
+
function entryComparator(parentIsService) {
|
|
407
|
+
return (left, right) => {
|
|
408
|
+
const rank = entryRank(parentIsService, left) - entryRank(parentIsService, right);
|
|
409
|
+
if (rank !== 0)
|
|
410
|
+
return rank;
|
|
411
|
+
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Which queued directory the walk opens next.
|
|
236
416
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
417
|
+
* Rank and path decide, not the order directories happened to be queued in: the
|
|
418
|
+
* corpus's own `_meta` at the top of the tree must not be read before a
|
|
419
|
+
* documented module's edition one level below it, which is what a breadth-first
|
|
420
|
+
* queue did — the inventories answered every question about the product before
|
|
421
|
+
* a document was reached.
|
|
240
422
|
*/
|
|
241
|
-
|
|
423
|
+
function scopeComparator(left, right) {
|
|
424
|
+
const rank = Number(isServicePath(left.pathFromDocs)) -
|
|
425
|
+
Number(isServicePath(right.pathFromDocs));
|
|
426
|
+
if (rank !== 0)
|
|
427
|
+
return rank;
|
|
428
|
+
if (left.pathFromDocs === right.pathFromDocs)
|
|
429
|
+
return 0;
|
|
430
|
+
return left.pathFromDocs < right.pathFromDocs ? -1 : 1;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Whether a name is material the walk never reads: pictures, media, archives,
|
|
434
|
+
* office binaries and fonts, recognised by extension.
|
|
435
|
+
*/
|
|
436
|
+
function isNonTextName(name) {
|
|
437
|
+
const dot = name.lastIndexOf(".");
|
|
438
|
+
if (dot <= 0)
|
|
439
|
+
return false;
|
|
440
|
+
return NON_TEXT_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Visit every documentation file of one target, in walk order.
|
|
444
|
+
*
|
|
445
|
+
* Symbolic links are counted as skipped and never followed. A file the caller's
|
|
446
|
+
* scope excludes is neither read nor counted, and material that is not text is
|
|
447
|
+
* counted as skipped rather than read, so the document budget below is spent on
|
|
448
|
+
* documents. The visitor returns `stop` to end the walk, which is how the output
|
|
449
|
+
* budget ends it early.
|
|
450
|
+
*/
|
|
451
|
+
async function visitDocumentation(target, accept, descend, visit) {
|
|
242
452
|
let filesScanned = 0;
|
|
453
|
+
let entriesSeen = 0;
|
|
243
454
|
let skipped = 0;
|
|
244
455
|
const info = await lstat(target.absolute).catch(() => undefined);
|
|
245
456
|
if (info === undefined) {
|
|
246
457
|
throw new QaDocsError("not-found", NOT_FOUND);
|
|
247
458
|
}
|
|
248
459
|
if (info.isFile()) {
|
|
460
|
+
// A path the caller named explicitly is read as it is: out of scope means
|
|
461
|
+
// no hits, not a refusal, and the refusal it may get comes from the reader.
|
|
462
|
+
if (!accept(target.pathFromDocs)) {
|
|
463
|
+
return { filesScanned, skipped, stopped: false };
|
|
464
|
+
}
|
|
249
465
|
return {
|
|
250
466
|
filesScanned: 1,
|
|
251
467
|
skipped,
|
|
@@ -257,24 +473,47 @@ async function visitDocumentation(target, visit) {
|
|
|
257
473
|
}
|
|
258
474
|
const pending = [target];
|
|
259
475
|
while (pending.length > 0) {
|
|
476
|
+
// Ranked by path, so the queue's order is the walk's order.
|
|
477
|
+
pending.sort(scopeComparator);
|
|
260
478
|
const scope = pending.shift();
|
|
479
|
+
const serviceScope = isServicePath(scope.pathFromDocs);
|
|
261
480
|
const entries = await readdir(scope.absolute, {
|
|
262
481
|
withFileTypes: true,
|
|
263
482
|
}).catch(() => []);
|
|
264
|
-
entries.sort((
|
|
483
|
+
entries.sort(entryComparator(serviceScope));
|
|
265
484
|
for (const entry of entries) {
|
|
266
485
|
const child = join(scope.absolute, entry.name);
|
|
267
486
|
const pathFromDocs = join(scope.pathFromDocs, entry.name);
|
|
268
487
|
if (entry.isSymbolicLink()) {
|
|
488
|
+
entriesSeen += 1;
|
|
489
|
+
if (entriesSeen > MAX_WALK_ENTRIES) {
|
|
490
|
+
return { filesScanned, skipped, stopped: true };
|
|
491
|
+
}
|
|
269
492
|
skipped += 1;
|
|
270
493
|
continue;
|
|
271
494
|
}
|
|
272
495
|
if (entry.isDirectory()) {
|
|
496
|
+
if (!descend(pathFromDocs))
|
|
497
|
+
continue;
|
|
498
|
+
entriesSeen += 1;
|
|
499
|
+
if (entriesSeen > MAX_WALK_ENTRIES) {
|
|
500
|
+
return { filesScanned, skipped, stopped: true };
|
|
501
|
+
}
|
|
273
502
|
pending.push({ absolute: child, pathFromDocs });
|
|
274
503
|
continue;
|
|
275
504
|
}
|
|
276
505
|
if (!entry.isFile())
|
|
277
506
|
continue;
|
|
507
|
+
if (!accept(pathFromDocs))
|
|
508
|
+
continue;
|
|
509
|
+
entriesSeen += 1;
|
|
510
|
+
if (entriesSeen > MAX_WALK_ENTRIES) {
|
|
511
|
+
return { filesScanned, skipped, stopped: true };
|
|
512
|
+
}
|
|
513
|
+
if (isNonTextName(entry.name)) {
|
|
514
|
+
skipped += 1;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
278
517
|
filesScanned += 1;
|
|
279
518
|
if (filesScanned > MAX_FILES_SCANNED) {
|
|
280
519
|
return { filesScanned, skipped, stopped: true };
|
|
@@ -321,9 +560,24 @@ export async function searchDocumentation(root, request) {
|
|
|
321
560
|
return false;
|
|
322
561
|
return true;
|
|
323
562
|
};
|
|
563
|
+
const wantedScope = (pathFromDocs) => {
|
|
564
|
+
// `docsIdentityOf` expects a file path. A synthetic leaf makes the current
|
|
565
|
+
// directory participate as a scope while still allowing an ancestor that
|
|
566
|
+
// has not reached its module/version segment yet.
|
|
567
|
+
const identity = docsIdentityOf(join(pathFromDocs, "__scope__"));
|
|
568
|
+
if (version !== "" &&
|
|
569
|
+
identity.version !== undefined &&
|
|
570
|
+
!versionMatches(identity, version)) {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
if (module !== "" &&
|
|
574
|
+
identity.module !== undefined &&
|
|
575
|
+
!moduleMatches(identity, module)) {
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
return true;
|
|
579
|
+
};
|
|
324
580
|
const visit = async (file, pathFromDocs, explicit) => {
|
|
325
|
-
if (!wanted(pathFromDocs))
|
|
326
|
-
return "continue";
|
|
327
581
|
let lines;
|
|
328
582
|
try {
|
|
329
583
|
lines = await readDocLines(file);
|
|
@@ -356,7 +610,7 @@ export async function searchDocumentation(root, request) {
|
|
|
356
610
|
}
|
|
357
611
|
return "continue";
|
|
358
612
|
};
|
|
359
|
-
const walk = await visitDocumentation(scope, visit);
|
|
613
|
+
const walk = await visitDocumentation(scope, wanted, wantedScope, visit);
|
|
360
614
|
return {
|
|
361
615
|
...identityOf(version === "" ? undefined : version, module === "" ? undefined : module),
|
|
362
616
|
query,
|
|
@@ -397,7 +651,12 @@ export async function readDocumentation(root, request) {
|
|
|
397
651
|
text: lines.slice(from - 1, Math.max(to, from - 1)).join("\n"),
|
|
398
652
|
};
|
|
399
653
|
}
|
|
400
|
-
|
|
654
|
+
/**
|
|
655
|
+
* The facets an answer ran under, as one leading-space clause the callers
|
|
656
|
+
* interpolate. `note` rides beside them in a report's header — never on a hit's
|
|
657
|
+
* own label, where it would repeat on every line.
|
|
658
|
+
*/
|
|
659
|
+
function filtersOf(identity, note = "") {
|
|
401
660
|
const parts = [];
|
|
402
661
|
if (identity.version !== undefined && identity.version !== "") {
|
|
403
662
|
parts.push(`version ${identity.version}`);
|
|
@@ -405,7 +664,9 @@ function filtersOf(identity) {
|
|
|
405
664
|
if (identity.module !== undefined && identity.module !== "") {
|
|
406
665
|
parts.push(`module ${identity.module}`);
|
|
407
666
|
}
|
|
408
|
-
|
|
667
|
+
const facets = parts.join(", ");
|
|
668
|
+
const described = note === "" ? facets : facets === "" ? note : `${facets}, ${note}`;
|
|
669
|
+
return described === "" ? "" : ` (${described})`;
|
|
409
670
|
}
|
|
410
671
|
function identityLabel(identity) {
|
|
411
672
|
return filtersOf(identity);
|
|
@@ -418,10 +679,10 @@ function identityLabel(identity) {
|
|
|
418
679
|
* version/module facets exist so a model that has been told "3.8" stops
|
|
419
680
|
* sweeping the whole tree.
|
|
420
681
|
*/
|
|
421
|
-
export function createDocsSearchTool() {
|
|
682
|
+
export function createDocsSearchTool(options = {}) {
|
|
422
683
|
return defineTool({
|
|
423
684
|
name: "docs_search",
|
|
424
|
-
description: `Search the stand's documentation, which lives
|
|
685
|
+
description: `Search the stand's documentation, which lives ${docsLayout(options)}. Use it — not memory and not a glob sweep over guessed paths — whenever a question is about the product, a version or a module. Hits are lines, tagged with the module and version parsed from the path (${QA_DOCS_DIRECTORY}/<module>/<version>/…); pass version and module to stay inside one edition, and path to stay inside one subtree. Output is bounded by limit and by a byte budget, and a truncated result says so.${defaultVersionNote(options)}`,
|
|
425
686
|
parameters: {
|
|
426
687
|
query: {
|
|
427
688
|
type: "string",
|
|
@@ -470,11 +731,15 @@ export function createDocsSearchTool() {
|
|
|
470
731
|
truncated: { type: "boolean", required: true },
|
|
471
732
|
filesScanned: { type: "integer", required: true },
|
|
472
733
|
skipped: { type: "integer", required: true },
|
|
734
|
+
// A search answers with the facets it ran under, so a filtered result
|
|
735
|
+
// says which edition of which module it is speaking about.
|
|
736
|
+
version: { type: "string" },
|
|
737
|
+
module: { type: "string" },
|
|
473
738
|
},
|
|
474
739
|
},
|
|
475
|
-
render: (
|
|
740
|
+
render: (args, value) => {
|
|
476
741
|
const result = value;
|
|
477
|
-
const filters = filtersOf(result);
|
|
742
|
+
const filters = filtersOf(result, defaultedVersion(options, args) ? "the stand's default" : "");
|
|
478
743
|
if (result.hits.length === 0) {
|
|
479
744
|
return [
|
|
480
745
|
{
|
|
@@ -493,10 +758,10 @@ export function createDocsSearchTool() {
|
|
|
493
758
|
},
|
|
494
759
|
},
|
|
495
760
|
execute: async (args, exec) => {
|
|
496
|
-
const root = await docsRootOf(exec);
|
|
761
|
+
const root = await docsRootOf(exec, options);
|
|
497
762
|
return searchDocumentation(root, {
|
|
498
763
|
query: args.query,
|
|
499
|
-
version: args
|
|
764
|
+
version: versionFor(options, args),
|
|
500
765
|
module: args.module,
|
|
501
766
|
path: args.path,
|
|
502
767
|
limit: args.limit,
|
|
@@ -504,11 +769,48 @@ export function createDocsSearchTool() {
|
|
|
504
769
|
},
|
|
505
770
|
});
|
|
506
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Whether the version in an answer is the deployment's default rather than one
|
|
774
|
+
* the caller named — the difference between "I looked in 3.8" and "the stand
|
|
775
|
+
* looked in 3.8", which the report has to keep.
|
|
776
|
+
*/
|
|
777
|
+
function defaultedVersion(options, args) {
|
|
778
|
+
const request = (args ?? {});
|
|
779
|
+
const asked = request.version;
|
|
780
|
+
if (typeof asked === "string" && asked.trim() !== "")
|
|
781
|
+
return false;
|
|
782
|
+
if (typeof request.path === "string" && request.path.trim() !== "") {
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
return (options.defaultVersion ?? "").trim() !== "";
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* The sentence that tells the model which edition a search it did not scope
|
|
789
|
+
* itself will stay inside. Without it the model reads a filtered answer as the
|
|
790
|
+
* whole corpus and reports a missing document rather than a narrowed search.
|
|
791
|
+
*/
|
|
792
|
+
function defaultVersionNote(options) {
|
|
793
|
+
const version = (options.defaultVersion ?? "").trim();
|
|
794
|
+
return version === ""
|
|
795
|
+
? ""
|
|
796
|
+
: ` This stand documents version ${version} by default: a search without version and without path stays inside that edition, and pass version or path to look at another one.`;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* How the tool description names the tree: the model has to know where the
|
|
800
|
+
* documentation is before it decides to look, and a configured corpus is named
|
|
801
|
+
* by the stand rather than by the chat, so the sentence differs.
|
|
802
|
+
*/
|
|
803
|
+
function docsLayout(options) {
|
|
804
|
+
const root = (options.root ?? "").trim();
|
|
805
|
+
return root === ""
|
|
806
|
+
? `in the ${QA_DOCS_DIRECTORY}/ directory of this chat's workspace`
|
|
807
|
+
: `in the ${QA_DOCS_DIRECTORY}/ tree this stand publishes (reported as ${QA_DOCS_DIRECTORY}/<module>/<version>/…)`;
|
|
808
|
+
}
|
|
507
809
|
/** `docs_read` — open one documentation file at a bounded window of lines. */
|
|
508
|
-
export function createDocsReadTool() {
|
|
810
|
+
export function createDocsReadTool(options = {}) {
|
|
509
811
|
return defineTool({
|
|
510
812
|
name: "docs_read",
|
|
511
|
-
description: `Read one documentation file of
|
|
813
|
+
description: `Read one documentation file of the stand's documentation, which lives ${docsLayout(options)}, at a bounded window of lines. Use it after docs_search reports a path, to read the passage a hit belongs to. Only files inside ${QA_DOCS_DIRECTORY}/ can be read; directories, paths outside the tree and paths that leave it through a symbolic link are refused with the reason.`,
|
|
512
814
|
parameters: {
|
|
513
815
|
path: {
|
|
514
816
|
type: "string",
|
|
@@ -551,7 +853,7 @@ export function createDocsReadTool() {
|
|
|
551
853
|
},
|
|
552
854
|
},
|
|
553
855
|
execute: async (args, exec) => {
|
|
554
|
-
const root = await docsRootOf(exec);
|
|
856
|
+
const root = await docsRootOf(exec, options);
|
|
555
857
|
return readDocumentation(root, {
|
|
556
858
|
path: args.path,
|
|
557
859
|
from: args.from,
|