@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,399 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2
+ import { dirname, join, resolve as resolvePath } from "node:path"
3
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"
4
+
5
+ export type ConfigScope = "user" | "workspace"
6
+ export type ConfigPatch = Record<string, unknown>
7
+ export type ScopedConfigPatch = Record<ConfigScope, ConfigPatch>
8
+ export type ResolvedConfig<Config extends object> = { [Key in keyof Config]-?: NonNullable<Config[Key]> }
9
+ export type ConfigWarning = { key?: string; message: string }
10
+ export type ScopedConfigWarning = ConfigWarning & { scope: ConfigScope; path: string }
11
+
12
+ type StringValues = readonly [string, ...string[]]
13
+ type NumberValues = readonly [number, ...number[]]
14
+
15
+ export type VisibilityContext = {
16
+ get(key: string): unknown
17
+ getScoped(key: string, scope?: ConfigScope): unknown
18
+ scope: ConfigScope
19
+ }
20
+
21
+ type FieldMeta = {
22
+ label?: string
23
+ description?: string
24
+ depth?: number
25
+ visibleWhen?: (ctx: VisibilityContext) => boolean
26
+ }
27
+
28
+ type BaseField = FieldMeta & {
29
+ kind: "enum" | "multiEnum" | "boolean" | "string" | "text" | "number"
30
+ }
31
+
32
+ export type EnumConfigField<Values extends StringValues = StringValues> = BaseField & {
33
+ kind: "enum"
34
+ values: Values
35
+ valueDescriptions?: Partial<Record<Values[number], string>> & Record<string, string>
36
+ search?: boolean
37
+ default: Values[number]
38
+ }
39
+
40
+ export type MultiEnumConfigField<Values extends StringValues = StringValues> = BaseField & {
41
+ kind: "multiEnum"
42
+ values: Values
43
+ valueDescriptions?: Partial<Record<Values[number], string>> & Record<string, string>
44
+ default: readonly Values[number][]
45
+ }
46
+
47
+ export type BooleanConfigField = BaseField & {
48
+ kind: "boolean"
49
+ valueDescriptions?: Partial<Record<"on" | "off", string>>
50
+ default: boolean
51
+ }
52
+
53
+ export type StringConfigField = BaseField & {
54
+ kind: "string"
55
+ default: string
56
+ }
57
+
58
+ export type TextConfigField = BaseField & {
59
+ kind: "text"
60
+ default: string
61
+ }
62
+
63
+ type BaseNumberConfigField = BaseField & {
64
+ kind: "number"
65
+ default: number
66
+ valueDescriptions?: Record<string, string>
67
+ }
68
+
69
+ export type RangedNumberConfigField = BaseNumberConfigField & {
70
+ min?: number
71
+ max?: number
72
+ step?: number
73
+ values?: never
74
+ }
75
+
76
+ export type ValuedNumberConfigField<Values extends NumberValues = NumberValues> = BaseNumberConfigField & {
77
+ values: Values
78
+ default: Values[number]
79
+ min?: never
80
+ max?: never
81
+ step?: never
82
+ }
83
+
84
+ export type NumberConfigField = RangedNumberConfigField | ValuedNumberConfigField
85
+ export type ConfigField =
86
+ | EnumConfigField
87
+ | MultiEnumConfigField
88
+ | BooleanConfigField
89
+ | StringConfigField
90
+ | TextConfigField
91
+ | NumberConfigField
92
+ export type ConfigSchema = Record<string, ConfigField>
93
+ export type ScopedConfigField = ConfigField & { key: string; label: string }
94
+
95
+ export type ConfigFromSchema<Schema extends ConfigSchema> = {
96
+ [Key in keyof Schema]: FieldValue<Schema[Key]>
97
+ }
98
+
99
+ type FieldValue<Field> = Field extends { kind: "enum"; values: infer Values extends readonly string[] }
100
+ ? Values[number]
101
+ : Field extends { kind: "multiEnum"; values: infer Values extends readonly string[] }
102
+ ? readonly Values[number][]
103
+ : Field extends { kind: "boolean" }
104
+ ? boolean
105
+ : Field extends { kind: "string" | "text" }
106
+ ? string
107
+ : Field extends { kind: "number"; values: infer Values extends readonly number[] }
108
+ ? Values[number]
109
+ : Field extends { kind: "number" }
110
+ ? number
111
+ : never
112
+
113
+ export type ScopedConfig<Config extends object> = {
114
+ fileName: string
115
+ scopes: readonly ConfigScope[]
116
+ fields: readonly ScopedConfigField[]
117
+ defaults: ResolvedConfig<Config>
118
+ cwd: string | undefined
119
+ value: ResolvedConfig<Config>
120
+ scoped: ScopedConfigPatch
121
+ warnings: ScopedConfigWarning[]
122
+ path(scope: ConfigScope, cwd?: string): string
123
+ resolve(scoped?: ScopedConfigPatch): ResolvedConfig<Config>
124
+ load(cwd: string): ScopedConfig<Config>
125
+ update<Key extends keyof Config & string>(scope: ConfigScope, key: Key, value: Config[Key] | undefined): ScopedConfig<Config>
126
+ resetScope(scope: ConfigScope): ScopedConfig<Config>
127
+ }
128
+
129
+ type EnumFieldOptions<Values extends StringValues> = Omit<EnumConfigField<Values>, "kind" | "values" | "default">
130
+ type MultiEnumFieldOptions<Values extends StringValues> = Omit<MultiEnumConfigField<Values>, "kind" | "values" | "default">
131
+ type BooleanFieldOptions = Omit<BooleanConfigField, "kind" | "default">
132
+ type StringFieldOptions = Omit<StringConfigField, "kind" | "default">
133
+ type TextFieldOptions = Omit<TextConfigField, "kind" | "default">
134
+ type RangedNumberOptions = Omit<RangedNumberConfigField, "kind" | "default">
135
+ type ValuedNumberOptions<Values extends NumberValues> = Omit<ValuedNumberConfigField<Values>, "kind" | "default">
136
+
137
+ function enumField<const Values extends StringValues>(
138
+ values: Values,
139
+ defaultValue: Values[number],
140
+ options: EnumFieldOptions<Values> = {}
141
+ ): EnumConfigField<Values> {
142
+ if (values.length === 0) throw new Error("Enum field must have at least one value")
143
+ if (!values.includes(defaultValue)) throw new Error(`Enum field default must be one of: ${values.join(", ")}`)
144
+ return { kind: "enum", values, default: defaultValue, ...options }
145
+ }
146
+
147
+ function multiEnumField<const Values extends StringValues>(
148
+ values: Values,
149
+ defaultValue: readonly Values[number][],
150
+ options: MultiEnumFieldOptions<Values> = {}
151
+ ): MultiEnumConfigField<Values> {
152
+ if (values.length === 0) throw new Error("Multi-enum field must have at least one value")
153
+ const warning = getMultiEnumWarning(values, defaultValue)
154
+ if (warning) throw new Error(`Multi-enum field default ${warning}`)
155
+ return { kind: "multiEnum", values, default: defaultValue, ...options }
156
+ }
157
+
158
+ function getMultiEnumWarning(values: readonly string[], value: unknown): string | undefined {
159
+ if (!Array.isArray(value)) return "must be array"
160
+ for (const item of value) {
161
+ if (typeof item !== "string" || !values.includes(item)) return `items should be one of: ${values.join(", ")}`
162
+ }
163
+ return undefined
164
+ }
165
+
166
+ function booleanField(defaultValue: boolean, options: BooleanFieldOptions = {}): BooleanConfigField {
167
+ return { kind: "boolean", default: defaultValue, ...options }
168
+ }
169
+
170
+ function stringField(defaultValue: string, options: StringFieldOptions = {}): StringConfigField {
171
+ if (/[\r\n]/.test(defaultValue)) throw new Error("String field default must be single-line")
172
+ return { kind: "string", default: defaultValue, ...options }
173
+ }
174
+
175
+ function textField(defaultValue: string, options: TextFieldOptions = {}): TextConfigField {
176
+ return { kind: "text", default: defaultValue, ...options }
177
+ }
178
+
179
+ function numberField<const Values extends NumberValues>(
180
+ defaultValue: Values[number],
181
+ options: ValuedNumberOptions<Values>
182
+ ): ValuedNumberConfigField<Values>
183
+ function numberField(defaultValue: number, options?: RangedNumberOptions): RangedNumberConfigField
184
+ function numberField(defaultValue: number, options: RangedNumberOptions | ValuedNumberOptions<NumberValues> = {}): NumberConfigField {
185
+ const rawRangeOptions = options as { min?: number; max?: number; step?: number }
186
+ if (
187
+ options.values !== undefined &&
188
+ (rawRangeOptions.min !== undefined || rawRangeOptions.max !== undefined || rawRangeOptions.step !== undefined)
189
+ ) {
190
+ throw new Error("Number field cannot combine values with min, max, or step")
191
+ }
192
+ if (!Number.isFinite(defaultValue)) throw new Error("Number field default must be finite")
193
+ if (options.values !== undefined) {
194
+ if (options.values.length === 0) throw new Error("Number field values must have at least one value")
195
+ for (const value of options.values) {
196
+ if (!Number.isFinite(value)) throw new Error("Number field values must be finite")
197
+ }
198
+ if (!options.values.includes(defaultValue)) throw new Error(`Number field default must be one of: ${options.values.join(", ")}`)
199
+ } else {
200
+ if (options.min !== undefined && !Number.isFinite(options.min)) throw new Error("Number field min must be finite")
201
+ if (options.max !== undefined && !Number.isFinite(options.max)) throw new Error("Number field max must be finite")
202
+ if (options.step !== undefined && (!Number.isFinite(options.step) || options.step <= 0)) {
203
+ throw new Error("Number field step must be a positive finite number")
204
+ }
205
+ if (options.min !== undefined && options.max !== undefined && options.min > options.max) {
206
+ throw new Error("Number field min must be less than or equal to max")
207
+ }
208
+ if (options.min !== undefined && defaultValue < options.min) throw new Error(`Number field default must be at least ${options.min}`)
209
+ if (options.max !== undefined && defaultValue > options.max) throw new Error(`Number field default must be at most ${options.max}`)
210
+ }
211
+ return { kind: "number", default: defaultValue, ...options } as NumberConfigField
212
+ }
213
+
214
+ export const field = {
215
+ enum: enumField,
216
+ multiEnum: multiEnumField,
217
+ boolean: booleanField,
218
+ string: stringField,
219
+ text: textField,
220
+ number: numberField
221
+ }
222
+
223
+ export function defineScopedConfig<const Schema extends ConfigSchema>(options: {
224
+ fileName: string
225
+ scope?: ConfigScope
226
+ schema: Schema
227
+ }): ScopedConfig<ConfigFromSchema<Schema>> {
228
+ type Config = ConfigFromSchema<Schema>
229
+ if (!/^[A-Za-z0-9._-]+$/.test(options.fileName) || options.fileName === "." || options.fileName === "..") {
230
+ throw new Error(`Invalid config file name: ${options.fileName}`)
231
+ }
232
+
233
+ const fields = Object.entries(options.schema).map(([key, field]) => ({ ...field, key, label: field.label ?? key }))
234
+ const defaults = Object.fromEntries(fields.map(field => [field.key, field.default]))
235
+
236
+ return new ScopedConfigImpl<Config>(
237
+ options.fileName,
238
+ options.scope === undefined ? ["user", "workspace"] : [options.scope],
239
+ fields,
240
+ defaults as ResolvedConfig<Config>
241
+ )
242
+ }
243
+
244
+ class ScopedConfigImpl<Config extends object> implements ScopedConfig<Config> {
245
+ readonly #fieldByKey: Map<string, ScopedConfigField>
246
+ cwd: string | undefined
247
+ value: ResolvedConfig<Config>
248
+ scoped: ScopedConfigPatch = { user: {}, workspace: {} }
249
+ warnings: ScopedConfigWarning[] = []
250
+
251
+ constructor(
252
+ readonly fileName: string,
253
+ readonly scopes: readonly ConfigScope[],
254
+ readonly fields: readonly ScopedConfigField[],
255
+ readonly defaults: ResolvedConfig<Config>
256
+ ) {
257
+ this.#fieldByKey = new Map(fields.map(field => [field.key, field]))
258
+ this.value = { ...defaults }
259
+ }
260
+
261
+ path(scope: ConfigScope, cwd?: string): string {
262
+ return scope === "user" ? join(getAgentDir(), this.fileName) : resolvePath(cwd ?? this.currentCwd(), CONFIG_DIR_NAME, this.fileName)
263
+ }
264
+
265
+ resolve(scoped: ScopedConfigPatch = this.scoped): ResolvedConfig<Config> {
266
+ const resolved: Record<string, unknown> = { ...this.defaults }
267
+ for (const scope of this.scopes) {
268
+ for (const field of this.fields) {
269
+ const value = scoped[scope][field.key]
270
+ if (value !== undefined && !getConfigValueWarning(field, value)) resolved[field.key] = value
271
+ }
272
+ }
273
+ return resolved as ResolvedConfig<Config>
274
+ }
275
+
276
+ load(cwd: string): ScopedConfig<Config> {
277
+ const scoped: ScopedConfigPatch = { user: {}, workspace: {} }
278
+ const warnings: ScopedConfigWarning[] = []
279
+ for (const scope of this.scopes) {
280
+ const path = this.path(scope, cwd)
281
+ const result = readConfigFile(path)
282
+ scoped[scope] = result.config
283
+ if (result.warning) warnings.push({ message: result.warning, scope, path })
284
+ }
285
+ this.cwd = cwd
286
+ this.scoped = scoped
287
+ this.value = this.resolve(scoped)
288
+ this.warnings = warnings
289
+ for (const scope of this.scopes) {
290
+ for (const warning of getConfigWarnings(this.fields, scoped[scope])) {
291
+ this.warnings.push({ ...warning, scope, path: this.path(scope, cwd) })
292
+ }
293
+ }
294
+ return this
295
+ }
296
+
297
+ update<Key extends keyof Config & string>(scope: ConfigScope, key: Key, value: Config[Key] | undefined): ScopedConfig<Config> {
298
+ if (!this.scopes.includes(scope)) throw new Error(`Config scope is not active: ${scope}`)
299
+ const field = this.#fieldByKey.get(key)
300
+ if (!field) throw new Error(`Unknown config key: ${key}`)
301
+ if (value !== undefined) {
302
+ const warning = getConfigValueWarning(field, value)
303
+ if (warning) throw new Error(warning)
304
+ }
305
+
306
+ const cwd = this.currentCwd()
307
+ const configPath = this.path(scope)
308
+ // Patch existing file instead of writing only known schema keys.
309
+ // Unknown keys belong to newer app versions and must survive old versions.
310
+ const patch = readConfigFile(configPath).config
311
+ if (value === undefined) delete patch[key]
312
+ else patch[key] = value
313
+ writeConfigFile(configPath, patch)
314
+ return this.load(cwd)
315
+ }
316
+
317
+ resetScope(scope: ConfigScope): ScopedConfig<Config> {
318
+ if (!this.scopes.includes(scope)) throw new Error(`Config scope is not active: ${scope}`)
319
+ const cwd = this.currentCwd()
320
+ const configPath = this.path(scope)
321
+ const patch = readConfigFile(configPath).config
322
+ for (const field of this.fields) delete patch[field.key]
323
+ writeConfigFile(configPath, patch)
324
+ return this.load(cwd)
325
+ }
326
+
327
+ private currentCwd(): string {
328
+ if (!this.cwd) throw new Error("Config must be loaded before this operation")
329
+ return this.cwd
330
+ }
331
+ }
332
+
333
+ function readConfigFile(path: string): { config: ConfigPatch; warning?: string } {
334
+ if (!existsSync(path)) return { config: {} }
335
+ const source = readFileSync(path, "utf-8")
336
+ let value: unknown
337
+ try {
338
+ value = JSON.parse(source)
339
+ } catch (error) {
340
+ const message = error instanceof Error && error.message ? error.message : String(error)
341
+ return { config: {}, warning: `Invalid config: ${message}; file is ignored` }
342
+ }
343
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
344
+ return { config: {}, warning: "Invalid config: must be an object; file is ignored" }
345
+ }
346
+ const config: ConfigPatch = {}
347
+ for (const [key, fieldValue] of Object.entries(value)) {
348
+ if (fieldValue !== undefined) config[key] = fieldValue
349
+ }
350
+ return { config }
351
+ }
352
+
353
+ function writeConfigFile(path: string, config: ConfigPatch): void {
354
+ if (Object.keys(config).length === 0) {
355
+ rmSync(path, { force: true })
356
+ return
357
+ }
358
+ mkdirSync(dirname(path), { recursive: true })
359
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8")
360
+ }
361
+
362
+ export function getConfigWarnings(fields: readonly ScopedConfigField[], config: object): ConfigWarning[] {
363
+ const warnings: ConfigWarning[] = []
364
+ for (const field of fields) {
365
+ const value = (config as Record<string, unknown>)[field.key]
366
+ if (value === undefined) continue
367
+ const message = getConfigValueWarning(field, value)
368
+ if (message) warnings.push({ key: field.key, message: `${message}; value is ignored while resolving` })
369
+ }
370
+ return warnings
371
+ }
372
+
373
+ function getConfigValueWarning(field: ScopedConfigField, value: unknown): string | undefined {
374
+ switch (field.kind) {
375
+ case "enum":
376
+ if (typeof value !== "string") return `/${field.key} must be string`
377
+ if (!field.values.includes(value)) return `/${field.key} should be one of: ${field.values.join(", ")}`
378
+ return undefined
379
+ case "multiEnum": {
380
+ const warning = getMultiEnumWarning(field.values, value)
381
+ return warning ? `/${field.key} ${warning}` : undefined
382
+ }
383
+ case "boolean":
384
+ return typeof value === "boolean" ? undefined : `/${field.key} must be boolean`
385
+ case "string":
386
+ if (typeof value !== "string") return `/${field.key} must be string`
387
+ return /[\r\n]/.test(value) ? `/${field.key} must be single-line string` : undefined
388
+ case "text":
389
+ return typeof value === "string" ? undefined : `/${field.key} must be string`
390
+ case "number":
391
+ if (typeof value !== "number" || !Number.isFinite(value)) return `/${field.key} must be number`
392
+ if (field.values !== undefined && !field.values.includes(value)) {
393
+ return `/${field.key} should be one of: ${field.values.join(", ")}`
394
+ }
395
+ if (field.min !== undefined && value < field.min) return `/${field.key} should be at least ${field.min}`
396
+ if (field.max !== undefined && value > field.max) return `/${field.key} should be at most ${field.max}`
397
+ return undefined
398
+ }
399
+ }
@@ -0,0 +1,3 @@
1
+ export type { ConfigFromSchema, ConfigScope, ScopedConfig } from "./config"
2
+ export { defineScopedConfig, field } from "./config"
3
+ export { ScopedConfigEditor } from "./ui"