@prevalentware/opencode-goal-plugin 0.1.6 → 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/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 activeSeconds = goal.status === "active" && goal.lastAccountedAt != null ? Math.max(0, nowSeconds() - goal.lastAccountedAt) : 0;
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prevalentware/opencode-goal-plugin",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Codex-style long-running goal mode for OpenCode.",
5
5
  "keywords": [
6
6
  "opencode",
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 goalFromSession(api: TuiPluginApi, sessionID: string) {
167
- const messages = [...api.state.session.messages(sessionID)].reverse()
168
- for (const message of messages) {
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 goal = createMemo(() => {
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 goalFromSession(props.api, props.sessionID)
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(value.tokensUsed)
203
- return `${compactNumber(value.tokensUsed)} / ${compactNumber(value.tokenBudget)}`
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
- return value.remainingTokens == null ? "unbounded" : compactNumber(value.remainingTokens)
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(value().timeUsedSeconds)}</text>
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(value().timeUsedSeconds)})
322
+ {formatDurationBadge(elapsed())})
236
323
  </text>
237
324
  </Show>
238
325
  )}