@tduyng/pi-autoresearch 0.0.1 → 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/README.md +3 -2
- package/package.json +1 -1
- package/src/experiment.ts +36 -41
- package/src/git.ts +13 -20
- package/src/index.ts +4 -2
- package/src/session.ts +25 -11
package/README.md
CHANGED
|
@@ -100,8 +100,9 @@ worktree is refused before it can run anything. Files that were already dirty wh
|
|
|
100
100
|
recorded in the lock and never committed or reverted.
|
|
101
101
|
|
|
102
102
|
For a second loop on the same repository, use a separate git worktree. Each worktree has its own `.auto/`,
|
|
103
|
-
its own branch, and its own log. The first run creates `autoresearch/<name>-<timestamp>`
|
|
104
|
-
|
|
103
|
+
its own branch, and its own log. The first run on trunk creates `autoresearch/<name>-<timestamp>` in a git
|
|
104
|
+
worktree at `~/worktrees/<repo>/autoresearch-<name>-<timestamp>`, and the shared checkout stays on trunk. Start
|
|
105
|
+
pi in that directory to run the loop. An existing feature branch is used as it is.
|
|
105
106
|
|
|
106
107
|
## Compaction and restarts
|
|
107
108
|
|
package/package.json
CHANGED
package/src/experiment.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
2
3
|
|
|
3
|
-
import type {
|
|
4
|
-
AgentToolResult,
|
|
5
|
-
AgentToolUpdateCallback,
|
|
6
|
-
ExtensionContext
|
|
7
|
-
} from '@earendil-works/pi-coding-agent'
|
|
4
|
+
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
8
5
|
import { t } from 'ata-validator/t'
|
|
9
6
|
|
|
10
7
|
import { runCommand } from './command.js'
|
|
@@ -61,13 +58,10 @@ const CHECKS_TAIL_LINES = 8
|
|
|
61
58
|
const PENDING_TTL_MS = 24 * 60 * 60 * 1000
|
|
62
59
|
const FILE_LIST_LIMIT = 10
|
|
63
60
|
|
|
64
|
-
const AsiParam = t.record(t.union([t.string(), t.number(), t.boolean(), t.null()])
|
|
65
|
-
)
|
|
61
|
+
const AsiParam = t.record(t.union([t.string(), t.number(), t.boolean(), t.null()]))
|
|
66
62
|
|
|
67
63
|
const RunParams = t.object({
|
|
68
|
-
command: t.optional(
|
|
69
|
-
t.string({ description: 'Benchmark command to run instead of the one in .auto/prompt.md' })
|
|
70
|
-
),
|
|
64
|
+
command: t.optional(t.string({ description: 'Benchmark command to run instead of the one in .auto/prompt.md' })),
|
|
71
65
|
timeout_seconds: t.optional(t.number({ description: 'Kill the benchmark after this many seconds' }))
|
|
72
66
|
})
|
|
73
67
|
|
|
@@ -80,9 +74,7 @@ const LogParams = t.object({
|
|
|
80
74
|
description: t.string({
|
|
81
75
|
description: 'One imperative line naming the change, in the words of a commit subject ("use forks pool").'
|
|
82
76
|
}),
|
|
83
|
-
asi: t.optional(
|
|
84
|
-
AsiParam
|
|
85
|
-
),
|
|
77
|
+
asi: t.optional(AsiParam),
|
|
86
78
|
note: t.optional(t.string({ description: 'One line of what this run taught you. Shorthand for asi.note.' }))
|
|
87
79
|
})
|
|
88
80
|
|
|
@@ -187,11 +179,7 @@ export function outOfScope(files: readonly string[], scope: readonly string[]):
|
|
|
187
179
|
return files.filter((file) => !patterns.some((pattern) => pattern.test(file)))
|
|
188
180
|
}
|
|
189
181
|
|
|
190
|
-
function primaryMetric(
|
|
191
|
-
samples: readonly PendingSample[],
|
|
192
|
-
config: ResearchConfig,
|
|
193
|
-
seconds: number
|
|
194
|
-
): number | null {
|
|
182
|
+
function primaryMetric(samples: readonly PendingSample[], config: ResearchConfig, seconds: number): number | null {
|
|
195
183
|
const parsed = metricValue(samples, config.metric)
|
|
196
184
|
if (parsed !== null) return parsed
|
|
197
185
|
if (config.metric === 'duration' || config.metric === 'seconds') return seconds
|
|
@@ -274,42 +262,35 @@ async function executeRun(
|
|
|
274
262
|
output: result.output,
|
|
275
263
|
tail: result.tail,
|
|
276
264
|
fullOutputPath: result.fullOutputPath,
|
|
277
|
-
metrics: parseMetricLines(
|
|
265
|
+
metrics: parseMetricLines(
|
|
266
|
+
result.fullOutputPath === null ? result.output : readFileSync(result.fullOutputPath, 'utf8')
|
|
267
|
+
)
|
|
278
268
|
}
|
|
279
269
|
}
|
|
280
270
|
|
|
281
|
-
async function runChecks(
|
|
282
|
-
pi: GitApi,
|
|
283
|
-
cwd: string,
|
|
284
|
-
signal: AbortSignal | undefined
|
|
285
|
-
): Promise<ChecksOutcome | null> {
|
|
271
|
+
async function runChecks(pi: GitApi, cwd: string, signal: AbortSignal | undefined): Promise<ChecksOutcome | null> {
|
|
286
272
|
const path = checksPath(cwd)
|
|
287
273
|
if (!fileExists(path)) return null
|
|
288
|
-
const result = await pi
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
.
|
|
295
|
-
|
|
296
|
-
.slice(-CHECKS_TAIL_LINES)
|
|
297
|
-
.join('\n')
|
|
274
|
+
const result = await pi
|
|
275
|
+
.exec('bash', [path], {
|
|
276
|
+
cwd,
|
|
277
|
+
timeout: CHECKS_TIMEOUT_SECONDS * 1000,
|
|
278
|
+
signal
|
|
279
|
+
})
|
|
280
|
+
.catch(() => ({ stdout: '', stderr: 'checks could not run', code: 1, killed: false }))
|
|
281
|
+
const tail = `${result.stdout}\n${result.stderr}`.trim().split('\n').slice(-CHECKS_TAIL_LINES).join('\n')
|
|
298
282
|
return { passed: result.code === 0 && !result.killed, tail }
|
|
299
283
|
}
|
|
300
284
|
|
|
301
|
-
function secondarySummary(
|
|
302
|
-
state: LogState,
|
|
303
|
-
samples: readonly PendingSample[],
|
|
304
|
-
config: ResearchConfig
|
|
305
|
-
): string | null {
|
|
285
|
+
function secondarySummary(state: LogState, samples: readonly PendingSample[], config: ResearchConfig): string | null {
|
|
306
286
|
const extras = samples.filter((sample) => sample.name !== config.metric).slice(0, 4)
|
|
307
287
|
if (extras.length === 0) return null
|
|
308
288
|
const baseline = runsInSegment(state)[0]?.metrics ?? {}
|
|
309
289
|
return extras
|
|
310
290
|
.map((sample) => {
|
|
311
291
|
const reference = baseline[sample.name]
|
|
312
|
-
const delta =
|
|
292
|
+
const delta =
|
|
293
|
+
reference === undefined || reference === 0 ? '' : ` (${formatPercent(sample.value, reference)})`
|
|
313
294
|
return `${sample.name} ${formatMetric(sample.value, inferUnit(sample.name))}${delta}`
|
|
314
295
|
})
|
|
315
296
|
.join(' | ')
|
|
@@ -365,7 +346,7 @@ export function createRunTool(pi: GitApi, hooks: ExperimentHooks) {
|
|
|
365
346
|
promptSnippet: 'Run the benchmark once and show the measurement',
|
|
366
347
|
promptGuidelines: [
|
|
367
348
|
'Run this after editing files and before log_experiment. It never changes git history.',
|
|
368
|
-
|
|
349
|
+
"Read the benchmark output, the secondary metrics and the noise advice before deciding. The decision is yours, not the tool's.",
|
|
369
350
|
'If a run is already pending, call log_experiment first. One pending run at a time.'
|
|
370
351
|
],
|
|
371
352
|
parameters: RunParams,
|
|
@@ -531,6 +512,20 @@ export function createLogTool(pi: GitApi, hooks: ExperimentHooks) {
|
|
|
531
512
|
const cwd = ctx.cwd
|
|
532
513
|
const setup = sessionSetup(cwd)
|
|
533
514
|
if (setup.kind === 'error') return errorResult(setup.message)
|
|
515
|
+
const lockProblem = ensureLock(ctx)
|
|
516
|
+
if (lockProblem !== null) return errorResult(lockProblem)
|
|
517
|
+
const activeLock = readLock(cwd)
|
|
518
|
+
const current = await git.currentBranch(pi, cwd)
|
|
519
|
+
if (
|
|
520
|
+
activeLock !== null &&
|
|
521
|
+
activeLock.branch.length > 0 &&
|
|
522
|
+
current.length > 0 &&
|
|
523
|
+
current !== activeLock.branch
|
|
524
|
+
) {
|
|
525
|
+
return errorResult(
|
|
526
|
+
`The worktree moved from ${activeLock.branch} to ${current}. Check out ${activeLock.branch} before logging a keep.`
|
|
527
|
+
)
|
|
528
|
+
}
|
|
534
529
|
const config = setup.config
|
|
535
530
|
const pending = readPending(cwd)
|
|
536
531
|
if (pending === null) {
|
package/src/git.ts
CHANGED
|
@@ -85,24 +85,14 @@ export async function stagePaths(pi: GitApi, cwd: string, files: readonly string
|
|
|
85
85
|
return git(pi, cwd, ['add', '--', ...files])
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
export async function commitStaged(
|
|
89
|
-
pi: GitApi,
|
|
90
|
-
cwd: string,
|
|
91
|
-
subject: string,
|
|
92
|
-
body: string
|
|
93
|
-
): Promise<CommitResult> {
|
|
88
|
+
export async function commitStaged(pi: GitApi, cwd: string, subject: string, body: string): Promise<CommitResult> {
|
|
94
89
|
const result = await git(pi, cwd, ['commit', '-m', subject, '-m', body])
|
|
95
90
|
if (result.code !== 0) return { sha: '', output: result.output }
|
|
96
91
|
const sha = await git(pi, cwd, ['rev-parse', '--short=7', 'HEAD'])
|
|
97
92
|
return sha.code === 0 ? { sha: sha.output, output: '' } : { sha: '', output: sha.output }
|
|
98
93
|
}
|
|
99
94
|
|
|
100
|
-
export async function commitAllowEmpty(
|
|
101
|
-
pi: GitApi,
|
|
102
|
-
cwd: string,
|
|
103
|
-
subject: string,
|
|
104
|
-
body: string
|
|
105
|
-
): Promise<GitResult> {
|
|
95
|
+
export async function commitAllowEmpty(pi: GitApi, cwd: string, subject: string, body: string): Promise<GitResult> {
|
|
106
96
|
return git(pi, cwd, ['commit', '--allow-empty', '-m', subject, '-m', body])
|
|
107
97
|
}
|
|
108
98
|
|
|
@@ -130,8 +120,15 @@ export async function restoreFiles(pi: GitApi, cwd: string, files: readonly Pend
|
|
|
130
120
|
return failure
|
|
131
121
|
}
|
|
132
122
|
|
|
133
|
-
export async function
|
|
134
|
-
|
|
123
|
+
export async function repoName(pi: GitApi, cwd: string): Promise<string> {
|
|
124
|
+
const origin = await git(pi, cwd, ['remote', 'get-url', 'origin'])
|
|
125
|
+
if (origin.code === 0 && origin.output.length > 0) return path.basename(origin.output).replace(/\.git$/, '')
|
|
126
|
+
const top = await git(pi, cwd, ['rev-parse', '--show-toplevel'])
|
|
127
|
+
return path.basename(top.output)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function loopWorktreePath(repo: string, branch: string): string {
|
|
131
|
+
return path.join(os.homedir(), 'worktrees', repo, branch.replace(/\//g, '-'))
|
|
135
132
|
}
|
|
136
133
|
|
|
137
134
|
export async function branchExists(pi: GitApi, cwd: string, name: string): Promise<boolean> {
|
|
@@ -154,12 +151,8 @@ export async function mergeBase(pi: GitApi, cwd: string, trunk: string): Promise
|
|
|
154
151
|
}
|
|
155
152
|
|
|
156
153
|
export async function filesInCommit(pi: GitApi, cwd: string, commit: string): Promise<readonly string[]> {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
return result.output
|
|
160
|
-
.split('\n')
|
|
161
|
-
.map((line) => line.trim())
|
|
162
|
-
.filter((line) => line.length > 0 && !line.startsWith(`${RESEARCH_DIRECTORY}/`))
|
|
154
|
+
const files = await zeroList(pi, cwd, ['show', '--name-only', '--format=', commit])
|
|
155
|
+
return files.filter((file) => !file.startsWith(`${RESEARCH_DIRECTORY}/`))
|
|
163
156
|
}
|
|
164
157
|
|
|
165
158
|
export async function fileInCommit(pi: GitApi, cwd: string, commit: string, file: string): Promise<boolean> {
|
package/src/index.ts
CHANGED
|
@@ -229,8 +229,10 @@ export default function autoresearchExtension(pi: ExtensionAPI): void {
|
|
|
229
229
|
}
|
|
230
230
|
if (branch.length > 0 && branch !== trunk) return null
|
|
231
231
|
const name = `autoresearch/${slugify(config?.name ?? goal, 30)}-${branchStamp()}`
|
|
232
|
-
const
|
|
233
|
-
|
|
232
|
+
const directory = git.loopWorktreePath(await git.repoName(pi, ctx.cwd), name)
|
|
233
|
+
const created = await git.worktreeAddBranch(pi, ctx.cwd, directory, name, 'HEAD')
|
|
234
|
+
if (created.code !== 0) return `Could not create branch ${name}: ${created.output}`
|
|
235
|
+
return `Autoresearch runs in its own worktree: ${directory}. This checkout stays on ${branch}. Start pi in that directory and run /autoresearch there.`
|
|
234
236
|
}
|
|
235
237
|
|
|
236
238
|
const activate = async (ctx: ExtensionCommandContext, text: string): Promise<void> => {
|
package/src/session.ts
CHANGED
|
@@ -29,9 +29,7 @@ const isLockSchema = new Validator(LockSchema).isValidObject
|
|
|
29
29
|
|
|
30
30
|
export type SessionLock = Infer<typeof LockSchema>
|
|
31
31
|
|
|
32
|
-
export type LockResult =
|
|
33
|
-
| { kind: 'ok'; lock: SessionLock }
|
|
34
|
-
| { kind: 'other'; lock: SessionLock }
|
|
32
|
+
export type LockResult = { kind: 'ok'; lock: SessionLock } | { kind: 'other'; lock: SessionLock }
|
|
35
33
|
|
|
36
34
|
export function researchPath(cwd: string, file: string): string {
|
|
37
35
|
return path.join(cwd, RESEARCH_DIRECTORY, file)
|
|
@@ -143,12 +141,18 @@ function writeLock(cwd: string, lock: SessionLock): void {
|
|
|
143
141
|
fs.renameSync(temporary, target)
|
|
144
142
|
}
|
|
145
143
|
|
|
146
|
-
|
|
147
|
-
cwd
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
144
|
+
function createLock(cwd: string, lock: SessionLock): boolean {
|
|
145
|
+
const directory = path.join(cwd, RESEARCH_DIRECTORY)
|
|
146
|
+
fs.mkdirSync(directory, { recursive: true })
|
|
147
|
+
try {
|
|
148
|
+
fs.writeFileSync(lockPath(cwd), `${JSON.stringify(lock)}\n`, { flag: 'wx' })
|
|
149
|
+
return true
|
|
150
|
+
} catch {
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function acquireLock(cwd: string, sessionId: string, branch: string, dirty: readonly string[]): LockResult {
|
|
152
156
|
const existing = readLock(cwd)
|
|
153
157
|
if (existing !== null && existing.sessionId !== sessionId && pidAlive(existing.pid)) {
|
|
154
158
|
return { kind: 'other', lock: existing }
|
|
@@ -161,8 +165,18 @@ export function acquireLock(
|
|
|
161
165
|
startedAt: sameSession ? existing.startedAt : Date.now(),
|
|
162
166
|
dirty: sameSession ? existing.dirty : [...dirty]
|
|
163
167
|
}
|
|
164
|
-
|
|
165
|
-
|
|
168
|
+
if (sameSession) {
|
|
169
|
+
writeLock(cwd, lock)
|
|
170
|
+
return { kind: 'ok', lock }
|
|
171
|
+
}
|
|
172
|
+
if (createLock(cwd, lock)) return { kind: 'ok', lock }
|
|
173
|
+
const latest = readLock(cwd)
|
|
174
|
+
if (latest !== null && latest.sessionId !== sessionId && pidAlive(latest.pid)) {
|
|
175
|
+
return { kind: 'other', lock: latest }
|
|
176
|
+
}
|
|
177
|
+
fs.rmSync(lockPath(cwd), { force: true })
|
|
178
|
+
if (createLock(cwd, lock)) return { kind: 'ok', lock }
|
|
179
|
+
return { kind: 'other', lock: readLock(cwd) ?? lock }
|
|
166
180
|
}
|
|
167
181
|
|
|
168
182
|
export function releaseLock(cwd: string, sessionId: string): void {
|