mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
Add built-in goal management plugin
Adds the always-enabled _goal plugin with per-chat goal storage, WebUI goal strip, /goal slash command, and agent-facing goal tools. Includes command-picker integration, focused plugin tests, and the generated 256x256 thumbnail asset.
This commit is contained in:
parent
9c9a4e00ca
commit
84c13dab01
26 changed files with 1199 additions and 0 deletions
|
|
@ -78,6 +78,7 @@ Direct child DOX files:
|
||||||
| [_editor/AGENTS.md](_editor/AGENTS.md) | Native Markdown editor surface and sessions. |
|
| [_editor/AGENTS.md](_editor/AGENTS.md) | Native Markdown editor surface and sessions. |
|
||||||
| [_email_integration/AGENTS.md](_email_integration/AGENTS.md) | IMAP/Exchange polling and SMTP reply integration. |
|
| [_email_integration/AGENTS.md](_email_integration/AGENTS.md) | IMAP/Exchange polling and SMTP reply integration. |
|
||||||
| [_error_retry/AGENTS.md](_error_retry/AGENTS.md) | Critical exception retry lifecycle hooks. |
|
| [_error_retry/AGENTS.md](_error_retry/AGENTS.md) | Critical exception retry lifecycle hooks. |
|
||||||
|
| [_goal/AGENTS.md](_goal/AGENTS.md) | Built-in chat goal strip, `/goal` slash command, and agent-facing goal tools. |
|
||||||
| [_infection_check/AGENTS.md](_infection_check/AGENTS.md) | Prompt-injection safety analysis before tool execution. |
|
| [_infection_check/AGENTS.md](_infection_check/AGENTS.md) | Prompt-injection safety analysis before tool execution. |
|
||||||
| [_kokoro_tts/AGENTS.md](_kokoro_tts/AGENTS.md) | Kokoro text-to-speech integration. |
|
| [_kokoro_tts/AGENTS.md](_kokoro_tts/AGENTS.md) | Kokoro text-to-speech integration. |
|
||||||
| [_memory/AGENTS.md](_memory/AGENTS.md) | Optional persistent recall plugin, knowledge import, tools, and dashboard; do not assume it is enabled outside this plugin. |
|
| [_memory/AGENTS.md](_memory/AGENTS.md) | Optional persistent recall plugin, knowledge import, tools, and dashboard; do not assume it is enabled outside this plugin. |
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
- Commands contributed by enabled plugins live in their `commands/` directory and must not be rediscovered through the generic plugin-distributed path from `_commands` itself.
|
- Commands contributed by enabled plugins live in their `commands/` directory and must not be rediscovered through the generic plugin-distributed path from `_commands` itself.
|
||||||
- On startup, `_commands` copies legacy `usr/plugins/commands` command and skill files into `usr/plugins/_commands` without overwriting existing files, copies scoped legacy command folders to `_commands`, and disables the legacy `commands` plugin roots to prevent duplicate WebUI popovers.
|
- On startup, `_commands` copies legacy `usr/plugins/commands` command and skill files into `usr/plugins/_commands` without overwriting existing files, copies scoped legacy command folders to `_commands`, and disables the legacy `commands` plugin roots to prevent duplicate WebUI popovers.
|
||||||
- Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
|
- Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
|
||||||
|
- Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
|
||||||
|
|
||||||
## Work Guidance
|
## Work Guidance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,7 @@ const model = {
|
||||||
const effects = Array.isArray(result.effects) ? result.effects : [];
|
const effects = Array.isArray(result.effects) ? result.effects : [];
|
||||||
let hadToast = false;
|
let hadToast = false;
|
||||||
let hadError = false;
|
let hadError = false;
|
||||||
|
let shouldSend = false;
|
||||||
|
|
||||||
for (const effect of effects) {
|
for (const effect of effects) {
|
||||||
if (!effect || typeof effect !== "object") continue;
|
if (!effect || typeof effect !== "object") continue;
|
||||||
|
|
@ -366,6 +367,11 @@ const model = {
|
||||||
nextText = nextText ? `${nextText}\n${chunk}` : chunk;
|
nextText = nextText ? `${nextText}\n${chunk}` : chunk;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (type === "send_message") {
|
||||||
|
nextText = String(effect.text || nextText || "");
|
||||||
|
shouldSend = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (type === "toast") {
|
if (type === "toast") {
|
||||||
hadToast = true;
|
hadToast = true;
|
||||||
const level = String(effect.level || "info").toLowerCase();
|
const level = String(effect.level || "info").toLowerCase();
|
||||||
|
|
@ -413,6 +419,10 @@ const model = {
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (type === "goal_changed") {
|
||||||
|
window.dispatchEvent(new CustomEvent("goal:changed", { detail: effect }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (type === "open_plugin_config") {
|
if (type === "open_plugin_config") {
|
||||||
const pluginName = String(effect.plugin || "").trim();
|
const pluginName = String(effect.plugin || "").trim();
|
||||||
if (pluginName) {
|
if (pluginName) {
|
||||||
|
|
@ -463,6 +473,9 @@ const model = {
|
||||||
this.rawArguments = "";
|
this.rawArguments = "";
|
||||||
this.rawMessage = nextText;
|
this.rawMessage = nextText;
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
|
if (shouldSend && nextText.trim()) {
|
||||||
|
await chatInputStore?.sendMessage?.();
|
||||||
|
}
|
||||||
return { hadToast, hadError };
|
return { hadToast, hadError };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
41
plugins/_goal/AGENTS.md
Normal file
41
plugins/_goal/AGENTS.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Goal Plugin DOX
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
- Own the built-in chat goal strip, `/goal` slash command, goal state API, and agent-facing goal tools.
|
||||||
|
- Keep chat goals scoped to the active chat context and stored as user data outside tracked plugin code.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- `plugin.yaml` owns the always-enabled `_goal` plugin metadata.
|
||||||
|
- `helpers/goals.py` owns file-backed goal storage under `usr/plugins/_goal/goals/` and goal status normalization.
|
||||||
|
- `api/goal.py` owns the WebUI JSON API for reading, editing, pausing, resuming, and deleting goals.
|
||||||
|
- `commands/` owns the `/goal` slash command contributed to `_commands`.
|
||||||
|
- `webui/` and `extensions/webui/` own the composer goal strip and inline controls.
|
||||||
|
- `tools/` and `prompts/` own agent-facing goal inspection, creation, and status update behavior.
|
||||||
|
- `extensions/python/message_loop_prompts_after/` owns injecting the active goal into agent context.
|
||||||
|
|
||||||
|
## Local Contracts
|
||||||
|
|
||||||
|
- Goal status values are `active`, `paused`, `complete`, and `blocked`.
|
||||||
|
- Active goals are injected into agent extras; paused and blocked goals remain visible in the UI, while complete goals are hidden.
|
||||||
|
- Goal records track accumulated active time with `elapsed_seconds` and `active_since`; pausing freezes elapsed time until resume.
|
||||||
|
- User controls may pause, resume, edit, or delete a goal; destructive delete uses inline confirmation. Model tools may create goals and mark them complete or blocked.
|
||||||
|
- `/goal <objective>` creates the goal and sends the objective as the user message so the agent starts working immediately.
|
||||||
|
- `/goal auto` fills the composer with a prompt asking the agent to create and manage its own goal instead of silently sending a message.
|
||||||
|
- Goal UI feedback uses toast notifications and inline controls, not modal dialogs.
|
||||||
|
|
||||||
|
## Work Guidance
|
||||||
|
|
||||||
|
- Keep goal state in `usr/plugins/_goal/`; do not store runtime goal data in tracked files.
|
||||||
|
- Keep the goal strip mounted through WebUI extension points instead of modifying core composer templates.
|
||||||
|
- Keep `_commands` compatibility in mind: `/goal` is a plugin-contributed command and should remain read-only in the command manager.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Run `conda run -n a0 pytest plugins/_goal/tests` after changing `_goal` backend behavior.
|
||||||
|
- Run `_commands` discovery tests when changing the `/goal` command contribution contract.
|
||||||
|
|
||||||
|
## Child DOX Index
|
||||||
|
|
||||||
|
No child DOX files.
|
||||||
1
plugins/_goal/__init__.py
Normal file
1
plugins/_goal/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
1
plugins/_goal/api/__init__.py
Normal file
1
plugins/_goal/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
55
plugins/_goal/api/goal.py
Normal file
55
plugins/_goal/api/goal.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from helpers.api import ApiHandler, Request, Response
|
||||||
|
|
||||||
|
from plugins._goal.helpers import goals
|
||||||
|
|
||||||
|
|
||||||
|
class Goal(ApiHandler):
|
||||||
|
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||||
|
action = str(input.get("action", "") or "").strip().lower()
|
||||||
|
context_id = str(input.get("context_id", "") or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action == "get":
|
||||||
|
return {"ok": True, "goal": goals.public_goal(goals.get_goal(context_id))}
|
||||||
|
if action in {"set", "create"}:
|
||||||
|
return self._set(context_id, input)
|
||||||
|
if action == "update":
|
||||||
|
return self._update(context_id, input)
|
||||||
|
if action == "pause":
|
||||||
|
return self._status(context_id, "paused")
|
||||||
|
if action == "resume":
|
||||||
|
return self._status(context_id, "active")
|
||||||
|
if action == "delete":
|
||||||
|
goals.delete_goal(context_id)
|
||||||
|
return {"ok": True, "goal": None}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Response(status=404, response="Goal not found")
|
||||||
|
except ValueError as error:
|
||||||
|
return Response(status=400, response=str(error))
|
||||||
|
|
||||||
|
return Response(status=400, response=f"Unknown action: {action}")
|
||||||
|
|
||||||
|
def _set(self, context_id: str, input: dict) -> dict:
|
||||||
|
goal = goals.create_goal(
|
||||||
|
context_id,
|
||||||
|
str(input.get("objective") or ""),
|
||||||
|
created_by=str(input.get("created_by") or "user"),
|
||||||
|
token_budget=input.get("token_budget"),
|
||||||
|
)
|
||||||
|
return {"ok": True, "goal": goals.public_goal(goal)}
|
||||||
|
|
||||||
|
def _update(self, context_id: str, input: dict) -> dict:
|
||||||
|
goal = goals.update_goal(
|
||||||
|
context_id,
|
||||||
|
objective=input.get("objective") if "objective" in input else None,
|
||||||
|
status=input.get("status") if "status" in input else None,
|
||||||
|
note=input.get("note") if "note" in input else None,
|
||||||
|
token_budget=input.get("token_budget") if "token_budget" in input else None,
|
||||||
|
)
|
||||||
|
return {"ok": True, "goal": goals.public_goal(goal)}
|
||||||
|
|
||||||
|
def _status(self, context_id: str, status: str) -> dict:
|
||||||
|
goal = goals.update_goal(context_id, status=status)
|
||||||
|
return {"ok": True, "goal": goals.public_goal(goal)}
|
||||||
5
plugins/_goal/commands/goal.command.yaml
Normal file
5
plugins/_goal/commands/goal.command.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: goal
|
||||||
|
description: Create, inspect, pause, resume, edit, or delete this chat goal.
|
||||||
|
argument_hint: "[objective|status|pause|resume|edit <goal>|delete|auto]"
|
||||||
|
type: script
|
||||||
|
script_path: goal_command.py
|
||||||
82
plugins/_goal/commands/goal_command.py
Normal file
82
plugins/_goal/commands/goal_command.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from plugins._goal.helpers import goals
|
||||||
|
|
||||||
|
|
||||||
|
def run(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
invocation = payload.get("invocation") or {}
|
||||||
|
raw_args = str(invocation.get("raw_arguments") or "").strip()
|
||||||
|
tokens = ((invocation.get("arguments") or {}).get("tokens") or [])
|
||||||
|
context_id = str((payload.get("context") or {}).get("context_id") or "").strip()
|
||||||
|
|
||||||
|
if not context_id:
|
||||||
|
return _effects(_toast("Open or create a chat context first.", level="error"))
|
||||||
|
|
||||||
|
action = str(tokens[0] if tokens else "").strip().lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if action in {"", "status", "show"}:
|
||||||
|
return _show_markdown("Goal", goals.summarize_goal(goals.get_goal(context_id)))
|
||||||
|
if action in {"pause", "paused"}:
|
||||||
|
goal = goals.update_goal(context_id, status="paused")
|
||||||
|
return _changed("Goal paused.", goal)
|
||||||
|
if action in {"resume", "start", "active"}:
|
||||||
|
goal = goals.update_goal(context_id, status="active")
|
||||||
|
return _changed("Goal resumed.", goal)
|
||||||
|
if action in {"delete", "clear", "remove"}:
|
||||||
|
goals.delete_goal(context_id)
|
||||||
|
return _changed("Goal deleted.", None)
|
||||||
|
if action in {"complete", "done"}:
|
||||||
|
goal = goals.update_goal(context_id, status="complete")
|
||||||
|
return _changed("Goal marked complete.", goal)
|
||||||
|
if action == "blocked":
|
||||||
|
note = raw_args.split(None, 1)[1].strip() if len(tokens) > 1 else ""
|
||||||
|
goal = goals.update_goal(context_id, status="blocked", note=note)
|
||||||
|
return _changed("Goal marked blocked.", goal)
|
||||||
|
if action == "edit":
|
||||||
|
objective = raw_args.split(None, 1)[1].strip() if len(tokens) > 1 else ""
|
||||||
|
goal = goals.update_goal(context_id, objective=objective, status="active")
|
||||||
|
return _changed("Goal updated.", goal)
|
||||||
|
if action in {"auto", "ask", "model"}:
|
||||||
|
hint = raw_args.split(None, 1)[1].strip() if len(tokens) > 1 else ""
|
||||||
|
return _auto_prompt(hint)
|
||||||
|
|
||||||
|
goal = goals.create_goal(context_id, raw_args, created_by="user")
|
||||||
|
return _changed("Goal set.", goal, send_text=raw_args)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return _effects(_toast("No goal is set for this chat.", level="error"))
|
||||||
|
except ValueError as error:
|
||||||
|
return _effects(_toast(str(error), level="error"))
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_prompt(hint: str) -> dict[str, Any]:
|
||||||
|
prompt = (
|
||||||
|
"Please create and manage a goal for this chat. Use the goal tools to inspect "
|
||||||
|
"any current goal, create a concise goal objective, and update it when the work "
|
||||||
|
"is complete or genuinely blocked."
|
||||||
|
)
|
||||||
|
if hint:
|
||||||
|
prompt += f"\n\nUser hint: {hint}"
|
||||||
|
return {"text": prompt, "effects": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _changed(message: str, goal: dict[str, Any] | None, *, send_text: str = "") -> dict[str, Any]:
|
||||||
|
return _effects(
|
||||||
|
_toast(message),
|
||||||
|
{"type": "goal_changed", "goal": goals.public_goal(goal)},
|
||||||
|
{"type": "send_message", "text": send_text} if send_text else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _effects(*effects: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"text": "", "effects": [effect for effect in effects if effect]}
|
||||||
|
|
||||||
|
|
||||||
|
def _toast(message: str, *, level: str = "success") -> dict[str, Any]:
|
||||||
|
return {"type": "toast", "message": message, "level": level}
|
||||||
|
|
||||||
|
|
||||||
|
def _show_markdown(title: str, content: str) -> dict[str, Any]:
|
||||||
|
return _effects({"type": "show_markdown", "title": title, "content": content})
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from agent import LoopData
|
||||||
|
from helpers.extension import Extension
|
||||||
|
from plugins._goal.helpers import goals
|
||||||
|
|
||||||
|
|
||||||
|
class IncludeGoal(Extension):
|
||||||
|
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
|
||||||
|
if not self.agent:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
goal = goals.get_goal(self.agent.context.id)
|
||||||
|
except ValueError:
|
||||||
|
goal = None
|
||||||
|
|
||||||
|
if not goal or goal.get("status") != "active":
|
||||||
|
loop_data.extras_temporary.pop("current_goal", None)
|
||||||
|
return
|
||||||
|
|
||||||
|
loop_data.extras_temporary["current_goal"] = self.agent.read_prompt(
|
||||||
|
"agent.extras.goal.md",
|
||||||
|
status=goal.get("status", ""),
|
||||||
|
objective=goal.get("objective", ""),
|
||||||
|
created_by=goal.get("created_by", ""),
|
||||||
|
updated_at=goal.get("updated_at", ""),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
<script type="module">
|
||||||
|
import { store } from "/plugins/_goal/webui/goal-store.js";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div x-data
|
||||||
|
class="goal-strip-root"
|
||||||
|
x-create="$store.goalBar.onMount()"
|
||||||
|
x-init="$watch('$store.chats.selected', () => $store.goalBar.refresh(true))"
|
||||||
|
x-destroy="$store.goalBar.cleanup()">
|
||||||
|
<template x-if="$store.goalBar && $store.goalBar.visible">
|
||||||
|
<div class="goal-strip" :class="`is-${$store.goalBar.goal?.status || 'active'}`">
|
||||||
|
<span class="material-symbols-outlined goal-strip-icon" x-text="$store.goalBar.statusIcon" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<template x-if="!$store.goalBar.editing">
|
||||||
|
<div class="goal-strip-copy">
|
||||||
|
<span class="goal-strip-status" x-text="$store.goalBar.statusLabel"></span>
|
||||||
|
<span class="goal-strip-objective" x-text="$store.goalBar.goal?.objective || ''"></span>
|
||||||
|
<span class="goal-strip-time" x-text="`• ${$store.goalBar.elapsedLabel}`"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="$store.goalBar.editing">
|
||||||
|
<div class="goal-strip-edit">
|
||||||
|
<input class="goal-strip-input"
|
||||||
|
type="text"
|
||||||
|
x-model="$store.goalBar.draft"
|
||||||
|
@keydown.enter.prevent="$store.goalBar.saveEdit()"
|
||||||
|
@keydown.escape.prevent="$store.goalBar.cancelEdit()"
|
||||||
|
aria-label="Goal objective" />
|
||||||
|
<button type="button" class="goal-strip-action" title="Save goal" @click="$store.goalBar.saveEdit()" :disabled="$store.goalBar.saving">
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">check</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="goal-strip-action" title="Cancel" @click="$store.goalBar.cancelEdit()" :disabled="$store.goalBar.saving">
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="goal-strip-actions" x-show="!$store.goalBar.editing">
|
||||||
|
<button type="button" class="goal-strip-action" title="Edit goal" @click="$store.goalBar.startEdit()" :disabled="$store.goalBar.saving">
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="goal-strip-action"
|
||||||
|
:title="$store.goalBar.goal?.status === 'active' ? 'Pause goal' : 'Resume goal'"
|
||||||
|
@click="$store.goalBar.pauseOrResume()"
|
||||||
|
:disabled="$store.goalBar.saving">
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.goalBar.goal?.status === 'active' ? 'pause_circle' : 'play_circle'"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="goal-strip-action" title="Delete goal" @click="$confirmClick($event, () => $store.goalBar.deleteGoal())" :disabled="$store.goalBar.saving">
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#progress-bar-box.has-goal-bar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress-bar-box.has-goal-bar .goal-strip-root {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.42rem;
|
||||||
|
padding: 0.18rem var(--spacing-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-objective {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 64%, transparent);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-time {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 44%, transparent);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-actions,
|
||||||
|
.goal-strip-edit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.18rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-edit {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 8rem;
|
||||||
|
height: 1.55rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-border) 82%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--color-background) 80%, transparent);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-input:focus {
|
||||||
|
border-color: color-mix(in srgb, var(--color-text) 28%, var(--color-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-action {
|
||||||
|
width: 1.55rem;
|
||||||
|
height: 1.55rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.68;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-action:hover:not(:disabled) {
|
||||||
|
opacity: 1;
|
||||||
|
background: color-mix(in srgb, var(--color-border) 34%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-action.confirming:not(:disabled) {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--color-warning, var(--color-text));
|
||||||
|
background: color-mix(in srgb, var(--color-warning, var(--color-text)) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-action:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-action .material-symbols-outlined {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip.is-paused .goal-strip-icon,
|
||||||
|
.goal-strip.is-blocked .goal-strip-icon {
|
||||||
|
color: var(--color-warning, var(--color-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip.is-complete .goal-strip-icon {
|
||||||
|
color: var(--color-success, var(--color-text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.goal-strip {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-strip-status,
|
||||||
|
.goal-strip-objective,
|
||||||
|
.goal-strip-time,
|
||||||
|
.goal-strip-input {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
plugins/_goal/helpers/__init__.py
Normal file
1
plugins/_goal/helpers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
304
plugins/_goal/helpers/goals.py
Normal file
304
plugins/_goal/helpers/goals.py
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from helpers import files
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN_NAME = "_goal"
|
||||||
|
GOALS_DIR = "goals"
|
||||||
|
ACTIVE_STATUSES = {"active", "paused"}
|
||||||
|
FINAL_STATUSES = {"complete", "blocked"}
|
||||||
|
VALID_STATUSES = ACTIVE_STATUSES | FINAL_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
def get_goal(context_id: str) -> dict[str, Any] | None:
|
||||||
|
context_id = _require_context_id(context_id)
|
||||||
|
path = _goal_path(context_id)
|
||||||
|
if not Path(path).is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(files.read_file(path))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
goal = _normalize_goal(raw, context_id=context_id)
|
||||||
|
if not goal.get("objective"):
|
||||||
|
return None
|
||||||
|
return goal
|
||||||
|
|
||||||
|
|
||||||
|
def list_goals() -> list[dict[str, Any]]:
|
||||||
|
directory = _goals_dir()
|
||||||
|
if not Path(directory).is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
goals: list[dict[str, Any]] = []
|
||||||
|
for goal_file in sorted(Path(directory).glob("*.json")):
|
||||||
|
try:
|
||||||
|
raw = json.loads(files.read_file(str(goal_file)))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
context_id = str(raw.get("context_id") or "").strip()
|
||||||
|
if not context_id:
|
||||||
|
continue
|
||||||
|
goal = _normalize_goal(raw, context_id=context_id)
|
||||||
|
if goal.get("objective"):
|
||||||
|
goals.append(goal)
|
||||||
|
return goals
|
||||||
|
|
||||||
|
|
||||||
|
def create_goal(
|
||||||
|
context_id: str,
|
||||||
|
objective: str,
|
||||||
|
*,
|
||||||
|
created_by: str = "user",
|
||||||
|
token_budget: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
context_id = _require_context_id(context_id)
|
||||||
|
objective = _clean_objective(objective)
|
||||||
|
if not objective:
|
||||||
|
raise ValueError("Goal objective is required")
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
existing = get_goal(context_id)
|
||||||
|
goal = {
|
||||||
|
"context_id": context_id,
|
||||||
|
"objective": objective,
|
||||||
|
"status": "active",
|
||||||
|
"created_by": _clean_created_by(created_by),
|
||||||
|
"token_budget": _clean_token_budget(token_budget),
|
||||||
|
"created_at": existing.get("created_at") if existing else now,
|
||||||
|
"active_since": now,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"updated_at": now,
|
||||||
|
"note": "",
|
||||||
|
}
|
||||||
|
_write_goal(goal)
|
||||||
|
return goal
|
||||||
|
|
||||||
|
|
||||||
|
def update_goal(
|
||||||
|
context_id: str,
|
||||||
|
*,
|
||||||
|
objective: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
note: str | None = None,
|
||||||
|
token_budget: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
context_id = _require_context_id(context_id)
|
||||||
|
goal = get_goal(context_id)
|
||||||
|
if not goal:
|
||||||
|
raise FileNotFoundError("Goal not found")
|
||||||
|
|
||||||
|
if objective is not None:
|
||||||
|
cleaned_objective = _clean_objective(objective)
|
||||||
|
if not cleaned_objective:
|
||||||
|
raise ValueError("Goal objective is required")
|
||||||
|
goal["objective"] = cleaned_objective
|
||||||
|
|
||||||
|
if status is not None:
|
||||||
|
_apply_status(goal, _normalize_status(status))
|
||||||
|
|
||||||
|
if note is not None:
|
||||||
|
goal["note"] = str(note or "").strip()
|
||||||
|
|
||||||
|
if token_budget is not None:
|
||||||
|
goal["token_budget"] = _clean_token_budget(token_budget)
|
||||||
|
|
||||||
|
goal["updated_at"] = _now()
|
||||||
|
_write_goal(goal)
|
||||||
|
return goal
|
||||||
|
|
||||||
|
|
||||||
|
def delete_goal(context_id: str) -> None:
|
||||||
|
context_id = _require_context_id(context_id)
|
||||||
|
files.delete_file(_goal_path(context_id))
|
||||||
|
|
||||||
|
|
||||||
|
def public_goal(goal: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||||
|
if not goal:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"context_id": str(goal.get("context_id") or ""),
|
||||||
|
"objective": str(goal.get("objective") or ""),
|
||||||
|
"status": str(goal.get("status") or "active"),
|
||||||
|
"created_by": str(goal.get("created_by") or "user"),
|
||||||
|
"token_budget": goal.get("token_budget"),
|
||||||
|
"created_at": str(goal.get("created_at") or ""),
|
||||||
|
"active_since": str(goal.get("active_since") or ""),
|
||||||
|
"elapsed_seconds": _clean_elapsed_seconds(goal.get("elapsed_seconds")),
|
||||||
|
"updated_at": str(goal.get("updated_at") or ""),
|
||||||
|
"note": str(goal.get("note") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_goal(goal: dict[str, Any] | None) -> str:
|
||||||
|
if not goal:
|
||||||
|
return "No goal is set for this chat."
|
||||||
|
|
||||||
|
status = str(goal.get("status") or "active")
|
||||||
|
objective = str(goal.get("objective") or "").strip()
|
||||||
|
elapsed = _format_elapsed(_elapsed_seconds(goal))
|
||||||
|
updated = str(goal.get("updated_at") or "").strip()
|
||||||
|
lines = [f"Status: {status}", f"Goal: {objective}", f"Active time: {elapsed}"]
|
||||||
|
if updated:
|
||||||
|
lines.append(f"Updated: {updated}")
|
||||||
|
note = str(goal.get("note") or "").strip()
|
||||||
|
if note:
|
||||||
|
lines.append(f"Note: {note}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_goal(goal: dict[str, Any]) -> None:
|
||||||
|
files.write_file(
|
||||||
|
_goal_path(str(goal["context_id"])),
|
||||||
|
json.dumps(public_goal(goal), indent=2, ensure_ascii=False) + "\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_goal(raw: dict[str, Any], *, context_id: str) -> dict[str, Any]:
|
||||||
|
status = str(raw.get("status") or "active").strip().lower()
|
||||||
|
if status not in VALID_STATUSES:
|
||||||
|
status = "active"
|
||||||
|
|
||||||
|
created_at = str(raw.get("created_at") or "")
|
||||||
|
updated_at = str(raw.get("updated_at") or "")
|
||||||
|
active_since = str(raw.get("active_since") or "")
|
||||||
|
elapsed_seconds = _clean_elapsed_seconds(raw.get("elapsed_seconds"))
|
||||||
|
if "elapsed_seconds" not in raw and status != "active":
|
||||||
|
elapsed_seconds = _seconds_between(created_at, updated_at)
|
||||||
|
if status == "active" and not active_since:
|
||||||
|
active_since = created_at or updated_at
|
||||||
|
|
||||||
|
return {
|
||||||
|
"context_id": context_id,
|
||||||
|
"objective": _clean_objective(str(raw.get("objective") or "")),
|
||||||
|
"status": status,
|
||||||
|
"created_by": _clean_created_by(str(raw.get("created_by") or "user")),
|
||||||
|
"token_budget": _clean_token_budget(raw.get("token_budget")),
|
||||||
|
"created_at": created_at,
|
||||||
|
"active_since": active_since,
|
||||||
|
"elapsed_seconds": elapsed_seconds,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"note": str(raw.get("note") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _goals_dir() -> str:
|
||||||
|
return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, PLUGIN_NAME, GOALS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def _goal_path(context_id: str) -> str:
|
||||||
|
return files.get_abs_path(_goals_dir(), f"{_safe_context_id(context_id)}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_context_id(context_id: str) -> str:
|
||||||
|
context_id = str(context_id or "").strip()
|
||||||
|
if not context_id:
|
||||||
|
raise ValueError("A chat context is required")
|
||||||
|
return context_id
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_context_id(context_id: str) -> str:
|
||||||
|
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", context_id).strip("._")
|
||||||
|
if not safe:
|
||||||
|
raise ValueError("A chat context is required")
|
||||||
|
return safe[:180]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_objective(objective: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", str(objective or "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_status(status: str) -> str:
|
||||||
|
cleaned = str(status or "").strip().lower()
|
||||||
|
if cleaned not in VALID_STATUSES:
|
||||||
|
raise ValueError("Goal status must be active, paused, complete, or blocked")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_created_by(created_by: str) -> str:
|
||||||
|
cleaned = str(created_by or "").strip().lower()
|
||||||
|
return cleaned if cleaned in {"user", "model"} else "user"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_token_budget(token_budget: Any) -> int | None:
|
||||||
|
if token_budget in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = int(token_budget)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return value if value > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_status(goal: dict[str, Any], status: str) -> None:
|
||||||
|
current = str(goal.get("status") or "active")
|
||||||
|
if current == "active" and status != "active":
|
||||||
|
goal["elapsed_seconds"] = _elapsed_seconds(goal)
|
||||||
|
goal["active_since"] = ""
|
||||||
|
elif current != "active" and status == "active":
|
||||||
|
goal["active_since"] = _now()
|
||||||
|
goal["status"] = status
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed_seconds(goal: dict[str, Any]) -> int:
|
||||||
|
seconds = _clean_elapsed_seconds(goal.get("elapsed_seconds"))
|
||||||
|
if str(goal.get("status") or "active") == "active":
|
||||||
|
seconds += _seconds_between(str(goal.get("active_since") or ""), _now())
|
||||||
|
return seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_elapsed_seconds(value: Any) -> int:
|
||||||
|
try:
|
||||||
|
seconds = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
return max(0, seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _seconds_between(start: str, end: str) -> int:
|
||||||
|
start_dt = _parse_time(start)
|
||||||
|
end_dt = _parse_time(end)
|
||||||
|
if not start_dt or not end_dt:
|
||||||
|
return 0
|
||||||
|
return max(0, int((end_dt - start_dt).total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(value: str) -> datetime | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.endswith("Z"):
|
||||||
|
text = f"{text[:-1]}+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_elapsed(seconds: int) -> str:
|
||||||
|
hours, remainder = divmod(max(0, int(seconds)), 3600)
|
||||||
|
minutes, seconds = divmod(remainder, 60)
|
||||||
|
if hours:
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
if minutes:
|
||||||
|
return f"{minutes}m {seconds}s"
|
||||||
|
return f"{seconds}s"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
5
plugins/_goal/plugin.yaml
Normal file
5
plugins/_goal/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: _goal
|
||||||
|
title: Goal
|
||||||
|
description: Built-in chat goal tracking and goal tools.
|
||||||
|
version: 0.1.0
|
||||||
|
always_enabled: true
|
||||||
7
plugins/_goal/prompts/agent.extras.goal.md
Normal file
7
plugins/_goal/prompts/agent.extras.goal.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
## current goal
|
||||||
|
status: {{status}}
|
||||||
|
objective: {{objective}}
|
||||||
|
created by: {{created_by}}
|
||||||
|
updated: {{updated_at}}
|
||||||
|
|
||||||
|
Follow this goal while it is active. When the work is complete, call `update_goal` with `status="complete"` before your final answer. If progress is genuinely blocked, call `update_goal` with `status="blocked"` and explain what is missing.
|
||||||
23
plugins/_goal/prompts/agent.system.tool.create_goal.md
Normal file
23
plugins/_goal/prompts/agent.system.tool.create_goal.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
### create_goal
|
||||||
|
Create or replace the current chat goal.
|
||||||
|
|
||||||
|
Use only when the user asks for a goal, asks you to manage a goal, or `/goal auto` asks you to create one.
|
||||||
|
|
||||||
|
Args: `objective`, optional `token_budget`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Create one concise objective that describes the current work, not a generic plan.
|
||||||
|
- The new goal becomes active.
|
||||||
|
- Do not create goals for casual replies or ordinary one-shot answers.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
~~~json
|
||||||
|
{
|
||||||
|
"thoughts": ["The user asked me to manage this task as a goal."],
|
||||||
|
"headline": "Creating goal",
|
||||||
|
"tool_name": "create_goal",
|
||||||
|
"tool_args": {
|
||||||
|
"objective": "Add the built-in goal plugin with a Web UI strip and slash command"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
~~~
|
||||||
10
plugins/_goal/prompts/agent.system.tool.get_goal.md
Normal file
10
plugins/_goal/prompts/agent.system.tool.get_goal.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
### get_goal
|
||||||
|
Inspect the current chat goal.
|
||||||
|
|
||||||
|
Use this when the user asks about the goal, asks you to manage a goal, or you need to check whether a goal already exists before creating one.
|
||||||
|
|
||||||
|
Args: none.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do not invent a goal if none exists.
|
||||||
|
- If a goal is paused, complete, or blocked, treat it as state to report unless the user asks you to resume or replace it.
|
||||||
11
plugins/_goal/prompts/agent.system.tool.update_goal.md
Normal file
11
plugins/_goal/prompts/agent.system.tool.update_goal.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
### update_goal
|
||||||
|
Mark the current chat goal complete or blocked.
|
||||||
|
|
||||||
|
Use when the active goal is actually achieved, or when progress is genuinely blocked by missing user input or an external-state change.
|
||||||
|
|
||||||
|
Args: `status` (`complete` or `blocked`), optional `objective`, optional `note`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Mark `complete` only when the objective has been achieved.
|
||||||
|
- Mark `blocked` only when meaningful progress cannot continue without user input or an external-state change.
|
||||||
|
- Pause, resume, edit, and delete are user controls; do not claim to perform them with this tool.
|
||||||
8
plugins/_goal/tests/conftest.py
Normal file
8
plugins/_goal/tests/conftest.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
a0_root = str(Path(__file__).resolve().parents[3])
|
||||||
|
while a0_root in sys.path:
|
||||||
|
sys.path.remove(a0_root)
|
||||||
|
sys.path.insert(0, a0_root)
|
||||||
124
plugins/_goal/tests/test_goal_plugin.py
Normal file
124
plugins/_goal/tests/test_goal_plugin.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from helpers import files
|
||||||
|
from plugins._goal.api.goal import Goal
|
||||||
|
from plugins._goal.commands import goal_command
|
||||||
|
from plugins._goal.helpers import goals
|
||||||
|
from plugins._goal.tools.create_goal import CreateGoal
|
||||||
|
from plugins._goal.tools.get_goal import GetGoal
|
||||||
|
from plugins._goal.tools.update_goal import UpdateGoal
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def context_id():
|
||||||
|
context_id = f"goal-test-{uuid.uuid4().hex}"
|
||||||
|
yield context_id
|
||||||
|
goals.delete_goal(context_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(context_id: str, command_text: str) -> dict:
|
||||||
|
from plugins._commands.helpers.commands import parse_slash_invocation
|
||||||
|
|
||||||
|
return {
|
||||||
|
"invocation": parse_slash_invocation(command_text),
|
||||||
|
"context": {"context_id": context_id},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_storage_round_trip(context_id: str):
|
||||||
|
goal = goals.create_goal(context_id, "Ship the goal plugin", token_budget=1200)
|
||||||
|
|
||||||
|
loaded = goals.get_goal(context_id)
|
||||||
|
assert loaded == goal
|
||||||
|
assert loaded["status"] == "active"
|
||||||
|
assert loaded["token_budget"] == 1200
|
||||||
|
assert loaded["active_since"]
|
||||||
|
assert loaded["elapsed_seconds"] == 0
|
||||||
|
|
||||||
|
updated = goals.update_goal(context_id, status="paused", objective="Polish the goal strip")
|
||||||
|
assert updated["status"] == "paused"
|
||||||
|
assert updated["objective"] == "Polish the goal strip"
|
||||||
|
assert updated["active_since"] == ""
|
||||||
|
paused_seconds = updated["elapsed_seconds"]
|
||||||
|
|
||||||
|
resumed = goals.update_goal(context_id, status="active")
|
||||||
|
assert resumed["status"] == "active"
|
||||||
|
assert resumed["active_since"]
|
||||||
|
assert resumed["elapsed_seconds"] == paused_seconds
|
||||||
|
|
||||||
|
goals.delete_goal(context_id)
|
||||||
|
assert goals.get_goal(context_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_command_sets_pauses_resumes_and_deletes(context_id: str):
|
||||||
|
created = goal_command.run(_payload(context_id, "/goal Add current goal support"))
|
||||||
|
assert created["effects"][0]["message"] == "Goal set."
|
||||||
|
assert created["effects"][2] == {"type": "send_message", "text": "Add current goal support"}
|
||||||
|
assert goals.get_goal(context_id)["objective"] == "Add current goal support"
|
||||||
|
|
||||||
|
paused = goal_command.run(_payload(context_id, "/goal pause"))
|
||||||
|
assert paused["effects"][0]["message"] == "Goal paused."
|
||||||
|
assert goals.get_goal(context_id)["status"] == "paused"
|
||||||
|
|
||||||
|
resumed = goal_command.run(_payload(context_id, "/goal resume"))
|
||||||
|
assert resumed["effects"][0]["message"] == "Goal resumed."
|
||||||
|
assert goals.get_goal(context_id)["status"] == "active"
|
||||||
|
|
||||||
|
deleted = goal_command.run(_payload(context_id, "/goal delete"))
|
||||||
|
assert deleted["effects"][0]["message"] == "Goal deleted."
|
||||||
|
assert goals.get_goal(context_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_auto_fills_prompt(context_id: str):
|
||||||
|
result = goal_command.run(_payload(context_id, "/goal auto keep this tight"))
|
||||||
|
|
||||||
|
assert "Please create and manage a goal" in result["text"]
|
||||||
|
assert "User hint: keep this tight" in result["text"]
|
||||||
|
assert result["effects"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_goal_files_stay_under_user_plugin_state(context_id: str):
|
||||||
|
goals.create_goal(context_id, "Keep state in usr")
|
||||||
|
goal_path = files.get_abs_path(
|
||||||
|
files.USER_DIR,
|
||||||
|
files.PLUGINS_DIR,
|
||||||
|
goals.PLUGIN_NAME,
|
||||||
|
goals.GOALS_DIR,
|
||||||
|
f"{context_id}.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert files.exists(goal_path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_goal_api_and_agent_tools(context_id: str):
|
||||||
|
handler = object.__new__(Goal)
|
||||||
|
created = await handler.process(
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"context_id": context_id,
|
||||||
|
"objective": "Exercise API path",
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert created["ok"] is True
|
||||||
|
assert created["goal"]["objective"] == "Exercise API path"
|
||||||
|
|
||||||
|
fake_agent = SimpleNamespace(context=SimpleNamespace(id=context_id))
|
||||||
|
get_tool = GetGoal(fake_agent, "get_goal", None, {}, "", None)
|
||||||
|
get_response = await get_tool.execute()
|
||||||
|
assert "Exercise API path" in get_response.message
|
||||||
|
|
||||||
|
update_tool = UpdateGoal(fake_agent, "update_goal", None, {}, "", None)
|
||||||
|
update_response = await update_tool.execute(status="complete")
|
||||||
|
assert "Status: complete" in update_response.message
|
||||||
|
|
||||||
|
create_tool = CreateGoal(fake_agent, "create_goal", None, {}, "", None)
|
||||||
|
create_response = await create_tool.execute(objective="Exercise tool path")
|
||||||
|
assert "Goal created: Exercise tool path" == create_response.message
|
||||||
|
assert goals.get_goal(context_id)["created_by"] == "model"
|
||||||
1
plugins/_goal/tools/__init__.py
Normal file
1
plugins/_goal/tools/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
23
plugins/_goal/tools/create_goal.py
Normal file
23
plugins/_goal/tools/create_goal.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from helpers.tool import Response, Tool
|
||||||
|
from plugins._goal.helpers import goals
|
||||||
|
|
||||||
|
|
||||||
|
class CreateGoal(Tool):
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
objective: str = "",
|
||||||
|
token_budget: int | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Response:
|
||||||
|
goal = goals.create_goal(
|
||||||
|
self.agent.context.id,
|
||||||
|
objective,
|
||||||
|
created_by="model",
|
||||||
|
token_budget=token_budget,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
message=f"Goal created: {goal['objective']}",
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
10
plugins/_goal/tools/get_goal.py
Normal file
10
plugins/_goal/tools/get_goal.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from helpers.tool import Response, Tool
|
||||||
|
from plugins._goal.helpers import goals
|
||||||
|
|
||||||
|
|
||||||
|
class GetGoal(Tool):
|
||||||
|
async def execute(self, **kwargs) -> Response:
|
||||||
|
goal = goals.get_goal(self.agent.context.id)
|
||||||
|
return Response(message=goals.summarize_goal(goal), break_loop=False)
|
||||||
31
plugins/_goal/tools/update_goal.py
Normal file
31
plugins/_goal/tools/update_goal.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from helpers.tool import Response, Tool
|
||||||
|
from plugins._goal.helpers import goals
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateGoal(Tool):
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
status: str = "",
|
||||||
|
objective: str = "",
|
||||||
|
note: str = "",
|
||||||
|
**kwargs,
|
||||||
|
) -> Response:
|
||||||
|
normalized_status = str(status or "").strip().lower()
|
||||||
|
if normalized_status not in {"complete", "blocked"}:
|
||||||
|
return Response(
|
||||||
|
message="Model-managed goal updates may only mark goals complete or blocked.",
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
goal = goals.update_goal(
|
||||||
|
self.agent.context.id,
|
||||||
|
status=normalized_status,
|
||||||
|
objective=objective if objective else None,
|
||||||
|
note=note if note else None,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
message=goals.summarize_goal(goal),
|
||||||
|
break_loop=False,
|
||||||
|
)
|
||||||
206
plugins/_goal/webui/goal-store.js
Normal file
206
plugins/_goal/webui/goal-store.js
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { createStore } from "/js/AlpineStore.js";
|
||||||
|
import { callJsonApi } from "/js/api.js";
|
||||||
|
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
||||||
|
import {
|
||||||
|
toastFrontendError,
|
||||||
|
toastFrontendSuccess,
|
||||||
|
} from "/components/notifications/notification-store.js";
|
||||||
|
|
||||||
|
const GOAL_API_PATH = "/plugins/_goal/goal";
|
||||||
|
|
||||||
|
const model = {
|
||||||
|
goal: null,
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
editing: false,
|
||||||
|
draft: "",
|
||||||
|
lastContextId: "",
|
||||||
|
intervalId: null,
|
||||||
|
clockIntervalId: null,
|
||||||
|
goalChangedHandler: null,
|
||||||
|
now: Date.now(),
|
||||||
|
|
||||||
|
get visible() {
|
||||||
|
return Boolean(this.goal?.objective && this.goal.status !== "complete");
|
||||||
|
},
|
||||||
|
|
||||||
|
get contextId() {
|
||||||
|
return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
|
||||||
|
},
|
||||||
|
|
||||||
|
get statusLabel() {
|
||||||
|
const status = this.goal?.status || "active";
|
||||||
|
if (status === "paused") return "Goal paused";
|
||||||
|
if (status === "complete") return "Goal complete";
|
||||||
|
if (status === "blocked") return "Goal blocked";
|
||||||
|
return "Pursuing goal";
|
||||||
|
},
|
||||||
|
|
||||||
|
get statusIcon() {
|
||||||
|
const status = this.goal?.status || "active";
|
||||||
|
if (status === "paused") return "pause_circle";
|
||||||
|
if (status === "complete") return "check_circle";
|
||||||
|
if (status === "blocked") return "error";
|
||||||
|
return "track_changes";
|
||||||
|
},
|
||||||
|
|
||||||
|
get elapsedSeconds() {
|
||||||
|
if (!this.goal) return 0;
|
||||||
|
const status = this.goal.status || "active";
|
||||||
|
const storedSeconds = Number.parseInt(this.goal.elapsed_seconds, 10);
|
||||||
|
let seconds = Number.isNaN(storedSeconds) ? 0 : storedSeconds;
|
||||||
|
if (Number.isNaN(storedSeconds) && status !== "active") {
|
||||||
|
seconds = this.secondsBetween(this.goal.created_at, this.goal.updated_at);
|
||||||
|
}
|
||||||
|
if (status === "active") {
|
||||||
|
seconds += this.secondsBetween(
|
||||||
|
this.goal.active_since || this.goal.created_at || this.goal.updated_at,
|
||||||
|
this.now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Math.max(0, seconds);
|
||||||
|
},
|
||||||
|
|
||||||
|
secondsBetween(start, end) {
|
||||||
|
const startMs = Date.parse(start || "");
|
||||||
|
const endMs = typeof end === "number" ? end : Date.parse(end || "");
|
||||||
|
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return 0;
|
||||||
|
return Math.max(0, Math.floor((endMs - startMs) / 1000));
|
||||||
|
},
|
||||||
|
|
||||||
|
get elapsedLabel() {
|
||||||
|
const seconds = this.elapsedSeconds;
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
if (hours) return `${hours}h ${minutes}m`;
|
||||||
|
if (minutes) return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
return `${remainingSeconds}s`;
|
||||||
|
},
|
||||||
|
|
||||||
|
onMount() {
|
||||||
|
document.getElementById("progress-bar-box")?.classList.add("has-goal-bar");
|
||||||
|
this.goalChangedHandler = (event) => {
|
||||||
|
const detail = event?.detail || {};
|
||||||
|
if (detail.goal === null) {
|
||||||
|
this.goal = null;
|
||||||
|
}
|
||||||
|
void this.refresh(true);
|
||||||
|
};
|
||||||
|
window.addEventListener("goal:changed", this.goalChangedHandler);
|
||||||
|
this.clockIntervalId = window.setInterval(() => {
|
||||||
|
this.now = Date.now();
|
||||||
|
}, 1000);
|
||||||
|
this.intervalId = window.setInterval(() => this.refresh(), 3000);
|
||||||
|
void this.refresh(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
document.getElementById("progress-bar-box")?.classList.remove("has-goal-bar");
|
||||||
|
if (this.goalChangedHandler) {
|
||||||
|
window.removeEventListener("goal:changed", this.goalChangedHandler);
|
||||||
|
}
|
||||||
|
if (this.intervalId) {
|
||||||
|
window.clearInterval(this.intervalId);
|
||||||
|
}
|
||||||
|
if (this.clockIntervalId) {
|
||||||
|
window.clearInterval(this.clockIntervalId);
|
||||||
|
}
|
||||||
|
this.goalChangedHandler = null;
|
||||||
|
this.intervalId = null;
|
||||||
|
this.clockIntervalId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh(force = false) {
|
||||||
|
const contextId = this.contextId;
|
||||||
|
if (!contextId) {
|
||||||
|
this.goal = null;
|
||||||
|
this.lastContextId = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!force && this.loading) return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await callJsonApi(GOAL_API_PATH, {
|
||||||
|
action: "get",
|
||||||
|
context_id: contextId,
|
||||||
|
});
|
||||||
|
this.goal = response?.goal || null;
|
||||||
|
this.now = Date.now();
|
||||||
|
this.lastContextId = contextId;
|
||||||
|
if (!this.goal) this.editing = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load goal:", error);
|
||||||
|
this.goal = null;
|
||||||
|
this.lastContextId = contextId;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startEdit() {
|
||||||
|
if (!this.goal) return;
|
||||||
|
this.draft = this.goal.objective || "";
|
||||||
|
this.editing = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.querySelector(".goal-strip-input")?.focus?.();
|
||||||
|
document.querySelector(".goal-strip-input")?.select?.();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelEdit() {
|
||||||
|
this.editing = false;
|
||||||
|
this.draft = "";
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveEdit() {
|
||||||
|
const objective = (this.draft || "").trim();
|
||||||
|
if (!objective) {
|
||||||
|
void toastFrontendError("Goal objective is required.", "Goal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.update({ action: "update", objective, status: "active" }, "Goal updated.");
|
||||||
|
this.editing = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async pauseOrResume() {
|
||||||
|
if (!this.goal) return;
|
||||||
|
const action = this.goal.status === "active" ? "pause" : "resume";
|
||||||
|
await this.update(
|
||||||
|
{ action },
|
||||||
|
action === "pause" ? "Goal paused." : "Goal resumed.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteGoal() {
|
||||||
|
await this.update({ action: "delete" }, "Goal deleted.");
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(payload, successMessage) {
|
||||||
|
const contextId = this.contextId;
|
||||||
|
if (!contextId || this.saving) return;
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
const response = await callJsonApi(GOAL_API_PATH, {
|
||||||
|
...payload,
|
||||||
|
context_id: contextId,
|
||||||
|
});
|
||||||
|
this.goal = response?.goal || null;
|
||||||
|
this.now = Date.now();
|
||||||
|
this.lastContextId = contextId;
|
||||||
|
window.dispatchEvent(new CustomEvent("goal:changed", {
|
||||||
|
detail: { goal: this.goal, context_id: contextId },
|
||||||
|
}));
|
||||||
|
void toastFrontendSuccess(successMessage, "Goal");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update goal:", error);
|
||||||
|
void toastFrontendError(error?.message || "Failed to update goal.", "Goal");
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const store = createStore("goalBar", model);
|
||||||
BIN
plugins/_goal/webui/thumbnail.png
Normal file
BIN
plugins/_goal/webui/thumbnail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Loading…
Add table
Add a link
Reference in a new issue