@f5-sales-demo/xcsh 22.3.3 → 22.4.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.
@@ -1,3 +1,12 @@
1
+ import {
2
+ DOCUMENTATION_CONTENT_TYPES,
3
+ DOCUMENTATION_LIFECYCLES,
4
+ DOCUMENTATION_TASK_TYPES,
5
+ type DocumentationContentType,
6
+ type DocumentationLifecycle,
7
+ type DocumentationRelatedDocument,
8
+ type DocumentationTaskType,
9
+ } from "./documentation-metadata";
1
10
  import type { InternalResource, InternalUrl } from "./types";
2
11
 
3
12
  export const DOCUMENTATION_SOURCES = ["docs-cloud-f5-com", "my-f5-com", "www-f5-com"] as const;
@@ -20,12 +29,34 @@ export interface DocumentationSearchResult {
20
29
  readonly stablePath: string;
21
30
  readonly snippet: string;
22
31
  readonly score: number;
32
+ readonly anchor?: string;
33
+ readonly heading?: string;
34
+ readonly canonicalUrl?: string;
35
+ readonly lastUpdated?: string | null;
36
+ readonly product?: string | null;
37
+ readonly contentType?: DocumentationContentType;
38
+ readonly taskType?: DocumentationTaskType;
39
+ readonly language?: string;
40
+ readonly lifecycle?: DocumentationLifecycle;
41
+ readonly replacementUrl?: string | null;
42
+ readonly aliases?: readonly string[];
43
+ readonly relatedDocuments?: readonly DocumentationRelatedDocument[];
23
44
  }
24
45
 
25
46
  export interface DocumentationDocument {
26
47
  readonly markdown: string;
27
48
  readonly title: string;
28
49
  readonly originalUrl: string;
50
+ readonly lifecycle?: DocumentationLifecycle;
51
+ readonly replacementUrl?: string | null;
52
+ }
53
+
54
+ export interface DocumentationSearchFilters {
55
+ readonly product?: string;
56
+ readonly contentType?: DocumentationContentType;
57
+ readonly taskType?: DocumentationTaskType;
58
+ readonly language?: string;
59
+ readonly lifecycle?: DocumentationLifecycle;
29
60
  }
30
61
 
31
62
  export interface DocumentationAsset {
@@ -39,8 +70,13 @@ export interface DocumentationRepository {
39
70
  query: string,
40
71
  source: DocumentationSource | undefined,
41
72
  limit: number,
73
+ filters?: DocumentationSearchFilters,
42
74
  ): Promise<readonly DocumentationSearchResult[]>;
43
- readDocument(source: DocumentationSource, stablePath: string): Promise<DocumentationDocument | null>;
75
+ readDocument(
76
+ source: DocumentationSource,
77
+ stablePath: string,
78
+ anchor?: string,
79
+ ): Promise<DocumentationDocument | null>;
44
80
  readAsset(source: DocumentationSource, stablePath: string, filename: string): Promise<DocumentationAsset | null>;
45
81
  }
46
82
 
@@ -53,9 +89,25 @@ const IMAGE_NAME = /^[a-f0-9]{64}\.(?:gif|jpe?g|png|svg|webp)$/;
53
89
  const SAFE_SEGMENT = /^[A-Za-z0-9._~-]+$/;
54
90
  const MAX_QUERY_BYTES = 512;
55
91
  const MAX_SNIPPET_CHARS = 600;
92
+ const PRODUCT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
93
+ const LANGUAGE = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
56
94
 
57
- function validateQuery(url: InternalUrl): { search?: string; source?: DocumentationSource; limit: number } {
58
- const allowed = new Set(["search", "source", "limit"]);
95
+ function validateQuery(url: InternalUrl): {
96
+ search?: string;
97
+ source?: DocumentationSource;
98
+ limit: number;
99
+ filters: DocumentationSearchFilters;
100
+ } {
101
+ const allowed = new Set([
102
+ "search",
103
+ "source",
104
+ "limit",
105
+ "product",
106
+ "content_type",
107
+ "task_type",
108
+ "language",
109
+ "lifecycle",
110
+ ]);
59
111
  for (const key of url.searchParams.keys()) {
60
112
  if (!allowed.has(key)) throw new Error(`Unknown documentation parameter: ${key}`);
61
113
  if (url.searchParams.getAll(key).length !== 1) throw new Error(`Duplicate documentation parameter: ${key}`);
@@ -73,12 +125,36 @@ function validateQuery(url: InternalUrl): { search?: string; source?: Documentat
73
125
  throw new Error("Documentation search limit must be an integer from 1 to 10");
74
126
  }
75
127
  if (!search && (sourceValue !== undefined || limitValue !== null)) {
76
- throw new Error("Documentation source and limit require search");
128
+ throw new Error("Documentation filters and limit require search");
129
+ }
130
+ const product = url.searchParams.get("product") ?? undefined;
131
+ if (product !== undefined && !PRODUCT.test(product)) throw new Error(`Invalid documentation product: ${product}`);
132
+ const contentType = url.searchParams.get("content_type") ?? undefined;
133
+ if (contentType !== undefined && !DOCUMENTATION_CONTENT_TYPES.includes(contentType as DocumentationContentType))
134
+ throw new Error(`Invalid documentation content type: ${contentType}`);
135
+ const taskType = url.searchParams.get("task_type") ?? undefined;
136
+ if (taskType !== undefined && !DOCUMENTATION_TASK_TYPES.includes(taskType as DocumentationTaskType))
137
+ throw new Error(`Invalid documentation task type: ${taskType}`);
138
+ const language = url.searchParams.get("language") ?? undefined;
139
+ if (language !== undefined && !LANGUAGE.test(language))
140
+ throw new Error(`Invalid documentation language: ${language}`);
141
+ const lifecycle = url.searchParams.get("lifecycle") ?? undefined;
142
+ if (lifecycle !== undefined && !DOCUMENTATION_LIFECYCLES.includes(lifecycle as DocumentationLifecycle))
143
+ throw new Error(`Invalid documentation lifecycle: ${lifecycle}`);
144
+ if (!search && [...url.searchParams.keys()].some(key => key !== "search")) {
145
+ throw new Error("Documentation filters and limit require search");
77
146
  }
78
147
  return {
79
148
  search,
80
149
  source: sourceValue as DocumentationSource | undefined,
81
150
  limit: limitValue === null ? 5 : Number(limitValue),
151
+ filters: {
152
+ product,
153
+ contentType: contentType as DocumentationContentType | undefined,
154
+ taskType: taskType as DocumentationTaskType | undefined,
155
+ language,
156
+ lifecycle: lifecycle as DocumentationLifecycle | undefined,
157
+ },
82
158
  };
83
159
  }
84
160
 
@@ -119,7 +195,7 @@ function inventory(repository: DocumentationRepository): string {
119
195
  "Sources:",
120
196
  ...DOCUMENTATION_SOURCES.map(source => `- \`${source}\``),
121
197
  "",
122
- "Search with `xcsh://documentation/?search=<query>&source=<optional-source>&limit=<1-10>`.",
198
+ "Search with `xcsh://documentation/?search=<query>&source=<optional-source>&product=<slug>&content_type=<type>&task_type=<type>&language=<tag>&lifecycle=<state>&limit=<1-10>`.",
123
199
  ].join("\n");
124
200
  }
125
201
 
@@ -136,17 +212,42 @@ function renderSearch(
136
212
  ];
137
213
  for (const result of results) {
138
214
  const snippet = result.snippet.replace(/\s+/g, " ").trim().slice(0, MAX_SNIPPET_CHARS);
139
- const followUp = `xcsh://documentation/${result.source}/${result.stablePath}/index.md`;
215
+ const followUp = `xcsh://documentation/${result.source}/${result.stablePath}/index.md${result.anchor ? `#${result.anchor}` : ""}`;
140
216
  lines.push(
141
217
  `## ${result.title}`,
142
218
  `- Source: \`${result.source}\``,
143
219
  `- Original: ${result.originalUrl}`,
220
+ `- Canonical: ${result.canonicalUrl ?? result.originalUrl}`,
221
+ `- Last updated: ${result.lastUpdated ?? "unknown"}`,
222
+ `- Product: ${result.product ?? "unclassified"}`,
223
+ `- Content type: ${result.contentType}`,
224
+ `- Task type: ${result.taskType}`,
225
+ `- Section: ${result.heading ?? result.anchor ?? "document"}`,
226
+ `- Language: ${result.language}`,
227
+ `- Lifecycle: ${result.lifecycle}`,
228
+ `- Aliases: ${result.aliases?.join(", ") || "none"}`,
144
229
  `- Score: ${result.score}`,
145
230
  `- Read: \`${followUp}\``,
146
231
  "",
147
232
  snippet,
148
233
  "",
149
234
  );
235
+ if (result.lifecycle && result.lifecycle !== "current") {
236
+ lines.push(
237
+ `> Warning: this document is ${result.lifecycle}.${result.replacementUrl ? ` Replacement: ${result.replacementUrl}` : ""}`,
238
+ "",
239
+ );
240
+ }
241
+ if ((result.relatedDocuments?.length ?? 0) > 0) {
242
+ lines.push(
243
+ "Related documents:",
244
+ ...(result.relatedDocuments ?? []).map(
245
+ related =>
246
+ `- ${related.relation}: [${related.title}](xcsh://documentation/${related.source}/${related.stablePath}/index.md)`,
247
+ ),
248
+ "",
249
+ );
250
+ }
150
251
  }
151
252
  if (results.length === 0) lines.push("No matching documents found.", "");
152
253
  return lines.join("\n");
@@ -159,7 +260,7 @@ export function createDocumentationResolver(repository: DocumentationRepository)
159
260
  const segments = parseResourcePath(url);
160
261
  if (query.search) {
161
262
  if (segments.length !== 0) throw new Error("Documentation search is available only at the root");
162
- const results = await repository.search(query.search, query.source, query.limit);
263
+ const results = await repository.search(query.search, query.source, query.limit, query.filters);
163
264
  return textResource(url, renderSearch(repository, query.search, results), "xcsh://documentation/");
164
265
  }
165
266
  if (segments.length === 0) {
@@ -171,9 +272,22 @@ export function createDocumentationResolver(repository: DocumentationRepository)
171
272
  if (rest.at(-1) === "index.md") {
172
273
  const stablePath = rest.slice(0, -1).join("/");
173
274
  if (!stablePath) throw new Error("Documentation path is missing");
174
- const document = await repository.readDocument(source, stablePath);
275
+ const rawAnchor = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
276
+ if (/%(?:2f|5c|2e)/i.test(rawAnchor)) throw new Error("Invalid documentation anchor");
277
+ const anchor = rawAnchor ? decodeURIComponent(rawAnchor) : undefined;
278
+ if (anchor && (anchor.length > 256 || !/^[\p{L}\p{N}\p{M}_-]+$/u.test(anchor)))
279
+ throw new Error("Invalid documentation anchor");
280
+ const document = await repository.readDocument(source, stablePath, anchor);
175
281
  if (!document) throw new Error(`Documentation file not found: ${source}/${stablePath}/index.md`);
176
- return textResource(url, document.markdown, `xcsh://documentation/${source}/${stablePath}/index.md`);
282
+ const warning =
283
+ !document.lifecycle || document.lifecycle === "current"
284
+ ? ""
285
+ : `> Warning: this document is ${document.lifecycle}.${document.replacementUrl ? ` Replacement: ${document.replacementUrl}` : ""}\n\n`;
286
+ return textResource(
287
+ url,
288
+ `${warning}${document.markdown}`,
289
+ `xcsh://documentation/${source}/${stablePath}/index.md${anchor ? `#${anchor}` : ""}`,
290
+ );
177
291
  }
178
292
  const assetsIndex = rest.length - 2;
179
293
  if (assetsIndex >= 1 && rest[assetsIndex] === "assets" && IMAGE_NAME.test(rest.at(-1) ?? "")) {
@@ -8,6 +8,14 @@ import { createGunzip, gunzipSync } from "node:zlib";
8
8
  import { createStore } from "@tobilu/qmd";
9
9
  import tar from "tar-stream";
10
10
  import { parse as parseYaml } from "yaml";
11
+ import {
12
+ type DocumentationMetadata,
13
+ type DocumentationPassage,
14
+ parseDocumentationMetadata,
15
+ passageHash,
16
+ splitHeadingPassages,
17
+ validateDocumentationGraph,
18
+ } from "./documentation-metadata";
11
19
  import { DOCUMENTATION_SOURCES, type DocumentationSource } from "./documentation-resolve";
12
20
 
13
21
  const MIB = 1024 * 1024;
@@ -33,6 +41,7 @@ const COMMIT = /^[a-f0-9]{40}$/;
33
41
  const RELEASE_TAG = /^content-[0-9]{8}T[0-9]{6}Z$/;
34
42
  const TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/;
35
43
  const FIXED_TIME = "2000-01-01T00:00:00.000Z";
44
+ const DOCUMENTATION_INDEX_SCHEMA_VERSION = 2;
36
45
  const MANIFEST_KEYS = [
37
46
  "asset_count",
38
47
  "assets",
@@ -112,6 +121,8 @@ export interface VerifiedDocumentationDocument {
112
121
  readonly fileSha256: string;
113
122
  readonly sizeBytes: number;
114
123
  readonly markdown: string;
124
+ readonly metadata: DocumentationMetadata;
125
+ readonly passages: readonly DocumentationPassage[];
115
126
  }
116
127
 
117
128
  export interface VerifiedDocumentationAsset {
@@ -198,6 +209,39 @@ function validateOriginalUrl(value: unknown, source: DocumentationSource, field:
198
209
  return result;
199
210
  }
200
211
 
212
+ function validateCorpusUrl(value: unknown, source: DocumentationSource | undefined, field: string): string {
213
+ if (source !== undefined) {
214
+ const result = validateOriginalUrl(value, source, field);
215
+ validateNormalizedUrl(result, field);
216
+ return result;
217
+ }
218
+ for (const candidate of DOCUMENTATION_SOURCES) {
219
+ try {
220
+ const result = validateOriginalUrl(value, candidate, field);
221
+ validateNormalizedUrl(result, field);
222
+ return result;
223
+ } catch {
224
+ // Continue until one complete source policy accepts the URL.
225
+ }
226
+ }
227
+ throw new Error(`${field} is outside the documentation corpus`);
228
+ }
229
+
230
+ function validateNormalizedUrl(value: string, field: string): void {
231
+ const parsed = new URL(value);
232
+ if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) {
233
+ throw new Error(`${field} is not a normalized HTTPS URL`);
234
+ }
235
+ const entries = [...parsed.searchParams.entries()].sort(
236
+ ([leftKey, leftValue], [rightKey, rightValue]) =>
237
+ leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue),
238
+ );
239
+ const normalizedQuery = new URLSearchParams(entries).toString();
240
+ const normalizedPath = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/{2,}/g, "/").replace(/\/$/, "");
241
+ const normalized = `${parsed.protocol}//${parsed.host}${normalizedPath}${normalizedQuery ? `?${normalizedQuery}` : ""}`;
242
+ if (normalized !== value) throw new Error(`${field} is not normalized`);
243
+ }
244
+
201
245
  export function sha256Bytes(value: Uint8Array | string): string {
202
246
  return createHash("sha256").update(value).digest("hex");
203
247
  }
@@ -616,6 +660,16 @@ export async function verifyDocumentationRelease(
616
660
  }
617
661
  if (parsed.metadata.sourceId !== source || parsed.metadata.url !== originalUrl)
618
662
  throw new Error(`manifest document frontmatter mismatch: ${archiveMemberPath}`);
663
+ const metadata = parseDocumentationMetadata(
664
+ parsed.metadata,
665
+ validateCorpusUrl,
666
+ source,
667
+ `document ${archiveMemberPath}`,
668
+ );
669
+ const passages = splitHeadingPassages(parsed.body);
670
+ if (new Set(passages.map(passage => passage.anchor)).size !== passages.length) {
671
+ throw new Error(`document heading anchors are not unique: ${archiveMemberPath}`);
672
+ }
619
673
  const bodySha256 = string(item.body_sha256, "manifest document body hash");
620
674
  const fileSha256 = string(item.file_sha256, "manifest document file hash");
621
675
  const sizeBytes = count(item.size_bytes, "manifest document size");
@@ -633,8 +687,11 @@ export async function verifyDocumentationRelease(
633
687
  fileSha256,
634
688
  sizeBytes,
635
689
  markdown,
690
+ metadata,
691
+ passages,
636
692
  };
637
693
  });
694
+ validateDocumentationGraph(documents);
638
695
 
639
696
  const marketingDocumentPaths = new Set(
640
697
  documents.filter(document => document.source === "www-f5-com").map(document => document.stablePath),
@@ -690,6 +747,7 @@ function fingerprint(snapshot: VerifiedDocumentationSnapshot): string {
690
747
  return sha256Bytes(
691
748
  JSON.stringify({
692
749
  archiveSha256: snapshot.archiveSha256,
750
+ indexSchema: DOCUMENTATION_INDEX_SCHEMA_VERSION,
693
751
  releaseTag: snapshot.releaseTag,
694
752
  sourceCommit: snapshot.sourceCommit,
695
753
  qmdVersion: "2.8.3",
@@ -739,8 +797,47 @@ function canonicalizeDatabase(
739
797
  file_sha256 TEXT NOT NULL,
740
798
  size_bytes INTEGER NOT NULL,
741
799
  markdown TEXT NOT NULL,
800
+ canonical_url TEXT NOT NULL,
801
+ last_updated TEXT,
802
+ product TEXT,
803
+ content_type TEXT NOT NULL,
804
+ task_type TEXT NOT NULL,
805
+ language TEXT NOT NULL,
806
+ lifecycle TEXT NOT NULL,
807
+ replacement_url TEXT,
808
+ aliases_json TEXT NOT NULL,
809
+ related_documents_json TEXT NOT NULL,
742
810
  PRIMARY KEY (source, stable_path)
743
811
  )`);
812
+ db.exec(`CREATE TABLE documentation_passages (
813
+ source TEXT NOT NULL,
814
+ stable_path TEXT NOT NULL,
815
+ anchor TEXT NOT NULL,
816
+ heading TEXT NOT NULL,
817
+ markdown TEXT NOT NULL,
818
+ ordinal INTEGER NOT NULL,
819
+ qmd_path TEXT NOT NULL UNIQUE,
820
+ PRIMARY KEY (source, stable_path, anchor)
821
+ )`);
822
+ db.exec(`CREATE INDEX documentation_metadata_filters ON documentation_documents (
823
+ product, content_type, task_type, language, lifecycle, source
824
+ )`);
825
+ db.exec(`CREATE INDEX documentation_passage_page ON documentation_passages (source, stable_path, ordinal)`);
826
+ db.exec(`CREATE TABLE documentation_aliases (
827
+ product TEXT NOT NULL,
828
+ alias TEXT NOT NULL,
829
+ PRIMARY KEY (product, alias)
830
+ )`);
831
+ db.exec(`CREATE TABLE documentation_relationships (
832
+ source TEXT NOT NULL,
833
+ stable_path TEXT NOT NULL,
834
+ relation TEXT NOT NULL,
835
+ target_source TEXT NOT NULL,
836
+ target_stable_path TEXT NOT NULL,
837
+ title TEXT NOT NULL,
838
+ canonical_url TEXT NOT NULL,
839
+ PRIMARY KEY (source, stable_path, canonical_url)
840
+ )`);
744
841
  db.exec(`CREATE TABLE documentation_assets (
745
842
  source TEXT NOT NULL,
746
843
  stable_path TEXT NOT NULL,
@@ -757,11 +854,17 @@ function canonicalizeDatabase(
757
854
  asset_count: String(snapshot.assets.length),
758
855
  document_count: String(snapshot.documents.length),
759
856
  fingerprint: indexFingerprint,
857
+ index_schema: String(DOCUMENTATION_INDEX_SCHEMA_VERSION),
760
858
  release_tag: snapshot.releaseTag,
761
859
  source_commit: snapshot.sourceCommit,
762
860
  }).sort(([left], [right]) => left.localeCompare(right)))
763
861
  provenance.run(key, value);
764
- const insertDocument = db.query("INSERT INTO documentation_documents VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
862
+ const insertDocument = db.query(
863
+ "INSERT INTO documentation_documents VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
864
+ );
865
+ const insertPassage = db.query("INSERT INTO documentation_passages VALUES (?, ?, ?, ?, ?, ?, ?)");
866
+ const insertAlias = db.query("INSERT OR IGNORE INTO documentation_aliases VALUES (?, ?)");
867
+ const insertRelationship = db.query("INSERT INTO documentation_relationships VALUES (?, ?, ?, ?, ?, ?, ?)");
765
868
  for (const document of snapshot.documents) {
766
869
  insertDocument.run(
767
870
  document.source,
@@ -773,7 +876,42 @@ function canonicalizeDatabase(
773
876
  document.fileSha256,
774
877
  document.sizeBytes,
775
878
  document.markdown,
879
+ document.metadata.canonicalUrl,
880
+ document.metadata.lastUpdated,
881
+ document.metadata.product,
882
+ document.metadata.contentType,
883
+ document.metadata.taskType,
884
+ document.metadata.language,
885
+ document.metadata.lifecycle,
886
+ document.metadata.replacementUrl,
887
+ JSON.stringify(document.metadata.aliases),
888
+ JSON.stringify(document.metadata.relatedDocuments),
776
889
  );
890
+ for (const passage of document.passages) {
891
+ insertPassage.run(
892
+ document.source,
893
+ document.stablePath,
894
+ passage.anchor,
895
+ passage.heading,
896
+ passage.markdown,
897
+ passage.ordinal,
898
+ `${document.stablePath}/index.md#${passage.anchor}`,
899
+ );
900
+ }
901
+ if (document.metadata.product) {
902
+ for (const alias of document.metadata.aliases) insertAlias.run(document.metadata.product, alias);
903
+ }
904
+ for (const related of document.metadata.relatedDocuments) {
905
+ insertRelationship.run(
906
+ document.source,
907
+ document.stablePath,
908
+ related.relation,
909
+ related.source,
910
+ related.stablePath,
911
+ related.title,
912
+ related.canonicalUrl,
913
+ );
914
+ }
777
915
  }
778
916
  const insertAsset = db.query("INSERT INTO documentation_assets VALUES (?, ?, ?, ?, ?, ?, ?)");
779
917
  for (const asset of snapshot.assets) {
@@ -810,9 +948,15 @@ function verifyIndexedBodies(databasePath: string, snapshot: VerifiedDocumentati
810
948
  FROM documents d JOIN content c ON c.hash = d.hash WHERE d.active = 1
811
949
  ORDER BY d.collection, d.path`)
812
950
  .all() as Array<{ source: string; path: string; markdown: string }>;
813
- if (rows.length !== snapshot.documents.length) throw new Error("documentation index document count mismatch");
951
+ const passageCount = snapshot.documents.reduce((total, document) => total + document.passages.length, 0);
952
+ if (rows.length !== passageCount) throw new Error("documentation index passage count mismatch");
814
953
  const expected = new Map(
815
- snapshot.documents.map(document => [`${document.source}/${document.stablePath}/index.md`, document.markdown]),
954
+ snapshot.documents.flatMap(document =>
955
+ document.passages.map(passage => [
956
+ `${document.source}/${document.stablePath}/index.md#${passage.anchor}`,
957
+ passage.markdown,
958
+ ]),
959
+ ),
816
960
  );
817
961
  for (const row of rows) {
818
962
  const key = `${row.source}/${row.path.replaceAll("\\", "/")}`;
@@ -875,15 +1019,27 @@ export async function buildDocumentationIndex(
875
1019
  });
876
1020
  try {
877
1021
  for (const document of snapshot.documents) {
878
- store.internal.insertContent(document.fileSha256, document.markdown, FIXED_TIME);
879
- store.internal.insertDocument(
880
- document.source,
881
- `${document.stablePath}/index.md`,
1022
+ const weightedTitle = [
882
1023
  document.title,
883
- document.fileSha256,
884
- FIXED_TIME,
885
- FIXED_TIME,
886
- );
1024
+ document.metadata.product,
1025
+ document.metadata.contentType,
1026
+ document.metadata.taskType,
1027
+ ...document.metadata.aliases,
1028
+ ]
1029
+ .filter(Boolean)
1030
+ .join(" ");
1031
+ for (const passage of document.passages) {
1032
+ const hash = passageHash(document.fileSha256, passage.anchor, passage.markdown);
1033
+ store.internal.insertContent(hash, passage.markdown, FIXED_TIME);
1034
+ store.internal.insertDocument(
1035
+ document.source,
1036
+ `${document.stablePath}/index.md#${passage.anchor}`,
1037
+ weightedTitle,
1038
+ hash,
1039
+ FIXED_TIME,
1040
+ FIXED_TIME,
1041
+ );
1042
+ }
887
1043
  }
888
1044
  store.internal.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
889
1045
  store.internal.db.exec("PRAGMA journal_mode = DELETE");
@@ -22,7 +22,7 @@ import type {
22
22
  } from "../extensibility/extensions";
23
23
  import { discoverAndLoadExtensions } from "../extensibility/extensions";
24
24
  import type { CompactOptions } from "../extensibility/extensions/types";
25
- import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
25
+ import { getBuiltinSlashCommandDiscoveryCandidates, loadSlashCommands } from "../extensibility/slash-commands";
26
26
  import { startSessionBridge } from "../remote-control/bridge";
27
27
  import type { ModelResolutionSource } from "../session/active-model";
28
28
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
@@ -254,10 +254,7 @@ export class InteractiveMode implements InteractiveModeContext {
254
254
 
255
255
  this.hideThinkingBlock = settings.get("hideThinkingBlock");
256
256
 
257
- const builtinCommands: SlashCommandDiscoveryCandidate[] = BUILTIN_SLASH_COMMANDS.map(command => ({
258
- ...command,
259
- discovery: { behavior: "execution", provenance: "xcsh built-in", scope: "native" },
260
- }));
257
+ const builtinCommands = getBuiltinSlashCommandDiscoveryCandidates();
261
258
  const builtinCommandNames = new Set(builtinCommands.map(c => c.name));
262
259
  const extensionCommands: SlashCommandDiscoveryCandidate[] = (
263
260
  this.session.extensionRunner?.getRegisteredCommandEntries(builtinCommandNames) ?? []
@@ -1,11 +1,13 @@
1
1
  # Documentation Lookup Hierarchy (Offline QMD First)
2
2
 
3
3
  For conceptual, product, operational, and support questions, search `xcsh://documentation/?search=<query>` first.
4
- Select the best result and read its exact follow-up URL,
5
- `xcsh://documentation/<source>/<stable-path>/index.md`, before answering. Use the source filter when the question
4
+ Select the best result and read its exact section-qualified follow-up URL,
5
+ `xcsh://documentation/<source>/<stable-path>/index.md#<anchor>`, before answering. Use the source filter when the question
6
6
  clearly targets `docs-cloud-f5-com`, `my-f5-com`, or `www-f5-com`. Prefer `www-f5-com` for conceptual and product
7
7
  questions, `docs-cloud-f5-com` for configuration and procedures, and `my-f5-com` for troubleshooting and support.
8
- An explicit source filter is authoritative.
8
+ An explicit source filter is authoritative. Add `product`, `content_type`, `task_type`, `language`, or `lifecycle`
9
+ filters only when the request makes that constraint explicit. Follow related internal documentation links when they
10
+ are relevant, and always surface deprecated or superseded lifecycle warnings and replacements.
9
11
 
10
12
  Render documentation images only when the user explicitly asks to see them. Extract all unique Markdown image references
11
13
  from the exact document, resolve relative references against its exact `xcsh://documentation/` URI, and
@@ -491,11 +491,13 @@ In `bash`, URIs auto-resolve to filesystem paths (e.g., `python skill://my-skill
491
491
  # Product and ecosystem knowledge
492
492
 
493
493
  For conceptual, product, operational, or support questions about F5 Distributed Cloud, you **MUST**
494
- search `xcsh://documentation/?search=<query>` first, then read the exact
495
- `xcsh://documentation/<source>/<stable-path>/index.md` result before answering. The search result
494
+ search `xcsh://documentation/?search=<query>` first, then read the exact section-qualified
495
+ `xcsh://documentation/<source>/<stable-path>/index.md#<anchor>` result before answering. The search result
496
496
  is discovery; the exact Markdown document is the evidence.
497
497
  Prefer `www-f5-com` for conceptual and product questions, `docs-cloud-f5-com` for configuration and
498
498
  procedural questions, and `my-f5-com` for troubleshooting and support. An explicit source filter is authoritative.
499
+ Use `product`, `content_type`, `task_type`, `language`, and `lifecycle` filters when the request explicitly supplies
500
+ those constraints. Follow relevant typed related-document links and surface lifecycle warnings and replacements.
499
501
 
500
502
  ### Documentation images
501
503
 
@@ -122,6 +122,7 @@ export interface SubcommandDef {
122
122
  /** Declarative builtin slash command definition used by autocomplete and help UI. */
123
123
  export interface BuiltinSlashCommand {
124
124
  name: string;
125
+ aliases?: string[];
125
126
  description: string;
126
127
  /** Subcommands for dropdown completion. */
127
128
  subcommands?: SubcommandDef[];
@@ -137,7 +138,6 @@ interface ParsedBuiltinSlashCommand {
137
138
  }
138
139
 
139
140
  interface BuiltinSlashCommandSpec extends BuiltinSlashCommand {
140
- aliases?: string[];
141
141
  allowArgs?: boolean;
142
142
  /**
143
143
  * Handle the command. Return a string to pass remaining text through as prompt input.
@@ -2508,6 +2508,7 @@ export function getBuiltinSlashCommandInventory() {
2508
2508
  export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BUILTIN_SLASH_COMMAND_REGISTRY.map(
2509
2509
  command => ({
2510
2510
  name: command.name,
2511
+ aliases: command.aliases,
2511
2512
  description: command.description,
2512
2513
  subcommands: command.subcommands,
2513
2514
  inlineHint: command.inlineHint,