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 000000000..4d886a818 Binary files /dev/null and b/plugins/_goal/webui/thumbnail.png differ