pantheon-opencode 1.3.2 → 1.3.3
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 +50 -0
- package/opencode.json +4 -1
- package/package.json +4 -2
- package/scripts/cost.mjs +69 -0
- package/scripts/install/opencode.mjs +27 -9
- package/src/agents/zeus.md +76 -0
- package/src/pantheon/background-job-board.ts +95 -6
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +110 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +270 -0
- package/src/pantheon/delegation-notify.ts +238 -0
- package/src/pantheon/delegation.ts +388 -0
- package/src/pantheon/dispatch-guard.ts +102 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +297 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +259 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/todo-enforcer.ts +369 -0
- package/src/plugin.ts +272 -6
- package/src/plugins/pantheon-hooks.ts +17 -8
- package/src/plugins/tui/package.json +1 -1
- package/src/routing.yml +33 -2
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch Guard (Wave 4, PR #46) — auto-retry-on-empty for background
|
|
3
|
+
* delegations. Classifies a dispatch result and redispatch once when it
|
|
4
|
+
* came back empty.
|
|
5
|
+
*
|
|
6
|
+
* Failure signature classification:
|
|
7
|
+
* - 'content' — text content present, nothing to do.
|
|
8
|
+
* - 'empty-mode1' — NO content AND NO tokens: the dispatch returned
|
|
9
|
+
* nothing at all (no reply / early abort).
|
|
10
|
+
* - 'empty-mode2' — no text part but tokens present: reasoning happened
|
|
11
|
+
* but the text part was lost — the themis Wave-2
|
|
12
|
+
* failure signature that triggered this guard.
|
|
13
|
+
*
|
|
14
|
+
* Retry cap is HARD at 1 (never 2x). The guard is stateful per instance:
|
|
15
|
+
* one retry budget total. It is a PURE library — it does NOT intercept
|
|
16
|
+
* task() (opencode 1.18.x cannot intercept task completion via hooks, so
|
|
17
|
+
* integration is manual-orchestration; see zeus.md "Empty-Result Retry").
|
|
18
|
+
*
|
|
19
|
+
* @module dispatch-guard
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A dispatch result — the minimal shape needed to classify emptiness. */
|
|
23
|
+
export interface DispatchResultLike {
|
|
24
|
+
/** Text content of the result (task_result / report markdown). */
|
|
25
|
+
content?: string | null
|
|
26
|
+
/** Input tokens (present when the model actually reasoned). */
|
|
27
|
+
tokensInput?: number
|
|
28
|
+
/** Output tokens (present when the model actually reasoned). */
|
|
29
|
+
tokensOutput?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Classification of a dispatch result. */
|
|
33
|
+
export type DispatchClassification = 'content' | 'empty-mode1' | 'empty-mode2'
|
|
34
|
+
|
|
35
|
+
export interface DispatchGuardOptions {
|
|
36
|
+
/** Enable auto-retry on empty results. Default: true. */
|
|
37
|
+
retryOnEmpty?: boolean
|
|
38
|
+
/** Injectable logger (defaults to console.warn with a prefix). */
|
|
39
|
+
logger?: { warn: (message: string) => void }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DispatchGuard {
|
|
43
|
+
/** Classify a result: content, empty-mode1 (nothing), empty-mode2 (reasoning, no text). */
|
|
44
|
+
classifyResult(result: DispatchResultLike): DispatchClassification
|
|
45
|
+
/**
|
|
46
|
+
* Redispatch ONCE when the result is empty and retryOnEmpty is enabled.
|
|
47
|
+
* A retried result that is STILL empty is returned as-is — the cap of 1
|
|
48
|
+
* is never exceeded. Never throws.
|
|
49
|
+
*/
|
|
50
|
+
maybeRetry(
|
|
51
|
+
result: DispatchResultLike,
|
|
52
|
+
redispatch: () => Promise<DispatchResultLike>,
|
|
53
|
+
): Promise<{
|
|
54
|
+
classification: DispatchClassification
|
|
55
|
+
retried: boolean
|
|
56
|
+
result: DispatchResultLike
|
|
57
|
+
}>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 1-based retry budget — the hard cap. Never 2x. */
|
|
61
|
+
const MAX_RETRIES = 1
|
|
62
|
+
|
|
63
|
+
/** Whether a result carries any text content (whitespace-only counts as empty). */
|
|
64
|
+
function hasContent(result: DispatchResultLike): boolean {
|
|
65
|
+
return typeof result.content === 'string' && result.content.trim() !== ''
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether the result carries any token counts (i.e. the model actually ran). */
|
|
69
|
+
function hasTokens(result: DispatchResultLike): boolean {
|
|
70
|
+
return (result.tokensInput ?? 0) > 0 || (result.tokensOutput ?? 0) > 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createDispatchGuard(options?: DispatchGuardOptions): DispatchGuard {
|
|
74
|
+
const retryOnEmpty = options?.retryOnEmpty ?? true
|
|
75
|
+
const warn =
|
|
76
|
+
options?.logger?.warn ??
|
|
77
|
+
((message: string) => console.warn(`[pantheon-dispatch-guard] ${message}`))
|
|
78
|
+
// Per-instance retry budget — the "cap 1" enforcement point.
|
|
79
|
+
let retriesUsed = 0
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
classifyResult(result: DispatchResultLike): DispatchClassification {
|
|
83
|
+
if (hasContent(result)) return 'content'
|
|
84
|
+
return hasTokens(result) ? 'empty-mode2' : 'empty-mode1'
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async maybeRetry(result, redispatch) {
|
|
88
|
+
const classification = this.classifyResult(result)
|
|
89
|
+
if (!retryOnEmpty || classification === 'content' || retriesUsed >= MAX_RETRIES) {
|
|
90
|
+
return { classification, retried: false, result }
|
|
91
|
+
}
|
|
92
|
+
retriesUsed += 1
|
|
93
|
+
warn(`dispatch returned ${classification}; retrying once (cap ${MAX_RETRIES})`)
|
|
94
|
+
const retriedResult = await redispatch()
|
|
95
|
+
return {
|
|
96
|
+
classification: this.classifyResult(retriedResult),
|
|
97
|
+
retried: true,
|
|
98
|
+
result: retriedResult,
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -23,8 +23,9 @@ const DEFAULT_STATE_PATH = '.pantheon/board/state.json'
|
|
|
23
23
|
* Writes are atomic: content is written to a `.tmp` file, then renamed.
|
|
24
24
|
*
|
|
25
25
|
* The board's in-memory `this.jobs` Map is the source of truth —
|
|
26
|
-
* this file is a crash-recovery snapshot
|
|
27
|
-
*
|
|
26
|
+
* this file is a crash-recovery snapshot. `deleteJob()` removes the
|
|
27
|
+
* record from the snapshot so pruned/reconciled jobs don't resurface
|
|
28
|
+
* after a restart.
|
|
28
29
|
*/
|
|
29
30
|
export class FilePersistenceAdapter implements PersistenceAdapter {
|
|
30
31
|
private readonly statePath: string
|
|
@@ -87,9 +88,12 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
|
|
|
87
88
|
return (await this.readState()) ?? []
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
async deleteJob(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
async deleteJob(taskID: string): Promise<void> {
|
|
92
|
+
const records = await this.readState()
|
|
93
|
+
if (!records) return
|
|
94
|
+
const idx = records.findIndex(r => r.taskID === taskID)
|
|
95
|
+
if (idx < 0) return
|
|
96
|
+
records.splice(idx, 1)
|
|
97
|
+
await this.writeState(records)
|
|
94
98
|
}
|
|
95
99
|
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-auto Goal Loop (Wave 3, PR #46) — one active goal per session,
|
|
3
|
+
* continued automatically while idle. `pantheon_goal_create` starts a goal;
|
|
4
|
+
* `pantheon_goal_update` moves it pending → in_progress → done; while
|
|
5
|
+
* active, `GoalLoop.onIdle` re-injects the continuation prompt (objective
|
|
6
|
+
* restated from the store) until marked done. Council-approved cut: NO LLM
|
|
7
|
+
* completion-audit prompt — the state machine is the source of truth.
|
|
8
|
+
*
|
|
9
|
+
* Persistence: FILE-based GoalStore (`.pantheon/goals/<sessionID>.json`,
|
|
10
|
+
* atomic tmp+rename). Opt-in `full_auto.enabled: false` default. Guards:
|
|
11
|
+
* board-running skip, in-flight Set, cooldown, max_continuations cap (25).
|
|
12
|
+
* Injectable now() clock. Pure TypeScript — zero runtime deps.
|
|
13
|
+
*
|
|
14
|
+
* @module goal-loop
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
|
|
19
|
+
import type { BackgroundJobBoard } from './background-job-board.ts'
|
|
20
|
+
import type { ToolContextLike } from './delegation.ts'
|
|
21
|
+
import { GoalStore } from './goal-store.ts'
|
|
22
|
+
import type { TodoEnforcerMessage } from './todo-enforcer.ts'
|
|
23
|
+
|
|
24
|
+
export type { Goal, GoalStatus } from './goal-store.ts'
|
|
25
|
+
export { GoalStore }
|
|
26
|
+
|
|
27
|
+
// ─── Constants ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Continuation text injected into idle sessions. `{objective}` is substituted. */
|
|
30
|
+
export const GOAL_CONTINUATION_PROMPT =
|
|
31
|
+
"Active goal: {objective}. Continue working toward it. Do not stop until you update the goal to done. If you believe it's complete, call pantheon_goal_update with status done."
|
|
32
|
+
|
|
33
|
+
/** Defaults matching routing.yml `full_auto`. */
|
|
34
|
+
export const GOAL_LOOP_DEFAULTS = {
|
|
35
|
+
enabled: false,
|
|
36
|
+
cooldownMs: 5000,
|
|
37
|
+
maxContinuations: 25,
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Structural client surface — the subset of the SDK the loop uses. */
|
|
43
|
+
export interface GoalLoopClient {
|
|
44
|
+
session: {
|
|
45
|
+
messages(input: { path: { id: string } }): Promise<TodoEnforcerMessage[]>
|
|
46
|
+
promptAsync(input: {
|
|
47
|
+
path: { id: string }
|
|
48
|
+
body: {
|
|
49
|
+
agent?: string
|
|
50
|
+
model?: { providerID: string; modelID: string }
|
|
51
|
+
parts: Array<{ type: 'text'; text: string }>
|
|
52
|
+
}
|
|
53
|
+
}): Promise<unknown>
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Tunables (all optional — see GOAL_LOOP_DEFAULTS). */
|
|
58
|
+
export interface GoalLoopOptions {
|
|
59
|
+
enabled?: boolean
|
|
60
|
+
/** Minimum gap between continuations (ms). */
|
|
61
|
+
cooldownMs?: number
|
|
62
|
+
/** Hard cap on continuations per goal — after this the loop halts. */
|
|
63
|
+
maxContinuations?: number
|
|
64
|
+
/** Injectable clock (testable), defaults to `Date.now`. */
|
|
65
|
+
now?: () => number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Dependencies threaded to the loop (structural board — list only). */
|
|
69
|
+
export interface GoalLoopDeps {
|
|
70
|
+
store: GoalStore
|
|
71
|
+
client: GoalLoopClient
|
|
72
|
+
board: Pick<BackgroundJobBoard, 'list'>
|
|
73
|
+
options?: GoalLoopOptions
|
|
74
|
+
logger?: { warn: (message: string) => void }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type InheritedContext = {
|
|
78
|
+
agent?: string
|
|
79
|
+
model?: { providerID: string; modelID: string }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Render a tool failure as error text (tools return errors as TEXT). */
|
|
83
|
+
function toolFailure(prefix: string, err: unknown): string {
|
|
84
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
85
|
+
return `${prefix} failed: ${reason}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Args schemas ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const goalCreateArgs = {
|
|
91
|
+
objective: z.string().min(1).describe('The goal objective the session keeps working toward.'),
|
|
92
|
+
} satisfies z.ZodRawShape
|
|
93
|
+
|
|
94
|
+
const goalUpdateArgs = {
|
|
95
|
+
status: z.enum(['pending', 'in_progress', 'done']).optional().describe('New goal status.'),
|
|
96
|
+
objective: z.string().min(1).optional().describe('New goal objective.'),
|
|
97
|
+
} satisfies z.ZodRawShape
|
|
98
|
+
|
|
99
|
+
const goalGetArgs = {} satisfies z.ZodRawShape
|
|
100
|
+
|
|
101
|
+
/** One structural goal tool: description + zod args shape + execute (delegation.ts shape). */
|
|
102
|
+
export type GoalTool<Args extends z.ZodRawShape> = {
|
|
103
|
+
description: string
|
|
104
|
+
args: Args
|
|
105
|
+
execute(args: z.infer<z.ZodObject<Args>>, ctx: ToolContextLike): Promise<string>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The three goal tools. */
|
|
109
|
+
export interface GoalToolset {
|
|
110
|
+
pantheon_goal_create: GoalTool<typeof goalCreateArgs>
|
|
111
|
+
pantheon_goal_update: GoalTool<typeof goalUpdateArgs>
|
|
112
|
+
pantheon_goal_get: GoalTool<typeof goalGetArgs>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── GoalLoop ──────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Re-inject a continuation prompt into idle sessions with an active goal.
|
|
119
|
+
* Per-session state: last-continuation timestamp (cooldown) and an in-flight
|
|
120
|
+
* set (never concurrent). `onIdle` never throws — the event hook can never
|
|
121
|
+
* break the session.
|
|
122
|
+
*/
|
|
123
|
+
export class GoalLoop {
|
|
124
|
+
private readonly store: GoalStore
|
|
125
|
+
private readonly client: GoalLoopClient
|
|
126
|
+
private readonly board: Pick<BackgroundJobBoard, 'list'>
|
|
127
|
+
private readonly options: Required<GoalLoopOptions>
|
|
128
|
+
private readonly warn: (message: string) => void
|
|
129
|
+
|
|
130
|
+
private readonly inFlight = new Set<string>()
|
|
131
|
+
private readonly lastContinuationAt = new Map<string, number>()
|
|
132
|
+
|
|
133
|
+
constructor(deps: GoalLoopDeps) {
|
|
134
|
+
this.store = deps.store
|
|
135
|
+
this.client = deps.client
|
|
136
|
+
this.board = deps.board
|
|
137
|
+
const opts = deps.options ?? {}
|
|
138
|
+
this.options = {
|
|
139
|
+
enabled: opts.enabled ?? GOAL_LOOP_DEFAULTS.enabled,
|
|
140
|
+
cooldownMs: opts.cooldownMs ?? GOAL_LOOP_DEFAULTS.cooldownMs,
|
|
141
|
+
maxContinuations: opts.maxContinuations ?? GOAL_LOOP_DEFAULTS.maxContinuations,
|
|
142
|
+
now: opts.now ?? Date.now,
|
|
143
|
+
}
|
|
144
|
+
this.warn =
|
|
145
|
+
deps.logger?.warn ?? ((message: string) => console.warn(`[pantheon-goal] ${message}`))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** True when the session has an active (non-done) goal. Used by the idle dispatcher. */
|
|
149
|
+
async hasActiveGoal(sessionID: string): Promise<boolean> {
|
|
150
|
+
return this.store.hasActive(sessionID)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Handle a `session.idle` event for a session with an active goal. Never throws. */
|
|
154
|
+
async onIdle(sessionID: string): Promise<void> {
|
|
155
|
+
if (!this.options.enabled) return
|
|
156
|
+
if (this.inFlight.has(sessionID)) return
|
|
157
|
+
this.inFlight.add(sessionID)
|
|
158
|
+
try {
|
|
159
|
+
await this.handleIdle(sessionID)
|
|
160
|
+
} catch (err: unknown) {
|
|
161
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
162
|
+
this.warn(`goal continuation failed for session ${sessionID}: ${reason}`)
|
|
163
|
+
} finally {
|
|
164
|
+
this.inFlight.delete(sessionID)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async handleIdle(sessionID: string): Promise<void> {
|
|
169
|
+
const goal = await this.store.load(sessionID)
|
|
170
|
+
// DONE (or absent) goal → nothing to continue.
|
|
171
|
+
if (goal === undefined || goal.status === 'done') return
|
|
172
|
+
|
|
173
|
+
// A session with a running background job is busy — skip.
|
|
174
|
+
if (this.board.list(sessionID).some((job) => job.state === 'running')) return
|
|
175
|
+
|
|
176
|
+
// Hard cap: stop the loop once the cap is reached.
|
|
177
|
+
if (goal.continuationCount >= this.options.maxContinuations) return
|
|
178
|
+
|
|
179
|
+
const now = this.options.now()
|
|
180
|
+
const last = this.lastContinuationAt.get(sessionID)
|
|
181
|
+
if (last !== undefined && now - last < this.options.cooldownMs) return
|
|
182
|
+
|
|
183
|
+
// Timestamp BEFORE the call — a failed injection still applies the
|
|
184
|
+
// cooldown (no hot retry loop).
|
|
185
|
+
this.lastContinuationAt.set(sessionID, now)
|
|
186
|
+
const inherited = await this.inheritAgentOrModel(sessionID)
|
|
187
|
+
const prompt = GOAL_CONTINUATION_PROMPT.replace('{objective}', goal.objective)
|
|
188
|
+
await this.client.session.promptAsync({
|
|
189
|
+
path: { id: sessionID },
|
|
190
|
+
body: {
|
|
191
|
+
...(inherited.agent !== undefined ? { agent: inherited.agent } : {}),
|
|
192
|
+
...(inherited.model !== undefined ? { model: inherited.model } : {}),
|
|
193
|
+
parts: [{ type: 'text', text: prompt }],
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
goal.continuationCount += 1
|
|
197
|
+
goal.updatedAt = now
|
|
198
|
+
await this.store.save(goal)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Inherit the agent (or model) from the LAST assistant message so the
|
|
203
|
+
* continuation runs in the same context as the session's last turn.
|
|
204
|
+
*/
|
|
205
|
+
private async inheritAgentOrModel(sessionID: string): Promise<InheritedContext> {
|
|
206
|
+
const messages = await this.client.session.messages({ path: { id: sessionID } })
|
|
207
|
+
for (const msg of [...messages].reverse()) {
|
|
208
|
+
const info = msg.info
|
|
209
|
+
if (info === undefined || info.role !== 'assistant') continue
|
|
210
|
+
if (typeof info.agent === 'string' && info.agent !== '') {
|
|
211
|
+
return { agent: info.agent }
|
|
212
|
+
}
|
|
213
|
+
const providerID = info.model?.providerID ?? info.providerID
|
|
214
|
+
const modelID = info.model?.modelID ?? info.modelID
|
|
215
|
+
if (
|
|
216
|
+
typeof providerID === 'string' &&
|
|
217
|
+
providerID !== '' &&
|
|
218
|
+
typeof modelID === 'string' &&
|
|
219
|
+
modelID !== ''
|
|
220
|
+
) {
|
|
221
|
+
return { model: { providerID, modelID } }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Build the three structural goal tools (single active goal per session). */
|
|
228
|
+
tools(): GoalToolset {
|
|
229
|
+
return {
|
|
230
|
+
pantheon_goal_create: {
|
|
231
|
+
description:
|
|
232
|
+
'Create the single active goal for this session. The goal loop then continues the session automatically on idle until the goal is marked done.',
|
|
233
|
+
args: goalCreateArgs,
|
|
234
|
+
execute: async (args, ctx) => {
|
|
235
|
+
try {
|
|
236
|
+
this.store.sanitizeSessionID(ctx.sessionID)
|
|
237
|
+
const existing = await this.store.load(ctx.sessionID)
|
|
238
|
+
if (existing !== undefined && existing.status !== 'done') {
|
|
239
|
+
return `pantheon_goal_create failed: session ${ctx.sessionID} already has an active goal`
|
|
240
|
+
}
|
|
241
|
+
const now = this.options.now()
|
|
242
|
+
await this.store.save({
|
|
243
|
+
id: ctx.sessionID,
|
|
244
|
+
sessionID: ctx.sessionID,
|
|
245
|
+
objective: args.objective,
|
|
246
|
+
status: 'pending',
|
|
247
|
+
createdAt: now,
|
|
248
|
+
updatedAt: now,
|
|
249
|
+
continuationCount: 0,
|
|
250
|
+
})
|
|
251
|
+
return `Goal created (${ctx.sessionID}): ${args.objective}`
|
|
252
|
+
} catch (err: unknown) {
|
|
253
|
+
return toolFailure('pantheon_goal_create', err)
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
pantheon_goal_update: {
|
|
258
|
+
description:
|
|
259
|
+
'Update the active goal: set status (pending/in_progress/done) and/or rewrite the objective. Setting status to done stops all further continuations.',
|
|
260
|
+
args: goalUpdateArgs,
|
|
261
|
+
execute: async (args, ctx) => {
|
|
262
|
+
try {
|
|
263
|
+
const goal = await this.store.load(ctx.sessionID)
|
|
264
|
+
if (goal === undefined) {
|
|
265
|
+
return `pantheon_goal_update failed: no active goal for session ${ctx.sessionID}`
|
|
266
|
+
}
|
|
267
|
+
if (args.status === undefined && args.objective === undefined) {
|
|
268
|
+
return 'pantheon_goal_update failed: provide status and/or objective'
|
|
269
|
+
}
|
|
270
|
+
if (args.objective !== undefined) goal.objective = args.objective
|
|
271
|
+
if (args.status !== undefined) goal.status = args.status
|
|
272
|
+
goal.updatedAt = this.options.now()
|
|
273
|
+
await this.store.save(goal)
|
|
274
|
+
return `Goal updated: status=${goal.status}, objective=${goal.objective}`
|
|
275
|
+
} catch (err: unknown) {
|
|
276
|
+
return toolFailure('pantheon_goal_update', err)
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
pantheon_goal_get: {
|
|
281
|
+
description: 'Return the active goal for this session (status, objective, continuations).',
|
|
282
|
+
args: goalGetArgs,
|
|
283
|
+
execute: async (_args, ctx) => {
|
|
284
|
+
try {
|
|
285
|
+
const goal = await this.store.load(ctx.sessionID)
|
|
286
|
+
if (goal === undefined) {
|
|
287
|
+
return `No active goal for session ${ctx.sessionID}`
|
|
288
|
+
}
|
|
289
|
+
return `Goal (${goal.id}): status=${goal.status}, objective=${goal.objective}, continuations=${goal.continuationCount}`
|
|
290
|
+
} catch (err: unknown) {
|
|
291
|
+
return toolFailure('pantheon_goal_get', err)
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based goal persistence for the full-auto Goal Loop (Wave 3, PR #46).
|
|
3
|
+
*
|
|
4
|
+
* One JSON file per session (`.pantheon/goals/<sessionID>.json`), written
|
|
5
|
+
* atomically (tmp + rename) so a crash never leaves a partial goal. The
|
|
6
|
+
* plugin layer cannot reach MCP KV servers, so goals live on disk.
|
|
7
|
+
* Session IDs are sanitized before path construction (path-traversal guard,
|
|
8
|
+
* same pattern as assertSafeParentSessionID).
|
|
9
|
+
*
|
|
10
|
+
* Pure TypeScript — zero runtime deps beyond node:fs.
|
|
11
|
+
*
|
|
12
|
+
* @module goal-store
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { assertSafeParentSessionID } from './delegation-finalize.ts'
|
|
19
|
+
|
|
20
|
+
export type GoalStatus = 'pending' | 'in_progress' | 'done'
|
|
21
|
+
|
|
22
|
+
/** One goal persisted at `.pantheon/goals/<sessionID>.json`. */
|
|
23
|
+
export interface Goal {
|
|
24
|
+
id: string
|
|
25
|
+
sessionID: string
|
|
26
|
+
objective: string
|
|
27
|
+
status: GoalStatus
|
|
28
|
+
createdAt: number
|
|
29
|
+
updatedAt: number
|
|
30
|
+
continuationCount: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_GOAL_DIR = '.pantheon/goals'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* File-based goal persistence. `save` writes atomically (tmp + rename);
|
|
37
|
+
* `load` returns undefined when no goal exists yet.
|
|
38
|
+
*/
|
|
39
|
+
export class GoalStore {
|
|
40
|
+
private readonly dir: string
|
|
41
|
+
|
|
42
|
+
constructor(opts: { dir?: string } = {}) {
|
|
43
|
+
this.dir = opts.dir ?? DEFAULT_GOAL_DIR
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Path-traversal guard for session IDs embedded in file paths (same
|
|
48
|
+
* pattern as assertSafeParentSessionID). Throws on `..`, `/`, `\`.
|
|
49
|
+
*/
|
|
50
|
+
sanitizeSessionID(sessionID: string): string {
|
|
51
|
+
assertSafeParentSessionID(sessionID)
|
|
52
|
+
return sessionID
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private pathFor(sessionID: string): string {
|
|
56
|
+
return join(this.dir, `${this.sanitizeSessionID(sessionID)}.json`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Load the session's goal; undefined when none exists yet. */
|
|
60
|
+
async load(sessionID: string): Promise<Goal | undefined> {
|
|
61
|
+
const path = this.pathFor(sessionID)
|
|
62
|
+
try {
|
|
63
|
+
const content = await readFile(path, 'utf-8')
|
|
64
|
+
return JSON.parse(content) as Goal
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
if (
|
|
67
|
+
err instanceof Error &&
|
|
68
|
+
'code' in err &&
|
|
69
|
+
(err as NodeJS.ErrnoException).code === 'ENOENT'
|
|
70
|
+
) {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
throw err
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Persist a goal atomically (tmp + rename). */
|
|
78
|
+
async save(goal: Goal): Promise<void> {
|
|
79
|
+
const path = this.pathFor(goal.sessionID)
|
|
80
|
+
const tmpPath = `${path}.tmp`
|
|
81
|
+
await mkdir(this.dir, { recursive: true })
|
|
82
|
+
await writeFile(tmpPath, JSON.stringify(goal, null, 2), 'utf-8')
|
|
83
|
+
await rename(tmpPath, path)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** True when the session has a goal that is not `done`. */
|
|
87
|
+
async hasActive(sessionID: string): Promise<boolean> {
|
|
88
|
+
const goal = await this.load(sessionID)
|
|
89
|
+
return goal !== undefined && goal.status !== 'done'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** List the session's goals — at most one (YAGNI: single active goal). */
|
|
93
|
+
async list(sessionID: string): Promise<Goal[]> {
|
|
94
|
+
const goal = await this.load(sessionID)
|
|
95
|
+
return goal === undefined ? [] : [goal]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashline core (Wave 2, PR #46) — tagged-line formatting, ref parsing, and
|
|
3
|
+
* mismatch diagnostics shared by the read enhancer and the hashline_edit tool.
|
|
4
|
+
*
|
|
5
|
+
* A "ref" is the stable anchor used by hashline_edit: `{line}#{tag}` where
|
|
6
|
+
* the tag is the 2-char sha256 hash of the line content. Ref format:
|
|
7
|
+
* ^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$
|
|
8
|
+
*
|
|
9
|
+
* @module hashline/core
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { HASHLINE_DICT, hashTag } from './xxhash.ts'
|
|
13
|
+
|
|
14
|
+
/** A ref string: line number + 2-char tag (e.g. "12#XJ"). */
|
|
15
|
+
export const HASHLINE_REF_RE = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
|
|
16
|
+
|
|
17
|
+
/** Matches an already-tagged line (`12#XJ|content`) — idempotency guard. */
|
|
18
|
+
const TAGGED_LINE_RE = new RegExp(`^\\s*[0-9]+#[${HASHLINE_DICT}]{2}\\|`)
|
|
19
|
+
|
|
20
|
+
/** A resolved line match used in mismatch diagnostics. */
|
|
21
|
+
export interface LineMatch {
|
|
22
|
+
/** 1-based line number. */
|
|
23
|
+
line: number
|
|
24
|
+
/** The tag ACTUALLY computed for the line content. */
|
|
25
|
+
tag: string
|
|
26
|
+
/** The raw line content (for the excerpt). */
|
|
27
|
+
content?: string | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format a line as a hashline-tagged line: `{lineNo}#{tag}|{content}`.
|
|
32
|
+
* This is the format the read enhancer emits and the edit tool consumes.
|
|
33
|
+
*/
|
|
34
|
+
export function formatTaggedLine(lineNo: number, content: string): string {
|
|
35
|
+
return `${lineNo}#${hashTag(content, lineNo)}|${content}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a line is already hashline-tagged (idempotency check). Used by the
|
|
40
|
+
* read enhancer to avoid re-tagging and by tests to verify the format.
|
|
41
|
+
*/
|
|
42
|
+
export function isTaggedLine(line: string): boolean {
|
|
43
|
+
return TAGGED_LINE_RE.test(line)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the "Did you mean" suggestion for a mismatched ref: the corrected
|
|
48
|
+
* ref is the ACTUAL line number + ACTUAL tag, e.g. `Did you mean "12#XJ"?`.
|
|
49
|
+
*/
|
|
50
|
+
export function suggestLineForHash(lineNo: number, tag: string): string {
|
|
51
|
+
return `Did you mean "${lineNo}#${tag}"?`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the full mismatch error text for the hashline_edit tool (returned
|
|
56
|
+
* as error-as-text, never thrown, never partially written):
|
|
57
|
+
*
|
|
58
|
+
* hashline_edit: ref "12#QQ" does not match /path/file at line 12 (tag "XJ")
|
|
59
|
+
*
|
|
60
|
+
* >>>12#XJ|actual line content
|
|
61
|
+
* ... 13#AB|next line
|
|
62
|
+
*
|
|
63
|
+
* Did you mean "12#XJ"?
|
|
64
|
+
*
|
|
65
|
+
* The excerpt is a RE-TAGGED view of the original `lines` around the target
|
|
66
|
+
* line (±2 lines of context), with the mismatched line marked `>>>` and
|
|
67
|
+
* carrying its ACTUAL tag.
|
|
68
|
+
*/
|
|
69
|
+
export function buildMismatchError(
|
|
70
|
+
file: string,
|
|
71
|
+
ref: string,
|
|
72
|
+
actual: LineMatch,
|
|
73
|
+
lines: readonly string[],
|
|
74
|
+
): string {
|
|
75
|
+
const start = Math.max(1, actual.line - 2)
|
|
76
|
+
const end = Math.min(lines.length, actual.line + 2)
|
|
77
|
+
const excerpt: string[] = []
|
|
78
|
+
for (let i = start; i <= end; i++) {
|
|
79
|
+
const content = lines[i - 1] ?? ''
|
|
80
|
+
const tag = i === actual.line ? actual.tag : hashTag(content, i)
|
|
81
|
+
const marker = i === actual.line ? '>>>' : ' '
|
|
82
|
+
excerpt.push(`${marker}${i}#${tag}|${content}`)
|
|
83
|
+
}
|
|
84
|
+
return [
|
|
85
|
+
`hashline_edit: ref "${ref}" does not match ${file} at line ${actual.line} (tag "${actual.tag}")`,
|
|
86
|
+
'',
|
|
87
|
+
excerpt.join('\n'),
|
|
88
|
+
'',
|
|
89
|
+
suggestLineForHash(actual.line, actual.tag),
|
|
90
|
+
].join('\n')
|
|
91
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read enhancer (Wave 2, PR #46) — augments the `read` tool output with
|
|
3
|
+
* per-line hashline tags so the model can anchor hashline_edit refs.
|
|
4
|
+
*
|
|
5
|
+
* Wired as the plugin's `tool.execute.after` hook (purely additive — the
|
|
6
|
+
* plugin has no `tool.execute.after` yet; pantheon-hooks.ts is a SEPARATE
|
|
7
|
+
* plugin instance, so no key collision). Only `input.tool === 'read'` is
|
|
8
|
+
* touched; every other tool passes through byte-for-byte.
|
|
9
|
+
*
|
|
10
|
+
* Transform: lines matching `^\s*(\d+): ?(.*)$` or `^\s*(\d+)\| ?(.*)$` are
|
|
11
|
+
* rewritten as `{line}#{tag}|{content}`. Already-tagged lines are skipped
|
|
12
|
+
* (idempotent — safe across multiple hook invocations). Lines that do not
|
|
13
|
+
* carry a line-number prefix (truncation markers like `... 500 more lines`,
|
|
14
|
+
* bare text) are left untouched.
|
|
15
|
+
*
|
|
16
|
+
* @module hashline/read-enhancer
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { isTaggedLine } from './core.ts'
|
|
20
|
+
import { hashTag } from './xxhash.ts'
|
|
21
|
+
|
|
22
|
+
/** Input shape of the opencode `tool.execute.after` hook. */
|
|
23
|
+
export interface ToolExecuteAfterInput {
|
|
24
|
+
tool: string
|
|
25
|
+
sessionID: string
|
|
26
|
+
callID: string
|
|
27
|
+
args: unknown
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Output shape of the opencode `tool.execute.after` hook (mutable). */
|
|
31
|
+
export interface ToolExecuteAfterOutput {
|
|
32
|
+
title?: string
|
|
33
|
+
output: string
|
|
34
|
+
metadata?: Record<string, unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `tool.execute.after` handler shape. */
|
|
38
|
+
export type ToolExecuteAfterHandler = (
|
|
39
|
+
input: ToolExecuteAfterInput,
|
|
40
|
+
output: ToolExecuteAfterOutput,
|
|
41
|
+
) => Promise<void>
|
|
42
|
+
|
|
43
|
+
/** Read-output line prefixes: `N: content`, `N| content`, `N|content`. */
|
|
44
|
+
const LINE_PREFIX_RE = /^\s*([0-9]+): ?(.*)$|^\s*([0-9]+)\| ?(.*)$/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the read enhancer handler. Throws nothing — any unexpected state is
|
|
48
|
+
* a no-op so the hook can never break the tool result.
|
|
49
|
+
*/
|
|
50
|
+
export function createReadEnhancer(): ToolExecuteAfterHandler {
|
|
51
|
+
return async (input: ToolExecuteAfterInput, output: ToolExecuteAfterOutput): Promise<void> => {
|
|
52
|
+
if (input.tool !== 'read') return
|
|
53
|
+
if (typeof output.output !== 'string') return
|
|
54
|
+
|
|
55
|
+
const tagged = output.output.split('\n').map((line: string) => {
|
|
56
|
+
if (isTaggedLine(line)) return line
|
|
57
|
+
const m = LINE_PREFIX_RE.exec(line)
|
|
58
|
+
if (!m) return line
|
|
59
|
+
const lineNo = Number(m[1] ?? m[3])
|
|
60
|
+
const content = m[2] ?? m[4] ?? ''
|
|
61
|
+
return `${lineNo}#${hashTag(content, lineNo)}|${content}`
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
output.output = tagged.join('\n')
|
|
65
|
+
}
|
|
66
|
+
}
|