@prevalentware/opencode-goal-plugin 0.1.3 → 0.1.5
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/README.md +30 -3
- package/dist/server.js +159 -20
- package/package.json +1 -1
- package/src/tui.tsx +19 -69
package/README.md
CHANGED
|
@@ -4,11 +4,13 @@ Codex-style long-running goal mode for OpenCode.
|
|
|
4
4
|
|
|
5
5
|
This plugin adds:
|
|
6
6
|
|
|
7
|
-
- `/goal
|
|
7
|
+
- `/goal <objective>` as an OpenCode command for TUI, desktop, and web.
|
|
8
8
|
- A sidebar goal indicator with status, elapsed time, token usage, remaining budget, and objective.
|
|
9
9
|
- Agent tools: `get_goal`, `create_goal`, `update_goal`, and `clear_goal`.
|
|
10
|
+
- Goal close evidence: `complete` requires verified evidence, and `unmet` requires a concrete blocker.
|
|
10
11
|
- Persistent per-session goal state.
|
|
11
12
|
- Optional automatic continuation on `session.idle`.
|
|
13
|
+
- Compaction context so active goals are preserved when OpenCode summarizes a long session.
|
|
12
14
|
|
|
13
15
|
## Install
|
|
14
16
|
|
|
@@ -58,7 +60,8 @@ Server options can be configured in `opencode.json`:
|
|
|
58
60
|
{
|
|
59
61
|
"auto_continue": true,
|
|
60
62
|
"max_auto_turns": 25,
|
|
61
|
-
"min_continue_interval_seconds": 3
|
|
63
|
+
"min_continue_interval_seconds": 3,
|
|
64
|
+
"default_token_budget": null
|
|
62
65
|
}
|
|
63
66
|
]
|
|
64
67
|
]
|
|
@@ -70,6 +73,30 @@ Defaults:
|
|
|
70
73
|
- `auto_continue`: `true`
|
|
71
74
|
- `max_auto_turns`: `25`
|
|
72
75
|
- `min_continue_interval_seconds`: `3`
|
|
76
|
+
- `register_command`: `true`
|
|
77
|
+
- `command_name`: `"goal"`
|
|
78
|
+
- `default_token_budget`: `null`
|
|
79
|
+
|
|
80
|
+
## Goal Workflow
|
|
81
|
+
|
|
82
|
+
Use `/goal <objective>` in a fresh OpenCode chat to create a long-running goal:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
/goal review the frontend and translate visible English UI text to Spanish
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Bare `/goal` reports the current goal state. `/goal clear` clears the goal. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, or clearing the current goal state without creating a new goal.
|
|
89
|
+
|
|
90
|
+
By default, `/goal <objective>` omits `token_budget`, matching Codex TUI behavior. If you want every new slash-created goal to use a fixed token budget without prompting the user, set `default_token_budget` to a positive integer in `opencode.json`.
|
|
91
|
+
|
|
92
|
+
When writing the objective, include the scope, non-goals, and verification path when they matter. The agent is reminded to audit real files, command output, tests, or PR state before closing the goal.
|
|
93
|
+
|
|
94
|
+
The `update_goal` tool can close a goal in two ways:
|
|
95
|
+
|
|
96
|
+
- `status: "complete"` with `evidence` when every requirement is actually achieved.
|
|
97
|
+
- `status: "unmet"` with `blocker` when the objective cannot be achieved or is blocked by missing external input.
|
|
98
|
+
|
|
99
|
+
Budget exhaustion does not close a goal by itself. It only marks the goal `budgetLimited` and asks the agent to wrap up with remaining work or blockers.
|
|
73
100
|
|
|
74
101
|
## State
|
|
75
102
|
|
|
@@ -124,4 +151,4 @@ OpenCode plugin modules are target-specific. This package exports separate modul
|
|
|
124
151
|
}
|
|
125
152
|
```
|
|
126
153
|
|
|
127
|
-
Codex goal mode has deeper runtime integration for
|
|
154
|
+
Codex goal mode has deeper runtime integration for thread lifecycle control. This plugin implements the same workflow using OpenCode plugin hooks. Token usage is read from OpenCode step-finish usage when available and falls back to message token metadata or text estimation when exact usage is unavailable. Continuation is driven by OpenCode's `session.idle` event.
|
package/dist/server.js
CHANGED
|
@@ -60,6 +60,17 @@ function validateBudget(tokenBudget) {
|
|
|
60
60
|
}
|
|
61
61
|
return tokenBudget;
|
|
62
62
|
}
|
|
63
|
+
function validateEvidence(evidence, label) {
|
|
64
|
+
const value = evidence?.trim();
|
|
65
|
+
if (!value)
|
|
66
|
+
throw new Error(`${label} must not be empty`);
|
|
67
|
+
if ([...value].length > 4000)
|
|
68
|
+
throw new Error(`${label} must be at most 4000 characters`);
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function isClosed(status) {
|
|
72
|
+
return status === "complete" || status === "unmet";
|
|
73
|
+
}
|
|
63
74
|
function snapshot(goal) {
|
|
64
75
|
const activeSeconds = goal.status === "active" && goal.lastAccountedAt != null ? Math.max(0, nowSeconds() - goal.lastAccountedAt) : 0;
|
|
65
76
|
const timeUsedSeconds = goal.timeUsedSeconds + activeSeconds;
|
|
@@ -72,6 +83,9 @@ function snapshot(goal) {
|
|
|
72
83
|
timeUsedSeconds,
|
|
73
84
|
createdAt: goal.createdAt,
|
|
74
85
|
updatedAt: goal.updatedAt,
|
|
86
|
+
completionEvidence: goal.completionEvidence ?? null,
|
|
87
|
+
blocker: goal.blocker ?? null,
|
|
88
|
+
closedAt: goal.closedAt ?? null,
|
|
75
89
|
remainingTokens: goal.tokenBudget == null ? null : Math.max(0, goal.tokenBudget - goal.tokensUsed)
|
|
76
90
|
};
|
|
77
91
|
}
|
|
@@ -85,8 +99,8 @@ async function createGoal(sessionID, objective, tokenBudget) {
|
|
|
85
99
|
const budget = validateBudget(tokenBudget);
|
|
86
100
|
return mutate((state) => {
|
|
87
101
|
const existing = state.goals[sessionID];
|
|
88
|
-
if (existing && existing.status
|
|
89
|
-
throw new Error("cannot create a new goal because this session already has a non-
|
|
102
|
+
if (existing && !isClosed(existing.status)) {
|
|
103
|
+
throw new Error("cannot create a new goal because this session already has a non-closed goal");
|
|
90
104
|
}
|
|
91
105
|
const now = nowSeconds();
|
|
92
106
|
const goal = {
|
|
@@ -98,6 +112,9 @@ async function createGoal(sessionID, objective, tokenBudget) {
|
|
|
98
112
|
timeUsedSeconds: 0,
|
|
99
113
|
createdAt: now,
|
|
100
114
|
updatedAt: now,
|
|
115
|
+
completionEvidence: null,
|
|
116
|
+
blocker: null,
|
|
117
|
+
closedAt: null,
|
|
101
118
|
lastAccountedAt: now,
|
|
102
119
|
autoTurns: 0,
|
|
103
120
|
lastContinuationAt: null
|
|
@@ -106,20 +123,32 @@ async function createGoal(sessionID, objective, tokenBudget) {
|
|
|
106
123
|
return snapshot(goal);
|
|
107
124
|
});
|
|
108
125
|
}
|
|
109
|
-
async function
|
|
126
|
+
async function closeGoal(sessionID, input) {
|
|
110
127
|
return mutate((state) => {
|
|
111
128
|
const goal = state.goals[sessionID];
|
|
112
129
|
if (!goal)
|
|
113
130
|
throw new Error("cannot update goal because this session has no goal");
|
|
114
131
|
accountWallClock(goal);
|
|
115
|
-
|
|
116
|
-
goal.
|
|
117
|
-
goal.
|
|
132
|
+
const now = nowSeconds();
|
|
133
|
+
goal.status = input.status;
|
|
134
|
+
goal.updatedAt = now;
|
|
135
|
+
goal.closedAt = now;
|
|
136
|
+
goal.lastAccountedAt = null;
|
|
137
|
+
if (input.status === "complete") {
|
|
138
|
+
goal.completionEvidence = validateEvidence(input.evidence, "completion evidence");
|
|
139
|
+
goal.blocker = null;
|
|
140
|
+
} else {
|
|
141
|
+
goal.blocker = validateEvidence(input.blocker, "blocker");
|
|
142
|
+
goal.completionEvidence = null;
|
|
143
|
+
}
|
|
118
144
|
return snapshot(goal);
|
|
119
145
|
});
|
|
120
146
|
}
|
|
121
|
-
async function completeGoal(sessionID) {
|
|
122
|
-
return
|
|
147
|
+
async function completeGoal(sessionID, evidence) {
|
|
148
|
+
return closeGoal(sessionID, { status: "complete", evidence });
|
|
149
|
+
}
|
|
150
|
+
async function markGoalUnmet(sessionID, blocker) {
|
|
151
|
+
return closeGoal(sessionID, { status: "unmet", blocker });
|
|
123
152
|
}
|
|
124
153
|
async function clearGoal(sessionID) {
|
|
125
154
|
return mutate((state) => {
|
|
@@ -182,13 +211,18 @@ function formatGoal(goal) {
|
|
|
182
211
|
if (!goal)
|
|
183
212
|
return "No goal is set for this session.";
|
|
184
213
|
const budget = goal.tokenBudget == null ? "none" : `${goal.tokensUsed} / ${goal.tokenBudget}`;
|
|
185
|
-
|
|
214
|
+
const lines = [
|
|
186
215
|
`Objective: ${goal.objective}`,
|
|
187
216
|
`Status: ${goal.status}`,
|
|
188
217
|
`Tokens: ${budget}`,
|
|
189
218
|
`Remaining tokens: ${goal.remainingTokens ?? "n/a"}`,
|
|
190
219
|
`Time used: ${goal.timeUsedSeconds}s`
|
|
191
|
-
]
|
|
220
|
+
];
|
|
221
|
+
if (goal.completionEvidence)
|
|
222
|
+
lines.push(`Completion evidence: ${goal.completionEvidence}`);
|
|
223
|
+
if (goal.blocker)
|
|
224
|
+
lines.push(`Blocker: ${goal.blocker}`);
|
|
225
|
+
return lines.join(`
|
|
192
226
|
`);
|
|
193
227
|
}
|
|
194
228
|
|
|
@@ -218,7 +252,7 @@ Before deciding that the goal is achieved, perform a completion audit against th
|
|
|
218
252
|
- Identify any missing, incomplete, weakly verified, or uncovered requirement.
|
|
219
253
|
- Treat uncertainty as not achieved; do more verification or continue the work.
|
|
220
254
|
|
|
221
|
-
Do not rely on intent, partial progress, elapsed effort, memory of earlier work, or a plausible final answer as proof of completion. Only call update_goal with status "complete" when the objective has actually been achieved and no required work remains.`;
|
|
255
|
+
Do not rely on intent, partial progress, elapsed effort, memory of earlier work, or a plausible final answer as proof of completion. Only call update_goal with status "complete" when the objective has actually been achieved and no required work remains, and include concise evidence. If the objective is impossible or blocked by missing external input, call update_goal with status "unmet" and include the blocker.`;
|
|
222
256
|
}
|
|
223
257
|
function budgetLimitedPrompt(goal) {
|
|
224
258
|
return `The active session goal has reached its token budget.
|
|
@@ -234,13 +268,13 @@ Budget:
|
|
|
234
268
|
- Tokens used: ${goal.tokensUsed}
|
|
235
269
|
- Token budget: ${goal.tokenBudget ?? "none"}
|
|
236
270
|
|
|
237
|
-
Goal mode has marked the goal as budgetLimited, so do not start new substantive work for this goal. Wrap up soon with useful progress, remaining work or blockers, and a clear next step. Do not call update_goal unless the goal is actually complete.`;
|
|
271
|
+
Goal mode has marked the goal as budgetLimited, so do not start new substantive work for this goal. Wrap up soon with useful progress, remaining work or blockers, and a clear next step. Do not call update_goal unless the goal is actually complete or objectively unmet.`;
|
|
238
272
|
}
|
|
239
273
|
function systemReminder(goal) {
|
|
240
274
|
if (!goal) {
|
|
241
275
|
return `OpenCode goal mode is available through get_goal, create_goal, and update_goal tools.
|
|
242
276
|
|
|
243
|
-
Create a goal only when explicitly requested by the user or system/developer instructions. Do not infer goals from ordinary tasks.`;
|
|
277
|
+
Create a goal only when explicitly requested by the user or system/developer instructions. Do not infer goals from ordinary tasks. When closing a goal, update_goal requires evidence for status "complete" or a blocker for status "unmet".`;
|
|
244
278
|
}
|
|
245
279
|
if (goal.status === "active")
|
|
246
280
|
return continuationPrompt(goal);
|
|
@@ -252,10 +286,60 @@ ${formatGoal(goal)}
|
|
|
252
286
|
|
|
253
287
|
If the user resumes the goal, continue from the objective and current evidence.`;
|
|
254
288
|
}
|
|
289
|
+
function compactionContext(goal) {
|
|
290
|
+
return `OpenCode goal mode is tracking this session goal across compaction.
|
|
291
|
+
|
|
292
|
+
${formatGoal(goal)}
|
|
293
|
+
|
|
294
|
+
Preserve the goal objective, status, budget, elapsed time, token count, and any completion evidence or blocker in the compacted context. After compaction, continue from the next concrete unfinished step. Before closing the goal, audit real artifacts and command outputs; close with update_goal status "complete" only with evidence, or status "unmet" only with a concrete blocker.`;
|
|
295
|
+
}
|
|
255
296
|
|
|
256
297
|
// src/server.ts
|
|
257
298
|
var DEFAULT_MAX_AUTO_TURNS = 25;
|
|
258
299
|
var DEFAULT_CONTINUE_INTERVAL_SECONDS = 3;
|
|
300
|
+
var DEFAULT_COMMAND_NAME = "goal";
|
|
301
|
+
function defaultTokenBudgetFromOptions(options) {
|
|
302
|
+
const budget = options?.default_token_budget;
|
|
303
|
+
if (budget == null)
|
|
304
|
+
return null;
|
|
305
|
+
return Number.isInteger(budget) && budget > 0 ? budget : null;
|
|
306
|
+
}
|
|
307
|
+
function goalCommandTemplate(commandName, defaultTokenBudget) {
|
|
308
|
+
const defaultBudgetInstruction = defaultTokenBudget == null ? "By default, omit token_budget. This matches Codex TUI behavior for /goal <objective>." : `By default, pass token_budget: ${defaultTokenBudget} when creating a goal unless the user explicitly requests a different token budget or no budget.`;
|
|
309
|
+
return `OpenCode goal mode command "/${commandName}" was invoked.
|
|
310
|
+
|
|
311
|
+
Arguments:
|
|
312
|
+
<goal_command_arguments>
|
|
313
|
+
$ARGUMENTS
|
|
314
|
+
</goal_command_arguments>
|
|
315
|
+
|
|
316
|
+
Use the goal tools to handle this command:
|
|
317
|
+
|
|
318
|
+
- If the arguments are empty, call get_goal and briefly report the current goal state.
|
|
319
|
+
- If the arguments are "status", "show", or "current", call get_goal and briefly report the current goal state.
|
|
320
|
+
- If the arguments are "clear", call clear_goal and report whether a goal was cleared.
|
|
321
|
+
- If the arguments start with "complete " or "done ", perform a completion audit against real artifacts and command output. Call update_goal with status "complete" only if the goal is achieved, using concise evidence from the audit.
|
|
322
|
+
- If the arguments start with "unmet ", "blocked ", or "blocker ", call update_goal with status "unmet" only when the goal cannot be achieved or needs external input, using the remaining arguments as the blocker.
|
|
323
|
+
- Otherwise, create a new goal with create_goal. Use the full arguments as the objective. ${defaultBudgetInstruction}
|
|
324
|
+
- Set token_budget only from this default or when the arguments explicitly include a token budget such as "--budget 250000", "budget=250000", or "token_budget=250000".
|
|
325
|
+
|
|
326
|
+
Create a goal only from these explicit command arguments. Do not infer a goal from unrelated session context. After create_goal succeeds, continue working toward the new goal.`;
|
|
327
|
+
}
|
|
328
|
+
function commandNameFromOptions(options) {
|
|
329
|
+
const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME;
|
|
330
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
|
|
331
|
+
return DEFAULT_COMMAND_NAME;
|
|
332
|
+
return name;
|
|
333
|
+
}
|
|
334
|
+
function registerDesktopCommand(config, commandName, defaultTokenBudget) {
|
|
335
|
+
config.command ??= {};
|
|
336
|
+
if (config.command[commandName])
|
|
337
|
+
return;
|
|
338
|
+
config.command[commandName] = {
|
|
339
|
+
description: "Set or view the long-running session goal",
|
|
340
|
+
template: goalCommandTemplate(commandName, defaultTokenBudget)
|
|
341
|
+
};
|
|
342
|
+
}
|
|
259
343
|
function textFromPart(part) {
|
|
260
344
|
if (!part || typeof part !== "object")
|
|
261
345
|
return "";
|
|
@@ -271,6 +355,39 @@ function estimateMessages(messages) {
|
|
|
271
355
|
return sum + (message.parts ?? []).reduce((partSum, part) => partSum + estimateTokensFromText(textFromPart(part)), 0);
|
|
272
356
|
}, 0);
|
|
273
357
|
}
|
|
358
|
+
function tokensFromRecord(value) {
|
|
359
|
+
if (!value || typeof value !== "object")
|
|
360
|
+
return;
|
|
361
|
+
const tokens = value;
|
|
362
|
+
if (typeof tokens.total === "number")
|
|
363
|
+
return tokens.total;
|
|
364
|
+
const cache = tokens.cache && typeof tokens.cache === "object" ? tokens.cache : {};
|
|
365
|
+
const fields = [tokens.input, tokens.output, tokens.reasoning, cache.read, cache.write];
|
|
366
|
+
if (!fields.some((field) => typeof field === "number"))
|
|
367
|
+
return;
|
|
368
|
+
return fields.reduce((sum, field) => sum + (typeof field === "number" && Number.isFinite(field) ? field : 0), 0);
|
|
369
|
+
}
|
|
370
|
+
function exactTokensFromPart(part) {
|
|
371
|
+
if (!part || typeof part !== "object")
|
|
372
|
+
return;
|
|
373
|
+
const value = part;
|
|
374
|
+
if (value.type !== "step-finish")
|
|
375
|
+
return;
|
|
376
|
+
return tokensFromRecord(value.tokens);
|
|
377
|
+
}
|
|
378
|
+
function exactTokensFromMessage(message) {
|
|
379
|
+
const partTotal = (message.parts ?? []).reduce((sum, part) => sum + (exactTokensFromPart(part) ?? 0), 0);
|
|
380
|
+
if (partTotal > 0)
|
|
381
|
+
return partTotal;
|
|
382
|
+
if (message.info && typeof message.info === "object") {
|
|
383
|
+
return tokensFromRecord(message.info.tokens);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
function tokensFromMessages(messages) {
|
|
388
|
+
const exactTotal = messages.reduce((sum, message) => sum + (exactTokensFromMessage(message) ?? 0), 0);
|
|
389
|
+
return exactTotal > 0 ? exactTotal : estimateMessages(messages);
|
|
390
|
+
}
|
|
274
391
|
async function sendContinuation(client, sessionID, prompt) {
|
|
275
392
|
await client.session.promptAsync({
|
|
276
393
|
path: { id: sessionID },
|
|
@@ -283,7 +400,15 @@ var server = async ({ client }, options) => {
|
|
|
283
400
|
const autoContinue = options?.auto_continue ?? true;
|
|
284
401
|
const maxAutoTurns = options?.max_auto_turns ?? DEFAULT_MAX_AUTO_TURNS;
|
|
285
402
|
const minInterval = options?.min_continue_interval_seconds ?? DEFAULT_CONTINUE_INTERVAL_SECONDS;
|
|
403
|
+
const registerCommand = options?.register_command ?? true;
|
|
404
|
+
const commandName = commandNameFromOptions(options);
|
|
405
|
+
const defaultTokenBudget = defaultTokenBudgetFromOptions(options);
|
|
286
406
|
return {
|
|
407
|
+
async config(config) {
|
|
408
|
+
if (!registerCommand)
|
|
409
|
+
return;
|
|
410
|
+
registerDesktopCommand(config, commandName, defaultTokenBudget);
|
|
411
|
+
},
|
|
287
412
|
tool: {
|
|
288
413
|
get_goal: {
|
|
289
414
|
description: "Get the current goal for this OpenCode session, including status, budgets, estimated token usage, elapsed-time usage, and remaining token budget.",
|
|
@@ -305,14 +430,22 @@ var server = async ({ client }, options) => {
|
|
|
305
430
|
}
|
|
306
431
|
},
|
|
307
432
|
update_goal: {
|
|
308
|
-
description: "
|
|
433
|
+
description: "Close the existing goal only after an audit against real evidence. Use status complete only when the objective is achieved and no required work remains, and include evidence. Use status unmet only when the objective cannot be achieved or is blocked, and include the blocker. Do not close a goal merely because the budget is exhausted or because work is stopping.",
|
|
309
434
|
args: {
|
|
310
|
-
status: z.enum(["complete"]).describe("Required.
|
|
435
|
+
status: z.enum(["complete", "unmet"]).describe("Required. complete means achieved; unmet means blocked or impossible."),
|
|
436
|
+
evidence: z.string().min(1).max(4000).optional().describe("Required when status is complete. Summarize the concrete evidence verified."),
|
|
437
|
+
blocker: z.string().min(1).max(4000).optional().describe("Required when status is unmet. Explain the concrete blocker or impossibility.")
|
|
311
438
|
},
|
|
312
|
-
async execute(
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
439
|
+
async execute(args, context) {
|
|
440
|
+
const input = args;
|
|
441
|
+
if (input.status === "complete") {
|
|
442
|
+
const goal2 = await completeGoal(context.sessionID, input.evidence ?? "");
|
|
443
|
+
const report2 = goal2.tokenBudget == null ? `Goal achieved. Time used: ${goal2.timeUsedSeconds} seconds. Evidence: ${goal2.completionEvidence}.` : `Goal achieved. Tokens used: ${goal2.tokensUsed} of ${goal2.tokenBudget}; time used: ${goal2.timeUsedSeconds} seconds. Evidence: ${goal2.completionEvidence}.`;
|
|
444
|
+
return JSON.stringify({ goal: goal2, remaining_tokens: goal2.remainingTokens, completion_budget_report: report2 }, null, 2);
|
|
445
|
+
}
|
|
446
|
+
const goal = await markGoalUnmet(context.sessionID, input.blocker ?? "");
|
|
447
|
+
const report = goal.tokenBudget == null ? `Goal unmet. Time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.` : `Goal unmet. Tokens used: ${goal.tokensUsed} of ${goal.tokenBudget}; time used: ${goal.timeUsedSeconds} seconds. Blocker: ${goal.blocker}.`;
|
|
448
|
+
return JSON.stringify({ goal, remaining_tokens: goal.remainingTokens, unmet_report: report }, null, 2);
|
|
316
449
|
}
|
|
317
450
|
},
|
|
318
451
|
clear_goal: {
|
|
@@ -327,13 +460,19 @@ var server = async ({ client }, options) => {
|
|
|
327
460
|
const sessionID = "sessionID" in input && typeof input.sessionID === "string" ? input.sessionID : output.messages.find((message) => typeof message.info.sessionID === "string")?.info.sessionID;
|
|
328
461
|
if (!sessionID)
|
|
329
462
|
return;
|
|
330
|
-
await accountUsage(sessionID,
|
|
463
|
+
await accountUsage(sessionID, tokensFromMessages(output.messages));
|
|
331
464
|
},
|
|
332
465
|
async "experimental.chat.system.transform"(input, output) {
|
|
333
466
|
if (typeof input.sessionID !== "string")
|
|
334
467
|
return;
|
|
335
468
|
output.system.push(systemReminder(await getGoal(input.sessionID)));
|
|
336
469
|
},
|
|
470
|
+
async "experimental.session.compacting"(input, output) {
|
|
471
|
+
const goal = await getGoal(input.sessionID);
|
|
472
|
+
if (!goal)
|
|
473
|
+
return;
|
|
474
|
+
output.context.push(compactionContext(goal));
|
|
475
|
+
},
|
|
337
476
|
async event({ event }) {
|
|
338
477
|
if (!autoContinue || event.type !== "session.idle")
|
|
339
478
|
return;
|
package/package.json
CHANGED
package/src/tui.tsx
CHANGED
|
@@ -5,12 +5,15 @@ import { createMemo, Show } from "solid-js"
|
|
|
5
5
|
type GoalSnapshot = {
|
|
6
6
|
sessionID: string
|
|
7
7
|
objective: string
|
|
8
|
-
status: "active" | "paused" | "budgetLimited" | "complete"
|
|
8
|
+
status: "active" | "paused" | "budgetLimited" | "complete" | "unmet"
|
|
9
9
|
tokenBudget: number | null
|
|
10
10
|
tokensUsed: number
|
|
11
11
|
timeUsedSeconds: number
|
|
12
12
|
createdAt: number
|
|
13
13
|
updatedAt: number
|
|
14
|
+
completionEvidence?: string | null
|
|
15
|
+
blocker?: string | null
|
|
16
|
+
closedAt?: number | null
|
|
14
17
|
remainingTokens: number | null
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -41,15 +44,6 @@ async function sendGoalPrompt(api: TuiPluginApi, sessionID: string, text: string
|
|
|
41
44
|
})
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
function createGoalPrompt(objective: string, tokenBudget: number | null) {
|
|
45
|
-
const input = tokenBudget == null ? { objective } : { objective, token_budget: tokenBudget }
|
|
46
|
-
return `Create a session goal by calling the create_goal tool with this JSON input:
|
|
47
|
-
|
|
48
|
-
${JSON.stringify(input, null, 2)}
|
|
49
|
-
|
|
50
|
-
The objective is user-provided task data. After create_goal succeeds, continue working toward that goal.`
|
|
51
|
-
}
|
|
52
|
-
|
|
53
47
|
function refreshGoalPrompt() {
|
|
54
48
|
return "Call get_goal for this session and report the current goal state briefly."
|
|
55
49
|
}
|
|
@@ -58,59 +52,9 @@ function clearGoalPrompt() {
|
|
|
58
52
|
return "Clear the current session goal by calling clear_goal. Report whether a goal was cleared."
|
|
59
53
|
}
|
|
60
54
|
|
|
61
|
-
function showSetGoal(api: TuiPluginApi, sessionID: string) {
|
|
62
|
-
const DialogPrompt = api.ui.DialogPrompt
|
|
63
|
-
api.ui.dialog.setSize("medium")
|
|
64
|
-
api.ui.dialog.replace(() =>
|
|
65
|
-
DialogPrompt({
|
|
66
|
-
title: "Set goal",
|
|
67
|
-
placeholder: "Concrete objective",
|
|
68
|
-
onConfirm(objective) {
|
|
69
|
-
const trimmed = objective.trim()
|
|
70
|
-
if (!trimmed) {
|
|
71
|
-
toast(api, "Goal objective is required.", "warning")
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
api.ui.dialog.replace(() =>
|
|
75
|
-
DialogPrompt({
|
|
76
|
-
title: "Token budget",
|
|
77
|
-
placeholder: "Optional positive integer",
|
|
78
|
-
onConfirm(rawBudget) {
|
|
79
|
-
const value = rawBudget.trim()
|
|
80
|
-
const budget = value ? Number(value) : null
|
|
81
|
-
if (budget != null && (!Number.isInteger(budget) || budget <= 0)) {
|
|
82
|
-
toast(api, "Token budget must be a positive integer.", "warning")
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
void sendGoalPrompt(api, sessionID, createGoalPrompt(trimmed, budget))
|
|
86
|
-
.then(() => {
|
|
87
|
-
api.ui.dialog.clear()
|
|
88
|
-
toast(api, "Goal request sent.", "success")
|
|
89
|
-
})
|
|
90
|
-
.catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
|
|
91
|
-
},
|
|
92
|
-
onCancel() {
|
|
93
|
-
api.ui.dialog.clear()
|
|
94
|
-
},
|
|
95
|
-
}),
|
|
96
|
-
)
|
|
97
|
-
},
|
|
98
|
-
onCancel() {
|
|
99
|
-
api.ui.dialog.clear()
|
|
100
|
-
},
|
|
101
|
-
}),
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
55
|
function showSummary(api: TuiPluginApi, sessionID: string, goal: GoalSnapshot | null) {
|
|
106
56
|
const DialogSelect = api.ui.DialogSelect
|
|
107
57
|
const options = [
|
|
108
|
-
{
|
|
109
|
-
title: "Set goal",
|
|
110
|
-
value: "set",
|
|
111
|
-
description: "Create a new active session goal",
|
|
112
|
-
onSelect: () => showSetGoal(api, sessionID),
|
|
113
|
-
},
|
|
114
58
|
{
|
|
115
59
|
title: "Refresh",
|
|
116
60
|
value: "refresh",
|
|
@@ -152,7 +96,7 @@ function showSummary(api: TuiPluginApi, sessionID: string, goal: GoalSnapshot |
|
|
|
152
96
|
|
|
153
97
|
function sessionIDOrToast(api: TuiPluginApi) {
|
|
154
98
|
const sessionID = currentSessionID(api)
|
|
155
|
-
if (!sessionID) toast(api, "Open a session before
|
|
99
|
+
if (!sessionID) toast(api, "Open a session before viewing goal state.", "warning")
|
|
156
100
|
return sessionID
|
|
157
101
|
}
|
|
158
102
|
|
|
@@ -189,12 +133,15 @@ function isGoalSnapshot(value: unknown): value is GoalSnapshot {
|
|
|
189
133
|
if (!isRecord(value)) return false
|
|
190
134
|
if (typeof value.sessionID !== "string") return false
|
|
191
135
|
if (typeof value.objective !== "string") return false
|
|
192
|
-
if (!["active", "paused", "budgetLimited", "complete"].includes(String(value.status))) return false
|
|
136
|
+
if (!["active", "paused", "budgetLimited", "complete", "unmet"].includes(String(value.status))) return false
|
|
193
137
|
if (value.tokenBudget !== null && typeof value.tokenBudget !== "number") return false
|
|
194
138
|
if (typeof value.tokensUsed !== "number") return false
|
|
195
139
|
if (typeof value.timeUsedSeconds !== "number") return false
|
|
196
140
|
if (typeof value.createdAt !== "number") return false
|
|
197
141
|
if (typeof value.updatedAt !== "number") return false
|
|
142
|
+
if (value.completionEvidence != null && typeof value.completionEvidence !== "string") return false
|
|
143
|
+
if (value.blocker != null && typeof value.blocker !== "string") return false
|
|
144
|
+
if (value.closedAt != null && typeof value.closedAt !== "number") return false
|
|
198
145
|
if (value.remainingTokens !== null && typeof value.remainingTokens !== "number") return false
|
|
199
146
|
return true
|
|
200
147
|
}
|
|
@@ -231,13 +178,16 @@ function goalFromSession(api: TuiPluginApi, sessionID: string) {
|
|
|
231
178
|
function formatGoal(goal: GoalSnapshot | null) {
|
|
232
179
|
if (!goal) return "No recent goal state found in this session."
|
|
233
180
|
const budget = goal.tokenBudget == null ? "none" : `${goal.tokensUsed} / ${goal.tokenBudget}`
|
|
234
|
-
|
|
181
|
+
const lines = [
|
|
235
182
|
`Objective: ${goal.objective}`,
|
|
236
183
|
`Status: ${goal.status}`,
|
|
237
184
|
`Tokens: ${budget}`,
|
|
238
185
|
`Remaining tokens: ${goal.remainingTokens ?? "n/a"}`,
|
|
239
186
|
`Time used: ${goal.timeUsedSeconds}s`,
|
|
240
|
-
]
|
|
187
|
+
]
|
|
188
|
+
if (goal.completionEvidence) lines.push(`Completion evidence: ${goal.completionEvidence}`)
|
|
189
|
+
if (goal.blocker) lines.push(`Blocker: ${goal.blocker}`)
|
|
190
|
+
return lines.join("\n")
|
|
241
191
|
}
|
|
242
192
|
|
|
243
193
|
function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
@@ -266,7 +216,7 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
266
216
|
<Show when={goal()}>
|
|
267
217
|
{(value: () => GoalSnapshot) => (
|
|
268
218
|
<Show
|
|
269
|
-
when={value().status === "complete"}
|
|
219
|
+
when={value().status === "complete" || value().status === "unmet"}
|
|
270
220
|
fallback={
|
|
271
221
|
<box>
|
|
272
222
|
<text fg={theme().text}>
|
|
@@ -280,8 +230,9 @@ function GoalSidebar(props: { api: TuiPluginApi; sessionID: string }) {
|
|
|
280
230
|
</box>
|
|
281
231
|
}
|
|
282
232
|
>
|
|
283
|
-
<text fg={theme().primary}>
|
|
284
|
-
<b>Goal achieved</b> (
|
|
233
|
+
<text fg={value().status === "complete" ? theme().primary : theme().textMuted}>
|
|
234
|
+
<b>{value().status === "complete" ? "Goal achieved" : "Goal unmet"}</b> (
|
|
235
|
+
{formatDurationBadge(value().timeUsedSeconds)})
|
|
285
236
|
</text>
|
|
286
237
|
</Show>
|
|
287
238
|
)}
|
|
@@ -304,8 +255,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
304
255
|
title: "Goal",
|
|
305
256
|
value: "goal.show",
|
|
306
257
|
category: "Goal",
|
|
307
|
-
description: "
|
|
308
|
-
slash: { name: "goal" },
|
|
258
|
+
description: "View or clear the long-running session goal",
|
|
309
259
|
onSelect: () => {
|
|
310
260
|
const sessionID = sessionIDOrToast(api)
|
|
311
261
|
if (!sessionID) return
|