@prevalentware/opencode-goal-plugin 0.1.5 → 0.1.7
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 -3
- package/dist/server.js +8 -3
- package/package.json +1 -1
- package/src/tui.tsx +100 -13
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Server options can be configured in `opencode.json`:
|
|
|
61
61
|
"auto_continue": true,
|
|
62
62
|
"max_auto_turns": 25,
|
|
63
63
|
"min_continue_interval_seconds": 3,
|
|
64
|
-
"default_token_budget":
|
|
64
|
+
"default_token_budget": 1000000
|
|
65
65
|
}
|
|
66
66
|
]
|
|
67
67
|
]
|
|
@@ -75,7 +75,7 @@ Defaults:
|
|
|
75
75
|
- `min_continue_interval_seconds`: `3`
|
|
76
76
|
- `register_command`: `true`
|
|
77
77
|
- `command_name`: `"goal"`
|
|
78
|
-
- `default_token_budget`: `
|
|
78
|
+
- `default_token_budget`: `1000000`
|
|
79
79
|
|
|
80
80
|
## Goal Workflow
|
|
81
81
|
|
|
@@ -87,7 +87,7 @@ Use `/goal <objective>` in a fresh OpenCode chat to create a long-running goal:
|
|
|
87
87
|
|
|
88
88
|
Bare `/goal` reports the current goal state. `/goal clear` clears the goal. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, or clearing the current goal state without creating a new goal.
|
|
89
89
|
|
|
90
|
-
By default, `/goal <objective>`
|
|
90
|
+
By default, `/goal <objective>` creates the goal with `token_budget: 1000000`. To omit the budget, set `default_token_budget` to `null`. To use a different fixed budget without prompting the user, set `default_token_budget` to another positive integer in `opencode.json`.
|
|
91
91
|
|
|
92
92
|
When writing the objective, include the scope, non-goals, and verification path when they matter. The agent is reminded to audit real files, command output, tests, or PR state before closing the goal.
|
|
93
93
|
|
package/dist/server.js
CHANGED
|
@@ -72,7 +72,8 @@ function isClosed(status) {
|
|
|
72
72
|
return status === "complete" || status === "unmet";
|
|
73
73
|
}
|
|
74
74
|
function snapshot(goal) {
|
|
75
|
-
const
|
|
75
|
+
const sampledAt = nowSeconds();
|
|
76
|
+
const activeSeconds = goal.status === "active" && goal.lastAccountedAt != null ? Math.max(0, sampledAt - goal.lastAccountedAt) : 0;
|
|
76
77
|
const timeUsedSeconds = goal.timeUsedSeconds + activeSeconds;
|
|
77
78
|
return {
|
|
78
79
|
sessionID: goal.sessionID,
|
|
@@ -86,7 +87,8 @@ function snapshot(goal) {
|
|
|
86
87
|
completionEvidence: goal.completionEvidence ?? null,
|
|
87
88
|
blocker: goal.blocker ?? null,
|
|
88
89
|
closedAt: goal.closedAt ?? null,
|
|
89
|
-
remainingTokens: goal.tokenBudget == null ? null : Math.max(0, goal.tokenBudget - goal.tokensUsed)
|
|
90
|
+
remainingTokens: goal.tokenBudget == null ? null : Math.max(0, goal.tokenBudget - goal.tokensUsed),
|
|
91
|
+
sampledAt
|
|
90
92
|
};
|
|
91
93
|
}
|
|
92
94
|
async function getGoal(sessionID) {
|
|
@@ -298,10 +300,13 @@ Preserve the goal objective, status, budget, elapsed time, token count, and any
|
|
|
298
300
|
var DEFAULT_MAX_AUTO_TURNS = 25;
|
|
299
301
|
var DEFAULT_CONTINUE_INTERVAL_SECONDS = 3;
|
|
300
302
|
var DEFAULT_COMMAND_NAME = "goal";
|
|
303
|
+
var DEFAULT_TOKEN_BUDGET = 1e6;
|
|
301
304
|
function defaultTokenBudgetFromOptions(options) {
|
|
302
305
|
const budget = options?.default_token_budget;
|
|
303
|
-
if (budget
|
|
306
|
+
if (budget === null)
|
|
304
307
|
return null;
|
|
308
|
+
if (budget === undefined)
|
|
309
|
+
return DEFAULT_TOKEN_BUDGET;
|
|
305
310
|
return Number.isInteger(budget) && budget > 0 ? budget : null;
|
|
306
311
|
}
|
|
307
312
|
function goalCommandTemplate(commandName, defaultTokenBudget) {
|
package/package.json
CHANGED
package/src/tui.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
-
import { createMemo, Show } from "solid-js"
|
|
3
|
+
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
|
4
4
|
|
|
5
5
|
type GoalSnapshot = {
|
|
6
6
|
sessionID: string
|
|
@@ -15,15 +15,30 @@ type GoalSnapshot = {
|
|
|
15
15
|
blocker?: string | null
|
|
16
16
|
closedAt?: number | null
|
|
17
17
|
remainingTokens: number | null
|
|
18
|
+
sampledAt?: number
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
type GoalToolPart = {
|
|
21
22
|
type: string
|
|
23
|
+
text?: string
|
|
24
|
+
content?: string
|
|
22
25
|
tool?: string
|
|
23
26
|
state?: {
|
|
24
27
|
status?: string
|
|
25
28
|
output?: string
|
|
26
29
|
}
|
|
30
|
+
tokens?: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SessionMessage = {
|
|
34
|
+
id: string
|
|
35
|
+
info?: unknown
|
|
36
|
+
tokens?: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type GoalSessionState = {
|
|
40
|
+
goal: GoalSnapshot | null
|
|
41
|
+
messageIndex: number
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
function currentSessionID(api: TuiPluginApi) {
|
|
@@ -125,6 +140,10 @@ function compactNumber(value: number) {
|
|
|
125
140
|
return String(value)
|
|
126
141
|
}
|
|
127
142
|
|
|
143
|
+
function nowSeconds() {
|
|
144
|
+
return Math.floor(Date.now() / 1000)
|
|
145
|
+
}
|
|
146
|
+
|
|
128
147
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
129
148
|
return typeof value === "object" && value !== null
|
|
130
149
|
}
|
|
@@ -143,6 +162,7 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
|
|
|
143
162
|
if (value.blocker != null && typeof value.blocker !== "string") return false
|
|
144
163
|
if (value.closedAt != null && typeof value.closedAt !== "number") return false
|
|
145
164
|
if (value.remainingTokens !== null && typeof value.remainingTokens !== "number") return false
|
|
165
|
+
if (value.sampledAt != null && typeof value.sampledAt !== "number") return false
|
|
146
166
|
return true
|
|
147
167
|
}
|
|
148
168
|
|
|
@@ -163,16 +183,67 @@ function parseGoalToolOutput(part: GoalToolPart): GoalSnapshot | null | undefine
|
|
|
163
183
|
}
|
|
164
184
|
}
|
|
165
185
|
|
|
166
|
-
function
|
|
167
|
-
const messages = [...api.state.session.messages(sessionID)]
|
|
168
|
-
for (
|
|
186
|
+
function goalStateFromSession(api: TuiPluginApi, sessionID: string): GoalSessionState {
|
|
187
|
+
const messages = [...api.state.session.messages(sessionID)] as SessionMessage[]
|
|
188
|
+
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
|
189
|
+
const message = messages[messageIndex]
|
|
190
|
+
if (!message) continue
|
|
169
191
|
const parts = [...api.state.part(message.id)].reverse() as GoalToolPart[]
|
|
170
192
|
for (const part of parts) {
|
|
171
193
|
const goal = parseGoalToolOutput(part)
|
|
172
|
-
if (goal !== undefined) return goal
|
|
194
|
+
if (goal !== undefined) return { goal, messageIndex }
|
|
173
195
|
}
|
|
174
196
|
}
|
|
175
|
-
return null
|
|
197
|
+
return { goal: null, messageIndex: -1 }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function goalFromSession(api: TuiPluginApi, sessionID: string) {
|
|
201
|
+
return goalStateFromSession(api, sessionID).goal
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function tokensFromRecord(value: unknown): number | undefined {
|
|
205
|
+
if (!isRecord(value)) return undefined
|
|
206
|
+
if (typeof value.total === "number" && Number.isFinite(value.total)) return value.total
|
|
207
|
+
const cache = isRecord(value.cache) ? value.cache : {}
|
|
208
|
+
const fields = [value.input, value.output, value.reasoning, cache.read, cache.write]
|
|
209
|
+
if (!fields.some((field) => typeof field === "number" && Number.isFinite(field))) return undefined
|
|
210
|
+
return fields.reduce<number>((sum, field) => sum + (typeof field === "number" && Number.isFinite(field) ? field : 0), 0)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function textFromPart(part: GoalToolPart) {
|
|
214
|
+
if (part.type === "text" && typeof part.text === "string") return part.text
|
|
215
|
+
if (typeof part.content === "string") return part.content
|
|
216
|
+
return ""
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function estimateTokensFromText(text: string) {
|
|
220
|
+
return Math.ceil(text.length / 4)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function estimatedTokensFromParts(parts: GoalToolPart[]) {
|
|
224
|
+
return parts.reduce<number>((sum, part) => sum + estimateTokensFromText(textFromPart(part)), 0)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function tokensFromMessage(api: TuiPluginApi, message: SessionMessage) {
|
|
228
|
+
const parts = [...api.state.part(message.id)] as GoalToolPart[]
|
|
229
|
+
const partTotal = parts.reduce<number>((sum, part) => sum + (tokensFromRecord(part.tokens) ?? 0), 0)
|
|
230
|
+
if (partTotal > 0) return partTotal
|
|
231
|
+
const infoTokens = isRecord(message.info) ? tokensFromRecord(message.info.tokens) : undefined
|
|
232
|
+
const exact = tokensFromRecord(message.tokens) ?? infoTokens
|
|
233
|
+
return exact && exact > 0 ? exact : estimatedTokensFromParts(parts)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function tokensSinceGoalSnapshot(api: TuiPluginApi, sessionID: string, messageIndex: number) {
|
|
237
|
+
if (messageIndex < 0) return 0
|
|
238
|
+
const messages = [...api.state.session.messages(sessionID)] as SessionMessage[]
|
|
239
|
+
return messages
|
|
240
|
+
.slice(messageIndex)
|
|
241
|
+
.reduce<number>((sum, message) => sum + tokensFromMessage(api, message), 0)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function liveTimeUsed(goal: GoalSnapshot, currentSeconds: number) {
|
|
245
|
+
if (goal.status !== "active" || goal.sampledAt == null) return goal.timeUsedSeconds
|
|
246
|
+
return goal.timeUsedSeconds + Math.max(0, currentSeconds - goal.sampledAt)
|
|
176
247
|
}
|
|
177
248
|
|
|
178
249
|
function formatGoal(goal: GoalSnapshot | null) {
|
|
@@ -192,20 +263,36 @@ function formatGoal(goal: GoalSnapshot | null) {
|
|
|
192
263
|
|
|
193
264
|
function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
194
265
|
const theme = () => props.api.theme.current
|
|
195
|
-
const
|
|
266
|
+
const [currentSeconds, setCurrentSeconds] = createSignal(nowSeconds())
|
|
267
|
+
onMount(() => {
|
|
268
|
+
const interval = setInterval(() => setCurrentSeconds(nowSeconds()), 1000)
|
|
269
|
+
onCleanup(() => clearInterval(interval))
|
|
270
|
+
})
|
|
271
|
+
const state = createMemo(() => {
|
|
196
272
|
props.api.state.session.messages(props.sessionID)
|
|
197
|
-
return
|
|
273
|
+
return goalStateFromSession(props.api, props.sessionID)
|
|
274
|
+
})
|
|
275
|
+
const goal = createMemo(() => state().goal)
|
|
276
|
+
const tokensUsed = createMemo(() => {
|
|
277
|
+
const value = state().goal
|
|
278
|
+
if (!value) return 0
|
|
279
|
+
return value.tokensUsed + tokensSinceGoalSnapshot(props.api, props.sessionID, state().messageIndex)
|
|
198
280
|
})
|
|
199
281
|
const tokens = createMemo(() => {
|
|
200
282
|
const value = goal()
|
|
201
283
|
if (!value) return ""
|
|
202
|
-
if (value.tokenBudget == null) return compactNumber(
|
|
203
|
-
return `${compactNumber(
|
|
284
|
+
if (value.tokenBudget == null) return compactNumber(tokensUsed())
|
|
285
|
+
return `${compactNumber(tokensUsed())} / ${compactNumber(value.tokenBudget)}`
|
|
204
286
|
})
|
|
205
287
|
const remaining = createMemo(() => {
|
|
206
288
|
const value = goal()
|
|
207
289
|
if (!value) return ""
|
|
208
|
-
|
|
290
|
+
if (value.tokenBudget == null) return "unbounded"
|
|
291
|
+
return compactNumber(Math.max(0, value.tokenBudget - tokensUsed()))
|
|
292
|
+
})
|
|
293
|
+
const elapsed = createMemo(() => {
|
|
294
|
+
const value = goal()
|
|
295
|
+
return value ? liveTimeUsed(value, currentSeconds()) : 0
|
|
209
296
|
})
|
|
210
297
|
const objective = createMemo(() => {
|
|
211
298
|
const value = goal()?.objective ?? ""
|
|
@@ -223,7 +310,7 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
223
310
|
<b>Goal</b>
|
|
224
311
|
</text>
|
|
225
312
|
<text fg={theme().textMuted}>Status: {value().status}</text>
|
|
226
|
-
<text fg={theme().textMuted}>Time: {formatDuration(
|
|
313
|
+
<text fg={theme().textMuted}>Time: {formatDuration(elapsed())}</text>
|
|
227
314
|
<text fg={theme().textMuted}>Tokens: {tokens()}</text>
|
|
228
315
|
<text fg={theme().textMuted}>Remaining: {remaining()}</text>
|
|
229
316
|
<text fg={theme().textMuted}>{objective()}</text>
|
|
@@ -232,7 +319,7 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
232
319
|
>
|
|
233
320
|
<text fg={value().status === "complete" ? theme().primary : theme().textMuted}>
|
|
234
321
|
<b>{value().status === "complete" ? "Goal achieved" : "Goal unmet"}</b> (
|
|
235
|
-
{formatDurationBadge(
|
|
322
|
+
{formatDurationBadge(elapsed())})
|
|
236
323
|
</text>
|
|
237
324
|
</Show>
|
|
238
325
|
)}
|