opencode-goal-plugin 0.1.10 → 0.1.12
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 +13 -0
- package/CONTRIBUTING.md +33 -4
- package/README.md +22 -10
- package/SECURITY.md +10 -4
- package/package.json +5 -2
- package/scripts/smoke-command-hook.mjs +49 -0
- package/src/goal-plugin.js +267 -23
package/.nvmrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
22
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.12 — 2026-06-08
|
|
6
|
+
|
|
7
|
+
- Harden `escapeGoalText` to escape all XML closing tags (`</` → `<\/`) instead of only `</goal_objective>`, closing a prompt-injection path where user-supplied goal text could break structural framing in the continuation message.
|
|
8
|
+
- Add unit tests for `outputTokensForMessage`, `budgetWrapupNeeded`, `getSessionID`, `stopReason`, `normalizeOptions` boundary inputs (zero, negative, NaN, null, `budgetWrapupRatio` at 0 and 1), and `escapeGoalText` covering all structural tags.
|
|
9
|
+
|
|
10
|
+
## 0.1.11 — 2026-06-04
|
|
11
|
+
|
|
12
|
+
- Add `npm run smoke`, a package-export smoke test that exercises the `/goal` command hook without invoking a model.
|
|
13
|
+
- Run CI across Node 18, 20, and 22, and wire the package-entry smoke test into the workflow.
|
|
14
|
+
- Harden persisted-state loading with schema validation and explicit skipping of malformed goal/result entries.
|
|
15
|
+
- Make hook handling more defensive around message payload shapes and `system` block normalization.
|
|
16
|
+
- Expand docs around compatibility, release checks, smoke testing, and security reporting fallback.
|
|
17
|
+
|
|
5
18
|
## 0.1.10 — 2026-05-30
|
|
6
19
|
|
|
7
20
|
- 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.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -4,10 +4,20 @@ 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
|
|
20
|
+
npm run smoke
|
|
11
21
|
npm run pack:check
|
|
12
22
|
```
|
|
13
23
|
|
|
@@ -15,11 +25,31 @@ For behavior changes, add or update tests in `test/goal-plugin.test.js`.
|
|
|
15
25
|
|
|
16
26
|
## OpenCode Compatibility
|
|
17
27
|
|
|
18
|
-
This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage or
|
|
28
|
+
This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage, command behavior, or system-prompt transforms:
|
|
19
29
|
|
|
20
30
|
1. Check the current OpenCode plugin and command documentation.
|
|
21
|
-
2.
|
|
22
|
-
3.
|
|
31
|
+
2. Run `npm run smoke` to verify the packaged entrypoint and command hook surface.
|
|
32
|
+
3. Test against a real OpenCode install when possible.
|
|
33
|
+
4. Update the README compatibility snapshot if the tested surface changes.
|
|
34
|
+
|
|
35
|
+
`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.
|
|
36
|
+
|
|
37
|
+
## Release checklist
|
|
38
|
+
|
|
39
|
+
Before publishing or tagging a release:
|
|
40
|
+
|
|
41
|
+
- update `CHANGELOG.md`
|
|
42
|
+
- run `npm test`
|
|
43
|
+
- run `npm run test:coverage`
|
|
44
|
+
- run `npm run smoke`
|
|
45
|
+
- run `npm run check`
|
|
46
|
+
- run `npm run pack:check`
|
|
47
|
+
- perform at least one manual OpenCode smoke test if hook behavior changed
|
|
48
|
+
- refresh compatibility notes if the tested OpenCode surface changed
|
|
49
|
+
|
|
50
|
+
`npm run smoke` verifies the published package entrypoint and `/goal` command hook without
|
|
51
|
+
invoking a model. It does not replace a manual OpenCode smoke test after hook or command
|
|
52
|
+
behavior changes.
|
|
23
53
|
|
|
24
54
|
## Pull Requests
|
|
25
55
|
|
|
@@ -29,4 +59,3 @@ Keep pull requests focused. Include:
|
|
|
29
59
|
- why it changed
|
|
30
60
|
- the checks you ran
|
|
31
61
|
- 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,22 @@ 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.
|
|
217
228
|
|
|
218
229
|
## Development
|
|
219
230
|
|
|
220
231
|
```sh
|
|
221
|
-
npm test
|
|
222
|
-
npm run test:coverage
|
|
223
|
-
npm run
|
|
224
|
-
npm run
|
|
232
|
+
npm test # run the test suite
|
|
233
|
+
npm run test:coverage # run tests with coverage
|
|
234
|
+
npm run smoke # verify package export + command hook without a model call
|
|
235
|
+
npm run check # syntax check + tests
|
|
236
|
+
npm run pack:check # verify package contents before publishing
|
|
225
237
|
```
|
|
226
238
|
|
|
227
239
|
## 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.12",
|
|
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
|
|
|
@@ -544,7 +686,9 @@ function buildLimitWarning(goal) {
|
|
|
544
686
|
}
|
|
545
687
|
|
|
546
688
|
function escapeGoalText(text) {
|
|
547
|
-
|
|
689
|
+
// Escape every XML closing tag so user-supplied goal text cannot break the
|
|
690
|
+
// structural framing used in buildGoalBlock and buildContinueMessage.
|
|
691
|
+
return String(text).replaceAll("</", "<\\/")
|
|
548
692
|
}
|
|
549
693
|
|
|
550
694
|
function buildGoalBlock(goal) {
|
|
@@ -634,12 +778,104 @@ function formatArgumentErrors(errors) {
|
|
|
634
778
|
].join("\n")
|
|
635
779
|
}
|
|
636
780
|
|
|
781
|
+
function messageRole(message) {
|
|
782
|
+
return message?.info?.role || message?.role || ""
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function messageID(message) {
|
|
786
|
+
return message?.info?.id || message?.id || ""
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function messageSessionID(message) {
|
|
790
|
+
return message?.info?.sessionID || message?.sessionID || ""
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function messageTokens(message) {
|
|
794
|
+
return isPlainObject(message?.info?.tokens)
|
|
795
|
+
? message.info.tokens
|
|
796
|
+
: isPlainObject(message?.tokens)
|
|
797
|
+
? message.tokens
|
|
798
|
+
: {}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function totalTokensForMessage(message) {
|
|
802
|
+
const tokens = messageTokens(message)
|
|
803
|
+
return (
|
|
804
|
+
toNonNegativeInteger(tokens.input) +
|
|
805
|
+
toNonNegativeInteger(tokens.output) +
|
|
806
|
+
toNonNegativeInteger(tokens.reasoning)
|
|
807
|
+
)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function messageInfoFromEvent(event) {
|
|
811
|
+
const candidates = [
|
|
812
|
+
event?.properties?.info,
|
|
813
|
+
event?.properties?.message?.info,
|
|
814
|
+
event?.properties?.message,
|
|
815
|
+
]
|
|
816
|
+
return candidates.find(isPlainObject) || null
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function appendGoalToSystemBlock(block, goalBlock) {
|
|
820
|
+
if (typeof block === "string") {
|
|
821
|
+
return `${block}\n\n${goalBlock}`
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (!isPlainObject(block)) return null
|
|
825
|
+
|
|
826
|
+
if (typeof block.text === "string") {
|
|
827
|
+
return {
|
|
828
|
+
...block,
|
|
829
|
+
text: `${block.text}\n\n${goalBlock}`,
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (typeof block.content === "string") {
|
|
834
|
+
return {
|
|
835
|
+
...block,
|
|
836
|
+
content: `${block.content}\n\n${goalBlock}`,
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (Array.isArray(block.content)) {
|
|
841
|
+
const content = [...block.content]
|
|
842
|
+
const firstTextIndex = content.findIndex(
|
|
843
|
+
(part) => isPlainObject(part) && typeof part.text === "string",
|
|
844
|
+
)
|
|
845
|
+
if (firstTextIndex >= 0) {
|
|
846
|
+
content[firstTextIndex] = {
|
|
847
|
+
...content[firstTextIndex],
|
|
848
|
+
text: `${content[firstTextIndex].text}\n\n${goalBlock}`,
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
...block,
|
|
852
|
+
content,
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return null
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function systemBlockContainsGoal(block) {
|
|
861
|
+
if (typeof block === "string") return block.includes("<goal_objective>")
|
|
862
|
+
if (!isPlainObject(block)) return false
|
|
863
|
+
if (typeof block.text === "string") return block.text.includes("<goal_objective>")
|
|
864
|
+
if (typeof block.content === "string") return block.content.includes("<goal_objective>")
|
|
865
|
+
if (Array.isArray(block.content)) {
|
|
866
|
+
return block.content.some(
|
|
867
|
+
(part) => isPlainObject(part) && typeof part.text === "string" && part.text.includes("<goal_objective>"),
|
|
868
|
+
)
|
|
869
|
+
}
|
|
870
|
+
return false
|
|
871
|
+
}
|
|
872
|
+
|
|
637
873
|
function findLatestAssistantMessage(messages) {
|
|
638
|
-
return [...(messages || [])].reverse().find((message) => message
|
|
874
|
+
return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null
|
|
639
875
|
}
|
|
640
876
|
|
|
641
877
|
function outputTokensForMessage(message) {
|
|
642
|
-
return message
|
|
878
|
+
return toNonNegativeInteger(messageTokens(message).output)
|
|
643
879
|
}
|
|
644
880
|
|
|
645
881
|
function budgetWrapupNeeded(goal) {
|
|
@@ -821,34 +1057,34 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
821
1057
|
|
|
822
1058
|
event: async ({ event }) => {
|
|
823
1059
|
if (event.type === "message.updated") {
|
|
824
|
-
const message = event
|
|
1060
|
+
const message = messageInfoFromEvent(event)
|
|
825
1061
|
if (!message) return
|
|
826
1062
|
|
|
827
|
-
const goal = goalStates.get(message
|
|
1063
|
+
const goal = goalStates.get(messageSessionID(message))
|
|
828
1064
|
if (!goal) return
|
|
829
1065
|
|
|
1066
|
+
const currentMessageID = messageID(message)
|
|
1067
|
+
if (!currentMessageID) return
|
|
1068
|
+
|
|
830
1069
|
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
|
|
1070
|
+
const currentOutputTokens = outputTokensForMessage(message)
|
|
1071
|
+
const previousOutputTokens = seenOutputTokens.get(currentMessageID) || 0
|
|
1072
|
+
const currentTokens = totalTokensForMessage(message)
|
|
1073
|
+
const previousTokens = seenTokens.get(currentMessageID) || 0
|
|
838
1074
|
if (currentTokens > previousTokens) {
|
|
839
1075
|
goal.totalTokens += currentTokens - previousTokens
|
|
840
|
-
seenTokens.set(
|
|
841
|
-
goal.messageIDs.add(
|
|
1076
|
+
seenTokens.set(currentMessageID, currentTokens)
|
|
1077
|
+
goal.messageIDs.add(currentMessageID)
|
|
842
1078
|
changed = true
|
|
843
1079
|
}
|
|
844
1080
|
|
|
845
1081
|
if (currentOutputTokens > previousOutputTokens) {
|
|
846
|
-
seenOutputTokens.set(
|
|
847
|
-
goal.messageIDs.add(
|
|
1082
|
+
seenOutputTokens.set(currentMessageID, currentOutputTokens)
|
|
1083
|
+
goal.messageIDs.add(currentMessageID)
|
|
848
1084
|
changed = true
|
|
849
1085
|
}
|
|
850
1086
|
|
|
851
|
-
if (message
|
|
1087
|
+
if (messageRole(message) === "assistant" && currentOutputTokens > previousOutputTokens) {
|
|
852
1088
|
goal.lastProgressAt = Date.now()
|
|
853
1089
|
changed = true
|
|
854
1090
|
}
|
|
@@ -1057,7 +1293,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1057
1293
|
const goal = goalStates.get(input.sessionID)
|
|
1058
1294
|
if (!goal) return
|
|
1059
1295
|
if (goal.stopped) return
|
|
1060
|
-
|
|
1296
|
+
const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
|
|
1297
|
+
if (systemBlocks.some(systemBlockContainsGoal)) return
|
|
1061
1298
|
|
|
1062
1299
|
const goalBlock = [
|
|
1063
1300
|
buildGoalBlock(goal),
|
|
@@ -1067,11 +1304,18 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1067
1304
|
buildLimitWarning(goal),
|
|
1068
1305
|
].filter(Boolean).join("\n")
|
|
1069
1306
|
|
|
1070
|
-
if (
|
|
1071
|
-
output.system
|
|
1307
|
+
if (systemBlocks.length === 0) {
|
|
1308
|
+
output.system = [goalBlock]
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const mergedFirstBlock = appendGoalToSystemBlock(systemBlocks[0], goalBlock)
|
|
1313
|
+
if (mergedFirstBlock) {
|
|
1314
|
+
systemBlocks[0] = mergedFirstBlock
|
|
1072
1315
|
} else {
|
|
1073
|
-
|
|
1316
|
+
systemBlocks.unshift(goalBlock)
|
|
1074
1317
|
}
|
|
1318
|
+
output.system = systemBlocks
|
|
1075
1319
|
},
|
|
1076
1320
|
}
|
|
1077
1321
|
}
|