switchroom 0.21.15 → 0.21.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/bin/rules-sentinel-hook.sh +101 -0
  2. package/dist/agent-scheduler/index.js +1 -0
  3. package/dist/auth-broker/index.js +1 -0
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1924 -841
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +1 -1
  10. package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
  11. package/skills/mental-model-curator/SKILL.md +187 -56
  12. package/telegram-plugin/dist/gateway/gateway.js +5 -4
  13. package/vendor/hindsight-memory/hooks/hooks.json +10 -0
  14. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
  15. package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
  16. package/vendor/hindsight-memory/scripts/recall.py +185 -0
  17. package/vendor/hindsight-memory/scripts/retain.py +132 -49
  18. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
  19. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
  20. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
  21. package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
  22. package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
  23. package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
  24. package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """M4 P-PRE — async Stop-hook prefetch producer.
3
+
4
+ carve-M4.md: moves recall injection off the synchronous UserPromptSubmit
5
+ path by speculatively retaining + recalling at the END of turn N (this
6
+ script, registered as an async Stop hook), so turn N+1's UserPromptSubmit
7
+ (`recall.py`) can join an already-warm buffer instead of paying the full
8
+ recall latency synchronously.
9
+
10
+ Entirely gated by `memoryPrefetchEnabled` (default OFF/falsy — Fix C, the
11
+ red-team-mandated per-agent kill switch). When off this script is a no-op:
12
+ it reads config, sees the flag off, and exits silently before touching
13
+ `lib.retain`, `lib.recall_buffer`, or the network.
14
+
15
+ Call order (test a-integration in `tests/test_prefetch_pipeline.py`):
16
+ 1. delta retain (`retain.run_retain(hook_input, force=False, delta=True)`)
17
+ — persists this turn's NEW slice using the Fix-A content-derived
18
+ document_id, never truncating the session document.
19
+ 2. speculative recall (best-effort query derived from the transcript's
20
+ last human turn — the strongest available proxy for what turn N+1
21
+ will ask about).
22
+ 3. `recall_buffer.write_buffer` then `recall_buffer.write_sentinel`
23
+ (STRICTLY in that order — the sentinel-ordering contract `lib/
24
+ recall_buffer.py` and `tests/test_recall_buffer.py` prove).
25
+
26
+ Silent on stdout always (an async Stop hook's stdout is not surfaced to the
27
+ transcript); errors go to stderr and NEVER raise past `main()` — a broken
28
+ prefetch must degrade to "recall.py's synchronous path runs as before",
29
+ never break the turn or leave a torn buffer (a crash between steps 2 and 3a
30
+ just means no sentinel is written this turn, which `read_if_fresh` already
31
+ treats as "nothing fresh" — fail-closed by construction).
32
+
33
+ Skips the same junk turns `recall.py` skips (`<task-notification>` prefix)
34
+ — a synthetic follow-up turn is not a real conversation shift worth
35
+ prefetching for.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import sys
42
+
43
+ from lib.bank import derive_bank_id
44
+ from lib.client import HindsightClient
45
+ from lib.config import debug_log, load_config
46
+ from lib.content import _extract_text_content, strip_channel_envelope
47
+ from lib.daemon import get_api_url
48
+ from lib import recall_buffer
49
+ import retain as retain_module
50
+
51
+
52
+ def _last_human_prompt(transcript_path: str) -> str:
53
+ """Best-effort: the most recent human-authored user turn's text, used
54
+ as the speculative query for next turn's prefetch. Never raises —
55
+ returns "" on any read/parse failure (degrades to no query, which the
56
+ caller treats as "nothing to prefetch")."""
57
+ try:
58
+ messages = retain_module.read_transcript(transcript_path)
59
+ except Exception:
60
+ return ""
61
+ for msg in reversed(messages):
62
+ if msg.get("role") != "user":
63
+ continue
64
+ text = _extract_text_content(msg.get("content"), role="user")
65
+ text = strip_channel_envelope(text) if text else text
66
+ if text and text.strip():
67
+ return text.strip()
68
+ return ""
69
+
70
+
71
+ def run_prefetch(hook_input: dict, config: dict) -> bool:
72
+ """Execute one prefetch cycle. Returns True iff a buffer+sentinel pair
73
+ was written. Never raises — every internal step is wrapped so a bug in
74
+ ANY sub-step degrades to "no buffer written this turn", never a crash
75
+ that could take the Stop hook (and thus the turn) down with it."""
76
+ session_id = hook_input.get("session_id") or "unknown"
77
+
78
+ prompt = (hook_input.get("prompt") or hook_input.get("user_prompt") or "").strip()
79
+ if prompt.startswith("<task-notification"):
80
+ debug_log(config, "Prefetch: task-notification turn, skipping")
81
+ return False
82
+
83
+ # Step 1 — delta retain (Fix A: content-derived document_id, never the
84
+ # bare {session_id} document; never truncates).
85
+ try:
86
+ retain_module.run_retain(hook_input, force=False, delta=True)
87
+ except Exception as exc: # pragma: no cover - defensive
88
+ debug_log(config, f"Prefetch: delta retain failed, continuing without it: {exc}")
89
+
90
+ # Step 2 — speculative recall.
91
+ transcript_path = hook_input.get("transcript_path") or ""
92
+ query = _last_human_prompt(transcript_path)
93
+ if not query:
94
+ debug_log(config, "Prefetch: no usable query, nothing to prefetch")
95
+ return False
96
+
97
+ try:
98
+ bank_id = derive_bank_id(hook_input, config)
99
+ api_url = get_api_url(config)
100
+ client = HindsightClient(api_url)
101
+ response = client.recall(
102
+ bank_id,
103
+ query,
104
+ types=None,
105
+ timeout=config.get("memoryPrefetchTimeoutSeconds", 5),
106
+ )
107
+ results = (response or {}).get("results") or []
108
+ except Exception as exc: # pragma: no cover - defensive
109
+ debug_log(config, f"Prefetch: recall fetch failed: {exc}")
110
+ return False
111
+
112
+ if not results:
113
+ debug_log(config, "Prefetch: no candidates, nothing to buffer")
114
+ return False
115
+
116
+ from lib.content import format_memories
117
+
118
+ memories_block = format_memories(results)
119
+ if not memories_block:
120
+ return False
121
+
122
+ # Step 3 — write payload THEN sentinel, strictly in that order.
123
+ try:
124
+ recall_buffer.write_buffer(session_id, memories_block, {"result_count": len(results)})
125
+ recall_buffer.write_sentinel(session_id)
126
+ except Exception as exc: # pragma: no cover - defensive
127
+ debug_log(config, f"Prefetch: buffer write failed: {exc}")
128
+ return False
129
+
130
+ return True
131
+
132
+
133
+ def main():
134
+ config = load_config()
135
+ if not config.get("memoryPrefetchEnabled", False):
136
+ # Fix C, producer side: the whole mechanism is dark by default.
137
+ return
138
+
139
+ try:
140
+ hook_input = json.load(sys.stdin)
141
+ except (json.JSONDecodeError, EOFError):
142
+ print("[Hindsight] Prefetch: failed to read hook input", file=sys.stderr)
143
+ return
144
+
145
+ try:
146
+ run_prefetch(hook_input, config)
147
+ except Exception as exc: # pragma: no cover - defensive, must never break the turn
148
+ print(f"[Hindsight] Prefetch: unexpected error: {exc}", file=sys.stderr)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ try:
153
+ main()
154
+ except Exception as e: # pragma: no cover - defensive
155
+ print(f"[Hindsight] Unexpected error in prefetch: {e}", file=sys.stderr)
156
+ sys.exit(0)
@@ -82,6 +82,7 @@ from lib.directives import (
82
82
  )
83
83
  from lib.gateway_ipc import extract_chat_id_from_prompt, extract_topic_from_prompt, extract_user_from_prompt, update_placeholder
84
84
  from lib.parallel_recall import run_parallel
85
+ from lib import recall_buffer
85
86
  from lib.state import read_state, write_state
86
87
 
87
88
  # Cost of everything above, charged against the hook ceiling (see
@@ -395,6 +396,148 @@ def _emit_cached_context(context: str) -> None:
395
396
  )
396
397
 
397
398
 
399
+ _PREFETCH_DEGRADED_NOTICE = (
400
+ "⏳ prefetch not ready and no prior recall is cached for this session — "
401
+ "proceeding without injected memory this turn."
402
+ )
403
+
404
+
405
+ def stale_recall_notice(memories_context: str) -> str:
406
+ """Wrap a PRIOR turn's cached memories-only block in an explicit
407
+ staleness marker for the M4 prefetch-buffer fallback path.
408
+
409
+ M4 P-REC Fix B (red-team BINDING, MUST NOT regress): `memories_context`
410
+ must NEVER contain a directives block — the caller is required to pass
411
+ `LAST_RECALL_STATE`'s `memories_context` field (directives-free by
412
+ construction, see the write site near `context_message`), never its
413
+ sibling `context` field (which bundles `directives_block`). Directives
414
+ stay on the synchronous, always-current fetch path only (M3's
415
+ directive-decoupling rule) — a stale directives block re-injected from a
416
+ prior turn could resurrect a rule the user rescinded moments ago.
417
+ """
418
+ if not memories_context:
419
+ return ""
420
+ return (
421
+ "⏳ stale (prefetch not ready this turn) — the memories below are "
422
+ "from the PREVIOUS turn's recall, not this turn's. Treat them as a "
423
+ "hint, not a confirmed current fact.\n\n" + memories_context
424
+ )
425
+
426
+
427
+ def _handle_prefetch_buffer(config: dict, hook_input: dict, prompt: str) -> bool:
428
+ """M4 P-REC Fix C (consumer side) — join the Stop-hook producer's
429
+ prefetch buffer for this session instead of running recall
430
+ synchronously.
431
+
432
+ Gated entirely by `config.get("memoryPrefetchEnabled", False)` at the
433
+ caller; this function assumes the flag is already on. Returns True iff
434
+ it emitted an `additionalContext` payload (fresh hit, stale fallback, or
435
+ the explicit degraded notice) and the caller should return without
436
+ running the synchronous path. Returns False on a clean no-op miss (flag
437
+ effectively off / nothing to say) so the caller falls through.
438
+
439
+ Never raises past this function's own boundary in normal operation —
440
+ every internal step is wrapped so a bug here degrades to "fall through
441
+ to synchronous recall", never a broken turn. (The caller additionally
442
+ wraps this call in its own try/except as defence in depth.)
443
+ """
444
+ session_id = hook_input.get("session_id") or "unknown"
445
+
446
+ # Cold-start short-circuit (red-team MAJOR finding): if this session has
447
+ # NEVER produced a sentinel, polling the full cap on every single
448
+ # session-open turn would cost ~the poll cap on every fresh session —
449
+ # the opposite of M4's latency goal. Only poll when a sentinel already
450
+ # exists (a producer has run at least once for this session).
451
+ if not recall_buffer.sentinel_exists(session_id):
452
+ debug_log(config, "Prefetch buffer: no sentinel ever written for this session, cold-start skip")
453
+ else:
454
+ cap_ms = int(config.get("memoryPrefetchPollCapMs", 400))
455
+ recall_buffer.poll_for_sentinel(session_id, last_consumed_token=None, cap_ms=cap_ms)
456
+
457
+ payload, _token = recall_buffer.read_if_fresh(session_id, last_consumed_token=None)
458
+
459
+ # Directives stay on the synchronous, always-fresh path (M3 rule) even
460
+ # in the fast path — fetched here directly, never from the buffer.
461
+ directives_block = None
462
+ try:
463
+ bank_id = derive_bank_id(hook_input, config)
464
+ api_url = get_api_url(config)
465
+ client = HindsightClient(api_url)
466
+ directives = fetch_active_directives_cached(
467
+ client, bank_id, ttl_seconds=config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS)
468
+ )
469
+ directives_block = format_active_directives_block(directives) if directives else None
470
+ except Exception as exc: # pragma: no cover - defensive, directives are best-effort here
471
+ debug_log(config, f"Prefetch buffer: directive fetch failed: {exc}")
472
+ directives_block = None
473
+
474
+ if payload is not None:
475
+ memories_block = payload.get("context") or ""
476
+ parts = [b for b in (directives_block, memories_block) if b]
477
+ if not parts:
478
+ return False
479
+ _emit_cached_context("\n\n".join(parts))
480
+ return True
481
+
482
+ # Miss: no fresh buffer. Fall back to the LAST_RECALL_STATE's
483
+ # directives-FREE `memories_context` field (Fix B) if one exists for
484
+ # this session, explicitly marked stale. Never `context` (directive-
485
+ # contaminated).
486
+ last = read_state(LAST_RECALL_STATE) or {}
487
+ stale_memories = last.get("memories_context") or ""
488
+ stale_block = stale_recall_notice(stale_memories)
489
+ parts = [b for b in (directives_block, stale_block) if b]
490
+ if parts:
491
+ _emit_cached_context("\n\n".join(parts))
492
+ return True
493
+
494
+ if directives_block:
495
+ _emit_cached_context(directives_block)
496
+ return True
497
+
498
+ # Nothing fresh, nothing stale, nothing cached — say so explicitly
499
+ # rather than silently emitting no context (so a degraded turn is
500
+ # legible, matching the #3619 degraded-disclosure precedent).
501
+ _emit_cached_context(_PREFETCH_DEGRADED_NOTICE)
502
+ return True
503
+
504
+
505
+ def _emit_directives_only(config: dict, hook_input: dict) -> None:
506
+ """#4756 F2 — directive exemption for the task-notification junk gate.
507
+
508
+ The M4 P-REC junk gate skips synchronous recall on synthetic
509
+ `<task-notification>` turns to avoid burning latency/cost on
510
+ machine-generated noise. But DIRECTIVES are the one memory class that must
511
+ survive that gate: an agent's standing rules apply on EVERY turn, synthetic
512
+ or not, and suppressing them on a sub-agent-handback turn is a behavior
513
+ change that should not ride along with the noise gate. So the gate drops
514
+ the non-directive classes (observations/world/etc. — the whole `recall`
515
+ result set) but still fetches and injects the active directives block,
516
+ exactly as the prefetch fast path does (M3 directive-decoupling rule).
517
+
518
+ Emits nothing when the bank has no active directives (no empty wrapper).
519
+ Failure-safe: any error → emit nothing rather than break the turn, matching
520
+ `fetch_active_directives_cached`'s never-raise contract. Deliberately does
521
+ NOT run the memory `recall` HTTP call — only `list_directives` is touched.
522
+ """
523
+ try:
524
+ bank_id = derive_bank_id(hook_input, config)
525
+ api_url = get_api_url(config)
526
+ client = HindsightClient(api_url, config.get("hindsightApiToken"))
527
+ directives = fetch_active_directives_cached(
528
+ client,
529
+ bank_id,
530
+ ttl_seconds=config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS),
531
+ )
532
+ directives_block = format_active_directives_block(directives) if directives else None
533
+ except Exception as exc: # pragma: no cover - defensive, directives are best-effort here
534
+ debug_log(config, f"Task-notification skip: directive fetch failed: {exc}")
535
+ return
536
+
537
+ if directives_block:
538
+ _emit_cached_context(directives_block)
539
+
540
+
398
541
  def _is_demoted_memory(memory) -> bool:
399
542
  """Return True if the memory has any demote-from-recall tag.
400
543
 
@@ -1971,6 +2114,38 @@ def main():
1971
2114
  debug_log(config, "Prompt too short for recall, skipping")
1972
2115
  return
1973
2116
 
2117
+ # M4 P-REC junk gate: `<task-notification>` is the CLI-native envelope a
2118
+ # sub-agent's own scheduler/harness prepends on a synthetic follow-up
2119
+ # turn — distinct from the gateway's `<channel source=...>` wrapper
2120
+ # (a real user message) and from `is_synthetic_inbound`. Recall on a
2121
+ # task-notification turn burns latency/cost on a turn no human is
2122
+ # waiting on and whose "query" is machine-generated noise, not intent.
2123
+ # Deterministic prefix check only — never a content classifier. On by
2124
+ # default (`recallSkipTaskNotification`); flips off for an agent that
2125
+ # deliberately wants recall on these turns.
2126
+ if config.get("recallSkipTaskNotification", True) and prompt.startswith("<task-notification"):
2127
+ # #4756 F2: skip the noisy NON-directive recall, but directives are
2128
+ # exempt — they are HARD RULES that apply on every turn, synthetic or
2129
+ # not, so still fetch + inject the active directives block. Only the
2130
+ # observation/world memory classes (the `recall` result set) are
2131
+ # suppressed here; `_emit_directives_only` never touches `recall`.
2132
+ debug_log(config, "Prompt is a task-notification envelope, skipping recall (directives exempt)")
2133
+ _emit_directives_only(config, hook_input)
2134
+ return
2135
+
2136
+ # M4 P-REC Fix C (consumer side) — the whole prefetch-buffer mechanism
2137
+ # is gated by `memoryPrefetchEnabled`, default OFF/falsy. When on, try
2138
+ # the buffer-join fast path first; on any hit (fresh or stale-fallback)
2139
+ # it emits and returns, short-circuiting the synchronous recall below.
2140
+ # A miss (disabled, cold-start, buffer absent, or any internal error)
2141
+ # falls through to the existing synchronous path untouched.
2142
+ if config.get("memoryPrefetchEnabled", False):
2143
+ try:
2144
+ if _handle_prefetch_buffer(config, hook_input, prompt):
2145
+ return
2146
+ except Exception as exc: # pragma: no cover - defensive, never break recall
2147
+ debug_log(config, f"Prefetch buffer join failed, falling back to sync recall: {exc}")
2148
+
1974
2149
  # Switchroom-local: skip recall on conversational acks.
1975
2150
  #
1976
2151
  # The 5-char short-circuit catches `ok`/`yes`/`no`/`ty` but passes
@@ -3134,6 +3309,16 @@ def main():
3134
3309
  LAST_RECALL_STATE,
3135
3310
  {
3136
3311
  "context": context_message,
3312
+ # M4 P-REC Fix B (red-team BINDING): a directives-FREE sibling of
3313
+ # `context`, memories/transcript-fallback only. The stale-buffer
3314
+ # fallback (`_handle_prefetch_buffer`) reads THIS field, never
3315
+ # `context` — `context` bundles `directives_block` (M3's
3316
+ # directive-decoupling rule forbids re-injecting stale directives
3317
+ # from a prior turn's cache; directives stay on the synchronous,
3318
+ # always-fresh fetch path only).
3319
+ "memories_context": "\n\n".join(
3320
+ [b for b in (memories_block, transcript_fallback_block) if b]
3321
+ ),
3137
3322
  "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
3138
3323
  "bank_id": bank_id,
3139
3324
  "result_count": len(results),
@@ -727,7 +727,7 @@ def build_retain_payload(
727
727
  }
728
728
 
729
729
 
730
- def run_retain(hook_input: dict, force: bool = False) -> dict:
730
+ def run_retain(hook_input: dict, force: bool = False, delta: bool = False) -> dict:
731
731
  """Run the auto-retain flow.
732
732
 
733
733
  Returns a status dict::
@@ -742,6 +742,22 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
742
742
  process (the SessionStart drainer, see ``drain_pending.py`` and
743
743
  ``lib/pending.py``). ``status="skipped"`` is the normal early-exit
744
744
  cases (auto-retain disabled, empty transcript, throttled chunk).
745
+
746
+ ``delta`` (M4 P-RET, per-turn delta retain — called ONLY from
747
+ ``prefetch.py``, itself gated on the ``memoryPrefetchEnabled`` kill
748
+ switch, default off): bypasses ``retainEveryNTurns`` entirely (the
749
+ caller already decided this turn fires) and slices ONLY the
750
+ transcript tail after the committed watermark. Ignored when
751
+ ``force=True`` (SessionEnd always takes the force path below).
752
+
753
+ **Document-id safety invariant (red-team M4 Finding A, BINDING):** a
754
+ delta retain is ALWAYS posted under a distinct, content-derived
755
+ ``document_id`` — the SAME strategy the ``retainEveryNTurns>1``
756
+ chunked path already uses — and NEVER under the bare ``{session_id}``
757
+ id the full-session/n==1 path reserves. Posting a tail slice under
758
+ ``{session_id}`` would UPSERT-OVERWRITE the whole session's stored
759
+ memory with just the latest turn on every successful turn (see
760
+ ``retain.py`` §4.3 note below and ``tests/test_retain_delta.py``).
745
761
  """
746
762
  # /private-mode enforcement (switchroom privacy PR1) — FIRST, before
747
763
  # load_config(). An OPEN private interval means the operator is discussing
@@ -810,66 +826,109 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
810
826
  retain_every_n = max(1, config.get("retainEveryNTurns", 1))
811
827
  retain_full_window = False
812
828
  messages_to_retain = all_messages
813
-
814
- # Respect retainEveryNTurns in both modes, unless force=True (SessionEnd final retain)
815
- if retain_every_n > 1 and not force:
816
- turn_count = increment_turn_count(session_id)
817
- if turn_count % retain_every_n != 0:
818
- next_at = ((turn_count // retain_every_n) + 1) * retain_every_n
819
- debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
820
- return {"status": "skipped", "reason": "throttled"}
821
-
822
- # Window selection is decoupled from the throttle above — see
823
- # select_retain_window() for the switchroom-divergence rationale
824
- # (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
825
- overlap_turns = config.get("retainOverlapTurns", 0)
826
-
827
- # Incremental SessionEnd sweep (memory-RFC P1): a forced chunked sweep at
828
- # retainEveryNTurns>1 retains only the transcript tail after the committed
829
- # watermark, not the whole session on top of the N per-window documents that
830
- # already carry it. The watermark READ is filesystem IO on a JSON file that
831
- # could be truncated / permission-broken / absent, so it lives HERE (not in
832
- # the pure select_retain_window) inside a catch-all try/except.
833
- #
834
- # §4.3 HAZARD: this is the one seam where a raise DELETES a turn rather than
835
- # degrading it. Every failure mode below MUST degrade to since_uuid=None,
836
- # which reproduces today's whole-session sweep exactly. The except catches
837
- # Exception (not a named subset) on purpose — any raise here is worse than a
838
- # wrong value. The n==1 and full-session paths deliberately never compute a
839
- # watermark: on those the document id is {session_id} and a tail slice would
840
- # TRUNCATE the stored document (§4.2), so they keep the full-session sweep.
841
829
  since_uuid = None
842
- if force and retain_mode == "chunked" and retain_every_n > 1:
830
+ # True only for the per-turn delta path (M4 P-RET) — threads a distinct,
831
+ # content-derived document_id below (Finding A) instead of the
832
+ # retainEveryNTurns>1 / n==1 strategy.
833
+ delta_document_id_override = False
834
+
835
+ if delta and not force:
836
+ # M4 P-RET — per-turn delta retain, called ONLY from prefetch.py
837
+ # (itself gated on memoryPrefetchEnabled, default off). Bypasses the
838
+ # retainEveryNTurns throttle entirely: the caller already decided
839
+ # this turn fires, every turn, superseding the P5 cadence on a
840
+ # flipped agent (config comment mandated at the switchroom.yaml
841
+ # seam — see P-CFG). Slices ONLY the transcript tail after the
842
+ # committed watermark; the §4.3 HAZARD discipline applies here too —
843
+ # any watermark-read failure MUST degrade to the full window, never
844
+ # raise, never emit an empty slice.
843
845
  try:
844
846
  _wm = watermark.load(session_id)
845
847
  since_uuid = _wm.get("last_uuid") if _wm else None
846
848
  except Exception as e: # noqa: BLE001 - degrade, never raise into this seam
847
849
  print(
848
- "[Hindsight] watermark read failed for incremental sweep; "
849
- f"falling back to full-session sweep: {e}",
850
+ "[Hindsight] watermark read failed for delta retain; "
851
+ f"falling back to full-window slice: {e}",
850
852
  file=sys.stderr,
851
853
  )
852
854
  since_uuid = None
853
-
854
- messages_to_retain, retain_full_window = select_retain_window(
855
- retain_mode, retain_every_n, overlap_turns, all_messages, force=force,
856
- since_uuid=since_uuid,
857
- )
858
- if retain_mode == "chunked" and not force:
859
- window_turns = max(retain_every_n, 1) + overlap_turns
855
+ messages_to_retain = watermark.tail_after(all_messages, since_uuid)
856
+ retain_full_window = True
857
+ delta_document_id_override = True
860
858
  debug_log(
861
859
  config,
862
- f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
863
- )
864
- elif retain_mode == "chunked" and force:
865
- _swept = "incremental (tail after watermark)" if since_uuid is not None else "full-session"
866
- debug_log(
867
- config,
868
- f"Chunked retain, forced {_swept} sweep (SessionEnd): "
869
- f"{len(messages_to_retain)}/{len(all_messages)} messages",
860
+ f"Delta retain firing (since_uuid={since_uuid!r}, "
861
+ f"{len(messages_to_retain)}/{len(all_messages)} messages)",
870
862
  )
871
863
  else:
872
- debug_log(config, f"Full session retain: {len(all_messages)} messages")
864
+ # Respect retainEveryNTurns in both modes, unless force=True (SessionEnd final retain)
865
+ if retain_every_n > 1 and not force:
866
+ turn_count = increment_turn_count(session_id)
867
+ if turn_count % retain_every_n != 0:
868
+ next_at = ((turn_count // retain_every_n) + 1) * retain_every_n
869
+ debug_log(config, f"Turn {turn_count}/{retain_every_n}, skipping retain (next at turn {next_at})")
870
+ return {"status": "skipped", "reason": "throttled"}
871
+
872
+ # Window selection is decoupled from the throttle above — see
873
+ # select_retain_window() for the switchroom-divergence rationale
874
+ # (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
875
+ overlap_turns = config.get("retainOverlapTurns", 0)
876
+
877
+ # Incremental SessionEnd sweep (memory-RFC P1): a forced chunked sweep at
878
+ # retainEveryNTurns>1 retains only the transcript tail after the committed
879
+ # watermark, not the whole session on top of the N per-window documents that
880
+ # already carry it. The watermark READ is filesystem IO on a JSON file that
881
+ # could be truncated / permission-broken / absent, so it lives HERE (not in
882
+ # the pure select_retain_window) inside a catch-all try/except.
883
+ #
884
+ # §4.3 HAZARD: this is the one seam where a raise DELETES a turn rather than
885
+ # degrading it. Every failure mode below MUST degrade to since_uuid=None,
886
+ # which reproduces today's whole-session sweep exactly. The except catches
887
+ # Exception (not a named subset) on purpose — any raise here is worse than a
888
+ # wrong value. The n==1 and full-session paths deliberately never compute a
889
+ # watermark UNLESS the M4 delta mechanism is enabled for this agent
890
+ # (``memoryPrefetchEnabled`` — red-team Finding A2): with the mechanism off
891
+ # (the fleet default) the document id is {session_id} and a tail slice
892
+ # would TRUNCATE the stored document (§4.2), so they keep the full-session
893
+ # sweep. With it ON, a per-turn delta retain has already been posting
894
+ # content-derived slice documents and advancing this same watermark every
895
+ # turn, so the force sweep MUST also go incremental — otherwise it
896
+ # blindly re-posts the whole session under {session_id} on top of the
897
+ # per-turn delta documents (redundant storage, not the destructive
898
+ # truncation Finding A guards, but still wrong for cadence-1 agents).
899
+ if force and retain_mode == "chunked" and (
900
+ retain_every_n > 1 or config.get("memoryPrefetchEnabled")
901
+ ):
902
+ try:
903
+ _wm = watermark.load(session_id)
904
+ since_uuid = _wm.get("last_uuid") if _wm else None
905
+ except Exception as e: # noqa: BLE001 - degrade, never raise into this seam
906
+ print(
907
+ "[Hindsight] watermark read failed for incremental sweep; "
908
+ f"falling back to full-session sweep: {e}",
909
+ file=sys.stderr,
910
+ )
911
+ since_uuid = None
912
+
913
+ messages_to_retain, retain_full_window = select_retain_window(
914
+ retain_mode, retain_every_n, overlap_turns, all_messages, force=force,
915
+ since_uuid=since_uuid,
916
+ )
917
+ if retain_mode == "chunked" and not force:
918
+ window_turns = max(retain_every_n, 1) + overlap_turns
919
+ debug_log(
920
+ config,
921
+ f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
922
+ )
923
+ elif retain_mode == "chunked" and force:
924
+ _swept = "incremental (tail after watermark)" if since_uuid is not None else "full-session"
925
+ debug_log(
926
+ config,
927
+ f"Chunked retain, forced {_swept} sweep (SessionEnd): "
928
+ f"{len(messages_to_retain)}/{len(all_messages)} messages",
929
+ )
930
+ else:
931
+ debug_log(config, f"Full session retain: {len(all_messages)} messages")
873
932
 
874
933
  # Resolve API URL
875
934
  def _dbg(*a):
@@ -909,7 +968,14 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
909
968
  # - Full-session mode / n==1: unchanged — a stable ``session_id`` base with
910
969
  # a compaction chunk counter so a shrinking transcript doesn't overwrite
911
970
  # the pre-compaction document with a shorter one.
912
- if retain_mode == "chunked" and retain_every_n > 1:
971
+ if delta_document_id_override:
972
+ # M4 P-RET (red-team Finding A, BINDING): a delta retain ALWAYS gets
973
+ # a content-derived slice id — mirroring the retainEveryNTurns>1
974
+ # strategy — and must NEVER fall through to the bare {session_id}
975
+ # id below, which the full-session/n==1 path reserves and which a
976
+ # tail-slice POST would silently truncate.
977
+ document_id = None # build_retain_payload computes the content-derived id
978
+ elif retain_mode == "chunked" and retain_every_n > 1:
913
979
  document_id = None # build_retain_payload computes the content-derived id
914
980
  else:
915
981
  chunk_index, compacted = track_retention(session_id, len(all_messages))
@@ -1009,6 +1075,23 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
1009
1075
 
1010
1076
 
1011
1077
  def main():
1078
+ # M4 P-WIRE — when the prefetch mechanism is on for this agent,
1079
+ # `prefetch.py`'s OWN Stop-hook entry already performs a delta retain
1080
+ # every turn (see `prefetch.run_prefetch`, step 1). Running THIS Stop
1081
+ # hook's own (non-delta) retain as well would double-retain the same
1082
+ # turn: two independent POSTs, and at `retainEveryNTurns > 1` two
1083
+ # independent `increment_turn_count` calls drifting the cadence
1084
+ # throttle out of sync with itself. Skip here — flag OFF (the default,
1085
+ # every agent today) leaves this function's behaviour byte-identical to
1086
+ # pre-M4; this check is the ONLY new code on that path.
1087
+ try:
1088
+ _cfg = load_config()
1089
+ if _cfg.get("memoryPrefetchEnabled", False):
1090
+ debug_log(_cfg, "Stop retain: memoryPrefetchEnabled, delegating to prefetch.py")
1091
+ return
1092
+ except Exception: # pragma: no cover - defensive, never block the legacy path
1093
+ pass
1094
+
1012
1095
  try:
1013
1096
  hook_input = json.load(sys.stdin)
1014
1097
  except (json.JSONDecodeError, EOFError):