@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/replay.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Validator } from 'ata-validator'
|
|
5
|
+
import { t } from 'ata-validator/t'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import { runCheck } from './check.js'
|
|
9
|
+
import type { Recurrence } from './corpus.js'
|
|
10
|
+
import { harnessDir } from './store.js'
|
|
11
|
+
|
|
12
|
+
export const REPLAY_TTL_MS = 24 * 60 * 60 * 1000
|
|
13
|
+
export const REPLAY_TIMEOUT_MS = 60_000
|
|
14
|
+
|
|
15
|
+
export interface Replay {
|
|
16
|
+
at: number
|
|
17
|
+
id: string
|
|
18
|
+
command: string
|
|
19
|
+
ok: boolean
|
|
20
|
+
exitCode: number | null
|
|
21
|
+
output: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ReplayVerdict = 'stale' | 'live' | 'unknown'
|
|
25
|
+
|
|
26
|
+
const ReplaySchema = t.object({
|
|
27
|
+
at: t.number(),
|
|
28
|
+
id: t.string(),
|
|
29
|
+
command: t.string(),
|
|
30
|
+
ok: t.boolean(),
|
|
31
|
+
exitCode: t.union([t.number(), t.null()]),
|
|
32
|
+
output: t.string()
|
|
33
|
+
})
|
|
34
|
+
const isReplaySchema = new Validator(ReplaySchema).isValidObject
|
|
35
|
+
|
|
36
|
+
const READ_ONLY_BINS = new Set([
|
|
37
|
+
'[',
|
|
38
|
+
'awk',
|
|
39
|
+
'basename',
|
|
40
|
+
'cat',
|
|
41
|
+
'comm',
|
|
42
|
+
'cut',
|
|
43
|
+
'date',
|
|
44
|
+
'diff',
|
|
45
|
+
'dirname',
|
|
46
|
+
'echo',
|
|
47
|
+
'fd',
|
|
48
|
+
'fdfind',
|
|
49
|
+
'grep',
|
|
50
|
+
'head',
|
|
51
|
+
'id',
|
|
52
|
+
'jq',
|
|
53
|
+
'ls',
|
|
54
|
+
'printenv',
|
|
55
|
+
'printf',
|
|
56
|
+
'pwd',
|
|
57
|
+
'realpath',
|
|
58
|
+
'rg',
|
|
59
|
+
'sort',
|
|
60
|
+
'stat',
|
|
61
|
+
'tail',
|
|
62
|
+
'test',
|
|
63
|
+
'tr',
|
|
64
|
+
'type',
|
|
65
|
+
'uniq',
|
|
66
|
+
'wc',
|
|
67
|
+
'which'
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
const GIT_READ = new Set([
|
|
71
|
+
'blame',
|
|
72
|
+
'branch',
|
|
73
|
+
'describe',
|
|
74
|
+
'diff',
|
|
75
|
+
'grep',
|
|
76
|
+
'log',
|
|
77
|
+
'ls-files',
|
|
78
|
+
'remote',
|
|
79
|
+
'rev-parse',
|
|
80
|
+
'show',
|
|
81
|
+
'status'
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
const SHELL_SYNTAX = /[<>&;`\n]|\$\(/
|
|
85
|
+
|
|
86
|
+
function segmentAllowed(segment: string): boolean {
|
|
87
|
+
const [bin, ...args] = segment.split(/\s+/)
|
|
88
|
+
if (bin === undefined) return false
|
|
89
|
+
if (args.some((arg) => arg.startsWith('--output'))) return false
|
|
90
|
+
if (bin === 'git') {
|
|
91
|
+
const sub = args[0] ?? ''
|
|
92
|
+
if (!GIT_READ.has(sub)) return false
|
|
93
|
+
if (sub === 'branch') {
|
|
94
|
+
return args.length === 1 || args.slice(1).every((arg) => /^(-a|-r|-v|--list|--all|--remotes)$/.test(arg))
|
|
95
|
+
}
|
|
96
|
+
if (sub === 'remote') return args.length === 1 || args[1] === '-v' || args[1] === 'show'
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
if (bin === 'sed') return !args.some((arg) => arg === '-i' || arg.startsWith('--in-place'))
|
|
100
|
+
if (bin === 'npx') return args[0] === 'tsc' && args.includes('--noEmit')
|
|
101
|
+
if (bin === 'node') return args.length === 1 && args[0] === '--version'
|
|
102
|
+
if (bin === 'find') return !args.some((arg) => /^-(delete|exec|execdir|ok)$/.test(arg))
|
|
103
|
+
if (bin === 'awk') return !args.some((arg) => arg.includes('system('))
|
|
104
|
+
return READ_ONLY_BINS.has(bin)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function canReplay(command: string): boolean {
|
|
108
|
+
const trimmed = command
|
|
109
|
+
.trim()
|
|
110
|
+
.replace(/\s*2>&1\s*/g, ' ')
|
|
111
|
+
.trim()
|
|
112
|
+
if (trimmed === '' || trimmed.length > 600 || SHELL_SYNTAX.test(trimmed)) return false
|
|
113
|
+
return trimmed.split('|').every((segment) => segmentAllowed(segment.trim()))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function replayFile(cwd: string): string {
|
|
117
|
+
return join(harnessDir(cwd), 'replay.jsonl')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function readReplays(cwd: string): Replay[] {
|
|
121
|
+
const path = replayFile(cwd)
|
|
122
|
+
if (!existsSync(path)) return []
|
|
123
|
+
const out: Replay[] = []
|
|
124
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
125
|
+
if (line.trim() === '') continue
|
|
126
|
+
let parsed: unknown
|
|
127
|
+
try {
|
|
128
|
+
parsed = JSON.parse(line)
|
|
129
|
+
} catch {
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
if (isReplaySchema(parsed)) out.push(parsed)
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function appendReplay(cwd: string, replay: Replay): void {
|
|
138
|
+
mkdirSync(harnessDir(cwd), { recursive: true })
|
|
139
|
+
appendFileSync(replayFile(cwd), `${JSON.stringify(replay)}\n`, 'utf8')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function replayVerdict(cwd: string, recurrence: Recurrence): ReplayVerdict {
|
|
143
|
+
if (recurrence.command === undefined) return 'unknown'
|
|
144
|
+
const latest = readReplays(cwd)
|
|
145
|
+
.filter((replay) => replay.id === recurrence.id && replay.command === recurrence.command)
|
|
146
|
+
.toSorted((a, b) => b.at - a.at)[0]
|
|
147
|
+
if (latest === undefined || Date.now() - latest.at > REPLAY_TTL_MS) return 'unknown'
|
|
148
|
+
return latest.ok ? 'stale' : 'live'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function runReplay(cwd: string, id: string, command: string): Promise<Replay | null> {
|
|
152
|
+
if (!canReplay(command)) return null
|
|
153
|
+
const result = await runCheck(cwd, command, { timeoutMs: REPLAY_TIMEOUT_MS })
|
|
154
|
+
const replay: Replay = {
|
|
155
|
+
at: Date.now(),
|
|
156
|
+
id,
|
|
157
|
+
command,
|
|
158
|
+
ok: result.ok,
|
|
159
|
+
exitCode: result.exitCode,
|
|
160
|
+
output: result.output.slice(0, 2000)
|
|
161
|
+
}
|
|
162
|
+
appendReplay(cwd, replay)
|
|
163
|
+
return replay
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function probeReplay(cwd: string, recurrence: Recurrence): Promise<ReplayVerdict> {
|
|
167
|
+
const cached = replayVerdict(cwd, recurrence)
|
|
168
|
+
if (cached !== 'unknown') return cached
|
|
169
|
+
if (recurrence.command === undefined) return 'unknown'
|
|
170
|
+
const replay = await runReplay(cwd, recurrence.id, recurrence.command)
|
|
171
|
+
if (replay === null) return 'unknown'
|
|
172
|
+
return replay.ok ? 'stale' : 'live'
|
|
173
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { Validator } from 'ata-validator'
|
|
6
|
+
import { t } from 'ata-validator/t'
|
|
7
|
+
|
|
8
|
+
import { writeFileAtomic } from './atomic.js'
|
|
9
|
+
import type { HarnessSettings, Install, Lesson } from './types.js'
|
|
10
|
+
|
|
11
|
+
export function harnessDir(cwd: string): string {
|
|
12
|
+
return join(cwd, '.pi', 'harness')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function lessonsFile(cwd: string): string {
|
|
16
|
+
return join(harnessDir(cwd), 'lessons.jsonl')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function installsFile(cwd: string): string {
|
|
20
|
+
return join(harnessDir(cwd), 'installs.jsonl')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function settingsFile(cwd: string): string {
|
|
24
|
+
return join(cwd, '.pi', 'harness.json')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const LessonSchema = t.object({
|
|
28
|
+
id: t.string(),
|
|
29
|
+
at: t.number(),
|
|
30
|
+
cue: t.string(),
|
|
31
|
+
lesson: t.string(),
|
|
32
|
+
evidence: t.string(),
|
|
33
|
+
confidence: t.union([t.literal('high'), t.literal('medium'), t.literal('low')]),
|
|
34
|
+
triggers: t.array(t.string()),
|
|
35
|
+
kind: t.union([
|
|
36
|
+
t.literal('repeat-error'),
|
|
37
|
+
t.literal('repeat-command'),
|
|
38
|
+
t.literal('repeat-read'),
|
|
39
|
+
t.literal('no-change'),
|
|
40
|
+
t.literal('abandoned-tool')
|
|
41
|
+
]),
|
|
42
|
+
state: t.union([
|
|
43
|
+
t.literal('recorded'),
|
|
44
|
+
t.literal('installed'),
|
|
45
|
+
t.literal('failed'),
|
|
46
|
+
t.literal('rejected'),
|
|
47
|
+
t.literal('superseded')
|
|
48
|
+
]),
|
|
49
|
+
confirm: t.optional(t.string()),
|
|
50
|
+
supersedes: t.optional(t.string()),
|
|
51
|
+
uses: t.optional(t.number()),
|
|
52
|
+
lastUsed: t.optional(t.number())
|
|
53
|
+
})
|
|
54
|
+
const isLessonSchema = new Validator(LessonSchema).isValidObject
|
|
55
|
+
|
|
56
|
+
const SettingsSchema = t.object({
|
|
57
|
+
check: t.optional(t.string()),
|
|
58
|
+
auto: t.optional(t.boolean()),
|
|
59
|
+
autoInstall: t.optional(t.boolean()),
|
|
60
|
+
global: t.optional(t.boolean()),
|
|
61
|
+
maxInstallsPerDay: t.optional(t.number()),
|
|
62
|
+
maxProposalsPerDay: t.optional(t.number()),
|
|
63
|
+
maxTurnsPerProposal: t.optional(t.number()),
|
|
64
|
+
maxStandingTokens: t.optional(t.number()),
|
|
65
|
+
writerModel: t.optional(t.string())
|
|
66
|
+
})
|
|
67
|
+
const isSettingsSchema = new Validator(SettingsSchema).isValidObject
|
|
68
|
+
|
|
69
|
+
const InstalledFile = t.object({
|
|
70
|
+
to: t.string(),
|
|
71
|
+
existed: t.boolean(),
|
|
72
|
+
before: t.string(),
|
|
73
|
+
after: t.optional(t.string()),
|
|
74
|
+
afterHash: t.optional(t.string()),
|
|
75
|
+
block: t.optional(t.string()),
|
|
76
|
+
mode: t.union([t.literal('replace'), t.literal('append')])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const InstallSchema = t.object({
|
|
80
|
+
id: t.string(),
|
|
81
|
+
at: t.number(),
|
|
82
|
+
finding: t.string(),
|
|
83
|
+
dir: t.string(),
|
|
84
|
+
files: t.array(InstalledFile),
|
|
85
|
+
check: t.string(),
|
|
86
|
+
ok: t.boolean(),
|
|
87
|
+
reverted: t.boolean(),
|
|
88
|
+
lesson: t.optional(t.string()),
|
|
89
|
+
key: t.optional(t.string()),
|
|
90
|
+
cost: t.optional(t.number()),
|
|
91
|
+
confirm: t.optional(t.string()),
|
|
92
|
+
confirmOk: t.optional(t.boolean()),
|
|
93
|
+
confirmOutput: t.optional(t.string()),
|
|
94
|
+
replay: t.optional(t.string()),
|
|
95
|
+
replayOk: t.optional(t.boolean()),
|
|
96
|
+
replayOutput: t.optional(t.string()),
|
|
97
|
+
note: t.optional(t.string())
|
|
98
|
+
})
|
|
99
|
+
const isInstallSchema = new Validator(InstallSchema).isValidObject
|
|
100
|
+
|
|
101
|
+
function normalizedCue(cue: string): string {
|
|
102
|
+
return cue.toLowerCase().replace(/\s+/g, ' ').trim()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function active(lesson: Lesson): boolean {
|
|
106
|
+
return lesson.state === 'recorded' || lesson.state === 'installed'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The file is append-only, so two processes can record the same cue as two active
|
|
111
|
+
* lines. The view keeps the newest active line of a cue, and every settled line,
|
|
112
|
+
* because a superseded record is how the report finds the rule still installed.
|
|
113
|
+
*/
|
|
114
|
+
function withoutDuplicateActives(lessons: Lesson[]): Lesson[] {
|
|
115
|
+
const newest = new Map<string, Lesson>()
|
|
116
|
+
for (const lesson of lessons) {
|
|
117
|
+
if (!active(lesson)) continue
|
|
118
|
+
const key = normalizedCue(lesson.cue)
|
|
119
|
+
const seen = newest.get(key)
|
|
120
|
+
if (seen === undefined || seen.at <= lesson.at) newest.set(key, lesson)
|
|
121
|
+
}
|
|
122
|
+
return lessons.filter((lesson) => !active(lesson) || newest.get(normalizedCue(lesson.cue))?.id === lesson.id)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function readLessons(cwd: string): Lesson[] {
|
|
126
|
+
const path = lessonsFile(cwd)
|
|
127
|
+
if (!existsSync(path)) return []
|
|
128
|
+
const lessons = new Map<string, Lesson>()
|
|
129
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
130
|
+
const trimmed = line.trim()
|
|
131
|
+
if (trimmed === '') continue
|
|
132
|
+
let parsed: unknown
|
|
133
|
+
try {
|
|
134
|
+
parsed = JSON.parse(trimmed)
|
|
135
|
+
} catch {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
if (isLessonSchema(parsed)) lessons.set(parsed.id, parsed)
|
|
139
|
+
}
|
|
140
|
+
return withoutDuplicateActives([...lessons.values()])
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function appendLesson(cwd: string, lesson: Lesson): void {
|
|
144
|
+
const path = lessonsFile(cwd)
|
|
145
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
146
|
+
appendFileSync(path, `${JSON.stringify(lesson)}\n`, 'utf8')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sameCue(left: string, right: string): boolean {
|
|
150
|
+
return normalizedCue(left) === normalizedCue(right)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A lesson whose part did not hold stops matching, and the record keeps why. */
|
|
154
|
+
export function failLesson(cwd: string, id: string): boolean {
|
|
155
|
+
const found = readLessons(cwd).find((lesson) => lesson.id === id)
|
|
156
|
+
if (found === undefined || found.state === 'failed') return false
|
|
157
|
+
appendLesson(cwd, { ...found, state: 'failed' })
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A lesson about a situation that another lesson already covers replaces it. The old one is
|
|
163
|
+
* kept, marked superseded, because it is the record of what the loop believed before, and it
|
|
164
|
+
* stops matching, because a later session has to read one rule for a situation, not two.
|
|
165
|
+
*/
|
|
166
|
+
export function recordLesson(cwd: string, lesson: Lesson) {
|
|
167
|
+
const previous =
|
|
168
|
+
readLessons(cwd).find(
|
|
169
|
+
(entry) => entry.state !== 'rejected' && entry.state !== 'superseded' && sameCue(entry.cue, lesson.cue)
|
|
170
|
+
) ?? null
|
|
171
|
+
if (previous !== null) appendLesson(cwd, { ...previous, state: 'superseded' })
|
|
172
|
+
const next = previous === null ? lesson : { ...lesson, supersedes: previous.id }
|
|
173
|
+
appendLesson(cwd, next)
|
|
174
|
+
return { lesson: next, superseded: previous }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function writeLessons(cwd: string, lessons: Lesson[]): void {
|
|
178
|
+
const path = lessonsFile(cwd)
|
|
179
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
180
|
+
writeFileAtomic(path, lessons.map((lesson) => `${JSON.stringify(lesson)}\n`).join(''))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const DEFAULT_SETTINGS: HarnessSettings = {
|
|
184
|
+
check: '',
|
|
185
|
+
auto: true,
|
|
186
|
+
autoInstall: true,
|
|
187
|
+
global: false,
|
|
188
|
+
maxInstallsPerDay: 3,
|
|
189
|
+
maxProposalsPerDay: 5,
|
|
190
|
+
maxTurnsPerProposal: 6,
|
|
191
|
+
maxStandingTokens: 1_500,
|
|
192
|
+
writerModel: ''
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function readSettings(cwd: string): HarnessSettings {
|
|
196
|
+
const path = settingsFile(cwd)
|
|
197
|
+
if (!existsSync(path)) return { ...DEFAULT_SETTINGS }
|
|
198
|
+
let parsed: unknown
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
201
|
+
} catch {
|
|
202
|
+
return { ...DEFAULT_SETTINGS }
|
|
203
|
+
}
|
|
204
|
+
if (!isSettingsSchema(parsed)) return { ...DEFAULT_SETTINGS }
|
|
205
|
+
return {
|
|
206
|
+
check: parsed.check ?? DEFAULT_SETTINGS.check,
|
|
207
|
+
auto: parsed.auto ?? DEFAULT_SETTINGS.auto,
|
|
208
|
+
autoInstall: parsed.autoInstall ?? DEFAULT_SETTINGS.autoInstall,
|
|
209
|
+
global: parsed.global ?? DEFAULT_SETTINGS.global,
|
|
210
|
+
maxInstallsPerDay: Math.max(0, parsed.maxInstallsPerDay ?? DEFAULT_SETTINGS.maxInstallsPerDay),
|
|
211
|
+
maxProposalsPerDay: Math.max(0, parsed.maxProposalsPerDay ?? DEFAULT_SETTINGS.maxProposalsPerDay),
|
|
212
|
+
maxTurnsPerProposal: Math.max(1, parsed.maxTurnsPerProposal ?? DEFAULT_SETTINGS.maxTurnsPerProposal),
|
|
213
|
+
maxStandingTokens: Math.max(0, parsed.maxStandingTokens ?? DEFAULT_SETTINGS.maxStandingTokens),
|
|
214
|
+
writerModel: parsed.writerModel ?? DEFAULT_SETTINGS.writerModel
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function readInstalls(cwd: string): Install[] {
|
|
219
|
+
const path = installsFile(cwd)
|
|
220
|
+
if (!existsSync(path)) return []
|
|
221
|
+
const installs = new Map<string, Install>()
|
|
222
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
223
|
+
if (line.trim() === '') continue
|
|
224
|
+
let parsed: unknown
|
|
225
|
+
try {
|
|
226
|
+
parsed = JSON.parse(line)
|
|
227
|
+
} catch {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (isInstallSchema(parsed)) installs.set(parsed.id, parsed)
|
|
231
|
+
}
|
|
232
|
+
return [...installs.values()].toSorted((a, b) => a.at - b.at)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function appendInstall(cwd: string, install: Install): void {
|
|
236
|
+
const path = installsFile(cwd)
|
|
237
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
238
|
+
appendFileSync(path, `${JSON.stringify(install)}\n`, 'utf8')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const LOCK_STALE_MS = 5 * 60_000
|
|
242
|
+
|
|
243
|
+
export function acquireLock(cwd: string): (() => void) | null {
|
|
244
|
+
const path = join(harnessDir(cwd), 'lock')
|
|
245
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
246
|
+
const token = `${process.pid} ${Date.now()} ${randomUUID()}\n`
|
|
247
|
+
const take = (): boolean => {
|
|
248
|
+
try {
|
|
249
|
+
writeFileSync(path, token, { encoding: 'utf8', flag: 'wx' })
|
|
250
|
+
return true
|
|
251
|
+
} catch {
|
|
252
|
+
return false
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!take()) {
|
|
256
|
+
try {
|
|
257
|
+
const existing = readFileSync(path, 'utf8')
|
|
258
|
+
const at = Number(existing.split(' ')[1])
|
|
259
|
+
if (Number.isFinite(at) && Date.now() - at > LOCK_STALE_MS) rmSync(path, { force: true })
|
|
260
|
+
} catch {}
|
|
261
|
+
if (!take()) return null
|
|
262
|
+
}
|
|
263
|
+
return () => {
|
|
264
|
+
try {
|
|
265
|
+
if (readFileSync(path, 'utf8') === token) rmSync(path, { force: true })
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function recordLessonUse(cwd: string, id: string): void {
|
|
271
|
+
const found = readLessons(cwd).find((lesson) => lesson.id === id)
|
|
272
|
+
if (found === undefined) return
|
|
273
|
+
appendLesson(cwd, { ...found, uses: (found.uses ?? 0) + 1, lastUsed: Date.now() })
|
|
274
|
+
}
|
package/src/target.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Recurrence, SkillUse } from './corpus.js'
|
|
2
|
+
import type { Finding } from './findings.js'
|
|
3
|
+
import type { FindingKind } from './types.js'
|
|
4
|
+
|
|
5
|
+
export interface Target {
|
|
6
|
+
id: string
|
|
7
|
+
title: string
|
|
8
|
+
count: number
|
|
9
|
+
evidence: string[]
|
|
10
|
+
sample: string
|
|
11
|
+
command?: string
|
|
12
|
+
tool: string
|
|
13
|
+
scope: string
|
|
14
|
+
kind: FindingKind
|
|
15
|
+
cue: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function targetFromFinding(finding: Finding): Target {
|
|
19
|
+
const kind: FindingKind =
|
|
20
|
+
finding.kind === 'command'
|
|
21
|
+
? 'repeat-command'
|
|
22
|
+
: finding.kind === 'read'
|
|
23
|
+
? 'repeat-read'
|
|
24
|
+
: finding.kind === 'no-change'
|
|
25
|
+
? 'no-change'
|
|
26
|
+
: finding.kind === 'unanswered'
|
|
27
|
+
? 'abandoned-tool'
|
|
28
|
+
: 'repeat-error'
|
|
29
|
+
return {
|
|
30
|
+
id: finding.id,
|
|
31
|
+
title: finding.title,
|
|
32
|
+
count: finding.count,
|
|
33
|
+
evidence: finding.evidence,
|
|
34
|
+
sample: finding.evidence[0] ?? '',
|
|
35
|
+
tool: '',
|
|
36
|
+
scope: 'this session',
|
|
37
|
+
kind,
|
|
38
|
+
cue: finding.title
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function targetFromSkillUse(
|
|
43
|
+
use: SkillUse,
|
|
44
|
+
description: string,
|
|
45
|
+
path: string
|
|
46
|
+
): Target {
|
|
47
|
+
const evidence = [`${path} :: ${description}`]
|
|
48
|
+
return {
|
|
49
|
+
id: use.id,
|
|
50
|
+
title: `the skill ${use.name} is advertised in every request and was opened in none of ${use.sessions} sessions`,
|
|
51
|
+
count: use.sessions,
|
|
52
|
+
evidence,
|
|
53
|
+
sample: '',
|
|
54
|
+
tool: '',
|
|
55
|
+
scope: `${use.sessions} sessions of this project`,
|
|
56
|
+
kind: 'dead-skill',
|
|
57
|
+
cue: `the skill ${use.name} is never opened`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function targetFromRecurrence(recurrence: Recurrence): Target {
|
|
62
|
+
const kind: FindingKind =
|
|
63
|
+
recurrence.kind === 'command' ? 'repeat-command' : recurrence.kind === 'read' ? 'repeat-read' : 'repeat-error'
|
|
64
|
+
return {
|
|
65
|
+
id: recurrence.id,
|
|
66
|
+
title: recurrence.title,
|
|
67
|
+
count: recurrence.occurrences,
|
|
68
|
+
evidence: recurrence.evidence,
|
|
69
|
+
sample: recurrence.sample,
|
|
70
|
+
command: recurrence.command,
|
|
71
|
+
tool: recurrence.tool,
|
|
72
|
+
scope: `${recurrence.sessions} sessions of this project`,
|
|
73
|
+
kind,
|
|
74
|
+
cue: recurrence.title
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Validator } from 'ata-validator'
|
|
2
|
+
import { t } from 'ata-validator/t'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import type { SessionEntry } from '@earendil-works/pi-coding-agent'
|
|
6
|
+
const isString = new Validator(t.string()).isValidObject
|
|
7
|
+
|
|
8
|
+
export interface ToolCall {
|
|
9
|
+
id: string
|
|
10
|
+
name: string
|
|
11
|
+
command: string
|
|
12
|
+
path: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ToolResult {
|
|
16
|
+
toolCallId: string
|
|
17
|
+
toolName: string
|
|
18
|
+
isError: boolean
|
|
19
|
+
body: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Transcript {
|
|
23
|
+
calls: ToolCall[]
|
|
24
|
+
results: ToolResult[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CommandArguments = t.object({ command: t.string() })
|
|
28
|
+
const isCommandArguments = new Validator(CommandArguments).isValidObject
|
|
29
|
+
const PathArguments = t.object({ path: t.string() })
|
|
30
|
+
const isPathArguments = new Validator(PathArguments).isValidObject
|
|
31
|
+
|
|
32
|
+
export function readTranscript(entries: SessionEntry[]): Transcript {
|
|
33
|
+
const calls: ToolCall[] = []
|
|
34
|
+
const results: ToolResult[] = []
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (entry.type !== 'message') continue
|
|
37
|
+
const message = entry.message
|
|
38
|
+
if (message.role === 'assistant') {
|
|
39
|
+
for (const block of message.content) {
|
|
40
|
+
if (block.type !== 'toolCall') continue
|
|
41
|
+
calls.push({
|
|
42
|
+
id: block.id,
|
|
43
|
+
name: block.name,
|
|
44
|
+
command: isCommandArguments(block.arguments) ? block.arguments.command : '',
|
|
45
|
+
path: isPathArguments(block.arguments) ? block.arguments.path : ''
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (message.role === 'toolResult') {
|
|
51
|
+
results.push({
|
|
52
|
+
toolCallId: message.toolCallId,
|
|
53
|
+
toolName: message.toolName,
|
|
54
|
+
isError: message.isError,
|
|
55
|
+
body: message.content
|
|
56
|
+
.filter((block) => block.type === 'text')
|
|
57
|
+
.map((block) => block.text)
|
|
58
|
+
.join('\n')
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { calls, results }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Usage {
|
|
66
|
+
input: number
|
|
67
|
+
output: number
|
|
68
|
+
cost: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function sumUsage(entries: SessionEntry[]): Usage {
|
|
72
|
+
let input = 0
|
|
73
|
+
let output = 0
|
|
74
|
+
let cost = 0
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue
|
|
77
|
+
const usage = entry.message.usage
|
|
78
|
+
input += usage.input
|
|
79
|
+
output += usage.output
|
|
80
|
+
cost += usage.cost.total
|
|
81
|
+
}
|
|
82
|
+
return { input, output, cost }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function countAssistantTurns(entries: SessionEntry[]): number {
|
|
86
|
+
return entries.filter((entry) => entry.type === 'message' && entry.message.role === 'assistant').length
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function lastAssistantText(entries: SessionEntry[]): string {
|
|
90
|
+
for (const entry of entries.toReversed()) {
|
|
91
|
+
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue
|
|
92
|
+
return entry.message.content
|
|
93
|
+
.filter((block) => block.type === 'text')
|
|
94
|
+
.map((block) => block.text)
|
|
95
|
+
.join('\n')
|
|
96
|
+
}
|
|
97
|
+
return ''
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function lastUserText(entries: SessionEntry[]): string {
|
|
101
|
+
for (const entry of entries.toReversed()) {
|
|
102
|
+
if (entry.type !== 'message' || entry.message.role !== 'user') continue
|
|
103
|
+
const content = entry.message.content
|
|
104
|
+
if (isString(content)) return content
|
|
105
|
+
return content
|
|
106
|
+
.filter((block) => block.type === 'text')
|
|
107
|
+
.map((block) => block.text)
|
|
108
|
+
.join('\n')
|
|
109
|
+
}
|
|
110
|
+
return ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function recentText(entries: SessionEntry[], limit = 6): string {
|
|
114
|
+
const texts: string[] = []
|
|
115
|
+
for (const entry of entries.toReversed().slice(0, limit)) {
|
|
116
|
+
if (entry.type !== 'message') continue
|
|
117
|
+
const message = entry.message
|
|
118
|
+
if (message.role === 'assistant') {
|
|
119
|
+
texts.push(
|
|
120
|
+
message.content
|
|
121
|
+
.filter((block) => block.type === 'text')
|
|
122
|
+
.map((block) => block.text)
|
|
123
|
+
.join('\n')
|
|
124
|
+
)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (message.role !== 'user') continue
|
|
128
|
+
const content = message.content
|
|
129
|
+
texts.push(
|
|
130
|
+
isString(content)
|
|
131
|
+
? content
|
|
132
|
+
: content
|
|
133
|
+
.filter((block) => block.type === 'text')
|
|
134
|
+
.map((block) => block.text)
|
|
135
|
+
.join('\n')
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
return texts.join('\n')
|
|
139
|
+
}
|