diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ccf5cc2..6b3e96d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,11 +43,13 @@ jobs: fail-fast: false matrix: include: - # No Intel macOS target: macos-13 (the last Intel runner image) is deprecated and - # its queue waits run to hours, which blocks the release job. Intel Macs are - # 2020-and-earlier hardware — revisit only if beta users actually ask. - os: macos-latest # Apple Silicon slug: macos-arm64 + # Intel macOS: macos-13 retired in Dec 2025; macos-15-intel replaced it and is + # the LAST x86_64 image Actions will offer (available until Aug 2027). Builds + # natively — the sidecar is a PyInstaller freeze, which cannot cross-compile. + - os: macos-15-intel + slug: macos-x64 - os: windows-latest slug: windows runs-on: ${{ matrix.os }} diff --git a/coworker/compaction.py b/coworker/compaction.py new file mode 100644 index 00000000..678682a5 --- /dev/null +++ b/coworker/compaction.py @@ -0,0 +1,561 @@ +"""Auto-compaction of long session histories (OPE-27). + +When the outbound history approaches the model's context limit, the older portion of the +*outbound* view is replaced with (a) an LLM-written structured summary and (b) mechanically +extracted state — the recent turns and all user messages survive. The persisted transcript +is never modified; only what is sent to the model. Full design: ocw-context +docs/auto-compaction-spec.md (approved 2026-07-28). + +This module is pure functions + one dataclass; the engine owns *when* (its run loop) and +*with what* (its provider/model), both injected here. That split keeps the engine.py +footprint to a few lines and makes every policy testable without a provider. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +# Trigger: min(threshold_pct × context_window, cap_tokens). The cap exists so 1M-context +# models compact early — quality and latency degrade well before the nominal limit. +DEFAULT_THRESHOLD_PCT = 0.8 +DEFAULT_CAP_TOKENS = 250_000 +# Models without a verified context_window entry in the matrix. +DEFAULT_CONTEXT_WINDOW = 128_000 +# The newest slice kept verbatim, as a fraction of the trigger (a token budget, not a +# turn count — one huge tool loop shouldn't starve the working set). +KEEP_RECENT_FRACTION = 0.25 +# The summarizer call itself: tools off, modest ceiling. +SUMMARY_MAX_TOKENS = 3_000 +# Per-message clip when rendering the span for the summarizer; tool results are the +# first casualty (huge and mostly stale — a file read 40 turns ago is better re-read). +_SPAN_TOOL_RESULT_CLIP = 400 +_SPAN_BUDGET_CHARS = 400_000 +# User messages preserved mechanically in the compacted block ("trimmed of pasted bulk"). +# The list is capped to the newest N across repeated compactions — otherwise it appends +# forever and the block slowly reclaims the window it freed. Dropped ones stay counted +# (their intent lives in the summary, which is asked to list user messages too). +_USER_MESSAGE_CLIP = 600 +_USER_MESSAGES_MAX = 40 +_TRIM_FRACTION = 0.10 + + +# -- token math --------------------------------------------------------------- + + +def estimate_tokens(messages: list[dict[str, Any]]) -> int: + """chars/4 over the serialized messages — the fallback signal for providers that + never report usage (documented in the metering code).""" + total = 0 + for msg in messages: + try: + total += len(json.dumps(msg, default=str)) + except (TypeError, ValueError): + total += len(str(msg)) + return total // 4 + + +def trigger_tokens( + context_window: Optional[int], + *, + threshold_pct: float = DEFAULT_THRESHOLD_PCT, + cap_tokens: int = DEFAULT_CAP_TOKENS, +) -> int: + window = context_window or DEFAULT_CONTEXT_WINDOW + return min(int(threshold_pct * window), int(cap_tokens)) + + +def should_compact( + signal: int, + context_window: Optional[int], + *, + threshold_pct: float = DEFAULT_THRESHOLD_PCT, + cap_tokens: int = DEFAULT_CAP_TOKENS, +) -> bool: + return signal >= trigger_tokens( + context_window, threshold_pct=threshold_pct, cap_tokens=cap_tokens + ) + + +# -- state -------------------------------------------------------------------- + + +@dataclass +class CompactionState: + """One compaction point. `boundary_index` is an index into the CANONICAL message list: + messages before it are represented by the compacted block in the outbound view; messages + from it on are sent verbatim. Persisted with the session so reloads keep the view.""" + + boundary_index: int + summary_text: str + working_state: str + user_messages: list[str] = field(default_factory=list) + # How many older user messages were dropped by the _USER_MESSAGES_MAX cap, across + # all compactions of this session — keeps the block's "N earlier omitted" honest. + user_messages_dropped: int = 0 + created_at: float = 0.0 + model_used: str = "" + trimmed: bool = False # True when this state came from the no-summary trim fallback + + def as_dict(self) -> dict[str, Any]: + return { + "boundary_index": self.boundary_index, + "summary_text": self.summary_text, + "working_state": self.working_state, + "user_messages": list(self.user_messages), + "user_messages_dropped": self.user_messages_dropped, + "created_at": self.created_at, + "model_used": self.model_used, + "trimmed": self.trimmed, + } + + @classmethod + def from_dict(cls, raw: Any) -> Optional["CompactionState"]: + if not isinstance(raw, dict) or "boundary_index" not in raw: + return None + return cls( + boundary_index=int(raw.get("boundary_index", 0)), + summary_text=str(raw.get("summary_text", "")), + working_state=str(raw.get("working_state", "")), + user_messages=[str(u) for u in raw.get("user_messages") or []], + user_messages_dropped=int(raw.get("user_messages_dropped", 0)), + created_at=float(raw.get("created_at", 0.0)), + model_used=str(raw.get("model_used", "")), + trimmed=bool(raw.get("trimmed", False)), + ) + + +# -- boundary ----------------------------------------------------------------- + + +def _turn_starts(messages: list[dict[str, Any]], *, start: int) -> tuple[list[int], list[int]]: + """Candidate boundary indexes past `start`: user-message indexes (turn starts, + preferred) and assistant indexes (iteration starts — legal suffix heads; a `tool` + message must never head the outbound view).""" + users, assistants = [], [] + for i in range(start, len(messages)): + role = messages[i].get("role") + if role == "user": + users.append(i) + elif role == "assistant": + assistants.append(i) + return users, assistants + + +def pick_boundary(messages: list[dict[str, Any]], *, keep_tokens: int) -> Optional[int]: + """The canonical index where the verbatim tail begins: the earliest turn start whose + suffix fits the keep budget. Prefers user-message boundaries; falls back to iteration + (assistant) boundaries when the newest turn alone exceeds the budget (a giant tool + loop). None when there is nothing meaningful to summarize.""" + start = 1 if messages and messages[0].get("role") == "system" else 0 + users, assistants = _turn_starts(messages, start=start) + + def _fit(candidates: list[int]) -> Optional[int]: + for i in candidates: # earliest-first: keep as much verbatim as fits + if estimate_tokens(messages[i:]) <= keep_tokens: + return i + return None + + boundary = _fit(users) + if boundary is None and users: + # The newest user turn alone blows the budget — cut inside it at an iteration + # boundary, keeping at least the most recent assistant step. + inside = [i for i in assistants if i > users[-1]] + boundary = _fit(inside) + if boundary is None: + boundary = inside[-1] if inside else users[-1] + if boundary is None: + boundary = _fit(assistants) or (assistants[-1] if assistants else None) + # A boundary at (or before) the first real message summarizes nothing — skip. + if boundary is None or boundary <= start: + return None + return boundary + + +# -- mechanical extraction (no LLM — zero hallucination risk) ----------------- + +_WRITE_HINTS = ("write", "edit", "append", "save", "create", "patch") +_ARTIFACT_HINTS = ("artifact", "publish", "deploy") + + +def _iter_tool_calls(span: list[dict[str, Any]]): + """(name, args, result_content) for every tool call in the span, in order.""" + results = { + m.get("tool_call_id"): m.get("content") + for m in span + if m.get("role") == "tool" + } + for msg in span: + if msg.get("role") != "assistant": + continue + for tc in msg.get("tool_calls") or []: + fn = tc.get("function") or {} + try: + args = json.loads(fn.get("arguments") or "{}") + except (ValueError, TypeError): + args = {} + yield str(fn.get("name") or ""), args, results.get(tc.get("id")) + + +def _result_status(result: Any) -> str: + if not isinstance(result, str): + return "" + try: + parsed = json.loads(result) + except (ValueError, TypeError): + return "" + if not isinstance(parsed, dict): + return "" + if parsed.get("error"): + return "error" + if "exit_code" in parsed: + code = parsed.get("exit_code") + return "ok" if code in (0, "0") else f"exit {code}" + return "" + + +def extract_working_state(span: list[dict[str, Any]]) -> str: + """The mechanical block appended to the summary by CODE, from the span's tool-call + records: files written, recent commands (+ exit status), artifacts, tools used.""" + files: list[str] = [] + commands: list[str] = [] + artifacts: list[str] = [] + tools: list[str] = [] + for name, args, result in _iter_tool_calls(span): + if name and name not in tools: + tools.append(name) + lowered = name.lower() + path = args.get("path") or args.get("file_path") + if path and any(h in lowered for h in _WRITE_HINTS): + files.append(str(path)) + if lowered == "run_shell" and args.get("command"): + status = _result_status(result) + line = " ".join(str(args["command"]).split())[:160] + commands.append(f"{line}" + (f" [{status}]" if status else "")) + if any(h in lowered for h in _ARTIFACT_HINTS): + location = args.get("url") or args.get("path") or args.get("title") + if location: + artifacts.append(str(location)) + + def _dedupe_recent_first(items: list[str], limit: int) -> list[str]: + seen: list[str] = [] + for item in reversed(items): # most recent first + if item not in seen: + seen.append(item) + if len(seen) >= limit: + break + return seen + + lines = ["## Working state (extracted mechanically from tool records)"] + written = _dedupe_recent_first(files, 20) + if written: + lines.append("Files written/edited (most recent first):") + lines += [f"- {p}" for p in written] + recent_cmds = commands[-10:] + if recent_cmds: + lines.append("Recent shell commands:") + lines += [f"- {c}" for c in recent_cmds] + made = _dedupe_recent_first(artifacts, 10) + if made: + lines.append("Artifacts produced:") + lines += [f"- {a}" for a in made] + if tools: + lines.append("Tools used in the summarized span: " + ", ".join(sorted(tools))) + return "\n".join(lines) if len(lines) > 1 else "" + + +def _text_of(content: Any) -> str: + """A message's text, whether plain or content-parts (images become a placeholder).""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for p in content: + if isinstance(p, dict) and p.get("type") == "text": + parts.append(str(p.get("text", ""))) + elif isinstance(p, dict) and p.get("type") == "image_url": + parts.append("[image]") + return "\n".join(parts) + return "" if content is None else str(content) + + +def extract_user_messages( + span: list[dict[str, Any]], *, clip: int = _USER_MESSAGE_CLIP +) -> list[str]: + """Every user message in the span, chronological, trimmed of pasted bulk. Preserved + mechanically — the summarizer is also asked to list them, but user words are the + ground truth of intent and must not depend on an LLM remembering to include them.""" + out: list[str] = [] + for msg in span: + if msg.get("role") != "user": + continue + text = " ".join(_text_of(msg.get("content")).split()) + if not text: + continue + out.append(text[: clip - 1] + "…" if len(text) > clip else text) + return out + + +def _cap_user_messages( + messages: list[str], *, prior_dropped: int, limit: int = _USER_MESSAGES_MAX +) -> tuple[list[str], int]: + """Newest-`limit` slice plus the running total of everything ever dropped.""" + if len(messages) <= limit: + return messages, prior_dropped + return messages[-limit:], prior_dropped + (len(messages) - limit) + + +# -- summarizer --------------------------------------------------------------- + +SUMMARY_SYSTEM_PROMPT = """You are compacting an AI coworker's session history so the coworker can continue working in a smaller context. Write a structured summary of the conversation below. It is the coworker's ONLY memory of these turns, so preserve everything load-bearing. + +Produce ALL of the following sections, in this order, each as a markdown heading: + +1. **Primary request and intent** — what the user is trying to get done, in their terms, including standing constraints stated at any point (e.g. "never send without my approval"). Constraints outlive the turns they were stated in. +2. **Key concepts and decisions** — domain facts, technical choices, and rationale established so far. Include the WHY, not just the what — a decision without its reason gets relitigated. +3. **Artifacts and files** — every file/deliverable created, modified, or read that still matters: path, its role, and a short excerpt of load-bearing content only. +4. **Errors and fixes** — problems hit and how they were resolved, including user corrections ("no, do it this way") — those are feedback with lasting force. +5. **All user messages** — a chronological list of every user message (trimmed of pasted bulk). This is the intent audit-trail. +6. **Pending tasks** — explicitly incomplete items, promised follow-ups, things the user said "later" about. +7. **Current work** — precisely what was in progress at this point: which step, which file, what state. +8. **Next step** — the immediate next action, justified by the user's request. + +Rules: +- Do NOT carry full file contents as truth. Note THAT a file was read/edited; the coworker re-reads if it needs the content again. Stale memory of a file is worse than no memory. +- Be concrete: paths, names, commands, ids — not vague references. +- Output only the summary sections, no preamble.""" + +CONTINUATION_CONTRACT = ( + "Continue where you left off: pick up the current work and next step exactly as " + "described. Do not re-ask answered questions, do not recap, do not mention that the " + "context was compacted. If you need the contents of a file noted above, re-read it." +) + + +def _render_span(span: list[dict[str, Any]], *, budget_chars: int = _SPAN_BUDGET_CHARS) -> str: + """The summarized span as compact text for the summarizer. Tool results are clipped + hard (first casualty); if the whole render still exceeds the budget, oldest lines are + dropped — the newest context is the most load-bearing.""" + lines: list[str] = [] + for msg in span: + role = msg.get("role") + if role == "system": + continue + if role == "notice": + continue + if role == "tool": + text = _text_of(msg.get("content")) + text = " ".join(text.split()) + if len(text) > _SPAN_TOOL_RESULT_CLIP: + text = text[: _SPAN_TOOL_RESULT_CLIP - 1] + "…" + lines.append(f"[tool result] {text}") + continue + text = _text_of(msg.get("content")) + if role == "assistant": + for tc in msg.get("tool_calls") or []: + fn = tc.get("function") or {} + args = " ".join(str(fn.get("arguments", "")).split()) + if len(args) > 200: + args = args[:199] + "…" + lines.append(f"[assistant → {fn.get('name')}] {args}") + if text: + lines.append(f"[assistant] {text}") + elif role == "user": + lines.append(f"[user] {text}") + rendered = "\n".join(lines) + if len(rendered) > budget_chars: + rendered = "(…oldest turns elided…)\n" + rendered[-budget_chars:] + return rendered + + +def summarizer_messages( + span: list[dict[str, Any]], *, prior_summary: str = "" +) -> list[dict[str, Any]]: + """The provider-ready messages for the summarizer call. On repeated compaction the + previous summary is message zero of the new span — summarized along with the turns + since.""" + body = _render_span(span) + if prior_summary: + body = ( + "[previous compaction summary — fold its still-relevant content into the new " + "summary]\n" + prior_summary + "\n\n[conversation since]\n" + body + ) + return [ + {"role": "system", "content": SUMMARY_SYSTEM_PROMPT}, + {"role": "user", "content": body}, + ] + + +def summarize_span( + provider: Any, + model: str, + span: list[dict[str, Any]], + *, + prior_summary: str = "", + max_tokens: int = SUMMARY_MAX_TOKENS, +) -> str: + """One summarizer round-trip (blocking — the engine runs it off-loop). Tools are + disabled; the Settings model override is just a different `model` id. Raises on + provider failure or an empty summary — the caller owns the retry/trim policy.""" + turn = provider.complete( + model=model, + messages=summarizer_messages(span, prior_summary=prior_summary), + tools=None, + max_tokens=max_tokens, + ) + text = (getattr(turn, "text", None) or "").strip() + if not text: + raise RuntimeError("summarizer returned an empty summary") + return text + + +# -- building + applying a compaction ----------------------------------------- + + +def build_state( + messages: list[dict[str, Any]], + *, + provider: Any, + model: str, + keep_tokens: int, + prior: Optional[CompactionState] = None, +) -> Optional[CompactionState]: + """Summarize everything older than the picked boundary into a new CompactionState. + On repeated compaction the prior summary heads the new span. Returns None when there + is nothing to compact; raises when the summarizer fails (caller applies policy).""" + boundary = pick_boundary(messages, keep_tokens=keep_tokens) + if boundary is None or (prior is not None and boundary <= prior.boundary_index): + return None + span_start = prior.boundary_index if prior is not None else 0 + span = messages[span_start:boundary] + prior_users = list(prior.user_messages) if prior is not None else [] + summary = summarize_span( + provider, + model, + span, + prior_summary=prior.summary_text if prior is not None else "", + ) + users, dropped = _cap_user_messages( + prior_users + extract_user_messages(span), + prior_dropped=prior.user_messages_dropped if prior is not None else 0, + ) + return CompactionState( + boundary_index=boundary, + summary_text=summary, + working_state=extract_working_state(span), + user_messages=users, + user_messages_dropped=dropped, + created_at=time.time(), + model_used=model, + ) + + +def trim_state( + messages: list[dict[str, Any]], + *, + prior: Optional[CompactionState] = None, + fraction: float = _TRIM_FRACTION, +) -> Optional[CompactionState]: + """The no-LLM fallback: advance the boundary past ~`fraction` of the outbound + messages. No summary — but the mechanical block and the user-message list (never + trimmed away, per spec) are free, so the model still gets deterministic state.""" + start = prior.boundary_index if prior is not None else 0 + remaining = len(messages) - start + if remaining <= 2: + return None + step = max(1, int(remaining * fraction)) + target = start + step + # Land on a legal suffix head at or after the target (never a tool message). + boundary = None + for i in range(target, len(messages)): + if messages[i].get("role") in ("user", "assistant"): + boundary = i + break + if boundary is None or boundary <= start or boundary >= len(messages): + return None + span = messages[start:boundary] + prior_users = list(prior.user_messages) if prior is not None else [] + summary = ( + (prior.summary_text + "\n\n" if prior is not None and prior.summary_text else "") + + "(Older turns were trimmed to fit the context window; no summary is available " + "for them. Re-read files and re-run commands if earlier results are needed.)" + ) + users, dropped = _cap_user_messages( + prior_users + extract_user_messages(span), + prior_dropped=prior.user_messages_dropped if prior is not None else 0, + ) + return CompactionState( + boundary_index=boundary, + summary_text=summary, + working_state=extract_working_state(span), + user_messages=users, + user_messages_dropped=dropped, + created_at=time.time(), + model_used="", + trimmed=True, + ) + + +def compacted_block(state: CompactionState) -> str: + """The single outbound message standing in for everything before the boundary.""" + parts = [ + "", + "Earlier turns of this session were compacted. The summary below is your memory " + "of them.", + "", + state.summary_text, + ] + if state.working_state: + parts += ["", state.working_state] + if state.user_messages: + parts += ["", "## User messages in the compacted span (verbatim, chronological)"] + if state.user_messages_dropped: + parts += [ + f"({state.user_messages_dropped} earlier user messages omitted — " + "their intent is covered by the summary above)" + ] + parts += [f"- {u}" for u in state.user_messages] + parts += ["", CONTINUATION_CONTRACT, ""] + return "\n".join(parts) + + +def apply_to_outbound( + messages: list[dict[str, Any]], state: Optional[CompactionState] +) -> list[dict[str, Any]]: + """The outbound view: [system?] + the compacted block (as a user message) + the + verbatim tail. Canonical history is untouched; provider-private sidecars in the + summarized span vanish with it (replay chains legally restart after a compaction + point). No-op when state is absent or stale.""" + if state is None: + return messages + boundary = state.boundary_index + if boundary <= 0 or boundary >= len(messages): + return messages + head: list[dict[str, Any]] = [] + if messages and messages[0].get("role") == "system": + head.append(messages[0]) + head.append({"role": "user", "content": compacted_block(state)}) + return head + messages[boundary:] + + +# -- overflow detection ------------------------------------------------------- + +_OVERFLOW_MARKERS = ( + "context_length_exceeded", + "maximum context length", + "context window", + "prompt is too long", + "input is too long", + "too many tokens", + "input length and `max_tokens` exceed", + "exceeds the maximum number of tokens", +) + + +def is_context_overflow(exc: BaseException) -> bool: + """A raw context-overflow 400 from the main model (compaction mispredicted, e.g. the + estimate path) — routed into the compaction policy instead of surfacing.""" + text = str(exc).lower() + return any(marker in text for marker in _OVERFLOW_MARKERS) diff --git a/coworker/connectors/browser_automation.py b/coworker/connectors/browser_automation.py index e1e22e0c..357cb005 100644 --- a/coworker/connectors/browser_automation.py +++ b/coworker/connectors/browser_automation.py @@ -17,6 +17,8 @@ import aisuite as ai +from ..web.guard import check_url + def _meta( name: str, *, approval: bool = False, capabilities: Optional[list[str]] = None @@ -332,6 +334,12 @@ def browser_open_url( ) -> dict[str, Any]: if not url.lower().startswith(("http://", "https://")): return {"error": "url must start with http:// or https://"} + # Same address guard as web_fetch. This is approval gated, so it is defense in + # depth, not the primary control. It checks the initial model supplied URL only; + # redirects that the browser follows internally are not hop checked here. + blocked = check_url(url) + if blocked: + return {"error": blocked} return _BROWSER.call( "open_url", lambda page: ( diff --git a/coworker/connectors/integration_tools.py b/coworker/connectors/integration_tools.py index 7588136d..f0f18d0b 100644 --- a/coworker/connectors/integration_tools.py +++ b/coworker/connectors/integration_tools.py @@ -19,6 +19,7 @@ import aisuite as ai from ..secrets import SecretStore +from ..web.guard import get_checked from .browser_automation import make_browser_automation_tools from .email_tools import make_email_tools from .tool_defs import approval_for_tool, connector_for_tool @@ -305,15 +306,39 @@ def _gmail_is_hidden( def _request( - method: str, url: str, *, headers=None, params=None, json=None, auth=None + method: str, + url: str, + *, + headers=None, + params=None, + json=None, + auth=None, + check_addresses: bool = False, ) -> dict[str, Any]: + """HTTP for the connectors. + + `check_addresses` is for URLs the *model* supplies (browser_read_url). It turns off + automatic redirects and walks the chain through the address guard instead, so a public + URL cannot 302 into loopback or the metadata endpoint. The vendor endpoints everything + else in this module calls are hardcoded, so they skip the guard and its DNS lookup. + """ try: import httpx - with httpx.Client(timeout=30.0, follow_redirects=True) as client: - resp = client.request( - method, url, headers=headers, params=params, json=json, auth=auth - ) + with httpx.Client( + timeout=30.0, follow_redirects=not check_addresses + ) as client: + if check_addresses: + if method.upper() != "GET": + return {"error": "address-checked requests must be GET"} + try: + resp = get_checked(client, url) + except PermissionError as exc: + return {"error": str(exc)} + else: + resp = client.request( + method, url, headers=headers, params=params, json=json, auth=auth + ) ctype = resp.headers.get("content-type", "") data: Any = resp.json() if "json" in ctype.lower() else resp.text if resp.status_code >= 400: @@ -533,7 +558,13 @@ def make_integration_tools( def browser_read_url(url: str, max_chars: int = 20000) -> dict[str, Any]: if not url.lower().startswith(("http://", "https://")): return {"error": "url must start with http:// or https://"} - out = _request("GET", url, headers={"User-Agent": "coworker/0.1 (+connector)"}) + # Model-supplied URL: address-check every hop, same guard as web_fetch. + out = _request( + "GET", + url, + headers={"User-Agent": "coworker/0.1 (+connector)"}, + check_addresses=True, + ) if "error" in out: return out data = out["data"] diff --git a/coworker/conversations.py b/coworker/conversations.py index fd2131bf..e67ef2fb 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -95,6 +95,7 @@ def __init__(self, base_dir: str | Path) -> None: "ALTER TABLE sessions ADD COLUMN auto_title TEXT", "ALTER TABLE sessions ADD COLUMN renamed INTEGER DEFAULT 0", "ALTER TABLE sessions ADD COLUMN grants TEXT", + "ALTER TABLE sessions ADD COLUMN compaction TEXT", ): try: self._conn.execute(ddl) @@ -191,13 +192,14 @@ def save(self, record: SessionRecord) -> None: title = record.title or title_from(record.messages) self._conn.execute( """ - INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO sessions (session_id, workspace, model, mode, title, agent, n_msgs, messages, extra_roots, grants, compaction, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(session_id) DO UPDATE SET workspace = excluded.workspace, model = excluded.model, mode = excluded.mode, title = COALESCE(sessions.title, excluded.title), agent = excluded.agent, n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots, - grants = excluded.grants, updated_at = CURRENT_TIMESTAMP + grants = excluded.grants, compaction = excluded.compaction, + updated_at = CURRENT_TIMESTAMP """, ( sid, @@ -209,6 +211,7 @@ def save(self, record: SessionRecord) -> None: len(record.messages), json.dumps(record.extra_roots or []), json.dumps(record.grants or {}), + json.dumps(record.compaction or {}), ), ) self._conn.commit() @@ -241,6 +244,10 @@ def load(self, session_id: str) -> Optional[SessionRecord]: row["extra_roots"] if "extra_roots" in row.keys() else None ), grants=_load_grants(row["grants"] if "grants" in row.keys() else None), + # Auto-compaction state (OPE-27) — same defensive parse as grants. + compaction=_load_grants( + row["compaction"] if "compaction" in row.keys() else None + ), pinned=bool(row["pinned"]), archived=bool(row["archived"]), origin=row["origin"], diff --git a/coworker/engine.py b/coworker/engine.py index 3ce77d1e..ae34d4a0 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -19,6 +19,7 @@ from enum import Enum from typing import Any, AsyncIterator, Awaitable, Callable, Optional +from . import compaction as _compaction from .events import Event, EventType from .permissions import Mode, PermissionEngine from .providers import AssistantTurn, ProviderClient, ToolCall @@ -103,6 +104,14 @@ def __init__( # (answerable inline in a live session or from the Inbox when unattended). None on surfaces # that can't ask (the tool then no-ops). self.question_asker = question_asker + # Auto-compaction (OPE-27) — set post-construction by the surface/manager so the + # constructor footprint stays put. `compaction_settings` is a live getter (Settings + # changes apply without a rebuild); `is_attended` gates the failure prompt (None → + # treat as unattended: never park a background run on internal bookkeeping). + self.compaction_state: Optional[_compaction.CompactionState] = None + self.compaction_settings: Optional[Callable[[], dict[str, Any]]] = None + self.is_attended: Optional[Callable[[], bool]] = None + self._last_context_tokens: Optional[int] = None self.audit_context: dict[str, Any] = {} if instructions and not ( self.messages and self.messages[0].get("role") == "system" @@ -302,6 +311,18 @@ async def _loop(self) -> AsyncIterator[Event]: return iterations += 1 + # Auto-compaction checkpoint (OPE-27): between tool turns and before a new + # turn's first call. Deliberately no "wrap up" warning to the model. The + # COMPACTING signal precedes the (multi-second) summarizer call so surfaces + # can show progress instead of a silent stall. + notice = None + if self._compaction_due(): + yield Event(EventType.COMPACTING, {}) + notice = await self._compact_now() + if notice: + self._append_notice("compacted", notice) + yield Event(EventType.COMPACTED, {"text": notice}) + turn: Optional[AssistantTurn] = None streamed: list[str] = [] streamed_reasoning: list[str] = [] @@ -329,6 +350,17 @@ def _partial_turn() -> AssistantTurn: if chunk.turn is not None: turn = chunk.turn except Exception as exc: # provider failure + # A raw context-overflow 400 (compaction mispredicted, e.g. the estimate + # path) routes into the compaction policy instead of surfacing. The retry + # is progress-guarded: each pass moves the boundary forward or gives up, + # so a model that keeps overflowing still terminates in the error path. + if _compaction.is_context_overflow(exc) and not self._cancel.is_set(): + yield Event(EventType.COMPACTING, {}) + notice = await self._compact_now(force=True) + if notice: + self._append_notice("compacted", notice) + yield Event(EventType.COMPACTED, {"text": notice}) + continue # Same contract as the stop path below: the partial the user watched # arrive survives the failure. if streamed or streamed_reasoning: @@ -352,6 +384,10 @@ def _partial_turn() -> AssistantTurn: return if turn is None: turn = AssistantTurn() + if turn.usage is not None: + # The trigger signal: the prompt-side total that actually occupied the + # window on this round-trip (estimate fallback when never reported). + self._last_context_tokens = turn.usage.context_tokens self.messages.append(_assistant_message(turn, model=self.model)) payload: dict[str, Any] = { @@ -386,6 +422,104 @@ def _partial_turn() -> AssistantTurn: if self._steering: self._inject_steering() + # -- auto-compaction (OPE-27) ------------------------------------------------ + def _compaction_config(self) -> dict[str, Any]: + cfg = dict(self.compaction_settings() or {}) if self.compaction_settings else {} + if not cfg.get("context_window"): + from .providers.matrix import model_context_windows + + cfg["context_window"] = model_context_windows().get(self.model) + cfg.setdefault("threshold_pct", _compaction.DEFAULT_THRESHOLD_PCT) + cfg.setdefault("cap_tokens", _compaction.DEFAULT_CAP_TOKENS) + return cfg + + def _compaction_due(self) -> bool: + """The trigger check alone — cheap and side-effect free, so the loop can emit + the COMPACTING signal before committing to the (slow) summarizer call.""" + cfg = self._compaction_config() + if cfg.get("enabled") is False: + return False + signal = self._last_context_tokens or _compaction.estimate_tokens( + self._outbound_messages() + ) + return _compaction.should_compact( + signal, + cfg.get("context_window"), + threshold_pct=float(cfg["threshold_pct"]), + cap_tokens=int(cfg["cap_tokens"]), + ) + + async def _compact_now(self, *, force: bool = False) -> Optional[str]: + """Run the compaction policy. Callers gate on `_compaction_due()` (or `force`, + the overflow path). Returns the user-facing notice text when the outbound view + changed, else None. Failure policy per spec: retry once (both modes); attended → + Retry / Trim prompt; unattended → auto-trim and continue (never park a run on + bookkeeping).""" + cfg = self._compaction_config() + pct = float(cfg["threshold_pct"]) + cap = int(cfg["cap_tokens"]) + window = cfg.get("context_window") + keep = int( + _compaction.KEEP_RECENT_FRACTION + * _compaction.trigger_tokens(window, threshold_pct=pct, cap_tokens=cap) + ) + model = str(cfg.get("model") or "") or self.model + + def _build() -> Optional[_compaction.CompactionState]: + return _compaction.build_state( + self.messages, + provider=self.provider, + model=model, + keep_tokens=keep, + prior=self.compaction_state, + ) + + state: Optional[_compaction.CompactionState] = None + failed = False + for _attempt in range(2): # first try + the unconditional single retry + try: + state = await asyncio.to_thread(_build) + failed = False + break + except Exception: + failed = True + if failed and self.question_asker is not None and self.is_attended and self.is_attended(): + while True: + answer = await self._interruptible( + self.question_asker( + { + "question": ( + "Context compaction failed — the summarizer couldn't " + "condense this session's history. How should I proceed?" + ), + "options": ["Retry", "Trim oldest 10%"], + "allow_text": False, + "header": "Compaction", + }, + None, + ), + interrupted=None, + ) + if not answer or answer.get("answer") != "Retry": + break + try: + state = await asyncio.to_thread(_build) + failed = False + break + except Exception: + continue + if state is not None: + self.compaction_state = state + self._last_context_tokens = None # stale once the outbound view shrank + return "Context compacted — earlier turns were summarized" + if failed or force: + trimmed = _compaction.trim_state(self.messages, prior=self.compaction_state) + if trimmed is not None: + self.compaction_state = trimmed + self._last_context_tokens = None + return "Context trimmed — oldest turns dropped (summary unavailable)" + return None + # -- helpers ---------------------------------------------------------------- async def _astream(self): """Bridge the provider's blocking stream generator to the async loop via a @@ -893,13 +1027,19 @@ def _outbound_messages(self) -> list[dict[str, Any]]: # one. Whole `notice` messages (error/interrupted/model-switch markers) are # display-only too: dropped entirely. _SIDECARS = ("source", "_display", "ts", "reasoning", "usage") + # Auto-compaction (OPE-27): everything before the boundary is represented by the + # compacted block. Outbound-only — the canonical history stays intact — and the + # block+tail are byte-stable between turns, so prompt caching keeps working. + source_messages = _compaction.apply_to_outbound( + self.messages, self.compaction_state + ) out = [ ( {k: v for k, v in msg.items() if k not in _SIDECARS} if any(s in msg for s in _SIDECARS) else msg ) - for msg in self.messages + for msg in source_messages if msg.get("role") != "notice" ] # PDF attachments (stored as `file` parts) are adapted to the ACTIVE model right diff --git a/coworker/events.py b/coworker/events.py index 4cdfc192..cdb9fe00 100644 --- a/coworker/events.py +++ b/coworker/events.py @@ -31,6 +31,8 @@ class EventType(str, Enum): TURN_END = "turn_end" ERROR = "error" INTERRUPTED = "interrupted" + COMPACTING = "compacting" # compaction started — surfaces show a transient signal + COMPACTED = "compacted" # outbound history was compacted (summary or trim) @dataclass diff --git a/coworker/inbox_routing.py b/coworker/inbox_routing.py index ef115e95..f3efc9ed 100644 --- a/coworker/inbox_routing.py +++ b/coworker/inbox_routing.py @@ -23,6 +23,9 @@ # to OpenWorker (2026-07-22); the legacy [ocw:…] spelling stays parseable so replies to # messages sent before the rename still resolve. _ID_TOKEN = re.compile(r"\[o(?:c)?w:([0-9a-f]{6,})\]") +# Whole words only — substring matching resolved "disallow" as allow and "note" as deny. +_ALLOW_WORDS = re.compile(r"\b(?:approve|approved|allow|allowed|yes)\b") +_DENY_WORDS = re.compile(r"\b(?:deny|denied|reject|rejected|no)\b") @dataclass @@ -130,9 +133,9 @@ def resolve_from_reply( return None item_id = m.group(1) lowered = reply.lower() - if any(w in lowered for w in ("approve", "allow", "yes", "👍", "✅")): + if _ALLOW_WORDS.search(lowered) or "👍" in reply or "✅" in reply: resolution = "allow" - elif any(w in lowered for w in ("deny", "reject", "no", "👎", "❌")): + elif _DENY_WORDS.search(lowered) or "👎" in reply or "❌" in reply: resolution = "deny" else: resolution = _ID_TOKEN.sub("", reply).strip() # free-text answer to a question diff --git a/coworker/mcp/config.py b/coworker/mcp/config.py index ae04a0ad..8bdbeadc 100644 --- a/coworker/mcp/config.py +++ b/coworker/mcp/config.py @@ -1,7 +1,9 @@ """MCP server config — the standard `mcpServers` JSON, layered global + workspace. Global: ~/.config/coworker/mcp.json -Workspace: /.coworker/mcp.json (overrides global on name clash) +Workspace: /.coworker/mcp.json (overrides global on name clash, + but only after the user trusts that workspace — same gate as + repository `allowed_commands`) Paste-compatible with Claude Desktop / Cursor / Codex. `${VAR}` refs in command/args/env/ url/headers are resolved at load time via the SecretStore (env + local `.env`). REST edits @@ -50,9 +52,15 @@ def _read(path: Path) -> dict[str, Any]: return {} -def _config_paths(workspace: Optional[str | Path]) -> list[Path]: +def _config_paths( + workspace: Optional[str | Path], *, workspace_trusted: bool +) -> list[Path]: + """Config files to merge. Workspace MCP is executable provenance (stdio spawn), + so an untrusted repo's `.coworker/mcp.json` is never read — cloning alone must + not be enough to define processes that run at session open. + """ paths = [global_mcp_path()] - if workspace: + if workspace and workspace_trusted: paths.append(Path(workspace).expanduser() / ".coworker" / "mcp.json") return paths @@ -79,15 +87,25 @@ def _parse(name: str, raw: dict[str, Any], secrets: SecretStore) -> MCPServerDef def load_mcp_servers( - workspace: Optional[str | Path] = None, *, secrets: Optional[SecretStore] = None + workspace: Optional[str | Path] = None, + *, + secrets: Optional[SecretStore] = None, + workspace_trusted: bool = False, ) -> list[MCPServerDef]: - """Merge global + workspace `mcpServers` (workspace wins) into parsed server defs.""" + """Merge global + (when trusted) workspace `mcpServers` into parsed server defs. + + Only trusted workspaces contribute — the same consent boundary as repository + ``allowed_commands`` — and **global wins on name clash**, so even a trusted repo + cannot silently redefine a global server by reusing its name. ``${VAR}`` refs in + a workspace def are resolved from the user's env, which is acceptable only because + the workspace is trusted; untrusted workspaces are never read. + """ secrets = secrets or SecretStore() merged: dict[str, dict[str, Any]] = {} - for path in _config_paths(workspace): + for path in _config_paths(workspace, workspace_trusted=workspace_trusted): for name, raw in (_read(path).get("mcpServers") or {}).items(): if isinstance(raw, dict): - merged[name] = raw + merged.setdefault(name, raw) # global first → global wins on clash return [_parse(name, raw, secrets) for name, raw in merged.items()] diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index 9c3e0d79..3f1eaf18 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -109,10 +109,37 @@ class ModelEntry: "mistral:mistral-large-latest": ModelEntry( "Mistral Large · Mistral", _AGENTIC, 128_000 ), + # -- SambaNova --------------------------------------------------------- + "sambanova:DeepSeek-V3.1": ModelEntry( + "DeepSeek V3.1 · SambaNova", _AGENTIC, 131_072 + ), + "sambanova:DeepSeek-V3.2": ModelEntry( + "DeepSeek V3.2 · SambaNova", _AGENTIC, 32_768 + ), + "sambanova:Meta-Llama-3.3-70B-Instruct": ModelEntry( + "Llama 3.3 70B · SambaNova", _AGENTIC, 131_072 + ), + "sambanova:MiniMax-M2.7": ModelEntry( + "MiniMax M2.7 · SambaNova", _AGENTIC, 196_608 + ), + "sambanova:gemma-4-31B-it": ModelEntry( + "Gemma 4 31B · SambaNova", _AGENTIC, 131_072 + ), + "sambanova:gpt-oss-120b": ModelEntry( + "GPT OSS 120B · SambaNova", _AGENTIC, 131_072 + ), # -- resellers (their model namespaces, verbatim) ----------------------------- "together:thinkingmachines/Inkling": ModelEntry("Inkling · via Together"), "together:zai-org/GLM-5.2": ModelEntry("GLM-5.2 · via Together", _AGENTIC, 128_000), - # Kimi K3 (2026-07-16) is not on Together yet — weights land ~07-27; revisit then. + # Kimi K3 on Together (landed late July 2026): 1M window, native vision; PDFs + # unverified over the compat surface (falls back via pdf_support.py, like Muse Spark). + "together:moonshotai/Kimi-K3": ModelEntry( + "Kimi K3 · via Together", + ModelCapabilities( + tools=True, vision=True, parallel_tool_calls=True, streaming=True + ), + 1_000_000, + ), "together:moonshotai/Kimi-K2.7-Code": ModelEntry( "Kimi K2.7 Code · via Together", _AGENTIC, 256_000 ), diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index a07ec316..1385966b 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -544,6 +544,14 @@ def _compat( recommended_model="z-ai/glm-5.2", env_key="OPENROUTER_API_KEY", ), + _compat( + "sambanova", + "SambaNova", + base_url="https://api.sambanova.ai/v1", + recommended_model="DeepSeek-V3.1", + env_key="SAMBANOVA_API_KEY", + endpoint_help="SambaNova's OpenAI-compatible API endpoint.", + ), ProviderDescriptor( name="ollama", title="Ollama (local models)", diff --git a/coworker/server/app.py b/coworker/server/app.py index 3b054945..1902ead6 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1367,6 +1367,11 @@ def settings_set_sessions_peek(body: dict) -> dict[str, Any]: # Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03). return manager.set_sessions_peek((body or {}).get("sessions_peek", 5)) + @app.post("/v1/settings/context-bar") + def settings_set_context_bar(body: dict) -> dict[str, Any]: + # Composer: show the context-window fill bar, or just the popover (owner ask). + return manager.set_context_bar((body or {}).get("context_bar", True)) + @app.post("/v1/settings/pdf") def settings_set_pdf(body: dict) -> dict[str, Any]: # Token savings (owner ask, 2026-07-17): fallback mode for models without native @@ -1378,6 +1383,17 @@ def settings_set_pdf(body: dict) -> dict[str, Any]: max_mb=b.get("pdf_max_mb"), ) + @app.post("/v1/settings/compaction") + def settings_set_compaction(body: dict) -> dict[str, Any]: + # Auto-compaction overrides (OPE-27): threshold % of the context window, the + # absolute token cap, and the summarizer-model pin ("" → session's own model). + b = body or {} + return manager.set_compaction_settings( + threshold_pct=b.get("compaction_threshold_pct"), + cap_tokens=b.get("compaction_cap_tokens"), + model=b.get("compaction_model"), + ) + @app.post("/v1/attachments/inspect-pdf") def attachments_inspect_pdf(body: dict) -> dict[str, Any]: # Attach-time page/size probe for the composer's threshold check. Local only. @@ -1677,6 +1693,9 @@ def _resolve_pending(resolution: str) -> None: ) await ws.close() return + # Auto-compaction failure prompt (OPE-27): only an ATTENDED session may be asked + # Retry/Trim — unattended runs auto-trim (the policy in engine._compact_now). + engine.is_attended = lambda: _visibility() == VIS_INLINE await ws.send_json( { "type": "ready", diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 048604d7..fb237ee8 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -276,6 +276,14 @@ def workspace_command_trust(self, path: str | Path) -> dict[str, Any]: "required": bool(commands and not trusted), } + def _mcp_workspace_trusted(self, workspace: Optional[str | Path]) -> bool: + """Whether workspace `.coworker/mcp.json` may be loaded (#213). + + Same consent boundary as repository ``allowed_commands``: an untrusted + clone must not define stdio processes that spawn at session open. + """ + return bool(workspace and self.workspace_trust.is_trusted(workspace)) + def set_workspace_trust( self, path: str | Path, *, trusted: bool ) -> dict[str, Any]: @@ -460,6 +468,13 @@ def get_engine( ) if record is not None and record.grants: self._apply_grants(engine, record.grants) + # Auto-compaction (OPE-27): restore the persisted view boundary and wire the live + # Settings getter — post-construction, so build_engine's signature stays put. + if record is not None and record.compaction: + from ..compaction import CompactionState + + engine.compaction_state = CompactionState.from_dict(record.compaction) + engine.compaction_settings = self.compaction_settings self._engines[session_id] = engine if is_new_session: self._emit_session_created(session_id, agent_name) @@ -881,7 +896,11 @@ async def prepare_mcp_tools( loop = asyncio.get_running_loop() effective: Optional[set[str]] = None # computed lazily, once out: list[Any] = [] - for server in load_mcp_servers(ws, secrets=self.secrets): + for server in load_mcp_servers( + ws, + secrets=self.secrets, + workspace_trusted=self._mcp_workspace_trusted(ws), + ): if not server.enabled: continue if server.auth == "oauth" and not mcp_oauth.has_tokens( @@ -997,7 +1016,11 @@ async def connect_mcp(self, name: str) -> dict[str, Any]: """Connect one server NOW — for OAuth servers this may open the browser and wait for the loopback callback, so callers run it as a background task and watch list_mcp for the status flip.""" - for server in load_mcp_servers(self.default_workspace, secrets=self.secrets): + for server in load_mcp_servers( + self.default_workspace, + secrets=self.secrets, + workspace_trusted=self._mcp_workspace_trusted(self.default_workspace), + ): if server.name != name: continue self._mcp_authorizing.add(name) @@ -1075,7 +1098,11 @@ def delete_mcp(self, name: str) -> dict[str, Any]: async def mcp_tools(self, name: str) -> dict[str, Any]: """Connect one server and list its tools (name + description).""" - for server in load_mcp_servers(self.default_workspace, secrets=self.secrets): + for server in load_mcp_servers( + self.default_workspace, + secrets=self.secrets, + workspace_trusted=self._mcp_workspace_trusted(self.default_workspace), + ): if server.name == name: try: conn = await self.mcp.ensure(server) @@ -1223,32 +1250,40 @@ def list_artifacts(self, session_id: str) -> list[dict[str, Any]]: ".doc", ".docm", } - for path in root.rglob("*"): - try: - rel = path.relative_to(root) - if any( - part.startswith(".") - or part in {"node_modules", "target", "dist", "__pycache__"} - for part in rel.parts - ): + # os.walk with in-place pruning, NOT rglob: rglob descends first and filters after, + # so a home-directory workspace walked into ~/Library and tripped the macOS App Data + # TCC prompt ("OpenWorker would like to access data from other apps") on every turn. + # Pruning here means those directories are never entered at all. + from ..tools.search import OS_DATA_DIRS + + skip = {"node_modules", "target", "dist", "__pycache__"} | OS_DATA_DIRS + for dirpath, dirs, files in os.walk(root): + dirs[:] = [d for d in dirs if not d.startswith(".") and d not in skip] + for name in files: + if name.startswith("."): continue - if not path.is_file() or path.suffix.lower() not in suffixes: + path = Path(dirpath) / name + if path.suffix.lower() not in suffixes: + continue + try: + st = path.stat() + if not path.is_file(): + continue + out.append( + { + "path": str(path.relative_to(root)), + # Absolute path for "Copy path" — the relative one is useless + # outside the app (tester catch 2026-07-12: it copied just the + # filename). + "abs_path": str(path), + "name": path.name, + "kind": _artifact_kind(path), + "size": st.st_size, + "modified_at": st.st_mtime, + } + ) + except OSError: continue - st = path.stat() - out.append( - { - "path": str(rel), - # Absolute path for "Copy path" — the relative one is useless outside - # the app (tester catch 2026-07-12: it copied just the filename). - "abs_path": str(path), - "name": path.name, - "kind": _artifact_kind(path), - "size": st.st_size, - "modified_at": st.st_mtime, - } - ) - except OSError: - continue out.sort(key=lambda a: a["modified_at"], reverse=True) return out[:80] @@ -1778,12 +1813,14 @@ def _selectable(m: str) -> bool: "surfaces": self._surfaces(), "nav_layout": self._nav_layout(), "sessions_peek": self.sessions_peek(), + "context_bar": self.context_bar(), "scratch_base": self._prefs.get("scratch_base") or self.DEFAULT_SCRATCH_BASE, # Real on-disk secrets location, so the UI shows the OS-native path instead of a # hardcoded POSIX one (Windows -> %APPDATA%\coworker, macOS/Linux -> ~/.config). "secrets_path": str(self.secrets.path), **self.pdf_settings(), + **self.compaction_settings_payload(), } def _surfaces(self) -> dict[str, bool]: @@ -1836,6 +1873,16 @@ def set_sessions_peek(self, n: int) -> dict[str, Any]: self._save_prefs() return {"ok": True, "sessions_peek": self.sessions_peek()} + def context_bar(self) -> bool: + """Whether the composer shows the context-window fill bar. OFF by default (owner + ask): the chip then states the session total, and the popover keeps both numbers.""" + return bool(self._prefs.get("context_bar", False)) + + def set_context_bar(self, shown: Any) -> dict[str, Any]: + self._prefs["context_bar"] = bool(shown) + self._save_prefs() + return {"ok": True, "context_bar": self.context_bar()} + # -- PDF attachments / token savings (owner ask, 2026-07-17) ---------------- DEFAULT_PDF_MAX_PAGES = 20 DEFAULT_PDF_MAX_MB = 10 @@ -1860,6 +1907,65 @@ def pdf_settings(self) -> dict[str, Any]: "pdf_max_mb": max(1, min(mb, 10)), } + def compaction_settings(self) -> dict[str, Any]: + """The live auto-compaction knobs (OPE-27) — read by every engine per check, so a + Settings change applies without a rebuild. Only the two spec'd overrides plus the + summarizer-model pin; absent keys fall back to compaction.py defaults.""" + from ..compaction import DEFAULT_CAP_TOKENS, DEFAULT_THRESHOLD_PCT + + return { + "threshold_pct": float( + self._prefs.get("compaction_threshold_pct") or DEFAULT_THRESHOLD_PCT + ), + "cap_tokens": int( + self._prefs.get("compaction_cap_tokens") or DEFAULT_CAP_TOKENS + ), + # "" → the session's own model (engine falls back to self.model). + "model": str(self._prefs.get("compaction_model") or ""), + } + + def compaction_settings_payload(self) -> dict[str, Any]: + """The same knobs under REST-facing names (prefixed to keep /v1/settings flat).""" + settings = self.compaction_settings() + return { + "compaction_threshold_pct": settings["threshold_pct"], + "compaction_cap_tokens": settings["cap_tokens"], + "compaction_model": settings["model"], + } + + def set_compaction_settings( + self, + threshold_pct: Any = None, + cap_tokens: Any = None, + model: Any = None, + ) -> dict[str, Any]: + """Persist the auto-compaction overrides (OPE-27). Threshold is a percentage of + the model's context window (10–95); the cap is an absolute token ceiling; model + pins the summarizer ('' → the session's own model). Engines read these live via + `compaction_settings()`, so changes apply to running sessions immediately.""" + if threshold_pct is not None: + try: + pct = float(threshold_pct) + except (TypeError, ValueError): + return {"ok": False, "error": "compaction_threshold_pct must be a number"} + if not 0.10 <= pct <= 0.95: + return { + "ok": False, + "error": "compaction_threshold_pct must be between 0.10 and 0.95", + } + self._prefs["compaction_threshold_pct"] = pct + if cap_tokens is not None: + try: + self._prefs["compaction_cap_tokens"] = max( + 10_000, min(int(cap_tokens), 2_000_000) + ) + except (TypeError, ValueError): + return {"ok": False, "error": "compaction_cap_tokens must be a number"} + if model is not None: + self._prefs["compaction_model"] = str(model) + self._save_prefs() + return {"ok": True, **self.compaction_settings()} + def set_pdf_settings( self, fallback: Any = None, @@ -3249,6 +3355,11 @@ def save(self, session_id: str, engine: TurnEngine) -> None: agent=getattr(engine, "agent_name", "code"), extra_roots=self._extra_roots_of(engine), grants=_grants_of(engine), + compaction=( + engine.compaction_state.as_dict() + if getattr(engine, "compaction_state", None) + else {} + ), ) ) diff --git a/coworker/sessions.py b/coworker/sessions.py index cc6c4cf5..4bd857c7 100644 --- a/coworker/sessions.py +++ b/coworker/sessions.py @@ -34,3 +34,6 @@ class SessionRecord: # (e.g. origin="slack", origin_label="#general · T0ABCD"). Set once at spawn. origin: Optional[str] = None origin_label: Optional[str] = None + # Auto-compaction state (OPE-27): CompactionState.as_dict(), {} when never compacted. + # Persisted so a reloaded session keeps its compacted outbound view. + compaction: dict[str, Any] = field(default_factory=dict) diff --git a/coworker/tools/search.py b/coworker/tools/search.py index ad489e1f..3ff7fc3e 100644 --- a/coworker/tools/search.py +++ b/coworker/tools/search.py @@ -16,6 +16,18 @@ import aisuite as ai +# Per-OS application data directories. These are not build noise: on macOS 14+ merely +# *descending* into ~/Library/Application Support (other apps' containers) trips the App +# Data TCC protection and macOS shows "would like to access data from other apps" — an +# alarming prompt the user never asked for, reachable whenever the workspace is a home +# directory. Never traversed; a workspace under one of these is still searched normally, +# because the guard matches directory NAMES encountered during a walk. +OS_DATA_DIRS = { + "Library", # macOS + "AppData", # Windows + "Application Data", # Windows (legacy junction) +} + _IGNORE_DIRS = { ".git", "node_modules", @@ -30,7 +42,7 @@ ".pytest_cache", ".ruff_cache", ".idea", -} +} | OS_DATA_DIRS _SCHEMA = { "type": "function", diff --git a/coworker/web/fetch.py b/coworker/web/fetch.py index 9d273d03..f58291da 100644 --- a/coworker/web/fetch.py +++ b/coworker/web/fetch.py @@ -13,6 +13,8 @@ import aisuite as ai +from .guard import get_checked + _MAX = 20000 # default chars returned _SCHEMA = { @@ -84,16 +86,20 @@ def web_fetch(url: str, max_chars: int = _MAX) -> dict[str, Any]: try: import httpx + # follow_redirects=False: guard.get_checked walks the chain so every hop is + # address-checked, not just the URL the model first supplied. with httpx.Client( - follow_redirects=True, + follow_redirects=False, timeout=20.0, headers={"User-Agent": "coworker/0.1 (+desktop)"}, ) as client: - resp = client.get(url) + resp = get_checked(client, url) resp.raise_for_status() ctype = resp.headers.get("content-type", "") body = resp.text final_url = str(resp.url) + except PermissionError as exc: # blocked address (loopback, private, metadata) + return {"error": str(exc)} except Exception as exc: # network / HTTP / TLS return {"error": f"fetch failed: {exc}"} text = _html_to_text(body) if "html" in ctype.lower() else body diff --git a/coworker/web/guard.py b/coworker/web/guard.py new file mode 100644 index 00000000..549d7841 --- /dev/null +++ b/coworker/web/guard.py @@ -0,0 +1,116 @@ +"""Address guard for URLs the model chooses. + +`web_fetch` and `browser_read_url` take a URL straight from the model, and the model's +input is untrusted by design — it reads web pages, email and Slack messages, all of which +are documented as "data, not instructions". A page that talks the agent into fetching +`http://169.254.169.254/` or `http://127.0.0.1:11434/` turns a read-only research tool into +a probe of the machine's own network position, and `web_fetch` is `requires_approval=False`, +so no prompt ever appears. + +This blocks the ranges that are only reachable *because* OpenWorker runs on the user's +machine: loopback, RFC1918 and other private space, link-local (which covers the cloud +metadata endpoint at 169.254.169.254), and the reserved/multicast blocks. + +Every hop is checked, not just the first: `follow_redirects=True` otherwise lets a public +URL 302 straight to loopback, which is the standard way this filter is bypassed. + +Not covered: DNS rebinding. The name is resolved here and resolved again by the client when +it connects, so a record with a ~0 TTL can change between the two. Closing that needs +connection-level IP pinning; the hop check is the cheap 90% and is stated as such. +""" + +from __future__ import annotations + +import ipaddress +import socket +from typing import Optional +from urllib.parse import urlsplit + +MAX_REDIRECTS = 5 + +# RFC 6598 shared address space. Python's is_private misses it, but it is carrier grade +# NAT space and Tailscale hands out internal hosts here (100.64.0.0/10), so a fetch to it +# is the same "reach the machine's network position" class as RFC1918. +_CGNAT = ipaddress.ip_network("100.64.0.0/10") + + +def _blocked_reason(ip: ipaddress._BaseAddress) -> Optional[str]: + if ip.is_loopback: + return "loopback" + if ip.is_link_local: + return "link-local (includes the cloud metadata endpoint)" + if ip.is_private: + return "a private network" + if ip.version == 4 and ip in _CGNAT: + return "shared address space (CGNAT / RFC 6598)" + if ip.is_multicast: + return "multicast" + if ip.is_reserved or ip.is_unspecified: + return "a reserved range" + return None + + +def check_url(url: str) -> Optional[str]: + """None if the URL may be fetched, else a human-readable refusal reason. + + Resolves the host and rejects when *any* answer lands in a blocked range, so a name + with both a public and a private A record cannot be used to slip through. + """ + parts = urlsplit(url) + if parts.scheme not in ("http", "https"): + return "url must start with http:// or https://" + host = parts.hostname + if not host: + return "url has no host" + + # A literal address needs no lookup. + try: + literal = ipaddress.ip_address(host) + except ValueError: + literal = None + if literal is not None: + reason = _blocked_reason(literal) + return f"refusing to fetch {host}: {reason}" if reason else None + + try: + infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80), + proto=socket.IPPROTO_TCP) + except OSError as exc: + return f"could not resolve {host}: {exc}" + + for info in infos: + raw = info[4][0] + try: + ip = ipaddress.ip_address(raw) + except ValueError: + continue + # ::ffff:127.0.0.1 and friends must be judged as the v4 address they carry. + mapped = getattr(ip, "ipv4_mapped", None) + if mapped is not None: + ip = mapped + reason = _blocked_reason(ip) + if reason: + return f"refusing to fetch {host} ({ip}): {reason}" + return None + + +def get_checked(client, url: str, *, max_redirects: int = MAX_REDIRECTS): + """GET `url`, validating the address before every hop. + + `client` must be built with `follow_redirects=False`; redirects are walked here so each + Location is checked. Returns the final response. Raises `PermissionError` when a hop is + refused, `RuntimeError` when the redirect budget is exhausted. + """ + seen = url + for _ in range(max_redirects + 1): + reason = check_url(seen) + if reason: + raise PermissionError(reason) + resp = client.get(seen) + if resp.status_code not in (301, 302, 303, 307, 308): + return resp + location = resp.headers.get("location") + if not location: + return resp + seen = str(resp.url.join(location)) + raise RuntimeError(f"too many redirects (>{max_redirects})") diff --git a/packaging/make_update_manifest.py b/packaging/make_update_manifest.py index 513f8223..84c4f295 100644 --- a/packaging/make_update_manifest.py +++ b/packaging/make_update_manifest.py @@ -10,6 +10,7 @@ uploads): OpenWorker-macos-arm64.app.tar.gz(.sig) -> platforms["darwin-aarch64"] + OpenWorker-macos-x64.app.tar.gz(.sig) -> platforms["darwin-x86_64"] OpenWorker-windows-setup.exe(.sig) -> platforms["windows-x86_64"] URLs point at the TAG-pinned GitHub download path (releases/download//), @@ -34,6 +35,7 @@ # stable asset name -> Tauri platform key ARTIFACTS = { "OpenWorker-macos-arm64.app.tar.gz": "darwin-aarch64", + "OpenWorker-macos-x64.app.tar.gz": "darwin-x86_64", "OpenWorker-windows-setup.exe": "windows-x86_64", } diff --git a/surfaces/gui/e2e/chat.spec.ts b/surfaces/gui/e2e/chat.spec.ts index b7b42d61..b40345a8 100644 --- a/surfaces/gui/e2e/chat.spec.ts +++ b/surfaces/gui/e2e/chat.spec.ts @@ -57,3 +57,37 @@ test("approval: Deny skips the tool and the agent says so", async ({ page }) => await page.getByRole("button", { name: "Deny" }).last().click(); await expect(page.getByText("Understood — skipped the command.")).toBeVisible(); }); + +test("long user pastes clamp with a more…/less… toggle", async ({ page }) => { + await page.goto("/"); + const box = page.getByPlaceholder(/Ask the coworker/); + await expect(box).toBeVisible(); + + const tail = "END-OF-PASTE-MARKER"; + const paste = + "reply OK. " + "lorem ipsum dolor sit amet consectetur ".repeat(60) + tail; // ~2.4k chars + await box.fill(paste); + await page.getByRole("button", { name: "Send" }).click(); + + // Clamped: the bubble shows the head but not the tail, plus the toggle. + const more = page.getByRole("button", { name: "more…" }); + await expect(more).toBeVisible(); + const bubble = page.locator(".bubble-user").last(); + await expect(bubble).toContainText("reply OK."); + await expect(bubble).not.toContainText(tail); + + // Expand → full text + "less…"; collapse → clamped again. + await more.click(); + await expect(bubble).toContainText(tail); + const less = page.getByRole("button", { name: "less…" }); + await expect(less).toBeVisible(); + await less.click(); + await expect(bubble).not.toContainText(tail); + + // Short messages never show the control. + await expect(page.getByText("Echo:").first()).toBeVisible(); + await box.fill("short follow-up"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByText("short follow-up", { exact: true }).first()).toBeVisible(); + await expect(page.getByRole("button", { name: "more…" })).toHaveCount(1); // still only the paste's +}); diff --git a/surfaces/gui/e2e/compaction.spec.ts b/surfaces/gui/e2e/compaction.spec.ts new file mode 100644 index 00000000..c5fa0b61 --- /dev/null +++ b/surfaces/gui/e2e/compaction.spec.ts @@ -0,0 +1,80 @@ +// OPE-27 — auto-compaction GUI: the Settings card's two overrides + summarizer-model +// pin POST through, and the "context compacted" divider renders inline mid-session +// (driven by the fixtures' scripted `compacted` event) without touching the transcript. +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +test("Settings: Context compaction card edits threshold, cap, and summarizer model", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Models", exact: true }).click(); + + const card = page.getByTestId("compaction-card"); + await expect(card).toBeVisible(); + await expect(card.getByText("Context compaction")).toBeVisible(); + + // Defaults render when the backend doesn't send the fields (older-backend robustness). + await expect(card.getByTestId("compaction-threshold")).toHaveValue("80"); + await expect(card.getByTestId("compaction-cap")).toHaveValue("250000"); + await expect(card.getByTestId("compaction-model")).toHaveValue(""); + + // Threshold edits POST as a fraction, clamped to 10–95%. + const [req] = await Promise.all([ + page.waitForRequest( + (r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST", + ), + card.getByTestId("compaction-threshold").fill("70"), + ]); + expect(req.postDataJSON()).toEqual({ compaction_threshold_pct: 0.7 }); + + const [req2] = await Promise.all([ + page.waitForRequest( + (r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST", + ), + card.getByTestId("compaction-cap").fill("100000"), + ]); + expect(req2.postDataJSON()).toEqual({ compaction_cap_tokens: 100000 }); + + // Summarizer pin: the picker offers the session-default plus the configured models. + const [req3] = await Promise.all([ + page.waitForRequest( + (r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST", + ), + card.getByTestId("compaction-model").selectOption("gpt-4o-mini"), + ]); + expect(req3.postDataJSON()).toEqual({ compaction_model: "gpt-4o-mini" }); +}); + +test("the compacted divider renders mid-session and the transcript stays intact", async ({ + page, +}) => { + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + const box = page.getByPlaceholder(/Ask the coworker/); + + // An earlier exchange that must survive the compaction marker (transcript intact). + await box.fill("remember the launch date"); + await box.press("Enter"); + await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible({ + timeout: 10_000, + }); + + await box.fill("compact the context"); + await box.press("Enter"); + // The transient signal shows while the summarizer runs, then yields to the divider. + await expect(page.getByText("Compacting context…").first()).toBeVisible({ + timeout: 10_000, + }); + await expect( + page.getByText("Context compacted — earlier turns were summarized").first(), + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("Compacting context…")).toHaveCount(0); + await expect( + page.getByText("Still on it — continuing where I left off.").first(), + ).toBeVisible(); + // Outbound-only: everything before the divider is still on screen. + await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible(); +}); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 4224bff7..81f21a3c 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -681,6 +681,18 @@ export async function mockApi(page: import("@playwright/test").Page) { }, 120); return; } + // Auto-compaction (OPE-27): the server signals `compacting` (the transient + // spinner label), summarizes for a beat, then emits the marker and the turn + // continues normally — the divider must render inline. + if (/compact the context/i.test(msg.text)) { + send("compacting", {}); + setTimeout(() => { + send("compacted", { text: "Context compacted — earlier turns were summarized" }); + send("assistant_message", { text: "Still on it — continuing where I left off." }); + send("turn_done"); + }, 400); + return; + } // A turn that dies on a provider error; the follow-up {type:"retry"} recovers. if (/fail the turn/i.test(msg.text)) { send("error", { error: "model unreachable" }); @@ -826,6 +838,10 @@ export async function mockApi(page: import("@playwright/test").Page) { if (p.endsWith("/v1/health")) return json(HEALTH); if (p.endsWith("/v1/settings")) return json(SETTINGS); + if (p.endsWith("/v1/settings/context-bar") && m === "POST") { + Object.assign(SETTINGS, req.postDataJSON()); + return json({ ok: true, context_bar: SETTINGS.context_bar }); + } if (p.endsWith("/v1/settings/pdf") && m === "POST") { Object.assign(SETTINGS, req.postDataJSON()); return json({ diff --git a/surfaces/gui/e2e/usage-chip.spec.ts b/surfaces/gui/e2e/usage-chip.spec.ts index bfb98750..92a62ca2 100644 --- a/surfaces/gui/e2e/usage-chip.spec.ts +++ b/surfaces/gui/e2e/usage-chip.spec.ts @@ -20,7 +20,8 @@ test("usage chip appears after a turn and opens the breakdown popover", async ({ timeout: 10_000, }); - // Chip shows the session total (1k + 200 + 8k + 800 = 10k). + // Default: no bar (owner ask 2026-07-30) — the chip states the session total + // (1k + 200 + 8k + 800 = 10k). The bar is opt-in via Settings. const chip = page.getByTestId("usage-chip"); await expect(chip).toContainText("10k"); @@ -58,9 +59,41 @@ test("usage resets on a new session", async ({ page }) => { const box = page.getByPlaceholder(/Ask the coworker/); await box.fill("hello"); await box.press("Enter"); - await expect(page.getByTestId("usage-chip")).toContainText("10k", { timeout: 10_000 }); + await expect(page.getByTestId("usage-chip")).toBeVisible({ timeout: 10_000 }); // "+ New session" wipes the transcript — and the usage accumulation with it. await page.getByRole("button", { name: /New session/ }).first().click(); await expect(page.getByTestId("usage-chip")).toHaveCount(0); }); + +test("Settings toggle turns the context bar on; default is the session total", async ({ page }) => { + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + const box = page.getByPlaceholder(/Ask the coworker/); + await box.fill("hello"); + await box.press("Enter"); + const chip = page.getByTestId("usage-chip"); + await expect(chip).toContainText("10k", { timeout: 10_000 }); // default: total, no bar + + // Turn the bar ON in Settings -> General. + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(page.getByTestId("context-bar-toggle")).not.toBeChecked(); + const [req] = await Promise.all([ + page.waitForRequest( + (r) => r.url().endsWith("/v1/settings/context-bar") && r.method() === "POST", + ), + page.getByTestId("context-bar-toggle").check(), + ]); + expect(req.postDataJSON()).toEqual({ context_bar: true }); + + // Reload so the app re-reads settings: the chip is now the fill bar, not a number. + await page.goto("/"); + await page.getByText("Draft the launch note").first().click(); + await page.getByPlaceholder(/Ask the coworker/).fill("hello"); + await page.getByPlaceholder(/Ask the coworker/).press("Enter"); + const bar = page.getByTestId("usage-chip"); + await expect(bar).toBeVisible({ timeout: 10_000 }); + await expect(bar).not.toContainText("10k"); + await expect(bar).toHaveAttribute("title", /Context window 5% full/); +}); diff --git a/surfaces/gui/src-tauri/tauri.conf.json b/surfaces/gui/src-tauri/tauri.conf.json index 95aa77cd..ca15a297 100644 --- a/surfaces/gui/src-tauri/tauri.conf.json +++ b/surfaces/gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenWorker", - "version": "0.1.6", + "version": "0.1.7", "identifier": "com.openworker.desktop", "build": { "frontendDist": "../dist", diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 6c261944..57f1ba8c 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -165,6 +165,9 @@ export function App() { // {full model id → context window in tokens} from the curated matrix (verified only); // drives the composer usage chip's context-fill meter. const [modelContextWindows, setModelContextWindows] = useState>({}); + // Settings: show the composer's context-window fill bar. OFF by default (owner ask), + // so an older backend without the field also shows the session total. + const [contextBar, setContextBar] = useState(false); // Per-session token usage (OPE-42): rebuilt from the transcript on session load, // accumulated live from assistant_message events, reset with the transcript. const [usage, setUsage] = useState(emptyUsage()); @@ -172,6 +175,10 @@ export function App() { const [mode, setMode] = useState("interactive"); const [connected, setConnected] = useState(false); const [running, setRunning] = useState(false); + // Transient "Compacting context…" indicator (OPE-27): set by the `compacting` event, + // cleared by whatever the engine emits next — the summarizer call is otherwise a + // multi-second silent stall mid-turn. + const [compacting, setCompacting] = useState(false); const [items, setItems] = useState([]); const [streaming, setStreamingState] = useState(""); // Ref mirror of `streaming`: the WS handler closure is built once per socket and can't read @@ -502,6 +509,7 @@ export function App() { setModels(s.models || []); setModelLabels(s.model_labels || {}); setModelContextWindows(s.model_context_windows || {}); + setContextBar(s.context_bar === true); setModelReady(s.model_ready); if (s.surfaces) setSurfaces(s.surfaces); }) @@ -576,6 +584,9 @@ export function App() { }, ]); }; + // Any engine event after `compacting` means the summarizer finished (compacted / + // silent no-op / failure prompt) — the transient must never outlive it. + if (ev.type !== "compacting") setCompacting(false); switch (ev.type) { case "ready": setConnected(true); @@ -709,6 +720,14 @@ export function App() { if (d.model) setModel(d.model); setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]); break; + case "compacting": + setCompacting(true); + break; + case "compacted": + // Auto-compaction marker (OPE-27): outbound-only — the transcript stays intact, + // this divider just shows where the model's memory was summarized. + setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Context compacted" }]); + break; case "interrupted": flushPartialStream(); setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]); @@ -1514,7 +1533,11 @@ export function App() { )} + {/* Compaction runs between provider turns (nothing streams during it), so + the transient takes over the waiting slot with a specific label. */} + {running && compacting && } {running && + !compacting && !reasoningStream && (!streaming || streamMode(streaming, items, running) === "hold") && !lastItemIsAssistant(items) && } @@ -1568,6 +1591,7 @@ export function App() { resetKey={sessionId} usage={usage} contextWindow={modelContextWindows[model]} + contextBar={contextBar} placeholder={ agent === "code" ? "Ask the coder to build, fix, or explain… (drop or paste files)" @@ -1680,12 +1704,12 @@ function lastItemIsAssistant(items: Item[]): boolean { return false; } -function WaitingForAgent() { +function WaitingForAgent({ label }: { label?: string }) { return (
- Waiting for agent... + {label || "Waiting for agent..."}
); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 80a5dcef..e7caf8a9 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -692,6 +692,9 @@ export interface ModelSettings { nav_layout?: "flat" | "grouped"; // Sidebar: sessions shown per group before "Show more" (default 5, 1–50). sessions_peek?: number; + // Composer: show the context-window fill bar (default FALSE; absent → the chip shows + // the session total). The usage popover keeps both numbers regardless. + context_bar?: boolean; // Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent. model_labels?: Record; // {full id → context window in tokens}, verified matrix entries only — drives the @@ -702,6 +705,12 @@ export interface ModelSettings { pdf_fallback?: "text" | "images"; pdf_max_pages?: number; // default 20, 1–100 pdf_max_mb?: number; // default 10, 1–10 + // Auto-compaction of long histories (OPE-27): trigger = min(threshold% × context + // window, cap tokens); model pins the summarizer ("" → the session's own model). + // Optional so the GUI is robust to an older backend. + compaction_threshold_pct?: number; // default 0.8, 0.10–0.95 + compaction_cap_tokens?: number; // default 250000 + compaction_model?: string; } export interface PdfSettings { @@ -722,6 +731,24 @@ export async function setPdfSettings( return res.json(); } +export interface CompactionSettings { + compaction_threshold_pct: number; + compaction_cap_tokens: number; + compaction_model: string; +} + +/** Persist the auto-compaction overrides (threshold %, token cap, summarizer model). */ +export async function setCompactionSettings( + patch: Partial, +): Promise<{ ok: boolean; error?: string }> { + const res = await fetch(`${httpBase()}/v1/settings/compaction`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + return res.json(); +} + /** Local page/size probe for a PDF data URL — the composer's attach-time threshold check. */ export async function inspectPdf( dataUrl: string, @@ -734,6 +761,18 @@ export async function inspectPdf( return res.json(); } +/** Persist whether the composer shows the context-window fill bar. */ +export async function setContextBar( + shown: boolean, +): Promise<{ ok: boolean; context_bar?: boolean; error?: string }> { + const res = await fetch(`${httpBase()}/v1/settings/context-bar`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ context_bar: shown }), + }); + return res.json(); +} + /** Persist how many sessions a sidebar group shows before "Show more". */ export async function setSessionsPeek( n: number, diff --git a/surfaces/gui/src/components/Composer.tsx b/surfaces/gui/src/components/Composer.tsx index 5d638ee1..53324818 100644 --- a/surfaces/gui/src/components/Composer.tsx +++ b/surfaces/gui/src/components/Composer.tsx @@ -84,6 +84,8 @@ interface Props { // Context-window size (tokens) of the ACTIVE model, from the curated matrix; // undefined hides the fill meter (unverified/custom models) but keeps the counts. contextWindow?: number; + // Settings toggle (default off): true shows the fill bar instead of the session total. + contextBar?: boolean; } export function Composer(props: Props) { @@ -469,13 +471,14 @@ export function Composer(props: Props) { - {/* token usage (OPE-42) — a quiet meter+count chip; hidden until the server - reports usage. Fill = context-window occupancy (bounded), count = session - consumption (unbounded, so never a fill). */} + {/* token usage (OPE-42) — a quiet chip; hidden until the server reports usage. + Shows the context-window fill bar alone (the session total lives in the + popover), or the session total when there's no window / the bar is off. */} {!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && ( @@ -572,11 +575,13 @@ export function Composer(props: Props) { function UsageChip({ usage, contextWindow, + contextBar, model, modelLabels, }: { usage: SessionUsage; contextWindow?: number; + contextBar?: boolean; model: string; modelLabels?: Record; }) { @@ -585,6 +590,8 @@ function UsageChip({ const pct = contextWindow ? Math.min(100, Math.round((usage.context / contextWindow) * 100)) : null; + // Settings can hide the bar; without a known window there is nothing to fill either. + const showBar = pct !== null && contextBar === true; const labelFor = (id: string) => id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id); // One field per line, session-summed (owner ask 2026-07-28). Values are cumulative @@ -605,21 +612,25 @@ function UsageChip({ aria-expanded={open} aria-label="Token usage" title={ - pct !== null - ? `Token usage — ${pct}% of the context window used` - : "Token usage this session" + showBar + ? `Context window ${pct}% full · ${formatTokens(total)} tokens this session` + : `Token usage this session: ${formatTokens(total)}` } data-testid="usage-chip" > - {pct !== null && ( -