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:
Alessandro 2026-07-09 17:09:08 +02:00
parent 9c9a4e00ca
commit 84c13dab01
26 changed files with 1199 additions and 0 deletions

View file

@ -78,6 +78,7 @@ Direct child DOX files:
| [_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. |
| [_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. |
| [_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. |

View file

@ -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.
- 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 may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
## Work Guidance

View file

@ -353,6 +353,7 @@ const model = {
const effects = Array.isArray(result.effects) ? result.effects : [];
let hadToast = false;
let hadError = false;
let shouldSend = false;
for (const effect of effects) {
if (!effect || typeof effect !== "object") continue;
@ -366,6 +367,11 @@ const model = {
nextText = nextText ? `${nextText}\n${chunk}` : chunk;
continue;
}
if (type === "send_message") {
nextText = String(effect.text || nextText || "");
shouldSend = true;
continue;
}
if (type === "toast") {
hadToast = true;
const level = String(effect.level || "info").toLowerCase();
@ -413,6 +419,10 @@ const model = {
);
continue;
}
if (type === "goal_changed") {
window.dispatchEvent(new CustomEvent("goal:changed", { detail: effect }));
continue;
}
if (type === "open_plugin_config") {
const pluginName = String(effect.plugin || "").trim();
if (pluginName) {
@ -463,6 +473,9 @@ const model = {
this.rawArguments = "";
this.rawMessage = nextText;
this.selectedIndex = 0;
if (shouldSend && nextText.trim()) {
await chatInputStore?.sendMessage?.();
}
return { hadToast, hadError };
},

41
plugins/_goal/AGENTS.md Normal file
View 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.

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

55
plugins/_goal/api/goal.py Normal file
View 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)}

View 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

View 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})

View file

@ -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", ""),
)

View file

@ -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>

View file

@ -0,0 +1 @@

View 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")

View 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

View 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.

View 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"
}
}
~~~

View 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.

View 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.

View 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)

View 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"

View file

@ -0,0 +1 @@

View 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,
)

View 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)

View 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,
)

View 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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB