opencode-goal-plugin 0.1.8 → 0.1.10
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/CHANGELOG.md +15 -0
- package/README.md +46 -7
- package/package.json +2 -1
- package/src/goal-plugin.js +462 -51
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.10 — 2026-05-30
|
|
6
|
+
|
|
7
|
+
- Fix `experimental.chat.system.transform` to merge the goal continuation block into the primary system entry instead of pushing a separate one. Prevents `"System message must be at the beginning."` errors on strict-template backends (Qwen on vLLM, several Llama.cpp/Mistral templates). See issue #1.
|
|
8
|
+
|
|
9
|
+
## 0.1.9 — 2026-05-18
|
|
10
|
+
|
|
11
|
+
> This release makes the goal plugin much more reliable for real unattended use. Goals now persist across restarts, recover in a safe paused state, expose better status/history visibility, and use smarter no-progress detection to avoid premature stalls. It also hardens persistence with atomic writes, stricter file permissions, and regression tests around corrupt or missing state.
|
|
12
|
+
|
|
13
|
+
- Persist active goals and recent results to `~/.opencode-goal-plugin/state.json` by default, with recovered goals loaded in a paused state.
|
|
14
|
+
- Add `/goal history` plus richer `/goal status` output with recent checkpoint and suggested-next-action hints.
|
|
15
|
+
- Replace one-shot low-output pausing with a configurable consecutive-stall grace window via `noProgressTurnsBeforePause` / `--no-progress-turns`.
|
|
16
|
+
- Expand tests to cover history output, persistence recovery, repeated-stall pausing, and changing short assistant updates.
|
|
17
|
+
|
|
3
18
|
## 0.1.8 — 2026-05-18
|
|
4
19
|
|
|
5
20
|
- Harden `--max-minutes` fallback arithmetic when mixed with millisecond duration overrides.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ An experimental session-scoped `/goal` command for [OpenCode](https://opencode.a
|
|
|
4
4
|
|
|
5
5
|
Set a goal and the plugin keeps it in context, auto-continues the session whenever the assistant goes idle, and stops when the goal is marked complete, a blocker is reported, or a safety limit is reached.
|
|
6
6
|
|
|
7
|
-
Compatibility: tested against OpenCode 1.15.
|
|
7
|
+
Compatibility: tested against OpenCode 1.15.11. The plugin relies on experimental OpenCode hooks; pin or re-test against your OpenCode version before using it for unattended long-running work.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -41,12 +41,20 @@ Override limits for a single goal:
|
|
|
41
41
|
/goal fix the failing tests --max-turns 20 --max-minutes 30 --max-tokens 400000
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Flags accept either `--flag value` or `--flag=value`. If a flag is unknown, missing a value, or given a non-positive integer, the plugin rejects the command with a helpful error instead of silently folding the bad flag into the goal text.
|
|
45
|
+
|
|
44
46
|
Check status:
|
|
45
47
|
|
|
46
48
|
```
|
|
47
49
|
/goal status
|
|
48
50
|
```
|
|
49
51
|
|
|
52
|
+
View lifecycle history and the latest checkpoint:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
/goal history
|
|
56
|
+
```
|
|
57
|
+
|
|
50
58
|
Resume a paused or stopped goal:
|
|
51
59
|
|
|
52
60
|
```
|
|
@@ -95,7 +103,7 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
|
|
|
95
103
|
| Max duration | 15 minutes |
|
|
96
104
|
| Tracked tokens | 200,000 |
|
|
97
105
|
| Min delay between continues | 1.5 seconds |
|
|
98
|
-
| No-progress pause | < 50 output tokens on a turn |
|
|
106
|
+
| No-progress pause | < 50 output tokens on a stalled turn (after a 2-turn grace window) |
|
|
99
107
|
| Budget wrap-up threshold | 80% of tracked token budget |
|
|
100
108
|
| Auto-continue failure pause | 3 consecutive prompt failures |
|
|
101
109
|
|
|
@@ -103,11 +111,17 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
|
|
|
103
111
|
|
|
104
112
|
**Token budget.** The plugin tracks `input + output + reasoning` tokens across all session messages. In high-context sessions (large codebases, long conversation history), input overhead per turn can be substantial and the budget may be exhausted before the turn limit is reached. Treat it as a safety brake, not precise billing accounting.
|
|
105
113
|
|
|
114
|
+
**No-progress heuristic.** A low-output turn does not pause immediately anymore. The plugin pauses only after `noProgressTurnsBeforePause` consecutive *stalled* low-output turns — repeated turns with very little output and no meaningful change in the latest assistant checkpoint.
|
|
115
|
+
|
|
106
116
|
**Wrap-up vs. hard stop.** When a limit is reached, the plugin sends one final prompt asking the assistant to summarize what is done, what remains, and the next concrete step — rather than stopping silently. Use `/goal resume` to continue after any stop, including limit stops and no-progress pauses.
|
|
107
117
|
|
|
108
|
-
Goal state is
|
|
118
|
+
Goal state is persisted by default to `~/.opencode-goal-plugin/state.json`, but only as a local workflow checkpoint. It is not synchronized across machines or OpenCode instances.
|
|
119
|
+
|
|
120
|
+
The state directory is created with owner-only permissions, and the JSON state file is written as `0600` because it may contain goal text, assistant checkpoints, and workflow history.
|
|
109
121
|
|
|
110
|
-
|
|
122
|
+
Recovered active goals are loaded in a **paused** state with a recovery note, so unattended auto-continue does not resume blindly after a restart. Set `"persistState": false` to keep purely in-memory behavior.
|
|
123
|
+
|
|
124
|
+
`/goal resume` continues the same objective with a fresh local budget window. This lets you continue after pause, blocker, no-progress pause, rate-limit failures, or a limit stop without retyping the objective.
|
|
111
125
|
|
|
112
126
|
### Per-goal flags
|
|
113
127
|
|
|
@@ -121,6 +135,15 @@ Override any limit for a single goal:
|
|
|
121
135
|
| `--max-tokens <n>` | Tracked token limit |
|
|
122
136
|
| `--cooldown-ms <n>` | Minimum delay between continues |
|
|
123
137
|
| `--no-progress-threshold <n>` | Output token floor before pausing |
|
|
138
|
+
| `--no-progress-turns <n>` | Consecutive stalled low-output turns before pausing |
|
|
139
|
+
|
|
140
|
+
Examples:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
/goal fix tests --max-turns 20 --max-tokens 400000
|
|
144
|
+
/goal fix tests --max-turns=20 --max-tokens=400000
|
|
145
|
+
/goal fix tests --no-progress-threshold 50 --no-progress-turns 2
|
|
146
|
+
```
|
|
124
147
|
|
|
125
148
|
### Plugin-level defaults
|
|
126
149
|
|
|
@@ -136,15 +159,30 @@ Pass options when registering the plugin to change the defaults for all goals. T
|
|
|
136
159
|
"maxDurationMs": 900000,
|
|
137
160
|
"maxTokens": 200000,
|
|
138
161
|
"minDelayMs": 1500,
|
|
162
|
+
"maxRecentMessages": 50,
|
|
139
163
|
"noProgressTokenThreshold": 50,
|
|
164
|
+
"noProgressTurnsBeforePause": 2,
|
|
140
165
|
"budgetWrapupRatio": 0.8,
|
|
141
|
-
"maxPromptFailures": 3
|
|
166
|
+
"maxPromptFailures": 3,
|
|
167
|
+
"persistState": true,
|
|
168
|
+
"stateFilePath": "/home/you/.opencode-goal-plugin/state.json",
|
|
169
|
+
"resultRetentionMs": 604800000,
|
|
170
|
+
"maxStoredResults": 200
|
|
142
171
|
}
|
|
143
172
|
]
|
|
144
173
|
]
|
|
145
174
|
}
|
|
146
175
|
```
|
|
147
176
|
|
|
177
|
+
Additional plugin-level options:
|
|
178
|
+
|
|
179
|
+
- `maxRecentMessages` — how many recent session messages to scan when looking for the latest assistant turn before auto-continuing. Higher values make long, tool-heavy sessions less likely to lose the most recent assistant response.
|
|
180
|
+
- `noProgressTurnsBeforePause` — grace window for low-output stalls. The plugin pauses only after this many consecutive stalled low-output turns rather than on the first one.
|
|
181
|
+
- `persistState` — whether to persist active goals and recent goal results to disk.
|
|
182
|
+
- `stateFilePath` — where the persisted state JSON is written. Useful if you want per-project or ephemeral storage.
|
|
183
|
+
- `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
|
|
184
|
+
- `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted.
|
|
185
|
+
|
|
148
186
|
## Prompt safety
|
|
149
187
|
|
|
150
188
|
The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies.
|
|
@@ -180,8 +218,9 @@ Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode wil
|
|
|
180
218
|
## Development
|
|
181
219
|
|
|
182
220
|
```sh
|
|
183
|
-
npm test
|
|
184
|
-
npm run
|
|
221
|
+
npm test # run the test suite
|
|
222
|
+
npm run test:coverage # run tests with coverage
|
|
223
|
+
npm run check # syntax check + tests
|
|
185
224
|
npm run pack:check # verify package contents before publishing
|
|
186
225
|
```
|
|
187
226
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Session-scoped /goal workflow for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/goal-plugin.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
21
|
"test": "node --test",
|
|
22
|
+
"test:coverage": "node --test --experimental-test-coverage",
|
|
22
23
|
"check": "node -c src/goal-plugin.js && npm test",
|
|
23
24
|
"pack:check": "npm pack --dry-run"
|
|
24
25
|
},
|
package/src/goal-plugin.js
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { promises as fs } from "node:fs"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
|
|
6
|
+
const STATE_FILE_VERSION = 1
|
|
7
|
+
const DEFAULT_STATE_FILE_PATH = join(homedir(), ".opencode-goal-plugin", "state.json")
|
|
8
|
+
const MAX_HISTORY_ENTRIES = 20
|
|
9
|
+
const MAX_CHECKPOINTS = 5
|
|
10
|
+
const CHECKPOINT_CHAR_LIMIT = 280
|
|
2
11
|
|
|
3
12
|
const DEFAULT_OPTIONS = {
|
|
4
13
|
maxTurns: 10,
|
|
5
14
|
maxDurationMs: 15 * 60 * 1000,
|
|
6
15
|
maxTokens: 200000,
|
|
7
16
|
minDelayMs: 1500,
|
|
17
|
+
maxRecentMessages: 50,
|
|
8
18
|
noProgressTokenThreshold: 50,
|
|
19
|
+
noProgressTurnsBeforePause: 2,
|
|
9
20
|
budgetWrapupRatio: 0.8,
|
|
10
21
|
warnTurnsRemaining: 3,
|
|
11
22
|
warnDurationMsRemaining: 60 * 1000,
|
|
12
23
|
warnTokensRemaining: 25000,
|
|
13
24
|
maxPromptFailures: 3,
|
|
25
|
+
resultRetentionMs: 7 * 24 * 60 * 60 * 1000,
|
|
26
|
+
maxStoredResults: 200,
|
|
14
27
|
}
|
|
15
28
|
|
|
16
29
|
const goalStates = new Map()
|
|
@@ -20,6 +33,39 @@ const seenOutputTokens = new Map()
|
|
|
20
33
|
const activeContinues = new Set()
|
|
21
34
|
const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
|
|
22
35
|
const PAUSE_COMMANDS = new Set(["pause"])
|
|
36
|
+
const GOAL_FLAG_SPECS = {
|
|
37
|
+
"--max-turns": {
|
|
38
|
+
optionKey: "maxTurns",
|
|
39
|
+
parse: (value, options) => toPositiveInteger(value, options.maxTurns),
|
|
40
|
+
},
|
|
41
|
+
"--max-duration-ms": {
|
|
42
|
+
optionKey: "maxDurationMs",
|
|
43
|
+
parse: (value, options) => toPositiveInteger(value, options.maxDurationMs),
|
|
44
|
+
},
|
|
45
|
+
"--max-minutes": {
|
|
46
|
+
optionKey: "maxDurationMs",
|
|
47
|
+
parse: (value, options) =>
|
|
48
|
+
toPositiveInteger(value, Math.ceil(options.maxDurationMs / 60000)) * 60000,
|
|
49
|
+
},
|
|
50
|
+
"--max-tokens": {
|
|
51
|
+
optionKey: "maxTokens",
|
|
52
|
+
parse: (value, options) => toPositiveInteger(value, options.maxTokens),
|
|
53
|
+
},
|
|
54
|
+
"--cooldown-ms": {
|
|
55
|
+
optionKey: "minDelayMs",
|
|
56
|
+
parse: (value, options) => toPositiveInteger(value, options.minDelayMs),
|
|
57
|
+
},
|
|
58
|
+
"--no-progress-threshold": {
|
|
59
|
+
optionKey: "noProgressTokenThreshold",
|
|
60
|
+
parse: (value, options) =>
|
|
61
|
+
toPositiveInteger(value, options.noProgressTokenThreshold),
|
|
62
|
+
},
|
|
63
|
+
"--no-progress-turns": {
|
|
64
|
+
optionKey: "noProgressTurnsBeforePause",
|
|
65
|
+
parse: (value, options) =>
|
|
66
|
+
toPositiveInteger(value, options.noProgressTurnsBeforePause),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
23
69
|
|
|
24
70
|
function getText(parts) {
|
|
25
71
|
return (parts || [])
|
|
@@ -44,12 +90,55 @@ function isIdleEvent(event) {
|
|
|
44
90
|
)
|
|
45
91
|
}
|
|
46
92
|
|
|
93
|
+
function summarizeText(text, limit = CHECKPOINT_CHAR_LIMIT) {
|
|
94
|
+
const normalized = String(text || "").replace(/\s+/g, " ").trim()
|
|
95
|
+
if (!normalized) return ""
|
|
96
|
+
return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatTimestamp(timestamp) {
|
|
100
|
+
if (!timestamp) return "unknown"
|
|
101
|
+
return new Date(timestamp).toISOString()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatAge(timestamp) {
|
|
105
|
+
if (!timestamp) return "unknown"
|
|
106
|
+
return `${Math.round((Date.now() - timestamp) / 1000)}s ago`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function makeHistoryEntry(type, detail, timestamp = Date.now()) {
|
|
110
|
+
return {
|
|
111
|
+
type,
|
|
112
|
+
detail: summarizeText(detail, 400),
|
|
113
|
+
timestamp,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pushHistory(goal, type, detail, timestamp = Date.now()) {
|
|
118
|
+
goal.history = [...(goal.history || []), makeHistoryEntry(type, detail, timestamp)].slice(
|
|
119
|
+
-MAX_HISTORY_ENTRIES,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function recordCheckpoint(goal, text, timestamp = Date.now()) {
|
|
124
|
+
const summary = summarizeText(text)
|
|
125
|
+
if (!summary) return
|
|
126
|
+
if (goal.lastCheckpoint?.summary === summary) return
|
|
127
|
+
|
|
128
|
+
const checkpoint = { summary, timestamp }
|
|
129
|
+
goal.lastCheckpoint = checkpoint
|
|
130
|
+
goal.checkpoints = [...(goal.checkpoints || []), checkpoint].slice(-MAX_CHECKPOINTS)
|
|
131
|
+
}
|
|
132
|
+
|
|
47
133
|
function formatStatus(goal) {
|
|
48
134
|
const elapsed = Math.round((Date.now() - goal.startedAt) / 1000)
|
|
49
135
|
const lastProgress =
|
|
50
136
|
goal.lastProgressAt > 0
|
|
51
137
|
? `${Math.round((Date.now() - goal.lastProgressAt) / 1000)}s ago`
|
|
52
138
|
: "none yet"
|
|
139
|
+
const lastCheckpoint = goal.lastCheckpoint
|
|
140
|
+
? `${goal.lastCheckpoint.summary} (${formatAge(goal.lastCheckpoint.timestamp)})`
|
|
141
|
+
: "none yet"
|
|
53
142
|
const lines = [
|
|
54
143
|
`Active goal: ${goal.condition}`,
|
|
55
144
|
`Auto-continues sent: ${goal.turnCount}/${goal.options.maxTurns}`,
|
|
@@ -57,21 +146,31 @@ function formatStatus(goal) {
|
|
|
57
146
|
`Elapsed: ${elapsed}s/${Math.round(goal.options.maxDurationMs / 1000)}s`,
|
|
58
147
|
`Last progress: ${lastProgress}`,
|
|
59
148
|
`No-progress turns: ${goal.noProgressTurns}`,
|
|
149
|
+
`Recent checkpoint: ${lastCheckpoint}`,
|
|
60
150
|
`Last status: ${goal.lastStatus || "No assistant turn recorded yet."}`,
|
|
61
151
|
]
|
|
62
152
|
if (goal.stopped) lines.push(`Stopped: ${goal.stopReason || "unknown"}`)
|
|
63
153
|
if (goal.blockedReason) lines.push(`Blocked reason: ${goal.blockedReason}`)
|
|
154
|
+
if (goal.stopped) {
|
|
155
|
+
lines.push(
|
|
156
|
+
`Suggested action: ${goal.stopReason === "blocked" ? "address the blocker, then run /goal resume" : "run /goal resume to continue, or /goal clear to discard"}`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
64
159
|
return lines.join("\n")
|
|
65
160
|
}
|
|
66
161
|
|
|
67
162
|
function formatGoalResult(result) {
|
|
68
163
|
const elapsed = Math.round((result.finishedAt - result.startedAt) / 1000)
|
|
164
|
+
const lastCheckpoint = result.lastCheckpoint
|
|
165
|
+
? `${result.lastCheckpoint.summary} (${formatTimestamp(result.lastCheckpoint.timestamp)})`
|
|
166
|
+
: "none recorded"
|
|
69
167
|
const lines = [
|
|
70
168
|
`Last goal: ${result.condition}`,
|
|
71
169
|
`State: ${result.state}`,
|
|
72
170
|
`Auto-continues sent: ${result.turnCount}`,
|
|
73
171
|
`Tokens: ${result.totalTokens.toLocaleString()}`,
|
|
74
172
|
`Elapsed: ${elapsed}s`,
|
|
173
|
+
`Last checkpoint: ${lastCheckpoint}`,
|
|
75
174
|
`Last status: ${result.lastStatus || "No status recorded."}`,
|
|
76
175
|
]
|
|
77
176
|
if (result.reason) lines.push(`Reason: ${result.reason}`)
|
|
@@ -79,6 +178,13 @@ function formatGoalResult(result) {
|
|
|
79
178
|
return lines.join("\n")
|
|
80
179
|
}
|
|
81
180
|
|
|
181
|
+
function formatHistory(history = []) {
|
|
182
|
+
if (!history.length) return "No goal history recorded yet."
|
|
183
|
+
return history
|
|
184
|
+
.map((entry) => `- [${formatTimestamp(entry.timestamp)}] ${entry.type}: ${entry.detail}`)
|
|
185
|
+
.join("\n")
|
|
186
|
+
}
|
|
187
|
+
|
|
82
188
|
function goalIsComplete(text) {
|
|
83
189
|
return /(^|\n)\s*(?:\[goal:complete\]|goal:complete)\s*$/i.test(text.trimEnd())
|
|
84
190
|
}
|
|
@@ -108,7 +214,34 @@ function cleanupGoal(sessionID) {
|
|
|
108
214
|
activeContinues.delete(sessionID)
|
|
109
215
|
}
|
|
110
216
|
|
|
217
|
+
function clearRuntimeState() {
|
|
218
|
+
goalStates.clear()
|
|
219
|
+
lastGoalResults.clear()
|
|
220
|
+
seenTokens.clear()
|
|
221
|
+
seenOutputTokens.clear()
|
|
222
|
+
activeContinues.clear()
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function pruneGoalResults(options) {
|
|
226
|
+
const retentionMs = options?.resultRetentionMs ?? DEFAULT_OPTIONS.resultRetentionMs
|
|
227
|
+
const maxStoredResults = options?.maxStoredResults ?? DEFAULT_OPTIONS.maxStoredResults
|
|
228
|
+
const now = Date.now()
|
|
229
|
+
|
|
230
|
+
for (const [sessionID, result] of lastGoalResults.entries()) {
|
|
231
|
+
if (!result?.finishedAt || now - result.finishedAt > retentionMs) {
|
|
232
|
+
lastGoalResults.delete(sessionID)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
while (lastGoalResults.size > maxStoredResults) {
|
|
237
|
+
const oldestSessionID = lastGoalResults.keys().next().value
|
|
238
|
+
if (oldestSessionID === undefined) break
|
|
239
|
+
lastGoalResults.delete(oldestSessionID)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
111
243
|
function rememberGoalResult(sessionID, goal, state, reason = "") {
|
|
244
|
+
lastGoalResults.delete(sessionID)
|
|
112
245
|
lastGoalResults.set(sessionID, {
|
|
113
246
|
condition: goal.condition,
|
|
114
247
|
state,
|
|
@@ -119,7 +252,11 @@ function rememberGoalResult(sessionID, goal, state, reason = "") {
|
|
|
119
252
|
startedAt: goal.startedAt,
|
|
120
253
|
finishedAt: Date.now(),
|
|
121
254
|
lastStatus: goal.lastStatus,
|
|
255
|
+
lastCheckpoint: goal.lastCheckpoint || null,
|
|
256
|
+
checkpoints: [...(goal.checkpoints || [])],
|
|
257
|
+
history: [...(goal.history || [])],
|
|
122
258
|
})
|
|
259
|
+
pruneGoalResults(goal.options)
|
|
123
260
|
}
|
|
124
261
|
|
|
125
262
|
function resetGoalBudget(goal) {
|
|
@@ -137,6 +274,8 @@ function resetGoalBudget(goal) {
|
|
|
137
274
|
goal.budgetWrapupSent = false
|
|
138
275
|
goal.messageIDs = new Set()
|
|
139
276
|
goal.promptFailures = 0
|
|
277
|
+
goal.lastAssistantMessageID = ""
|
|
278
|
+
goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
|
|
140
279
|
}
|
|
141
280
|
|
|
142
281
|
function currentGoal(sessionID, goalID) {
|
|
@@ -151,16 +290,33 @@ function toPositiveInteger(value, fallback) {
|
|
|
151
290
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
|
|
152
291
|
}
|
|
153
292
|
|
|
293
|
+
function parsePositiveIntegerStrict(value) {
|
|
294
|
+
const parsed = Number(value)
|
|
295
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function stripWrappingQuotes(value) {
|
|
299
|
+
return value.replace(/^["']|["']$/g, "")
|
|
300
|
+
}
|
|
301
|
+
|
|
154
302
|
function normalizeOptions(options = {}) {
|
|
155
303
|
return {
|
|
156
304
|
maxTurns: toPositiveInteger(options.maxTurns, DEFAULT_OPTIONS.maxTurns),
|
|
157
305
|
maxDurationMs: toPositiveInteger(options.maxDurationMs, DEFAULT_OPTIONS.maxDurationMs),
|
|
158
306
|
maxTokens: toPositiveInteger(options.maxTokens, DEFAULT_OPTIONS.maxTokens),
|
|
159
307
|
minDelayMs: toPositiveInteger(options.minDelayMs, DEFAULT_OPTIONS.minDelayMs),
|
|
308
|
+
maxRecentMessages: toPositiveInteger(
|
|
309
|
+
options.maxRecentMessages,
|
|
310
|
+
DEFAULT_OPTIONS.maxRecentMessages,
|
|
311
|
+
),
|
|
160
312
|
noProgressTokenThreshold: toPositiveInteger(
|
|
161
313
|
options.noProgressTokenThreshold,
|
|
162
314
|
DEFAULT_OPTIONS.noProgressTokenThreshold,
|
|
163
315
|
),
|
|
316
|
+
noProgressTurnsBeforePause: toPositiveInteger(
|
|
317
|
+
options.noProgressTurnsBeforePause,
|
|
318
|
+
DEFAULT_OPTIONS.noProgressTurnsBeforePause,
|
|
319
|
+
),
|
|
164
320
|
budgetWrapupRatio:
|
|
165
321
|
Number(options.budgetWrapupRatio) > 0 && Number(options.budgetWrapupRatio) < 1
|
|
166
322
|
? Number(options.budgetWrapupRatio)
|
|
@@ -181,6 +337,121 @@ function normalizeOptions(options = {}) {
|
|
|
181
337
|
options.maxPromptFailures,
|
|
182
338
|
DEFAULT_OPTIONS.maxPromptFailures,
|
|
183
339
|
),
|
|
340
|
+
resultRetentionMs: toPositiveInteger(
|
|
341
|
+
options.resultRetentionMs,
|
|
342
|
+
DEFAULT_OPTIONS.resultRetentionMs,
|
|
343
|
+
),
|
|
344
|
+
maxStoredResults: toPositiveInteger(
|
|
345
|
+
options.maxStoredResults,
|
|
346
|
+
DEFAULT_OPTIONS.maxStoredResults,
|
|
347
|
+
),
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function normalizePersistenceOptions(options = {}) {
|
|
352
|
+
return {
|
|
353
|
+
persistState: options.persistState !== false,
|
|
354
|
+
stateFilePath:
|
|
355
|
+
typeof options.stateFilePath === "string" && options.stateFilePath.trim()
|
|
356
|
+
? options.stateFilePath.trim()
|
|
357
|
+
: DEFAULT_STATE_FILE_PATH,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function serializeGoal(goal) {
|
|
362
|
+
return {
|
|
363
|
+
...goal,
|
|
364
|
+
messageIDs: [...(goal.messageIDs || [])],
|
|
365
|
+
history: [...(goal.history || [])],
|
|
366
|
+
checkpoints: [...(goal.checkpoints || [])],
|
|
367
|
+
lastCheckpoint: goal.lastCheckpoint || null,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function deserializeGoal(goal) {
|
|
372
|
+
const hydrated = {
|
|
373
|
+
...goal,
|
|
374
|
+
messageIDs: new Set(goal?.messageIDs || []),
|
|
375
|
+
history: Array.isArray(goal?.history) ? goal.history : [],
|
|
376
|
+
checkpoints: Array.isArray(goal?.checkpoints) ? goal.checkpoints : [],
|
|
377
|
+
lastCheckpoint: goal?.lastCheckpoint || null,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (!hydrated.stopped) {
|
|
381
|
+
hydrated.stopped = true
|
|
382
|
+
hydrated.stopReason = "recovered after restart"
|
|
383
|
+
hydrated.lastStatus = "Recovered persisted goal state. Review /goal status and run /goal resume when ready."
|
|
384
|
+
pushHistory(
|
|
385
|
+
hydrated,
|
|
386
|
+
"recovered",
|
|
387
|
+
"Recovered persisted goal state after plugin restart; auto-continue remains paused until you resume.",
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return hydrated
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function loadPersistedState(persistenceOptions, client) {
|
|
395
|
+
if (!persistenceOptions.persistState) return "disabled"
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const raw = await fs.readFile(persistenceOptions.stateFilePath, "utf8")
|
|
399
|
+
const parsed = JSON.parse(raw)
|
|
400
|
+
if (parsed?.version !== STATE_FILE_VERSION) {
|
|
401
|
+
await logPluginError(
|
|
402
|
+
client,
|
|
403
|
+
`Skipped persisted goal state: unsupported version ${parsed?.version ?? "unknown"}.`,
|
|
404
|
+
)
|
|
405
|
+
return "invalid"
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
clearRuntimeState()
|
|
409
|
+
|
|
410
|
+
for (const goal of parsed.goals || []) {
|
|
411
|
+
goalStates.set(goal.sessionID, deserializeGoal(goal))
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for (const result of parsed.results || []) {
|
|
415
|
+
lastGoalResults.set(result.sessionID, result)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return "loaded"
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (error?.code === "ENOENT") return "missing"
|
|
421
|
+
await logPluginError(client, "Failed to load persisted goal state", error)
|
|
422
|
+
return "invalid"
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function persistState(persistenceOptions, client) {
|
|
427
|
+
if (!persistenceOptions.persistState) return
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
await fs.mkdir(dirname(persistenceOptions.stateFilePath), { recursive: true, mode: 0o700 })
|
|
431
|
+
const tmpPath = `${persistenceOptions.stateFilePath}.${process.pid}.${randomUUID()}.tmp`
|
|
432
|
+
await fs.writeFile(
|
|
433
|
+
tmpPath,
|
|
434
|
+
JSON.stringify(
|
|
435
|
+
{
|
|
436
|
+
version: STATE_FILE_VERSION,
|
|
437
|
+
goals: [...goalStates.values()].map(serializeGoal),
|
|
438
|
+
results: [...lastGoalResults.entries()].map(([sessionID, result]) => ({
|
|
439
|
+
...result,
|
|
440
|
+
sessionID,
|
|
441
|
+
history: [...(result.history || [])],
|
|
442
|
+
checkpoints: [...(result.checkpoints || [])],
|
|
443
|
+
lastCheckpoint: result.lastCheckpoint || null,
|
|
444
|
+
})),
|
|
445
|
+
},
|
|
446
|
+
null,
|
|
447
|
+
2,
|
|
448
|
+
),
|
|
449
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
450
|
+
)
|
|
451
|
+
await fs.rename(tmpPath, persistenceOptions.stateFilePath)
|
|
452
|
+
await fs.chmod(persistenceOptions.stateFilePath, 0o600)
|
|
453
|
+
} catch (error) {
|
|
454
|
+
await logPluginError(client, "Failed to persist goal state", error)
|
|
184
455
|
}
|
|
185
456
|
}
|
|
186
457
|
|
|
@@ -204,47 +475,48 @@ function parseGoalArguments(args, defaults) {
|
|
|
204
475
|
const parts = args.match(/"[^"]*"|'[^']*'|\S+/g) || []
|
|
205
476
|
const condition = []
|
|
206
477
|
const options = { ...defaults }
|
|
478
|
+
const errors = []
|
|
207
479
|
|
|
208
480
|
for (let i = 0; i < parts.length; i += 1) {
|
|
209
481
|
const part = parts[i]
|
|
210
|
-
const next = parts[i + 1]
|
|
211
|
-
const nextIsValue = next !== undefined && !next.startsWith("--")
|
|
212
482
|
|
|
213
|
-
if (part
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (nextIsValue) {
|
|
223
|
-
options.maxDurationMs =
|
|
224
|
-
toPositiveInteger(next, Math.ceil(options.maxDurationMs / 60000)) * 60000
|
|
225
|
-
i += 1
|
|
483
|
+
if (part.startsWith("--")) {
|
|
484
|
+
const [flagName, inlineValue] = part.split(/=(.*)/s, 2)
|
|
485
|
+
const flagSpec = GOAL_FLAG_SPECS[flagName]
|
|
486
|
+
|
|
487
|
+
if (!flagSpec) {
|
|
488
|
+
const next = parts[i + 1]
|
|
489
|
+
if (inlineValue === undefined && next !== undefined && !next.startsWith("--")) i += 1
|
|
490
|
+
errors.push(`Unsupported flag: ${flagName}`)
|
|
491
|
+
continue
|
|
226
492
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
493
|
+
|
|
494
|
+
const next = parts[i + 1]
|
|
495
|
+
const value = inlineValue ?? (next !== undefined && !next.startsWith("--") ? next : undefined)
|
|
496
|
+
if (inlineValue === undefined && value !== undefined) i += 1
|
|
497
|
+
|
|
498
|
+
if (value === undefined) {
|
|
499
|
+
errors.push(`Missing value for ${flagName}`)
|
|
500
|
+
continue
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const parsedValue = parsePositiveIntegerStrict(stripWrappingQuotes(value))
|
|
504
|
+
if (parsedValue === null) {
|
|
505
|
+
errors.push(`Invalid positive integer for ${flagName}: ${value}`)
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
options[flagSpec.optionKey] = flagSpec.parse(parsedValue, options)
|
|
239
510
|
continue
|
|
240
511
|
}
|
|
241
512
|
|
|
242
|
-
condition.push(part
|
|
513
|
+
condition.push(stripWrappingQuotes(part))
|
|
243
514
|
}
|
|
244
515
|
|
|
245
516
|
return {
|
|
246
517
|
condition: condition.join(" ").trim(),
|
|
247
518
|
options,
|
|
519
|
+
errors,
|
|
248
520
|
}
|
|
249
521
|
}
|
|
250
522
|
|
|
@@ -352,6 +624,20 @@ function extractBlockedReason(text) {
|
|
|
352
624
|
.find((line) => line.trim())?.trim() || ""
|
|
353
625
|
}
|
|
354
626
|
|
|
627
|
+
function formatArgumentErrors(errors) {
|
|
628
|
+
return [
|
|
629
|
+
"Goal flags could not be parsed.",
|
|
630
|
+
...errors.map((error) => `- ${error}`),
|
|
631
|
+
"",
|
|
632
|
+
"Supported flags: --max-turns, --max-minutes, --max-duration-ms, --max-tokens, --cooldown-ms, --no-progress-threshold, --no-progress-turns.",
|
|
633
|
+
"You can pass them as `--flag value` or `--flag=value`.",
|
|
634
|
+
].join("\n")
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function findLatestAssistantMessage(messages) {
|
|
638
|
+
return [...(messages || [])].reverse().find((message) => message.info?.role === "assistant") || null
|
|
639
|
+
}
|
|
640
|
+
|
|
355
641
|
function outputTokensForMessage(message) {
|
|
356
642
|
return message?.info?.tokens?.output || 0
|
|
357
643
|
}
|
|
@@ -365,6 +651,15 @@ function budgetWrapupNeeded(goal) {
|
|
|
365
651
|
|
|
366
652
|
export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
367
653
|
const defaultGoalOptions = normalizeOptions(pluginOptions)
|
|
654
|
+
const persistenceOptions = normalizePersistenceOptions(pluginOptions)
|
|
655
|
+
const persist = async () => persistState(persistenceOptions, client)
|
|
656
|
+
|
|
657
|
+
clearRuntimeState()
|
|
658
|
+
const persistedStateStatus = await loadPersistedState(persistenceOptions, client)
|
|
659
|
+
pruneGoalResults(defaultGoalOptions)
|
|
660
|
+
if (persistedStateStatus === "loaded" || persistedStateStatus === "missing") {
|
|
661
|
+
await persist()
|
|
662
|
+
}
|
|
368
663
|
|
|
369
664
|
return {
|
|
370
665
|
"command.execute.before": async (input, output) => {
|
|
@@ -372,6 +667,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
372
667
|
|
|
373
668
|
const args = (input.arguments || "").trim()
|
|
374
669
|
const sessionID = input.sessionID
|
|
670
|
+
pruneGoalResults(defaultGoalOptions)
|
|
375
671
|
|
|
376
672
|
if (!args || args === "status") {
|
|
377
673
|
const goal = goalStates.get(sessionID)
|
|
@@ -388,9 +684,37 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
388
684
|
return
|
|
389
685
|
}
|
|
390
686
|
|
|
687
|
+
if (args === "history") {
|
|
688
|
+
const goal = goalStates.get(sessionID)
|
|
689
|
+
const lastResult = lastGoalResults.get(sessionID)
|
|
690
|
+
output.parts = [
|
|
691
|
+
makeTextPart(
|
|
692
|
+
goal
|
|
693
|
+
? [
|
|
694
|
+
`Goal history for: ${goal.condition}`,
|
|
695
|
+
"",
|
|
696
|
+
`Latest checkpoint: ${goal.lastCheckpoint?.summary || "none yet"}`,
|
|
697
|
+
"",
|
|
698
|
+
formatHistory(goal.history),
|
|
699
|
+
].join("\n")
|
|
700
|
+
: lastResult
|
|
701
|
+
? [
|
|
702
|
+
`Last goal history for: ${lastResult.condition}`,
|
|
703
|
+
"",
|
|
704
|
+
`Latest checkpoint: ${lastResult.lastCheckpoint?.summary || "none recorded"}`,
|
|
705
|
+
"",
|
|
706
|
+
formatHistory(lastResult.history),
|
|
707
|
+
].join("\n")
|
|
708
|
+
: "No goal history recorded yet. Set a goal with `/goal <condition>`.",
|
|
709
|
+
),
|
|
710
|
+
]
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
|
|
391
714
|
if (CLEAR_COMMANDS.has(args)) {
|
|
392
715
|
cleanupGoal(sessionID)
|
|
393
716
|
lastGoalResults.delete(sessionID)
|
|
717
|
+
await persist()
|
|
394
718
|
output.parts = [makeTextPart("Goal cleared.")]
|
|
395
719
|
return
|
|
396
720
|
}
|
|
@@ -404,6 +728,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
404
728
|
goal.stopped = true
|
|
405
729
|
goal.stopReason = "paused"
|
|
406
730
|
goal.lastStatus = "Goal paused."
|
|
731
|
+
pushHistory(goal, "paused", "User paused the active goal.")
|
|
732
|
+
await persist()
|
|
407
733
|
output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
|
|
408
734
|
return
|
|
409
735
|
}
|
|
@@ -424,11 +750,17 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
424
750
|
goal.stopReason = ""
|
|
425
751
|
goal.blockedReason = ""
|
|
426
752
|
goal.lastStatus = "Goal resumed with a fresh local budget."
|
|
753
|
+
pushHistory(goal, "resumed", "User resumed the goal with a fresh local budget window.")
|
|
754
|
+
await persist()
|
|
427
755
|
output.parts = [makeTextPart(`Goal resumed with fresh limits: ${goal.condition}`)]
|
|
428
756
|
return
|
|
429
757
|
}
|
|
430
758
|
|
|
431
759
|
const parsed = parseGoalArguments(args, defaultGoalOptions)
|
|
760
|
+
if (parsed.errors.length > 0) {
|
|
761
|
+
output.parts = [makeTextPart(formatArgumentErrors(parsed.errors))]
|
|
762
|
+
return
|
|
763
|
+
}
|
|
432
764
|
if (!parsed.condition) {
|
|
433
765
|
output.parts = [makeTextPart("No goal provided. Set one with `/goal <condition>`.")]
|
|
434
766
|
return
|
|
@@ -444,6 +776,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
444
776
|
options: parsed.options,
|
|
445
777
|
lastStatus: "Goal set.",
|
|
446
778
|
lastAssistantText: "",
|
|
779
|
+
lastAssistantMessageID: "",
|
|
447
780
|
lastContinueAt: 0,
|
|
448
781
|
lastProgressAt: 0,
|
|
449
782
|
noProgressTurns: 0,
|
|
@@ -453,11 +786,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
453
786
|
stopReason: "",
|
|
454
787
|
promptFailures: 0,
|
|
455
788
|
messageIDs: new Set(),
|
|
789
|
+
history: [],
|
|
790
|
+
checkpoints: [],
|
|
791
|
+
lastCheckpoint: null,
|
|
456
792
|
}
|
|
457
793
|
|
|
794
|
+
pushHistory(
|
|
795
|
+
goal,
|
|
796
|
+
"set",
|
|
797
|
+
`Goal created with limits: ${goal.options.maxTurns} auto-continues, ${Math.round(goal.options.maxDurationMs / 1000)}s, ${goal.options.maxTokens.toLocaleString()} tracked tokens.`,
|
|
798
|
+
)
|
|
799
|
+
|
|
458
800
|
cleanupGoal(sessionID)
|
|
459
801
|
lastGoalResults.delete(sessionID)
|
|
460
802
|
goalStates.set(sessionID, goal)
|
|
803
|
+
await persist()
|
|
461
804
|
output.parts = [
|
|
462
805
|
makeTextPart(
|
|
463
806
|
[
|
|
@@ -466,6 +809,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
466
809
|
"Start working toward this goal now.",
|
|
467
810
|
"When the goal is fully satisfied, end your response with `[goal:complete]`.",
|
|
468
811
|
"If you are truly blocked and need the user, end with `[goal:blocked]`.",
|
|
812
|
+
"Use `/goal history` to inspect recent lifecycle events and checkpoints.",
|
|
469
813
|
"",
|
|
470
814
|
`Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
|
|
471
815
|
goal.options.maxDurationMs / 1000,
|
|
@@ -483,6 +827,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
483
827
|
const goal = goalStates.get(message.sessionID)
|
|
484
828
|
if (!goal) return
|
|
485
829
|
|
|
830
|
+
let changed = false
|
|
486
831
|
const currentOutputTokens = message.tokens?.output || 0
|
|
487
832
|
const previousOutputTokens = seenOutputTokens.get(message.id) || 0
|
|
488
833
|
const currentTokens =
|
|
@@ -494,17 +839,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
494
839
|
goal.totalTokens += currentTokens - previousTokens
|
|
495
840
|
seenTokens.set(message.id, currentTokens)
|
|
496
841
|
goal.messageIDs.add(message.id)
|
|
842
|
+
changed = true
|
|
497
843
|
}
|
|
498
844
|
|
|
499
845
|
if (currentOutputTokens > previousOutputTokens) {
|
|
500
846
|
seenOutputTokens.set(message.id, currentOutputTokens)
|
|
501
847
|
goal.messageIDs.add(message.id)
|
|
848
|
+
changed = true
|
|
502
849
|
}
|
|
503
850
|
|
|
504
851
|
if (message.role === "assistant" && currentOutputTokens > previousOutputTokens) {
|
|
505
852
|
goal.lastProgressAt = Date.now()
|
|
506
|
-
|
|
853
|
+
changed = true
|
|
507
854
|
}
|
|
855
|
+
|
|
856
|
+
if (changed) await persist()
|
|
508
857
|
return
|
|
509
858
|
}
|
|
510
859
|
|
|
@@ -519,23 +868,32 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
519
868
|
try {
|
|
520
869
|
const messages = await client.session.messages({
|
|
521
870
|
path: { id: sessionID },
|
|
522
|
-
query: { limit:
|
|
871
|
+
query: { limit: goal.options.maxRecentMessages },
|
|
523
872
|
})
|
|
524
873
|
const activeGoalAfterMessages = currentGoal(sessionID, goalID)
|
|
525
874
|
if (!activeGoalAfterMessages) return
|
|
526
875
|
|
|
527
|
-
const latestAssistant =
|
|
528
|
-
|
|
529
|
-
.find((message) => message.info?.role === "assistant")
|
|
876
|
+
const latestAssistant = findLatestAssistantMessage(messages.data)
|
|
877
|
+
const latestAssistantID = latestAssistant?.info?.id || ""
|
|
530
878
|
const latestText = getText(latestAssistant?.parts)
|
|
531
|
-
const latestOutputTokens = outputTokensForMessage(latestAssistant)
|
|
532
|
-
|
|
879
|
+
const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null
|
|
880
|
+
const previousAssistantText = activeGoalAfterMessages.lastAssistantText
|
|
881
|
+
const assistantChanged = summarizeText(latestText) !== summarizeText(previousAssistantText)
|
|
882
|
+
const assistantRepeated =
|
|
883
|
+
latestAssistantID && latestAssistantID === activeGoalAfterMessages.lastAssistantMessageID
|
|
884
|
+
|
|
885
|
+
if (latestText && (!assistantRepeated || assistantChanged)) {
|
|
886
|
+
recordCheckpoint(activeGoalAfterMessages, latestText)
|
|
887
|
+
}
|
|
533
888
|
activeGoalAfterMessages.lastAssistantText = latestText
|
|
889
|
+
activeGoalAfterMessages.lastAssistantMessageID = latestAssistantID
|
|
534
890
|
|
|
535
891
|
if (goalIsComplete(latestText)) {
|
|
536
892
|
activeGoalAfterMessages.lastStatus = "Goal completed."
|
|
893
|
+
pushHistory(activeGoalAfterMessages, "completed", "Assistant marked the goal complete.")
|
|
537
894
|
rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved")
|
|
538
895
|
cleanupGoal(sessionID)
|
|
896
|
+
await persist()
|
|
539
897
|
return
|
|
540
898
|
}
|
|
541
899
|
|
|
@@ -544,6 +902,12 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
544
902
|
activeGoalAfterMessages.lastStatus = "Assistant reported blocked."
|
|
545
903
|
activeGoalAfterMessages.stopped = true
|
|
546
904
|
activeGoalAfterMessages.stopReason = "blocked"
|
|
905
|
+
pushHistory(
|
|
906
|
+
activeGoalAfterMessages,
|
|
907
|
+
"blocked",
|
|
908
|
+
activeGoalAfterMessages.blockedReason || "Assistant reported blocked and requested user input.",
|
|
909
|
+
)
|
|
910
|
+
await persist()
|
|
547
911
|
return
|
|
548
912
|
}
|
|
549
913
|
|
|
@@ -554,6 +918,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
554
918
|
activeGoalAfterMessages.stopped = true
|
|
555
919
|
activeGoalAfterMessages.stopReason = limitReason
|
|
556
920
|
activeGoalAfterMessages.lastStatus = `${limitReason}; requested final handoff.`
|
|
921
|
+
pushHistory(activeGoalAfterMessages, "limit", `${limitReason}; requested a final handoff.`)
|
|
557
922
|
await client.session.promptAsync({
|
|
558
923
|
path: { id: sessionID },
|
|
559
924
|
body: { parts: [makeTextPart(buildContinueMessage(activeGoalAfterMessages, { budgetWrapup: true }))] },
|
|
@@ -562,19 +927,44 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
562
927
|
activeGoalAfterMessages.stopped = true
|
|
563
928
|
activeGoalAfterMessages.stopReason = limitReason
|
|
564
929
|
activeGoalAfterMessages.lastStatus = limitReason
|
|
930
|
+
pushHistory(activeGoalAfterMessages, "limit", limitReason)
|
|
565
931
|
}
|
|
932
|
+
await persist()
|
|
566
933
|
return
|
|
567
934
|
}
|
|
568
935
|
|
|
569
|
-
|
|
936
|
+
const lowOutputTurn =
|
|
570
937
|
activeGoalAfterMessages.turnCount > 0 &&
|
|
938
|
+
latestOutputTokens !== null &&
|
|
571
939
|
latestOutputTokens < activeGoalAfterMessages.options.noProgressTokenThreshold
|
|
572
|
-
|
|
940
|
+
const lowOutputLooksStalled =
|
|
941
|
+
lowOutputTurn && (assistantRepeated || !latestText || !assistantChanged)
|
|
942
|
+
if (lowOutputLooksStalled) {
|
|
573
943
|
activeGoalAfterMessages.noProgressTurns += 1
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
944
|
+
if (
|
|
945
|
+
activeGoalAfterMessages.noProgressTurns >=
|
|
946
|
+
activeGoalAfterMessages.options.noProgressTurnsBeforePause
|
|
947
|
+
) {
|
|
948
|
+
activeGoalAfterMessages.stopped = true
|
|
949
|
+
activeGoalAfterMessages.stopReason = "no progress"
|
|
950
|
+
activeGoalAfterMessages.lastStatus = `Goal auto-continue paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s); the latest turn produced ${latestOutputTokens} output token(s). Run /goal resume to continue.`
|
|
951
|
+
pushHistory(
|
|
952
|
+
activeGoalAfterMessages,
|
|
953
|
+
"paused",
|
|
954
|
+
`Paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s) below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens.`,
|
|
955
|
+
)
|
|
956
|
+
await persist()
|
|
957
|
+
return
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
activeGoalAfterMessages.lastStatus = `Low-progress turn detected (${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}); monitoring for another stalled turn before pausing.`
|
|
961
|
+
pushHistory(
|
|
962
|
+
activeGoalAfterMessages,
|
|
963
|
+
"warning",
|
|
964
|
+
`Observed a low-progress turn below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens; grace count ${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}.`,
|
|
965
|
+
)
|
|
966
|
+
} else if (latestOutputTokens !== null || assistantChanged) {
|
|
967
|
+
activeGoalAfterMessages.noProgressTurns = 0
|
|
578
968
|
}
|
|
579
969
|
|
|
580
970
|
const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
|
|
@@ -619,6 +1009,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
619
1009
|
if (activeGoalAfterPrompt) {
|
|
620
1010
|
activeGoalAfterPrompt.promptFailures += 1
|
|
621
1011
|
activeGoalAfterPrompt.lastStatus = message
|
|
1012
|
+
pushHistory(activeGoalAfterPrompt, "error", message)
|
|
622
1013
|
if (activeGoalAfterPrompt.promptFailures >= activeGoalAfterPrompt.options.maxPromptFailures) {
|
|
623
1014
|
activeGoalAfterPrompt.stopped = true
|
|
624
1015
|
activeGoalAfterPrompt.stopReason = "auto-continue failures"
|
|
@@ -628,19 +1019,31 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
628
1019
|
await logPluginError(client, message, response.error)
|
|
629
1020
|
} else {
|
|
630
1021
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID)
|
|
631
|
-
if (activeGoalAfterPrompt)
|
|
1022
|
+
if (activeGoalAfterPrompt) {
|
|
1023
|
+
activeGoalAfterPrompt.promptFailures = 0
|
|
1024
|
+
pushHistory(
|
|
1025
|
+
activeGoalAfterPrompt,
|
|
1026
|
+
budgetWrapup ? "budget-wrapup" : "auto-continue",
|
|
1027
|
+
budgetWrapup
|
|
1028
|
+
? "Sent a final handoff request near the tracked token budget."
|
|
1029
|
+
: `Sent auto-continue prompt ${activeGoalAfterPrompt.turnCount}/${activeGoalAfterPrompt.options.maxTurns}.`,
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
632
1032
|
}
|
|
1033
|
+
await persist()
|
|
633
1034
|
} catch (error) {
|
|
634
1035
|
const activeGoalAfterError = currentGoal(sessionID, goalID)
|
|
635
1036
|
if (activeGoalAfterError) {
|
|
636
1037
|
activeGoalAfterError.promptFailures += 1
|
|
637
1038
|
const message = `Auto-continue failed: ${error?.message || error}`
|
|
638
1039
|
activeGoalAfterError.lastStatus = message
|
|
1040
|
+
pushHistory(activeGoalAfterError, "error", message)
|
|
639
1041
|
if (activeGoalAfterError.promptFailures >= activeGoalAfterError.options.maxPromptFailures) {
|
|
640
1042
|
activeGoalAfterError.stopped = true
|
|
641
1043
|
activeGoalAfterError.stopReason = "auto-continue failures"
|
|
642
1044
|
activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /goal resume to retry.`
|
|
643
1045
|
}
|
|
1046
|
+
await persist()
|
|
644
1047
|
}
|
|
645
1048
|
await logPluginError(client, "Auto-continue failed", error)
|
|
646
1049
|
} finally {
|
|
@@ -656,15 +1059,19 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
656
1059
|
if (goal.stopped) return
|
|
657
1060
|
if (output.system.some((block) => block.includes("<goal_objective>"))) return
|
|
658
1061
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
)
|
|
1062
|
+
const goalBlock = [
|
|
1063
|
+
buildGoalBlock(goal),
|
|
1064
|
+
"Keep working until the goal is fully satisfied.",
|
|
1065
|
+
"When fully satisfied, end the response with `[goal:complete]`.",
|
|
1066
|
+
"If user input is required, explain the blocker in the line immediately before `[goal:blocked]`.",
|
|
1067
|
+
buildLimitWarning(goal),
|
|
1068
|
+
].filter(Boolean).join("\n")
|
|
1069
|
+
|
|
1070
|
+
if (output.system.length === 0) {
|
|
1071
|
+
output.system.push(goalBlock)
|
|
1072
|
+
} else {
|
|
1073
|
+
output.system[0] = `${output.system[0]}\n\n${goalBlock}`
|
|
1074
|
+
}
|
|
668
1075
|
},
|
|
669
1076
|
}
|
|
670
1077
|
}
|
|
@@ -683,6 +1090,8 @@ export const testInternals = {
|
|
|
683
1090
|
currentGoal,
|
|
684
1091
|
escapeGoalText,
|
|
685
1092
|
extractBlockedReason,
|
|
1093
|
+
findLatestAssistantMessage,
|
|
1094
|
+
formatArgumentErrors,
|
|
686
1095
|
formatStatus,
|
|
687
1096
|
getSessionID,
|
|
688
1097
|
goalIsBlocked,
|
|
@@ -691,5 +1100,7 @@ export const testInternals = {
|
|
|
691
1100
|
normalizeOptions,
|
|
692
1101
|
outputTokensForMessage,
|
|
693
1102
|
parseGoalArguments,
|
|
1103
|
+
parsePositiveIntegerStrict,
|
|
1104
|
+
pruneGoalResults,
|
|
694
1105
|
stopReason,
|
|
695
1106
|
}
|