@aefree/pi-plastic 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.
- package/CHANGELOG.md +109 -0
- package/CONTRIBUTING.md +88 -0
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/SECURITY.md +31 -0
- package/evals/tool-loading/README.md +38 -0
- package/evals/tool-loading/RESULTS.md +57 -0
- package/evals/tool-loading/baseline.json +34 -0
- package/evals/tool-loading/cases.json +70 -0
- package/evals/tool-loading/config.json +19 -0
- package/evals/tool-loading/mutation-guard.ts +30 -0
- package/evals/tool-loading/provider-capture.ts +22 -0
- package/evals/tool-loading/results/.gitignore +3 -0
- package/evals/tool-loading/run-eval.ts +372 -0
- package/extensions/bash-cm-diff-guard.ts +49 -0
- package/extensions/bash-cm-merge-guard.ts +53 -0
- package/extensions/plastic-branch-status.ts +285 -0
- package/extensions/repository-search-provider.ts +66 -0
- package/extensions/shared/bash-command-guards.ts +67 -0
- package/index.ts +777 -0
- package/package.json +76 -0
- package/skills/_shared/references/conventional-commits.md +198 -0
- package/skills/using-plastic/SKILL.md +50 -0
- package/skills/using-plastic/references/branch-management.md +78 -0
- package/skills/using-plastic/references/changeset-operations.md +96 -0
- package/skills/using-plastic/references/code-review-creation.md +30 -0
- package/skills/using-plastic/references/integration.md +47 -0
- package/skills/using-plastic/references/quick-reference.md +32 -0
- package/skills/using-plastic/references/resources.md +7 -0
- package/skills/using-plastic/references/reviewing-changes.md +71 -0
- package/skills/using-plastic/references/shelving.md +25 -0
- package/skills/using-plastic/references/troubleshooting.md +115 -0
- package/skills/using-plastic/references/workspaces.md +33 -0
- package/src/pi-tool-compat.ts +56 -0
- package/src/plastic-core.ts +4204 -0
- package/src/plastic-tool-loading.ts +234 -0
- package/src/plastic-workspace.ts +176 -0
- package/src/repository-search-provider.ts +64 -0
- package/tests/bash-cm-diff-guard-validation.ts +55 -0
- package/tests/fixtures/plastic.selector +3 -0
- package/tests/fixtures/status-compact.txt +1 -0
- package/tests/fixtures/status-normal.txt +1 -0
- package/tests/packed-copy-owner-conflict.test.ts +28 -0
- package/tests/pi-tool-harness.ts +116 -0
- package/tests/plastic-branch-create-live.test.ts +78 -0
- package/tests/plastic-branch-query.test.ts +26 -0
- package/tests/plastic-branch-status.test.ts +241 -0
- package/tests/plastic-checkin-path-resolution.ts +219 -0
- package/tests/plastic-constrained-sampling.test.ts +77 -0
- package/tests/plastic-diff-workflow.test.ts +86 -0
- package/tests/plastic-dynamic-tool-loading.test.ts +224 -0
- package/tests/plastic-extension-registration.test.ts +34 -0
- package/tests/plastic-live-smoke.test.ts +37 -0
- package/tests/plastic-mutation-execution.test.ts +61 -0
- package/tests/plastic-patch-command.test.ts +118 -0
- package/tests/plastic-process-lifecycle.test.ts +148 -0
- package/tests/plastic-tool-rendering.test.ts +16 -0
- package/tests/plastic-tool-validation.ts +153 -0
- package/tests/plastic-workspace.test.ts +62 -0
- package/tests/repository-search-provider-registration.test.ts +37 -0
- package/tests/repository-search-provider.test.ts +27 -0
package/index.ts
ADDED
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type TSchema, Type } from "typebox";
|
|
4
|
+
import * as core from "./src/plastic-core";
|
|
5
|
+
import {
|
|
6
|
+
getEffectivePlasticToolOwnership,
|
|
7
|
+
getInitiallyInactivePlasticTools,
|
|
8
|
+
getPlasticToolLoadingMode,
|
|
9
|
+
getRestoredPlasticToolNames,
|
|
10
|
+
getUnknownExactPlasticToolNames,
|
|
11
|
+
isPlasticToolBrowseRequest,
|
|
12
|
+
PLASTIC_TOOL_BROWSE_TEXT,
|
|
13
|
+
PLASTIC_TOOL_SEARCH_NAME,
|
|
14
|
+
searchPlasticTools,
|
|
15
|
+
} from "./src/plastic-tool-loading";
|
|
16
|
+
|
|
17
|
+
type CoreSchemaNode = {
|
|
18
|
+
kind: string;
|
|
19
|
+
values?: unknown;
|
|
20
|
+
inner?: unknown;
|
|
21
|
+
metadata?: {
|
|
22
|
+
optional?: boolean;
|
|
23
|
+
description?: string;
|
|
24
|
+
min?: number;
|
|
25
|
+
max?: number;
|
|
26
|
+
int?: boolean;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type CoreTool = {
|
|
31
|
+
description?: string;
|
|
32
|
+
args?: Record<string, unknown>;
|
|
33
|
+
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ToolConfig = {
|
|
37
|
+
prepareArguments?: (args: unknown) => Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const EXTENSION_SOURCE_PATH = fileURLToPath(import.meta.url);
|
|
41
|
+
const EMPTY_PARAMETERS = Type.Object({});
|
|
42
|
+
const enumSchema = <T extends readonly [string, ...string[]]>(values: T, description: string): TSchema =>
|
|
43
|
+
Type.Union(values.map((value) => Type.Literal(value)) as [TSchema, TSchema, ...TSchema[]], { description });
|
|
44
|
+
|
|
45
|
+
const OUTPUT_FORMAT_SCHEMA = enumSchema(["text", "json"], "Output format. Defaults to text.");
|
|
46
|
+
const PENDING_CHANGES_SCHEMA = enumSchema(["shelve", "bring", "cancel"], "How to handle pending changes when switching branches.");
|
|
47
|
+
const MERGE_STRATEGY_SCHEMA = enumSchema(["auto", "source", "destination"], "Conflict resolution strategy.");
|
|
48
|
+
const COMPARISON_METHOD_SCHEMA = enumSchema(
|
|
49
|
+
["ignoreeol", "ignorewhitespaces", "ignoreeolandwhitespaces", "recognizeall"],
|
|
50
|
+
"Comparison method used for diff calculations.",
|
|
51
|
+
);
|
|
52
|
+
const REVIEW_TARGET_TYPE_SCHEMA = enumSchema(["branch", "changeset"], "Filter by review target type.");
|
|
53
|
+
const BRANCH_ORDER_BY_SCHEMA = enumSchema(["date", "branchname"], "Sort field for branch queries.");
|
|
54
|
+
const REVIEW_ORDER_BY_SCHEMA = enumSchema(["date", "modifieddate", "status"], "Sort field for review queries.");
|
|
55
|
+
|
|
56
|
+
const PLASTIC_EXPORTS = [
|
|
57
|
+
"status",
|
|
58
|
+
"update",
|
|
59
|
+
"add",
|
|
60
|
+
"checkin",
|
|
61
|
+
"undo",
|
|
62
|
+
"resolveDeleteChangeConflict",
|
|
63
|
+
"diff",
|
|
64
|
+
"patch",
|
|
65
|
+
"diffRevisions",
|
|
66
|
+
"diffFile",
|
|
67
|
+
"branchCreate",
|
|
68
|
+
"switchBranch",
|
|
69
|
+
"merge",
|
|
70
|
+
"mergeToBranch",
|
|
71
|
+
"finalizeMerge",
|
|
72
|
+
"currentBranch",
|
|
73
|
+
"branchList",
|
|
74
|
+
"branchExists",
|
|
75
|
+
"branchDelete",
|
|
76
|
+
"shelvesetCreate",
|
|
77
|
+
"shelvesetApply",
|
|
78
|
+
"shelvesetDelete",
|
|
79
|
+
"shelvesetList",
|
|
80
|
+
"codeReviewCreate",
|
|
81
|
+
"codeReviewUpdate",
|
|
82
|
+
"codeReviewDelete",
|
|
83
|
+
"codeReviewFind",
|
|
84
|
+
"workspaceList",
|
|
85
|
+
] as const;
|
|
86
|
+
|
|
87
|
+
type PlasticExportName = (typeof PLASTIC_EXPORTS)[number];
|
|
88
|
+
|
|
89
|
+
const TOOL_CONFIG: Partial<Record<PlasticExportName, ToolConfig>> = {
|
|
90
|
+
status: {
|
|
91
|
+
prepareArguments(args) {
|
|
92
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
93
|
+
normalizeWorkdirAliases(input);
|
|
94
|
+
assignAlias(input, "includeRevId", ["include_rev_id"]);
|
|
95
|
+
assignAlias(input, "machineReadable", ["machine_readable", "machine"]);
|
|
96
|
+
return input;
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
update: {
|
|
100
|
+
prepareArguments(args) {
|
|
101
|
+
const input = normalizeArgs(args);
|
|
102
|
+
normalizeWorkdirAliases(input);
|
|
103
|
+
return input;
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
add: {
|
|
107
|
+
prepareArguments(args) {
|
|
108
|
+
const input = normalizeArgs(args);
|
|
109
|
+
normalizeWorkdirAliases(input);
|
|
110
|
+
promoteSinglePath(input);
|
|
111
|
+
assignAlias(input, "paths", ["items"]);
|
|
112
|
+
return input;
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
checkin: {
|
|
116
|
+
prepareArguments(args) {
|
|
117
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
118
|
+
normalizeWorkdirAliases(input);
|
|
119
|
+
assignAlias(input, "message", ["comment", "comments"]);
|
|
120
|
+
promoteSinglePath(input);
|
|
121
|
+
assignAlias(input, "paths", ["items", "files"]);
|
|
122
|
+
assignAlias(input, "applyChanged", ["apply_changed"]);
|
|
123
|
+
assignAlias(input, "includePrivate", ["include_private"]);
|
|
124
|
+
assignAlias(input, "includeAll", ["include_all"]);
|
|
125
|
+
assignAlias(input, "updateAfter", ["update_after"]);
|
|
126
|
+
return input;
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
undo: {
|
|
130
|
+
prepareArguments(args) {
|
|
131
|
+
const input = normalizeArgs(args);
|
|
132
|
+
normalizeWorkdirAliases(input);
|
|
133
|
+
promoteSinglePath(input);
|
|
134
|
+
assignAlias(input, "paths", ["items"]);
|
|
135
|
+
return input;
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
resolveDeleteChangeConflict: {
|
|
139
|
+
prepareArguments(args) {
|
|
140
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
141
|
+
normalizeWorkdirAliases(input);
|
|
142
|
+
promoteSinglePath(input);
|
|
143
|
+
assignAlias(input, "paths", ["items", "files"]);
|
|
144
|
+
assignAlias(input, "keepOnDisk", ["keep_on_disk", "keepOnDisk", "nodisk"]);
|
|
145
|
+
return input;
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
diff: {
|
|
149
|
+
prepareArguments(args) {
|
|
150
|
+
const input = normalizeArgs(args);
|
|
151
|
+
normalizeWorkdirAliases(input);
|
|
152
|
+
assignAlias(input, "repositoryPaths", ["repository_paths"]);
|
|
153
|
+
assignAlias(input, "dateFormat", ["date_format"]);
|
|
154
|
+
assignAlias(input, "comparisonMethod", ["comparison_method"]);
|
|
155
|
+
assignAlias(input, "fullPaths", ["full_paths"]);
|
|
156
|
+
return input;
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
patch: {
|
|
160
|
+
prepareArguments(args) {
|
|
161
|
+
const input = normalizeArgs(args);
|
|
162
|
+
normalizeWorkdirAliases(input);
|
|
163
|
+
assignAlias(input, "output", ["output_file", "outputFile"]);
|
|
164
|
+
assignAlias(input, "toolPath", ["tool_path", "tool"]);
|
|
165
|
+
return input;
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
diffRevisions: {
|
|
169
|
+
prepareArguments(args) {
|
|
170
|
+
const input = normalizeArgs(args);
|
|
171
|
+
normalizeWorkdirAliases(input);
|
|
172
|
+
assignAlias(input, "leftRevision", ["left_revision"]);
|
|
173
|
+
assignAlias(input, "rightRevision", ["right_revision"]);
|
|
174
|
+
return input;
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
diffFile: {
|
|
178
|
+
prepareArguments(args) {
|
|
179
|
+
const input = normalizeArgs(args);
|
|
180
|
+
normalizeWorkdirAliases(input);
|
|
181
|
+
return input;
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
branchCreate: {
|
|
185
|
+
prepareArguments(args) {
|
|
186
|
+
const input = normalizeArgs(args);
|
|
187
|
+
normalizeWorkdirAliases(input);
|
|
188
|
+
assignAlias(input, "commentsFile", ["comments_file"]);
|
|
189
|
+
return input;
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
switchBranch: {
|
|
193
|
+
prepareArguments(args) {
|
|
194
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
195
|
+
normalizeWorkdirAliases(input);
|
|
196
|
+
assignAlias(input, "pendingChanges", ["pending_changes"]);
|
|
197
|
+
return input;
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
merge: {
|
|
201
|
+
prepareArguments(args) {
|
|
202
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
203
|
+
normalizeWorkdirAliases(input);
|
|
204
|
+
assignAlias(input, "cherrypicking", ["cherry_picking", "cherryPicking"]);
|
|
205
|
+
return input;
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
mergeToBranch: {
|
|
209
|
+
prepareArguments(args) {
|
|
210
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
211
|
+
normalizeWorkdirAliases(input);
|
|
212
|
+
assignAlias(input, "source", ["sourceBranch", "source_branch", "branch"]);
|
|
213
|
+
assignAlias(input, "target", ["targetBranch", "target_branch", "destination", "destinationBranch", "destination_branch"]);
|
|
214
|
+
assignAlias(input, "cardRef", ["card", "cardCode", "card_code", "codecksCard", "codecks_card"]);
|
|
215
|
+
assignAlias(input, "updateTarget", ["update_target", "update"]);
|
|
216
|
+
assignAlias(input, "includePrivate", ["include_private"]);
|
|
217
|
+
return input;
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
finalizeMerge: {
|
|
221
|
+
prepareArguments(args) {
|
|
222
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
223
|
+
normalizeWorkdirAliases(input);
|
|
224
|
+
assignAlias(input, "source", ["mergeSource", "merge_source"]);
|
|
225
|
+
return input;
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
currentBranch: {
|
|
229
|
+
prepareArguments(args) {
|
|
230
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
231
|
+
normalizeWorkdirAliases(input);
|
|
232
|
+
return input;
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
branchList: {
|
|
236
|
+
prepareArguments(args) {
|
|
237
|
+
const input = normalizeArgs(args);
|
|
238
|
+
normalizeWorkdirAliases(input);
|
|
239
|
+
assignAlias(input, "nameLike", ["name_like"]);
|
|
240
|
+
assignAlias(input, "includeHidden", ["include_hidden"]);
|
|
241
|
+
assignAlias(input, "orderBy", ["order_by"]);
|
|
242
|
+
return input;
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
branchExists: {
|
|
246
|
+
prepareArguments(args) {
|
|
247
|
+
const input = normalizeArgs(args);
|
|
248
|
+
normalizeWorkdirAliases(input);
|
|
249
|
+
return input;
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
branchDelete: {
|
|
253
|
+
prepareArguments(args) {
|
|
254
|
+
const input = normalizeArgs(args);
|
|
255
|
+
normalizeWorkdirAliases(input);
|
|
256
|
+
assignAlias(input, "deleteChangesets", ["delete_changesets"]);
|
|
257
|
+
return input;
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
shelvesetCreate: {
|
|
261
|
+
prepareArguments(args) {
|
|
262
|
+
const input = normalizeArgs(args);
|
|
263
|
+
normalizeWorkdirAliases(input);
|
|
264
|
+
promoteSinglePath(input);
|
|
265
|
+
assignAlias(input, "paths", ["items"]);
|
|
266
|
+
assignAlias(input, "commentsFile", ["comments_file"]);
|
|
267
|
+
assignAlias(input, "summaryFormat", ["summary_format"]);
|
|
268
|
+
return input;
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
shelvesetApply: {
|
|
272
|
+
prepareArguments(args) {
|
|
273
|
+
const input = normalizeArgs(args);
|
|
274
|
+
normalizeWorkdirAliases(input);
|
|
275
|
+
assignAlias(input, "changePaths", ["change_paths"]);
|
|
276
|
+
assignAlias(input, "dontCheckout", ["dont_checkout"]);
|
|
277
|
+
assignAlias(input, "comparisonMethod", ["comparison_method"]);
|
|
278
|
+
return input;
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
shelvesetDelete: {
|
|
282
|
+
prepareArguments(args) {
|
|
283
|
+
const input = normalizeArgs(args);
|
|
284
|
+
normalizeWorkdirAliases(input);
|
|
285
|
+
return input;
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
shelvesetList: {
|
|
289
|
+
prepareArguments(args) {
|
|
290
|
+
const input = normalizeArgs(args);
|
|
291
|
+
normalizeWorkdirAliases(input);
|
|
292
|
+
assignAlias(input, "commentLike", ["comment_like"]);
|
|
293
|
+
assignAlias(input, "dateFrom", ["date_from"]);
|
|
294
|
+
assignAlias(input, "dateFormat", ["date_format"]);
|
|
295
|
+
return input;
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
codeReviewCreate: {
|
|
299
|
+
prepareArguments(args) {
|
|
300
|
+
const input = normalizeArgs(args);
|
|
301
|
+
normalizeWorkdirAliases(input);
|
|
302
|
+
assignAlias(input, "reviewId", ["id"]);
|
|
303
|
+
return input;
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
codeReviewUpdate: {
|
|
307
|
+
prepareArguments(args) {
|
|
308
|
+
const input = normalizeArgs(args);
|
|
309
|
+
normalizeWorkdirAliases(input);
|
|
310
|
+
assignAlias(input, "reviewId", ["review_id"]);
|
|
311
|
+
if (input.reviewId !== undefined && input.id === undefined) input.id = input.reviewId;
|
|
312
|
+
return input;
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
codeReviewDelete: {
|
|
316
|
+
prepareArguments(args) {
|
|
317
|
+
const input = normalizeArgs(args);
|
|
318
|
+
normalizeWorkdirAliases(input);
|
|
319
|
+
if (input.id !== undefined && input.ids === undefined) input.ids = [String(input.id)];
|
|
320
|
+
assignAlias(input, "ids", ["review_ids"]);
|
|
321
|
+
return input;
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
codeReviewFind: {
|
|
325
|
+
prepareArguments(args) {
|
|
326
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
327
|
+
normalizeWorkdirAliases(input);
|
|
328
|
+
assignAlias(input, "targetType", ["target_type"]);
|
|
329
|
+
assignAlias(input, "titleLike", ["title_like"]);
|
|
330
|
+
assignAlias(input, "orderBy", ["order_by"]);
|
|
331
|
+
assignAlias(input, "dateFormat", ["date_format"]);
|
|
332
|
+
assignAlias(input, "output", ["output_format", "response_format"]);
|
|
333
|
+
return input;
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
workspaceList: {
|
|
337
|
+
prepareArguments(args) {
|
|
338
|
+
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
339
|
+
normalizeWorkdirAliases(input);
|
|
340
|
+
assignAlias(input, "output", ["output_format", "response_format"]);
|
|
341
|
+
return input;
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
function toToolName(exportName: string): string {
|
|
347
|
+
return `plastic_${exportName}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
type TextLikeComponent = {
|
|
351
|
+
invalidate: () => void;
|
|
352
|
+
render: (width: number) => string[];
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
type RenderTheme = {
|
|
356
|
+
fg?: (color: string, text: string) => string;
|
|
357
|
+
bold?: (text: string) => string;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
type PlasticToolResult = {
|
|
361
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
362
|
+
details?: {
|
|
363
|
+
exportName?: string;
|
|
364
|
+
rawResult?: unknown;
|
|
365
|
+
};
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const COLLAPSED_RESULT_LINES = 12;
|
|
369
|
+
const ANSI_PATTERN = /\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
370
|
+
|
|
371
|
+
function toText(result: unknown): string {
|
|
372
|
+
if (typeof result === "string") {
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return JSON.stringify(result, null, 2);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function visibleLength(value: string): number {
|
|
380
|
+
return value.replace(ANSI_PATTERN, "").length;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function truncateAnsiLine(value: string, width: number): string {
|
|
384
|
+
if (width <= 0 || !value) {
|
|
385
|
+
return "";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (visibleLength(value) <= width) {
|
|
389
|
+
return value;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const target = Math.max(0, width - 1);
|
|
393
|
+
let visible = 0;
|
|
394
|
+
let output = "";
|
|
395
|
+
for (let index = 0; index < value.length;) {
|
|
396
|
+
const remaining = value.slice(index);
|
|
397
|
+
const ansi = remaining.match(ANSI_PATTERN);
|
|
398
|
+
if (ansi && ansi.index === 0) {
|
|
399
|
+
output += ansi[0];
|
|
400
|
+
index += ansi[0].length;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (visible >= target) {
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const codePoint = value.codePointAt(index);
|
|
409
|
+
if (codePoint === undefined) {
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const char = String.fromCodePoint(codePoint);
|
|
414
|
+
output += char;
|
|
415
|
+
visible += 1;
|
|
416
|
+
index += char.length;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return `${output}…`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function textComponent(text: string): TextLikeComponent {
|
|
423
|
+
return {
|
|
424
|
+
invalidate() {},
|
|
425
|
+
render(width: number) {
|
|
426
|
+
if (!text) {
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
return text.split(/\r?\n/).map((line) => truncateAnsiLine(line, width));
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function themed(theme: RenderTheme, color: string, text: string): string {
|
|
435
|
+
return typeof theme.fg === "function" ? theme.fg(color, text) : text;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function bold(theme: RenderTheme, text: string): string {
|
|
439
|
+
return typeof theme.bold === "function" ? theme.bold(text) : text;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function extractTextContent(result: PlasticToolResult | undefined): string {
|
|
443
|
+
return result?.content
|
|
444
|
+
?.filter((entry) => entry?.type === "text")
|
|
445
|
+
.map((entry) => String(entry.text ?? ""))
|
|
446
|
+
.join("\n") ?? "";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function trimTrailingEmptyLines(lines: string[]): string[] {
|
|
450
|
+
let end = lines.length;
|
|
451
|
+
while (end > 0 && lines[end - 1]?.trim() === "") {
|
|
452
|
+
end -= 1;
|
|
453
|
+
}
|
|
454
|
+
return lines.slice(0, end);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function renderPlasticCall(exportName: string, args: Record<string, unknown>, theme: RenderTheme): TextLikeComponent {
|
|
458
|
+
const target = args.workdir ?? args.branch ?? args.source ?? args.path ?? args.paths ?? args.target ?? args.shelveset ?? args.id ?? "";
|
|
459
|
+
const targetText = Array.isArray(target) ? target.join(", ") : String(target);
|
|
460
|
+
const suffix = targetText ? ` ${themed(theme, "accent", targetText)}` : "";
|
|
461
|
+
return textComponent(`${themed(theme, "toolTitle", bold(theme, toToolName(exportName)))}${suffix}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function renderPlasticResult(
|
|
465
|
+
exportName: string,
|
|
466
|
+
result: PlasticToolResult | undefined,
|
|
467
|
+
options: { expanded?: boolean; isPartial?: boolean } | undefined,
|
|
468
|
+
theme: RenderTheme,
|
|
469
|
+
): TextLikeComponent {
|
|
470
|
+
if (options?.isPartial) {
|
|
471
|
+
return textComponent(themed(theme, "warning", `Running ${toToolName(exportName)}...`));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const output = extractTextContent(result);
|
|
475
|
+
const lines = trimTrailingEmptyLines(output.split(/\r?\n/).map((line) => line.replace(/\t/g, " ")));
|
|
476
|
+
const maxLines = options?.expanded ? lines.length : COLLAPSED_RESULT_LINES;
|
|
477
|
+
const displayLines = lines.slice(0, maxLines);
|
|
478
|
+
const remaining = lines.length - displayLines.length;
|
|
479
|
+
const rendered = displayLines.map((line) => themed(theme, "toolOutput", line));
|
|
480
|
+
|
|
481
|
+
if (remaining > 0) {
|
|
482
|
+
rendered.push(themed(theme, "muted", `... (${remaining} more lines, ctrl+o to expand)`));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (rendered.length === 0) {
|
|
486
|
+
rendered.push(themed(theme, "toolOutput", "(no output)"));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return textComponent(rendered.join("\n"));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function normalizeArgs(args: unknown): Record<string, unknown> {
|
|
493
|
+
return args && typeof args === "object" ? { ...(args as Record<string, unknown>) } : {};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function assignAlias(target: Record<string, unknown>, key: string, aliases: string[]): void {
|
|
497
|
+
if (target[key] !== undefined) return;
|
|
498
|
+
for (const alias of aliases) {
|
|
499
|
+
if (target[alias] !== undefined) {
|
|
500
|
+
target[key] = target[alias];
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function normalizeWorkdirAliases(input: Record<string, unknown>): void {
|
|
507
|
+
assignAlias(input, "workdir", ["cwd", "workingDirectory", "working_directory"]);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function normalizeOutputFormatAlias(input: Record<string, unknown>): Record<string, unknown> {
|
|
511
|
+
if (typeof input.format === "string" && input.format.trim().toLowerCase() === "markdown") {
|
|
512
|
+
input.format = "text";
|
|
513
|
+
}
|
|
514
|
+
if (typeof input.output === "string" && input.output.trim().toLowerCase() === "markdown") {
|
|
515
|
+
input.output = "text";
|
|
516
|
+
}
|
|
517
|
+
return input;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function promoteSinglePath(input: Record<string, unknown>): void {
|
|
521
|
+
if (input.paths !== undefined) return;
|
|
522
|
+
const singlePath = input.path ?? input.file;
|
|
523
|
+
if (typeof singlePath === "string" && singlePath.trim().length > 0) {
|
|
524
|
+
input.paths = [singlePath];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function getCoreTool(exportName: string): CoreTool {
|
|
529
|
+
const candidate = (core as Record<string, unknown>)[exportName] as CoreTool | undefined;
|
|
530
|
+
if (!candidate || typeof candidate.execute !== "function") {
|
|
531
|
+
throw new Error(`Missing Plastic core tool export '${exportName}'.`);
|
|
532
|
+
}
|
|
533
|
+
return candidate;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function isCoreSchemaNode(value: unknown): value is CoreSchemaNode {
|
|
537
|
+
return Boolean(value) && typeof value === "object" && typeof (value as { kind?: unknown }).kind === "string";
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function applySchemaMetadata(schema: TSchema, node: CoreSchemaNode): TSchema {
|
|
541
|
+
const metadata = node.metadata ?? {};
|
|
542
|
+
const options: Record<string, unknown> = {};
|
|
543
|
+
|
|
544
|
+
if (metadata.description) options.description = metadata.description;
|
|
545
|
+
if (metadata.min !== undefined) options.minimum = metadata.min;
|
|
546
|
+
if (metadata.max !== undefined) options.maximum = metadata.max;
|
|
547
|
+
|
|
548
|
+
let nextSchema: TSchema = schema;
|
|
549
|
+
if (Object.keys(options).length > 0) {
|
|
550
|
+
if (schema.type === "string") nextSchema = Type.String(options);
|
|
551
|
+
else if (schema.type === "number" || schema.type === "integer") nextSchema = metadata.int ? Type.Integer(options) : Type.Number(options);
|
|
552
|
+
else if (schema.type === "boolean") nextSchema = Type.Boolean(options);
|
|
553
|
+
else if (schema.type === "array") nextSchema = Type.Array((schema as any).items ?? Type.Any(), options);
|
|
554
|
+
else nextSchema = Type.Unsafe({ ...schema, ...options });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (metadata.int && nextSchema.type === "number") {
|
|
558
|
+
nextSchema = Type.Integer({ description: options.description as string | undefined, minimum: options.minimum as number | undefined, maximum: options.maximum as number | undefined });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return metadata.optional ? Type.Optional(nextSchema) : nextSchema;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function literalSchema(value: unknown): TSchema {
|
|
565
|
+
if (typeof value === "string") return Type.Literal(value);
|
|
566
|
+
if (typeof value === "number") return Number.isInteger(value) ? Type.Literal(value) : Type.Literal(value);
|
|
567
|
+
if (typeof value === "boolean") return Type.Literal(value);
|
|
568
|
+
return Type.Any();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function convertCoreSchema(node: unknown): TSchema {
|
|
572
|
+
if (!isCoreSchemaNode(node)) {
|
|
573
|
+
return literalSchema(node);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
let schema: TSchema;
|
|
577
|
+
switch (node.kind) {
|
|
578
|
+
case "string":
|
|
579
|
+
schema = Type.String();
|
|
580
|
+
break;
|
|
581
|
+
case "number":
|
|
582
|
+
schema = node.metadata?.int ? Type.Integer() : Type.Number();
|
|
583
|
+
break;
|
|
584
|
+
case "boolean":
|
|
585
|
+
schema = Type.Boolean();
|
|
586
|
+
break;
|
|
587
|
+
case "any":
|
|
588
|
+
schema = Type.Any();
|
|
589
|
+
break;
|
|
590
|
+
case "array":
|
|
591
|
+
schema = Type.Array(convertCoreSchema(node.inner));
|
|
592
|
+
break;
|
|
593
|
+
case "enum": {
|
|
594
|
+
const values = Array.isArray(node.values) ? node.values : [];
|
|
595
|
+
if (values.every((value) => typeof value === "string")) {
|
|
596
|
+
const valueSet = values as string[];
|
|
597
|
+
if (arraysEqual(valueSet, ["text", "json"])) {
|
|
598
|
+
schema = OUTPUT_FORMAT_SCHEMA;
|
|
599
|
+
} else if (arraysEqual(valueSet, ["shelve", "bring", "cancel"])) {
|
|
600
|
+
schema = PENDING_CHANGES_SCHEMA;
|
|
601
|
+
} else if (arraysEqual(valueSet, ["auto", "source", "destination"])) {
|
|
602
|
+
schema = MERGE_STRATEGY_SCHEMA;
|
|
603
|
+
} else if (arraysEqual(valueSet, ["ignoreeol", "ignorewhitespaces", "ignoreeolandwhitespaces", "recognizeall"])) {
|
|
604
|
+
schema = COMPARISON_METHOD_SCHEMA;
|
|
605
|
+
} else if (arraysEqual(valueSet, ["branch", "changeset"])) {
|
|
606
|
+
schema = REVIEW_TARGET_TYPE_SCHEMA;
|
|
607
|
+
} else if (arraysEqual(valueSet, ["date", "branchname"])) {
|
|
608
|
+
schema = BRANCH_ORDER_BY_SCHEMA;
|
|
609
|
+
} else if (arraysEqual(valueSet, ["date", "modifieddate", "status"])) {
|
|
610
|
+
schema = REVIEW_ORDER_BY_SCHEMA;
|
|
611
|
+
} else {
|
|
612
|
+
schema = enumSchema(valueSet as [string, ...string[]], metadataDescription(node));
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
schema = Type.Union(values.map((value) => literalSchema(value)) as [TSchema, TSchema, ...TSchema[]]);
|
|
616
|
+
}
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
case "union": {
|
|
620
|
+
const values = Array.isArray(node.values) ? node.values : [];
|
|
621
|
+
const converted = values.map((value) => convertCoreSchema(value));
|
|
622
|
+
schema = converted.length > 1 ? Type.Union(converted as [TSchema, TSchema, ...TSchema[]]) : (converted[0] ?? Type.Any());
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
default:
|
|
626
|
+
schema = Type.Any();
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return applySchemaMetadata(schema, node);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function arraysEqual(left: readonly string[], right: readonly string[]): boolean {
|
|
634
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function metadataDescription(node: CoreSchemaNode): string {
|
|
638
|
+
return node.metadata?.description ?? "Allowed values.";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function buildParameters(args: Record<string, unknown> | undefined): TSchema {
|
|
642
|
+
if (!args || Object.keys(args).length === 0) {
|
|
643
|
+
return EMPTY_PARAMETERS;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const properties: Record<string, TSchema> = {};
|
|
647
|
+
for (const [key, value] of Object.entries(args)) {
|
|
648
|
+
properties[key] = convertCoreSchema(value);
|
|
649
|
+
}
|
|
650
|
+
return Type.Object(properties);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export default function plasticTools(pi: ExtensionAPI) {
|
|
654
|
+
const coreDescriptions = new Map<string, string>();
|
|
655
|
+
for (const exportName of PLASTIC_EXPORTS) {
|
|
656
|
+
const coreTool = getCoreTool(exportName);
|
|
657
|
+
coreDescriptions.set(toToolName(exportName), coreTool.description ?? toToolName(exportName));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
for (const exportName of PLASTIC_EXPORTS) {
|
|
661
|
+
const coreTool = getCoreTool(exportName);
|
|
662
|
+
const config = TOOL_CONFIG[exportName] ?? {};
|
|
663
|
+
pi.registerTool({
|
|
664
|
+
name: toToolName(exportName),
|
|
665
|
+
label: toToolName(exportName),
|
|
666
|
+
description: coreTool.description ?? toToolName(exportName),
|
|
667
|
+
parameters: buildParameters(coreTool.args),
|
|
668
|
+
prepareArguments: config.prepareArguments,
|
|
669
|
+
renderCall(args, theme) {
|
|
670
|
+
return renderPlasticCall(exportName, (args ?? {}) as Record<string, unknown>, theme as RenderTheme);
|
|
671
|
+
},
|
|
672
|
+
renderResult(result, options, theme) {
|
|
673
|
+
return renderPlasticResult(exportName, result as PlasticToolResult | undefined, options, theme as RenderTheme);
|
|
674
|
+
},
|
|
675
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
676
|
+
const normalizedParams = normalizeArgs(params);
|
|
677
|
+
if (normalizedParams.workdir === undefined && ctx?.cwd) {
|
|
678
|
+
normalizedParams.workdir = ctx.cwd;
|
|
679
|
+
}
|
|
680
|
+
const result = await core.runWithAbortSignal(signal, async () => coreTool.execute(normalizedParams));
|
|
681
|
+
const text = toText(result);
|
|
682
|
+
return {
|
|
683
|
+
content: [{ type: "text", text }],
|
|
684
|
+
details: {
|
|
685
|
+
exportName,
|
|
686
|
+
rawResult: result,
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
pi.registerTool({
|
|
694
|
+
name: PLASTIC_TOOL_SEARCH_NAME,
|
|
695
|
+
label: "Plastic Tool Search",
|
|
696
|
+
description: "Search and enable Plastic SCM / Unity Version Control tools for workspace status and sync, changes and checkins, text-only diffs and patches, branches and merges, shelvesets, code reviews, and workspaces.",
|
|
697
|
+
promptSnippet: "Use plastic_tool_search to find and enable Plastic SCM capabilities that are not active.",
|
|
698
|
+
promptGuidelines: [
|
|
699
|
+
"Use plastic_tool_search before a Plastic SCM action when an appropriate plastic_* tool is not active. Inspect exact targets and use each tool's preflight and safety options before mutations.",
|
|
700
|
+
],
|
|
701
|
+
parameters: Type.Object({
|
|
702
|
+
query: Type.Optional(Type.String({ description: "Capability or workflow to search for." })),
|
|
703
|
+
toolNames: Type.Optional(Type.Array(Type.String({ description: "Exact public Plastic tool name." }), { maxItems: 4, description: "Optional exact tool names to enable." })),
|
|
704
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 4, description: "Maximum matching tools to enable. Defaults to the single best keyword match, except generic diff loads both safe text-only alternatives; exact toolNames requests may enable up to four." })),
|
|
705
|
+
}),
|
|
706
|
+
async execute(_toolCallId, params) {
|
|
707
|
+
if (isPlasticToolBrowseRequest(params)) {
|
|
708
|
+
return {
|
|
709
|
+
content: [{ type: "text", text: PLASTIC_TOOL_BROWSE_TEXT }],
|
|
710
|
+
details: { browse: true, matches: [], added: [], alreadyActive: [], guidance: [] },
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const ownership = getEffectivePlasticToolOwnership(pi.getAllTools(), EXTENSION_SOURCE_PATH);
|
|
715
|
+
const unknownToolNames = getUnknownExactPlasticToolNames(params.toolNames);
|
|
716
|
+
const active = pi.getActiveTools();
|
|
717
|
+
// Never infer ownership without Pi 0.82 canonical sourceInfo. In that
|
|
718
|
+
// compatibility mode we may describe known tools that are already active,
|
|
719
|
+
// but cannot safely activate an inactive same-named definition.
|
|
720
|
+
const matches = searchPlasticTools(params, coreDescriptions).filter((match) =>
|
|
721
|
+
ownership.usesSourceInfo ? ownership.ownedToolNames.has(match.name) : active.includes(match.name),
|
|
722
|
+
);
|
|
723
|
+
const requestedExactToolNames = Array.isArray(params.toolNames)
|
|
724
|
+
? [...new Set(params.toolNames.filter((name): name is string => typeof name === "string" && name.trim().length > 0).map((name) => name.trim()))]
|
|
725
|
+
: [];
|
|
726
|
+
const unavailableToolNames = requestedExactToolNames.filter((name) => !matches.some((match) => match.name.toLowerCase() === name.toLowerCase()));
|
|
727
|
+
if (matches.length === 0) {
|
|
728
|
+
const unavailable = unavailableToolNames.length > 0
|
|
729
|
+
? ` Unknown or unavailable exact tool names: ${unavailableToolNames.join(", ")}.`
|
|
730
|
+
: "";
|
|
731
|
+
return {
|
|
732
|
+
content: [{ type: "text", text: `No executable Plastic tools matched. Try a capability, workflow term, or exact public plastic_* tool name.${unavailable}` }],
|
|
733
|
+
details: { matches: [], added: [], alreadyActive: [], guidance: [], unknownToolNames, unavailableToolNames },
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const matchNames = matches.map((match) => match.name);
|
|
738
|
+
const added = matchNames.filter((name) => !active.includes(name));
|
|
739
|
+
const alreadyActive = matchNames.filter((name) => active.includes(name));
|
|
740
|
+
if (added.length > 0) pi.setActiveTools([...new Set([...active, ...added])]);
|
|
741
|
+
|
|
742
|
+
const guidance = matches.flatMap((match) => match.guidance);
|
|
743
|
+
const unknownText = unavailableToolNames.length > 0 ? `\nUnknown or unavailable exact tool names: ${unavailableToolNames.join(", ")}.` : "";
|
|
744
|
+
const loadedText = added.length > 0 ? `Activated: ${added.join(", ")}.` : "All matching tools were already active.";
|
|
745
|
+
return {
|
|
746
|
+
content: [{
|
|
747
|
+
type: "text",
|
|
748
|
+
text: `${loadedText}\nMatches: ${matchNames.join(", ")}.\nGuidance: ${guidance.join(" ")}${unknownText}`,
|
|
749
|
+
}],
|
|
750
|
+
details: { matches: matchNames, added, alreadyActive, guidance, unknownToolNames, unavailableToolNames },
|
|
751
|
+
};
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
pi.on("session_start", (_event, ctx) => {
|
|
756
|
+
const ownership = getEffectivePlasticToolOwnership(pi.getAllTools(), EXTENSION_SOURCE_PATH);
|
|
757
|
+
const active = pi.getActiveTools();
|
|
758
|
+
const mode = getPlasticToolLoadingMode();
|
|
759
|
+
// Fail safe when provenance is unavailable or the effective loader is a
|
|
760
|
+
// foreign first-registration-wins collision. Preserve the active set
|
|
761
|
+
// exactly; even adding the public loader name could activate foreign code.
|
|
762
|
+
if (!ownership.usesSourceInfo) return;
|
|
763
|
+
|
|
764
|
+
// The all-active eval mode keeps every currently exposed Plastic tool
|
|
765
|
+
// active while omitting the dynamic loader from the provider surface.
|
|
766
|
+
if (mode === "all-active") {
|
|
767
|
+
pi.setActiveTools(active.filter((name) => name !== PLASTIC_TOOL_SEARCH_NAME));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const initiallyInactive = getInitiallyInactivePlasticTools(mode);
|
|
772
|
+
const ownedInitiallyInactive = new Set([...initiallyInactive].filter((name) => ownership.ownedToolNames.has(name)));
|
|
773
|
+
const restored = getRestoredPlasticToolNames(ctx.sessionManager.getBranch(), ownership.ownedToolNames);
|
|
774
|
+
const preserved = active.filter((name) => !ownedInitiallyInactive.has(name));
|
|
775
|
+
pi.setActiveTools([...new Set([...preserved, PLASTIC_TOOL_SEARCH_NAME, ...restored])]);
|
|
776
|
+
});
|
|
777
|
+
}
|