From 84c13dab01cc9ffe7089c6d8614af8f06110d5a1 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:09:08 +0200 Subject: [PATCH] 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. --- plugins/AGENTS.md | 1 + plugins/_commands/AGENTS.md | 1 + .../_commands/webui/commands-slash-store.js | 13 + plugins/_goal/AGENTS.md | 41 +++ plugins/_goal/__init__.py | 1 + plugins/_goal/api/__init__.py | 1 + plugins/_goal/api/goal.py | 55 ++++ plugins/_goal/commands/goal.command.yaml | 5 + plugins/_goal/commands/goal_command.py | 82 +++++ .../_50_include_goal.py | 28 ++ .../chat-input-progress-start/goal-strip.html | 207 ++++++++++++ plugins/_goal/helpers/__init__.py | 1 + plugins/_goal/helpers/goals.py | 304 ++++++++++++++++++ plugins/_goal/plugin.yaml | 5 + plugins/_goal/prompts/agent.extras.goal.md | 7 + .../prompts/agent.system.tool.create_goal.md | 23 ++ .../prompts/agent.system.tool.get_goal.md | 10 + .../prompts/agent.system.tool.update_goal.md | 11 + plugins/_goal/tests/conftest.py | 8 + plugins/_goal/tests/test_goal_plugin.py | 124 +++++++ plugins/_goal/tools/__init__.py | 1 + plugins/_goal/tools/create_goal.py | 23 ++ plugins/_goal/tools/get_goal.py | 10 + plugins/_goal/tools/update_goal.py | 31 ++ plugins/_goal/webui/goal-store.js | 206 ++++++++++++ plugins/_goal/webui/thumbnail.png | Bin 0 -> 17502 bytes 26 files changed, 1199 insertions(+) create mode 100644 plugins/_goal/AGENTS.md create mode 100644 plugins/_goal/__init__.py create mode 100644 plugins/_goal/api/__init__.py create mode 100644 plugins/_goal/api/goal.py create mode 100644 plugins/_goal/commands/goal.command.yaml create mode 100644 plugins/_goal/commands/goal_command.py create mode 100644 plugins/_goal/extensions/python/message_loop_prompts_after/_50_include_goal.py create mode 100644 plugins/_goal/extensions/webui/chat-input-progress-start/goal-strip.html create mode 100644 plugins/_goal/helpers/__init__.py create mode 100644 plugins/_goal/helpers/goals.py create mode 100644 plugins/_goal/plugin.yaml create mode 100644 plugins/_goal/prompts/agent.extras.goal.md create mode 100644 plugins/_goal/prompts/agent.system.tool.create_goal.md create mode 100644 plugins/_goal/prompts/agent.system.tool.get_goal.md create mode 100644 plugins/_goal/prompts/agent.system.tool.update_goal.md create mode 100644 plugins/_goal/tests/conftest.py create mode 100644 plugins/_goal/tests/test_goal_plugin.py create mode 100644 plugins/_goal/tools/__init__.py create mode 100644 plugins/_goal/tools/create_goal.py create mode 100644 plugins/_goal/tools/get_goal.py create mode 100644 plugins/_goal/tools/update_goal.py create mode 100644 plugins/_goal/webui/goal-store.js create mode 100644 plugins/_goal/webui/thumbnail.png diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index c913b12f1..0d42f9124 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -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. | diff --git a/plugins/_commands/AGENTS.md b/plugins/_commands/AGENTS.md index 92b2a89e7..f9c0a0e61 100644 --- a/plugins/_commands/AGENTS.md +++ b/plugins/_commands/AGENTS.md @@ -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 diff --git a/plugins/_commands/webui/commands-slash-store.js b/plugins/_commands/webui/commands-slash-store.js index 0d43de546..9b1c2a207 100644 --- a/plugins/_commands/webui/commands-slash-store.js +++ b/plugins/_commands/webui/commands-slash-store.js @@ -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 }; }, diff --git a/plugins/_goal/AGENTS.md b/plugins/_goal/AGENTS.md new file mode 100644 index 000000000..cd797adad --- /dev/null +++ b/plugins/_goal/AGENTS.md @@ -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 ` 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. diff --git a/plugins/_goal/__init__.py b/plugins/_goal/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/plugins/_goal/__init__.py @@ -0,0 +1 @@ + diff --git a/plugins/_goal/api/__init__.py b/plugins/_goal/api/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/plugins/_goal/api/__init__.py @@ -0,0 +1 @@ + diff --git a/plugins/_goal/api/goal.py b/plugins/_goal/api/goal.py new file mode 100644 index 000000000..7dd5a47ee --- /dev/null +++ b/plugins/_goal/api/goal.py @@ -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)} diff --git a/plugins/_goal/commands/goal.command.yaml b/plugins/_goal/commands/goal.command.yaml new file mode 100644 index 000000000..34bf24a34 --- /dev/null +++ b/plugins/_goal/commands/goal.command.yaml @@ -0,0 +1,5 @@ +name: goal +description: Create, inspect, pause, resume, edit, or delete this chat goal. +argument_hint: "[objective|status|pause|resume|edit |delete|auto]" +type: script +script_path: goal_command.py diff --git a/plugins/_goal/commands/goal_command.py b/plugins/_goal/commands/goal_command.py new file mode 100644 index 000000000..26bd58a64 --- /dev/null +++ b/plugins/_goal/commands/goal_command.py @@ -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}) diff --git a/plugins/_goal/extensions/python/message_loop_prompts_after/_50_include_goal.py b/plugins/_goal/extensions/python/message_loop_prompts_after/_50_include_goal.py new file mode 100644 index 000000000..e18ac7080 --- /dev/null +++ b/plugins/_goal/extensions/python/message_loop_prompts_after/_50_include_goal.py @@ -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", ""), + ) diff --git a/plugins/_goal/extensions/webui/chat-input-progress-start/goal-strip.html b/plugins/_goal/extensions/webui/chat-input-progress-start/goal-strip.html new file mode 100644 index 000000000..ffb19f19d --- /dev/null +++ b/plugins/_goal/extensions/webui/chat-input-progress-start/goal-strip.html @@ -0,0 +1,207 @@ + + +
+ +
+ + diff --git a/plugins/_goal/helpers/__init__.py b/plugins/_goal/helpers/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/plugins/_goal/helpers/__init__.py @@ -0,0 +1 @@ + diff --git a/plugins/_goal/helpers/goals.py b/plugins/_goal/helpers/goals.py new file mode 100644 index 000000000..840358de8 --- /dev/null +++ b/plugins/_goal/helpers/goals.py @@ -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") diff --git a/plugins/_goal/plugin.yaml b/plugins/_goal/plugin.yaml new file mode 100644 index 000000000..68ef170bc --- /dev/null +++ b/plugins/_goal/plugin.yaml @@ -0,0 +1,5 @@ +name: _goal +title: Goal +description: Built-in chat goal tracking and goal tools. +version: 0.1.0 +always_enabled: true diff --git a/plugins/_goal/prompts/agent.extras.goal.md b/plugins/_goal/prompts/agent.extras.goal.md new file mode 100644 index 000000000..d1c0f88a7 --- /dev/null +++ b/plugins/_goal/prompts/agent.extras.goal.md @@ -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. diff --git a/plugins/_goal/prompts/agent.system.tool.create_goal.md b/plugins/_goal/prompts/agent.system.tool.create_goal.md new file mode 100644 index 000000000..38919d228 --- /dev/null +++ b/plugins/_goal/prompts/agent.system.tool.create_goal.md @@ -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" + } +} +~~~ diff --git a/plugins/_goal/prompts/agent.system.tool.get_goal.md b/plugins/_goal/prompts/agent.system.tool.get_goal.md new file mode 100644 index 000000000..e178111af --- /dev/null +++ b/plugins/_goal/prompts/agent.system.tool.get_goal.md @@ -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. diff --git a/plugins/_goal/prompts/agent.system.tool.update_goal.md b/plugins/_goal/prompts/agent.system.tool.update_goal.md new file mode 100644 index 000000000..60c25deef --- /dev/null +++ b/plugins/_goal/prompts/agent.system.tool.update_goal.md @@ -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. diff --git a/plugins/_goal/tests/conftest.py b/plugins/_goal/tests/conftest.py new file mode 100644 index 000000000..820354eaf --- /dev/null +++ b/plugins/_goal/tests/conftest.py @@ -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) diff --git a/plugins/_goal/tests/test_goal_plugin.py b/plugins/_goal/tests/test_goal_plugin.py new file mode 100644 index 000000000..6754f2974 --- /dev/null +++ b/plugins/_goal/tests/test_goal_plugin.py @@ -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" diff --git a/plugins/_goal/tools/__init__.py b/plugins/_goal/tools/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/plugins/_goal/tools/__init__.py @@ -0,0 +1 @@ + diff --git a/plugins/_goal/tools/create_goal.py b/plugins/_goal/tools/create_goal.py new file mode 100644 index 000000000..4853ce4fc --- /dev/null +++ b/plugins/_goal/tools/create_goal.py @@ -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, + ) diff --git a/plugins/_goal/tools/get_goal.py b/plugins/_goal/tools/get_goal.py new file mode 100644 index 000000000..8c81f0236 --- /dev/null +++ b/plugins/_goal/tools/get_goal.py @@ -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) diff --git a/plugins/_goal/tools/update_goal.py b/plugins/_goal/tools/update_goal.py new file mode 100644 index 000000000..7caac0b5c --- /dev/null +++ b/plugins/_goal/tools/update_goal.py @@ -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, + ) diff --git a/plugins/_goal/webui/goal-store.js b/plugins/_goal/webui/goal-store.js new file mode 100644 index 000000000..348f5f998 --- /dev/null +++ b/plugins/_goal/webui/goal-store.js @@ -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); diff --git a/plugins/_goal/webui/thumbnail.png b/plugins/_goal/webui/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..4d886a818750c15c1e86262b6ab40afc85280d64 GIT binary patch literal 17502 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 literal 0 HcmV?d00001