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.
- package/bin/rules-sentinel-hook.sh +101 -0
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1924 -841
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
- package/skills/mental-model-curator/SKILL.md +187 -56
- package/telegram-plugin/dist/gateway/gateway.js +5 -4
- package/vendor/hindsight-memory/hooks/hooks.json +10 -0
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
- package/vendor/hindsight-memory/scripts/recall.py +185 -0
- package/vendor/hindsight-memory/scripts/retain.py +132 -49
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
- 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
|
-
|
|
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
|
|
849
|
-
f"falling back to full-
|
|
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
|
-
|
|
855
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
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):
|