@xl0/pi-lovely-agents 0.1.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 +17 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/extensions/lovely-agents/agent.ts +1374 -0
- package/extensions/lovely-agents/bash.ts +599 -0
- package/extensions/lovely-agents/child-session.ts +296 -0
- package/extensions/lovely-agents/config.ts +221 -0
- package/extensions/lovely-agents/coordinator.ts +506 -0
- package/extensions/lovely-agents/definitions.ts +380 -0
- package/extensions/lovely-agents/index.ts +400 -0
- package/extensions/lovely-agents/lifecycle.ts +251 -0
- package/extensions/lovely-agents/management.ts +638 -0
- package/extensions/lovely-agents/notifications.ts +220 -0
- package/extensions/lovely-agents/provider-limits.ts +13 -0
- package/extensions/lovely-agents/rendering.ts +90 -0
- package/extensions/lovely-agents/state.ts +1179 -0
- package/extensions/lovely-agents/task-panel.ts +192 -0
- package/extensions/lovely-agents/tools.ts +635 -0
- package/extensions/lovely-agents/updates.ts +45 -0
- package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
- package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
- package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
- package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
- package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
- package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
- package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
- package/package.json +68 -0
- package/skills/agent/SKILL.md +21 -0
- package/skills/agent-creator/SKILL.md +35 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { type Dirent, readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
|
|
4
|
+
import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter, type ScopedModel } from "@earendil-works/pi-coding-agent"
|
|
5
|
+
import { MODEL_ALIASES } from "./config.js"
|
|
6
|
+
|
|
7
|
+
const ALLOWED_KEYS = new Set(["name", "description", "model", "thinking", "tools", "exclude_agents_md"])
|
|
8
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
|
9
|
+
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"])
|
|
10
|
+
|
|
11
|
+
export type DefinitionSource = "user" | "project"
|
|
12
|
+
export type AgentThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
|
|
13
|
+
|
|
14
|
+
export type AgentDefinition = {
|
|
15
|
+
name: string
|
|
16
|
+
description: string
|
|
17
|
+
systemPrompt: string
|
|
18
|
+
source: DefinitionSource
|
|
19
|
+
filePath: string
|
|
20
|
+
displayPath: string
|
|
21
|
+
model?: string
|
|
22
|
+
thinking?: AgentThinkingLevel
|
|
23
|
+
tools?: string[]
|
|
24
|
+
excludeAgentsMd?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type DefinitionDiagnostic = {
|
|
28
|
+
type: "error" | "warning"
|
|
29
|
+
code: string
|
|
30
|
+
message: string
|
|
31
|
+
source: DefinitionSource
|
|
32
|
+
path: string
|
|
33
|
+
name?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type DefinitionDiscoveryResult = {
|
|
37
|
+
definitions: AgentDefinition[]
|
|
38
|
+
diagnostics: DefinitionDiagnostic[]
|
|
39
|
+
projectAgentsDir: string | undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type DefinitionCandidate = {
|
|
43
|
+
definition?: AgentDefinition
|
|
44
|
+
declaredName?: string
|
|
45
|
+
diagnostics: DefinitionDiagnostic[]
|
|
46
|
+
filePath: string
|
|
47
|
+
displayPath: string
|
|
48
|
+
source: DefinitionSource
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type AgentFrontmatter = Record<string, unknown> & {
|
|
52
|
+
name?: unknown
|
|
53
|
+
description?: unknown
|
|
54
|
+
model?: unknown
|
|
55
|
+
thinking?: unknown
|
|
56
|
+
tools?: unknown
|
|
57
|
+
exclude_agents_md?: unknown
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function discoverAgentDefinitions(options: {
|
|
61
|
+
cwd: string
|
|
62
|
+
projectTrusted: boolean
|
|
63
|
+
toolNames: readonly string[]
|
|
64
|
+
models: readonly ScopedModel["model"][]
|
|
65
|
+
agentDir?: string
|
|
66
|
+
configDirName?: string
|
|
67
|
+
homeDir?: string
|
|
68
|
+
}): DefinitionDiscoveryResult {
|
|
69
|
+
const cwd = resolve(options.cwd)
|
|
70
|
+
const homeDir = resolve(options.homeDir ?? homedir())
|
|
71
|
+
const userDir = join(options.agentDir ?? getAgentDir(), "agents")
|
|
72
|
+
const projectAgentsDir = options.projectTrusted ? findNearestProjectAgentsDir(cwd, options.configDirName ?? CONFIG_DIR_NAME) : undefined
|
|
73
|
+
const userCandidates = scanDefinitionDirectory(userDir, "user", options.toolNames, options.models, cwd, homeDir)
|
|
74
|
+
const projectCandidates = projectAgentsDir
|
|
75
|
+
? scanDefinitionDirectory(projectAgentsDir, "project", options.toolNames, options.models, cwd, homeDir)
|
|
76
|
+
: []
|
|
77
|
+
|
|
78
|
+
invalidateDuplicates(userCandidates)
|
|
79
|
+
invalidateDuplicates(projectCandidates)
|
|
80
|
+
|
|
81
|
+
const diagnostics = [...userCandidates, ...projectCandidates].flatMap(candidate => candidate.diagnostics)
|
|
82
|
+
const projectNames = new Set(projectCandidates.flatMap(candidate => candidate.declaredName ?? []))
|
|
83
|
+
const definitions: AgentDefinition[] = []
|
|
84
|
+
|
|
85
|
+
for (const candidate of userCandidates) {
|
|
86
|
+
if (!candidate.definition) continue
|
|
87
|
+
if (projectNames.has(candidate.definition.name)) {
|
|
88
|
+
diagnostics.push({
|
|
89
|
+
type: "warning",
|
|
90
|
+
code: "shadowed",
|
|
91
|
+
message: `User definition "${candidate.definition.name}" is shadowed by the project scope`,
|
|
92
|
+
source: "user",
|
|
93
|
+
path: candidate.displayPath,
|
|
94
|
+
name: candidate.definition.name
|
|
95
|
+
})
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
definitions.push(candidate.definition)
|
|
99
|
+
}
|
|
100
|
+
for (const candidate of projectCandidates) {
|
|
101
|
+
if (candidate.definition) definitions.push(candidate.definition)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
definitions: definitions.sort((left, right) => compareText(left.name, right.name)),
|
|
106
|
+
diagnostics: diagnostics.sort(compareDiagnostics),
|
|
107
|
+
projectAgentsDir
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function findNearestProjectAgentsDir(cwd: string, configDirName = CONFIG_DIR_NAME): string | undefined {
|
|
112
|
+
let directory = resolve(cwd)
|
|
113
|
+
while (true) {
|
|
114
|
+
const candidate = join(directory, configDirName, "agents")
|
|
115
|
+
try {
|
|
116
|
+
if (statSync(candidate).isDirectory()) return candidate
|
|
117
|
+
} catch {
|
|
118
|
+
// Missing, inaccessible, and broken links do not stop the ancestor walk.
|
|
119
|
+
}
|
|
120
|
+
const parent = dirname(directory)
|
|
121
|
+
if (parent === directory) return undefined
|
|
122
|
+
directory = parent
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function scanDefinitionDirectory(
|
|
127
|
+
directory: string,
|
|
128
|
+
source: DefinitionSource,
|
|
129
|
+
toolNames: readonly string[],
|
|
130
|
+
models: readonly ScopedModel["model"][],
|
|
131
|
+
cwd: string,
|
|
132
|
+
homeDir: string
|
|
133
|
+
): DefinitionCandidate[] {
|
|
134
|
+
let entries: Dirent[]
|
|
135
|
+
try {
|
|
136
|
+
entries = readdirSync(directory, { withFileTypes: true })
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (isMissing(error)) return []
|
|
139
|
+
return [diagnosticCandidate(directory, source, "directory-unreadable", errorMessage(error), cwd, homeDir)]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const candidates: DefinitionCandidate[] = []
|
|
143
|
+
for (const entry of entries.sort((left, right) => compareText(left.name, right.name))) {
|
|
144
|
+
if (!entry.name.endsWith(".md") || (!entry.isFile() && !entry.isSymbolicLink())) continue
|
|
145
|
+
const filePath = join(directory, entry.name)
|
|
146
|
+
const displayPath = formatDisplayPath(filePath, source, cwd, homeDir)
|
|
147
|
+
if (entry.isSymbolicLink()) {
|
|
148
|
+
try {
|
|
149
|
+
if (!statSync(filePath).isFile()) {
|
|
150
|
+
candidates.push(diagnosticCandidate(filePath, source, "not-file", "Definition link does not target a regular file", cwd, homeDir))
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
} catch (error) {
|
|
154
|
+
candidates.push(diagnosticCandidate(filePath, source, "unreadable-link", errorMessage(error), cwd, homeDir))
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
candidates.push(parseDefinitionFile(filePath, displayPath, source, toolNames, models))
|
|
159
|
+
}
|
|
160
|
+
return candidates
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseDefinitionFile(
|
|
164
|
+
filePath: string,
|
|
165
|
+
displayPath: string,
|
|
166
|
+
source: DefinitionSource,
|
|
167
|
+
toolNames: readonly string[],
|
|
168
|
+
models: readonly ScopedModel["model"][]
|
|
169
|
+
): DefinitionCandidate {
|
|
170
|
+
const candidate: DefinitionCandidate = { diagnostics: [], filePath, displayPath, source }
|
|
171
|
+
let content: string
|
|
172
|
+
try {
|
|
173
|
+
content = readFileSync(filePath, "utf8")
|
|
174
|
+
} catch (error) {
|
|
175
|
+
candidate.diagnostics.push(makeDiagnostic(candidate, "unreadable", errorMessage(error)))
|
|
176
|
+
return candidate
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let frontmatter: AgentFrontmatter
|
|
180
|
+
let body: string
|
|
181
|
+
try {
|
|
182
|
+
const parsed = parseFrontmatter<AgentFrontmatter>(content)
|
|
183
|
+
frontmatter = parsed.frontmatter
|
|
184
|
+
body = parsed.body
|
|
185
|
+
} catch (error) {
|
|
186
|
+
candidate.diagnostics.push(makeDiagnostic(candidate, "invalid-frontmatter", errorMessage(error)))
|
|
187
|
+
return candidate
|
|
188
|
+
}
|
|
189
|
+
if (!isRecord(frontmatter)) {
|
|
190
|
+
candidate.diagnostics.push(makeDiagnostic(candidate, "invalid-frontmatter", "Frontmatter must be a mapping"))
|
|
191
|
+
return candidate
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const rawName = frontmatter.name
|
|
195
|
+
if (typeof rawName === "string" && NAME_PATTERN.test(rawName)) candidate.declaredName = rawName
|
|
196
|
+
if (typeof rawName !== "string" || !NAME_PATTERN.test(rawName)) {
|
|
197
|
+
candidate.diagnostics.push(
|
|
198
|
+
makeDiagnostic(
|
|
199
|
+
candidate,
|
|
200
|
+
"invalid-name",
|
|
201
|
+
"name must match [a-z0-9][a-z0-9_-]{0,63}",
|
|
202
|
+
typeof rawName === "string" ? rawName : undefined
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const unknownKeys = Object.keys(frontmatter)
|
|
208
|
+
.filter(key => !ALLOWED_KEYS.has(key))
|
|
209
|
+
.sort(compareText)
|
|
210
|
+
for (const key of unknownKeys) {
|
|
211
|
+
candidate.diagnostics.push(makeDiagnostic(candidate, "unknown-key", `Unknown frontmatter key "${key}"`, candidate.declaredName))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const description = typeof frontmatter.description === "string" ? frontmatter.description.trim() : ""
|
|
215
|
+
if (!description || Buffer.byteLength(description, "utf8") > 500) {
|
|
216
|
+
candidate.diagnostics.push(
|
|
217
|
+
makeDiagnostic(candidate, "invalid-description", "description must be nonempty and at most 500 UTF-8 bytes", candidate.declaredName)
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
if (!body.trim())
|
|
221
|
+
candidate.diagnostics.push(makeDiagnostic(candidate, "empty-body", "Definition body must be nonempty", candidate.declaredName))
|
|
222
|
+
|
|
223
|
+
const model = parseDefinitionModel(frontmatter.model, models)
|
|
224
|
+
if (model.error) candidate.diagnostics.push(makeDiagnostic(candidate, "invalid-model", model.error, candidate.declaredName))
|
|
225
|
+
|
|
226
|
+
const thinking = parseThinkingLevel(frontmatter.thinking)
|
|
227
|
+
if (thinking.error) candidate.diagnostics.push(makeDiagnostic(candidate, "invalid-thinking", thinking.error, candidate.declaredName))
|
|
228
|
+
|
|
229
|
+
const tools = parseTools(frontmatter.tools, toolNames)
|
|
230
|
+
if (tools.error) candidate.diagnostics.push(makeDiagnostic(candidate, "invalid-tools", tools.error, candidate.declaredName))
|
|
231
|
+
|
|
232
|
+
const excludeAgentsMd = frontmatter.exclude_agents_md
|
|
233
|
+
if (excludeAgentsMd !== undefined && typeof excludeAgentsMd !== "boolean") {
|
|
234
|
+
candidate.diagnostics.push(
|
|
235
|
+
makeDiagnostic(candidate, "invalid-exclude-agents-md", "exclude_agents_md must be boolean", candidate.declaredName)
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (candidate.diagnostics.length === 0 && candidate.declaredName) {
|
|
240
|
+
candidate.definition = {
|
|
241
|
+
name: candidate.declaredName,
|
|
242
|
+
description,
|
|
243
|
+
systemPrompt: body,
|
|
244
|
+
source,
|
|
245
|
+
filePath,
|
|
246
|
+
displayPath,
|
|
247
|
+
...(model.value ? { model: model.value } : {}),
|
|
248
|
+
...(thinking.value ? { thinking: thinking.value } : {}),
|
|
249
|
+
...(tools.value ? { tools: tools.value } : {}),
|
|
250
|
+
...(typeof excludeAgentsMd === "boolean" ? { excludeAgentsMd } : {})
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return candidate
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parseDefinitionModel(value: unknown, models: readonly ScopedModel["model"][]): { value?: string; error?: string } {
|
|
257
|
+
if (value === undefined) return {}
|
|
258
|
+
if (typeof value !== "string" || !value.trim()) return { error: "model must be a nonempty string" }
|
|
259
|
+
const reference = value.trim().toLowerCase()
|
|
260
|
+
if (Object.hasOwn(MODEL_ALIASES, reference)) return { value: reference }
|
|
261
|
+
const canonical = models.filter(model => `${model.provider}/${model.id}`.toLowerCase() === reference)
|
|
262
|
+
if (canonical.length === 1) return { value: `${canonical[0]?.provider}/${canonical[0]?.id}` }
|
|
263
|
+
const byId = models.filter(model => model.id.toLowerCase() === reference)
|
|
264
|
+
if (byId.length === 1) return { value: `${byId[0]?.provider}/${byId[0]?.id}` }
|
|
265
|
+
if (byId.length > 1) {
|
|
266
|
+
return {
|
|
267
|
+
error: `model "${value.trim()}" is ambiguous: ${byId
|
|
268
|
+
.map(model => `${model.provider}/${model.id}`)
|
|
269
|
+
.sort(compareText)
|
|
270
|
+
.join(", ")}`
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { error: `unknown model "${value.trim()}"` }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseThinkingLevel(value: unknown): { value?: AgentThinkingLevel; error?: string } {
|
|
277
|
+
if (value === undefined) return {}
|
|
278
|
+
if (typeof value !== "string" || !THINKING_LEVELS.has(value)) {
|
|
279
|
+
return { error: `thinking must be one of: ${[...THINKING_LEVELS].join(", ")}` }
|
|
280
|
+
}
|
|
281
|
+
return { value: value as AgentThinkingLevel }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseTools(value: unknown, knownTools: readonly string[]): { value?: string[]; error?: string } {
|
|
285
|
+
if (value === undefined) return {}
|
|
286
|
+
const raw = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : undefined
|
|
287
|
+
if (!raw || raw.some(tool => typeof tool !== "string" || !tool.trim())) {
|
|
288
|
+
return { error: "tools must be a comma-separated string or a list of nonempty strings" }
|
|
289
|
+
}
|
|
290
|
+
const tools = raw.map(tool => (tool as string).trim())
|
|
291
|
+
const duplicate = tools.find((tool, index) => tools.indexOf(tool) !== index)
|
|
292
|
+
if (duplicate) return { error: `duplicate tool "${duplicate}"` }
|
|
293
|
+
const unknown = tools.filter(tool => !knownTools.includes(tool))
|
|
294
|
+
if (unknown.length > 0) return { error: `unknown tools: ${unknown.sort(compareText).join(", ")}` }
|
|
295
|
+
return { value: tools }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function invalidateDuplicates(candidates: DefinitionCandidate[]): void {
|
|
299
|
+
const byName = new Map<string, DefinitionCandidate[]>()
|
|
300
|
+
for (const candidate of candidates) {
|
|
301
|
+
if (!candidate.declaredName) continue
|
|
302
|
+
const group = byName.get(candidate.declaredName) ?? []
|
|
303
|
+
group.push(candidate)
|
|
304
|
+
byName.set(candidate.declaredName, group)
|
|
305
|
+
}
|
|
306
|
+
for (const [name, group] of byName) {
|
|
307
|
+
if (group.length < 2) continue
|
|
308
|
+
const paths = group
|
|
309
|
+
.map(candidate => candidate.displayPath)
|
|
310
|
+
.sort(compareText)
|
|
311
|
+
.join(", ")
|
|
312
|
+
for (const candidate of group) {
|
|
313
|
+
delete candidate.definition
|
|
314
|
+
candidate.diagnostics.push(
|
|
315
|
+
makeDiagnostic(candidate, "duplicate-name", `Duplicate name "${name}" in ${candidate.source} scope: ${paths}`, name)
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function diagnosticCandidate(
|
|
322
|
+
filePath: string,
|
|
323
|
+
source: DefinitionSource,
|
|
324
|
+
code: string,
|
|
325
|
+
message: string,
|
|
326
|
+
cwd: string,
|
|
327
|
+
homeDir: string
|
|
328
|
+
): DefinitionCandidate {
|
|
329
|
+
const candidate: DefinitionCandidate = {
|
|
330
|
+
diagnostics: [],
|
|
331
|
+
filePath,
|
|
332
|
+
displayPath: formatDisplayPath(filePath, source, cwd, homeDir),
|
|
333
|
+
source
|
|
334
|
+
}
|
|
335
|
+
candidate.diagnostics.push(makeDiagnostic(candidate, code, message))
|
|
336
|
+
return candidate
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function makeDiagnostic(candidate: DefinitionCandidate, code: string, message: string, name?: string): DefinitionDiagnostic {
|
|
340
|
+
return {
|
|
341
|
+
type: "error",
|
|
342
|
+
code,
|
|
343
|
+
message,
|
|
344
|
+
source: candidate.source,
|
|
345
|
+
path: candidate.displayPath,
|
|
346
|
+
...(name ? { name } : {})
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function formatDisplayPath(filePath: string, source: DefinitionSource, cwd: string, homeDir: string): string {
|
|
351
|
+
const absolute = resolve(filePath)
|
|
352
|
+
if (source === "user" && isWithin(homeDir, absolute)) return join("~", relative(homeDir, absolute))
|
|
353
|
+
const workspaceRelative = relative(cwd, absolute)
|
|
354
|
+
return workspaceRelative && !isAbsolute(workspaceRelative) ? workspaceRelative : basename(absolute)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isWithin(parent: string, child: string): boolean {
|
|
358
|
+
const path = relative(parent, child)
|
|
359
|
+
return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
363
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isMissing(error: unknown): boolean {
|
|
367
|
+
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT"
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function errorMessage(error: unknown): string {
|
|
371
|
+
return error instanceof Error ? error.message : String(error)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function compareDiagnostics(left: DefinitionDiagnostic, right: DefinitionDiagnostic): number {
|
|
375
|
+
return compareText(left.path, right.path) || compareText(left.code, right.code) || compareText(left.message, right.message)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function compareText(left: string, right: string): number {
|
|
379
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
380
|
+
}
|