Skip to content

fix(sleep): stop a re-run hook template from filling the session cap - #323

Open
Ansu (SU-AN-coder) wants to merge 2 commits into
microsoft:mainfrom
SU-AN-coder:fix/harvest-machine-generated-repeats-319
Open

Ansu (SU-AN-coder) wants to merge 2 commits into
microsoft:mainfrom
SU-AN-coder:fix/harvest-machine-generated-repeats-319

Conversation

@SU-AN-coder

@SU-AN-coder Ansu (SU-AN-coder) commented Oct 18, 2026 •

Copy link
Copy Markdown

Summary

harvest() had no filter for single-turn transcripts written by local tooling. #319 measured what that costs: in one real project folder, 274 of 324 transcripts were an hourly hook's session summaries, arriving one per hour. Their only user prompt is the hook's own template — identical leading text, the run's log appended.

Neither existing filter matches them. _is_headless_replay() catches engine replay markers, or a sub-3 s session with a prompt under 200 characters; these prompts are ~400 characters. _is_agent_session() matches the claude-mem markers and the plugin's own command body. And in the Claude Code desktop app these runs carry entrypoint: "claude-desktop", like interactive ones, so the entrypoint filter from #62 cannot separate them either (I did not add one, for that reason).

Because harvest() walks newest first and stops at --max-sessions, they filled the cap. Two real sessions plus three summary sessions with limit=3 returned only the three summary sessions, and heuristic_mine() turned the template into "recurring tasks" — the issue's own output, reproduced on ae2f766 below.

What changed

1. harvest() can drop a repeated single-turn template before the cap (skillopt_sleep/harvest.py) — opt-in, off by default. A single-turn session that shares its first 120 characters of prompt with at least N sessions of the same harvest window is treated as one template re-running, and dropped before limit is applied so it cannot consume the cap it used to fill.

Recurrence is not evidence of who wrote a prompt, though, and the review named the counter-example: three human requests for a code review share one long checklist prefix and differ only in the task payload. That is structurally identical to a hook re-running itself — one turn each, >120 shared leading characters, a different tail — so labelling those records machine-generated is a guess, and losing a real task is worse than harvesting a hook's summary sessions. Hence:

  • default _DEFAULT_REPEAT_MIN_SESSIONS = 0 — the filter does nothing unless a project opts in with SKILLOPT_SLEEP_REPEAT_MIN_SESSIONS=N (3 is documented as the usual value, 0 disables an exported value again);
  • the guard rails are unchanged when it is on: a prompt shorter than 120 characters never matches, a session with a follow-up user turn never matches, and the shared text must be boilerplate that carries a fresh payload past it;
  • the docstring and both READMEs state why the default is off and name the counter-example rather than presenting the heuristic as attribution.

2. harvest() reports what it filtered, through an optional stats out-parameter filled in place: candidates, repeated_template_sessions, skipped_by_cap, sessions. The counter is named for what was measured — a repeated template — not for a producer the harvester cannot identify; the earlier machine_generated_repeats / "machine-generated" wording is gone from the stats dict, the progress note and the docs. format_harvest_note() renders it:

[sleep] 120 sessions -> 40 tasks (skipped: 297 repeated-template sessions, 2 past the session cap)

That line appears in skillopt-sleep harvest and in the run / dry-run progress line (harvest done: sessions=120 (skipped: …)).

3. harvest_for_config() forwards stats to the Claude source. The other sources (codex, copilot, cursor, pi, opencode) have no repeat filter of their own yet and leave stats untouched, which is stated in its docstring; the callers only print when it is non-empty.

4. The filters that already existed were undocumented. I checked before writing: grep -rl SKILLOPT_SLEEP_AGENT_MARKERS --include=*.md . returned nothing, and the same for SKILLOPT_SLEEP_NEG_FEEDBACK and SKILLOPT_SLEEP_POS_FEEDBACK — all three were named only in harvest.py comments. They are now in the harvest documentation (docs/sleep/README.md, Harvest filters (environment)) and the plugin README (plugins/claude-code/README.md, Sessions you did not type), with the hourly-summary-hook example from this report.

The cap stays bounded when the filter is off

With the default (off), harvest() keeps the single pass older releases ran: it digests newest-first and stops at limit, so --max-sessions 1 reads one transcript, not the whole eligible window. The full-window scan happens only in the opted-in mode, which needs it: a template can only be recognised by comparing it against the rest of the window, and the repeats must not be scored against the cap. Both behaviours are pinned by tests (test_default_cap_still_bounds_how_many_transcripts_are_digested: 40 transcripts on disk, limit=1, exactly 1 digest call; test_opt_in_scans_the_window_so_the_cap_can_be_refilled: 40 calls, the deliberate trade).

Verification

New tests: tests/test_harvest_machine_repeats.py (15).

On the pre-review commit 17745c1 (the rebased filter, default on), with the new tests present: 9 failed, 6 passed.

$ python -m pytest tests/test_harvest_machine_repeats.py -q    # in a worktree at 17745c1
FAILED ...TestDefaultIsSafe::test_default_cap_still_bounds_how_many_transcripts_are_digested
       AssertionError: 40 != 1        # whole window digested for limit=1
FAILED ...TestDefaultIsSafe::test_shared_checklist_requests_are_not_dropped_by_default
       AssertionError: Lists differ: [] != ['review-0', 'review-1', 'review-2']
FAILED ...::test_harvest_note_is_empty_when_nothing_was_filtered
FAILED ...::test_non_integer_threshold_falls_back_to_the_safe_default
FAILED ...::test_prefix_is_normalized_and_length_gated
FAILED ...::test_stats_are_empty_when_the_filter_is_off
FAILED ...::test_stats_count_sessions_past_the_cap
FAILED ...::test_stats_report_what_was_filtered
FAILED ...TestHarvestForConfigStats::test_claude_source_reports_stats_through_the_router
9 failed, 6 passed in 0.26s

$ python -m pytest tests/test_harvest_machine_repeats.py -q    # this branch
15 passed in 0.23s

The six that pass on both commits are named here rather than left to look like evidence: test_summary_sessions_do_not_fill_the_session_cap, test_the_mined_intents_are_the_real_ones, test_short_repeated_one_shot_prompts_are_kept, test_multi_turn_session_sharing_the_prefix_is_kept, test_threshold_follows_the_env_var and test_opt_in_scans_the_window_so_the_cap_can_be_refilled. They exercise the mechanism with the variable exported, and the old code already filtered with the variable set — they pin the filter, not the default change. test_short_repeated_one_shot_prompts_are_kept is a false-positive guard and is green by construction on any commit.

The issue's reproduction script, base vs. branch (two real sessions + three hourly summary runs, no model calls, no network):

# 17745c1 — the pre-review filter, default on
harvested (no cap): ['real-1', 'real-2']
harvested (limit=3): ['real-1', 'real-2']

# 7aabc1d — opted in (SKILLOPT_SLEEP_REPEAT_MIN_SESSIONS=3)
harvested (no cap): ['real-1', 'real-2']
harvested (limit=3): ['real-1', 'real-2']

# 7aabc1d — default, filter off
harvested (no cap): ['real-1', 'real-2', 'summary-0', 'summary-1', 'summary-2']
harvested (limit=3): ['summary-0', 'summary-1', 'summary-2']

The last block is the honest cost of the safe default: #319's behaviour is unchanged until a project opts in — the hourly runs still fill limit=3. On the pre-review code the summary sessions also reached heuristic_mine() and produced "recurring tasks" from the hook's template; with the filter off they still do. That is why this PR references #319 rather than closing it: it ships the opt-in mechanism, the diagnostics and the documentation, and leaves the default attribution to the people who can recognize their own hook.

Gate set on 7aabc1d:

$ python -m pytest -q
1846 passed, 11 skipped, 8 warnings, 368 subtests passed in 47.78s

$ python -m mkdocs build --strict
INFO -  Documentation built in 0.66 seconds      (exit 0)

$ python -m ruff check skillopt_sleep/harvest.py skillopt_sleep/harvest_sources.py \
      skillopt_sleep/cycle.py skillopt_sleep/__main__.py tests/test_harvest_machine_repeats.py
Found 2 errors: I001 in cycle.py:10 and harvest.py:16 — the identical two findings on 17745c1

Scope

Refs #319 — opt-in mitigation, not a default attribution. The other half of that report — the entrypoint proposal from #62 — I deliberately did not implement: those runs carry "claude-desktop", so it would not have caught anything here. #312 (per-source harvest checkpoint) and #318 (pasted briefs) are separate changes; this PR does not touch them.

I read every line of the diff myself. The branch is SU-AN-coder:fix/harvest-machine-generated-repeats-319 at 7aabc1d, rebased onto main (1a44050) and the owner has it.

Files

File Change
skillopt_sleep/harvest.py opt-in repeat filter, stats out-parameter, format_harvest_note()
skillopt_sleep/harvest_sources.py forward stats to the Claude source
skillopt_sleep/cycle.py report the counters on the harvest done: line
skillopt_sleep/__main__.py report the counters in skillopt-sleep harvest
tests/test_harvest_machine_repeats.py 15 tests (new defaults, opt-in, bounded cap, stats)
docs/sleep/README.md, plugins/claude-code/README.md document all four filters and the opt-in default

@Yif-Yang

Copy link
Copy Markdown
Contributor

Reviewed d3eeb3fcb1ea78eb4326a97ee8c575d28b1d39dd. The explicit-filter documentation and counters are useful, and the hook control is removed before the cap. The default classifier also drops three genuine human code-review requests that share a long checklist prefix while containing different task payloads. The same requests survive on main, and this is independently reproduced under both invoked and all-project scope. Labeling those records machine-generated does not become safe merely because the heuristic is documented.

Please keep a safe default and make uncertain recurrence filtering explicitly opt-in or restrict it to evidence that distinguishes the intended hook. Preserve real recurring templates and use counters that do not overstate producer identity. With recurrence disabled, limit=1 still digests the whole eligible window (40 files in the parent control versus one on main); restore the bounded cap behavior in that mode. This is a request for narrower scope, not a maintainer rewrite. #319 remains open.

…icrosoft#319)

A local hook that summarises the previous session on a timer writes one
single-turn transcript per run. Its only user prompt is the hook's own
template: identical leading text, a fresh log appended. Neither the engine
replay markers nor the sub-agent markers match it, and in the desktop app
those runs carry `entrypoint: "claude-desktop"` like interactive sessions, so
it was harvested as a user task. Because harvest walks newest first and stops
at `--max-sessions`, an hourly hook filled the cap and pushed real sessions out
of the night entirely.

`harvest()` now drops single-turn sessions whose first 120 characters of
prompt recur across at least three sessions of the same harvest window, before
the cap is applied. The whole window is scanned first: a template can only be
recognized by comparing it with the rest of the window, and the repeats must
not be counted against the cap they used to fill. A session with a follow-up
user turn is never matched, and neither is a prompt shorter than the prefix —
a real one-shot request repeats entirely instead of sharing a long boilerplate
header. `SKILLOPT_SLEEP_REPEAT_MIN_SESSIONS` tunes the threshold (default 3)
and `0` disables the filter.

`harvest()` takes an optional `stats` out-parameter that `harvest` and the
`run` / `dry-run` progress line report, so a thin night says why:
`harvest done: sessions=120 (skipped: 297 machine-generated, 2 past the session cap)`.

The harvest filters that already existed were undocumented — no .md file named
`SKILLOPT_SLEEP_AGENT_MARKERS`, `SKILLOPT_SLEEP_NEG_FEEDBACK` or
`SKILLOPT_SLEEP_POS_FEEDBACK`. They are now documented in the sleep guide and
the Claude Code plugin README, with the hourly-summary-hook example.
Two corrections from review, both about the default path rather than the
mechanism:

* Recurrence is not evidence of who wrote a prompt. Three human code-review
  requests that share one long checklist prefix and differ in the task payload
  are structurally identical to a hook re-running itself: one single-turn
  session each, >120 shared leading characters, a different tail. Dropping
  those loses real user tasks, which is worse than harvesting a hook's summary
  sessions, so the filter is now off unless a project opts in with
  SKILLOPT_SLEEP_REPEAT_MIN_SESSIONS (3 documented as the usual value; 0
  disables an exported value again). The docstring and both READMEs say why,
  and name the counter-example.

* With the filter off, harvest keeps its single bounded pass: it digests
  newest-first and stops at `limit`, so `--max-sessions 1` reads one transcript
  instead of the whole eligible window. The full-window scan now happens only
  when the filter is opted into, which is the mode that needs it.

Counters no longer claim a producer: `repeated_template_sessions` /
"repeated-template sessions" replaces `machine_generated_repeats` /
"machine-generated", in the stats dict and in the progress note.

Measured on the pre-change code with the new tests: 9 failed, 6 passed —
including the shared-checklist case (3 requests dropped) and the bounded-cap
case (40 transcripts read for `limit=1`). After: 15 passed. Full suite: 1,846
passed, 11 skipped, 368 subtests.
@SU-AN-coder
Ansu (SU-AN-coder) force-pushed the fix/harvest-machine-generated-repeats-319 branch from d3eeb3f to 7aabc1d Compare October 19, 2026 05:01
@SU-AN-coder

Copy link
Copy Markdown
Author

Rebased onto main (1a44050) and addressed both points in 7aabc1d; the PR body now references #319 instead of closing it.

Default is safe: the filter is off unless a project opts in. You were right that recurrence is not attribution. Three human requests for a code review against one long checklist share a single-turn shape, a >120-character common prefix and a different task after it — the same evidence a hook's re-runs leave. So _DEFAULT_REPEAT_MIN_SESSIONS is now 0, and a project that recognizes its own hourly summary template sets SKILLOPT_SLEEP_REPEAT_MIN_SESSIONS=N (3 documented as the usual value; 0 disables an exported value again). The module comment and both READMEs name the counter-example rather than presenting the heuristic as safe by documentation. test_shared_checklist_requests_are_not_dropped_by_default pins it: three requests over one shared checklist, default env, all three kept.

The bounded cap is back in the disabled mode. With the filter off, harvest() is the single pass it used to be — newest-first, stop at limit, nothing else read. The full-window scan happens only in the opted-in mode, where it is needed: a template can only be recognised against the rest of the window, and the repeats must not be scored against the cap they used to fill. Both are pinned:

  • test_default_cap_still_bounds_how_many_transcripts_are_digested — 40 transcripts on disk, limit=1, exactly 1 digest_transcript() call (pre-change: 40).
  • test_opt_in_scans_the_window_so_the_cap_can_be_refilled — the opted-in mode reads all 40, and the test says the trade is deliberate.

Counters no longer name a producer. machine_generated_repeats / "machine-generated" is gone; the stats key and the progress note say repeated_template_sessions / "repeated-template sessions" — what was measured, not who wrote it.

Rebase. The only conflict was one import line in cycle.py (handoff_backend.PendingCalls from main's #317 merge versus this branch's harvest.format_harvest_note); both are kept, since a call site in each needs one. Nothing else in cycle.py moved.

Verification on 7aabc1d:

  • pytest tests/test_harvest_machine_repeats.py -q — 15 passed. On the pre-review commit 17745c1 with the same tests: 9 failed, 6 passed; the six that pass both ways are the mechanism tests that export the variable, and I name them in the PR body rather than letting them look like evidence.
  • pytest -q — 1,846 passed, 11 skipped, 368 subtests passed.
  • mkdocs build --strict — exit 0. ruff check — the same two pre-existing I001 findings as on 17745c1, no new ones.

One thing I did not do: I considered one more signal that would let the filter stay on by default — the hook's runs use no tools, while a human review request in Claude Code normally reads files — but I cannot verify that against the three records you reproduced, so it would be a second guess stacked on the first. I left the default off instead. If you would rather have that condition as a default-on narrowing, say so and I will add it with the same before/after measurement.

The consequence, stated in the PR body: with the safe default, a folder like the one in #319 still harvests the hook's runs and limit=3 still fills with them until the variable is set. So this PR ships the opt-in control, the counters and the documentation, and leaves #319 open — which matches "narrower scope, not a maintainer rewrite".

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants