opencode-goal-plugin 0.1.9 → 0.1.11
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/.nvmrc +1 -0
- package/CHANGELOG.md +14 -0
- package/CONTRIBUTING.md +28 -4
- package/README.md +28 -10
- package/SECURITY.md +10 -4
- package/package.json +5 -2
- package/scripts/smoke-command-hook.mjs +49 -0
- package/src/goal-plugin.js +275 -29
package/.nvmrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
22
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.11 — 2026-06-04
|
|
6
|
+
|
|
7
|
+
- Add `npm run smoke`, a package-export smoke test that exercises the `/goal` command hook without invoking a model.
|
|
8
|
+
- Run CI across Node 18, 20, and 22, and wire the package-entry smoke test into the workflow.
|
|
9
|
+
- Harden persisted-state loading with schema validation and explicit skipping of malformed goal/result entries.
|
|
10
|
+
- Make hook handling more defensive around message payload shapes and `system` block normalization.
|
|
11
|
+
- Expand docs around compatibility, release checks, smoke testing, and security reporting fallback.
|
|
12
|
+
|
|
13
|
+
## 0.1.10 — 2026-05-30
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
3
17
|
## 0.1.9 — 2026-05-18
|
|
4
18
|
|
|
5
19
|
> 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.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -4,9 +4,18 @@ Thanks for helping improve `opencode-goal-plugin`.
|
|
|
4
4
|
|
|
5
5
|
## Development
|
|
6
6
|
|
|
7
|
+
Use the pinned Node version when possible:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
nvm use
|
|
11
|
+
```
|
|
12
|
+
|
|
7
13
|
Run the local checks before submitting changes:
|
|
8
14
|
|
|
9
15
|
```sh
|
|
16
|
+
npm test
|
|
17
|
+
npm run test:coverage
|
|
18
|
+
npm run smoke
|
|
10
19
|
npm run check
|
|
11
20
|
npm run pack:check
|
|
12
21
|
```
|
|
@@ -15,11 +24,27 @@ For behavior changes, add or update tests in `test/goal-plugin.test.js`.
|
|
|
15
24
|
|
|
16
25
|
## OpenCode Compatibility
|
|
17
26
|
|
|
18
|
-
This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage or
|
|
27
|
+
This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage, command behavior, or system-prompt transforms:
|
|
19
28
|
|
|
20
29
|
1. Check the current OpenCode plugin and command documentation.
|
|
21
|
-
2.
|
|
22
|
-
3.
|
|
30
|
+
2. Run `npm run smoke` to verify the packaged entrypoint and command hook surface.
|
|
31
|
+
3. Test against a real OpenCode install when possible.
|
|
32
|
+
4. Update the README compatibility snapshot if the tested surface changes.
|
|
33
|
+
|
|
34
|
+
`npm run smoke` verifies the package export path and `/goal` command hook without invoking a model. It does not replace a real OpenCode smoke test after hook or command behavior changes.
|
|
35
|
+
|
|
36
|
+
## Release checklist
|
|
37
|
+
|
|
38
|
+
Before publishing or tagging a release:
|
|
39
|
+
|
|
40
|
+
- update `CHANGELOG.md`
|
|
41
|
+
- run `npm test`
|
|
42
|
+
- run `npm run test:coverage`
|
|
43
|
+
- run `npm run smoke`
|
|
44
|
+
- run `npm run check`
|
|
45
|
+
- run `npm run pack:check`
|
|
46
|
+
- perform at least one manual OpenCode smoke test if hook behavior changed
|
|
47
|
+
- refresh compatibility notes if the tested OpenCode surface changed
|
|
23
48
|
|
|
24
49
|
## Pull Requests
|
|
25
50
|
|
|
@@ -29,4 +54,3 @@ Keep pull requests focused. Include:
|
|
|
29
54
|
- why it changed
|
|
30
55
|
- the checks you ran
|
|
31
56
|
- any manual OpenCode smoke testing performed
|
|
32
|
-
|
package/README.md
CHANGED
|
@@ -4,7 +4,16 @@ 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:
|
|
7
|
+
Compatibility: this plugin relies on experimental OpenCode hooks. Re-test against the exact OpenCode build and provider/backend stack you plan to use for unattended work.
|
|
8
|
+
|
|
9
|
+
## Compatibility snapshot
|
|
10
|
+
|
|
11
|
+
| Surface | Status |
|
|
12
|
+
|---|---|
|
|
13
|
+
| Node.js | Declared support: `>=18`; CI covers Node 18, 20, and 22 |
|
|
14
|
+
| Package entrypoint | `npm run smoke` verifies the package export path plus `/goal` command-hook behavior from a local install without invoking a model |
|
|
15
|
+
| OpenCode host | Manually smoke-tested against OpenCode 1.15.10 using the `opencode-go` provider (`qwen3.7-plus`) on this repo's local hardening branch; re-test your own version/provider stack before relying on unattended runs |
|
|
16
|
+
| Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |
|
|
8
17
|
|
|
9
18
|
## Install
|
|
10
19
|
|
|
@@ -209,19 +218,28 @@ Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode wil
|
|
|
209
218
|
|
|
210
219
|
### Smoke-test checklist
|
|
211
220
|
|
|
212
|
-
1.
|
|
213
|
-
2.
|
|
214
|
-
3.
|
|
215
|
-
4. Run `/goal
|
|
216
|
-
5.
|
|
221
|
+
1. Run `npm run smoke` to verify the package export path and `/goal` command hook without a model call.
|
|
222
|
+
2. Install or file-load the plugin in a temporary OpenCode config.
|
|
223
|
+
3. Add a `goal` command with `"template": "$ARGUMENTS"`.
|
|
224
|
+
4. Run `/goal status` — should report no active goal.
|
|
225
|
+
5. Run `/goal inspect this repo and stop immediately with [goal:blocked] if you need user input`.
|
|
226
|
+
6. Verify `/goal status`, `/goal pause`, `/goal resume`, and `/goal clear` behave as expected.
|
|
227
|
+
7. If you changed hook payload handling or command behavior, repeat the smoke test against the exact OpenCode version and provider/backend combination you care about.
|
|
228
|
+
|
|
229
|
+
### Release checklist
|
|
230
|
+
|
|
231
|
+
- Confirm `npm test`, `npm run test:coverage`, `npm run check`, `npm run smoke`, and `npm run pack:check` all pass.
|
|
232
|
+
- Re-test against a real OpenCode install when touching command hooks, idle-event handling, or system-prompt transforms.
|
|
233
|
+
- Update compatibility notes and changelog entries when behavior or tested surfaces change.
|
|
217
234
|
|
|
218
235
|
## Development
|
|
219
236
|
|
|
220
237
|
```sh
|
|
221
|
-
npm test
|
|
222
|
-
npm run test:coverage
|
|
223
|
-
npm run
|
|
224
|
-
npm run
|
|
238
|
+
npm test # run the test suite
|
|
239
|
+
npm run test:coverage # run tests with coverage
|
|
240
|
+
npm run smoke # verify package export + command hook without a model call
|
|
241
|
+
npm run check # syntax check + tests
|
|
242
|
+
npm run pack:check # verify package contents before publishing
|
|
225
243
|
```
|
|
226
244
|
|
|
227
245
|
## License
|
package/SECURITY.md
CHANGED
|
@@ -6,13 +6,18 @@ This project is experimental. Security fixes are provided for the latest publish
|
|
|
6
6
|
|
|
7
7
|
## Reporting a Vulnerability
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
GitHub private vulnerability reporting may not always be enabled for this repository.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Until a dedicated private reporting channel is documented here, do **not** open a public issue with exploit details, credentials, local paths, or reproduction steps that could expose user data or local system access.
|
|
12
|
+
|
|
13
|
+
Instead:
|
|
14
|
+
|
|
15
|
+
1. open a minimal public issue asking for a private contact path, or
|
|
16
|
+
2. contact the maintainer through their GitHub profile and request a private handoff.
|
|
12
17
|
|
|
13
18
|
## Scope
|
|
14
19
|
|
|
15
|
-
This plugin does not intentionally read credentials, write files, or execute shell commands. It observes OpenCode session events, injects goal context into prompts, and sends continuation prompts through OpenCode's SDK client.
|
|
20
|
+
This plugin does not intentionally read credentials, write arbitrary user files, or execute shell commands. It observes OpenCode session events, injects goal context into prompts, and sends continuation prompts through OpenCode's SDK client.
|
|
16
21
|
|
|
17
22
|
Relevant security-sensitive areas include:
|
|
18
23
|
|
|
@@ -20,5 +25,6 @@ Relevant security-sensitive areas include:
|
|
|
20
25
|
- unexpected auto-continuation behavior
|
|
21
26
|
- incorrect command or hook handling across OpenCode versions
|
|
22
27
|
- leakage of goal text through logs or status output
|
|
28
|
+
- malformed persisted state causing stale or unexpected goal recovery
|
|
23
29
|
|
|
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
|
|
30
|
+
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 still receives plaintext rather than true privileged structure, but you should still treat goal text as trusted local input rather than pasting in arbitrary third-party content.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-goal-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Session-scoped /goal workflow for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/goal-plugin.js",
|
|
@@ -10,16 +10,19 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"src",
|
|
13
|
+
"scripts",
|
|
13
14
|
"examples",
|
|
14
15
|
"README.md",
|
|
15
16
|
"CHANGELOG.md",
|
|
16
17
|
"CONTRIBUTING.md",
|
|
17
18
|
"SECURITY.md",
|
|
18
|
-
"LICENSE"
|
|
19
|
+
"LICENSE",
|
|
20
|
+
".nvmrc"
|
|
19
21
|
],
|
|
20
22
|
"scripts": {
|
|
21
23
|
"test": "node --test",
|
|
22
24
|
"test:coverage": "node --test --experimental-test-coverage",
|
|
25
|
+
"smoke": "node scripts/smoke-command-hook.mjs",
|
|
23
26
|
"check": "node -c src/goal-plugin.js && npm test",
|
|
24
27
|
"pack:check": "npm pack --dry-run"
|
|
25
28
|
},
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import pluginModule, { GoalPlugin } from "opencode-goal-plugin"
|
|
3
|
+
|
|
4
|
+
const sessionID = `smoke-${Date.now()}`
|
|
5
|
+
const promptCalls = []
|
|
6
|
+
const logCalls = []
|
|
7
|
+
|
|
8
|
+
const client = {
|
|
9
|
+
app: {
|
|
10
|
+
log: async (input) => {
|
|
11
|
+
logCalls.push(input)
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
session: {
|
|
15
|
+
messages: async () => ({ data: [] }),
|
|
16
|
+
promptAsync: async (input) => {
|
|
17
|
+
promptCalls.push(input)
|
|
18
|
+
return {}
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
assert.equal(pluginModule.id, "opencode-goal-plugin")
|
|
24
|
+
assert.equal(pluginModule.server, GoalPlugin)
|
|
25
|
+
|
|
26
|
+
const hooks = await GoalPlugin({ client }, { minDelayMs: 1 })
|
|
27
|
+
assert.equal(typeof hooks["command.execute.before"], "function")
|
|
28
|
+
assert.equal(typeof hooks.event, "function")
|
|
29
|
+
assert.equal(typeof hooks["experimental.chat.system.transform"], "function")
|
|
30
|
+
|
|
31
|
+
const commandHook = hooks["command.execute.before"]
|
|
32
|
+
|
|
33
|
+
async function runGoalCommand(args) {
|
|
34
|
+
const output = { parts: [] }
|
|
35
|
+
await commandHook({ command: "goal", sessionID, arguments: args }, output)
|
|
36
|
+
assert.equal(output.parts.length, 1)
|
|
37
|
+
assert.equal(output.parts[0].type, "text")
|
|
38
|
+
return output.parts[0].text
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
assert.match(await runGoalCommand("status"), /No active goal/)
|
|
42
|
+
assert.match(await runGoalCommand("ship a smoke test --max-turns 1"), /New active goal/)
|
|
43
|
+
assert.match(await runGoalCommand("status"), /Active goal: ship a smoke test/)
|
|
44
|
+
assert.match(await runGoalCommand("clear"), /Goal cleared/)
|
|
45
|
+
assert.match(await runGoalCommand("status"), /No active goal/)
|
|
46
|
+
assert.equal(promptCalls.length, 0)
|
|
47
|
+
assert.equal(logCalls.length, 0)
|
|
48
|
+
|
|
49
|
+
console.log("opencode-goal-plugin command hook smoke passed")
|
package/src/goal-plugin.js
CHANGED
|
@@ -295,6 +295,11 @@ function parsePositiveIntegerStrict(value) {
|
|
|
295
295
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
function toNonNegativeInteger(value, fallback = 0) {
|
|
299
|
+
const parsed = Number(value)
|
|
300
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback
|
|
301
|
+
}
|
|
302
|
+
|
|
298
303
|
function stripWrappingQuotes(value) {
|
|
299
304
|
return value.replace(/^["']|["']$/g, "")
|
|
300
305
|
}
|
|
@@ -358,6 +363,109 @@ function normalizePersistenceOptions(options = {}) {
|
|
|
358
363
|
}
|
|
359
364
|
}
|
|
360
365
|
|
|
366
|
+
function isPlainObject(value) {
|
|
367
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function normalizeTimestamp(value, fallback = Date.now()) {
|
|
371
|
+
const parsed = Number(value)
|
|
372
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function normalizeHistoryEntries(entries) {
|
|
376
|
+
if (!Array.isArray(entries)) return []
|
|
377
|
+
return entries
|
|
378
|
+
.filter(isPlainObject)
|
|
379
|
+
.map((entry) =>
|
|
380
|
+
makeHistoryEntry(
|
|
381
|
+
typeof entry.type === "string" && entry.type.trim() ? entry.type.trim() : "event",
|
|
382
|
+
typeof entry.detail === "string" ? entry.detail : "",
|
|
383
|
+
normalizeTimestamp(entry.timestamp),
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function normalizeCheckpointEntry(entry) {
|
|
389
|
+
if (!isPlainObject(entry)) return null
|
|
390
|
+
const summary = summarizeText(entry.summary)
|
|
391
|
+
if (!summary) return null
|
|
392
|
+
return {
|
|
393
|
+
summary,
|
|
394
|
+
timestamp: normalizeTimestamp(entry.timestamp),
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function normalizeCheckpointEntries(entries) {
|
|
399
|
+
if (!Array.isArray(entries)) return []
|
|
400
|
+
return entries.map(normalizeCheckpointEntry).filter(Boolean)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function normalizePersistedGoal(rawGoal) {
|
|
404
|
+
if (!isPlainObject(rawGoal)) return null
|
|
405
|
+
if (typeof rawGoal.sessionID !== "string" || !rawGoal.sessionID.trim()) return null
|
|
406
|
+
if (typeof rawGoal.condition !== "string" || !rawGoal.condition.trim()) return null
|
|
407
|
+
|
|
408
|
+
const checkpoints = normalizeCheckpointEntries(rawGoal.checkpoints)
|
|
409
|
+
const lastCheckpoint = normalizeCheckpointEntry(rawGoal.lastCheckpoint) || checkpoints.at(-1) || null
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
goalId:
|
|
413
|
+
typeof rawGoal.goalId === "string" && rawGoal.goalId.trim()
|
|
414
|
+
? rawGoal.goalId
|
|
415
|
+
: randomUUID(),
|
|
416
|
+
condition: rawGoal.condition.trim(),
|
|
417
|
+
sessionID: rawGoal.sessionID.trim(),
|
|
418
|
+
turnCount: toNonNegativeInteger(rawGoal.turnCount),
|
|
419
|
+
startedAt: normalizeTimestamp(rawGoal.startedAt),
|
|
420
|
+
totalTokens: toNonNegativeInteger(rawGoal.totalTokens),
|
|
421
|
+
options: normalizeOptions(isPlainObject(rawGoal.options) ? rawGoal.options : {}),
|
|
422
|
+
lastStatus: typeof rawGoal.lastStatus === "string" ? rawGoal.lastStatus : "Goal recovered.",
|
|
423
|
+
lastAssistantText:
|
|
424
|
+
typeof rawGoal.lastAssistantText === "string" ? rawGoal.lastAssistantText : "",
|
|
425
|
+
lastAssistantMessageID:
|
|
426
|
+
typeof rawGoal.lastAssistantMessageID === "string" ? rawGoal.lastAssistantMessageID : "",
|
|
427
|
+
lastContinueAt: toNonNegativeInteger(rawGoal.lastContinueAt),
|
|
428
|
+
lastProgressAt: toNonNegativeInteger(rawGoal.lastProgressAt),
|
|
429
|
+
noProgressTurns: toNonNegativeInteger(rawGoal.noProgressTurns),
|
|
430
|
+
blockedReason: typeof rawGoal.blockedReason === "string" ? rawGoal.blockedReason : "",
|
|
431
|
+
budgetWrapupSent: rawGoal.budgetWrapupSent === true,
|
|
432
|
+
stopped: rawGoal.stopped === true,
|
|
433
|
+
stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
|
|
434
|
+
promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
|
|
435
|
+
messageIDs: Array.isArray(rawGoal.messageIDs)
|
|
436
|
+
? rawGoal.messageIDs.filter((messageID) => typeof messageID === "string" && messageID)
|
|
437
|
+
: [],
|
|
438
|
+
history: normalizeHistoryEntries(rawGoal.history).slice(-MAX_HISTORY_ENTRIES),
|
|
439
|
+
checkpoints: checkpoints.slice(-MAX_CHECKPOINTS),
|
|
440
|
+
lastCheckpoint,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function normalizePersistedResult(rawResult) {
|
|
445
|
+
if (!isPlainObject(rawResult)) return null
|
|
446
|
+
if (typeof rawResult.sessionID !== "string" || !rawResult.sessionID.trim()) return null
|
|
447
|
+
if (typeof rawResult.condition !== "string" || !rawResult.condition.trim()) return null
|
|
448
|
+
|
|
449
|
+
const checkpoints = normalizeCheckpointEntries(rawResult.checkpoints)
|
|
450
|
+
const lastCheckpoint = normalizeCheckpointEntry(rawResult.lastCheckpoint) || checkpoints.at(-1) || null
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
sessionID: rawResult.sessionID.trim(),
|
|
454
|
+
condition: rawResult.condition.trim(),
|
|
455
|
+
state: typeof rawResult.state === "string" && rawResult.state.trim() ? rawResult.state : "unknown",
|
|
456
|
+
reason: typeof rawResult.reason === "string" ? rawResult.reason : "",
|
|
457
|
+
blockedReason: typeof rawResult.blockedReason === "string" ? rawResult.blockedReason : "",
|
|
458
|
+
turnCount: toNonNegativeInteger(rawResult.turnCount),
|
|
459
|
+
totalTokens: toNonNegativeInteger(rawResult.totalTokens),
|
|
460
|
+
startedAt: normalizeTimestamp(rawResult.startedAt),
|
|
461
|
+
finishedAt: normalizeTimestamp(rawResult.finishedAt),
|
|
462
|
+
lastStatus: typeof rawResult.lastStatus === "string" ? rawResult.lastStatus : "",
|
|
463
|
+
lastCheckpoint,
|
|
464
|
+
checkpoints: checkpoints.slice(-MAX_CHECKPOINTS),
|
|
465
|
+
history: normalizeHistoryEntries(rawResult.history).slice(-MAX_HISTORY_ENTRIES),
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
361
469
|
function serializeGoal(goal) {
|
|
362
470
|
return {
|
|
363
471
|
...goal,
|
|
@@ -405,13 +513,47 @@ async function loadPersistedState(persistenceOptions, client) {
|
|
|
405
513
|
return "invalid"
|
|
406
514
|
}
|
|
407
515
|
|
|
516
|
+
if (!Array.isArray(parsed.goals) || !Array.isArray(parsed.results)) {
|
|
517
|
+
await logPluginError(client, "Skipped persisted goal state: malformed goals/results arrays.")
|
|
518
|
+
return "invalid"
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const loadedGoals = []
|
|
522
|
+
let skippedGoals = 0
|
|
523
|
+
for (const rawGoal of parsed.goals) {
|
|
524
|
+
const normalizedGoal = normalizePersistedGoal(rawGoal)
|
|
525
|
+
if (normalizedGoal) {
|
|
526
|
+
loadedGoals.push(normalizedGoal)
|
|
527
|
+
} else {
|
|
528
|
+
skippedGoals += 1
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const loadedResults = []
|
|
533
|
+
let skippedResults = 0
|
|
534
|
+
for (const rawResult of parsed.results) {
|
|
535
|
+
const normalizedResult = normalizePersistedResult(rawResult)
|
|
536
|
+
if (normalizedResult) {
|
|
537
|
+
loadedResults.push(normalizedResult)
|
|
538
|
+
} else {
|
|
539
|
+
skippedResults += 1
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (skippedGoals > 0 || skippedResults > 0) {
|
|
544
|
+
await logPluginError(
|
|
545
|
+
client,
|
|
546
|
+
`Skipped invalid persisted entries: ${skippedGoals} goal(s), ${skippedResults} result(s).`,
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
|
|
408
550
|
clearRuntimeState()
|
|
409
551
|
|
|
410
|
-
for (const goal of
|
|
552
|
+
for (const goal of loadedGoals) {
|
|
411
553
|
goalStates.set(goal.sessionID, deserializeGoal(goal))
|
|
412
554
|
}
|
|
413
555
|
|
|
414
|
-
for (const result of
|
|
556
|
+
for (const result of loadedResults) {
|
|
415
557
|
lastGoalResults.set(result.sessionID, result)
|
|
416
558
|
}
|
|
417
559
|
|
|
@@ -634,12 +776,104 @@ function formatArgumentErrors(errors) {
|
|
|
634
776
|
].join("\n")
|
|
635
777
|
}
|
|
636
778
|
|
|
779
|
+
function messageRole(message) {
|
|
780
|
+
return message?.info?.role || message?.role || ""
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function messageID(message) {
|
|
784
|
+
return message?.info?.id || message?.id || ""
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function messageSessionID(message) {
|
|
788
|
+
return message?.info?.sessionID || message?.sessionID || ""
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function messageTokens(message) {
|
|
792
|
+
return isPlainObject(message?.info?.tokens)
|
|
793
|
+
? message.info.tokens
|
|
794
|
+
: isPlainObject(message?.tokens)
|
|
795
|
+
? message.tokens
|
|
796
|
+
: {}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function totalTokensForMessage(message) {
|
|
800
|
+
const tokens = messageTokens(message)
|
|
801
|
+
return (
|
|
802
|
+
toNonNegativeInteger(tokens.input) +
|
|
803
|
+
toNonNegativeInteger(tokens.output) +
|
|
804
|
+
toNonNegativeInteger(tokens.reasoning)
|
|
805
|
+
)
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function messageInfoFromEvent(event) {
|
|
809
|
+
const candidates = [
|
|
810
|
+
event?.properties?.info,
|
|
811
|
+
event?.properties?.message?.info,
|
|
812
|
+
event?.properties?.message,
|
|
813
|
+
]
|
|
814
|
+
return candidates.find(isPlainObject) || null
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function appendGoalToSystemBlock(block, goalBlock) {
|
|
818
|
+
if (typeof block === "string") {
|
|
819
|
+
return `${block}\n\n${goalBlock}`
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (!isPlainObject(block)) return null
|
|
823
|
+
|
|
824
|
+
if (typeof block.text === "string") {
|
|
825
|
+
return {
|
|
826
|
+
...block,
|
|
827
|
+
text: `${block.text}\n\n${goalBlock}`,
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (typeof block.content === "string") {
|
|
832
|
+
return {
|
|
833
|
+
...block,
|
|
834
|
+
content: `${block.content}\n\n${goalBlock}`,
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (Array.isArray(block.content)) {
|
|
839
|
+
const content = [...block.content]
|
|
840
|
+
const firstTextIndex = content.findIndex(
|
|
841
|
+
(part) => isPlainObject(part) && typeof part.text === "string",
|
|
842
|
+
)
|
|
843
|
+
if (firstTextIndex >= 0) {
|
|
844
|
+
content[firstTextIndex] = {
|
|
845
|
+
...content[firstTextIndex],
|
|
846
|
+
text: `${content[firstTextIndex].text}\n\n${goalBlock}`,
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
...block,
|
|
850
|
+
content,
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return null
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function systemBlockContainsGoal(block) {
|
|
859
|
+
if (typeof block === "string") return block.includes("<goal_objective>")
|
|
860
|
+
if (!isPlainObject(block)) return false
|
|
861
|
+
if (typeof block.text === "string") return block.text.includes("<goal_objective>")
|
|
862
|
+
if (typeof block.content === "string") return block.content.includes("<goal_objective>")
|
|
863
|
+
if (Array.isArray(block.content)) {
|
|
864
|
+
return block.content.some(
|
|
865
|
+
(part) => isPlainObject(part) && typeof part.text === "string" && part.text.includes("<goal_objective>"),
|
|
866
|
+
)
|
|
867
|
+
}
|
|
868
|
+
return false
|
|
869
|
+
}
|
|
870
|
+
|
|
637
871
|
function findLatestAssistantMessage(messages) {
|
|
638
|
-
return [...(messages || [])].reverse().find((message) => message
|
|
872
|
+
return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
|
|
639
873
|
}
|
|
640
874
|
|
|
641
875
|
function outputTokensForMessage(message) {
|
|
642
|
-
return message
|
|
876
|
+
return toNonNegativeInteger(messageTokens(message).output)
|
|
643
877
|
}
|
|
644
878
|
|
|
645
879
|
function budgetWrapupNeeded(goal) {
|
|
@@ -821,34 +1055,34 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
821
1055
|
|
|
822
1056
|
event: async ({ event }) => {
|
|
823
1057
|
if (event.type === "message.updated") {
|
|
824
|
-
const message = event
|
|
1058
|
+
const message = messageInfoFromEvent(event)
|
|
825
1059
|
if (!message) return
|
|
826
1060
|
|
|
827
|
-
const goal = goalStates.get(message
|
|
1061
|
+
const goal = goalStates.get(messageSessionID(message))
|
|
828
1062
|
if (!goal) return
|
|
829
1063
|
|
|
1064
|
+
const currentMessageID = messageID(message)
|
|
1065
|
+
if (!currentMessageID) return
|
|
1066
|
+
|
|
830
1067
|
let changed = false
|
|
831
|
-
const currentOutputTokens = message
|
|
832
|
-
const previousOutputTokens = seenOutputTokens.get(
|
|
833
|
-
const currentTokens =
|
|
834
|
-
|
|
835
|
-
currentOutputTokens +
|
|
836
|
-
(message.tokens?.reasoning || 0)
|
|
837
|
-
const previousTokens = seenTokens.get(message.id) || 0
|
|
1068
|
+
const currentOutputTokens = outputTokensForMessage(message)
|
|
1069
|
+
const previousOutputTokens = seenOutputTokens.get(currentMessageID) || 0
|
|
1070
|
+
const currentTokens = totalTokensForMessage(message)
|
|
1071
|
+
const previousTokens = seenTokens.get(currentMessageID) || 0
|
|
838
1072
|
if (currentTokens > previousTokens) {
|
|
839
1073
|
goal.totalTokens += currentTokens - previousTokens
|
|
840
|
-
seenTokens.set(
|
|
841
|
-
goal.messageIDs.add(
|
|
1074
|
+
seenTokens.set(currentMessageID, currentTokens)
|
|
1075
|
+
goal.messageIDs.add(currentMessageID)
|
|
842
1076
|
changed = true
|
|
843
1077
|
}
|
|
844
1078
|
|
|
845
1079
|
if (currentOutputTokens > previousOutputTokens) {
|
|
846
|
-
seenOutputTokens.set(
|
|
847
|
-
goal.messageIDs.add(
|
|
1080
|
+
seenOutputTokens.set(currentMessageID, currentOutputTokens)
|
|
1081
|
+
goal.messageIDs.add(currentMessageID)
|
|
848
1082
|
changed = true
|
|
849
1083
|
}
|
|
850
1084
|
|
|
851
|
-
if (message
|
|
1085
|
+
if (messageRole(message) === "assistant" && currentOutputTokens > previousOutputTokens) {
|
|
852
1086
|
goal.lastProgressAt = Date.now()
|
|
853
1087
|
changed = true
|
|
854
1088
|
}
|
|
@@ -1057,17 +1291,29 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1057
1291
|
const goal = goalStates.get(input.sessionID)
|
|
1058
1292
|
if (!goal) return
|
|
1059
1293
|
if (goal.stopped) return
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1294
|
+
const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
|
|
1295
|
+
if (systemBlocks.some(systemBlockContainsGoal)) return
|
|
1296
|
+
|
|
1297
|
+
const goalBlock = [
|
|
1298
|
+
buildGoalBlock(goal),
|
|
1299
|
+
"Keep working until the goal is fully satisfied.",
|
|
1300
|
+
"When fully satisfied, end the response with `[goal:complete]`.",
|
|
1301
|
+
"If user input is required, explain the blocker in the line immediately before `[goal:blocked]`.",
|
|
1302
|
+
buildLimitWarning(goal),
|
|
1303
|
+
].filter(Boolean).join("\n")
|
|
1304
|
+
|
|
1305
|
+
if (systemBlocks.length === 0) {
|
|
1306
|
+
output.system = [goalBlock]
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const mergedFirstBlock = appendGoalToSystemBlock(systemBlocks[0], goalBlock)
|
|
1311
|
+
if (mergedFirstBlock) {
|
|
1312
|
+
systemBlocks[0] = mergedFirstBlock
|
|
1313
|
+
} else {
|
|
1314
|
+
systemBlocks.unshift(goalBlock)
|
|
1315
|
+
}
|
|
1316
|
+
output.system = systemBlocks
|
|
1071
1317
|
},
|
|
1072
1318
|
}
|
|
1073
1319
|
}
|