@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/index.ts
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
formatSkillsForPrompt,
|
|
6
|
+
getAgentDir,
|
|
7
|
+
type ExtensionAPI,
|
|
8
|
+
type ExtensionContext
|
|
9
|
+
} from '@earendil-works/pi-coding-agent'
|
|
10
|
+
import { type Infer, Validator } from 'ata-validator'
|
|
11
|
+
import { t } from 'ata-validator/t'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
import { revertApplied } from './apply.js'
|
|
15
|
+
import {
|
|
16
|
+
budgetLeft,
|
|
17
|
+
fingerprintOf,
|
|
18
|
+
freshScan,
|
|
19
|
+
liveScan,
|
|
20
|
+
loopEnabled,
|
|
21
|
+
needsCode,
|
|
22
|
+
notify,
|
|
23
|
+
pendingTarget,
|
|
24
|
+
proposeToDisk,
|
|
25
|
+
readScan,
|
|
26
|
+
skillFacts,
|
|
27
|
+
toLive,
|
|
28
|
+
writeScan,
|
|
29
|
+
type LiveScan
|
|
30
|
+
} from './auto.js'
|
|
31
|
+
import { DEAD_SKILL_SESSIONS, scanCorpus, type PromptWords, type Recurrence, type SkillUse } from './corpus.js'
|
|
32
|
+
import { findCollapse, proposalTexts, type Collapse } from './diversity.js'
|
|
33
|
+
import { rate, type Effect } from './effect.js'
|
|
34
|
+
import { evaluateInstalls, autoRevert } from './evaluate.js'
|
|
35
|
+
import { findFindings, type Finding } from './findings.js'
|
|
36
|
+
import { appendSpend, readSpend, spentToday, sumSpend, type SpendTotal } from './ledger.js'
|
|
37
|
+
import { confirmOf, installContext, installProposal, installRoots, standingChars } from './install.js'
|
|
38
|
+
import { goalInTheWay } from './coordination.js'
|
|
39
|
+
import { lintManifest, type LintProblem } from './lint.js'
|
|
40
|
+
import { matchLessons, rareTriggersOf, triggersOf } from './match.js'
|
|
41
|
+
import { LESSON_TOOL, LessonParameters, MAX_LESSONS_SHOWN, formatLessons, matchedLessons } from './lesson-tool.js'
|
|
42
|
+
|
|
43
|
+
type LessonParams = Infer<typeof LessonParameters>
|
|
44
|
+
import { readProducers } from './producer.js'
|
|
45
|
+
import { probeReplay } from './replay.js'
|
|
46
|
+
import {
|
|
47
|
+
acquireLock,
|
|
48
|
+
appendInstall,
|
|
49
|
+
harnessDir,
|
|
50
|
+
readInstalls,
|
|
51
|
+
readLessons,
|
|
52
|
+
readSettings,
|
|
53
|
+
recordLesson,
|
|
54
|
+
recordLessonUse,
|
|
55
|
+
writeLessons
|
|
56
|
+
} from './store.js'
|
|
57
|
+
import { targetFromFinding, targetFromRecurrence, targetFromSkillUse, type Target } from './target.js'
|
|
58
|
+
import { readTranscript, lastUserText } from './transcript.js'
|
|
59
|
+
import { loadHarness, type Harness } from './tree.js'
|
|
60
|
+
import { writePart, type Completion } from './writer.js'
|
|
61
|
+
import type { Install, Lesson } from './types.js'
|
|
62
|
+
|
|
63
|
+
const WIDGET_KEY = 'harness'
|
|
64
|
+
const MAX_LINES = 20
|
|
65
|
+
const WRITE_TIMEOUT_MS = 120_000
|
|
66
|
+
|
|
67
|
+
function rarityOf(cwd: string): PromptWords | undefined {
|
|
68
|
+
const scan = readScan(cwd)
|
|
69
|
+
return scan === null ? undefined : toLive(scan).prompts
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The tool list is the front of every request, so the lesson tool is armed for a run only
|
|
73
|
+
// when the request names a situation a lesson already covers.
|
|
74
|
+
function armLessonTool(pi: ExtensionAPI, ctx: ExtensionContext, prompt: string): void {
|
|
75
|
+
const active = pi.getActiveTools()
|
|
76
|
+
const armed = prompt !== '' && matchedLessons(ctx.cwd, prompt, rarityOf(ctx.cwd)).length > 0
|
|
77
|
+
if (armed === active.includes(LESSON_TOOL)) return
|
|
78
|
+
pi.setActiveTools(armed ? [...active, LESSON_TOOL] : active.filter((name) => name !== LESSON_TOOL))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function supersededOnDisk(lessons: Lesson[], installs: Install[]): string[] {
|
|
82
|
+
return lessons
|
|
83
|
+
.filter((lesson) => lesson.state === 'superseded')
|
|
84
|
+
.flatMap((lesson) => {
|
|
85
|
+
const install = installs.find((entry) => entry.lesson === lesson.id && !entry.reverted)
|
|
86
|
+
if (install === undefined) return []
|
|
87
|
+
return [`${lesson.cue} is still in ${install.files.map((file) => file.to).join(', ')}`]
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function reportLines(
|
|
92
|
+
harness: Harness,
|
|
93
|
+
findings: Finding[],
|
|
94
|
+
recurrences: Recurrence[],
|
|
95
|
+
lessons: string[],
|
|
96
|
+
stale: string[],
|
|
97
|
+
installs: Install[],
|
|
98
|
+
dead: string[],
|
|
99
|
+
spend: SpendTotal,
|
|
100
|
+
deadChars: number,
|
|
101
|
+
collapse: Collapse | null,
|
|
102
|
+
notAdvertised: number,
|
|
103
|
+
sessions: number,
|
|
104
|
+
available: number,
|
|
105
|
+
next: string,
|
|
106
|
+
effects: Effect[],
|
|
107
|
+
staleFindings: string[],
|
|
108
|
+
needsCodeFindings: Recurrence[]
|
|
109
|
+
): string[] {
|
|
110
|
+
const lines: string[] = []
|
|
111
|
+
const active = installs.filter((install) => install.ok && !install.reverted)
|
|
112
|
+
const standing = standingChars(installs)
|
|
113
|
+
lines.push(
|
|
114
|
+
`skills ${harness.skills.length}, agents ${harness.agents.length}, skill block ${harness.skillBlockChars} chars, sessions ${sessions} of ${available}`
|
|
115
|
+
)
|
|
116
|
+
lines.push(
|
|
117
|
+
`installed ${active.length} active, ${installs.length - active.length} reverted, ${Math.round(standing / 4)} tokens of the prompt come from this loop`
|
|
118
|
+
)
|
|
119
|
+
if (harness.problems.length === 0) {
|
|
120
|
+
lines.push('tree: no problems')
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(`tree: ${harness.problems.length} problems`)
|
|
123
|
+
for (const problem of harness.problems) lines.push(` ${problem.rule} ${problem.entry}: ${problem.detail}`)
|
|
124
|
+
}
|
|
125
|
+
if (findings.length === 0) {
|
|
126
|
+
lines.push('findings: none')
|
|
127
|
+
} else {
|
|
128
|
+
lines.push(`findings: ${findings.length}`)
|
|
129
|
+
for (const finding of findings.slice(0, 3)) lines.push(` ${finding.id} ${finding.title}`)
|
|
130
|
+
}
|
|
131
|
+
if (recurrences.length > 0) {
|
|
132
|
+
lines.push(`recurrences: ${recurrences.length}`)
|
|
133
|
+
for (const recurrence of recurrences.slice(0, 5)) lines.push(` ${recurrence.id} ${recurrence.title}`)
|
|
134
|
+
if (recurrences.length > 5) lines.push(` and ${recurrences.length - 5} more`)
|
|
135
|
+
const held = recurrences.filter((recurrence) => recurrence.blocked !== undefined).length
|
|
136
|
+
if (held > 0) lines.push(`held: ${held} failures whose sessions edited the same files`)
|
|
137
|
+
}
|
|
138
|
+
if (next !== '') lines.push(`the loop will propose: ${next}`)
|
|
139
|
+
if (notAdvertised > 0) {
|
|
140
|
+
lines.push(`not advertised: ${notAdvertised} skills, which cost nothing`)
|
|
141
|
+
}
|
|
142
|
+
if (dead.length > 0) {
|
|
143
|
+
const shown = dead.slice(0, 5).join(', ')
|
|
144
|
+
const more = dead.length > 5 ? ` and ${dead.length - 5} more` : ''
|
|
145
|
+
lines.push(`never opened in ${sessions} sessions: ${shown}${more} (propose skill-<name>)`)
|
|
146
|
+
lines.push(`dead advertisement: ${deadChars} of ${harness.skillBlockChars} chars`)
|
|
147
|
+
}
|
|
148
|
+
for (const lesson of lessons) lines.push(`lesson ${lesson}`)
|
|
149
|
+
for (const entry of stale) lines.push(`superseded ${entry}`)
|
|
150
|
+
if (installs.length > 0) {
|
|
151
|
+
const kept = installs.filter((install) => install.ok && !install.reverted).length
|
|
152
|
+
const confirmed = installs.filter((install) => install.confirmOk === true && !install.reverted).length
|
|
153
|
+
lines.push(
|
|
154
|
+
`installs: ${kept} kept, ${installs.length - kept} reverted${confirmed > 0 ? `, ${confirmed} confirmed` : ''}`
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
if (effects.length > 0) {
|
|
158
|
+
const held = effects.filter((effect) => effect.verdict === 'held').length
|
|
159
|
+
const failed = effects.filter((effect) => effect.verdict === 'flat' || effect.verdict === 'worse').length
|
|
160
|
+
const confounded = effects.filter((effect) => effect.verdict === 'confounded').length
|
|
161
|
+
const dodged = effects.filter((effect) => effect.verdict === 'avoided').length
|
|
162
|
+
lines.push(
|
|
163
|
+
`fixes: ${held} held, ${failed} did not, ${dodged} avoided the tool, ${confounded} confounded, ${effects.length - held - failed - confounded - dodged} too early to judge`
|
|
164
|
+
)
|
|
165
|
+
for (const effect of effects
|
|
166
|
+
.filter(
|
|
167
|
+
(entry) => entry.verdict === 'flat' || entry.verdict === 'worse' || entry.verdict === 'avoided'
|
|
168
|
+
)
|
|
169
|
+
.slice(0, 3)) {
|
|
170
|
+
lines.push(` ${effect.finding} ${effect.verdict} ${rate(effect.before)} -> ${rate(effect.after)}`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (needsCodeFindings.length > 0) {
|
|
174
|
+
lines.push(
|
|
175
|
+
`needs code, because a prose part did not carry: ${needsCodeFindings
|
|
176
|
+
.slice(0, 3)
|
|
177
|
+
.map((recurrence) => recurrence.id)
|
|
178
|
+
.join(', ')}`
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
if (staleFindings.length > 0) {
|
|
182
|
+
lines.push(`stale findings, skipped: ${staleFindings.slice(0, 2).join(' | ')}`)
|
|
183
|
+
}
|
|
184
|
+
const total = spend
|
|
185
|
+
if (total.proposals > 0) {
|
|
186
|
+
lines.push(`loop: ${total.proposals} proposals, ${total.turns} turns, ${total.cost.toFixed(4)} cost`)
|
|
187
|
+
}
|
|
188
|
+
if (collapse !== null) {
|
|
189
|
+
lines.push(
|
|
190
|
+
`the loop repeats itself: ${collapse.pair[0]} and ${collapse.pair[1]} are ${Math.round(collapse.similarity * 100)}% the same`
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
if (lines.length > MAX_LINES) lines.push(` ... ${lines.length - MAX_LINES} more`)
|
|
194
|
+
return lines.slice(0, MAX_LINES)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pickTarget(
|
|
198
|
+
findings: Finding[],
|
|
199
|
+
recurrences: Recurrence[],
|
|
200
|
+
dead: SkillUse[],
|
|
201
|
+
skills: Map<string, { description: string; filePath: string }>,
|
|
202
|
+
id: string
|
|
203
|
+
): Target | null {
|
|
204
|
+
if (id === '') {
|
|
205
|
+
const finding = findings[0]
|
|
206
|
+
if (finding !== undefined) return targetFromFinding(finding)
|
|
207
|
+
const recurrence = recurrences[0]
|
|
208
|
+
if (recurrence !== undefined) return targetFromRecurrence(recurrence)
|
|
209
|
+
const skill = dead[0]
|
|
210
|
+
if (skill === undefined) return null
|
|
211
|
+
const live = skills.get(skill.name)
|
|
212
|
+
return targetFromSkillUse(
|
|
213
|
+
skill,
|
|
214
|
+
live?.description ?? '',
|
|
215
|
+
live?.filePath ?? ''
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
const finding = findings.find((entry) => entry.id === id || entry.kind === id)
|
|
219
|
+
if (finding !== undefined) return targetFromFinding(finding)
|
|
220
|
+
const recurrence = recurrences.find((entry) => entry.id === id)
|
|
221
|
+
if (recurrence !== undefined) return targetFromRecurrence(recurrence)
|
|
222
|
+
const skill = dead.find((entry) => entry.id === id)
|
|
223
|
+
if (skill === undefined) return null
|
|
224
|
+
const live = skills.get(skill.name)
|
|
225
|
+
return targetFromSkillUse(
|
|
226
|
+
skill,
|
|
227
|
+
live?.description ?? '',
|
|
228
|
+
live?.filePath ?? ''
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function nextTarget(ctx: ExtensionContext): Promise<{ target: Target; scan: LiveScan } | null> {
|
|
233
|
+
const cwd = ctx.cwd
|
|
234
|
+
if (!loopEnabled()) return null
|
|
235
|
+
evaluateInstalls(cwd)
|
|
236
|
+
if (!readSettings(cwd).auto) return null
|
|
237
|
+
if (!budgetLeft(cwd)) return null
|
|
238
|
+
if (goalInTheWay(ctx.sessionManager.getBranch())) return null
|
|
239
|
+
const sessionDir = ctx.sessionManager.getSessionDir()
|
|
240
|
+
const sessionId = ctx.sessionManager.getSessionId()
|
|
241
|
+
const fingerprint = fingerprintOf(sessionDir, sessionId)
|
|
242
|
+
const cached = readScan(cwd)
|
|
243
|
+
const stale = cached === null || cached.fingerprint !== fingerprint
|
|
244
|
+
const skills = skillFacts(cwd, getAgentDir())
|
|
245
|
+
const scan = stale ? freshScan(cwd, getAgentDir(), sessionDir, sessionId, skills) : toLive(cached)
|
|
246
|
+
if (stale) writeScan(cwd, scan)
|
|
247
|
+
const target = pendingTarget(cwd, scan, skills)
|
|
248
|
+
if (target === null) return null
|
|
249
|
+
const recurrence = scan.recurrences.find((entry) => entry.id === target.id)
|
|
250
|
+
if (recurrence?.command !== undefined) {
|
|
251
|
+
const verdict = await probeReplay(cwd, recurrence)
|
|
252
|
+
if (verdict === 'stale') return null
|
|
253
|
+
}
|
|
254
|
+
return { target, scan }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
interface DriveOutcome {
|
|
258
|
+
message: string
|
|
259
|
+
level: 'info' | 'warning'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type SelectedModel = NonNullable<ExtensionContext['model']>
|
|
263
|
+
|
|
264
|
+
async function completeFor(
|
|
265
|
+
ctx: ExtensionContext,
|
|
266
|
+
model: SelectedModel,
|
|
267
|
+
systemPrompt: string,
|
|
268
|
+
prompt: string
|
|
269
|
+
): Promise<Completion> {
|
|
270
|
+
const reply = await ctx.modelRegistry.complete(
|
|
271
|
+
model,
|
|
272
|
+
{
|
|
273
|
+
systemPrompt,
|
|
274
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }]
|
|
275
|
+
},
|
|
276
|
+
{ signal: AbortSignal.timeout(WRITE_TIMEOUT_MS), sessionId: ctx.sessionManager.getSessionId() }
|
|
277
|
+
)
|
|
278
|
+
return {
|
|
279
|
+
text: reply.errorMessage ?? reply.content.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n'),
|
|
280
|
+
stop: reply.stopReason,
|
|
281
|
+
input: reply.usage.input,
|
|
282
|
+
output: reply.usage.output,
|
|
283
|
+
cost: reply.usage.cost.total
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The writer bills to the parent's session, and `/cost` reads this line to say so. */
|
|
288
|
+
function recordOverhead(ctx: ExtensionContext, purpose: string, part: { input: number; output: number; cost: number }): void {
|
|
289
|
+
try {
|
|
290
|
+
appendFileSync(
|
|
291
|
+
join(getAgentDir(), 'overhead.jsonl'),
|
|
292
|
+
`${JSON.stringify({
|
|
293
|
+
at: Date.now(),
|
|
294
|
+
extension: 'harness-loop',
|
|
295
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
296
|
+
purpose,
|
|
297
|
+
input: part.input,
|
|
298
|
+
output: part.output,
|
|
299
|
+
costUsd: part.cost
|
|
300
|
+
})}\n`,
|
|
301
|
+
'utf8'
|
|
302
|
+
)
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function partText(dir: string): string {
|
|
307
|
+
for (const path of [join(dir, 'RULE.md'), join(dir, 'global', 'RULE.md')]) {
|
|
308
|
+
if (!existsSync(path)) continue
|
|
309
|
+
const line = readFileSync(path, 'utf8')
|
|
310
|
+
.split('\n')
|
|
311
|
+
.map((entry) => entry.trim())
|
|
312
|
+
.find((entry) => entry !== '' && !entry.startsWith('#'))
|
|
313
|
+
if (line !== undefined) return line
|
|
314
|
+
}
|
|
315
|
+
return ''
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function partLesson(cwd: string, target: Target, dir: string, scan: LiveScan): Lesson | null {
|
|
319
|
+
const confirm = confirmOf(dir)
|
|
320
|
+
if (confirm === '') return null
|
|
321
|
+
const text = partText(dir)
|
|
322
|
+
return {
|
|
323
|
+
id: `${target.id}-${Date.now()}`,
|
|
324
|
+
at: Date.now(),
|
|
325
|
+
cue: target.cue,
|
|
326
|
+
lesson: text === '' ? `the part in ${relative(cwd, dir)} covers this situation` : text,
|
|
327
|
+
evidence: target.title,
|
|
328
|
+
confidence: 'medium',
|
|
329
|
+
triggers: rareTriggersOf(`${target.title} ${target.cue}`, scan.prompts),
|
|
330
|
+
kind: target.kind,
|
|
331
|
+
state: 'installed',
|
|
332
|
+
confirm
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const SubagentConfig = t.object({ model: t.optional(t.string()) })
|
|
337
|
+
const isSubagentConfig = new Validator(SubagentConfig).isValidObject
|
|
338
|
+
|
|
339
|
+
/** The model pi already runs chores on, so the writer does not need a setting of its own. */
|
|
340
|
+
export function subagentModel(cwd: string, agentDir: string): string {
|
|
341
|
+
for (const path of [join(cwd, '.pi', 'subagent.json'), join(agentDir, 'subagent.json')]) {
|
|
342
|
+
if (!existsSync(path)) continue
|
|
343
|
+
try {
|
|
344
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
345
|
+
if (!isSubagentConfig(parsed)) continue
|
|
346
|
+
const model = parsed.model?.trim() ?? ''
|
|
347
|
+
if (model !== '') return model
|
|
348
|
+
} catch {
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return ''
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function writerModelName(project: string, env: string, subagent: string): string {
|
|
356
|
+
if (project !== '') return project
|
|
357
|
+
if (env !== '') return env
|
|
358
|
+
return subagent
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function writerModelFor(ctx: ExtensionContext): SelectedModel | undefined {
|
|
362
|
+
const configured = writerModelName(
|
|
363
|
+
readSettings(ctx.cwd).writerModel.trim(),
|
|
364
|
+
process.env['PI_HARNESS_WRITER_MODEL']?.trim() ?? '',
|
|
365
|
+
subagentModel(ctx.cwd, getAgentDir())
|
|
366
|
+
)
|
|
367
|
+
const slash = configured.indexOf('/')
|
|
368
|
+
if (slash < 1) return ctx.model
|
|
369
|
+
const found = ctx.modelRegistry.find(configured.slice(0, slash), configured.slice(slash + 1))
|
|
370
|
+
if (found === undefined || !ctx.modelRegistry.hasConfiguredAuth(found)) return ctx.model
|
|
371
|
+
return found
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The whole loop for one finding: brief, part, install, record. A part nobody installs is a file. */
|
|
375
|
+
async function drive(ctx: ExtensionContext, target: Target, scan: LiveScan): Promise<DriveOutcome> {
|
|
376
|
+
const dir = proposeToDisk(ctx.cwd, target, scan)
|
|
377
|
+
const model = ctx.model === undefined ? undefined : writerModelFor(ctx)
|
|
378
|
+
if (model === undefined) {
|
|
379
|
+
appendSpend(ctx.cwd, {
|
|
380
|
+
at: Date.now(),
|
|
381
|
+
target: target.id,
|
|
382
|
+
turns: 0,
|
|
383
|
+
input: 0,
|
|
384
|
+
output: 0,
|
|
385
|
+
cost: 0,
|
|
386
|
+
outcome: 'recorded'
|
|
387
|
+
})
|
|
388
|
+
return { message: `no model is selected, so the brief stays at ${dir}`, level: 'info' }
|
|
389
|
+
}
|
|
390
|
+
const brief = readFileSync(join(dir, 'BRIEF.md'), 'utf8')
|
|
391
|
+
const part = await writePart(dir, brief, (systemPrompt, prompt) => completeFor(ctx, model, systemPrompt, prompt))
|
|
392
|
+
recordOverhead(ctx, target.id, part)
|
|
393
|
+
const spend = {
|
|
394
|
+
at: Date.now(),
|
|
395
|
+
target: target.id,
|
|
396
|
+
turns: part.turns,
|
|
397
|
+
input: part.input,
|
|
398
|
+
output: part.output,
|
|
399
|
+
cost: part.cost
|
|
400
|
+
}
|
|
401
|
+
if (!part.ok) {
|
|
402
|
+
appendSpend(ctx.cwd, { ...spend, outcome: 'recorded' })
|
|
403
|
+
return {
|
|
404
|
+
message: `wrote no part for ${target.id} (${part.message}). The brief is at ${dir}`,
|
|
405
|
+
level: 'info'
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const recurrence = scan.recurrences.find((entry) => entry.id === target.id)
|
|
409
|
+
const lesson = partLesson(ctx.cwd, target, dir, scan)
|
|
410
|
+
const outcome = await installProposal(dir, target.id, ctx.cwd, {
|
|
411
|
+
context: installContext(ctx.cwd, getAgentDir()),
|
|
412
|
+
key: recurrence?.key,
|
|
413
|
+
command: recurrence?.command,
|
|
414
|
+
lesson: lesson?.id
|
|
415
|
+
})
|
|
416
|
+
if (outcome.state === 'installed' && lesson !== null) recordLesson(ctx.cwd, lesson)
|
|
417
|
+
appendSpend(ctx.cwd, {
|
|
418
|
+
...spend,
|
|
419
|
+
outcome: outcome.state === 'rejected' ? 'rejected' : outcome.state === 'installed' ? 'installed' : 'recorded'
|
|
420
|
+
})
|
|
421
|
+
return { message: `${outcome.message} (${target.id})`, level: outcome.state === 'rejected' ? 'warning' : 'info' }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export default function harnessExtension(pi: ExtensionAPI): void {
|
|
425
|
+
let scheduled = false
|
|
426
|
+
|
|
427
|
+
pi.on('before_agent_start', (event, ctx) => {
|
|
428
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined)
|
|
429
|
+
armLessonTool(pi, ctx, event.messageType === 'user' ? (event.prompt ?? '') : '')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
pi.registerCommand('harness-loop', {
|
|
433
|
+
description:
|
|
434
|
+
'Report what this session shows about the harness, propose a fix for one finding, install it when the check passes, revert an install, or clear the report.',
|
|
435
|
+
handler: async (args, ctx) => {
|
|
436
|
+
const cwd = ctx.cwd
|
|
437
|
+
const [command, id] = args.trim().split(/\s+/, 2)
|
|
438
|
+
if (command !== '') ctx.ui.setWidget(WIDGET_KEY, undefined)
|
|
439
|
+
|
|
440
|
+
if (command === 'clear') {
|
|
441
|
+
ctx.ui.notify('harness: report cleared, and the next prompt clears it too')
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (command === 'revert') {
|
|
446
|
+
const found = readInstalls(cwd)
|
|
447
|
+
.toReversed()
|
|
448
|
+
.find((install) => install.id === id)
|
|
449
|
+
if (found === undefined) {
|
|
450
|
+
ctx.ui.notify(id ? `no install named ${id}` : 'name the install to revert', 'error')
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
if (found.reverted) {
|
|
454
|
+
ctx.ui.notify(`install ${found.id} is already reverted`, 'info')
|
|
455
|
+
return
|
|
456
|
+
}
|
|
457
|
+
const release = acquireLock(cwd)
|
|
458
|
+
if (release === null) {
|
|
459
|
+
ctx.ui.notify('another install is running, so this revert waits', 'warning')
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const result = revertApplied(found.files, installRoots(installContext(cwd, getAgentDir())))
|
|
464
|
+
if (result.refused.length > 0) {
|
|
465
|
+
ctx.ui.notify(
|
|
466
|
+
`left ${result.refused.length} file(s) alone because they changed since the install: ${result.refused[0]}`,
|
|
467
|
+
'warning'
|
|
468
|
+
)
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
appendInstall(cwd, { ...found, at: Date.now(), reverted: true })
|
|
472
|
+
ctx.ui.notify(`reverted ${found.finding}: ${result.restored.length} file(s) restored`)
|
|
473
|
+
} finally {
|
|
474
|
+
release()
|
|
475
|
+
}
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (command === 'install') {
|
|
480
|
+
if (id === undefined || id === '') {
|
|
481
|
+
ctx.ui.notify('name the finding to install', 'error')
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
const dir = join(harnessDir(cwd), id)
|
|
485
|
+
const recurrence = readScan(cwd)?.recurrences.find((entry) => entry.id === id)
|
|
486
|
+
const outcome = await installProposal(dir, id, cwd, {
|
|
487
|
+
forced: true,
|
|
488
|
+
context: installContext(cwd, getAgentDir()),
|
|
489
|
+
key: recurrence?.key,
|
|
490
|
+
command: recurrence?.command
|
|
491
|
+
})
|
|
492
|
+
ctx.ui.notify(outcome.message, outcome.state === 'rejected' ? 'warning' : 'info')
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (command === 'lint') {
|
|
497
|
+
const problems: LintProblem[] = [
|
|
498
|
+
...loadHarness(cwd, getAgentDir()).problems.map((problem) => ({
|
|
499
|
+
rule: problem.rule,
|
|
500
|
+
entry: problem.entry,
|
|
501
|
+
detail: problem.detail
|
|
502
|
+
})),
|
|
503
|
+
...lintManifest(cwd)
|
|
504
|
+
]
|
|
505
|
+
ctx.ui.setWidget(
|
|
506
|
+
WIDGET_KEY,
|
|
507
|
+
problems.length === 0
|
|
508
|
+
? ['lint: no problems']
|
|
509
|
+
: [
|
|
510
|
+
`lint: ${problems.length} problems`,
|
|
511
|
+
...problems.map((problem) => ` ${problem.rule} ${problem.entry}: ${problem.detail}`)
|
|
512
|
+
]
|
|
513
|
+
)
|
|
514
|
+
ctx.ui.notify(
|
|
515
|
+
problems.length === 0 ? 'lint: no problems' : `lint: ${problems.length} problems`,
|
|
516
|
+
problems.length === 0 ? 'info' : 'warning'
|
|
517
|
+
)
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (command !== 'propose') {
|
|
522
|
+
const harness = loadHarness(cwd, getAgentDir())
|
|
523
|
+
const branch = ctx.sessionManager.getBranch()
|
|
524
|
+
const advertised = harness.skills.filter((skill) => !skill.disableModelInvocation)
|
|
525
|
+
const scan = scanCorpus(
|
|
526
|
+
ctx.sessionManager.getSessionDir(),
|
|
527
|
+
advertised.map((skill) => skill.name),
|
|
528
|
+
join(cwd, '.pi', 'harness', 'corpus-cache.json')
|
|
529
|
+
)
|
|
530
|
+
const dead = scan.skills.filter((use) => use.opens === 0 && use.sessions >= DEAD_SKILL_SESSIONS)
|
|
531
|
+
const deadNames = new Set(dead.map((use) => use.name))
|
|
532
|
+
const deadChars =
|
|
533
|
+
formatSkillsForPrompt(advertised).length -
|
|
534
|
+
formatSkillsForPrompt(advertised.filter((skill) => !deadNames.has(skill.name))).length
|
|
535
|
+
const lessons = matchLessons(readLessons(cwd), lastUserText(branch))
|
|
536
|
+
.slice(0, MAX_LESSONS_SHOWN)
|
|
537
|
+
.map((lesson) => `${lesson.cue}: ${lesson.lesson}`)
|
|
538
|
+
const installs = readInstalls(cwd)
|
|
539
|
+
const stale = supersededOnDisk(readLessons(cwd), installs)
|
|
540
|
+
const effects = evaluateInstalls(cwd)
|
|
541
|
+
const needsCodeFindings = scan.recurrences.filter((recurrence) => needsCode(cwd, recurrence))
|
|
542
|
+
const staleFindings = readProducers(cwd)
|
|
543
|
+
.filter((verdict) => verdict.state === 'removed')
|
|
544
|
+
.map((verdict) => `${verdict.phrase} (${verdict.detail})`)
|
|
545
|
+
const pending = pendingTarget(
|
|
546
|
+
cwd,
|
|
547
|
+
liveScan(scan),
|
|
548
|
+
harness.skills.map((skill) => ({
|
|
549
|
+
name: skill.name,
|
|
550
|
+
description: skill.description,
|
|
551
|
+
filePath: skill.filePath,
|
|
552
|
+
advertised: !skill.disableModelInvocation
|
|
553
|
+
}))
|
|
554
|
+
)
|
|
555
|
+
ctx.ui.setWidget(
|
|
556
|
+
WIDGET_KEY,
|
|
557
|
+
reportLines(
|
|
558
|
+
harness,
|
|
559
|
+
findFindings(readTranscript(branch)),
|
|
560
|
+
scan.recurrences,
|
|
561
|
+
lessons,
|
|
562
|
+
stale,
|
|
563
|
+
installs,
|
|
564
|
+
dead.map((use) => use.name),
|
|
565
|
+
sumSpend(readSpend(cwd)),
|
|
566
|
+
deadChars,
|
|
567
|
+
findCollapse(proposalTexts(harnessDir(cwd))),
|
|
568
|
+
harness.skills.length - advertised.length,
|
|
569
|
+
scan.sessions,
|
|
570
|
+
scan.available,
|
|
571
|
+
pending?.title ?? '',
|
|
572
|
+
effects,
|
|
573
|
+
staleFindings,
|
|
574
|
+
needsCodeFindings
|
|
575
|
+
)
|
|
576
|
+
)
|
|
577
|
+
ctx.ui.notify(
|
|
578
|
+
`harness: ${advertised.length} advertised skills, ${harness.skills.length - advertised.length} not advertised, ${harness.problems.length} problems, ${scan.recurrences.length} recurrences, ${lessons.length} lessons`
|
|
579
|
+
)
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const harness = loadHarness(cwd, getAgentDir())
|
|
584
|
+
const advertised = harness.skills.filter((skill) => !skill.disableModelInvocation)
|
|
585
|
+
const scan = scanCorpus(
|
|
586
|
+
ctx.sessionManager.getSessionDir(),
|
|
587
|
+
advertised.map((skill) => skill.name),
|
|
588
|
+
join(cwd, '.pi', 'harness', 'corpus-cache.json')
|
|
589
|
+
)
|
|
590
|
+
const dead = scan.skills.filter((use) => use.opens === 0 && use.sessions >= DEAD_SKILL_SESSIONS)
|
|
591
|
+
const skills = new Map(
|
|
592
|
+
harness.skills.map((skill) => [
|
|
593
|
+
skill.name,
|
|
594
|
+
{ description: skill.description, filePath: skill.filePath }
|
|
595
|
+
])
|
|
596
|
+
)
|
|
597
|
+
const target = pickTarget(
|
|
598
|
+
findFindings(readTranscript(ctx.sessionManager.getBranch())),
|
|
599
|
+
scan.recurrences,
|
|
600
|
+
dead,
|
|
601
|
+
skills,
|
|
602
|
+
id ?? ''
|
|
603
|
+
)
|
|
604
|
+
if (target === null) {
|
|
605
|
+
ctx.ui.notify(id ? `no finding or recurrence named ${id}` : 'nothing to propose from', 'error')
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
if (goalInTheWay(ctx.sessionManager.getBranch())) {
|
|
609
|
+
ctx.ui.notify('a goal is active, and it drives the session too. Pause it first.', 'error')
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
const recurrence = scan.recurrences.find((entry) => entry.id === target.id)
|
|
613
|
+
if (recurrence !== undefined && needsCode(cwd, recurrence)) {
|
|
614
|
+
ctx.ui.notify(
|
|
615
|
+
`a prose part already failed for ${target.id}, so the next fix has to live in the tool, not in a rule`,
|
|
616
|
+
'warning'
|
|
617
|
+
)
|
|
618
|
+
return
|
|
619
|
+
}
|
|
620
|
+
if (recurrence?.command !== undefined) {
|
|
621
|
+
const verdict = await probeReplay(cwd, recurrence)
|
|
622
|
+
if (verdict === 'stale') {
|
|
623
|
+
ctx.ui.notify(`the failure in ${target.id} no longer reproduces, so the finding is stale`, 'info')
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const settings = readSettings(cwd)
|
|
628
|
+
const today = spentToday(readSpend(cwd))
|
|
629
|
+
if (today.proposals >= settings.maxProposalsPerDay) {
|
|
630
|
+
ctx.ui.notify(
|
|
631
|
+
`the loop spent its budget for today (${today.proposals} proposals, ${today.turns} turns)`,
|
|
632
|
+
'warning'
|
|
633
|
+
)
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const outcome = await drive(ctx, target, liveScan(scan))
|
|
638
|
+
ctx.ui.notify(`harness: ${outcome.message}`, outcome.level)
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
pi.registerTool({
|
|
643
|
+
name: LESSON_TOOL,
|
|
644
|
+
label: 'Lesson',
|
|
645
|
+
defaultActive: false,
|
|
646
|
+
description:
|
|
647
|
+
'Read the lessons this project learned from its own sessions, and record a new one. The lessons are what the past runs showed, kept in the shape of a situation rather than a task.',
|
|
648
|
+
promptSnippet: 'Lessons learned from earlier sessions in this project',
|
|
649
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
650
|
+
exposure: 'direct',
|
|
651
|
+
namespace: {
|
|
652
|
+
name: 'harness',
|
|
653
|
+
description: 'The lessons earlier sessions in this project left behind.'
|
|
654
|
+
},
|
|
655
|
+
promptGuidelines: [
|
|
656
|
+
'When the request looks like one an earlier session in this project already hit, call lesson with action match and the request as the cue, before you start working.',
|
|
657
|
+
'A lesson is a shape of situation, not a task, so it applies to work that looks the same and is not the same.',
|
|
658
|
+
'Recall once per task, unless the task changes direction.'
|
|
659
|
+
],
|
|
660
|
+
parameters: LessonParameters,
|
|
661
|
+
async execute(_toolCallId, params: LessonParams, _signal, _onUpdate, ctx) {
|
|
662
|
+
const cwd = ctx.cwd
|
|
663
|
+
if (params.action === 'match') {
|
|
664
|
+
const lessons = matchedLessons(cwd, params.cue ?? '', rarityOf(cwd))
|
|
665
|
+
for (const lesson of lessons) recordLessonUse(cwd, lesson.id)
|
|
666
|
+
return {
|
|
667
|
+
content: [{ type: 'text', text: formatLessons(lessons) }],
|
|
668
|
+
details: { matched: lessons.length }
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (params.action === 'list') {
|
|
672
|
+
const lessons = readLessons(cwd)
|
|
673
|
+
return {
|
|
674
|
+
content: [{ type: 'text', text: formatLessons(lessons) }],
|
|
675
|
+
details: { lessons: lessons.length }
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (params.action === 'record') {
|
|
679
|
+
if (
|
|
680
|
+
params.cue === undefined ||
|
|
681
|
+
params.lesson === undefined ||
|
|
682
|
+
params.confirm === undefined ||
|
|
683
|
+
params.evidence === undefined
|
|
684
|
+
) {
|
|
685
|
+
return {
|
|
686
|
+
content: [
|
|
687
|
+
{
|
|
688
|
+
type: 'text',
|
|
689
|
+
text: 'a lesson needs a cue, a lesson, a way to confirm it and the evidence it rests on'
|
|
690
|
+
}
|
|
691
|
+
],
|
|
692
|
+
details: { recorded: false }
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const rarity = rarityOf(cwd)
|
|
696
|
+
const text = `${params.cue} ${params.lesson}`
|
|
697
|
+
const lesson: Lesson = {
|
|
698
|
+
id: `manual-${Date.now()}`,
|
|
699
|
+
at: Date.now(),
|
|
700
|
+
cue: params.cue,
|
|
701
|
+
lesson: params.lesson,
|
|
702
|
+
confirm: params.confirm,
|
|
703
|
+
evidence: params.evidence,
|
|
704
|
+
confidence: params.confidence ?? 'medium',
|
|
705
|
+
triggers: rarity === undefined ? triggersOf(text) : rareTriggersOf(text, rarity),
|
|
706
|
+
kind: 'repeat-error',
|
|
707
|
+
state: 'recorded'
|
|
708
|
+
}
|
|
709
|
+
const recorded = recordLesson(cwd, lesson)
|
|
710
|
+
return {
|
|
711
|
+
content: [
|
|
712
|
+
{
|
|
713
|
+
type: 'text',
|
|
714
|
+
text:
|
|
715
|
+
recorded.superseded === null
|
|
716
|
+
? `recorded: ${lesson.cue}`
|
|
717
|
+
: `recorded: ${lesson.cue}, superseding ${recorded.superseded.cue}`
|
|
718
|
+
}
|
|
719
|
+
],
|
|
720
|
+
details: { recorded: true }
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const lessons = readLessons(cwd)
|
|
724
|
+
const found = lessons.find((lesson) => lesson.id === params.id)
|
|
725
|
+
if (found === undefined) {
|
|
726
|
+
return {
|
|
727
|
+
content: [{ type: 'text', text: `no lesson with the id ${params.id ?? ''}` }],
|
|
728
|
+
details: { dropped: false }
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const kept = lessons.filter((lesson) => lesson.id !== found.id)
|
|
732
|
+
writeLessons(cwd, kept)
|
|
733
|
+
return {
|
|
734
|
+
content: [{ type: 'text', text: `dropped: ${found.cue}` }],
|
|
735
|
+
details: { dropped: true }
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
pi.on('agent_settled', (event, ctx) => {
|
|
741
|
+
if (event.aborted || !ctx.isIdle() || scheduled) return
|
|
742
|
+
scheduled = true
|
|
743
|
+
setTimeout(async () => {
|
|
744
|
+
scheduled = false
|
|
745
|
+
try {
|
|
746
|
+
if (readInstalls(ctx.cwd).some((entry) => entry.ok && !entry.reverted)) {
|
|
747
|
+
const context = installContext(ctx.cwd, getAgentDir())
|
|
748
|
+
for (const line of autoRevert(ctx.cwd, context, evaluateInstalls(ctx.cwd))) {
|
|
749
|
+
notify(ctx, line, 'info')
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const target = await nextTarget(ctx)
|
|
753
|
+
if (target === null) return
|
|
754
|
+
const outcome = await drive(ctx, target.target, target.scan)
|
|
755
|
+
notify(ctx, `harness: ${outcome.message}`, outcome.level)
|
|
756
|
+
} catch (error) {
|
|
757
|
+
notify(ctx, `the loop failed: ${error instanceof Error ? error.message : String(error)}`, 'warning')
|
|
758
|
+
}
|
|
759
|
+
}, 0)
|
|
760
|
+
})
|
|
761
|
+
}
|