@aaroncql/pim-agent 0.2.0 → 0.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.
@@ -0,0 +1,39 @@
1
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
2
+
3
+ export type FilePickerWorkerRequest =
4
+ | {
5
+ readonly id: number;
6
+ readonly type: "refreshRelative";
7
+ readonly root: string;
8
+ }
9
+ | {
10
+ readonly id: number;
11
+ readonly type: "rank";
12
+ readonly query: string;
13
+ readonly limit?: number;
14
+ };
15
+
16
+ export type FilePickerWorkerResponse =
17
+ | {
18
+ readonly id: number;
19
+ readonly type: "refreshRelative";
20
+ readonly ok: true;
21
+ }
22
+ | {
23
+ readonly id: number;
24
+ readonly type: "refreshRelative";
25
+ readonly ok: false;
26
+ readonly error: string;
27
+ }
28
+ | {
29
+ readonly id: number;
30
+ readonly type: "rank";
31
+ readonly ok: true;
32
+ readonly items: readonly AutocompleteItem[] | undefined;
33
+ }
34
+ | {
35
+ readonly id: number;
36
+ readonly type: "rank";
37
+ readonly ok: false;
38
+ readonly error: string;
39
+ };
@@ -3,116 +3,171 @@ import type {
3
3
  ExtensionAPI,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import type { AutocompleteProvider } from "@earendil-works/pi-tui";
6
- import { type FileCandidate, loadRelative } from "./catalog";
7
- import { rank } from "./ranker";
6
+ import type { FilePickerSuggestionEngine } from "./FilePickerSuggestionEngine";
7
+ import { WorkerFilePickerSuggestionEngine } from "./WorkerFilePickerSuggestionEngine";
8
8
 
9
9
  const MAX_VISIBLE_ROWS = 50;
10
10
  const AT_PREFIX = /(?:^|\s)@(\S*)$/;
11
11
 
12
+ // Pi cancels autocomplete after Tab; for directories we want to keep
13
+ // drilling, so re-enter Tab on the next tick.
14
+ function keepDrilling(): void {
15
+ setTimeout(() => {
16
+ try {
17
+ process.stdin.emit("data", "\t");
18
+ } catch {}
19
+ }, 0);
20
+ }
21
+
22
+ function activeAtTokenFromMatch(
23
+ match: RegExpMatchArray,
24
+ cursorLine: number
25
+ ): ActiveAtToken {
26
+ const matchedText = match[0] ?? "";
27
+ const matchCol = match.index ?? 0;
28
+ const atCol = matchCol + (matchedText.startsWith("@") ? 0 : 1);
29
+ return { cursorLine, atCol };
30
+ }
31
+
32
+ function sameActiveAtToken(
33
+ a: ActiveAtToken | undefined,
34
+ b: ActiveAtToken
35
+ ): boolean {
36
+ return a?.cursorLine === b.cursorLine && a.atCol === b.atCol;
37
+ }
38
+
12
39
  export type FilePickerProviderFactoryOptions = {
13
- readonly loadRelativeCatalog: () => Promise<readonly FileCandidate[]>;
40
+ readonly engine: FilePickerSuggestionEngine;
41
+ };
42
+
43
+ type ActiveAtToken = {
44
+ readonly cursorLine: number;
45
+ readonly atCol: number;
14
46
  };
15
47
 
16
48
  export function createFilePickerProviderFactory(
17
49
  options: FilePickerProviderFactoryOptions
18
50
  ): AutocompleteProviderFactory {
19
- let cachedRelative: readonly FileCandidate[] | undefined;
20
- let relativeRefresh: Promise<void> | undefined;
21
-
22
51
  const refreshRelative = (): void => {
23
- relativeRefresh ??= options
24
- .loadRelativeCatalog()
25
- .then((catalog) => {
26
- cachedRelative = catalog;
27
- })
28
- .catch(() => {
29
- if (cachedRelative === undefined) {
30
- cachedRelative = [];
31
- }
32
- })
33
- .finally(() => {
34
- relativeRefresh = undefined;
35
- });
52
+ void options.engine.refreshRelative();
36
53
  };
37
54
 
38
- refreshRelative();
55
+ return (current: AutocompleteProvider): AutocompleteProvider => {
56
+ let activeAtToken: ActiveAtToken | undefined;
39
57
 
40
- return (current: AutocompleteProvider): AutocompleteProvider => ({
41
- async getSuggestions(lines, cursorLine, cursorCol, autocompleteOptions) {
42
- const line = lines[cursorLine] ?? "";
43
- const beforeCursor = line.slice(0, cursorCol);
58
+ return {
59
+ async getSuggestions(lines, cursorLine, cursorCol, autocompleteOptions) {
60
+ const line = lines[cursorLine] ?? "";
61
+ const beforeCursor = line.slice(0, cursorCol);
44
62
 
45
- const atMatch = beforeCursor.match(AT_PREFIX);
46
- if (!atMatch) {
47
- return current.getSuggestions(
48
- lines,
49
- cursorLine,
50
- cursorCol,
51
- autocompleteOptions
52
- );
53
- }
63
+ const atMatch = beforeCursor.match(AT_PREFIX);
64
+ if (!atMatch) {
65
+ activeAtToken = undefined;
66
+ return current.getSuggestions(
67
+ lines,
68
+ cursorLine,
69
+ cursorCol,
70
+ autocompleteOptions
71
+ );
72
+ }
73
+
74
+ const query = atMatch[1] ?? "";
75
+ const atToken = activeAtTokenFromMatch(atMatch, cursorLine);
76
+ if (!sameActiveAtToken(activeAtToken, atToken)) {
77
+ activeAtToken = atToken;
78
+ refreshRelative();
79
+ }
80
+
81
+ const items = await options.engine
82
+ .rank(query, {
83
+ limit: MAX_VISIBLE_ROWS,
84
+ signal: autocompleteOptions.signal,
85
+ })
86
+ .catch(() => undefined);
87
+ if (items === undefined) {
88
+ return current.getSuggestions(
89
+ lines,
90
+ cursorLine,
91
+ cursorCol,
92
+ autocompleteOptions
93
+ );
94
+ }
95
+ if (items.length === 0) {
96
+ return null;
97
+ }
98
+ return {
99
+ items: items.map((item) => ({
100
+ ...item,
101
+ value: `@${item.value}`,
102
+ })),
103
+ prefix: `@${query}`,
104
+ };
105
+ },
106
+
107
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
108
+ // Pi appends a trailing space after file completions; apply @ items
109
+ // ourselves so Tab inserts the bare path.
110
+ if (prefix.startsWith("@")) {
111
+ const line = lines[cursorLine] ?? "";
112
+ const beforePrefix = line.slice(0, cursorCol - prefix.length);
113
+ const afterCursor = line.slice(cursorCol);
114
+ const hasTrailingQuote = item.value.endsWith('"');
115
+ const adjustedAfterCursor =
116
+ prefix.startsWith('@"') &&
117
+ hasTrailingQuote &&
118
+ afterCursor.startsWith('"')
119
+ ? afterCursor.slice(1)
120
+ : afterCursor;
121
+
122
+ const newLines = [...lines];
123
+ newLines[cursorLine] =
124
+ `${beforePrefix}${item.value}${adjustedAfterCursor}`;
54
125
 
55
- const query = atMatch[1] ?? "";
56
- refreshRelative();
126
+ const isDirectory = item.label.endsWith("/");
127
+ const cursorOffset =
128
+ isDirectory && hasTrailingQuote
129
+ ? item.value.length - 1
130
+ : item.value.length;
57
131
 
58
- const items = await rank(query, {
59
- cachedRelative,
60
- limit: MAX_VISIBLE_ROWS,
61
- });
62
- if (items === undefined) {
63
- return current.getSuggestions(
132
+ if (isDirectory) {
133
+ keepDrilling();
134
+ }
135
+
136
+ return {
137
+ lines: newLines,
138
+ cursorLine,
139
+ cursorCol: beforePrefix.length + cursorOffset,
140
+ };
141
+ }
142
+
143
+ const result = current.applyCompletion(
64
144
  lines,
65
145
  cursorLine,
66
146
  cursorCol,
67
- autocompleteOptions
147
+ item,
148
+ prefix
68
149
  );
69
- }
70
- if (items.length === 0) {
71
- return null;
72
- }
73
- return {
74
- items: items.map((item) => ({
75
- ...item,
76
- value: `@${item.value}`,
77
- })),
78
- prefix: `@${query}`,
79
- };
80
- },
81
-
82
- applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
83
- const result = current.applyCompletion(
84
- lines,
85
- cursorLine,
86
- cursorCol,
87
- item,
88
- prefix
89
- );
90
- // Pi cancels autocomplete after Tab; for directories we want to keep
91
- // drilling, so re-enter Tab on the next tick.
92
- if (typeof item.value === "string" && item.value.endsWith("/")) {
93
- setTimeout(() => {
94
- try {
95
- process.stdin.emit("data", "\t");
96
- } catch {}
97
- }, 0);
98
- }
99
- return result;
100
- },
101
-
102
- shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
103
- return (
104
- current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
105
- true
106
- );
107
- },
108
- });
150
+ if (item.value.endsWith("/")) {
151
+ keepDrilling();
152
+ }
153
+ return result;
154
+ },
155
+
156
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
157
+ return (
158
+ current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
159
+ true
160
+ );
161
+ },
162
+ };
163
+ };
109
164
  }
110
165
 
111
166
  export default function (pi: ExtensionAPI): void {
112
167
  pi.on("session_start", (_event, ctx) => {
113
168
  ctx.ui.addAutocompleteProvider(
114
169
  createFilePickerProviderFactory({
115
- loadRelativeCatalog: () => loadRelative({ root: ctx.cwd }),
170
+ engine: new WorkerFilePickerSuggestionEngine(ctx.cwd),
116
171
  })
117
172
  );
118
173
  });
@@ -14,31 +14,175 @@ export type FileRankOptions = {
14
14
 
15
15
  const isAbsoluteQuery = (query: string): boolean =>
16
16
  query.startsWith("/") || query.startsWith("~");
17
+ const GLOBAL_FUZZY_QUERY_MIN_LENGTH = 3;
17
18
 
18
19
  let cachedIndex:
19
20
  | {
20
21
  readonly source: readonly FileCandidate[];
21
- readonly index: FuzzyIndex<FileCandidate>;
22
+ readonly index: RelativeRankingIndex;
22
23
  }
23
24
  | undefined;
24
25
 
25
26
  const indexFor = (
26
27
  candidates: readonly FileCandidate[]
27
- ): FuzzyIndex<FileCandidate> => {
28
+ ): RelativeRankingIndex => {
28
29
  if (cachedIndex?.source === candidates) {
29
30
  return cachedIndex.index;
30
31
  }
31
- const fuzzy: FuzzyCandidate<FileCandidate>[] = candidates.map(
32
- (candidate) => ({
33
- item: candidate,
34
- haystacks: [candidate.matchHaystack],
35
- })
36
- );
37
- const index = FuzzyMatcher.prepare(fuzzy);
32
+
33
+ const index = new RelativeRankingIndex(candidates);
38
34
  cachedIndex = { source: candidates, index };
39
35
  return index;
40
36
  };
41
37
 
38
+ type LoweredCandidate = {
39
+ readonly candidate: FileCandidate;
40
+ readonly nameLower: string;
41
+ readonly haystackLower: string;
42
+ };
43
+
44
+ class RelativeRankingIndex {
45
+ private readonly childrenByDirectory = new Map<string, FileCandidate[]>();
46
+ private readonly loweredSource: readonly LoweredCandidate[];
47
+
48
+ private readonly scopedIndexes = new Map<string, FuzzyIndex<FileCandidate>>();
49
+ private globalIndex: FuzzyIndex<FileCandidate> | undefined;
50
+
51
+ public constructor(private readonly source: readonly FileCandidate[]) {
52
+ const lowered: LoweredCandidate[] = [];
53
+
54
+ for (const candidate of source) {
55
+ const slash = candidate.insertPath.lastIndexOf("/");
56
+ const directory =
57
+ slash === -1 ? "" : candidate.insertPath.slice(0, slash);
58
+ const children = this.childrenByDirectory.get(directory) ?? [];
59
+ children.push(candidate);
60
+ this.childrenByDirectory.set(directory, children);
61
+
62
+ lowered.push({
63
+ candidate,
64
+ nameLower: basename(candidate.insertPath).toLocaleLowerCase(),
65
+ haystackLower: candidate.matchHaystack.toLocaleLowerCase(),
66
+ });
67
+ }
68
+
69
+ this.loweredSource = lowered;
70
+ }
71
+
72
+ public rank(query: string, limit: number | undefined): AutocompleteItem[] {
73
+ const scoped = this.scopedCandidates(query);
74
+ if (scoped !== undefined) {
75
+ return rankCandidates(scoped.candidates, scoped.residualQuery, limit, {
76
+ index: () => this.indexForScope(scoped.directory, scoped.candidates),
77
+ });
78
+ }
79
+
80
+ const literalHits = this.literalRank(query, limit);
81
+ if (literalHits !== undefined) {
82
+ return literalHits;
83
+ }
84
+
85
+ return rankCandidates(this.source, query, limit, {
86
+ index: () => this.indexForGlobal(),
87
+ });
88
+ }
89
+
90
+ private scopedCandidates(query: string):
91
+ | {
92
+ readonly directory: string;
93
+ readonly residualQuery: string;
94
+ readonly candidates: readonly FileCandidate[];
95
+ }
96
+ | undefined {
97
+ const slash = query.lastIndexOf("/");
98
+ if (slash === -1) {
99
+ return undefined;
100
+ }
101
+
102
+ const directory = query.slice(0, slash);
103
+ const candidates = this.childrenByDirectory.get(directory);
104
+ if (candidates === undefined) {
105
+ return undefined;
106
+ }
107
+
108
+ return {
109
+ directory,
110
+ residualQuery: query.slice(slash + 1),
111
+ candidates,
112
+ };
113
+ }
114
+
115
+ private indexForScope(
116
+ directory: string,
117
+ candidates: readonly FileCandidate[]
118
+ ): FuzzyIndex<FileCandidate> {
119
+ const cached = this.scopedIndexes.get(directory);
120
+ if (cached !== undefined) {
121
+ return cached;
122
+ }
123
+
124
+ const index = prepareIndex(candidates, (candidate) =>
125
+ basename(candidate.insertPath)
126
+ );
127
+ this.scopedIndexes.set(directory, index);
128
+ return index;
129
+ }
130
+
131
+ private indexForGlobal(): FuzzyIndex<FileCandidate> {
132
+ this.globalIndex ??= prepareIndex(
133
+ this.source,
134
+ (candidate) => candidate.matchHaystack
135
+ );
136
+ return this.globalIndex;
137
+ }
138
+
139
+ private literalRank(
140
+ query: string,
141
+ limit: number | undefined
142
+ ): AutocompleteItem[] | undefined {
143
+ const needle = query.trim().toLocaleLowerCase();
144
+ if (needle.length === 0 || query.includes("/")) {
145
+ return undefined;
146
+ }
147
+
148
+ const prefixHits: FileCandidate[] = [];
149
+ const substringHits: FileCandidate[] = [];
150
+ const limitSize = limit ?? Infinity;
151
+
152
+ for (const { candidate, nameLower, haystackLower } of this.loweredSource) {
153
+ if (nameLower.startsWith(needle) || haystackLower.startsWith(needle)) {
154
+ prefixHits.push(candidate);
155
+ } else if (nameLower.includes(needle) || haystackLower.includes(needle)) {
156
+ substringHits.push(candidate);
157
+ }
158
+
159
+ // Prefix hits always outrank substring hits, so we can stop only once we
160
+ // have enough prefixes to fill the limit; otherwise a late-sorting prefix
161
+ // could be dropped for an earlier substring match.
162
+ if (prefixHits.length >= limitSize) {
163
+ break;
164
+ }
165
+ }
166
+
167
+ const hitCount = prefixHits.length + substringHits.length;
168
+ if (hitCount === 0) {
169
+ return undefined;
170
+ }
171
+
172
+ if (
173
+ prefixHits.length === 0 &&
174
+ needle.length >= GLOBAL_FUZZY_QUERY_MIN_LENGTH &&
175
+ hitCount < limitSize
176
+ ) {
177
+ return undefined;
178
+ }
179
+
180
+ return [...prefixHits, ...substringHits]
181
+ .slice(0, limit)
182
+ .map((candidate) => toItem(candidate));
183
+ }
184
+ }
185
+
42
186
  export async function rank(
43
187
  query: string,
44
188
  options: FileRankOptions
@@ -52,18 +196,42 @@ export async function rank(
52
196
  return undefined;
53
197
  }
54
198
 
55
- return rankCandidates(options.cachedRelative, query, options.limit);
199
+ return indexFor(options.cachedRelative).rank(query, options.limit);
56
200
  }
57
201
 
202
+ type RankCandidatesOptions = {
203
+ readonly index?: () => FuzzyIndex<FileCandidate>;
204
+ };
205
+
58
206
  const rankCandidates = (
59
207
  candidates: readonly FileCandidate[],
60
208
  query: string,
61
- limit: number | undefined
209
+ limit: number | undefined,
210
+ options: RankCandidatesOptions = {}
62
211
  ): AutocompleteItem[] => {
63
- const hits = indexFor(candidates).find(query, { limit });
212
+ if (query.trim().length === 0) {
213
+ return candidates.slice(0, limit).map((candidate) => toItem(candidate));
214
+ }
215
+
216
+ const index = options.index ?? (() => prepareIndex(candidates));
217
+ const hits = index().find(query, { limit });
64
218
  return hits.map((hit) => toItem(hit.item));
65
219
  };
66
220
 
221
+ const prepareIndex = (
222
+ candidates: readonly FileCandidate[],
223
+ haystack: (candidate: FileCandidate) => string = (candidate) =>
224
+ candidate.matchHaystack
225
+ ): FuzzyIndex<FileCandidate> => {
226
+ const fuzzy: FuzzyCandidate<FileCandidate>[] = candidates.map(
227
+ (candidate) => ({
228
+ item: candidate,
229
+ haystacks: [haystack(candidate)],
230
+ })
231
+ );
232
+ return FuzzyMatcher.prepare(fuzzy);
233
+ };
234
+
67
235
  const toItem = (candidate: FileCandidate): AutocompleteItem => {
68
236
  const suffix = candidate.isDirectory ? "/" : "";
69
237
  const value = `${candidate.insertPath}${suffix}`;
@@ -2,7 +2,7 @@ import { FileScanner, type FileScanOptions } from "../../shared/FileScanner";
2
2
  import { FsErrors } from "../../shared/FsErrors";
3
3
  import { Lines } from "../../shared/Lines";
4
4
 
5
- const MATCH_CONCURRENCY = 32;
5
+ const MATCH_CONCURRENCY = 16;
6
6
 
7
7
  export type GrepLine = {
8
8
  readonly lineNumber: number;
@@ -25,10 +25,33 @@ export type GrepMatch = {
25
25
  export type GrepMatcher = {
26
26
  readonly regex: RegExp;
27
27
  readonly matchAcrossLines: boolean;
28
+ /**
29
+ * Raw-byte needle for the literal fast path: present only when the pattern is
30
+ * a pure literal, case-sensitive, and single-line, so `matchFile` can reject a
31
+ * non-matching file with `Buffer.indexOf` before decoding it. Undefined
32
+ * otherwise, in which case the regex path runs unchanged.
33
+ */
34
+ readonly literal: Buffer | undefined;
28
35
  };
29
36
 
30
37
  export type GrepScanOptions = FileScanOptions;
31
38
 
39
+ // Characters that stand for themselves in both a default-flag regex and raw
40
+ // UTF-8 bytes (all ASCII, so they never alias a multibyte sequence). A pattern
41
+ // made only of these is a literal we can scan on bytes.
42
+ const PURE_LITERAL = /^[A-Za-z0-9_ \-/]+$/;
43
+
44
+ function literalNeedle(
45
+ pattern: string,
46
+ caseInsensitive: boolean,
47
+ matchAcrossLines: boolean
48
+ ): Buffer | undefined {
49
+ if (caseInsensitive || matchAcrossLines || !PURE_LITERAL.test(pattern)) {
50
+ return undefined;
51
+ }
52
+ return Buffer.from(pattern, "utf8");
53
+ }
54
+
32
55
  export function buildMatcher(options: {
33
56
  readonly pattern: string;
34
57
  readonly caseInsensitive: boolean;
@@ -42,6 +65,11 @@ export function buildMatcher(options: {
42
65
  return {
43
66
  regex: new RegExp(options.pattern, flags),
44
67
  matchAcrossLines: options.matchAcrossLines,
68
+ literal: literalNeedle(
69
+ options.pattern,
70
+ options.caseInsensitive,
71
+ options.matchAcrossLines
72
+ ),
45
73
  };
46
74
  } catch (error) {
47
75
  const message = error instanceof Error ? error.message : String(error);
@@ -87,11 +115,26 @@ async function matchFile(
87
115
  ): Promise<GrepMatch | undefined> {
88
116
  const file = Bun.file(filePath);
89
117
 
118
+ // Binary skip reads only the first 8KB, so a binary file is never fully read.
90
119
  if (await Lines.isBinary(file)) {
91
120
  return undefined;
92
121
  }
93
122
 
94
- const content = Lines.normalize(await file.text());
123
+ let text: string;
124
+ if (matcher.literal !== undefined) {
125
+ // Literal fast path: scan raw bytes and bail on a miss without decoding. An
126
+ // ASCII literal can't match across a normalized newline or alias a
127
+ // multibyte char, so a raw-byte hit/miss matches the decoded result.
128
+ const bytes = Buffer.from(await file.arrayBuffer());
129
+ if (bytes.indexOf(matcher.literal) < 0) {
130
+ return undefined;
131
+ }
132
+ text = bytes.toString("utf8");
133
+ } else {
134
+ text = await file.text();
135
+ }
136
+
137
+ const content = Lines.normalize(text);
95
138
  const fileLines = Lines.split(content);
96
139
  const ranges = matcher.matchAcrossLines
97
140
  ? regexRanges(content, matcher.regex)
@@ -1,9 +1,11 @@
1
1
  import { McpClient, type McpFetch } from "../../shared/McpClient";
2
+ import { RateLimiter } from "../../shared/RateLimiter";
2
3
 
3
4
  type ExaMcpClientOptions = {
4
5
  readonly endpoint?: string;
5
6
  readonly apiKey?: string;
6
7
  readonly fetch?: McpFetch;
8
+ readonly rateLimiter?: RateLimiter;
7
9
  };
8
10
 
9
11
  type ExaSearchInput = {
@@ -28,15 +30,30 @@ class ExaSearchError extends Error {
28
30
  export class ExaMcpClient {
29
31
  private static readonly defaultEndpoint = "https://mcp.exa.ai/mcp";
30
32
  private static readonly toolName = "web_search_exa";
33
+ private static readonly maxRequestsPerWindow = 3;
34
+ private static readonly windowMs = 1000;
31
35
 
32
36
  private readonly client: McpClient;
33
37
 
34
38
  public constructor(options: ExaMcpClientOptions = {}) {
39
+ const apiKey =
40
+ options.apiKey === undefined || options.apiKey.length === 0
41
+ ? undefined
42
+ : options.apiKey;
43
+ // Throttle only on the free tier; an API key lifts the request rate limit.
44
+ const rateLimiter =
45
+ apiKey !== undefined
46
+ ? undefined
47
+ : (options.rateLimiter ??
48
+ new RateLimiter({
49
+ maxRequests: ExaMcpClient.maxRequestsPerWindow,
50
+ windowMs: ExaMcpClient.windowMs,
51
+ }));
52
+
35
53
  this.client = new McpClient({
36
54
  endpoint: options.endpoint ?? ExaMcpClient.defaultEndpoint,
37
- ...(options.apiKey === undefined || options.apiKey.length === 0
38
- ? {}
39
- : { headers: { "x-api-key": options.apiKey } }),
55
+ ...(apiKey === undefined ? {} : { headers: { "x-api-key": apiKey } }),
56
+ ...(rateLimiter === undefined ? {} : { rateLimiter }),
40
57
  ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
41
58
  });
42
59
  }