@f5-sales-demo/xcsh 22.1.2 → 22.2.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.
package/CHANGELOG.md CHANGED
@@ -42,6 +42,10 @@
42
42
 
43
43
  ### Changed
44
44
 
45
+ - Made standalone executable and schema-v2 receipt updates transactional, canonicalized receipt paths,
46
+ removed unused adjacent native payloads from standalone installs, and added published installer
47
+ qualification on Linux, macOS, and Windows ([#4510](https://github.com/f5-sales-demo/xcsh/issues/4510)).
48
+
45
49
  - Standardized executable updates on `xcsh self-update`; `xcsh update` is now reserved for strict manifest resource updates ([#4487](https://github.com/f5-sales-demo/xcsh/issues/4487)).
46
50
 
47
51
  - Added `power.sleepPrevention` with an `idle` default and cumulative `off`, `idle`, `display`, and
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "22.1.2",
4
+ "version": "22.2.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -67,13 +67,13 @@
67
67
  },
68
68
  "dependencies": {
69
69
  "@agentclientprotocol/sdk": "1.4.0",
70
- "@f5-sales-demo/pi-agent-core": "22.1.2",
71
- "@f5-sales-demo/pi-ai": "22.1.2",
72
- "@f5-sales-demo/pi-natives": "22.1.2",
73
- "@f5-sales-demo/pi-resource-management": "22.1.2",
74
- "@f5-sales-demo/pi-tui": "22.1.2",
75
- "@f5-sales-demo/pi-utils": "22.1.2",
76
- "@f5-sales-demo/xcsh-stats": "22.1.2",
70
+ "@f5-sales-demo/pi-agent-core": "22.2.1",
71
+ "@f5-sales-demo/pi-ai": "22.2.1",
72
+ "@f5-sales-demo/pi-natives": "22.2.1",
73
+ "@f5-sales-demo/pi-resource-management": "22.2.1",
74
+ "@f5-sales-demo/pi-tui": "22.2.1",
75
+ "@f5-sales-demo/pi-utils": "22.2.1",
76
+ "@f5-sales-demo/xcsh-stats": "22.2.1",
77
77
  "@mozilla/readability": "^0.6",
78
78
  "@resvg/resvg-wasm": "2.6.2",
79
79
  "@sinclair/typebox": "0.34.52",
@@ -1,10 +1,12 @@
1
1
  /** Context-aware executable update policy and standalone replacement. */
2
2
  import * as fs from "node:fs";
3
3
  import { realpath } from "node:fs/promises";
4
+ import path from "node:path";
4
5
  import { pipeline } from "node:stream/promises";
5
6
  import { APP_NAME, isEnoent, VERSION } from "@f5-sales-demo/pi-utils";
6
7
  import chalk from "chalk";
7
8
  import {
9
+ createInstallReceipt,
8
10
  defaultInstallChannelDependencies,
9
11
  formatUpdateRecommendation,
10
12
  resolveInstallChannel,
@@ -70,23 +72,49 @@ export interface StandaloneReplacementOptions {
70
72
  arch?: NodeJS.Architecture;
71
73
  fetchImpl?(input: string | URL | Request, init?: RequestInit): Promise<Response>;
72
74
  rename?(source: string, destination: string): Promise<void>;
75
+ readFile?(file: string): Promise<string>;
76
+ writeFile?(file: string, contents: string): Promise<void>;
77
+ unlink?(file: string): Promise<void>;
73
78
  validate?(executable: string, expectedVersion: string): Promise<void>;
74
79
  }
75
80
 
76
- /** Atomically replace one proven standalone executable, restoring it on every failure. */
81
+ async function ignoreMissing(unlink: (file: string) => Promise<void>, file: string): Promise<void> {
82
+ try {
83
+ await unlink(file);
84
+ } catch (error) {
85
+ if (!isEnoent(error)) throw error;
86
+ }
87
+ }
88
+
89
+ /** Replace a proven standalone executable and receipt as one rollback-safe transaction. */
77
90
  export async function replaceStandaloneExecutable(
78
91
  expectedVersion: string,
79
92
  options: StandaloneReplacementOptions,
80
93
  ): Promise<void> {
81
94
  const targetPath = options.targetPath;
82
- const binaryName = getBinaryName(options.platform, options.arch);
95
+ const platform = options.platform ?? process.platform;
96
+ const arch = options.arch ?? process.arch;
97
+ const binaryName = getBinaryName(platform, arch);
83
98
  const url = `https://github.com/${REPO}/releases/download/v${expectedVersion}/${binaryName}`;
84
99
  const tempPath = `${targetPath}.new-${process.pid}`;
85
- const backupPath = `${targetPath}.bak`;
100
+ const receiptPath = path.join(path.dirname(targetPath), "xcsh-install.json");
101
+ const tempReceiptPath = `${receiptPath}.new-${process.pid}`;
102
+ const backupPath = `${targetPath}.bak-${process.pid}`;
103
+ const backupReceiptPath = `${receiptPath}.bak-${process.pid}`;
86
104
  const fetchImpl = options.fetchImpl ?? fetch;
87
105
  const rename = options.rename ?? fs.promises.rename;
106
+ const readFile = options.readFile ?? (file => fs.promises.readFile(file, "utf8"));
107
+ const writeFile =
108
+ options.writeFile ??
109
+ (async (file, contents) => fs.promises.writeFile(file, contents, { flag: "wx", mode: 0o644 }));
110
+ const unlink = options.unlink ?? fs.promises.unlink;
88
111
  const validate = options.validate ?? validateExecutableVersion;
89
- let originalMoved = false;
112
+ const receiptContents = `${JSON.stringify(createInstallReceipt("standalone", expectedVersion, targetPath, platform, arch))}\n`;
113
+ let originalBinaryMoved = false;
114
+ let originalReceiptMoved = false;
115
+ let replacementBinaryInstalled = false;
116
+ let replacementReceiptInstalled = false;
117
+ let committed = false;
90
118
 
91
119
  try {
92
120
  const response = await fetchImpl(url, { redirect: "follow" });
@@ -94,33 +122,69 @@ export async function replaceStandaloneExecutable(
94
122
  await pipeline(response.body, fs.createWriteStream(tempPath, { mode: 0o755, flags: "wx" }));
95
123
  await fs.promises.chmod(tempPath, 0o755);
96
124
  await validate(tempPath, expectedVersion);
125
+ await writeFile(tempReceiptPath, receiptContents);
126
+ if ((await readFile(tempReceiptPath)) !== receiptContents)
127
+ throw new Error("Staged install receipt validation failed");
97
128
 
98
- try {
99
- await fs.promises.unlink(backupPath);
100
- } catch (error) {
101
- if (!isEnoent(error)) throw error;
102
- }
103
129
  await rename(targetPath, backupPath);
104
- originalMoved = true;
130
+ originalBinaryMoved = true;
131
+ await rename(receiptPath, backupReceiptPath);
132
+ originalReceiptMoved = true;
105
133
  await rename(tempPath, targetPath);
134
+ replacementBinaryInstalled = true;
135
+ await rename(tempReceiptPath, receiptPath);
136
+ replacementReceiptInstalled = true;
106
137
  await validate(targetPath, expectedVersion);
107
- await fs.promises.unlink(backupPath);
108
- originalMoved = false;
138
+ if ((await readFile(receiptPath)) !== receiptContents) throw new Error("Installed receipt validation failed");
139
+ committed = true;
140
+ await ignoreMissing(unlink, backupPath).catch(() => {});
141
+ originalBinaryMoved = false;
142
+ await ignoreMissing(unlink, backupReceiptPath).catch(() => {});
143
+ originalReceiptMoved = false;
109
144
  } catch (error) {
110
- if (originalMoved) {
145
+ const rollbackErrors: unknown[] = [];
146
+ if (replacementReceiptInstalled) {
147
+ try {
148
+ await ignoreMissing(unlink, receiptPath);
149
+ replacementReceiptInstalled = false;
150
+ } catch (rollbackError) {
151
+ rollbackErrors.push(rollbackError);
152
+ }
153
+ }
154
+ if (originalReceiptMoved) {
155
+ try {
156
+ await rename(backupReceiptPath, receiptPath);
157
+ originalReceiptMoved = false;
158
+ } catch (rollbackError) {
159
+ rollbackErrors.push(rollbackError);
160
+ }
161
+ }
162
+ if (replacementBinaryInstalled) {
111
163
  try {
112
- await fs.promises.unlink(targetPath);
113
- } catch (unlinkError) {
114
- if (!isEnoent(unlinkError)) throw unlinkError;
164
+ await ignoreMissing(unlink, targetPath);
165
+ replacementBinaryInstalled = false;
166
+ } catch (rollbackError) {
167
+ rollbackErrors.push(rollbackError);
115
168
  }
116
- await rename(backupPath, targetPath);
117
- originalMoved = false;
118
169
  }
170
+ if (originalBinaryMoved) {
171
+ try {
172
+ await rename(backupPath, targetPath);
173
+ originalBinaryMoved = false;
174
+ } catch (rollbackError) {
175
+ rollbackErrors.push(rollbackError);
176
+ }
177
+ }
178
+ if (rollbackErrors.length > 0)
179
+ throw new AggregateError([error, ...rollbackErrors], "Self-update rollback failed");
119
180
  throw error;
120
181
  } finally {
121
- try {
122
- await fs.promises.unlink(tempPath);
123
- } catch {}
182
+ await ignoreMissing(unlink, tempPath).catch(() => {});
183
+ await ignoreMissing(unlink, tempReceiptPath).catch(() => {});
184
+ if (committed) {
185
+ await ignoreMissing(unlink, backupPath).catch(() => {});
186
+ await ignoreMissing(unlink, backupReceiptPath).catch(() => {});
187
+ }
124
188
  }
125
189
  }
126
190
 
@@ -37,8 +37,10 @@ export interface InstallChannelDependencies {
37
37
  run(command: string, args: string[]): Promise<CommandResult>;
38
38
  }
39
39
 
40
- interface InstallReceipt {
41
- schemaVersion: 1;
40
+ export const INSTALL_RECEIPT_SCHEMA_VERSION = 2;
41
+
42
+ export interface InstallReceipt {
43
+ schemaVersion: typeof INSTALL_RECEIPT_SCHEMA_VERSION;
42
44
  channel: "standalone" | "windows-installer";
43
45
  version: string;
44
46
  executablePath: string;
@@ -46,6 +48,8 @@ interface InstallReceipt {
46
48
  arch: string;
47
49
  }
48
50
 
51
+ const INSTALL_RECEIPT_KEYS = ["arch", "channel", "executablePath", "platform", "schemaVersion", "version"];
52
+
49
53
  function platformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
50
54
  return platform === "win32" ? path.win32 : path.posix;
51
55
  }
@@ -55,6 +59,25 @@ function comparablePath(value: string, platform: NodeJS.Platform): string {
55
59
  return platform === "win32" ? normalized.toLowerCase() : normalized;
56
60
  }
57
61
 
62
+ export function createInstallReceipt(
63
+ channel: InstallReceipt["channel"],
64
+ version: string,
65
+ executablePath: string,
66
+ platform: NodeJS.Platform,
67
+ arch: string,
68
+ ): InstallReceipt {
69
+ const pathApi = platformPath(platform);
70
+ if (!pathApi.isAbsolute(executablePath)) throw new Error("Install receipt executable path must be absolute");
71
+ return {
72
+ schemaVersion: INSTALL_RECEIPT_SCHEMA_VERSION,
73
+ channel,
74
+ version,
75
+ executablePath: pathApi.normalize(executablePath),
76
+ platform,
77
+ arch,
78
+ };
79
+ }
80
+
58
81
  function releaseAsset(platform: NodeJS.Platform, arch: string): string | undefined {
59
82
  if (platform === "linux" && (arch === "x64" || arch === "arm64")) return `xcsh-linux-${arch}`;
60
83
  if (platform === "darwin" && (arch === "x64" || arch === "arm64")) return `xcsh-darwin-${arch}`;
@@ -82,12 +105,15 @@ function receiptIsValid(
82
105
  if (!value || typeof value !== "object") return false;
83
106
  const receipt = value as Partial<InstallReceipt>;
84
107
  if (
85
- receipt.schemaVersion !== 1 ||
108
+ JSON.stringify(Object.keys(receipt).sort()) !== JSON.stringify(INSTALL_RECEIPT_KEYS) ||
109
+ receipt.schemaVersion !== INSTALL_RECEIPT_SCHEMA_VERSION ||
86
110
  (receipt.channel !== "standalone" && receipt.channel !== "windows-installer") ||
87
111
  receipt.version !== deps.version ||
88
112
  receipt.platform !== deps.platform ||
89
113
  receipt.arch !== deps.arch ||
90
114
  typeof receipt.executablePath !== "string" ||
115
+ !platformPath(deps.platform).isAbsolute(receipt.executablePath) ||
116
+ platformPath(deps.platform).normalize(receipt.executablePath) !== receipt.executablePath ||
91
117
  comparablePath(receipt.executablePath, deps.platform) !== comparablePath(resolvedExecPath, deps.platform)
92
118
  ) {
93
119
  return false;
@@ -233,7 +259,7 @@ export async function resolveInstallChannel(deps: InstallChannelDependencies): P
233
259
  if (receiptIsValid(parsed, deps, resolvedExecPath)) {
234
260
  signals.push({
235
261
  channel: parsed.channel,
236
- evidence: `valid schema-v1 ${parsed.channel} receipt at ${receiptPath}`,
262
+ evidence: `valid schema-v2 ${parsed.channel} receipt at ${receiptPath}`,
237
263
  });
238
264
  } else {
239
265
  diagnostics.push(`invalid or stale install receipt at ${receiptPath}`);
@@ -95,6 +95,48 @@ export function matchesBaselineCatalogDiscoveryTerm(term: string, values: readon
95
95
  return values.some(value => normalizeApiCatalogDiscoveryTerm(value).includes(normalized));
96
96
  }
97
97
 
98
+ function categoryDocuments(category: ApiCatalogCategory): ApiCatalogDiscoveryDocument[] {
99
+ const destination = `xcsh://api-catalog/${category.name}`;
100
+ if (category.operations.length === 0) {
101
+ return [
102
+ {
103
+ id: `category:${category.name}`,
104
+ categoryName: category.name,
105
+ markdown: [
106
+ `# ${category.displayName}`,
107
+ "",
108
+ `- Category: ${category.name}`,
109
+ `- Destination: ${destination}`,
110
+ "",
111
+ "## Operations",
112
+ "- None",
113
+ "",
114
+ ].join("\n"),
115
+ },
116
+ ];
117
+ }
118
+ return category.operations
119
+ .slice()
120
+ .sort((left, right) => left.operationId.localeCompare(right.operationId))
121
+ .map(operation => ({
122
+ id: `operation:${category.name}:${operation.operationId}`,
123
+ categoryName: category.name,
124
+ markdown: [
125
+ `# ${category.displayName}: ${operation.name}`,
126
+ "",
127
+ `- Category: ${category.name}`,
128
+ `- Destination: ${destination}`,
129
+ `- Operation: ${operation.name}`,
130
+ `- Alias: ${(operation.operationAliases ?? []).join(", ")}`,
131
+ `- Description: ${operation.description}`,
132
+ `- Method: ${operation.method.toUpperCase()}`,
133
+ `- Path: ${operation.path}`,
134
+ `- Operation ID: ${operation.operationId}`,
135
+ "",
136
+ ].join("\n"),
137
+ }));
138
+ }
139
+
98
140
  function categoryDocument(category: ApiCatalogCategory): ApiCatalogDiscoveryDocument {
99
141
  const destination = `xcsh://api-catalog/${category.name}`;
100
142
  const operations = category.operations.flatMap(operation => [
@@ -105,7 +147,6 @@ function categoryDocument(category: ApiCatalogCategory): ApiCatalogDiscoveryDocu
105
147
  ` - Path: ${operation.path}`,
106
148
  ` - Operation ID: ${operation.operationId}`,
107
149
  ]);
108
-
109
150
  return {
110
151
  id: `category:${category.name}`,
111
152
  categoryName: category.name,
@@ -139,6 +180,20 @@ export function buildApiCatalogDiscoveryCorpus(
139
180
  };
140
181
  }
141
182
 
183
+ /** Candidate-only operation granularity; authoritative results remain category URLs. */
184
+ export function buildApiOperationDiscoveryCorpus(
185
+ index: ApiCatalogIndex,
186
+ data: Readonly<Record<string, ApiCatalogCategory>>,
187
+ ): ApiCatalogDiscoveryCorpus {
188
+ return {
189
+ catalogVersion: index.version,
190
+ documents: Object.values(data)
191
+ .slice()
192
+ .sort((left, right) => left.name.localeCompare(right.name))
193
+ .flatMap(categoryDocuments),
194
+ };
195
+ }
196
+
142
197
  export function fingerprintApiCatalogDiscoveryCorpus(
143
198
  corpus: ApiCatalogDiscoveryCorpus,
144
199
  sourceSha: string,
@@ -3,18 +3,20 @@ import path from "node:path";
3
3
  import { rankQmdBm25CatalogDiscovery } from "./api-catalog-discovery";
4
4
  import { QMD_API_CATALOG_PREBUILT_INDEX } from "./api-catalog-qmd-index.generated";
5
5
  import type { ApiSpecIndex } from "./api-spec-types";
6
+ import {
7
+ buildKnowledgeSearchPlan,
8
+ classifyKnowledgeRequest,
9
+ type KnowledgeClassifierResource,
10
+ type KnowledgeSearchPlan,
11
+ } from "./knowledge-classifier";
6
12
 
7
- export interface ApiCatalogPreflightResource {
8
- readonly name: string;
9
- readonly aliases: readonly string[];
10
- readonly domain: string;
11
- readonly categories: readonly string[];
12
- }
13
+ export interface ApiCatalogPreflightResource extends KnowledgeClassifierResource {}
13
14
 
14
15
  export interface ApiCatalogPreflightIntent {
15
16
  readonly resource: string;
16
17
  readonly domain: string;
17
18
  readonly queries: readonly string[];
19
+ readonly structuredQuery?: KnowledgeSearchPlan;
18
20
  }
19
21
 
20
22
  export interface ApiCatalogPreflightDestination {
@@ -45,16 +47,6 @@ interface RunApiCatalogPreflightOptions extends ApiCatalogPreflightOptions {
45
47
  readonly rank?: (query: string) => Promise<readonly string[]>;
46
48
  }
47
49
 
48
- const EXCLUDED_INTENT =
49
- /\b(?:troubleshoot|debug|not working|returning\s+\d{3}|pricing|price|quote|licen[cs]ing|sales)\b/i;
50
- const THIRD_PARTY_SCOPE = /\b(?:aws|amazon|azure|gcp|google cloud|kubernetes)\b/i;
51
- const F5_XC_SCOPE = /\b(?:f5(?:\s+distributed\s+cloud)?|xc)\b/i;
52
- const ANAPHORIC_RESOURCE = /\b(?:its|it|that|this)\b/i;
53
- const DOCUMENTATION_ROUTE = /xcsh:\/\/documentation(?:\/|\?)/i;
54
- const API_METADATA_INTENT =
55
- /\b(?:endpoint|api\s+path|http\s+method|method|payload|request\s+body|required\s+fields?|enum|allowed\s+values?|constraints?|limits?|maximum|max(?:imum)?|minimum|min(?:imum)?|how\s+many|create|creates|creating|get|gets|list|lists|update|updates|replace|replaces|delete|deletes|clone|import)\b/i;
56
- const EXPLICIT_API_INTENT = /\b(?:api|endpoint|method|payload|schema)\b/i;
57
-
58
50
  let generatedResources: readonly ApiCatalogPreflightResource[] | undefined;
59
51
  let generatedCatalogVersion: string | undefined;
60
52
 
@@ -67,10 +59,6 @@ function normalizedPhrase(value: string): string {
67
59
  .replace(/\s+/g, " ");
68
60
  }
69
61
 
70
- function preferredAlias(resource: { name: string; aliases: readonly string[] }): string {
71
- return normalizedPhrase(resource.aliases[0] ?? resource.name);
72
- }
73
-
74
62
  function defaultAliases(name: string, descriptionShort?: string): string[] {
75
63
  const canonical = normalizedPhrase(name);
76
64
  const aliases = [normalizedPhrase(descriptionShort ?? ""), canonical].filter(Boolean);
@@ -97,56 +85,40 @@ function loadGeneratedMetadata(): { resources: readonly ApiCatalogPreflightResou
97
85
  return { resources: generatedResources, catalogVersion: generatedCatalogVersion };
98
86
  }
99
87
 
100
- function crudVerb(prompt: string): string | undefined {
101
- const value = normalizedPhrase(prompt);
102
- if (/\b(?:create|creates|creating)\b/.test(value)) return "create";
103
- if (/\b(?:get|gets|read|fetch)\b/.test(value)) return "get";
104
- if (/\b(?:list|lists)\b/.test(value)) return "list";
105
- if (/\b(?:update|updates)\b/.test(value)) return "update";
106
- if (/\b(?:replace|replaces)\b/.test(value)) return "replace";
107
- if (/\b(?:delete|deletes|remove)\b/.test(value)) return "delete";
108
- if (/\bclone\b/.test(value)) return "clone";
109
- if (/\bimport\b/.test(value)) return "import";
110
- return undefined;
111
- }
112
-
113
88
  export function classifyApiCatalogPreflight(
114
89
  prompt: string,
115
90
  options: ApiCatalogPreflightOptions,
116
91
  ): ApiCatalogPreflightIntent | null {
117
- if (!options.toolsEnabled || DOCUMENTATION_ROUTE.test(prompt) || EXCLUDED_INTENT.test(prompt)) return null;
118
- if (THIRD_PARTY_SCOPE.test(prompt) && !F5_XC_SCOPE.test(prompt)) return null;
119
- if (/\bterraform\b/i.test(prompt) && !EXPLICIT_API_INTENT.test(prompt)) return null;
120
- if (!API_METADATA_INTENT.test(prompt)) return null;
121
-
122
92
  const resources = options.resources ?? loadGeneratedMetadata().resources;
123
- const normalizedPrompt = ` ${normalizedPhrase(prompt)} `;
124
- const matches = resources
125
- .flatMap(resource =>
126
- resource.aliases
127
- .map(alias => normalizedPhrase(alias))
128
- .filter(alias => alias && normalizedPrompt.includes(` ${alias} `))
129
- .map(alias => ({ resource, alias })),
130
- )
131
- .sort(
132
- (left, right) =>
133
- right.alias.length - left.alias.length ||
134
- right.resource.categories.length - left.resource.categories.length ||
135
- left.resource.name.localeCompare(right.resource.name) ||
136
- left.resource.domain.localeCompare(right.resource.domain),
137
- );
138
- const selected = matches[0]?.resource;
139
- if (!selected) {
140
- if (ANAPHORIC_RESOURCE.test(prompt) && options.previousResource) return options.previousResource;
141
- return null;
93
+ const previous = options.previousResource
94
+ ? {
95
+ route: "api" as const,
96
+ confidence: 0.99,
97
+ resource: options.previousResource.resource,
98
+ domain: options.previousResource.domain,
99
+ constraints: { domain: options.previousResource.domain },
100
+ query:
101
+ options.previousResource.structuredQuery ??
102
+ buildKnowledgeSearchPlan(
103
+ "api",
104
+ options.previousResource.queries[0] ?? options.previousResource.resource,
105
+ ),
106
+ }
107
+ : undefined;
108
+ const classified = classifyKnowledgeRequest(prompt, { toolsEnabled: options.toolsEnabled, resources, previous });
109
+ if (classified.route !== "api" || !classified.resource || !classified.domain) return null;
110
+ if (
111
+ options.previousResource &&
112
+ classified.resource === options.previousResource.resource &&
113
+ /\b(?:its|it|that|this|those|them|the same)\b/i.test(prompt)
114
+ ) {
115
+ return options.previousResource;
142
116
  }
143
-
144
- const alias = preferredAlias(selected);
145
- const verb = crudVerb(prompt);
146
117
  return {
147
- resource: selected.name,
148
- domain: selected.domain,
149
- queries: verb ? [alias, `${verb} ${alias}`] : [alias],
118
+ resource: classified.resource,
119
+ domain: classified.domain,
120
+ queries: classified.query.lex,
121
+ structuredQuery: classified.query,
150
122
  };
151
123
  }
152
124