@tduyng/pi-goal 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 +108 -2
- package/package.json +44 -4
- package/src/check.ts +58 -0
- package/src/goal.ts +1222 -0
- package/src/loop/index.ts +198 -0
- package/src/loop/logic.ts +60 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { JsonValue } from '@earendil-works/pi-ai'
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
import { Validator } from 'ata-validator'
|
|
4
|
+
import { t } from 'ata-validator/t'
|
|
5
|
+
|
|
6
|
+
import { MAX_INTERVAL_MS, paceLabel, parseLoopArgs, type LoopState } from './logic.js'
|
|
7
|
+
|
|
8
|
+
const STATE_TYPE = 'loop'
|
|
9
|
+
const DEFAULT_PROMPT =
|
|
10
|
+
'Run a maintenance check: review recent work and address anything stale, broken, or left half-finished.'
|
|
11
|
+
const SELF_PACE_DELAY_MS = 250
|
|
12
|
+
const RecordSchema = t.object({}, { additionalProperties: true })
|
|
13
|
+
const isRecordSchema = new Validator(RecordSchema).isValidObject
|
|
14
|
+
const LoopStateSchema = t.object({
|
|
15
|
+
prompt: t.string(),
|
|
16
|
+
intervalMs: t.union([t.null(), t.number({ exclusiveMinimum: 0, maximum: MAX_INTERVAL_MS })]),
|
|
17
|
+
iterations: t.integer({ minimum: 0 }),
|
|
18
|
+
createdAt: t.number(),
|
|
19
|
+
lastFiredAt: t.union([t.null(), t.number()])
|
|
20
|
+
})
|
|
21
|
+
const isLoopStateSchema = new Validator(LoopStateSchema).isValidObject
|
|
22
|
+
|
|
23
|
+
function isJsonObject(value: unknown): value is Record<string, JsonValue> {
|
|
24
|
+
return isRecordSchema(value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseLoopState(value: JsonValue): LoopState | null {
|
|
28
|
+
return isLoopStateSchema(value) ? value : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function loopExtension(pi: ExtensionAPI): void {
|
|
32
|
+
let loop: LoopState | null = null
|
|
33
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
34
|
+
let lastError: string | undefined
|
|
35
|
+
|
|
36
|
+
function clearTimer(): void {
|
|
37
|
+
if (timer) {
|
|
38
|
+
clearTimeout(timer)
|
|
39
|
+
timer = null
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function persist(): void {
|
|
44
|
+
pi.appendEntry(STATE_TYPE, loop)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
48
|
+
if (!ctx.hasUI) return
|
|
49
|
+
if (!loop) {
|
|
50
|
+
ctx.ui.setStatus('loop', undefined)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const theme = ctx.ui.theme
|
|
54
|
+
ctx.ui.setStatus('loop', theme.fg('accent', `Loop ${paceLabel(loop.intervalMs)} (${loop.iterations})`))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function fire(ctx: ExtensionContext): void {
|
|
58
|
+
if (!loop) return
|
|
59
|
+
loop.iterations += 1
|
|
60
|
+
loop.lastFiredAt = Date.now()
|
|
61
|
+
persist()
|
|
62
|
+
updateStatus(ctx)
|
|
63
|
+
|
|
64
|
+
if (!ctx.hasPendingMessages()) {
|
|
65
|
+
pi.sendUserMessage(loop.prompt, ctx.isIdle() ? undefined : { deliverAs: 'followUp' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (loop.intervalMs !== null) {
|
|
69
|
+
clearTimer()
|
|
70
|
+
timer = setTimeout(() => fireSafely(ctx), loop.intervalMs)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fireSafely(ctx: ExtensionContext): void {
|
|
75
|
+
try {
|
|
76
|
+
fire(ctx)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
clearTimer()
|
|
79
|
+
loop = null
|
|
80
|
+
try {
|
|
81
|
+
persist()
|
|
82
|
+
updateStatus(ctx)
|
|
83
|
+
ctx.ui.notify(`Loop stopped: ${error instanceof Error ? error.message : String(error)}`, 'warning')
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function startLoop(ctx: ExtensionContext, prompt: string, intervalMs: number | null): void {
|
|
89
|
+
clearTimer()
|
|
90
|
+
const now = Date.now()
|
|
91
|
+
loop = { prompt: prompt || DEFAULT_PROMPT, intervalMs, iterations: 0, createdAt: now, lastFiredAt: null }
|
|
92
|
+
persist()
|
|
93
|
+
updateStatus(ctx)
|
|
94
|
+
ctx.ui.notify(`Loop started (${paceLabel(intervalMs)}): ${loop.prompt}`, 'info')
|
|
95
|
+
fire(ctx)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stopLoop(ctx: ExtensionContext): void {
|
|
99
|
+
if (!loop) {
|
|
100
|
+
ctx.ui.notify('No loop running', 'info')
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
clearTimer()
|
|
104
|
+
const stopped = loop
|
|
105
|
+
loop = null
|
|
106
|
+
persist()
|
|
107
|
+
updateStatus(ctx)
|
|
108
|
+
ctx.ui.notify(
|
|
109
|
+
`Loop stopped (${stopped.iterations} run${stopped.iterations === 1 ? '' : 's'}): ${stopped.prompt}`,
|
|
110
|
+
'info'
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function showStatus(ctx: ExtensionContext): void {
|
|
115
|
+
if (!loop) {
|
|
116
|
+
ctx.ui.notify('No loop running.\n\nUsage: /loop <interval> <prompt> | /loop <prompt> | /loop stop', 'info')
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
ctx.ui.notify(
|
|
120
|
+
`Loop (${paceLabel(loop.intervalMs)}): ${loop.prompt}\n${loop.iterations} run${loop.iterations === 1 ? '' : 's'}`,
|
|
121
|
+
'info'
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function reconstructState(ctx: ExtensionContext): void {
|
|
126
|
+
clearTimer()
|
|
127
|
+
loop = null
|
|
128
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
129
|
+
if (entry.type !== 'custom' || entry.customType !== STATE_TYPE) continue
|
|
130
|
+
const data = entry.data
|
|
131
|
+
loop = isJsonObject(data) ? parseLoopState(data) : null
|
|
132
|
+
}
|
|
133
|
+
updateStatus(ctx)
|
|
134
|
+
if (!loop || loop.intervalMs === null) return
|
|
135
|
+
|
|
136
|
+
const elapsed = Date.now() - (loop.lastFiredAt ?? loop.createdAt)
|
|
137
|
+
const remaining = Math.max(0, loop.intervalMs - elapsed)
|
|
138
|
+
timer = setTimeout(() => fireSafely(ctx), remaining)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
pi.on('session_start', async (_event, ctx) => reconstructState(ctx))
|
|
142
|
+
pi.on('session_tree', async (_event, ctx) => reconstructState(ctx))
|
|
143
|
+
|
|
144
|
+
pi.on('session_shutdown', async () => clearTimer())
|
|
145
|
+
|
|
146
|
+
pi.on('agent_end', async (event) => {
|
|
147
|
+
const assistant = event.messages.findLast((message) => message.role === 'assistant')
|
|
148
|
+
lastError =
|
|
149
|
+
assistant?.stopReason === 'error'
|
|
150
|
+
? (assistant.errorMessage ?? 'the last turn ended with an error')
|
|
151
|
+
: undefined
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
pi.on('agent_settled', async (event, ctx) => {
|
|
155
|
+
if (!loop) return
|
|
156
|
+
if (lastError !== undefined) {
|
|
157
|
+
const reason = lastError
|
|
158
|
+
lastError = undefined
|
|
159
|
+
clearTimer()
|
|
160
|
+
loop = null
|
|
161
|
+
persist()
|
|
162
|
+
updateStatus(ctx)
|
|
163
|
+
ctx.ui.notify(`Loop stopped: ${reason}`, 'warning')
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (loop.intervalMs !== null) return
|
|
167
|
+
if (event.aborted) return
|
|
168
|
+
clearTimer()
|
|
169
|
+
timer = setTimeout(() => fireSafely(ctx), SELF_PACE_DELAY_MS)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
pi.registerCommand('loop', {
|
|
173
|
+
description: 'Re-fire a prompt on an interval, or self-paced after each turn',
|
|
174
|
+
getArgumentCompletions: (prefix: string) => {
|
|
175
|
+
const items = [
|
|
176
|
+
{ value: 'stop', label: 'stop', description: 'stop the current loop' },
|
|
177
|
+
{ value: '5m', label: '5m', description: 'fire every 5 minutes' },
|
|
178
|
+
{ value: '1h', label: '1h', description: 'fire every hour' }
|
|
179
|
+
]
|
|
180
|
+
const filtered = items.filter((item) => item.value.startsWith(prefix.trimStart()))
|
|
181
|
+
return filtered.length > 0 ? filtered : null
|
|
182
|
+
},
|
|
183
|
+
handler: async (args, ctx) => {
|
|
184
|
+
const parsed = parseLoopArgs(args)
|
|
185
|
+
switch (parsed.kind) {
|
|
186
|
+
case 'status':
|
|
187
|
+
showStatus(ctx)
|
|
188
|
+
return
|
|
189
|
+
case 'stop':
|
|
190
|
+
stopLoop(ctx)
|
|
191
|
+
return
|
|
192
|
+
case 'start':
|
|
193
|
+
startLoop(ctx, parsed.prompt, parsed.intervalMs)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type LoopState = {
|
|
2
|
+
prompt: string
|
|
3
|
+
intervalMs: number | null
|
|
4
|
+
iterations: number
|
|
5
|
+
createdAt: number
|
|
6
|
+
lastFiredAt: number | null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ParsedLoopArgs =
|
|
10
|
+
| { kind: 'stop' }
|
|
11
|
+
| { kind: 'status' }
|
|
12
|
+
| { kind: 'start'; prompt: string; intervalMs: number | null }
|
|
13
|
+
|
|
14
|
+
const INTERVAL_PATTERN = /^(\d+)(ms|s|m|h|d)$/i
|
|
15
|
+
const MS_PER_UNIT = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 } satisfies Record<string, number>
|
|
16
|
+
export const MAX_INTERVAL_MS = 2_147_483_647
|
|
17
|
+
export const MIN_INTERVAL_MS = 10_000
|
|
18
|
+
const STOP_WORDS = new Set(['stop', 'cancel', 'clear', 'off'])
|
|
19
|
+
|
|
20
|
+
export function parseInterval(token: string): number | null {
|
|
21
|
+
const match = INTERVAL_PATTERN.exec(token)
|
|
22
|
+
if (!match) return null
|
|
23
|
+
const amount = parseInt(match[1] ?? '', 10)
|
|
24
|
+
const unit = (match[2] ?? '').toLowerCase()
|
|
25
|
+
const msPerUnit = MS_PER_UNIT[unit as keyof typeof MS_PER_UNIT]
|
|
26
|
+
if (!Number.isFinite(amount) || amount <= 0 || msPerUnit === undefined) return null
|
|
27
|
+
return Math.min(Math.max(amount * msPerUnit, MIN_INTERVAL_MS), MAX_INTERVAL_MS)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseLoopArgs(input: string): ParsedLoopArgs {
|
|
31
|
+
const trimmed = input.trim()
|
|
32
|
+
if (!trimmed) return { kind: 'status' }
|
|
33
|
+
if (STOP_WORDS.has(trimmed.toLowerCase())) return { kind: 'stop' }
|
|
34
|
+
|
|
35
|
+
const spaceIndex = trimmed.indexOf(' ')
|
|
36
|
+
const firstToken = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex)
|
|
37
|
+
const intervalMs = parseInterval(firstToken)
|
|
38
|
+
if (intervalMs === null) return { kind: 'start', prompt: trimmed, intervalMs: null }
|
|
39
|
+
|
|
40
|
+
const prompt = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim()
|
|
41
|
+
return { kind: 'start', prompt, intervalMs }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function paceLabel(intervalMs: number | null): string {
|
|
45
|
+
if (intervalMs === null) return 'self-paced'
|
|
46
|
+
return `every ${formatDuration(intervalMs)}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatDuration(ms: number): string {
|
|
50
|
+
if (ms < 1_000) return `${ms}ms`
|
|
51
|
+
const totalSeconds = Math.round(ms / 1_000)
|
|
52
|
+
const days = Math.floor(totalSeconds / 86_400)
|
|
53
|
+
const hours = Math.floor((totalSeconds % 86_400) / 3_600)
|
|
54
|
+
const minutes = Math.floor((totalSeconds % 3_600) / 60)
|
|
55
|
+
const seconds = totalSeconds % 60
|
|
56
|
+
if (days > 0) return `${days}d ${hours}h`
|
|
57
|
+
if (hours > 0) return `${hours}h ${minutes}m`
|
|
58
|
+
if (minutes > 0) return `${minutes}m`
|
|
59
|
+
return `${seconds}s`
|
|
60
|
+
}
|