@tduyng/pi-harness-loop 0.0.0-stage → 0.0.2
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/LICENSE +178 -0
- package/README.md +380 -2
- package/package.json +41 -4
- package/src/apply.ts +257 -0
- package/src/atomic.ts +7 -0
- package/src/auto.ts +483 -0
- package/src/check.ts +123 -0
- package/src/coordination.ts +30 -0
- package/src/corpus.ts +641 -0
- package/src/diversity.ts +73 -0
- package/src/effect.ts +134 -0
- package/src/evaluate.ts +81 -0
- package/src/findings.ts +141 -0
- package/src/index.ts +761 -0
- package/src/install.ts +308 -0
- package/src/ledger.ts +89 -0
- package/src/lesson-tool.ts +39 -0
- package/src/lint.ts +87 -0
- package/src/match.ts +192 -0
- package/src/producer.ts +236 -0
- package/src/prompt.ts +162 -0
- package/src/replay.ts +173 -0
- package/src/store.ts +274 -0
- package/src/target.ts +76 -0
- package/src/transcript.ts +139 -0
- package/src/tree.ts +307 -0
- package/src/types.ts +77 -0
- package/src/writer.ts +134 -0
package/src/tree.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, type Dirent } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { formatSkillsForPrompt, loadSkills, type Skill } from '@earendil-works/pi-coding-agent'
|
|
6
|
+
|
|
7
|
+
export interface AgentProfile {
|
|
8
|
+
name: string
|
|
9
|
+
description: string
|
|
10
|
+
file: string
|
|
11
|
+
body: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Problem {
|
|
15
|
+
entry: string
|
|
16
|
+
file: string
|
|
17
|
+
rule: string
|
|
18
|
+
detail: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Harness {
|
|
22
|
+
skills: Skill[]
|
|
23
|
+
agents: AgentProfile[]
|
|
24
|
+
problems: Problem[]
|
|
25
|
+
skillBlockChars: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Frontmatter {
|
|
29
|
+
fields: Record<string, string>
|
|
30
|
+
body: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const RUN_COMMAND = /(?:python3?|bash|sh|node)\s+([^\s`'"]+)/g
|
|
34
|
+
|
|
35
|
+
function skillDirs(cwd: string, agentDir: string): string[] {
|
|
36
|
+
const dirs = [join(agentDir, 'skills'), join(cwd, '.pi', 'skills')]
|
|
37
|
+
let dir = resolve(cwd)
|
|
38
|
+
while (true) {
|
|
39
|
+
dirs.push(join(dir, '.agents', 'skills'))
|
|
40
|
+
const parent = join(dir, '..')
|
|
41
|
+
if (parent === dir || existsSync(join(dir, '.git'))) break
|
|
42
|
+
dir = parent
|
|
43
|
+
}
|
|
44
|
+
dirs.push(join(homedir(), '.agents', 'skills'))
|
|
45
|
+
return dirs.filter((candidate) => existsSync(candidate))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function agentDirs(cwd: string, agentDir: string): string[] {
|
|
49
|
+
return [join(agentDir, 'agents'), join(cwd, '.pi', 'agents')].filter((dir) => existsSync(dir))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function frontmatter(raw: string): Frontmatter {
|
|
53
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(raw)
|
|
54
|
+
if (!match?.[1]) return { fields: {}, body: raw }
|
|
55
|
+
const fields: Record<string, string> = {}
|
|
56
|
+
for (const line of match[1].split('\n')) {
|
|
57
|
+
const entry = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line)
|
|
58
|
+
if (entry?.[1] !== undefined) fields[entry[1]] = (entry[2] ?? '').trim().replace(/^["']|["']$/g, '')
|
|
59
|
+
}
|
|
60
|
+
return { fields, body: raw.slice(match[0].length) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadAgents(cwd: string, agentDir: string): AgentProfile[] {
|
|
64
|
+
const agents: AgentProfile[] = []
|
|
65
|
+
for (const dir of agentDirs(cwd, agentDir)) {
|
|
66
|
+
let entries: string[]
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(dir)
|
|
69
|
+
} catch {
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
for (const entry of entries.toSorted()) {
|
|
73
|
+
if (!entry.endsWith('.md')) continue
|
|
74
|
+
const file = join(dir, entry)
|
|
75
|
+
let raw: string
|
|
76
|
+
try {
|
|
77
|
+
raw = readFileSync(file, 'utf8')
|
|
78
|
+
} catch {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const { fields, body } = frontmatter(raw)
|
|
82
|
+
agents.push({
|
|
83
|
+
name: fields['name'] || basename(entry, '.md'),
|
|
84
|
+
description: fields['description'] ?? '',
|
|
85
|
+
file,
|
|
86
|
+
body: body.trim()
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return agents
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveHelper(target: string, skillDir: string, searchDirs: string[]): string | null {
|
|
94
|
+
const candidates: string[] = []
|
|
95
|
+
if (target.startsWith('~/')) candidates.push(join(homedir(), target.slice(2)))
|
|
96
|
+
else if (target.startsWith('/')) candidates.push(target)
|
|
97
|
+
else {
|
|
98
|
+
candidates.push(resolve(skillDir, target))
|
|
99
|
+
for (const dir of searchDirs) candidates.push(resolve(dir, target))
|
|
100
|
+
}
|
|
101
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface SkillFiles {
|
|
105
|
+
filePath: string
|
|
106
|
+
baseDir: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function missingHelpers(skill: SkillFiles, searchDirs: string[]): string[] {
|
|
110
|
+
let body: string
|
|
111
|
+
try {
|
|
112
|
+
body = readFileSync(skill.filePath, 'utf8')
|
|
113
|
+
} catch {
|
|
114
|
+
return []
|
|
115
|
+
}
|
|
116
|
+
const missing = new Set<string>()
|
|
117
|
+
for (const match of body.matchAll(RUN_COMMAND)) {
|
|
118
|
+
const target = match[1]
|
|
119
|
+
if (!target?.includes('scripts/')) continue
|
|
120
|
+
if (resolveHelper(target, skill.baseDir, searchDirs) === null) missing.add(target)
|
|
121
|
+
}
|
|
122
|
+
return [...missing]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findSkillFiles(dir: string): string[] {
|
|
126
|
+
const files: string[] = []
|
|
127
|
+
let entries: Dirent[]
|
|
128
|
+
try {
|
|
129
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
130
|
+
} catch {
|
|
131
|
+
return files
|
|
132
|
+
}
|
|
133
|
+
for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
|
|
134
|
+
const full = join(dir, entry.name)
|
|
135
|
+
if (entry.isFile() && entry.name === 'SKILL.md') {
|
|
136
|
+
files.push(full)
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (entry.isDirectory()) files.push(...findSkillFiles(full))
|
|
140
|
+
}
|
|
141
|
+
return files
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function validateProposal(dir: string, cwd: string): Problem[] {
|
|
145
|
+
const problems: Problem[] = []
|
|
146
|
+
for (const file of findSkillFiles(dir)) {
|
|
147
|
+
const raw = readFileSync(file, 'utf8')
|
|
148
|
+
const { fields } = frontmatter(raw)
|
|
149
|
+
const name = fields['name'] ?? ''
|
|
150
|
+
const description = fields['description'] ?? ''
|
|
151
|
+
if (description.trim() === '') {
|
|
152
|
+
problems.push({
|
|
153
|
+
entry: name,
|
|
154
|
+
file,
|
|
155
|
+
rule: 'no-description',
|
|
156
|
+
detail: 'pi drops a skill with no description at load, so it never reaches the model'
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
if (name !== '' && !/^[a-z0-9-]+$/.test(name)) {
|
|
160
|
+
problems.push({
|
|
161
|
+
entry: name,
|
|
162
|
+
file,
|
|
163
|
+
rule: 'bad-name',
|
|
164
|
+
detail: 'a skill name is lowercase letters, digits and hyphens'
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
for (const helper of missingHelpers({ filePath: file, baseDir: dirname(file) }, [cwd])) {
|
|
168
|
+
problems.push({
|
|
169
|
+
entry: name,
|
|
170
|
+
file,
|
|
171
|
+
rule: 'missing-helper',
|
|
172
|
+
detail: `the skill tells the agent to run ${helper}, and no such file exists`
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const file of findRuleFiles(dir)) problems.push(...ruleProblems(file))
|
|
177
|
+
return problems
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const RULE_MAX_LINES = 30
|
|
181
|
+
const CREDENTIAL = /\b(api[_-]?key|access[_-]?token|secret|password|passwd|bearer)\b\s*[:=]\s*\S+/i
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A rule lands in a file every request of every project reads, so the project check cannot
|
|
185
|
+
* fail for it and the lint is the only gate it passes.
|
|
186
|
+
*/
|
|
187
|
+
function ruleProblems(file: string): Problem[] {
|
|
188
|
+
const problems: Problem[] = []
|
|
189
|
+
let raw: string
|
|
190
|
+
try {
|
|
191
|
+
raw = readFileSync(file, 'utf8')
|
|
192
|
+
} catch {
|
|
193
|
+
return problems
|
|
194
|
+
}
|
|
195
|
+
const body = raw.trim()
|
|
196
|
+
if (!/^##\s+\S/m.test(body)) {
|
|
197
|
+
problems.push({
|
|
198
|
+
entry: 'RULE',
|
|
199
|
+
file,
|
|
200
|
+
rule: 'no-heading',
|
|
201
|
+
detail: 'a rule is addressed by its heading, and without one the loop cannot replace it later'
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
const lines = body.split('\n').length
|
|
205
|
+
if (lines > RULE_MAX_LINES) {
|
|
206
|
+
problems.push({
|
|
207
|
+
entry: 'RULE',
|
|
208
|
+
file,
|
|
209
|
+
rule: 'too-long',
|
|
210
|
+
detail: `the rule is ${lines} lines and every request of every project pays for it`
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
if (CREDENTIAL.test(body)) {
|
|
214
|
+
problems.push({
|
|
215
|
+
entry: 'RULE',
|
|
216
|
+
file,
|
|
217
|
+
rule: 'credential',
|
|
218
|
+
detail: 'the rule carries a credential and lands in a file every session reads'
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
return problems
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function findRuleFiles(dir: string): string[] {
|
|
225
|
+
const files: string[] = []
|
|
226
|
+
for (const path of [join(dir, 'RULE.md'), join(dir, 'global', 'RULE.md')]) {
|
|
227
|
+
if (existsSync(path)) files.push(path)
|
|
228
|
+
}
|
|
229
|
+
return files
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function loadHarness(cwd: string, agentDir: string): Harness {
|
|
233
|
+
const { skills, diagnostics } = loadSkills({
|
|
234
|
+
agentDir,
|
|
235
|
+
skillPaths: skillDirs(cwd, agentDir),
|
|
236
|
+
includeDefaults: true,
|
|
237
|
+
cwd
|
|
238
|
+
})
|
|
239
|
+
const problems: Problem[] = []
|
|
240
|
+
for (const diagnostic of diagnostics) {
|
|
241
|
+
problems.push({
|
|
242
|
+
entry: diagnostic.path ?? '',
|
|
243
|
+
file: diagnostic.path ?? '',
|
|
244
|
+
rule: diagnostic.type,
|
|
245
|
+
detail: diagnostic.message
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const searchDirs = [cwd, agentDir]
|
|
250
|
+
const names = new Map<string, string>()
|
|
251
|
+
for (const skill of skills) {
|
|
252
|
+
const dir = basename(skill.baseDir)
|
|
253
|
+
if (skill.name !== dir) {
|
|
254
|
+
problems.push({
|
|
255
|
+
entry: skill.name,
|
|
256
|
+
file: skill.filePath,
|
|
257
|
+
rule: 'name-differs-from-directory',
|
|
258
|
+
detail: `pi advertises the frontmatter name "${skill.name}" and the folder is "${dir}", so a prompt that names the folder does not name the skill`
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
const previous = names.get(skill.name)
|
|
262
|
+
if (previous !== undefined) {
|
|
263
|
+
problems.push({
|
|
264
|
+
entry: skill.name,
|
|
265
|
+
file: skill.filePath,
|
|
266
|
+
rule: 'duplicate-name',
|
|
267
|
+
detail: `pi keeps the first skill named "${skill.name}" (${previous}) and drops this one`
|
|
268
|
+
})
|
|
269
|
+
} else {
|
|
270
|
+
names.set(skill.name, skill.filePath)
|
|
271
|
+
}
|
|
272
|
+
for (const helper of missingHelpers(skill, searchDirs)) {
|
|
273
|
+
problems.push({
|
|
274
|
+
entry: skill.name,
|
|
275
|
+
file: skill.filePath,
|
|
276
|
+
rule: 'missing-helper',
|
|
277
|
+
detail: `the skill tells the agent to run ${helper}, and no such file exists`
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const agents = loadAgents(cwd, agentDir)
|
|
283
|
+
const agentNames = new Map<string, string>()
|
|
284
|
+
for (const agent of agents) {
|
|
285
|
+
if (agent.description.trim() === '') {
|
|
286
|
+
problems.push({
|
|
287
|
+
entry: agent.name,
|
|
288
|
+
file: agent.file,
|
|
289
|
+
rule: 'no-description',
|
|
290
|
+
detail: 'a profile with no description is listed as "no description", so the model cannot choose it'
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
const previous = agentNames.get(agent.name)
|
|
294
|
+
if (previous !== undefined) {
|
|
295
|
+
problems.push({
|
|
296
|
+
entry: agent.name,
|
|
297
|
+
file: agent.file,
|
|
298
|
+
rule: 'duplicate-name',
|
|
299
|
+
detail: `this profile replaces the earlier one named "${agent.name}" (${previous})`
|
|
300
|
+
})
|
|
301
|
+
} else {
|
|
302
|
+
agentNames.set(agent.name, agent.file)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { skills, agents, problems, skillBlockChars: formatSkillsForPrompt(skills).length }
|
|
307
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type Confidence = 'high' | 'medium' | 'low'
|
|
2
|
+
|
|
3
|
+
export type FindingKind =
|
|
4
|
+
| 'repeat-error'
|
|
5
|
+
| 'repeat-command'
|
|
6
|
+
| 'repeat-read'
|
|
7
|
+
| 'no-change'
|
|
8
|
+
| 'abandoned-tool'
|
|
9
|
+
| 'dead-skill'
|
|
10
|
+
|
|
11
|
+
export type LessonState = 'recorded' | 'installed' | 'failed' | 'rejected' | 'superseded'
|
|
12
|
+
|
|
13
|
+
export type InstallState = 'recorded' | 'installed' | 'rejected'
|
|
14
|
+
|
|
15
|
+
export interface Lesson {
|
|
16
|
+
id: string
|
|
17
|
+
at: number
|
|
18
|
+
cue: string
|
|
19
|
+
lesson: string
|
|
20
|
+
evidence: string
|
|
21
|
+
confidence: Confidence
|
|
22
|
+
triggers: string[]
|
|
23
|
+
kind: FindingKind
|
|
24
|
+
state: LessonState
|
|
25
|
+
confirm?: string
|
|
26
|
+
supersedes?: string
|
|
27
|
+
uses?: number
|
|
28
|
+
lastUsed?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HarnessSettings {
|
|
32
|
+
check: string
|
|
33
|
+
/** Whether the loop starts a proposal on its own. The manual command works either way. */
|
|
34
|
+
auto: boolean
|
|
35
|
+
autoInstall: boolean
|
|
36
|
+
/** Whether a part may reach outside the project, into the skills or the context every project reads. */
|
|
37
|
+
global: boolean
|
|
38
|
+
maxInstallsPerDay: number
|
|
39
|
+
maxProposalsPerDay: number
|
|
40
|
+
maxTurnsPerProposal: number
|
|
41
|
+
/** Tokens of always-on prompt the active installs may add, all of them together. */
|
|
42
|
+
maxStandingTokens: number
|
|
43
|
+
/** `provider/model` for the writer, so a hard session does not pay its model for a short part. */
|
|
44
|
+
writerModel: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface InstalledFile {
|
|
48
|
+
to: string
|
|
49
|
+
existed: boolean
|
|
50
|
+
before: string
|
|
51
|
+
after?: string
|
|
52
|
+
afterHash?: string
|
|
53
|
+
block?: string
|
|
54
|
+
mode: 'replace' | 'append'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Install {
|
|
58
|
+
id: string
|
|
59
|
+
at: number
|
|
60
|
+
finding: string
|
|
61
|
+
dir: string
|
|
62
|
+
files: InstalledFile[]
|
|
63
|
+
check: string
|
|
64
|
+
ok: boolean
|
|
65
|
+
reverted: boolean
|
|
66
|
+
lesson?: string
|
|
67
|
+
key?: string
|
|
68
|
+
/** Characters the part added to the always-on prompt, which is what it costs on every later request. */
|
|
69
|
+
cost?: number
|
|
70
|
+
confirm?: string
|
|
71
|
+
confirmOk?: boolean
|
|
72
|
+
confirmOutput?: string
|
|
73
|
+
replay?: string
|
|
74
|
+
replayOk?: boolean
|
|
75
|
+
replayOutput?: string
|
|
76
|
+
note?: string
|
|
77
|
+
}
|
package/src/writer.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface Completion {
|
|
5
|
+
text: string
|
|
6
|
+
stop: string
|
|
7
|
+
input: number
|
|
8
|
+
output: number
|
|
9
|
+
cost: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Complete = (systemPrompt: string, prompt: string) => Promise<Completion>
|
|
13
|
+
|
|
14
|
+
export interface WriteOutcome {
|
|
15
|
+
ok: boolean
|
|
16
|
+
message: string
|
|
17
|
+
files: string[]
|
|
18
|
+
turns: number
|
|
19
|
+
input: number
|
|
20
|
+
output: number
|
|
21
|
+
cost: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PartFile {
|
|
25
|
+
path: string
|
|
26
|
+
content: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FILE_BLOCK = /<file path="([^"]+)">\r?\n([\s\S]*?)<\/file>/g
|
|
30
|
+
const MAX_PART_CHARS = 40_000
|
|
31
|
+
const MAX_FILE_CHARS = 20_000
|
|
32
|
+
const ATTEMPTS = 2
|
|
33
|
+
|
|
34
|
+
const REPLY = [
|
|
35
|
+
'Reply with one block per file, and nothing outside them:',
|
|
36
|
+
'',
|
|
37
|
+
'<file path="RULE.md">',
|
|
38
|
+
'## Heading',
|
|
39
|
+
'the rule',
|
|
40
|
+
'</file>',
|
|
41
|
+
'',
|
|
42
|
+
'A path is relative to the proposal directory: RULE.md, CONFIRM.md, <name>/SKILL.md,',
|
|
43
|
+
'<name>.md for a profile, <name>/scripts/<file> for a helper, and the same under global/ for',
|
|
44
|
+
'every project. Reply KEEP_BASE alone when the evidence does not argue for a change.'
|
|
45
|
+
].join('\n')
|
|
46
|
+
|
|
47
|
+
function nameFile(name: string): boolean {
|
|
48
|
+
return /^[a-z0-9][a-z0-9._-]*$/.test(name)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The paths a part may write, so a reply cannot reach outside its proposal directory. */
|
|
52
|
+
export function proposalPath(raw: string): string | null {
|
|
53
|
+
const path = raw.trim().replaceAll('\\', '/')
|
|
54
|
+
if (path === '' || path.startsWith('/') || path.includes('..') || path.includes(' ')) return null
|
|
55
|
+
const parts = path.split('/').filter((part) => part !== '')
|
|
56
|
+
const tail = parts[0] === 'global' ? parts.slice(1) : parts
|
|
57
|
+
if (tail.length === 1) {
|
|
58
|
+
const single = tail[0] ?? ''
|
|
59
|
+
if (single === 'RULE.md' || single === 'CONFIRM.md') return path
|
|
60
|
+
return single.endsWith('.md') && nameFile(single.slice(0, -3)) ? path : null
|
|
61
|
+
}
|
|
62
|
+
if (tail.length === 2 && (tail[1] === 'SKILL.md' || tail[1] === 'RULE.md')) {
|
|
63
|
+
return nameFile(tail[0] ?? '') ? path : null
|
|
64
|
+
}
|
|
65
|
+
if (tail.length === 3 && tail[1] === 'scripts') {
|
|
66
|
+
return nameFile(tail[0] ?? '') && nameFile(tail[2] ?? '') ? path : null
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parsePart(reply: string): PartFile[] {
|
|
72
|
+
const files: PartFile[] = []
|
|
73
|
+
let total = 0
|
|
74
|
+
for (const match of reply.matchAll(FILE_BLOCK)) {
|
|
75
|
+
const path = proposalPath(match[1] ?? '')
|
|
76
|
+
const body = (match[2] ?? '').trimEnd()
|
|
77
|
+
if (path === null || body.trim() === '' || body.length > MAX_FILE_CHARS) continue
|
|
78
|
+
total += body.length
|
|
79
|
+
if (total > MAX_PART_CHARS) return []
|
|
80
|
+
if (files.some((file) => file.path === path)) continue
|
|
81
|
+
files.push({ path, content: `${body}\n` })
|
|
82
|
+
}
|
|
83
|
+
return files
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function keptBase(reply: string): boolean {
|
|
87
|
+
return /^\s*KEEP_BASE\s*[.!]?\s*$/m.test(reply)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One completion writes the part, because a proposal that waits for a person to read a brief
|
|
92
|
+
* is a proposal that never lands. A reply is parsed into files, never executed.
|
|
93
|
+
*/
|
|
94
|
+
export async function writePart(dir: string, brief: string, complete: Complete): Promise<WriteOutcome> {
|
|
95
|
+
const system = `${brief}\n\n# HOW TO REPLY\n\n${REPLY}`
|
|
96
|
+
let problem = ''
|
|
97
|
+
let input = 0
|
|
98
|
+
let output = 0
|
|
99
|
+
let cost = 0
|
|
100
|
+
for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
|
|
101
|
+
const prompt =
|
|
102
|
+
problem === ''
|
|
103
|
+
? 'Write the part now.'
|
|
104
|
+
: `The last reply was rejected: ${problem}. Reply again with the file blocks.`
|
|
105
|
+
const reply = await complete(system, prompt)
|
|
106
|
+
input += reply.input
|
|
107
|
+
output += reply.output
|
|
108
|
+
cost += reply.cost
|
|
109
|
+
if (keptBase(reply.text)) {
|
|
110
|
+
return { ok: false, message: 'the writer kept the base', files: [], turns: attempt, input, output, cost }
|
|
111
|
+
}
|
|
112
|
+
const files = parsePart(reply.text)
|
|
113
|
+
if (files.length === 0) {
|
|
114
|
+
writeFileSync(join(dir, 'REPLY.txt'), `[${reply.stop}]\n${reply.text}`, 'utf8')
|
|
115
|
+
problem = 'the reply held no <file> block with an allowed path'
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
const to = join(dir, file.path)
|
|
120
|
+
mkdirSync(dirname(to), { recursive: true })
|
|
121
|
+
writeFileSync(to, file.content, 'utf8')
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
message: `wrote ${files.map((file) => file.path).join(', ')}`,
|
|
126
|
+
files: files.map((file) => file.path),
|
|
127
|
+
turns: attempt,
|
|
128
|
+
input,
|
|
129
|
+
output,
|
|
130
|
+
cost
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { ok: false, message: problem, files: [], turns: ATTEMPTS, input, output, cost }
|
|
134
|
+
}
|