@raidou/pi-pm-subagents 0.1.1
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/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { loadMarkdown, mergePromptDefinitions } from './markdown.js'
|
|
8
|
+
import { composeTools } from './tools.js'
|
|
9
|
+
|
|
10
|
+
describe('composeTools', () => {
|
|
11
|
+
it('uses base tools when config has no tools', () => {
|
|
12
|
+
expect(composeTools(['read', 'write'], {})).toEqual(['read', 'write'])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('replaces base with config.tools', () => {
|
|
16
|
+
expect(composeTools(['read', 'write'], { tools: ['grep'] })).toEqual([
|
|
17
|
+
'grep',
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('adds extraTools to candidates', () => {
|
|
22
|
+
expect(composeTools(['read', 'write'], { extraTools: ['bash'] })).toEqual([
|
|
23
|
+
'read',
|
|
24
|
+
'write',
|
|
25
|
+
'bash',
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('removes tools from base and candidates', () => {
|
|
30
|
+
expect(
|
|
31
|
+
composeTools(['read', 'write', 'edit'], { removeTools: ['write'] }),
|
|
32
|
+
).toEqual(['read', 'edit'])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('combines tools, extraTools, and removeTools', () => {
|
|
36
|
+
const result = composeTools(['read', 'write', 'edit'], {
|
|
37
|
+
tools: ['read', 'bash'],
|
|
38
|
+
extraTools: ['grep'],
|
|
39
|
+
removeTools: ['write'],
|
|
40
|
+
})
|
|
41
|
+
expect(result).toEqual(['read', 'bash', 'grep'])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('deduplicates tools', () => {
|
|
45
|
+
const result = composeTools(['read', 'write'], {
|
|
46
|
+
tools: ['read'],
|
|
47
|
+
extraTools: ['read', 'bash'],
|
|
48
|
+
})
|
|
49
|
+
expect(result).toEqual(['read', 'bash'])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('loadMarkdown', () => {
|
|
54
|
+
let tempDir: string
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
tempDir = await mkdtemp(join(tmpdir(), 'pi-pm-subagents-test-markdown-'))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns undefined for non-existent file', async () => {
|
|
65
|
+
const result = await loadMarkdown(join(tempDir, 'nonexistent.md'))
|
|
66
|
+
expect(result).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('loads markdown with frontmatter', async () => {
|
|
70
|
+
const filePath = join(tempDir, 'test.md')
|
|
71
|
+
await writeFile(
|
|
72
|
+
filePath,
|
|
73
|
+
`---
|
|
74
|
+
description: A test mode.
|
|
75
|
+
tools: [read, write]
|
|
76
|
+
---
|
|
77
|
+
This is a test mode prompt.`,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const result = await loadMarkdown(filePath)
|
|
81
|
+
expect(result).toEqual({
|
|
82
|
+
fm: {
|
|
83
|
+
description: 'A test mode.',
|
|
84
|
+
model: undefined,
|
|
85
|
+
thinkingLevel: undefined,
|
|
86
|
+
tools: ['read', 'write'],
|
|
87
|
+
extraTools: undefined,
|
|
88
|
+
removeTools: undefined,
|
|
89
|
+
reviewOnEnd: undefined,
|
|
90
|
+
},
|
|
91
|
+
systemPrompt: 'This is a test mode prompt.',
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('handles markdown without frontmatter', async () => {
|
|
96
|
+
const filePath = join(tempDir, 'no-fm.md')
|
|
97
|
+
await writeFile(filePath, 'Plain prompt without frontmatter.')
|
|
98
|
+
|
|
99
|
+
const result = await loadMarkdown(filePath)
|
|
100
|
+
expect(result).toEqual({
|
|
101
|
+
fm: {
|
|
102
|
+
description: undefined,
|
|
103
|
+
model: undefined,
|
|
104
|
+
thinkingLevel: undefined,
|
|
105
|
+
tools: undefined,
|
|
106
|
+
extraTools: undefined,
|
|
107
|
+
removeTools: undefined,
|
|
108
|
+
reviewOnEnd: undefined,
|
|
109
|
+
},
|
|
110
|
+
systemPrompt: 'Plain prompt without frontmatter.',
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('supports tools as array', async () => {
|
|
115
|
+
const filePath = join(tempDir, 'array-tools.md')
|
|
116
|
+
await writeFile(
|
|
117
|
+
filePath,
|
|
118
|
+
`---
|
|
119
|
+
tools: [read, write, edit, grep]
|
|
120
|
+
---
|
|
121
|
+
Test prompt.`,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const result = await loadMarkdown(filePath)
|
|
125
|
+
expect(result?.fm.tools).toEqual(['read', 'write', 'edit', 'grep'])
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('supports extraTools as array', async () => {
|
|
129
|
+
const filePath = join(tempDir, 'extra-array.md')
|
|
130
|
+
await writeFile(
|
|
131
|
+
filePath,
|
|
132
|
+
`---
|
|
133
|
+
extraTools: [bash, grep]
|
|
134
|
+
---
|
|
135
|
+
Test prompt.`,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const result = await loadMarkdown(filePath)
|
|
139
|
+
expect(result?.fm.extraTools).toEqual(['bash', 'grep'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('supports removeTools as array', async () => {
|
|
143
|
+
const filePath = join(tempDir, 'remove-array.md')
|
|
144
|
+
await writeFile(
|
|
145
|
+
filePath,
|
|
146
|
+
`---
|
|
147
|
+
removeTools: [write, bash, edit]
|
|
148
|
+
---
|
|
149
|
+
Test prompt.`,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
const result = await loadMarkdown(filePath)
|
|
153
|
+
expect(result?.fm.removeTools).toEqual(['write', 'bash', 'edit'])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('supports model field', async () => {
|
|
157
|
+
const filePath = join(tempDir, 'model-test.md')
|
|
158
|
+
await writeFile(
|
|
159
|
+
filePath,
|
|
160
|
+
`---
|
|
161
|
+
model: anthropic/claude-sonnet-4-5
|
|
162
|
+
---
|
|
163
|
+
Test prompt.`,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const result = await loadMarkdown(filePath)
|
|
167
|
+
expect(result?.fm.model).toBe('anthropic/claude-sonnet-4-5')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('supports reviewOnEnd field as true', async () => {
|
|
171
|
+
const filePath = join(tempDir, 'review-on-end.md')
|
|
172
|
+
await writeFile(
|
|
173
|
+
filePath,
|
|
174
|
+
`---
|
|
175
|
+
reviewOnEnd: true
|
|
176
|
+
---
|
|
177
|
+
Test prompt.`,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const result = await loadMarkdown(filePath)
|
|
181
|
+
expect(result?.fm.reviewOnEnd).toBe(true)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('supports reviewOnEnd field as false', async () => {
|
|
185
|
+
const filePath = join(tempDir, 'review-on-end-false.md')
|
|
186
|
+
await writeFile(
|
|
187
|
+
filePath,
|
|
188
|
+
`---
|
|
189
|
+
reviewOnEnd: false
|
|
190
|
+
---
|
|
191
|
+
Test prompt.`,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const result = await loadMarkdown(filePath)
|
|
195
|
+
expect(result?.fm.reviewOnEnd).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('returns undefined reviewOnEnd when not specified', async () => {
|
|
199
|
+
const filePath = join(tempDir, 'no-review.md')
|
|
200
|
+
await writeFile(filePath, 'Test prompt.')
|
|
201
|
+
|
|
202
|
+
const result = await loadMarkdown(filePath)
|
|
203
|
+
expect(result?.fm.reviewOnEnd).toBeUndefined()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('supports thinkingLevel field', async () => {
|
|
207
|
+
const filePath = join(tempDir, 'thinking-level.md')
|
|
208
|
+
await writeFile(
|
|
209
|
+
filePath,
|
|
210
|
+
`---
|
|
211
|
+
thinkingLevel: high
|
|
212
|
+
---
|
|
213
|
+
Test prompt.`,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const result = await loadMarkdown(filePath)
|
|
217
|
+
expect(result?.fm.thinkingLevel).toBe('high')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('supports thinkingLevel off', async () => {
|
|
221
|
+
const filePath = join(tempDir, 'thinking-level-off.md')
|
|
222
|
+
await writeFile(
|
|
223
|
+
filePath,
|
|
224
|
+
`---
|
|
225
|
+
thinkingLevel: off
|
|
226
|
+
---
|
|
227
|
+
Test prompt.`,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
const result = await loadMarkdown(filePath)
|
|
231
|
+
expect(result?.fm.thinkingLevel).toBe('off')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('returns undefined thinkingLevel when not specified', async () => {
|
|
235
|
+
const filePath = join(tempDir, 'no-thinking-level.md')
|
|
236
|
+
await writeFile(filePath, 'Test prompt.')
|
|
237
|
+
|
|
238
|
+
const result = await loadMarkdown(filePath)
|
|
239
|
+
expect(result?.fm.thinkingLevel).toBeUndefined()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('throws when thinkingLevel is invalid', async () => {
|
|
243
|
+
const filePath = join(tempDir, 'invalid-thinking-level.md')
|
|
244
|
+
await writeFile(
|
|
245
|
+
filePath,
|
|
246
|
+
`---
|
|
247
|
+
thinkingLevel: ultra
|
|
248
|
+
---
|
|
249
|
+
Test prompt.`,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
await expect(loadMarkdown(filePath)).rejects.toThrow()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('handles empty tools array', async () => {
|
|
256
|
+
const filePath = join(tempDir, 'empty-tools.md')
|
|
257
|
+
await writeFile(
|
|
258
|
+
filePath,
|
|
259
|
+
`---
|
|
260
|
+
tools: []
|
|
261
|
+
---
|
|
262
|
+
Test prompt.`,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
const result = await loadMarkdown(filePath)
|
|
266
|
+
expect(result?.fm.tools).toEqual([])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('throws when tools is a number', async () => {
|
|
270
|
+
const filePath = join(tempDir, 'invalid-tools-number.md')
|
|
271
|
+
await writeFile(
|
|
272
|
+
filePath,
|
|
273
|
+
`---
|
|
274
|
+
tools: 123
|
|
275
|
+
---
|
|
276
|
+
Test prompt.`,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
await expect(loadMarkdown(filePath)).rejects.toThrow()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('throws when tools array contains non-string', async () => {
|
|
283
|
+
const filePath = join(tempDir, 'invalid-tools-array.md')
|
|
284
|
+
await writeFile(
|
|
285
|
+
filePath,
|
|
286
|
+
`---
|
|
287
|
+
tools: [123, "read"]
|
|
288
|
+
---
|
|
289
|
+
Test prompt.`,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
await expect(loadMarkdown(filePath)).rejects.toThrow()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('throws when reviewOnEnd is a string', async () => {
|
|
296
|
+
const filePath = join(tempDir, 'invalid-review-string.md')
|
|
297
|
+
await writeFile(
|
|
298
|
+
filePath,
|
|
299
|
+
`---
|
|
300
|
+
reviewOnEnd: "true"
|
|
301
|
+
---
|
|
302
|
+
Test prompt.`,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
await expect(loadMarkdown(filePath)).rejects.toThrow()
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe('mergePromptDefinitions', () => {
|
|
310
|
+
const base = {
|
|
311
|
+
fm: {
|
|
312
|
+
description: 'Base description.',
|
|
313
|
+
model: 'base/model',
|
|
314
|
+
tools: ['read', 'write'] as string[],
|
|
315
|
+
extraTools: ['bash'] as string[],
|
|
316
|
+
removeTools: ['write'] as string[],
|
|
317
|
+
thinkingLevel: undefined,
|
|
318
|
+
reviewOnEnd: undefined,
|
|
319
|
+
},
|
|
320
|
+
systemPrompt: 'Base prompt.',
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
it('append description overrides base description', () => {
|
|
324
|
+
const result = mergePromptDefinitions(base, {
|
|
325
|
+
...base,
|
|
326
|
+
fm: { ...base.fm, description: 'Append description.' },
|
|
327
|
+
})
|
|
328
|
+
expect(result.fm.description).toBe('Append description.')
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('keeps base description when append has none', () => {
|
|
332
|
+
const result = mergePromptDefinitions(base, {
|
|
333
|
+
...base,
|
|
334
|
+
fm: { ...base.fm, description: undefined },
|
|
335
|
+
})
|
|
336
|
+
expect(result.fm.description).toBe('Base description.')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('append model overrides base model', () => {
|
|
340
|
+
const result = mergePromptDefinitions(base, {
|
|
341
|
+
...base,
|
|
342
|
+
fm: { ...base.fm, model: 'append/model' },
|
|
343
|
+
})
|
|
344
|
+
expect(result.fm.model).toBe('append/model')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('merges tools arrays as union, preserving order with dedup', () => {
|
|
348
|
+
const result = mergePromptDefinitions(base, {
|
|
349
|
+
...base,
|
|
350
|
+
fm: { ...base.fm, tools: ['write', 'grep', 'read'] },
|
|
351
|
+
})
|
|
352
|
+
expect(result.fm.tools).toEqual(['read', 'write', 'grep'])
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('merges extraTools and removeTools as union, preserving order with dedup', () => {
|
|
356
|
+
const result = mergePromptDefinitions(base, {
|
|
357
|
+
...base,
|
|
358
|
+
fm: {
|
|
359
|
+
...base.fm,
|
|
360
|
+
extraTools: ['bash', 'glob'],
|
|
361
|
+
removeTools: ['write', 'edit'],
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
expect(result.fm.extraTools).toEqual(['bash', 'glob'])
|
|
365
|
+
expect(result.fm.removeTools).toEqual(['write', 'edit'])
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('does not throw with empty / partial fields', () => {
|
|
369
|
+
const result = mergePromptDefinitions(
|
|
370
|
+
{ fm: {}, systemPrompt: 'Base.' },
|
|
371
|
+
{ fm: {}, systemPrompt: '' },
|
|
372
|
+
)
|
|
373
|
+
expect(result).toEqual({
|
|
374
|
+
fm: {
|
|
375
|
+
description: undefined,
|
|
376
|
+
model: undefined,
|
|
377
|
+
thinkingLevel: undefined,
|
|
378
|
+
tools: undefined,
|
|
379
|
+
extraTools: undefined,
|
|
380
|
+
removeTools: undefined,
|
|
381
|
+
reviewOnEnd: undefined,
|
|
382
|
+
},
|
|
383
|
+
systemPrompt: 'Base.',
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('append thinkingLevel overrides base thinkingLevel', () => {
|
|
388
|
+
const result = mergePromptDefinitions(base, {
|
|
389
|
+
...base,
|
|
390
|
+
fm: { ...base.fm, thinkingLevel: 'high' },
|
|
391
|
+
})
|
|
392
|
+
expect(result.fm.thinkingLevel).toBe('high')
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('keeps base thinkingLevel when append has none', () => {
|
|
396
|
+
const result = mergePromptDefinitions(
|
|
397
|
+
{ ...base, fm: { ...base.fm, thinkingLevel: 'low' } },
|
|
398
|
+
{ ...base, fm: { ...base.fm, thinkingLevel: undefined } },
|
|
399
|
+
)
|
|
400
|
+
expect(result.fm.thinkingLevel).toBe('low')
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('returns undefined thinkingLevel when neither has it', () => {
|
|
404
|
+
const result = mergePromptDefinitions(
|
|
405
|
+
{ ...base, fm: { ...base.fm, thinkingLevel: undefined } },
|
|
406
|
+
{ ...base, fm: { ...base.fm, thinkingLevel: undefined } },
|
|
407
|
+
)
|
|
408
|
+
expect(result.fm.thinkingLevel).toBeUndefined()
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('uses append reviewOnEnd when defined', () => {
|
|
412
|
+
const result = mergePromptDefinitions(base, {
|
|
413
|
+
...base,
|
|
414
|
+
fm: { ...base.fm, reviewOnEnd: true },
|
|
415
|
+
})
|
|
416
|
+
expect(result.fm.reviewOnEnd).toBe(true)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('uses base reviewOnEnd when append has none', () => {
|
|
420
|
+
const result = mergePromptDefinitions(
|
|
421
|
+
{ ...base, fm: { ...base.fm, reviewOnEnd: true } },
|
|
422
|
+
{ ...base, fm: { ...base.fm, reviewOnEnd: undefined } },
|
|
423
|
+
)
|
|
424
|
+
expect(result.fm.reviewOnEnd).toBe(true)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('returns undefined reviewOnEnd when neither has it', () => {
|
|
428
|
+
const result = mergePromptDefinitions(
|
|
429
|
+
{ ...base, fm: { ...base.fm, reviewOnEnd: undefined } },
|
|
430
|
+
{ ...base, fm: { ...base.fm, reviewOnEnd: undefined } },
|
|
431
|
+
)
|
|
432
|
+
expect(result.fm.reviewOnEnd).toBeUndefined()
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('joins systemPrompt with a blank line', () => {
|
|
436
|
+
const result = mergePromptDefinitions(
|
|
437
|
+
{ ...base, systemPrompt: 'First.' },
|
|
438
|
+
{ ...base, systemPrompt: 'Second.' },
|
|
439
|
+
)
|
|
440
|
+
expect(result.systemPrompt).toBe('First.\n\nSecond.')
|
|
441
|
+
})
|
|
442
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { Static } from 'typebox'
|
|
3
|
+
import { Type } from 'typebox'
|
|
4
|
+
import { Parse } from 'typebox/value'
|
|
5
|
+
|
|
6
|
+
import { readOptional } from './fs.js'
|
|
7
|
+
|
|
8
|
+
const StringList = Type.Array(Type.String())
|
|
9
|
+
|
|
10
|
+
const ThinkingLevelSchema = Type.Union([
|
|
11
|
+
Type.Literal('off'),
|
|
12
|
+
Type.Literal('minimal'),
|
|
13
|
+
Type.Literal('low'),
|
|
14
|
+
Type.Literal('medium'),
|
|
15
|
+
Type.Literal('high'),
|
|
16
|
+
Type.Literal('xhigh'),
|
|
17
|
+
Type.Literal('max'),
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
const PromptFrontmatterSchema = Type.Object({
|
|
21
|
+
description: Type.Optional(Type.String()),
|
|
22
|
+
model: Type.Optional(Type.String()),
|
|
23
|
+
thinkingLevel: Type.Optional(ThinkingLevelSchema),
|
|
24
|
+
tools: Type.Optional(StringList),
|
|
25
|
+
extraTools: Type.Optional(StringList),
|
|
26
|
+
removeTools: Type.Optional(StringList),
|
|
27
|
+
reviewOnEnd: Type.Optional(Type.Boolean()),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export type PromptFrontmatter = Static<typeof PromptFrontmatterSchema>
|
|
31
|
+
|
|
32
|
+
export interface PromptDefinition {
|
|
33
|
+
fm: PromptFrontmatter
|
|
34
|
+
systemPrompt?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loadMarkdown(
|
|
38
|
+
path: string,
|
|
39
|
+
): Promise<PromptDefinition | undefined> {
|
|
40
|
+
const content = await readOptional(path)
|
|
41
|
+
if (content === undefined) return undefined
|
|
42
|
+
const { frontmatter, body } = parseFrontmatter(content)
|
|
43
|
+
const fm = Parse(PromptFrontmatterSchema, frontmatter)
|
|
44
|
+
return { fm, systemPrompt: body.trim() }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function mergeUniqueTools(
|
|
48
|
+
baseTools: readonly string[] | undefined,
|
|
49
|
+
appendTools: readonly string[] | undefined,
|
|
50
|
+
): string[] | undefined {
|
|
51
|
+
const merged = new Set([...(baseTools ?? []), ...(appendTools ?? [])])
|
|
52
|
+
return merged.size > 0 ? [...merged] : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function mergePromptDefinitions(
|
|
56
|
+
baseTools: PromptDefinition,
|
|
57
|
+
append: PromptDefinition,
|
|
58
|
+
): PromptDefinition {
|
|
59
|
+
return {
|
|
60
|
+
fm: {
|
|
61
|
+
description: append.fm.description ?? baseTools.fm.description,
|
|
62
|
+
model: append.fm.model ?? baseTools.fm.model,
|
|
63
|
+
thinkingLevel: append.fm.thinkingLevel ?? baseTools.fm.thinkingLevel,
|
|
64
|
+
tools: mergeUniqueTools(baseTools.fm.tools, append.fm.tools),
|
|
65
|
+
extraTools: mergeUniqueTools(
|
|
66
|
+
baseTools.fm.extraTools,
|
|
67
|
+
append.fm.extraTools,
|
|
68
|
+
),
|
|
69
|
+
removeTools: mergeUniqueTools(
|
|
70
|
+
baseTools.fm.removeTools,
|
|
71
|
+
append.fm.removeTools,
|
|
72
|
+
),
|
|
73
|
+
reviewOnEnd: append.fm.reviewOnEnd ?? baseTools.fm.reviewOnEnd,
|
|
74
|
+
},
|
|
75
|
+
systemPrompt: append.systemPrompt
|
|
76
|
+
? `${baseTools.systemPrompt}\n\n${append.systemPrompt}`
|
|
77
|
+
: baseTools.systemPrompt,
|
|
78
|
+
}
|
|
79
|
+
}
|