@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/extensions/lovely-agents/agent.ts +1374 -0
  5. package/extensions/lovely-agents/bash.ts +599 -0
  6. package/extensions/lovely-agents/child-session.ts +296 -0
  7. package/extensions/lovely-agents/config.ts +221 -0
  8. package/extensions/lovely-agents/coordinator.ts +506 -0
  9. package/extensions/lovely-agents/definitions.ts +380 -0
  10. package/extensions/lovely-agents/index.ts +400 -0
  11. package/extensions/lovely-agents/lifecycle.ts +251 -0
  12. package/extensions/lovely-agents/management.ts +638 -0
  13. package/extensions/lovely-agents/notifications.ts +220 -0
  14. package/extensions/lovely-agents/provider-limits.ts +13 -0
  15. package/extensions/lovely-agents/rendering.ts +90 -0
  16. package/extensions/lovely-agents/state.ts +1179 -0
  17. package/extensions/lovely-agents/task-panel.ts +192 -0
  18. package/extensions/lovely-agents/tools.ts +635 -0
  19. package/extensions/lovely-agents/updates.ts +45 -0
  20. package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
  21. package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
  22. package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
  23. package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
  24. package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
  25. package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
  26. package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
  27. package/package.json +68 -0
  28. package/skills/agent/SKILL.md +21 -0
  29. 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
+ }