@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/corpus.ts
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { Validator, type Infer } from 'ata-validator'
|
|
6
|
+
import { t } from 'ata-validator/t'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
import { writeFileAtomic } from './atomic.js'
|
|
10
|
+
import { failureSignature, usableSignature } from './findings.js'
|
|
11
|
+
const isString = new Validator(t.string()).isValidObject
|
|
12
|
+
|
|
13
|
+
export const MAX_CACHED_SESSIONS = 400
|
|
14
|
+
export const MAX_SESSION_BYTES = 24_000_000
|
|
15
|
+
export const MAX_NEW_SESSION_BYTES = 8_000_000
|
|
16
|
+
export const MIN_SESSIONS = 2
|
|
17
|
+
export const MIN_FAILURE_OCCURRENCES = 2
|
|
18
|
+
export const MIN_HABIT_OCCURRENCES = 3
|
|
19
|
+
export const DEAD_SKILL_SESSIONS = 20
|
|
20
|
+
|
|
21
|
+
const CorpusSessionSchema = t.object({
|
|
22
|
+
file: t.string(),
|
|
23
|
+
prompts: t.array(t.string()),
|
|
24
|
+
errors: t.array(
|
|
25
|
+
t.object({
|
|
26
|
+
signature: t.string(),
|
|
27
|
+
tool: t.string(),
|
|
28
|
+
body: t.string(),
|
|
29
|
+
command: t.optional(t.string())
|
|
30
|
+
})
|
|
31
|
+
),
|
|
32
|
+
commands: t.array(t.string()),
|
|
33
|
+
reads: t.array(t.string()),
|
|
34
|
+
skills: t.array(t.string()),
|
|
35
|
+
/** Missing in caches written before edits were counted, so an old cache reads as unknown. */
|
|
36
|
+
edits: t.optional(t.array(t.string())),
|
|
37
|
+
/** Missing in caches written before tool use was counted, so an old cache reads as unknown. */
|
|
38
|
+
tools: t.optional(t.record(t.number()))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const CorpusCacheSchema = t.object({
|
|
42
|
+
files: t.array(
|
|
43
|
+
t.object({
|
|
44
|
+
path: t.string(),
|
|
45
|
+
mtime: t.number(),
|
|
46
|
+
size: t.number(),
|
|
47
|
+
session: CorpusSessionSchema
|
|
48
|
+
})
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
const isCorpusCacheSchema = new Validator(CorpusCacheSchema).isValidObject
|
|
52
|
+
|
|
53
|
+
export interface Recurrence {
|
|
54
|
+
id: string
|
|
55
|
+
/** The raw signature, so an install can be measured against the sessions after it. */
|
|
56
|
+
key: string
|
|
57
|
+
kind: 'error' | 'command' | 'read'
|
|
58
|
+
title: string
|
|
59
|
+
sessions: number
|
|
60
|
+
occurrences: number
|
|
61
|
+
tool: string
|
|
62
|
+
sample: string
|
|
63
|
+
command?: string
|
|
64
|
+
/** Why the loop will not act on it, when the evidence comes from one piece of work. */
|
|
65
|
+
blocked?: string
|
|
66
|
+
evidence: string[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SkillUse {
|
|
70
|
+
id: string
|
|
71
|
+
name: string
|
|
72
|
+
sessions: number
|
|
73
|
+
opens: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CorpusSession {
|
|
77
|
+
file: string
|
|
78
|
+
prompts: string[]
|
|
79
|
+
errors: { signature: string; tool: string; body: string; command?: string }[]
|
|
80
|
+
commands: string[]
|
|
81
|
+
reads: string[]
|
|
82
|
+
skills: string[]
|
|
83
|
+
edits: string[]
|
|
84
|
+
tools?: Record<string, number>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const Block = t.object({
|
|
88
|
+
type: t.string(),
|
|
89
|
+
id: t.optional(t.string()),
|
|
90
|
+
name: t.optional(t.string()),
|
|
91
|
+
arguments: t.optional(t.unknown())
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const AssistantMessage = t.object({
|
|
95
|
+
type: t.literal('message'),
|
|
96
|
+
message: t.object({ role: t.literal('assistant'), content: t.array(Block) })
|
|
97
|
+
})
|
|
98
|
+
const isAssistantMessage = new Validator(AssistantMessage).isValidObject
|
|
99
|
+
|
|
100
|
+
const ResultBlock = t.object({ type: t.string(), text: t.optional(t.string()) })
|
|
101
|
+
type ResultBlock = Infer<typeof ResultBlock>
|
|
102
|
+
|
|
103
|
+
const ToolMessage = t.object({
|
|
104
|
+
type: t.literal('message'),
|
|
105
|
+
message: t.object({
|
|
106
|
+
role: t.literal('toolResult'),
|
|
107
|
+
toolCallId: t.optional(t.string()),
|
|
108
|
+
toolName: t.string(),
|
|
109
|
+
isError: t.boolean(),
|
|
110
|
+
content: t.union([t.string(), t.array(ResultBlock)])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
const isToolMessage = new Validator(ToolMessage).isValidObject
|
|
114
|
+
|
|
115
|
+
const UserMessage = t.object({
|
|
116
|
+
type: t.literal('message'),
|
|
117
|
+
message: t.object({
|
|
118
|
+
role: t.literal('user'),
|
|
119
|
+
content: t.union([t.string(), t.array(ResultBlock)])
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
const isUserMessage = new Validator(UserMessage).isValidObject
|
|
123
|
+
|
|
124
|
+
const CommandArguments = t.object({ command: t.string() })
|
|
125
|
+
const isCommandArguments = new Validator(CommandArguments).isValidObject
|
|
126
|
+
const PathArguments = t.object({ path: t.string() })
|
|
127
|
+
const isPathArguments = new Validator(PathArguments).isValidObject
|
|
128
|
+
|
|
129
|
+
export function sessionFiles(dir: string): string[] {
|
|
130
|
+
if (!existsSync(dir)) return []
|
|
131
|
+
try {
|
|
132
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
133
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
|
|
134
|
+
.map((entry) => {
|
|
135
|
+
const file = join(entry.parentPath, entry.name)
|
|
136
|
+
return { file, at: statOf(file).mtime }
|
|
137
|
+
})
|
|
138
|
+
.toSorted((a, b) => b.at - a.at)
|
|
139
|
+
.map((entry) => entry.file)
|
|
140
|
+
} catch {
|
|
141
|
+
return []
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resultBody(content: string | ResultBlock[]): string {
|
|
146
|
+
if (isString(content)) return content
|
|
147
|
+
return content.map((block) => block.text ?? '').join('\n')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function skillNamesIn(command: string): string[] {
|
|
151
|
+
const out: string[] = []
|
|
152
|
+
for (const match of command.matchAll(/([\w.-]+)\/SKILL\.md/g)) {
|
|
153
|
+
const name = match[1]
|
|
154
|
+
if (name !== undefined) out.push(name)
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function readCorpusSession(file: string): CorpusSession {
|
|
160
|
+
const session: CorpusSession = {
|
|
161
|
+
file,
|
|
162
|
+
prompts: [],
|
|
163
|
+
errors: [],
|
|
164
|
+
commands: [],
|
|
165
|
+
reads: [],
|
|
166
|
+
skills: [],
|
|
167
|
+
edits: [],
|
|
168
|
+
tools: {}
|
|
169
|
+
}
|
|
170
|
+
const commands = new Map<string, string>()
|
|
171
|
+
let text: string
|
|
172
|
+
try {
|
|
173
|
+
if (statSync(file).size > MAX_SESSION_BYTES) return session
|
|
174
|
+
text = readFileSync(file, 'utf8')
|
|
175
|
+
} catch {
|
|
176
|
+
return session
|
|
177
|
+
}
|
|
178
|
+
for (const line of text.split('\n')) {
|
|
179
|
+
if (line.trim() === '') continue
|
|
180
|
+
let parsed: unknown
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(line)
|
|
183
|
+
} catch {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
if (isUserMessage(parsed)) {
|
|
187
|
+
const body = resultBody(parsed.message.content)
|
|
188
|
+
for (const match of body.matchAll(/\/skill:([\w.-]+)/g)) {
|
|
189
|
+
const name = match[1]
|
|
190
|
+
if (name !== undefined) session.skills.push(name)
|
|
191
|
+
}
|
|
192
|
+
if (body.trim() !== '' && !body.trimStart().startsWith('<')) session.prompts.push(body)
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if (isAssistantMessage(parsed)) {
|
|
196
|
+
for (const block of parsed.message.content) {
|
|
197
|
+
if (block.type !== 'toolCall') continue
|
|
198
|
+
if (block.name !== undefined) {
|
|
199
|
+
session.tools ??= {}
|
|
200
|
+
session.tools[block.name] = (session.tools[block.name] ?? 0) + 1
|
|
201
|
+
}
|
|
202
|
+
if (block.name === 'bash' && isCommandArguments(block.arguments)) {
|
|
203
|
+
const command = block.arguments.command
|
|
204
|
+
session.commands.push(command)
|
|
205
|
+
if (block.id !== undefined) commands.set(block.id, command)
|
|
206
|
+
for (const name of skillNamesIn(command)) session.skills.push(name)
|
|
207
|
+
}
|
|
208
|
+
if (block.name === 'read' && isPathArguments(block.arguments)) {
|
|
209
|
+
const path = block.arguments.path
|
|
210
|
+
session.reads.push(path)
|
|
211
|
+
if (basename(path) === 'SKILL.md') session.skills.push(basename(dirname(path)))
|
|
212
|
+
}
|
|
213
|
+
if ((block.name === 'edit' || block.name === 'write') && isPathArguments(block.arguments)) {
|
|
214
|
+
session.edits.push(basename(block.arguments.path))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (!isToolMessage(parsed)) continue
|
|
220
|
+
if (!parsed.message.isError) continue
|
|
221
|
+
const body = resultBody(parsed.message.content)
|
|
222
|
+
const signature = failureSignature(body)
|
|
223
|
+
if (signature === '') continue
|
|
224
|
+
const command = parsed.message.toolCallId === undefined ? undefined : commands.get(parsed.message.toolCallId)
|
|
225
|
+
const error = { signature, tool: parsed.message.toolName, body: body.slice(0, 400), command }
|
|
226
|
+
session.errors.push(error)
|
|
227
|
+
}
|
|
228
|
+
session.edits = [...new Set(session.edits)]
|
|
229
|
+
return session
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function idFor(key: string): string {
|
|
233
|
+
return createHash('sha1').update(key).digest('hex').slice(0, 8)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function tally(
|
|
237
|
+
sessions: CorpusSession[],
|
|
238
|
+
pick: (session: CorpusSession) => string[]
|
|
239
|
+
): Map<string, { files: Set<string>; count: number }> {
|
|
240
|
+
const out = new Map<string, { files: Set<string>; count: number }>()
|
|
241
|
+
for (const session of sessions) {
|
|
242
|
+
for (const value of pick(session)) {
|
|
243
|
+
if (value === '') continue
|
|
244
|
+
const found = out.get(value) ?? { files: new Set<string>(), count: 0 }
|
|
245
|
+
found.files.add(session.file)
|
|
246
|
+
found.count += 1
|
|
247
|
+
out.set(value, found)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function collect(
|
|
254
|
+
kind: Recurrence['kind'],
|
|
255
|
+
sessions: CorpusSession[],
|
|
256
|
+
pick: (session: CorpusSession) => string[],
|
|
257
|
+
label: (key: string, files: number) => string,
|
|
258
|
+
minOccurrences: number
|
|
259
|
+
): Recurrence[] {
|
|
260
|
+
const out: Recurrence[] = []
|
|
261
|
+
for (const [key, found] of tally(sessions, pick)) {
|
|
262
|
+
if (found.files.size < MIN_SESSIONS || found.count < minOccurrences) continue
|
|
263
|
+
out.push({
|
|
264
|
+
id: idFor(`${kind}:${key}`),
|
|
265
|
+
key,
|
|
266
|
+
kind,
|
|
267
|
+
title: label(key, found.files.size),
|
|
268
|
+
sessions: found.files.size,
|
|
269
|
+
occurrences: found.count,
|
|
270
|
+
tool: '',
|
|
271
|
+
sample: '',
|
|
272
|
+
evidence: [...found.files].map((file) => basename(file)).slice(0, 4)
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
return out
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function collectErrors(sessions: CorpusSession[]): Recurrence[] {
|
|
279
|
+
const byKey = new Map<
|
|
280
|
+
string,
|
|
281
|
+
{
|
|
282
|
+
files: Set<string>
|
|
283
|
+
edits: Map<string, Set<string>>
|
|
284
|
+
count: number
|
|
285
|
+
tool: string
|
|
286
|
+
sample: string
|
|
287
|
+
command?: string
|
|
288
|
+
}
|
|
289
|
+
>()
|
|
290
|
+
for (const session of sessions) {
|
|
291
|
+
for (const error of session.errors) {
|
|
292
|
+
if (!usableSignature(error.signature)) continue
|
|
293
|
+
const found = byKey.get(error.signature) ?? {
|
|
294
|
+
files: new Set<string>(),
|
|
295
|
+
edits: new Map<string, Set<string>>(),
|
|
296
|
+
count: 0,
|
|
297
|
+
tool: error.tool,
|
|
298
|
+
sample: error.body,
|
|
299
|
+
command: error.command
|
|
300
|
+
}
|
|
301
|
+
if (found.command === undefined && error.command !== undefined) found.command = error.command
|
|
302
|
+
found.files.add(session.file)
|
|
303
|
+
found.edits.set(session.file, new Set(session.edits))
|
|
304
|
+
found.count += 1
|
|
305
|
+
byKey.set(error.signature, found)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const out: Recurrence[] = []
|
|
309
|
+
for (const [key, found] of byKey) {
|
|
310
|
+
if (found.files.size < MIN_SESSIONS || found.count < MIN_FAILURE_OCCURRENCES) continue
|
|
311
|
+
out.push({
|
|
312
|
+
id: idFor(`error:${key}`),
|
|
313
|
+
key,
|
|
314
|
+
kind: 'error',
|
|
315
|
+
title: `the same failure shows up in ${found.files.size} sessions: ${key}`,
|
|
316
|
+
sessions: found.files.size,
|
|
317
|
+
occurrences: found.count,
|
|
318
|
+
tool: found.tool,
|
|
319
|
+
sample: found.sample,
|
|
320
|
+
command: found.command,
|
|
321
|
+
blocked: onePieceOfWork(found.edits) ? 'the sessions that hit it edited the same files' : undefined,
|
|
322
|
+
evidence: [...found.files].map((file) => basename(file)).slice(0, 4)
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
return out
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Are the sessions that hit this failure one piece of work? They are when every one of them
|
|
330
|
+
* edited a file and every pair shares one. A session that edited nothing met the failure
|
|
331
|
+
* outside the edit loop, so its evidence is independent.
|
|
332
|
+
*/
|
|
333
|
+
function onePieceOfWork(work: Map<string, Set<string>>): boolean {
|
|
334
|
+
const sets = [...work.values()]
|
|
335
|
+
if (sets.some((edits) => edits.size === 0)) return false
|
|
336
|
+
for (let i = 0; i < sets.length; i += 1) {
|
|
337
|
+
for (let j = i + 1; j < sets.length; j += 1) {
|
|
338
|
+
const left = sets[i]
|
|
339
|
+
const right = sets[j]
|
|
340
|
+
if (left === undefined || right === undefined) continue
|
|
341
|
+
let shared = false
|
|
342
|
+
for (const file of left) {
|
|
343
|
+
if (right.has(file)) {
|
|
344
|
+
shared = true
|
|
345
|
+
break
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!shared) return false
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function findRecurrences(sessionDir: string): Recurrence[] {
|
|
355
|
+
return scanCorpus(sessionDir, []).recurrences
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface CorpusScan {
|
|
359
|
+
recurrences: Recurrence[]
|
|
360
|
+
skills: SkillUse[]
|
|
361
|
+
sessions: number
|
|
362
|
+
available: number
|
|
363
|
+
prompts: PromptWords
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface PromptWords {
|
|
367
|
+
count: number
|
|
368
|
+
words: Map<string, number>
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* One pass over the session files, because the corpus is the biggest thing the
|
|
373
|
+
* loop reads and reading it twice for two answers is the same work twice.
|
|
374
|
+
*/
|
|
375
|
+
export function scanCorpus(sessionDir: string, names: string[], cachePath?: string, skipSuffix?: string): CorpusScan {
|
|
376
|
+
const cache = cachePath === undefined ? new Map<string, CorpusCacheEntry>() : readCorpusCache(cachePath)
|
|
377
|
+
const files = sessionFiles(sessionDir)
|
|
378
|
+
const kept = new Map<string, CorpusCacheEntry>()
|
|
379
|
+
let parsedBytes = 0
|
|
380
|
+
let parsed = 0
|
|
381
|
+
let changed = false
|
|
382
|
+
for (const file of files) {
|
|
383
|
+
if (kept.size >= MAX_CACHED_SESSIONS) break
|
|
384
|
+
if (skipSuffix !== undefined && file.endsWith(skipSuffix)) continue
|
|
385
|
+
const stat = statOf(file)
|
|
386
|
+
const hit = cache.get(file)
|
|
387
|
+
if (
|
|
388
|
+
hit !== undefined &&
|
|
389
|
+
hit.mtime === stat.mtime &&
|
|
390
|
+
hit.size === stat.size &&
|
|
391
|
+
hit.session.edits !== undefined
|
|
392
|
+
) {
|
|
393
|
+
kept.set(file, hit)
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
if (parsed > 0 && parsedBytes + stat.size > MAX_NEW_SESSION_BYTES) {
|
|
397
|
+
if (hit !== undefined && hit.session.edits !== undefined) kept.set(file, hit)
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
if (stat.size > MAX_SESSION_BYTES) {
|
|
401
|
+
if (hit !== undefined && hit.session.edits !== undefined) kept.set(file, hit)
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
const session = readCorpusSession(file)
|
|
405
|
+
kept.set(file, { path: file, mtime: stat.mtime, size: stat.size, session })
|
|
406
|
+
parsedBytes += stat.size
|
|
407
|
+
parsed += 1
|
|
408
|
+
changed = true
|
|
409
|
+
}
|
|
410
|
+
if (cache.size !== kept.size) changed = true
|
|
411
|
+
if (cachePath !== undefined && changed) writeCorpusCache(cachePath, [...kept.values()])
|
|
412
|
+
const sessions: CorpusSession[] = [...kept.values()].map(({ session }) => ({
|
|
413
|
+
...session,
|
|
414
|
+
edits: session.edits ?? []
|
|
415
|
+
}))
|
|
416
|
+
const available = files.length
|
|
417
|
+
if (sessions.length < MIN_SESSIONS) {
|
|
418
|
+
return {
|
|
419
|
+
recurrences: [],
|
|
420
|
+
skills: [],
|
|
421
|
+
sessions: sessions.length,
|
|
422
|
+
available,
|
|
423
|
+
prompts: { count: 0, words: new Map() }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
recurrences: recurrencesFrom(sessions),
|
|
428
|
+
skills: skillUseFrom(sessions, names),
|
|
429
|
+
sessions: sessions.length,
|
|
430
|
+
available,
|
|
431
|
+
prompts: promptWords(sessions)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
interface CorpusCacheEntry {
|
|
436
|
+
path: string
|
|
437
|
+
mtime: number
|
|
438
|
+
size: number
|
|
439
|
+
/** Older caches have no `edits`, which reads as unknown and forces a reparse of that file. */
|
|
440
|
+
session: Infer<typeof CorpusSessionSchema>
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function readCorpusCache(path: string): Map<string, CorpusCacheEntry> {
|
|
444
|
+
const cache = new Map<string, CorpusCacheEntry>()
|
|
445
|
+
if (!existsSync(path)) return cache
|
|
446
|
+
let parsed: unknown
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
449
|
+
} catch {
|
|
450
|
+
return cache
|
|
451
|
+
}
|
|
452
|
+
if (!isCorpusCacheSchema(parsed)) return cache
|
|
453
|
+
for (const entry of parsed.files) cache.set(entry.path, entry)
|
|
454
|
+
return cache
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function writeCorpusCache(path: string, files: CorpusCacheEntry[]): void {
|
|
458
|
+
try {
|
|
459
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
460
|
+
writeFileAtomic(path, `${JSON.stringify({ files })}\n`)
|
|
461
|
+
} catch {
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function corpusCachePath(cwd: string): string {
|
|
467
|
+
return join(cwd, '.pi', 'harness', 'corpus-cache.json')
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export interface SignatureUse {
|
|
471
|
+
/** When the session started, from the name of the session file. */
|
|
472
|
+
at: number
|
|
473
|
+
count: number
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
let historyCache: {
|
|
477
|
+
path: string
|
|
478
|
+
mtime: number
|
|
479
|
+
size: number
|
|
480
|
+
value: History
|
|
481
|
+
} | null = null
|
|
482
|
+
|
|
483
|
+
export interface History {
|
|
484
|
+
/** Failures per session, for each signature. */
|
|
485
|
+
signatures: Map<string, SignatureUse[]>
|
|
486
|
+
/** Calls per session, for each tool name. */
|
|
487
|
+
tools: Map<string, SignatureUse[]>
|
|
488
|
+
/** Calls of every tool per session, which is how much work the session did. */
|
|
489
|
+
calls: SignatureUse[]
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
interface SessionHistory {
|
|
493
|
+
at: number
|
|
494
|
+
counts: Map<string, number>
|
|
495
|
+
tools: Record<string, number>
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The session file is named after the run's start, and its mtime is when the file
|
|
500
|
+
* was last copied, so the name is the date the failures happened on.
|
|
501
|
+
*/
|
|
502
|
+
export function sessionStartedAt(file: string, fallback: number): number {
|
|
503
|
+
const match = /(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/.exec(basename(file))
|
|
504
|
+
if (match === null) return fallback
|
|
505
|
+
const [year, month, day, hour, minute, second] = match.slice(1).map(Number)
|
|
506
|
+
const at = Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1, hour ?? 0, minute ?? 0, second ?? 0)
|
|
507
|
+
return Number.isNaN(at) || at === 0 ? fallback : at
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* What each failure signature did per session, read from the parsed corpus
|
|
512
|
+
* instead of the transcripts. It is what an install can be measured against:
|
|
513
|
+
* the sessions before it and the sessions after it, including the ones that
|
|
514
|
+
* never hit the failure, because a session that did not fail is the evidence.
|
|
515
|
+
*/
|
|
516
|
+
export function signatureHistory(cachePath: string): Map<string, SignatureUse[]> {
|
|
517
|
+
return historyOf(cachePath).signatures
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* A part can lower the rate of a failure without preventing it, by making the agent do less
|
|
522
|
+
* work. The calls of every tool per session is what tells the two apart, so it is read from
|
|
523
|
+
* the same parse as the signatures.
|
|
524
|
+
*/
|
|
525
|
+
export function callHistory(cachePath: string): SignatureUse[] {
|
|
526
|
+
return historyOf(cachePath).calls
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function historyOf(cachePath: string): History {
|
|
530
|
+
const stat = statOf(cachePath)
|
|
531
|
+
if (
|
|
532
|
+
historyCache !== null &&
|
|
533
|
+
historyCache.path === cachePath &&
|
|
534
|
+
historyCache.mtime === stat.mtime &&
|
|
535
|
+
historyCache.size === stat.size
|
|
536
|
+
) {
|
|
537
|
+
return historyCache.value
|
|
538
|
+
}
|
|
539
|
+
const sessions: SessionHistory[] = []
|
|
540
|
+
for (const entry of readCorpusCache(cachePath).values()) {
|
|
541
|
+
const counts = new Map<string, number>()
|
|
542
|
+
for (const error of entry.session.errors) {
|
|
543
|
+
if (!usableSignature(error.signature)) continue
|
|
544
|
+
counts.set(error.signature, (counts.get(error.signature) ?? 0) + 1)
|
|
545
|
+
}
|
|
546
|
+
sessions.push({
|
|
547
|
+
at: sessionStartedAt(entry.path, entry.mtime),
|
|
548
|
+
counts,
|
|
549
|
+
tools: entry.session.tools ?? {}
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
sessions.sort((a, b) => a.at - b.at)
|
|
553
|
+
const signatures = new Set<string>()
|
|
554
|
+
const tools = new Set<string>()
|
|
555
|
+
for (const session of sessions) {
|
|
556
|
+
for (const signature of session.counts.keys()) signatures.add(signature)
|
|
557
|
+
for (const tool of Object.keys(session.tools)) tools.add(tool)
|
|
558
|
+
}
|
|
559
|
+
const series = (keys: Set<string>, count: (session: SessionHistory, key: string) => number) => {
|
|
560
|
+
const out = new Map<string, SignatureUse[]>()
|
|
561
|
+
for (const key of keys) {
|
|
562
|
+
out.set(
|
|
563
|
+
key,
|
|
564
|
+
sessions.map((session) => ({ at: session.at, count: count(session, key) }))
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
return out
|
|
568
|
+
}
|
|
569
|
+
const value: History = {
|
|
570
|
+
signatures: series(signatures, (session, key) => session.counts.get(key) ?? 0),
|
|
571
|
+
tools: series(tools, (session, key) => session.tools[key] ?? 0),
|
|
572
|
+
calls: sessions.map((session) => ({
|
|
573
|
+
at: session.at,
|
|
574
|
+
count: Object.values(session.tools).reduce((total, count) => total + count, 0)
|
|
575
|
+
}))
|
|
576
|
+
}
|
|
577
|
+
historyCache = { path: cachePath, mtime: stat.mtime, size: stat.size, value }
|
|
578
|
+
return value
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function statOf(file: string) {
|
|
582
|
+
try {
|
|
583
|
+
const stat = statSync(file)
|
|
584
|
+
return { mtime: stat.mtimeMs, size: stat.size }
|
|
585
|
+
} catch {
|
|
586
|
+
return { mtime: 0, size: 0 }
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function recurrencesFrom(sessions: CorpusSession[]): Recurrence[] {
|
|
591
|
+
const errors = collectErrors(sessions)
|
|
592
|
+
const commands = collect(
|
|
593
|
+
'command',
|
|
594
|
+
sessions,
|
|
595
|
+
(session) => session.commands,
|
|
596
|
+
(key, files) => `the same command runs in ${files} sessions: ${key.slice(0, 100)}`,
|
|
597
|
+
MIN_HABIT_OCCURRENCES
|
|
598
|
+
)
|
|
599
|
+
const reads = collect(
|
|
600
|
+
'read',
|
|
601
|
+
sessions,
|
|
602
|
+
(session) => session.reads,
|
|
603
|
+
(key, files) => `the same file is read in ${files} sessions: ${key}`,
|
|
604
|
+
MIN_HABIT_OCCURRENCES
|
|
605
|
+
)
|
|
606
|
+
return [...errors, ...commands, ...reads].toSorted(
|
|
607
|
+
(a, b) => b.sessions - a.sessions || b.occurrences - a.occurrences
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function findSkillUse(sessionDir: string, names: string[]): SkillUse[] {
|
|
612
|
+
return scanCorpus(sessionDir, names).skills
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function promptWords(sessions: CorpusSession[]): PromptWords {
|
|
616
|
+
const words = new Map<string, number>()
|
|
617
|
+
let count = 0
|
|
618
|
+
for (const session of sessions) {
|
|
619
|
+
for (const prompt of session.prompts) {
|
|
620
|
+
count += 1
|
|
621
|
+
for (const word of new Set(prompt.toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g) ?? [])) {
|
|
622
|
+
words.set(word, (words.get(word) ?? 0) + 1)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return { count, words }
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function skillUseFrom(sessions: CorpusSession[], names: string[]): SkillUse[] {
|
|
630
|
+
const wanted = new Set(names)
|
|
631
|
+
const out = new Map<string, SkillUse>()
|
|
632
|
+
for (const name of wanted) out.set(name, { id: `skill-${name}`, name, sessions: sessions.length, opens: 0 })
|
|
633
|
+
for (const session of sessions) {
|
|
634
|
+
for (const name of new Set(session.skills)) {
|
|
635
|
+
const found = out.get(name)
|
|
636
|
+
if (found === undefined) continue
|
|
637
|
+
found.opens += 1
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return [...out.values()].toSorted((a, b) => a.opens - b.opens)
|
|
641
|
+
}
|
package/src/diversity.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface ProposalText {
|
|
5
|
+
id: string
|
|
6
|
+
text: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function proposalTexts(harnessDir: string): ProposalText[] {
|
|
10
|
+
if (!existsSync(harnessDir)) return []
|
|
11
|
+
const out: ProposalText[] = []
|
|
12
|
+
for (const name of readdirSync(harnessDir).toSorted()) {
|
|
13
|
+
const dir = join(harnessDir, name)
|
|
14
|
+
let entries: string[]
|
|
15
|
+
try {
|
|
16
|
+
entries = readdirSync(dir)
|
|
17
|
+
} catch {
|
|
18
|
+
continue
|
|
19
|
+
}
|
|
20
|
+
const text: string[] = []
|
|
21
|
+
for (const entry of entries.toSorted()) {
|
|
22
|
+
const path = join(dir, entry)
|
|
23
|
+
if (entry === 'BRIEF.md') continue
|
|
24
|
+
try {
|
|
25
|
+
text.push(readFileSync(path, 'utf8'))
|
|
26
|
+
} catch {
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (text.length > 0) out.push({ id: name, text: text.join('\n') })
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function lines(text: string): Set<string> {
|
|
36
|
+
return new Set(
|
|
37
|
+
text
|
|
38
|
+
.split('\n')
|
|
39
|
+
.map((line) => line.trim())
|
|
40
|
+
.filter((line) => line !== '')
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function jaccard(left: string, right: string): number {
|
|
45
|
+
const a = lines(left)
|
|
46
|
+
const b = lines(right)
|
|
47
|
+
if (a.size === 0 || b.size === 0) return 0
|
|
48
|
+
let shared = 0
|
|
49
|
+
for (const line of a) if (b.has(line)) shared += 1
|
|
50
|
+
return shared / (a.size + b.size - shared)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Collapse {
|
|
54
|
+
pair: [string, string]
|
|
55
|
+
similarity: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The loop writing the same part twice is the failure of a self-improving loop
|
|
60
|
+
* that nobody watches, so the pair that says it is named.
|
|
61
|
+
*/
|
|
62
|
+
export function findCollapse(proposals: ProposalText[], threshold = 0.8): Collapse | null {
|
|
63
|
+
let worst: Collapse | null = null
|
|
64
|
+
for (let left = 0; left < proposals.length; left += 1) {
|
|
65
|
+
for (let right = left + 1; right < proposals.length; right += 1) {
|
|
66
|
+
const similarity = jaccard(proposals[left]?.text ?? '', proposals[right]?.text ?? '')
|
|
67
|
+
if (similarity < threshold) continue
|
|
68
|
+
if (worst !== null && worst.similarity >= similarity) continue
|
|
69
|
+
worst = { pair: [proposals[left]?.id ?? '', proposals[right]?.id ?? ''], similarity }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return worst
|
|
73
|
+
}
|