@tduyng/pi-harness-loop 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts ADDED
@@ -0,0 +1,268 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { Validator } from 'ata-validator'
5
+ import { t } from 'ata-validator/t'
6
+
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 take = (): boolean => {
247
+ try {
248
+ writeFileSync(path, `${process.pid} ${Date.now()}\n`, { encoding: 'utf8', flag: 'wx' })
249
+ return true
250
+ } catch {
251
+ return false
252
+ }
253
+ }
254
+ if (!take()) {
255
+ try {
256
+ const at = Number(readFileSync(path, 'utf8').split(' ')[1])
257
+ if (Number.isFinite(at) && Date.now() - at > LOCK_STALE_MS) rmSync(path, { force: true })
258
+ } catch {}
259
+ if (!take()) return null
260
+ }
261
+ return () => rmSync(path, { force: true })
262
+ }
263
+
264
+ export function recordLessonUse(cwd: string, id: string): void {
265
+ const found = readLessons(cwd).find((lesson) => lesson.id === id)
266
+ if (found === undefined) return
267
+ appendLesson(cwd, { ...found, uses: (found.uses ?? 0) + 1, lastUsed: Date.now() })
268
+ }
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
+ }