switchroom 0.21.14 → 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 (36) hide show
  1. package/bin/rules-sentinel-hook.sh +101 -0
  2. package/dist/agent-scheduler/index.js +7 -2
  3. package/dist/auth-broker/index.js +7 -2
  4. package/dist/cli/notion-write-pretool.mjs +7 -2
  5. package/dist/cli/switchroom.js +2601 -1009
  6. package/dist/host-control/main.js +8 -3
  7. package/dist/vault/approvals/kernel-server.js +7 -2
  8. package/dist/vault/broker/server.js +7 -2
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +9 -0
  11. package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
  12. package/skills/mental-model-curator/SKILL.md +187 -56
  13. package/telegram-plugin/dist/gateway/gateway.js +11 -6
  14. package/vendor/hindsight-memory/hooks/hooks.json +10 -0
  15. package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
  16. package/vendor/hindsight-memory/scripts/lib/config.py +22 -0
  17. package/vendor/hindsight-memory/scripts/lib/directives.py +45 -7
  18. package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
  19. package/vendor/hindsight-memory/scripts/lib/watermark.py +27 -0
  20. package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
  21. package/vendor/hindsight-memory/scripts/recall.py +520 -5
  22. package/vendor/hindsight-memory/scripts/reconcile_tail.py +4 -12
  23. package/vendor/hindsight-memory/scripts/retain.py +167 -28
  24. package/vendor/hindsight-memory/scripts/tests/test_config_retain_tool_calls_env.py +98 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_directives.py +52 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_incremental_sweep.py +293 -0
  27. package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
  28. package/vendor/hindsight-memory/scripts/tests/test_profile_capture_nudge.py +335 -0
  29. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
  30. package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_query_timestamp.py +376 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
@@ -54,6 +54,7 @@ import re # noqa: E402
54
54
  import socket # noqa: E402
55
55
  import sys # noqa: E402
56
56
  import urllib.error # noqa: E402
57
+ from datetime import datetime # noqa: E402
57
58
 
58
59
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
59
60
 
@@ -81,6 +82,7 @@ from lib.directives import (
81
82
  )
82
83
  from lib.gateway_ipc import extract_chat_id_from_prompt, extract_topic_from_prompt, extract_user_from_prompt, update_placeholder
83
84
  from lib.parallel_recall import run_parallel
85
+ from lib import recall_buffer
84
86
  from lib.state import read_state, write_state
85
87
 
86
88
  # Cost of everything above, charged against the hook ceiling (see
@@ -394,6 +396,148 @@ def _emit_cached_context(context: str) -> None:
394
396
  )
395
397
 
396
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
+
397
541
  def _is_demoted_memory(memory) -> bool:
398
542
  """Return True if the memory has any demote-from-recall tag.
399
543
 
@@ -1578,6 +1722,257 @@ def looks_like_standing_rule(text) -> bool:
1578
1722
  return bool(_DIRECTIVE_NUDGE_RE.search(scrubbed))
1579
1723
 
1580
1724
 
1725
+ # ── Temporal-expression detection for the recall `query_timestamp` anchor ──
1726
+ # Switchroom P2 (memory-redesign RFC §5). When the inbound prompt asks a
1727
+ # time-relative question ("what did we work on last week", "on the 12th"),
1728
+ # the recall body carries an explicit `query_timestamp` anchor so the engine
1729
+ # resolves the relative expression and anchors recency scoring against the
1730
+ # real ask-time. The anchor is ALWAYS the current wall clock — that is the
1731
+ # documented semantics ("when the query is being asked, from the user's
1732
+ # perspective", https://hindsight.vectorize.io/developer/api/recall) — the
1733
+ # regex only GATES whether the field is sent, it does not resolve the phrase
1734
+ # itself (the engine does that server-side against the anchor we supply).
1735
+ #
1736
+ # Deterministic regex only — NO model call (claude-native invariant, same as
1737
+ # the directive-capture nudge above). The match is a cheap substring scan on
1738
+ # the already-stripped prompt, so it never extends the recall hook's critical
1739
+ # path (the 12s ceiling / parallel deadline live entirely on the network I/O
1740
+ # below). Detection is deliberately conservative: it requires a preposition or
1741
+ # quantifier around ambiguous tokens (bare weekday / month / "may") so an
1742
+ # ordinary sentence does not fire the field. A false negative merely omits an
1743
+ # anchor the server would default to anyway; a false positive sends the true
1744
+ # ask-time, which is the correct anchor regardless — so both error directions
1745
+ # degrade to today's behaviour.
1746
+ _TEMPORAL_EXPRESSION_RE = re.compile(
1747
+ r"""(?ix)
1748
+ (?:
1749
+ # --- absolute-relative day words ---
1750
+ \b (?: yesterday | tonight | tomorrow ) \b
1751
+ | \b last \s+ night \b
1752
+ # --- this/last/next + period ---
1753
+ | \b (?: this | last | next | past ) \s+
1754
+ (?: week | month | year | quarter | fortnight | weekend
1755
+ | morning | afternoon | evening | night | decade ) \b
1756
+ # --- earlier / other-day framings ---
1757
+ | \b the \s+ other \s+ (?: day | week | night ) \b
1758
+ | \b earlier \s+ (?: today | this \s+ (?: week | month | year ) ) \b
1759
+ | \b a \s+ (?: while | moment ) \s+ ago \b
1760
+ # --- "<N> <unit> ago" (worded or digit quantifier) ---
1761
+ | \b (?: a | an | one | two | three | four | five | six | seven | eight
1762
+ | nine | ten | \d+ | couple \s+ of | few ) \s+
1763
+ (?: second | minute | hour | day | week | month | year ) s? \s+ ago \b
1764
+ # --- weekday, only with a temporal preposition/qualifier ---
1765
+ | \b (?: this | last | next | on | since | by ) \s+
1766
+ (?: monday | tuesday | wednesday | thursday | friday
1767
+ | saturday | sunday ) \b
1768
+ # --- ordinal day-of-month ("on the 12th", "by the 3rd") ---
1769
+ | \b (?: on | by | since | before | after | around ) \s+ the \s+
1770
+ \d{1,2} (?: st | nd | rd | th ) \b
1771
+ # --- month name, only with a temporal preposition ---
1772
+ | \b (?: in | on | since | during | back \s+ in | early | late ) \s+
1773
+ (?: january | february | march | april | may | june | july
1774
+ | august | september | october | november | december ) \b
1775
+ )
1776
+ """
1777
+ )
1778
+
1779
+
1780
+ # RFC phase4 P3 — deterministic operator-profile capture nudge.
1781
+ #
1782
+ # Ken's first stated want is "save memories about him" (RFC §0 constraint 5a).
1783
+ # Auto-retain does store transcript facts, but there is no deterministic signal
1784
+ # that a durable *profile fact* about the operator himself just went by, so
1785
+ # capture-as-profile is left to model discretion — the same per-agent lottery
1786
+ # Stage A measured for directives. This mirrors the shipped directive-capture
1787
+ # nudge (recall.py #2848): a POSITIVE regex detects a first-person durable
1788
+ # self-statement ("I prefer …", "my … is …", "I always …", "remind me that
1789
+ # I …"); a NEGATIVE regex scrubs the two shapes that would otherwise misfire —
1790
+ # questions ("do I prefer …?", "what's my …?") and third-/second-party
1791
+ # attributions ("you said I prefer …", "she claims my …") — BEFORE the positive
1792
+ # match. On a hit the hook appends a terse advisory telling the model to persist
1793
+ # the fact with an explicit mcp__hindsight__retain carrying a `profile:ken` tag
1794
+ # into THIS AGENT'S OWN bank. Pure regex — NO model callsite (the claude-native
1795
+ # invariant forbids a classifier call); the model makes the judgment in-session
1796
+ # and calls retain itself (chat-legible). The hook NEVER writes on its own.
1797
+ #
1798
+ # On by default; operators opt out per-agent via memory.profile_capture_nudge
1799
+ # =false → HINDSIGHT_PROFILE_CAPTURE_NUDGE (recall.py falls back to True).
1800
+ #
1801
+ # ROUTING CONSTRAINT (RFC §0 constraint 2, §7 Q3): the fact goes to the agent's
1802
+ # OWN bank, NOT a shared/cross-agent person bank (ken-profile/lisa-profile).
1803
+ # The tag makes the facts cheap to find and retire later if Q3 is answered
1804
+ # differently. The `profile:ken` operator identity is intentionally literal —
1805
+ # this fleet has a single named operator (Ken); a multi-operator deployment
1806
+ # would parameterise the tag, which is out of scope for P3.
1807
+ #
1808
+ # CONSERVATISM (RFC §7 Q1, unanswered): the RFC flags that this regex set is
1809
+ # derived from an ASSUMPTION that the profile facts Ken wants are
1810
+ # preference-/identity-shaped. Until Q1 is answered from a real instance, the
1811
+ # positive set is deliberately TIGHT (favouring false negatives) — only clearly
1812
+ # durable first-person framings, not bare "I like"/"I use" reactions that are
1813
+ # usually one-off. If Q1's answer is not preference-shaped, this set is wrong
1814
+ # and should be re-derived before it is relied on.
1815
+ _PROFILE_NUDGE_NEGATIVE_RE = re.compile(
1816
+ r"""(?ix)
1817
+ (?:
1818
+ # --- interrogatives: a question ABOUT the operator is not a statement
1819
+ # OF a durable fact. Scrub the "<wh|aux> [do] I" / "<aux> my" lead so
1820
+ # the following "I prefer" / "my X is" can't fire. ---
1821
+ \b (?: what | which | where | when | why | how | do | did | does
1822
+ | should | would | could | can | are | is | was | were )
1823
+ \s+ (?: do \s+ )? i \b
1824
+ | \b what (?: ['’]? s | \s+ is | \s+ are ) \s+ my \b
1825
+ | \b (?: where | when | is | are | was | were ) \s+ my \b
1826
+ | \b remind \s+ me \s+ what \b
1827
+ # --- attributions: a fact the operator ascribes to someone else (or to
1828
+ # the agent) is not the operator stating his own profile. Scrub the
1829
+ # attributed clause up to the next clause boundary. Stop at a comma as
1830
+ # well as sentence-enders so a trailing real fact in the SAME sentence
1831
+ # ("she said X, my timezone is Melbourne") still reaches the positive
1832
+ # matcher instead of being swallowed. ---
1833
+ | \b (?: he | she | they | you | who | someone | everyone | nobody )
1834
+ \s+ (?: said | says | say | claimed | claims | thinks? | thought
1835
+ | told | mentions? | mentioned | asks? | asked | wants? | wanted
1836
+ | wrote | believes? | reckons? )
1837
+ \b [^.?!,]*
1838
+ # --- pleasantries that embed a bare always/never after "I". ---
1839
+ | \b i \s+ (?: always | never )
1840
+ \s+ (?: appreciate | enjoy | 'm \s+ happy | am \s+ happy | love \s+ working ) \b
1841
+ # --- "I'm a <hedge>" is a transient mood/quantifier, not "I'm a <noun>"
1842
+ # identity ("I'm a bit tired", "I'm a little confused"). Scrub the
1843
+ # "I'm a/an" lead so the identity arm can't fire on it. ---
1844
+ | \b i (?: \s+ am | \s* ['’] m ) \s+ (?: a | an )
1845
+ \s+ (?: bit | little | lot | tad | touch | bunch | couple | few
1846
+ | fan \b | big \s+ fan ) \b
1847
+ # --- "call me <phone-phrasing>" is a request, not a name form
1848
+ # ("call me back", "call me later"). Scrub so the name arm ("call me
1849
+ # Ken") is the only thing left that can fire. ---
1850
+ | \b call \s+ me \s+ (?: back | later | tomorrow | tonight | soon | again
1851
+ | when | if | once | after | before | at | on | in | asap ) \b
1852
+ )
1853
+ """
1854
+ )
1855
+
1856
+
1857
+ def detect_query_timestamp(text, now=None) -> "str | None":
1858
+ """Return an ISO 8601 ask-time anchor when ``text`` carries a temporal
1859
+ expression, else ``None``.
1860
+
1861
+ Deterministic and IO-free — a single regex scan, no model call and no
1862
+ clock dependency the caller cannot control (``now`` is injectable so the
1863
+ behaviour is unit-testable to the exact output string). When a temporal
1864
+ phrase is present the anchor returned is the CURRENT time, because
1865
+ ``query_timestamp`` is defined by the engine as *when the query is asked*,
1866
+ not the period the phrase names — the engine resolves the phrase against
1867
+ this anchor. ``None`` means "send no field", which keeps the recall body
1868
+ byte-identical to a pre-P2 client.
1869
+
1870
+ The anchor carries the LOCAL wall-clock offset (``datetime.now()`` +
1871
+ ``.astimezone()``), NOT UTC. This matters precisely on the dimension P2
1872
+ serves: for a Melbourne evening query "what did we do yesterday", a
1873
+ UTC-stamped anchor (``+00:00``) can be a calendar day ahead of the
1874
+ operator's real day, so the engine would resolve "yesterday"/"last
1875
+ week"/"on the 12th" against the wrong day. ``.astimezone()`` with no
1876
+ argument attaches the process TZ (the container clock is already
1877
+ Australia/Melbourne), which is the operator's actual day. Never
1878
+ ``timezone.utc`` here — that would re-introduce the off-by-one this fix
1879
+ removes.
1880
+
1881
+ Returns ``None`` on empty / non-string input so a caller can pass a raw
1882
+ prompt without a guard.
1883
+ """
1884
+ if not isinstance(text, str) or not text.strip():
1885
+ return None
1886
+ if not _TEMPORAL_EXPRESSION_RE.search(text):
1887
+ return None
1888
+ anchor = now if now is not None else datetime.now().astimezone()
1889
+ return anchor.isoformat()
1890
+
1891
+
1892
+ _PROFILE_NUDGE_RE = re.compile(
1893
+ r"""(?ix)
1894
+ (?:
1895
+ # --- stated preferences / tastes ---
1896
+ \b i \s+ prefer \b
1897
+ | \b i['’]? d \s+ prefer \b
1898
+ | \b my \s+ preference \s+ (?: is | are ) \b
1899
+ | \b i \s+ (?: hate | love | despise | adore | dislike
1900
+ | can ['’]? t \s+ stand ) \b
1901
+ # --- durable self-facts: "my <ATTRIBUTE> is/are/'s <value>".
1902
+ # ATTRIBUTE is a TIGHT allow-list of durable identity attributes.
1903
+ # A free noun ("my build is failing", "my container is down", "my PR
1904
+ # is ready") is transient dev state, not a profile fact — firing on
1905
+ # it inverts the RFC's favour-false-negatives constraint on this very
1906
+ # agent (klanker), so the free-`\w+` arm is deliberately NOT used. ---
1907
+ | \b my \s+
1908
+ (?: name | e-?mail | timezone | time \s+ zone | address
1909
+ | (?: phone | mobile | cell ) (?: \s+ number )? | number
1910
+ | birthday | birthdate | dob | anniversary | age | pronouns?
1911
+ | handle | username | nickname | initials
1912
+ | employer | company | partner | wife | husband | spouse
1913
+ | girlfriend | boyfriend | kids? | children | child | son
1914
+ | daughter | sister | brother | mother | father | mom | dad
1915
+ | parents | dog | cat | pet | diet | allerg(?: y | ies )
1916
+ | location | city | country | hometown )
1917
+ (?: \s+ \w+ )?
1918
+ (?: \s+ (?: is | are ) | \s* ['’] s ) \b
1919
+ # --- identity / situation ---
1920
+ | \b i \s+ live \s+ (?: in | at | near ) \b
1921
+ | \b i \s+ work \s+ (?: at | as | for | in ) \b
1922
+ | \b i (?: \s+ am | \s* ['’] m ) \s+ (?: allergic \s+ to
1923
+ | based \s+ (?: in | at ) | from | located \s+ in
1924
+ | vegetarian | vegan | pescatarian | teetotal ) \b
1925
+ | \b i (?: \s+ am | \s* ['’] m ) \s+ (?: a | an ) \s+ \w+
1926
+ # --- dietary / abstention identity ("I don't eat meat") ---
1927
+ | \b i \s+ (?: do \s* n['’]? t | don['’]? t | do \s+ not )
1928
+ \s+ (?: eat | drink | use | own | drive ) \b
1929
+ # --- name / address form ("call me Ken") ---
1930
+ | \b call \s+ me \b
1931
+ # --- durable habits (first person; questions pre-scrubbed) ---
1932
+ | \b i \s+ always \b
1933
+ | \b i \s+ usually \b
1934
+ | \b i \s+ normally \b
1935
+ | \b i \s+ never \b
1936
+ # --- explicit memory framing about the operator himself ---
1937
+ | \b remember \s+ that \s+ i \b
1938
+ | \b remind \s+ me \s+ that \s+ i \b
1939
+ )
1940
+ """
1941
+ )
1942
+
1943
+ # Terse, advisory. The model decides IN-SESSION whether this is a durable
1944
+ # operator-profile fact and, if so, calls retain itself (chat-legible),
1945
+ # tagging it `profile:ken` and routing it to the agent's OWN bank. Kept short
1946
+ # so it costs a handful of tokens on a false positive.
1947
+ _PROFILE_CAPTURE_NUDGE = (
1948
+ "<profile_capture_check>\n"
1949
+ "The latest user message states a durable fact about the operator himself "
1950
+ '(e.g. a preference "I prefer …", an identity/situation fact "my … is …", '
1951
+ 'a habit "I always …", or "remind me that I …") — NOT an instruction about '
1952
+ "how you should behave (that is the directive path). If it is a DURABLE "
1953
+ "fact worth remembering about him across sessions — not a one-off for this "
1954
+ "task — persist it NOW with mcp__hindsight__retain, in his own words, "
1955
+ 'tagged ["profile:ken"], into THIS AGENT\'S OWN bank (the default bank — do '
1956
+ "NOT route it to a shared or cross-agent person bank). If an equivalent "
1957
+ "fact is already stored, do not duplicate it. If it is only a passing "
1958
+ "remark, ignore this note and just answer.\n"
1959
+ "</profile_capture_check>"
1960
+ )
1961
+
1962
+
1963
+ def looks_like_profile_statement(text) -> bool:
1964
+ """Deterministic (regex-only) test for an operator durable-profile shape.
1965
+
1966
+ Question and attribution shapes ("do I prefer …?", "what's my …?", "you
1967
+ said I prefer …") are scrubbed BEFORE the positive match so they can't trip
1968
+ the first-person signals. Returns False on empty / non-string input. No
1969
+ model call — the model does the actual judgment in-session (RFC P3)."""
1970
+ if not isinstance(text, str) or not text.strip():
1971
+ return False
1972
+ scrubbed = _PROFILE_NUDGE_NEGATIVE_RE.sub(" ", text)
1973
+ return bool(_PROFILE_NUDGE_RE.search(scrubbed))
1974
+
1975
+
1581
1976
  def _combine_context(base, nudge) -> str:
1582
1977
  """Join the recall/directives context with the directive-capture nudge,
1583
1978
  skipping empties. Either may be None/empty. The nudge is kept OUT of the
@@ -1719,6 +2114,38 @@ def main():
1719
2114
  debug_log(config, "Prompt too short for recall, skipping")
1720
2115
  return
1721
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
+
1722
2149
  # Switchroom-local: skip recall on conversational acks.
1723
2150
  #
1724
2151
  # The 5-char short-circuit catches `ok`/`yes`/`no`/`ty` but passes
@@ -1780,6 +2207,42 @@ def main():
1780
2207
  nudge_block = _DIRECTIVE_CAPTURE_NUDGE
1781
2208
  debug_log(config, "Directive-capture nudge: inbound looks like a standing rule")
1782
2209
 
2210
+ # Switchroom P2 (memory-redesign RFC §5) — anchor recall to the ask-time
2211
+ # when the inbound prompt is time-relative, so the engine resolves "last
2212
+ # week"/"yesterday"/"on the 12th" against the real now and scores recency
2213
+ # from it. Deterministic regex on `_stripped` (same channel-stripped text
2214
+ # the nudge uses); no model call, microsecond cost, off the network path.
2215
+ # `None` when the prompt has no temporal phrase → the field is never added
2216
+ # to the recall body (byte-identical to pre-P2). The `recallQueryTimestamp`
2217
+ # key is an IN-CODE guard only — it has NO schema/scaffold/env surface, so
2218
+ # a settings.json edit would be clobbered on the next `switchroom apply`
2219
+ # (the plugin dir is re-copied from vendor/). Per RFC P2 the rollback is
2220
+ # "stop sending the field" = revert the commit; if a runtime knob is ever
2221
+ # wanted, wire it the full schema->scaffold->config->env way
2222
+ # `directiveCaptureNudge` is, not by hand-editing settings.json.
2223
+ query_timestamp = None
2224
+ if config.get("recallQueryTimestamp", True):
2225
+ query_timestamp = detect_query_timestamp(_stripped)
2226
+ if query_timestamp:
2227
+ debug_log(config, "Recall query_timestamp anchor: inbound is time-relative")
2228
+
2229
+ # RFC phase4 P3 — operator-profile capture nudge. Independent second nudge
2230
+ # class: deterministic (regex) detection of a first-person durable
2231
+ # self-statement by the operator; when it fires we append a terse advisory
2232
+ # telling the model to persist it with an explicit retain carrying a
2233
+ # `profile:ken` tag into the agent's OWN bank (RFC §0 constraint 2 forbids a
2234
+ # cross-agent person bank). Computed on `_stripped` like the directive
2235
+ # nudge, so the `<channel …>` wrapper never trips it. Both nudges may fire
2236
+ # on one turn (e.g. "I prefer …" is both preference and profile-shaped) —
2237
+ # they give different advice (create_directive vs profile:ken retain) and
2238
+ # are combined independently at emit time. On by default;
2239
+ # HINDSIGHT_PROFILE_CAPTURE_NUDGE=false (or memory.profile_capture_nudge:
2240
+ # false) turns it off. No model callsite (claude-native invariant).
2241
+ profile_nudge_block = None
2242
+ if config.get("profileCaptureNudge", True) and looks_like_profile_statement(_stripped):
2243
+ profile_nudge_block = _PROFILE_CAPTURE_NUDGE
2244
+ debug_log(config, "Profile-capture nudge: inbound states a durable operator fact")
2245
+
1783
2246
  session_id = hook_input.get("session_id") or ""
1784
2247
 
1785
2248
  # Switchroom #303 — push a "📚 recalling memories" status to the
@@ -1889,7 +2352,11 @@ def main():
1889
2352
  # #2848 — append the nudge to the cached context at emit time
1890
2353
  # (the cache stores nudge-free context; the nudge is re-derived
1891
2354
  # from the current prompt, so a hit can't replay a stale one).
1892
- _emit_cached_context(_combine_context(cached_context, nudge_block))
2355
+ _emit_cached_context(
2356
+ _combine_context(
2357
+ _combine_context(cached_context, nudge_block), profile_nudge_block
2358
+ )
2359
+ )
1893
2360
  _write_recall_log({
1894
2361
  "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1895
2362
  "session_id": (session_id or "")[:32],
@@ -1962,6 +2429,15 @@ def main():
1962
2429
  "active_topic_alias": active_topic_alias,
1963
2430
  "topic_filter_mode": _topic_filter_mode(),
1964
2431
  "directive_nudge": bool(nudge_block),
2432
+ # Switchroom P2 — the ISO ask-time anchor sent to recall, or
2433
+ # null when the inbound had no temporal phrase. Logged on cache
2434
+ # hits too (no bank ran, so nothing was sent this turn) for a
2435
+ # uniformly queryable schema and to measure the firing rate
2436
+ # from day one (precedent: `directive_nudge` above).
2437
+ "query_timestamp": query_timestamp,
2438
+ # RFC P3 — profile-capture nudge firing rate, measurable from
2439
+ # day one (mirrors the directive_nudge precedent).
2440
+ "profile_nudge": bool(profile_nudge_block),
1965
2441
  # E1 / PR8 (#3369) — no banks ran on a cache hit, so the
1966
2442
  # transcript fallback never fires; carry the zeroed fields for a
1967
2443
  # uniformly queryable schema.
@@ -2107,6 +2583,12 @@ def main():
2107
2583
 
2108
2584
  def _make_bank_task(target_bank_id, b_tags, b_tags_match, b_tag_groups, timeout_override=None):
2109
2585
  def _bank_task():
2586
+ # Switchroom P2 — include the ask-time anchor ONLY when the prompt
2587
+ # was time-relative. Passing it conditionally (not as `=None`) keeps
2588
+ # the client CALL — not just the wire body — byte-identical on the
2589
+ # common non-temporal turn, so a caller/fake with a narrower recall
2590
+ # signature is never handed a kwarg it did not have before.
2591
+ qts_kwarg = {"query_timestamp": query_timestamp} if query_timestamp else {}
2110
2592
  return client.recall(
2111
2593
  bank_id=target_bank_id,
2112
2594
  query=search_query,
@@ -2148,6 +2630,8 @@ def main():
2148
2630
  if timeout_override is None
2149
2631
  else timeout_override
2150
2632
  ),
2633
+ # Switchroom P2 — present only on a time-relative turn (see above).
2634
+ **qts_kwarg,
2151
2635
  )
2152
2636
  return _bank_task
2153
2637
 
@@ -2735,6 +3219,14 @@ def main():
2735
3219
  "topic_filter_mode": topic_filter_mode,
2736
3220
  "topic_dropped": topic_dropped,
2737
3221
  "directive_nudge": bool(nudge_block),
3222
+ # Switchroom P2 — the ISO ask-time anchor actually sent to recall this
3223
+ # turn (null when the inbound had no temporal phrase), so the field's
3224
+ # firing rate is measurable from day one against the RFC's falsification
3225
+ # window (precedent: `directive_nudge` above).
3226
+ "query_timestamp": query_timestamp,
3227
+ # RFC P3 — profile-capture nudge firing rate, measurable from day one
3228
+ # (mirrors the directive_nudge precedent).
3229
+ "profile_nudge": bool(profile_nudge_block),
2738
3230
  # Switchroom hindsight-leverage E1 / PR8 (#3369) — transcript-grep
2739
3231
  # fallback telemetry so its firing (and its bounds) are visible per turn
2740
3232
  # in recall_log.jsonl. `transcript_fallback` True only on an all-zero,
@@ -2775,10 +3267,19 @@ def main():
2775
3267
  # is precisely the turn on which the agent must not assume it remembers.
2776
3268
  # #3837: so is a set the score floor withheld entirely.
2777
3269
  if not directives_block and not memories_block and not transcript_fallback_block:
2778
- if degraded_block or withheld_block or nudge_block:
3270
+ if degraded_block or withheld_block or nudge_block or profile_nudge_block:
2779
3271
  _emit_cached_context(
2780
3272
  "\n\n".join(
2781
- [b for b in (degraded_block, withheld_block, nudge_block) if b]
3273
+ [
3274
+ b
3275
+ for b in (
3276
+ degraded_block,
3277
+ withheld_block,
3278
+ nudge_block,
3279
+ profile_nudge_block,
3280
+ )
3281
+ if b
3282
+ ]
2782
3283
  )
2783
3284
  )
2784
3285
  return
@@ -2808,6 +3309,16 @@ def main():
2808
3309
  LAST_RECALL_STATE,
2809
3310
  {
2810
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
+ ),
2811
3322
  "saved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
2812
3323
  "bank_id": bank_id,
2813
3324
  "result_count": len(results),
@@ -2836,9 +3347,13 @@ def main():
2836
3347
  "hookEventName": "UserPromptSubmit",
2837
3348
  "additionalContext": _combine_context(
2838
3349
  _combine_context(
2839
- _combine_context(degraded_block, withheld_block), context_message
3350
+ _combine_context(
3351
+ _combine_context(degraded_block, withheld_block),
3352
+ context_message,
3353
+ ),
3354
+ nudge_block,
2840
3355
  ),
2841
- nudge_block,
3356
+ profile_nudge_block,
2842
3357
  ),
2843
3358
  }
2844
3359
  }
@@ -109,18 +109,10 @@ def _human_turns(messages: list) -> int:
109
109
  )
110
110
 
111
111
 
112
- def _tail_after(messages: list, last_uuid: str | None) -> list:
113
- """Return the transcript entries AFTER the watermark anchor.
114
-
115
- No watermark, or a stale anchor compaction removed → the whole transcript is
116
- the gap (a safe re-upsert, never a skip).
117
- """
118
- if not last_uuid:
119
- return list(messages)
120
- for i, m in enumerate(messages):
121
- if isinstance(m, dict) and m.get("uuid") == last_uuid:
122
- return messages[i + 1:]
123
- return list(messages)
112
+ # The transcript tail-slice is shared with the incremental SessionEnd sweep
113
+ # (retain.py, memory-RFC P1); the single implementation lives in lib.watermark
114
+ # so there is only ever one copy of this reconcile/watermark-critical slice.
115
+ _tail_after = watermark.tail_after
124
116
 
125
117
 
126
118
  def _session_id_from_path(path: str) -> str: