diff --git a/skillopt_sleep/harvest.py b/skillopt_sleep/harvest.py index 7f3bee8f..da0ac9dc 100644 --- a/skillopt_sleep/harvest.py +++ b/skillopt_sleep/harvest.py @@ -17,6 +17,7 @@ import json import os +import re from datetime import datetime from typing import Any, Dict, Iterable, List, Optional @@ -134,13 +135,68 @@ def _detect_feedback(text: str) -> List[str]: return sig +# ── Issue #318: host wrappers around user text ───────────────────────── +# +# The Claude Code desktop app delivers a pasted block as the user message, +# wrapped in ``…``. The brief +# the user pasted is typed by them and is often the whole point of the +# session, so the wrapper is unwrapped instead of dropped. +_PASTED_CONTENT_RE = re.compile(r"]*>", re.IGNORECASE) + +# Host-injected status notes. A message that is only a reminder is host noise, +# and a reminder *prefixed* to a real request must not ride into the mined +# intent as text the user never wrote. The match is anchored by the caller: +# only the block that leads a message is removed. +_SYSTEM_REMINDER_RE = re.compile( + r"]*>.*?", re.IGNORECASE | re.DOTALL +) + +# Tags the host injects as a whole user message: expanded slash-command +# bodies, local-command output, shell-mode input and editor selections. The +# list is closed deliberately — a wrapper this list does not know about is +# treated as user text, so such a message keeps its request instead of losing +# it. is listed as well as stripped, so a variant carrying +# attributes is still recognised as host noise. +_HOST_META_TAGS = ( + "command-name", "command-message", "command-args", + "local-command-stdout", "local-command-caveat", + "system-reminder", + "bash-input", "bash-stdout", "bash-stderr", + "ide_opened_file", "ide_selection", + # VS Code Copilot Chat brackets the machine context of every request + # (…); harvesting it leaks local + # environment detail and was never a user prompt. + "environment_info", +) + + +def _strip_host_wrappers(text: str) -> str: + """Unwrap pasted user content and drop the host's own leading notes. + + ```` tags only ever wrap text the user typed, so both the + opening and the closing tag are removed wherever they appear. A + ```` block is removed only while it *leads* the message, + which is how the host prepends it. The same text inside a prompt the user + wrote — an inline-code sample, a fenced XML example — is their content and + survives, so the harvested request stays what they typed. + """ + if "<" not in text: + return text + out = _PASTED_CONTENT_RE.sub("", text) + while True: + stripped = out.lstrip() + match = _SYSTEM_REMINDER_RE.match(stripped) + if match is None: + break + out = stripped[match.end():] + return out + + def _is_meta_prompt(text: str) -> bool: """Skip slash-commands / system noise that aren't real user intents.""" - t = text.strip() + t = _strip_host_wrappers(text).strip() if not t: return True - if t.startswith("<") and t.endswith(">"): - return True if t.startswith("/") and len(t.split()) <= 3: return True if t.startswith("[Pasted text") or t.startswith("Caveat:"): @@ -152,6 +208,14 @@ def _is_meta_prompt(text: str) -> bool: return True if t.startswith("# /"): return True + # A message that is nothing but one host tag block (``, + # ``, …) carries no user intent. The tag list is + # closed on purpose: text that merely arrives wrapped in angle brackets + # is user content, and dropping it loses the session's real request + # (issue #318: the desktop app delivers a pasted brief that way). + if t.startswith("<") and t.endswith(">"): + head = t[1:].split(">", 1)[0].split(None, 1) + return bool(head) and head[0].lower() in _HOST_META_TAGS return False @@ -215,6 +279,11 @@ def _is_headless_replay(digest: "SessionDigest") -> bool: if digest.n_user_turns > 1: return False if digest.n_user_turns == 0: + # Every user turn was filtered as host noise or engine input, so there + # is no user intent to mine. Such a record must not be admitted: it + # would take a slot under --max-sessions and yield no task, displacing + # a real session (review of #318). A pasted brief is not this case — + # it now survives the filter as a user turn. return True prompt = digest.user_prompts[0] if digest.user_prompts else "" for marker in _REPLAY_PROMPT_MARKERS: @@ -278,7 +347,10 @@ def digest_transcript(path: str) -> Optional[SessionDigest]: # other sessions, usage-limit notices. The user typed none of it. # A body that names a known agent session is still kept, so # _is_agent_session can drop the whole session as before. - text = _text_from_content(content) + # Host wrappers are removed before the meta test: a pasted brief + # (#318) is user content, and a prefix must not + # ride into the intent (see _strip_host_wrappers). + text = _strip_host_wrappers(_text_from_content(content)) if rec.get("isMeta") is True and not any( marker in text for marker in _AGENT_SESSION_MARKERS ): diff --git a/tests/test_sleep_engine.py b/tests/test_sleep_engine.py index 62fe982e..71476365 100644 --- a/tests/test_sleep_engine.py +++ b/tests/test_sleep_engine.py @@ -82,6 +82,176 @@ def test_meta_prompt_filter(self): self.assertTrue(_is_meta_prompt("x")) self.assertFalse(_is_meta_prompt("please refactor the auth module")) + def _write_claude_session(self, path, turns): + """Write one Claude Code transcript; ``turns`` are (role, text) pairs. + + Records are an hour apart so the sub-3-second heuristic in + _is_headless_replay never decides these fixtures. + """ + self._write_jsonl(path, [ + {"type": role, + "timestamp": f"2026-10-15T0{index + 1}:00:00Z", + "cwd": "/repo/example", + "sessionId": os.path.splitext(os.path.basename(path))[0], + "message": {"role": role, "content": text}} + for index, (role, text) in enumerate(turns) + ]) + + def test_meta_prompt_keeps_pasted_user_content(self): + # issue #318: the desktop app delivers a pasted brief inside a tag + # pair carrying the same id. The user typed that brief, so it is user + # content, and dropping it left the session's follow-up as the intent. + from skillopt_sleep.harvest import _strip_host_wrappers + + brief = ('\n' + 'Install tool X into a dedicated folder, pin the version.\n' + '') + + self.assertFalse(_is_meta_prompt(brief)) + # the helper removes the tags only; callers strip the remainder + self.assertEqual( + _strip_host_wrappers(brief), + "\nInstall tool X into a dedicated folder, pin the version.\n", + ) + + def test_meta_prompt_filters_only_known_host_tags(self): + self.assertTrue(_is_meta_prompt("ok")) + self.assertTrue(_is_meta_prompt( + "skillopt-sleep\n" + "/skillopt-sleep" + )) + # VS Code Copilot Chat brackets each request's machine context the + # same way; it is host detail, not a prompt, and must stay out + # (tests/test_copilot_harvest.py pins the privacy boundary). + self.assertTrue(_is_meta_prompt( + "private machine context" + )) + # A wrapper the host does not own is user text, not noise: it must + # survive the filter with its content instead of being dropped for + # being wrapped in angle brackets, which is what lost pasted briefs. + self.assertFalse(_is_meta_prompt("Ship 1.4")) + + def test_pasted_brief_becomes_the_mined_intent(self): + brief = ('\n' + 'Install tool X into a dedicated folder, pin the version, ' + 'back up the configs first.\n' + '') + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "pasted-session.jsonl") + self._write_claude_session(path, [ + ("user", brief), + ("assistant", "Plan: ..."), + ("user", "Yes, go ahead with option A."), + ("assistant", "Done."), + ]) + digest = digest_transcript(path) + + [task] = heuristic_mine([digest]) + self.assertEqual( + task.intent, + "Install tool X into a dedicated folder, pin the version, back up the configs first.", + ) + self.assertNotIn("pasted_content", task.intent) + # the follow-up stays available as constraint context, not as intent + self.assertIn("Yes, go ahead with option A.", task.context_excerpt) + + def test_session_whose_only_user_turn_is_a_pasted_brief_survives_harvest(self): + from skillopt_sleep.harvest import harvest + + brief = ('\nInstall tool X into a dedicated folder.\n' + '') + with tempfile.TemporaryDirectory() as tmp: + project_dir = os.path.join(tmp, "projects", "-repo-example") + os.makedirs(project_dir) + self._write_claude_session(os.path.join(project_dir, "pasted-only.jsonl"), [ + ("user", brief), + ("assistant", "Plan: ..."), + ("assistant", "Done."), + ]) + + digests = harvest(os.path.join(tmp, "projects"), scope="all") + + self.assertEqual([d.session_id for d in digests], ["pasted-only"]) + self.assertEqual(digests[0].user_prompts, + ["Install tool X into a dedicated folder."]) + + def test_system_reminder_prefix_does_not_ride_into_the_intent(self): + request = "Yes: run the third review on range r2..abc1234 and update the report." + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "reminder-prefix.jsonl") + self._write_claude_session(path, [ + ("user", "\nStatus note injected by the host app.\n" + f"\n\n{request}"), + ("assistant", "Running the review."), + ]) + digest = digest_transcript(path) + + self.assertEqual(digest.user_prompts, [request]) + [task] = heuristic_mine([digest]) + self.assertEqual(task.intent, request) + self.assertNotIn("Status note", task.intent) + + def test_host_only_transcript_does_not_take_a_session_slot(self): + from skillopt_sleep.harvest import harvest + + # Review of #318: a record with no user intent must not be admitted + # before the cap. It mines nothing, so admitting it turns a night into + # zero tasks by displacing a real session with a newer host-only one. + with tempfile.TemporaryDirectory() as tmp: + project_dir = os.path.join(tmp, "projects", "-repo-example") + os.makedirs(project_dir) + host_only = os.path.join(project_dir, "host-note.jsonl") + self._write_jsonl(host_only, [ + {"type": "user", "isMeta": True, + "timestamp": "2026-10-15T09:00:00Z", + "cwd": "/repo/example", "sessionId": "host-note", + "message": {"role": "user", + "content": "ok"}}, + {"type": "assistant", "timestamp": "2026-10-15T09:00:30Z", + "cwd": "/repo/example", "sessionId": "host-note", + "message": {"role": "assistant", "content": "Noted."}}, + ]) + real = os.path.join(project_dir, "real-task.jsonl") + self._write_claude_session(real, [ + ("user", "Fix the failing date parser test in module B."), + ("assistant", "Fixed."), + ]) + os.utime(host_only, (2_000_000, 2_000_000)) # newer + os.utime(real, (1_000_000, 1_000_000)) + + digests = harvest(os.path.join(tmp, "projects"), scope="all", limit=1) + + self.assertEqual([d.session_id for d in digests], ["real-task"]) + + def test_inline_code_system_reminder_example_is_user_text(self): + # Review of #318: the user quoting a reminder inside inline code was + # being deleted, which changed both the request and the mined intent. + request = ("How should I escape `note` " + "inside my transcript fixture?") + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "inline-example.jsonl") + self._write_claude_session(path, [ + ("user", request), + ("assistant", "Use html.escape()."), + ]) + digest = digest_transcript(path) + + self.assertEqual(digest.user_prompts, [request]) + + def test_fenced_system_reminder_example_is_user_text(self): + request = ("This is what our host writes before a real request:\n" + "```xml\n\ncontext\n\n```\n" + "Please make the parser strip it.") + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "fenced-example.jsonl") + self._write_claude_session(path, [ + ("user", request), + ("assistant", "Will do."), + ]) + digest = digest_transcript(path) + + self.assertEqual(digest.user_prompts, [request]) + def _claude_session_with_injected_messages(self, tmp, follow_up): """A Claude Code session as written to disk when a skill is loaded.