1"""Per-task state for long-task cards (kinds 43001 to 43006).23This module has no Hermes or network dependencies. The adapter records what it4sends during a task turn here and asks this state what to publish; the rules5for progress and result content live in one place.6"""78from __future__ import annotations910from dataclasses import dataclass, field11import re12from typing import Any1314KIND_JOB_REQUEST = 4300115KIND_JOB_ACCEPTED = 4300216KIND_JOB_PROGRESS = 4300317KIND_JOB_RESULT = 4300418KIND_JOB_CANCEL = 4300519KIND_JOB_ERROR = 4300620TERMINAL_KINDS = (KIND_JOB_RESULT, KIND_JOB_CANCEL, KIND_JOB_ERROR)2122# Message classes the adapter reports while a task runs.23TEXT = "text" # assistant text: a streamed draft or a final reply24CHROME = "chrome" # tool progress bubbles and other status sends25FILE = "file" # an attachment message2627SUMMARY_LIMIT = 20028PROGRESS_LINE_LIMIT = 20029RESULT_TEXT_LIMIT = 400030ERROR_TEXT_LIMIT = 30031# Streaming cursor glyphs Hermes appends to an in-progress draft.32_CURSOR = re.compile("[\\sā-ā]+$")33_SPACES = re.compile(r"\s+")343536def clip(text: str, limit: int) -> str:37 """Collapse whitespace and cut to ``limit`` characters with a marker."""38 text = _SPACES.sub(" ", str(text or "")).strip()39 if len(text) <= limit:40 return text41 return text[: max(0, limit - 3)].rstrip() + "..."424344def first_line(text: str) -> str:45 """Return the first non-empty line of ``text``."""46 return next((line.strip() for line in str(text or "").splitlines() if line.strip()), "")474849def last_line(text: str) -> str:50 """Return the last non-empty line of ``text`` without a streaming cursor."""51 text = _CURSOR.sub("", str(text or ""))52 return next((line.strip() for line in reversed(text.splitlines()) if line.strip()), "")535455def request_text(event: dict) -> str:56 """Return the turn text for a request: the subject line, then the content."""57 subject = next((row[1] for row in event.get("tags", []) if len(row) > 1 and row[0] == "subject"), "")58 subject = str(subject or "").strip()59 content = str(event.get("content", "") or "").strip()60 if subject and content:61 return subject + "\n\n" + content62 return subject or content636465def request_root(event: dict) -> str:66 """Return the marked root e tag of a request, else the request id."""67 for row in event.get("tags", []):68 if len(row) >= 4 and row[0] == "e" and row[3] == "root" and row[1]:69 return str(row[1])70 return str(event.get("id", ""))71727374class TaskState:75 request_id: str76 room: str77 requester: str78 thread_root: str79 session_key: str80 accepted: bool = False81 terminal: bool = False82 # Monotonic time of the last published progress; None before the first one.83 last_progress: float | None = None84 pending_text: str = ""85 pending_tool: str = ""86 # Text Hermes returned from the handler, used only when nothing was posted.87 final_text: str = ""88 error: str = ""89 # message id -> latest content and class, in posting order.90 messages: dict[str, str] = field(default_factory=dict)91 classes: dict[str, str] = field(default_factory=dict)92 files: list[tuple[str, str]] = field(default_factory=list)93 final_id: str | None = None94 timer: Any = None9596 # -- observation -----------------------------------------------------9798 def observe(self, message_id: str, content: str, cls: str, *, final: bool = False,99 file_name: str | None = None) -> None:100 """Record a successful send or edit that belongs to this task's turn."""101 if self.terminal or not message_id:102 return103 message_id = str(message_id)104 if message_id not in self.classes:105 self.classes[message_id] = cls106 if cls == FILE and file_name and message_id not in {row[0] for row in self.files}:107 self.files.append((message_id, file_name))108 if cls != FILE or content:109 self.messages[message_id] = content or ""110 if final and self.classes.get(message_id) != FILE:111 # The result follows a final reply; no progress line repeats it.112 self.final_id = message_id113 self.pending_text = self.pending_tool = ""114 return115 line = last_line(content)116 if not line:117 return118 if self.classes.get(message_id) == TEXT:119 self.pending_text = clip(line, PROGRESS_LINE_LIMIT)120 elif cls != FILE:121 self.pending_tool = clip(line, PROGRESS_LINE_LIMIT)122123 def note_error(self, text: str) -> None:124 text = clip(text, ERROR_TEXT_LIMIT)125 if text:126 self.error = text127128129 def delivered(self) -> bool:130 """True once a reply or a file of this task reached the room."""131 return any(cls in (TEXT, FILE) for cls in self.classes.values())132133 # -- progress --------------------------------------------------------134135136 def has_progress(self) -> bool:137 return bool(self.pending_text or self.pending_tool)138139 def progress_due(self, now: float, interval: float) -> float:140 """Return seconds to wait before the pending progress may be published."""141 if self.last_progress is None:142 return 0.0143 return max(0.0, self.last_progress + interval - now)144145 def take_progress(self, now: float) -> str:146 """Return the coalesced progress line and reset the pending state."""147 parts = [part for part in (self.pending_text, self.pending_tool) if part]148 self.pending_text = self.pending_tool = ""149 self.last_progress = now150 return "\n".join(parts)151152 # -- terminal --------------------------------------------------------153154 def end(self) -> bool:155 """Mark the task terminal; True only for the first caller."""156 if self.terminal:157 return False158 self.terminal = True159 self.pending_text = self.pending_tool = ""160 return True161162 def result(self) -> tuple[str, list[list[str]]]:163 """Return the 43004 content and extra tags.164165 Rule: when the final reply is in the thread (a send Hermes marked final or a166 finalizing edit, else the last text message), the content is its first line,167 clipped to 200 characters, and an e tag names that message. Posted files add168 one e tag each and a "Posted ..." line. When nothing was posted, the content169 is the text Hermes returned, else "Done".170 """171 final_id = self.final_id172 if final_id is None:173 texts = [key for key, cls in self.classes.items() if cls == TEXT and self.messages.get(key, "").strip()]174 final_id = texts[-1] if texts else None175 tags: list[list[str]] = []176 lines: list[str] = []177 if final_id is not None and self.messages.get(final_id, "").strip():178 lines.append(clip(first_line(self.messages[final_id]), SUMMARY_LIMIT))179 tags.append(["e", final_id])180 if self.files:181 lines.append("Posted " + ", ".join(name for _, name in self.files))182 tags.extend(["e", message_id] for message_id, _ in self.files)183 if lines:184 return "\n".join(lines), tags185 text = str(self.final_text or "").strip()186 return (text[:RESULT_TEXT_LIMIT] if text else "Done"), []187188 def failure(self) -> str:189 return self.error or "The task failed"190191192def answer_tags(task: TaskState) -> list[list[str]]:193 """Tags every answer to a request carries besides the room's h tag."""194 return [["e", task.request_id], ["p", task.requester]]195196197__all__ = ["TaskState", "TERMINAL_KINDS", "KIND_JOB_REQUEST", "KIND_JOB_ACCEPTED", "KIND_JOB_PROGRESS",198 "KIND_JOB_RESULT", "KIND_JOB_CANCEL", "KIND_JOB_ERROR", "TEXT", "CHROME", "FILE",199 "answer_tags", "clip", "first_line", "last_line", "request_text", "request_root"]