@typeship-ax/cli 0.22.0 → 0.23.1

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.
Files changed (138) hide show
  1. package/AGENTS.md +12 -8
  2. package/README.md +14 -27
  3. package/api.json +8898 -9026
  4. package/api.md +369 -327
  5. package/dist/arguments.d.ts +47 -0
  6. package/dist/arguments.d.ts.map +1 -0
  7. package/dist/arguments.js +254 -0
  8. package/dist/cli-agent.d.ts +31 -8
  9. package/dist/cli-agent.d.ts.map +1 -1
  10. package/dist/cli-agent.js +146 -28
  11. package/dist/cli.js +483 -237
  12. package/dist/core/http.d.ts +162 -19
  13. package/dist/core/http.d.ts.map +1 -1
  14. package/dist/core/http.js +381 -48
  15. package/dist/core/pagination.d.ts +42 -6
  16. package/dist/core/pagination.d.ts.map +1 -1
  17. package/dist/core/pagination.js +111 -17
  18. package/dist/credential-storage.d.ts +10 -3
  19. package/dist/credential-storage.d.ts.map +1 -1
  20. package/dist/credential-storage.js +15 -6
  21. package/dist/dates.d.ts +1 -1
  22. package/dist/dates.js +1 -1
  23. package/dist/errors.d.ts +20 -84
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +20 -108
  26. package/dist/fields.d.ts +29 -0
  27. package/dist/fields.d.ts.map +1 -0
  28. package/dist/fields.js +101 -0
  29. package/dist/index.d.ts +28 -18
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +35 -25
  32. package/dist/named-credentials.d.ts +19 -0
  33. package/dist/named-credentials.d.ts.map +1 -1
  34. package/dist/named-credentials.js +81 -1
  35. package/dist/oauth-login.d.ts +8 -2
  36. package/dist/oauth-login.d.ts.map +1 -1
  37. package/dist/oauth-login.js +31 -19
  38. package/dist/oauth-request.d.ts +7 -1
  39. package/dist/oauth-request.d.ts.map +1 -1
  40. package/dist/oauth-request.js +26 -4
  41. package/dist/oauth-session.d.ts +13 -1
  42. package/dist/oauth-session.d.ts.map +1 -1
  43. package/dist/oauth-session.js +34 -18
  44. package/dist/ops.d.ts +53 -5
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +49 -40
  47. package/dist/polling-login.d.ts +8 -2
  48. package/dist/polling-login.d.ts.map +1 -1
  49. package/dist/polling-login.js +25 -11
  50. package/dist/resources/api-keys.d.ts +10 -7
  51. package/dist/resources/api-keys.d.ts.map +1 -1
  52. package/dist/resources/api-keys.js +10 -31
  53. package/dist/resources/deliveries.d.ts +88 -4
  54. package/dist/resources/deliveries.d.ts.map +1 -1
  55. package/dist/resources/deliveries.js +95 -18
  56. package/dist/resources/drafts.d.ts +15 -15
  57. package/dist/resources/drafts.d.ts.map +1 -1
  58. package/dist/resources/drafts.js +11 -64
  59. package/dist/resources/files.d.ts +4 -4
  60. package/dist/resources/files.d.ts.map +1 -1
  61. package/dist/resources/files.js +3 -12
  62. package/dist/resources/generations.d.ts +14 -14
  63. package/dist/resources/generations.d.ts.map +1 -1
  64. package/dist/resources/generations.js +21 -45
  65. package/dist/resources/organization.d.ts +4 -4
  66. package/dist/resources/organization.d.ts.map +1 -1
  67. package/dist/resources/organization.js +3 -10
  68. package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
  69. package/dist/resources/packages.d.ts.map +1 -0
  70. package/dist/resources/{generate.js → packages.js} +13 -29
  71. package/dist/resources/projects.d.ts +50 -50
  72. package/dist/resources/projects.d.ts.map +1 -1
  73. package/dist/resources/projects.js +60 -116
  74. package/dist/resources/releases.d.ts +21 -16
  75. package/dist/resources/releases.d.ts.map +1 -1
  76. package/dist/resources/releases.js +18 -39
  77. package/dist/resources/spec-revisions.d.ts +15 -6
  78. package/dist/resources/spec-revisions.d.ts.map +1 -1
  79. package/dist/resources/spec-revisions.js +6 -28
  80. package/dist/resources/specs.d.ts +7 -7
  81. package/dist/resources/specs.d.ts.map +1 -1
  82. package/dist/resources/specs.js +6 -34
  83. package/dist/resources/targets.d.ts +48 -48
  84. package/dist/resources/targets.d.ts.map +1 -1
  85. package/dist/resources/targets.js +58 -114
  86. package/dist/schemas.d.ts.map +1 -1
  87. package/dist/schemas.js +83 -81
  88. package/dist/search.d.ts +54 -0
  89. package/dist/search.d.ts.map +1 -0
  90. package/dist/search.js +421 -0
  91. package/dist/types.d.ts +499 -339
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/types.js +18 -18
  94. package/package.json +5 -2
  95. package/src/arguments.ts +242 -0
  96. package/src/cli-agent.ts +156 -30
  97. package/src/cli.ts +444 -211
  98. package/src/core/http.ts +457 -58
  99. package/src/core/pagination.ts +129 -18
  100. package/src/credential-storage.ts +16 -6
  101. package/src/dates.ts +1 -1
  102. package/src/errors.ts +46 -115
  103. package/src/fields.ts +91 -0
  104. package/src/index.ts +45 -28
  105. package/src/named-credentials.ts +66 -1
  106. package/src/oauth-login.ts +36 -21
  107. package/src/oauth-request.ts +32 -6
  108. package/src/oauth-session.ts +37 -19
  109. package/src/ops.ts +82 -44
  110. package/src/polling-login.ts +24 -11
  111. package/src/resources/api-keys.ts +34 -48
  112. package/src/resources/deliveries.ts +211 -30
  113. package/src/resources/drafts.ts +60 -107
  114. package/src/resources/files.ts +19 -20
  115. package/src/resources/generations.ts +57 -75
  116. package/src/resources/organization.ts +11 -16
  117. package/src/resources/{generate.ts → packages.ts} +43 -51
  118. package/src/resources/projects.ts +145 -200
  119. package/src/resources/releases.ts +48 -65
  120. package/src/resources/spec-revisions.ts +38 -47
  121. package/src/resources/specs.ts +39 -59
  122. package/src/resources/targets.ts +143 -193
  123. package/src/schemas.ts +83 -81
  124. package/src/search.ts +434 -0
  125. package/src/types.ts +538 -357
  126. package/dist/console-login-check.d.ts +0 -21
  127. package/dist/console-login-check.d.ts.map +0 -1
  128. package/dist/console-login-check.js +0 -107
  129. package/dist/console-login-contract.d.ts +0 -45
  130. package/dist/console-login-contract.d.ts.map +0 -1
  131. package/dist/console-login-contract.js +0 -40
  132. package/dist/resources/generate.d.ts.map +0 -1
  133. package/dist/resources/publications.d.ts +0 -47
  134. package/dist/resources/publications.d.ts.map +0 -1
  135. package/dist/resources/publications.js +0 -70
  136. package/src/console-login-check.ts +0 -88
  137. package/src/console-login-contract.ts +0 -65
  138. package/src/resources/publications.ts +0 -140
package/src/search.ts ADDED
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Operation search shared by the generated CLI (`docs search`) and MCP
3
+ * server (`search_docs`). Generated by Typeship — https://typeship.dev
4
+ *
5
+ * Agents describe a task ("assign issue", "list calls"), not an operation
6
+ * name, so ranking works on stems, weighs rare words over common ones
7
+ * (IDF), and reads the verb of the query against what each operation does:
8
+ * its HTTP method and whether its path names one item or a collection, or
9
+ * its GraphQL root field. GraphQL mutations are also indexed by their input
10
+ * fields, so "assign issue" finds issueUpdate through assigneeId.
11
+ * Deterministic and dependency-free.
12
+ */
13
+
14
+ /** The parts of an operation search reads. The CLI and MCP operation
15
+ * specs both satisfy it. */
16
+ export interface SearchableOperation {
17
+ tool: string;
18
+ resource?: string;
19
+ method?: string;
20
+ httpMethod: string;
21
+ path: string;
22
+ summary?: string;
23
+ description?: string;
24
+ paginated?: boolean;
25
+ deprecated?: boolean;
26
+ graphql?: { kind: string; field?: string };
27
+ params: readonly { name: string; enum?: readonly string[]; description?: string }[];
28
+ inputSchema?: Record<string, unknown>;
29
+ outputSchema?: Record<string, unknown>;
30
+ }
31
+
32
+ /** Reference matches per search page. */
33
+ export const SEARCH_PAGE_SIZE = 10;
34
+
35
+ type Action = "list" | "get" | "create" | "update" | "delete" | "search";
36
+
37
+ /** Query verbs and the operation kinds they ask for, best first. */
38
+ const VERBS: Record<string, Action[]> = {
39
+ list: ["list"], all: ["list"], browse: ["list"], enumerate: ["list"],
40
+ search: ["search", "list"], find: ["search", "list", "get"], query: ["search", "list"], lookup: ["get", "search"],
41
+ get: ["get", "list"], release: ["delete"], fetch: ["get", "list"], retrieve: ["get", "list"], read: ["get", "list"], show: ["get", "list"],
42
+ view: ["get", "list"], check: ["get"], describe: ["get"], inspect: ["get"], status: ["get"],
43
+ create: ["create"], add: ["create", "update"], new: ["create"], make: ["create"], send: ["create"], post: ["create"],
44
+ submit: ["create"], open: ["create"], start: ["create", "update"], upload: ["create", "update"], generate: ["create"],
45
+ buy: ["create"], purchase: ["create"], place: ["create"], invite: ["create"], write: ["create", "update"],
46
+ publish: ["create", "update"], register: ["create"],
47
+ update: ["update"], change: ["update"], edit: ["update"], modify: ["update"], set: ["update", "create"],
48
+ assign: ["update", "create"], rename: ["update"], move: ["update"], close: ["update", "delete"], reopen: ["update"],
49
+ mark: ["update"], archive: ["delete", "update"], unarchive: ["update"], enable: ["update"], disable: ["update"],
50
+ pause: ["update"], resume: ["update"], replace: ["update"], patch: ["update"], merge: ["update", "create"],
51
+ save: ["create", "update"], follow: ["create", "update"], unfollow: ["delete", "update"], comment: ["create"], reply: ["create"], request: ["create"], hang: ["update", "delete"],
52
+ delete: ["delete"], remove: ["delete", "update"], destroy: ["delete"], erase: ["delete"], drop: ["delete"],
53
+ revoke: ["delete"], cancel: ["delete", "update"], unassign: ["update", "delete"], clear: ["delete"],
54
+ };
55
+
56
+ /** Words that name the caller: "who am I", "my issues", "me". */
57
+ const IDENTITY_WORDS = new Set(["who", "whoami", "me", "my", "mine", "myself", "self"]);
58
+ const IDENTITY_TERMS = ["me", "self", "whoami", "authenticated"];
59
+
60
+ /** An operation about the caller: GET /user, /me/…, a viewer or me root
61
+ * field, users_get_authenticated. */
62
+ function isIdentityOperation(op: SearchableOperation): boolean {
63
+ if (op.graphql?.field) return /^(viewer|me|currentUser|whoami)$/.test(op.graphql.field);
64
+ const nameWords = words([op.tool, op.method ?? ""].join(" "));
65
+ if (nameWords.some((w) => w === "me" || w === "whoami" || w === "authenticated" || w === "self")) return true;
66
+ return /^(\/v\d+)?\/(me|user|self|whoami)(\/|$)/i.test(op.path) || /\/(users\/)?me(\/|$)/i.test(op.path);
67
+ }
68
+
69
+ /** Words that narrow a request rather than name the operation. */
70
+ const QUALIFIERS = new Set(["yesterday", "today", "tomorrow", "recent", "recently", "latest", "last", "week", "month", "year", "daily"]);
71
+
72
+ /** A few everyday words for what APIs call something else. */
73
+ const SYNONYMS: Record<string, string[]> = {
74
+ now: ["current"], sms: ["message"], mms: ["message"], text: ["message"], document: ["file"], kyc: ["identity", "verification"],
75
+ };
76
+
77
+ const STOPWORDS = new Set([
78
+ "a", "an", "the", "to", "of", "for", "in", "on", "at", "from", "by", "with", "and", "or", "is", "are", "am", "be",
79
+ "do", "does", "it", "its", "this", "that", "these", "those", "into", "onto", "up", "how", "what", "which", "can",
80
+ "please", "via", "using", "some", "any", "there", "i", "you", "your", "we", "our",
81
+ ]);
82
+
83
+ /** Field weights: a word in the operation's name says most about it. */
84
+ const W_NAME = 10, W_SUMMARY = 6, W_PATH = 4, W_PARAM = 3, W_ENUM = 2, W_DESCRIPTION = 1.5, W_OUTPUT = 1;
85
+
86
+ /** A light English stemmer: plurals, -ing/-ed, a few derivational
87
+ * suffixes, a trailing e. It only has to map a word and its common
88
+ * variants to one stem ("assignee", "assigned", "assigns" to "assign";
89
+ * "moderate", "moderations" to "moder"). */
90
+ export function stem(word: string): string {
91
+ let w = word.toLowerCase();
92
+ if (w.length <= 3) return w;
93
+ if (w.endsWith("ies") && w.length > 4) w = w.slice(0, -3) + "y";
94
+ else if (/(sses|xes|ches|shes|zes)$/.test(w)) w = w.slice(0, -2);
95
+ else if (w.endsWith("s") && !/(ss|us|is)$/.test(w)) w = w.slice(0, -1);
96
+ let stripped = false;
97
+ if (w.endsWith("ly") && w.length >= 6) w = w.slice(0, -2);
98
+ for (const [suffix, min] of [["ing", 4], ["ed", 3]] as const) {
99
+ if (w.endsWith(suffix) && w.length - suffix.length >= min) {
100
+ w = w.slice(0, -suffix.length);
101
+ stripped = true;
102
+ break;
103
+ }
104
+ }
105
+ if (stripped) {
106
+ if (/(at|bl|iz)$/.test(w)) w += "e";
107
+ else if (/([^aeioulsz])\1$/.test(w)) w = w.slice(0, -1);
108
+ }
109
+ for (const [suffix, min] of [["ations", 4], ["ation", 4], ["ments", 4], ["ment", 4], ["ption", 4], ["ions", 4], ["ion", 4], ["ers", 4], ["er", 4], ["ee", 4], ["ate", 4]] as const) {
110
+ if (w.endsWith(suffix) && w.length - suffix.length >= min) {
111
+ w = w.slice(0, -suffix.length);
112
+ break;
113
+ }
114
+ }
115
+ if (w.endsWith("e") && w.length >= 5) w = w.slice(0, -1);
116
+ return w;
117
+ }
118
+
119
+ /** Lowercase words of two or more characters, with snake, kebab, camel and
120
+ * letter-digit seams split so "createAccount" and "accounts_create" share
121
+ * words. */
122
+ function words(text: string): string[] {
123
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([a-zA-Z])([0-9])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 2);
124
+ }
125
+
126
+ function stems(text: string): string[] {
127
+ return words(text).filter((w) => !STOPWORDS.has(w)).map(stem);
128
+ }
129
+
130
+ interface IndexedOperation {
131
+ /** stem -> best field weight, and the sum of the others. */
132
+ weights: Map<string, { best: number; rest: number }>;
133
+ /** Distinct stems of the operation's name, less its action word. */
134
+ nameStems: string[];
135
+ action: Action;
136
+ identity: boolean;
137
+ /** The summary's stems, for a query that is the summary. */
138
+ summary: string;
139
+ /** The stem of the name's verb ("add" in issue_add_label), if any. */
140
+ actionStem: string | null;
141
+ deprecated: boolean;
142
+ }
143
+
144
+ interface SearchIndex {
145
+ docs: IndexedOperation[];
146
+ /** Document frequency of each stem. */
147
+ df: Map<string, number>;
148
+ vocabulary: string[];
149
+ }
150
+
151
+ // A server keeps one operation list, and a hosted one filters the same
152
+ // operation objects per request: cache by both.
153
+ const INDEXES = new WeakMap<readonly SearchableOperation[], SearchIndex>();
154
+ const INDEXED = new WeakMap<SearchableOperation, IndexedOperation>();
155
+
156
+ const READ_ACTIONS: readonly Action[] = ["list", "get", "search"];
157
+ const WRITE_ACTIONS: readonly Action[] = ["create", "update", "delete"];
158
+
159
+ /** The first of `words` (in order) that names an allowed action. */
160
+ function actionIn(candidates: string[], allowed: readonly Action[]): { action: Action; word: string } | undefined {
161
+ for (const word of candidates) {
162
+ const action = VERBS[word]?.[0];
163
+ if (action && allowed.includes(action)) return { action, word };
164
+ }
165
+ return undefined;
166
+ }
167
+
168
+ /** What an operation does, and the word of its name that says so: its
169
+ * name's verb when it has one, else its HTTP method and whether the path
170
+ * ends at one item or a collection. A read never creates, updates or
171
+ * deletes, whatever its name ("query comment" is a read of a comment). */
172
+ function operationAction(op: SearchableOperation): { action: Action; word: string | null } {
173
+ const collection = (): Action => {
174
+ const segments = op.path.replace(/\.[a-z]+$/i, "").split("/").filter(Boolean);
175
+ const last = segments.at(-1) ?? "";
176
+ if (last.startsWith("{") || last.startsWith(":")) return "get";
177
+ return op.paginated ? "list" : "get";
178
+ };
179
+ if (op.graphql?.field) {
180
+ // issueUpdate, createIssue: the verb may sit at either end.
181
+ const fieldWords = words(op.graphql.field);
182
+ const read = op.graphql.kind !== "mutation";
183
+ const found = actionIn([...fieldWords].reverse(), read ? READ_ACTIONS : WRITE_ACTIONS);
184
+ if (found) return found;
185
+ return { action: read ? (op.paginated || /s$/.test(op.graphql.field) ? "list" : "get") : "update", word: null };
186
+ }
187
+ const verb = op.httpMethod.toUpperCase();
188
+ const read = verb === "GET" || verb === "HEAD";
189
+ const found = actionIn(words(op.method ?? ""), read ? READ_ACTIONS : [...WRITE_ACTIONS, "search"]);
190
+ if (found) return found;
191
+ if (verb === "DELETE") return { action: "delete", word: null };
192
+ if (verb === "PUT" || verb === "PATCH") return { action: "update", word: null };
193
+ if (verb === "POST") return { action: "create", word: null };
194
+ return { action: collection(), word: null };
195
+ }
196
+
197
+ /** Literal path words: no parameters, versions or date segments. */
198
+ function pathWords(path: string): string {
199
+ return path.replace(/\.[a-z]+$/i, "").split("/").filter((s) => s && !/^[{:]/.test(s) && !/^v\d+$/i.test(s) && !/^\d/.test(s)).join(" ");
200
+ }
201
+
202
+ /** Property names and enum values of a schema, one level into objects,
203
+ * and one more for object-typed properties (a GraphQL input object). */
204
+ function schemaWords(schema: unknown, depth: number, names: string[], enums: string[]): void {
205
+ if (!schema || typeof schema !== "object" || depth < 0) return;
206
+ const node = schema as Record<string, unknown>;
207
+ if (Array.isArray(node.enum)) for (const value of node.enum) if (typeof value === "string") enums.push(value);
208
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
209
+ if (Array.isArray(node[key])) for (const part of node[key] as unknown[]) schemaWords(part, depth, names, enums);
210
+ }
211
+ if (node.items) schemaWords(node.items, depth, names, enums);
212
+ const properties = node.properties as Record<string, unknown> | undefined;
213
+ if (properties && typeof properties === "object") {
214
+ for (const [name, child] of Object.entries(properties)) {
215
+ names.push(name);
216
+ schemaWords(child, depth - 1, names, enums);
217
+ }
218
+ }
219
+ }
220
+
221
+ function outputWords(schema: unknown, depth: number, out: string[]): void {
222
+ if (!schema || typeof schema !== "object" || depth < 0) return;
223
+ const node = schema as Record<string, unknown>;
224
+ if (typeof node.description === "string") out.push(node.description.slice(0, 200));
225
+ if (node.items) outputWords(node.items, depth, out);
226
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
227
+ if (Array.isArray(node[key])) for (const part of node[key] as unknown[]) outputWords(part, depth, out);
228
+ }
229
+ const properties = node.properties as Record<string, unknown> | undefined;
230
+ if (properties && typeof properties === "object") {
231
+ for (const [name, child] of Object.entries(properties)) {
232
+ out.push(name);
233
+ outputWords(child, depth - 1, out);
234
+ }
235
+ }
236
+ }
237
+
238
+ function firstSentence(text: string): string {
239
+ return /^(.{1,200}?[.!?])(\s|$)/s.exec(text.trim())?.[1] ?? "";
240
+ }
241
+
242
+ function indexOperation(op: SearchableOperation): IndexedOperation {
243
+ const weights = new Map<string, { best: number; rest: number }>();
244
+ const add = (text: string, weight: number) => {
245
+ for (const s of new Set(stems(text))) {
246
+ const entry = weights.get(s);
247
+ if (!entry) weights.set(s, { best: weight, rest: 0 });
248
+ else if (weight > entry.best) { entry.rest += entry.best; entry.best = weight; }
249
+ else entry.rest += weight;
250
+ }
251
+ };
252
+ // GraphQL tools are named query_<field> / mutation_<field>; the prefix is
253
+ // the kind, not a word a task would use.
254
+ const name = op.graphql?.field
255
+ ? [op.graphql.field]
256
+ : [op.tool, op.resource ?? "", op.method ?? ""];
257
+ add(name.join(" "), W_NAME);
258
+ // Without a summary, the description's first sentence plays its part.
259
+ const summary = op.summary?.trim() || firstSentence(op.description ?? "");
260
+ add(summary, W_SUMMARY);
261
+ add(pathWords(op.path), W_PATH);
262
+ const paramNames: string[] = [];
263
+ const enumValues: string[] = [];
264
+ const paramText: string[] = [];
265
+ for (const param of op.params) {
266
+ paramNames.push(param.name);
267
+ if (param.enum) enumValues.push(...param.enum);
268
+ if (param.description) paramText.push(param.description.split("\n")[0]!.slice(0, 200));
269
+ }
270
+ const properties = (op.inputSchema?.properties ?? {}) as Record<string, unknown>;
271
+ for (const child of Object.values(properties)) schemaWords(child, 1, paramNames, enumValues);
272
+ add(paramNames.join(" "), W_PARAM);
273
+ add(enumValues.join(" "), W_ENUM);
274
+ add(op.description ?? "", W_DESCRIPTION);
275
+ add(paramText.join(" "), W_OUTPUT);
276
+ const output: string[] = [];
277
+ outputWords(op.outputSchema, 2, output);
278
+ add(output.join(" "), W_OUTPUT);
279
+ const { action, word } = operationAction(op);
280
+ const actionStem = word === null ? null : stem(word);
281
+ const nameStems = [...new Set(stems(name.join(" ")))].filter((s) => s !== actionStem);
282
+ return { weights, nameStems, action, actionStem, identity: isIdentityOperation(op), summary: stems(summary).join(" "), deprecated: op.deprecated === true || /^deprecated\b/i.test(op.summary ?? "") };
283
+ }
284
+
285
+ function indexFor(ops: readonly SearchableOperation[]): SearchIndex {
286
+ const cached = INDEXES.get(ops);
287
+ if (cached) return cached;
288
+ const docs = ops.map((op) => {
289
+ let doc = INDEXED.get(op);
290
+ if (!doc) INDEXED.set(op, doc = indexOperation(op));
291
+ return doc;
292
+ });
293
+ const df = new Map<string, number>();
294
+ for (const doc of docs) for (const s of doc.weights.keys()) df.set(s, (df.get(s) ?? 0) + 1);
295
+ const index = { docs, df, vocabulary: [...df.keys()] };
296
+ INDEXES.set(ops, index);
297
+ return index;
298
+ }
299
+
300
+ /** A query stem's matches in the vocabulary: itself, and longer or
301
+ * shorter forms sharing a long prefix ("transcrib" and "transcript"),
302
+ * which count for less. */
303
+ function variants(term: string, index: SearchIndex): Map<string, number> {
304
+ const out = new Map<string, number>();
305
+ if (index.df.has(term)) out.set(term, 1);
306
+ if (term.length < 5) return out;
307
+ for (const word of index.vocabulary) {
308
+ if (word === term || word.length < 5) continue;
309
+ let common = 0;
310
+ while (common < word.length && common < term.length && word[common] === term[common]) common++;
311
+ if (common >= 6 && Math.max(word.length, term.length) - common <= 3) out.set(word, 0.6);
312
+ }
313
+ return out;
314
+ }
315
+
316
+ interface QueryTerm {
317
+ /** Vocabulary stem -> match strength. */
318
+ matches: Map<string, number>;
319
+ /** How much the term counts: the task's object most, its verb and
320
+ * qualifiers ("yesterday") least. */
321
+ weight: number;
322
+ /** The query's verb: the operation kinds it asks for, best first. */
323
+ verb: Action[] | null;
324
+ /** The first noun, usually the object of the task. */
325
+ primary: boolean;
326
+ stem: string;
327
+ }
328
+
329
+ function parseQuery(query: string, index: SearchIndex): { terms: QueryTerm[]; verb: Action[] | null } {
330
+ const raw = words(query);
331
+ let verb: Action[] | null = null;
332
+ let identity = false;
333
+ const terms: QueryTerm[] = [];
334
+ const seen = new Set<string>();
335
+ for (const word of raw) {
336
+ if (IDENTITY_WORDS.has(word)) { identity = true; continue; }
337
+ if (STOPWORDS.has(word)) continue;
338
+ const s = stem(word);
339
+ if (seen.has(s)) continue;
340
+ seen.add(s);
341
+ const actions = VERBS[word] ?? VERBS[s] ?? null;
342
+ const isVerb = actions !== null && actions.length > 0 && verb === null;
343
+ if (isVerb) verb = actions;
344
+ const matches = variants(s, index);
345
+ for (const synonym of SYNONYMS[word] ?? []) for (const [v, strength] of variants(stem(synonym), index)) matches.set(v, Math.max(matches.get(v) ?? 0, strength * 0.8));
346
+ // The verb is scored against what the operation does, so its word
347
+ // counts for little; the first noun is usually the object of the task.
348
+ const qualifier = QUALIFIERS.has(word);
349
+ const primary = !isVerb && !qualifier && !terms.some((t) => t.primary);
350
+ const weight = isVerb || qualifier ? 0.3 : primary ? 1.3 : 1;
351
+ terms.push({ matches, weight, verb: isVerb ? actions : null, primary, stem: s });
352
+ }
353
+ if (identity) {
354
+ const matches = new Map<string, number>();
355
+ for (const t of IDENTITY_TERMS) for (const [s, strength] of variants(stem(t), index)) matches.set(s, Math.max(matches.get(s) ?? 0, strength));
356
+ terms.push({ matches, weight: 0.8, verb: null, primary: false, stem: "@me" });
357
+ }
358
+ return { terms, verb };
359
+ }
360
+
361
+ export interface RankedOperation<T> {
362
+ op: T;
363
+ score: number;
364
+ }
365
+
366
+ /**
367
+ * Operations for a free-text query, best first. Weak matches (under a
368
+ * fifth of the best score, or matching only the query's verb) are left
369
+ * out, and deprecated operations follow every current one.
370
+ */
371
+ export function rankOperations<T extends SearchableOperation>(ops: readonly T[], query: string): RankedOperation<T>[] {
372
+ const index = indexFor(ops);
373
+ const { terms, verb } = parseQuery(query, index);
374
+ if (terms.length === 0) return [];
375
+ const idf = (s: string) => Math.log(1 + index.docs.length / (index.df.get(s) ?? index.docs.length));
376
+ const nonVerbTerms = terms.filter((t) => t.verb === null).length;
377
+ const primary = terms.find((t) => t.primary);
378
+ const verbStem = terms.find((t) => t.verb !== null)?.stem ?? null;
379
+ const identityTerm = terms.find((t) => t.stem === "@me");
380
+ const identityIdf = Math.log(1 + index.docs.length / Math.max(1, index.docs.filter((d) => d.identity).length));
381
+ const scored: { op: T; score: number; deprecated: boolean }[] = [];
382
+ const phrase = words(query).filter((w) => !STOPWORDS.has(w)).map(stem).join(" ");
383
+ index.docs.forEach((doc, position) => {
384
+ let score = 0;
385
+ let matchedNonVerb = 0;
386
+ let primaryInName = false;
387
+ let verbInName = false;
388
+ const covered = new Set<string>();
389
+ for (const term of terms) {
390
+ let best = term === identityTerm && doc.identity ? W_NAME * identityIdf : 0;
391
+ for (const [s, strength] of term.matches) {
392
+ const entry = doc.weights.get(s);
393
+ if (!entry) continue;
394
+ const value = (entry.best + 0.1 * Math.min(entry.rest, 10)) * strength * idf(s);
395
+ if (value > best) best = value;
396
+ if (doc.nameStems.includes(s)) {
397
+ covered.add(s);
398
+ if (term === primary) primaryInName = true;
399
+ // The query's verb as a noun of the name: upload_url for "upload".
400
+ if (term.verb !== null) verbInName = true;
401
+ }
402
+ }
403
+ if (best > 0 && term.verb === null) matchedNonVerb++;
404
+ score += best * term.weight;
405
+ }
406
+ if (score === 0) return;
407
+ if (nonVerbTerms > 0 && matchedNonVerb === 0) return;
408
+ // How much of the operation's name the query accounts for: call_list
409
+ // for "list calls" over sip_auth_calls_credential_list_mapping_delete.
410
+ const coverage = doc.nameStems.length === 0 ? 1 : covered.size / doc.nameStems.length;
411
+ score *= 0.6 + 0.8 * coverage;
412
+ // The query's verb against what the operation does.
413
+ if (verb) {
414
+ const rank = verb.indexOf(doc.action);
415
+ score *= rank === 0 ? 1.5 : rank > 0 ? 1.2 : doc.action === "search" && verb.includes("list") ? 1.2 : verbInName ? 1 : 0.55;
416
+ // The very word: issue_add_label for "add", over a generic create.
417
+ if (verbStem !== null && doc.actionStem === verbStem) score *= 1.2;
418
+ }
419
+ // Every query word matched: a stronger signal than any single word.
420
+ if (matchedNonVerb === nonVerbTerms && nonVerbTerms > 1) score *= 1.2;
421
+ if (phrase.includes(" ") && doc.nameStems.join(" ").includes(phrase)) score *= 1.2;
422
+ if (phrase.includes(" ") && doc.summary.includes(phrase)) score *= 1.3;
423
+ // The operation is named for the task's object: issue_update, not
424
+ // workflow_state_update, for "change issue state".
425
+ if (primaryInName) score *= 1.3;
426
+ scored.push({ op: ops[position]!, score, deprecated: doc.deprecated });
427
+ });
428
+ if (scored.length === 0) return [];
429
+ const top = Math.max(...scored.map((s) => s.score));
430
+ return scored
431
+ .filter((s) => s.score >= top * 0.2)
432
+ .sort((a, b) => Number(a.deprecated) - Number(b.deprecated) || b.score - a.score || a.op.tool.localeCompare(b.op.tool))
433
+ .map(({ op, score }) => ({ op, score: Math.round(score * 100) / 100 }));
434
+ }