opencode-goal-plugin 0.1.7 → 0.1.9
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 +14 -0
- package/README.md +46 -7
- package/SECURITY.md +1 -1
- package/package.json +2 -1
- package/src/goal-plugin.js +450 -39
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.9 — 2026-05-18
|
|
4
|
+
|
|
5
|
+
> 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.
|
|
6
|
+
|
|
7
|
+
- Persist active goals and recent results to `~/.opencode-goal-plugin/state.json` by default, with recovered goals loaded in a paused state.
|
|
8
|
+
- Add `/goal history` plus richer `/goal status` output with recent checkpoint and suggested-next-action hints.
|
|
9
|
+
- Replace one-shot low-output pausing with a configurable consecutive-stall grace window via `noProgressTurnsBeforePause` / `--no-progress-turns`.
|
|
10
|
+
- Expand tests to cover history output, persistence recovery, repeated-stall pausing, and changing short assistant updates.
|
|
11
|
+
|
|
12
|
+
## 0.1.8 — 2026-05-18
|
|
13
|
+
|
|
14
|
+
- Harden `--max-minutes` fallback arithmetic when mixed with millisecond duration overrides.
|
|
15
|
+
- Clarify plugin-default config merging and goal-text trust guidance.
|
|
16
|
+
|
|
3
17
|
## 0.1.7 — 2026-05-18
|
|
4
18
|
|
|
5
19
|
- Accept bare final-line `goal:complete` and `goal:blocked` markers in addition to canonical bracketed markers, matching observed model output during smoke testing.
|
package/README.md
CHANGED
|
@@ -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,10 +135,19 @@ 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
|
|
|
127
|
-
Pass options when registering the plugin to change the defaults for all goals
|
|
150
|
+
Pass options when registering the plugin to change the defaults for all goals. To combine with the `goal` command, merge this plugin entry into the config shown above.
|
|
128
151
|
|
|
129
152
|
```json
|
|
130
153
|
{
|
|
@@ -136,15 +159,30 @@ Pass options when registering the plugin to change the defaults for all goals:
|
|
|
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/SECURITY.md
CHANGED
|
@@ -21,4 +21,4 @@ Relevant security-sensitive areas include:
|
|
|
21
21
|
- incorrect command or hook handling across OpenCode versions
|
|
22
22
|
- leakage of goal text through logs or status output
|
|
23
23
|
|
|
24
|
-
The goal text is wrapped in `<goal_objective>` tags and the closing tag is escaped before insertion. Other structural tags used in continuation prompts (`<goal_continuation>`, `<progress_budget>`, etc.) are not escaped. Crafted goal text containing those literal strings would close the tag early in the plaintext prompt; the model treats it as text rather than structure, so the practical risk for a local single-user tool is negligible.
|
|
24
|
+
The goal text is wrapped in `<goal_objective>` tags and the closing tag is escaped before insertion. Other structural tags used in continuation prompts (`<goal_continuation>`, `<progress_budget>`, etc.) are not escaped. Crafted goal text containing those literal strings would close the tag early in the plaintext prompt; the model treats it as text rather than structure, so the practical risk for a local single-user tool is negligible. Do not paste untrusted third-party text into a goal; treat goal text as if you typed it directly into the assistant.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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,43 +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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (
|
|
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
|
|
492
|
+
}
|
|
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)
|
|
235
510
|
continue
|
|
236
511
|
}
|
|
237
512
|
|
|
238
|
-
condition.push(part
|
|
513
|
+
condition.push(stripWrappingQuotes(part))
|
|
239
514
|
}
|
|
240
515
|
|
|
241
516
|
return {
|
|
242
517
|
condition: condition.join(" ").trim(),
|
|
243
518
|
options,
|
|
519
|
+
errors,
|
|
244
520
|
}
|
|
245
521
|
}
|
|
246
522
|
|
|
@@ -348,6 +624,20 @@ function extractBlockedReason(text) {
|
|
|
348
624
|
.find((line) => line.trim())?.trim() || ""
|
|
349
625
|
}
|
|
350
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
|
+
|
|
351
641
|
function outputTokensForMessage(message) {
|
|
352
642
|
return message?.info?.tokens?.output || 0
|
|
353
643
|
}
|
|
@@ -361,6 +651,15 @@ function budgetWrapupNeeded(goal) {
|
|
|
361
651
|
|
|
362
652
|
export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
363
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
|
+
}
|
|
364
663
|
|
|
365
664
|
return {
|
|
366
665
|
"command.execute.before": async (input, output) => {
|
|
@@ -368,6 +667,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
368
667
|
|
|
369
668
|
const args = (input.arguments || "").trim()
|
|
370
669
|
const sessionID = input.sessionID
|
|
670
|
+
pruneGoalResults(defaultGoalOptions)
|
|
371
671
|
|
|
372
672
|
if (!args || args === "status") {
|
|
373
673
|
const goal = goalStates.get(sessionID)
|
|
@@ -384,9 +684,37 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
384
684
|
return
|
|
385
685
|
}
|
|
386
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
|
+
|
|
387
714
|
if (CLEAR_COMMANDS.has(args)) {
|
|
388
715
|
cleanupGoal(sessionID)
|
|
389
716
|
lastGoalResults.delete(sessionID)
|
|
717
|
+
await persist()
|
|
390
718
|
output.parts = [makeTextPart("Goal cleared.")]
|
|
391
719
|
return
|
|
392
720
|
}
|
|
@@ -400,6 +728,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
400
728
|
goal.stopped = true
|
|
401
729
|
goal.stopReason = "paused"
|
|
402
730
|
goal.lastStatus = "Goal paused."
|
|
731
|
+
pushHistory(goal, "paused", "User paused the active goal.")
|
|
732
|
+
await persist()
|
|
403
733
|
output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)]
|
|
404
734
|
return
|
|
405
735
|
}
|
|
@@ -420,11 +750,17 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
420
750
|
goal.stopReason = ""
|
|
421
751
|
goal.blockedReason = ""
|
|
422
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()
|
|
423
755
|
output.parts = [makeTextPart(`Goal resumed with fresh limits: ${goal.condition}`)]
|
|
424
756
|
return
|
|
425
757
|
}
|
|
426
758
|
|
|
427
759
|
const parsed = parseGoalArguments(args, defaultGoalOptions)
|
|
760
|
+
if (parsed.errors.length > 0) {
|
|
761
|
+
output.parts = [makeTextPart(formatArgumentErrors(parsed.errors))]
|
|
762
|
+
return
|
|
763
|
+
}
|
|
428
764
|
if (!parsed.condition) {
|
|
429
765
|
output.parts = [makeTextPart("No goal provided. Set one with `/goal <condition>`.")]
|
|
430
766
|
return
|
|
@@ -440,6 +776,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
440
776
|
options: parsed.options,
|
|
441
777
|
lastStatus: "Goal set.",
|
|
442
778
|
lastAssistantText: "",
|
|
779
|
+
lastAssistantMessageID: "",
|
|
443
780
|
lastContinueAt: 0,
|
|
444
781
|
lastProgressAt: 0,
|
|
445
782
|
noProgressTurns: 0,
|
|
@@ -449,11 +786,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
449
786
|
stopReason: "",
|
|
450
787
|
promptFailures: 0,
|
|
451
788
|
messageIDs: new Set(),
|
|
789
|
+
history: [],
|
|
790
|
+
checkpoints: [],
|
|
791
|
+
lastCheckpoint: null,
|
|
452
792
|
}
|
|
453
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
|
+
|
|
454
800
|
cleanupGoal(sessionID)
|
|
455
801
|
lastGoalResults.delete(sessionID)
|
|
456
802
|
goalStates.set(sessionID, goal)
|
|
803
|
+
await persist()
|
|
457
804
|
output.parts = [
|
|
458
805
|
makeTextPart(
|
|
459
806
|
[
|
|
@@ -462,6 +809,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
462
809
|
"Start working toward this goal now.",
|
|
463
810
|
"When the goal is fully satisfied, end your response with `[goal:complete]`.",
|
|
464
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.",
|
|
465
813
|
"",
|
|
466
814
|
`Limits: ${goal.options.maxTurns} auto-continues, ${Math.round(
|
|
467
815
|
goal.options.maxDurationMs / 1000,
|
|
@@ -479,6 +827,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
479
827
|
const goal = goalStates.get(message.sessionID)
|
|
480
828
|
if (!goal) return
|
|
481
829
|
|
|
830
|
+
let changed = false
|
|
482
831
|
const currentOutputTokens = message.tokens?.output || 0
|
|
483
832
|
const previousOutputTokens = seenOutputTokens.get(message.id) || 0
|
|
484
833
|
const currentTokens =
|
|
@@ -490,17 +839,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
490
839
|
goal.totalTokens += currentTokens - previousTokens
|
|
491
840
|
seenTokens.set(message.id, currentTokens)
|
|
492
841
|
goal.messageIDs.add(message.id)
|
|
842
|
+
changed = true
|
|
493
843
|
}
|
|
494
844
|
|
|
495
845
|
if (currentOutputTokens > previousOutputTokens) {
|
|
496
846
|
seenOutputTokens.set(message.id, currentOutputTokens)
|
|
497
847
|
goal.messageIDs.add(message.id)
|
|
848
|
+
changed = true
|
|
498
849
|
}
|
|
499
850
|
|
|
500
851
|
if (message.role === "assistant" && currentOutputTokens > previousOutputTokens) {
|
|
501
852
|
goal.lastProgressAt = Date.now()
|
|
502
|
-
|
|
853
|
+
changed = true
|
|
503
854
|
}
|
|
855
|
+
|
|
856
|
+
if (changed) await persist()
|
|
504
857
|
return
|
|
505
858
|
}
|
|
506
859
|
|
|
@@ -515,23 +868,32 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
515
868
|
try {
|
|
516
869
|
const messages = await client.session.messages({
|
|
517
870
|
path: { id: sessionID },
|
|
518
|
-
query: { limit:
|
|
871
|
+
query: { limit: goal.options.maxRecentMessages },
|
|
519
872
|
})
|
|
520
873
|
const activeGoalAfterMessages = currentGoal(sessionID, goalID)
|
|
521
874
|
if (!activeGoalAfterMessages) return
|
|
522
875
|
|
|
523
|
-
const latestAssistant =
|
|
524
|
-
|
|
525
|
-
.find((message) => message.info?.role === "assistant")
|
|
876
|
+
const latestAssistant = findLatestAssistantMessage(messages.data)
|
|
877
|
+
const latestAssistantID = latestAssistant?.info?.id || ""
|
|
526
878
|
const latestText = getText(latestAssistant?.parts)
|
|
527
|
-
const latestOutputTokens = outputTokensForMessage(latestAssistant)
|
|
528
|
-
|
|
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
|
+
}
|
|
529
888
|
activeGoalAfterMessages.lastAssistantText = latestText
|
|
889
|
+
activeGoalAfterMessages.lastAssistantMessageID = latestAssistantID
|
|
530
890
|
|
|
531
891
|
if (goalIsComplete(latestText)) {
|
|
532
892
|
activeGoalAfterMessages.lastStatus = "Goal completed."
|
|
893
|
+
pushHistory(activeGoalAfterMessages, "completed", "Assistant marked the goal complete.")
|
|
533
894
|
rememberGoalResult(sessionID, activeGoalAfterMessages, "achieved")
|
|
534
895
|
cleanupGoal(sessionID)
|
|
896
|
+
await persist()
|
|
535
897
|
return
|
|
536
898
|
}
|
|
537
899
|
|
|
@@ -540,6 +902,12 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
540
902
|
activeGoalAfterMessages.lastStatus = "Assistant reported blocked."
|
|
541
903
|
activeGoalAfterMessages.stopped = true
|
|
542
904
|
activeGoalAfterMessages.stopReason = "blocked"
|
|
905
|
+
pushHistory(
|
|
906
|
+
activeGoalAfterMessages,
|
|
907
|
+
"blocked",
|
|
908
|
+
activeGoalAfterMessages.blockedReason || "Assistant reported blocked and requested user input.",
|
|
909
|
+
)
|
|
910
|
+
await persist()
|
|
543
911
|
return
|
|
544
912
|
}
|
|
545
913
|
|
|
@@ -550,6 +918,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
550
918
|
activeGoalAfterMessages.stopped = true
|
|
551
919
|
activeGoalAfterMessages.stopReason = limitReason
|
|
552
920
|
activeGoalAfterMessages.lastStatus = `${limitReason}; requested final handoff.`
|
|
921
|
+
pushHistory(activeGoalAfterMessages, "limit", `${limitReason}; requested a final handoff.`)
|
|
553
922
|
await client.session.promptAsync({
|
|
554
923
|
path: { id: sessionID },
|
|
555
924
|
body: { parts: [makeTextPart(buildContinueMessage(activeGoalAfterMessages, { budgetWrapup: true }))] },
|
|
@@ -558,19 +927,44 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
558
927
|
activeGoalAfterMessages.stopped = true
|
|
559
928
|
activeGoalAfterMessages.stopReason = limitReason
|
|
560
929
|
activeGoalAfterMessages.lastStatus = limitReason
|
|
930
|
+
pushHistory(activeGoalAfterMessages, "limit", limitReason)
|
|
561
931
|
}
|
|
932
|
+
await persist()
|
|
562
933
|
return
|
|
563
934
|
}
|
|
564
935
|
|
|
565
|
-
|
|
936
|
+
const lowOutputTurn =
|
|
566
937
|
activeGoalAfterMessages.turnCount > 0 &&
|
|
938
|
+
latestOutputTokens !== null &&
|
|
567
939
|
latestOutputTokens < activeGoalAfterMessages.options.noProgressTokenThreshold
|
|
568
|
-
|
|
940
|
+
const lowOutputLooksStalled =
|
|
941
|
+
lowOutputTurn && (assistantRepeated || !latestText || !assistantChanged)
|
|
942
|
+
if (lowOutputLooksStalled) {
|
|
569
943
|
activeGoalAfterMessages.noProgressTurns += 1
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
|
574
968
|
}
|
|
575
969
|
|
|
576
970
|
const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt
|
|
@@ -615,6 +1009,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
615
1009
|
if (activeGoalAfterPrompt) {
|
|
616
1010
|
activeGoalAfterPrompt.promptFailures += 1
|
|
617
1011
|
activeGoalAfterPrompt.lastStatus = message
|
|
1012
|
+
pushHistory(activeGoalAfterPrompt, "error", message)
|
|
618
1013
|
if (activeGoalAfterPrompt.promptFailures >= activeGoalAfterPrompt.options.maxPromptFailures) {
|
|
619
1014
|
activeGoalAfterPrompt.stopped = true
|
|
620
1015
|
activeGoalAfterPrompt.stopReason = "auto-continue failures"
|
|
@@ -624,19 +1019,31 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
624
1019
|
await logPluginError(client, message, response.error)
|
|
625
1020
|
} else {
|
|
626
1021
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID)
|
|
627
|
-
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
|
+
}
|
|
628
1032
|
}
|
|
1033
|
+
await persist()
|
|
629
1034
|
} catch (error) {
|
|
630
1035
|
const activeGoalAfterError = currentGoal(sessionID, goalID)
|
|
631
1036
|
if (activeGoalAfterError) {
|
|
632
1037
|
activeGoalAfterError.promptFailures += 1
|
|
633
1038
|
const message = `Auto-continue failed: ${error?.message || error}`
|
|
634
1039
|
activeGoalAfterError.lastStatus = message
|
|
1040
|
+
pushHistory(activeGoalAfterError, "error", message)
|
|
635
1041
|
if (activeGoalAfterError.promptFailures >= activeGoalAfterError.options.maxPromptFailures) {
|
|
636
1042
|
activeGoalAfterError.stopped = true
|
|
637
1043
|
activeGoalAfterError.stopReason = "auto-continue failures"
|
|
638
1044
|
activeGoalAfterError.lastStatus = `${message}; paused after ${activeGoalAfterError.promptFailures} failure(s). Run /goal resume to retry.`
|
|
639
1045
|
}
|
|
1046
|
+
await persist()
|
|
640
1047
|
}
|
|
641
1048
|
await logPluginError(client, "Auto-continue failed", error)
|
|
642
1049
|
} finally {
|
|
@@ -679,6 +1086,8 @@ export const testInternals = {
|
|
|
679
1086
|
currentGoal,
|
|
680
1087
|
escapeGoalText,
|
|
681
1088
|
extractBlockedReason,
|
|
1089
|
+
findLatestAssistantMessage,
|
|
1090
|
+
formatArgumentErrors,
|
|
682
1091
|
formatStatus,
|
|
683
1092
|
getSessionID,
|
|
684
1093
|
goalIsBlocked,
|
|
@@ -687,5 +1096,7 @@ export const testInternals = {
|
|
|
687
1096
|
normalizeOptions,
|
|
688
1097
|
outputTokensForMessage,
|
|
689
1098
|
parseGoalArguments,
|
|
1099
|
+
parsePositiveIntegerStrict,
|
|
1100
|
+
pruneGoalResults,
|
|
690
1101
|
stopReason,
|
|
691
1102
|
}
|