@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.
@@ -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
+ }