switchroom 0.21.13 → 0.21.15

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 (27) hide show
  1. package/dist/agent-scheduler/index.js +6 -2
  2. package/dist/auth-broker/index.js +6 -2
  3. package/dist/cli/notion-write-pretool.mjs +6 -2
  4. package/dist/cli/switchroom.js +1608 -699
  5. package/dist/host-control/main.js +22 -16
  6. package/dist/vault/approvals/kernel-server.js +6 -2
  7. package/dist/vault/broker/server.js +132 -11
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +9 -0
  10. package/profiles/_shared/agent-self-service.md.hbs +32 -86
  11. package/profiles/_shared/vault-protocol.md.hbs +17 -62
  12. package/profiles/default/CLAUDE.md.hbs +76 -74
  13. package/skills/switchroom-runtime/SKILL.md +32 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +10 -6
  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 +63 -7
  18. package/vendor/hindsight-memory/scripts/lib/watermark.py +27 -0
  19. package/vendor/hindsight-memory/scripts/recall.py +349 -5
  20. package/vendor/hindsight-memory/scripts/reconcile_tail.py +4 -12
  21. package/vendor/hindsight-memory/scripts/retain.py +59 -3
  22. package/vendor/hindsight-memory/scripts/tests/test_config_retain_tool_calls_env.py +98 -0
  23. package/vendor/hindsight-memory/scripts/tests/test_directives.py +98 -0
  24. package/vendor/hindsight-memory/scripts/tests/test_incremental_sweep.py +293 -0
  25. package/vendor/hindsight-memory/scripts/tests/test_profile_capture_nudge.py +335 -0
  26. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +53 -0
  27. package/vendor/hindsight-memory/scripts/tests/test_recall_query_timestamp.py +376 -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
 
@@ -77,6 +78,7 @@ from lib.directives import (
77
78
  count_omitted_directives,
78
79
  fetch_active_directives_cached,
79
80
  format_active_directives_block,
81
+ injected_directive_ids,
80
82
  )
81
83
  from lib.gateway_ipc import extract_chat_id_from_prompt, extract_topic_from_prompt, extract_user_from_prompt, update_placeholder
82
84
  from lib.parallel_recall import run_parallel
@@ -1577,6 +1579,257 @@ def looks_like_standing_rule(text) -> bool:
1577
1579
  return bool(_DIRECTIVE_NUDGE_RE.search(scrubbed))
1578
1580
 
1579
1581
 
1582
+ # ── Temporal-expression detection for the recall `query_timestamp` anchor ──
1583
+ # Switchroom P2 (memory-redesign RFC §5). When the inbound prompt asks a
1584
+ # time-relative question ("what did we work on last week", "on the 12th"),
1585
+ # the recall body carries an explicit `query_timestamp` anchor so the engine
1586
+ # resolves the relative expression and anchors recency scoring against the
1587
+ # real ask-time. The anchor is ALWAYS the current wall clock — that is the
1588
+ # documented semantics ("when the query is being asked, from the user's
1589
+ # perspective", https://hindsight.vectorize.io/developer/api/recall) — the
1590
+ # regex only GATES whether the field is sent, it does not resolve the phrase
1591
+ # itself (the engine does that server-side against the anchor we supply).
1592
+ #
1593
+ # Deterministic regex only — NO model call (claude-native invariant, same as
1594
+ # the directive-capture nudge above). The match is a cheap substring scan on
1595
+ # the already-stripped prompt, so it never extends the recall hook's critical
1596
+ # path (the 12s ceiling / parallel deadline live entirely on the network I/O
1597
+ # below). Detection is deliberately conservative: it requires a preposition or
1598
+ # quantifier around ambiguous tokens (bare weekday / month / "may") so an
1599
+ # ordinary sentence does not fire the field. A false negative merely omits an
1600
+ # anchor the server would default to anyway; a false positive sends the true
1601
+ # ask-time, which is the correct anchor regardless — so both error directions
1602
+ # degrade to today's behaviour.
1603
+ _TEMPORAL_EXPRESSION_RE = re.compile(
1604
+ r"""(?ix)
1605
+ (?:
1606
+ # --- absolute-relative day words ---
1607
+ \b (?: yesterday | tonight | tomorrow ) \b
1608
+ | \b last \s+ night \b
1609
+ # --- this/last/next + period ---
1610
+ | \b (?: this | last | next | past ) \s+
1611
+ (?: week | month | year | quarter | fortnight | weekend
1612
+ | morning | afternoon | evening | night | decade ) \b
1613
+ # --- earlier / other-day framings ---
1614
+ | \b the \s+ other \s+ (?: day | week | night ) \b
1615
+ | \b earlier \s+ (?: today | this \s+ (?: week | month | year ) ) \b
1616
+ | \b a \s+ (?: while | moment ) \s+ ago \b
1617
+ # --- "<N> <unit> ago" (worded or digit quantifier) ---
1618
+ | \b (?: a | an | one | two | three | four | five | six | seven | eight
1619
+ | nine | ten | \d+ | couple \s+ of | few ) \s+
1620
+ (?: second | minute | hour | day | week | month | year ) s? \s+ ago \b
1621
+ # --- weekday, only with a temporal preposition/qualifier ---
1622
+ | \b (?: this | last | next | on | since | by ) \s+
1623
+ (?: monday | tuesday | wednesday | thursday | friday
1624
+ | saturday | sunday ) \b
1625
+ # --- ordinal day-of-month ("on the 12th", "by the 3rd") ---
1626
+ | \b (?: on | by | since | before | after | around ) \s+ the \s+
1627
+ \d{1,2} (?: st | nd | rd | th ) \b
1628
+ # --- month name, only with a temporal preposition ---
1629
+ | \b (?: in | on | since | during | back \s+ in | early | late ) \s+
1630
+ (?: january | february | march | april | may | june | july
1631
+ | august | september | october | november | december ) \b
1632
+ )
1633
+ """
1634
+ )
1635
+
1636
+
1637
+ # RFC phase4 P3 — deterministic operator-profile capture nudge.
1638
+ #
1639
+ # Ken's first stated want is "save memories about him" (RFC §0 constraint 5a).
1640
+ # Auto-retain does store transcript facts, but there is no deterministic signal
1641
+ # that a durable *profile fact* about the operator himself just went by, so
1642
+ # capture-as-profile is left to model discretion — the same per-agent lottery
1643
+ # Stage A measured for directives. This mirrors the shipped directive-capture
1644
+ # nudge (recall.py #2848): a POSITIVE regex detects a first-person durable
1645
+ # self-statement ("I prefer …", "my … is …", "I always …", "remind me that
1646
+ # I …"); a NEGATIVE regex scrubs the two shapes that would otherwise misfire —
1647
+ # questions ("do I prefer …?", "what's my …?") and third-/second-party
1648
+ # attributions ("you said I prefer …", "she claims my …") — BEFORE the positive
1649
+ # match. On a hit the hook appends a terse advisory telling the model to persist
1650
+ # the fact with an explicit mcp__hindsight__retain carrying a `profile:ken` tag
1651
+ # into THIS AGENT'S OWN bank. Pure regex — NO model callsite (the claude-native
1652
+ # invariant forbids a classifier call); the model makes the judgment in-session
1653
+ # and calls retain itself (chat-legible). The hook NEVER writes on its own.
1654
+ #
1655
+ # On by default; operators opt out per-agent via memory.profile_capture_nudge
1656
+ # =false → HINDSIGHT_PROFILE_CAPTURE_NUDGE (recall.py falls back to True).
1657
+ #
1658
+ # ROUTING CONSTRAINT (RFC §0 constraint 2, §7 Q3): the fact goes to the agent's
1659
+ # OWN bank, NOT a shared/cross-agent person bank (ken-profile/lisa-profile).
1660
+ # The tag makes the facts cheap to find and retire later if Q3 is answered
1661
+ # differently. The `profile:ken` operator identity is intentionally literal —
1662
+ # this fleet has a single named operator (Ken); a multi-operator deployment
1663
+ # would parameterise the tag, which is out of scope for P3.
1664
+ #
1665
+ # CONSERVATISM (RFC §7 Q1, unanswered): the RFC flags that this regex set is
1666
+ # derived from an ASSUMPTION that the profile facts Ken wants are
1667
+ # preference-/identity-shaped. Until Q1 is answered from a real instance, the
1668
+ # positive set is deliberately TIGHT (favouring false negatives) — only clearly
1669
+ # durable first-person framings, not bare "I like"/"I use" reactions that are
1670
+ # usually one-off. If Q1's answer is not preference-shaped, this set is wrong
1671
+ # and should be re-derived before it is relied on.
1672
+ _PROFILE_NUDGE_NEGATIVE_RE = re.compile(
1673
+ r"""(?ix)
1674
+ (?:
1675
+ # --- interrogatives: a question ABOUT the operator is not a statement
1676
+ # OF a durable fact. Scrub the "<wh|aux> [do] I" / "<aux> my" lead so
1677
+ # the following "I prefer" / "my X is" can't fire. ---
1678
+ \b (?: what | which | where | when | why | how | do | did | does
1679
+ | should | would | could | can | are | is | was | were )
1680
+ \s+ (?: do \s+ )? i \b
1681
+ | \b what (?: ['’]? s | \s+ is | \s+ are ) \s+ my \b
1682
+ | \b (?: where | when | is | are | was | were ) \s+ my \b
1683
+ | \b remind \s+ me \s+ what \b
1684
+ # --- attributions: a fact the operator ascribes to someone else (or to
1685
+ # the agent) is not the operator stating his own profile. Scrub the
1686
+ # attributed clause up to the next clause boundary. Stop at a comma as
1687
+ # well as sentence-enders so a trailing real fact in the SAME sentence
1688
+ # ("she said X, my timezone is Melbourne") still reaches the positive
1689
+ # matcher instead of being swallowed. ---
1690
+ | \b (?: he | she | they | you | who | someone | everyone | nobody )
1691
+ \s+ (?: said | says | say | claimed | claims | thinks? | thought
1692
+ | told | mentions? | mentioned | asks? | asked | wants? | wanted
1693
+ | wrote | believes? | reckons? )
1694
+ \b [^.?!,]*
1695
+ # --- pleasantries that embed a bare always/never after "I". ---
1696
+ | \b i \s+ (?: always | never )
1697
+ \s+ (?: appreciate | enjoy | 'm \s+ happy | am \s+ happy | love \s+ working ) \b
1698
+ # --- "I'm a <hedge>" is a transient mood/quantifier, not "I'm a <noun>"
1699
+ # identity ("I'm a bit tired", "I'm a little confused"). Scrub the
1700
+ # "I'm a/an" lead so the identity arm can't fire on it. ---
1701
+ | \b i (?: \s+ am | \s* ['’] m ) \s+ (?: a | an )
1702
+ \s+ (?: bit | little | lot | tad | touch | bunch | couple | few
1703
+ | fan \b | big \s+ fan ) \b
1704
+ # --- "call me <phone-phrasing>" is a request, not a name form
1705
+ # ("call me back", "call me later"). Scrub so the name arm ("call me
1706
+ # Ken") is the only thing left that can fire. ---
1707
+ | \b call \s+ me \s+ (?: back | later | tomorrow | tonight | soon | again
1708
+ | when | if | once | after | before | at | on | in | asap ) \b
1709
+ )
1710
+ """
1711
+ )
1712
+
1713
+
1714
+ def detect_query_timestamp(text, now=None) -> "str | None":
1715
+ """Return an ISO 8601 ask-time anchor when ``text`` carries a temporal
1716
+ expression, else ``None``.
1717
+
1718
+ Deterministic and IO-free — a single regex scan, no model call and no
1719
+ clock dependency the caller cannot control (``now`` is injectable so the
1720
+ behaviour is unit-testable to the exact output string). When a temporal
1721
+ phrase is present the anchor returned is the CURRENT time, because
1722
+ ``query_timestamp`` is defined by the engine as *when the query is asked*,
1723
+ not the period the phrase names — the engine resolves the phrase against
1724
+ this anchor. ``None`` means "send no field", which keeps the recall body
1725
+ byte-identical to a pre-P2 client.
1726
+
1727
+ The anchor carries the LOCAL wall-clock offset (``datetime.now()`` +
1728
+ ``.astimezone()``), NOT UTC. This matters precisely on the dimension P2
1729
+ serves: for a Melbourne evening query "what did we do yesterday", a
1730
+ UTC-stamped anchor (``+00:00``) can be a calendar day ahead of the
1731
+ operator's real day, so the engine would resolve "yesterday"/"last
1732
+ week"/"on the 12th" against the wrong day. ``.astimezone()`` with no
1733
+ argument attaches the process TZ (the container clock is already
1734
+ Australia/Melbourne), which is the operator's actual day. Never
1735
+ ``timezone.utc`` here — that would re-introduce the off-by-one this fix
1736
+ removes.
1737
+
1738
+ Returns ``None`` on empty / non-string input so a caller can pass a raw
1739
+ prompt without a guard.
1740
+ """
1741
+ if not isinstance(text, str) or not text.strip():
1742
+ return None
1743
+ if not _TEMPORAL_EXPRESSION_RE.search(text):
1744
+ return None
1745
+ anchor = now if now is not None else datetime.now().astimezone()
1746
+ return anchor.isoformat()
1747
+
1748
+
1749
+ _PROFILE_NUDGE_RE = re.compile(
1750
+ r"""(?ix)
1751
+ (?:
1752
+ # --- stated preferences / tastes ---
1753
+ \b i \s+ prefer \b
1754
+ | \b i['’]? d \s+ prefer \b
1755
+ | \b my \s+ preference \s+ (?: is | are ) \b
1756
+ | \b i \s+ (?: hate | love | despise | adore | dislike
1757
+ | can ['’]? t \s+ stand ) \b
1758
+ # --- durable self-facts: "my <ATTRIBUTE> is/are/'s <value>".
1759
+ # ATTRIBUTE is a TIGHT allow-list of durable identity attributes.
1760
+ # A free noun ("my build is failing", "my container is down", "my PR
1761
+ # is ready") is transient dev state, not a profile fact — firing on
1762
+ # it inverts the RFC's favour-false-negatives constraint on this very
1763
+ # agent (klanker), so the free-`\w+` arm is deliberately NOT used. ---
1764
+ | \b my \s+
1765
+ (?: name | e-?mail | timezone | time \s+ zone | address
1766
+ | (?: phone | mobile | cell ) (?: \s+ number )? | number
1767
+ | birthday | birthdate | dob | anniversary | age | pronouns?
1768
+ | handle | username | nickname | initials
1769
+ | employer | company | partner | wife | husband | spouse
1770
+ | girlfriend | boyfriend | kids? | children | child | son
1771
+ | daughter | sister | brother | mother | father | mom | dad
1772
+ | parents | dog | cat | pet | diet | allerg(?: y | ies )
1773
+ | location | city | country | hometown )
1774
+ (?: \s+ \w+ )?
1775
+ (?: \s+ (?: is | are ) | \s* ['’] s ) \b
1776
+ # --- identity / situation ---
1777
+ | \b i \s+ live \s+ (?: in | at | near ) \b
1778
+ | \b i \s+ work \s+ (?: at | as | for | in ) \b
1779
+ | \b i (?: \s+ am | \s* ['’] m ) \s+ (?: allergic \s+ to
1780
+ | based \s+ (?: in | at ) | from | located \s+ in
1781
+ | vegetarian | vegan | pescatarian | teetotal ) \b
1782
+ | \b i (?: \s+ am | \s* ['’] m ) \s+ (?: a | an ) \s+ \w+
1783
+ # --- dietary / abstention identity ("I don't eat meat") ---
1784
+ | \b i \s+ (?: do \s* n['’]? t | don['’]? t | do \s+ not )
1785
+ \s+ (?: eat | drink | use | own | drive ) \b
1786
+ # --- name / address form ("call me Ken") ---
1787
+ | \b call \s+ me \b
1788
+ # --- durable habits (first person; questions pre-scrubbed) ---
1789
+ | \b i \s+ always \b
1790
+ | \b i \s+ usually \b
1791
+ | \b i \s+ normally \b
1792
+ | \b i \s+ never \b
1793
+ # --- explicit memory framing about the operator himself ---
1794
+ | \b remember \s+ that \s+ i \b
1795
+ | \b remind \s+ me \s+ that \s+ i \b
1796
+ )
1797
+ """
1798
+ )
1799
+
1800
+ # Terse, advisory. The model decides IN-SESSION whether this is a durable
1801
+ # operator-profile fact and, if so, calls retain itself (chat-legible),
1802
+ # tagging it `profile:ken` and routing it to the agent's OWN bank. Kept short
1803
+ # so it costs a handful of tokens on a false positive.
1804
+ _PROFILE_CAPTURE_NUDGE = (
1805
+ "<profile_capture_check>\n"
1806
+ "The latest user message states a durable fact about the operator himself "
1807
+ '(e.g. a preference "I prefer …", an identity/situation fact "my … is …", '
1808
+ 'a habit "I always …", or "remind me that I …") — NOT an instruction about '
1809
+ "how you should behave (that is the directive path). If it is a DURABLE "
1810
+ "fact worth remembering about him across sessions — not a one-off for this "
1811
+ "task — persist it NOW with mcp__hindsight__retain, in his own words, "
1812
+ 'tagged ["profile:ken"], into THIS AGENT\'S OWN bank (the default bank — do '
1813
+ "NOT route it to a shared or cross-agent person bank). If an equivalent "
1814
+ "fact is already stored, do not duplicate it. If it is only a passing "
1815
+ "remark, ignore this note and just answer.\n"
1816
+ "</profile_capture_check>"
1817
+ )
1818
+
1819
+
1820
+ def looks_like_profile_statement(text) -> bool:
1821
+ """Deterministic (regex-only) test for an operator durable-profile shape.
1822
+
1823
+ Question and attribution shapes ("do I prefer …?", "what's my …?", "you
1824
+ said I prefer …") are scrubbed BEFORE the positive match so they can't trip
1825
+ the first-person signals. Returns False on empty / non-string input. No
1826
+ model call — the model does the actual judgment in-session (RFC P3)."""
1827
+ if not isinstance(text, str) or not text.strip():
1828
+ return False
1829
+ scrubbed = _PROFILE_NUDGE_NEGATIVE_RE.sub(" ", text)
1830
+ return bool(_PROFILE_NUDGE_RE.search(scrubbed))
1831
+
1832
+
1580
1833
  def _combine_context(base, nudge) -> str:
1581
1834
  """Join the recall/directives context with the directive-capture nudge,
1582
1835
  skipping empties. Either may be None/empty. The nudge is kept OUT of the
@@ -1779,6 +2032,42 @@ def main():
1779
2032
  nudge_block = _DIRECTIVE_CAPTURE_NUDGE
1780
2033
  debug_log(config, "Directive-capture nudge: inbound looks like a standing rule")
1781
2034
 
2035
+ # Switchroom P2 (memory-redesign RFC §5) — anchor recall to the ask-time
2036
+ # when the inbound prompt is time-relative, so the engine resolves "last
2037
+ # week"/"yesterday"/"on the 12th" against the real now and scores recency
2038
+ # from it. Deterministic regex on `_stripped` (same channel-stripped text
2039
+ # the nudge uses); no model call, microsecond cost, off the network path.
2040
+ # `None` when the prompt has no temporal phrase → the field is never added
2041
+ # to the recall body (byte-identical to pre-P2). The `recallQueryTimestamp`
2042
+ # key is an IN-CODE guard only — it has NO schema/scaffold/env surface, so
2043
+ # a settings.json edit would be clobbered on the next `switchroom apply`
2044
+ # (the plugin dir is re-copied from vendor/). Per RFC P2 the rollback is
2045
+ # "stop sending the field" = revert the commit; if a runtime knob is ever
2046
+ # wanted, wire it the full schema->scaffold->config->env way
2047
+ # `directiveCaptureNudge` is, not by hand-editing settings.json.
2048
+ query_timestamp = None
2049
+ if config.get("recallQueryTimestamp", True):
2050
+ query_timestamp = detect_query_timestamp(_stripped)
2051
+ if query_timestamp:
2052
+ debug_log(config, "Recall query_timestamp anchor: inbound is time-relative")
2053
+
2054
+ # RFC phase4 P3 — operator-profile capture nudge. Independent second nudge
2055
+ # class: deterministic (regex) detection of a first-person durable
2056
+ # self-statement by the operator; when it fires we append a terse advisory
2057
+ # telling the model to persist it with an explicit retain carrying a
2058
+ # `profile:ken` tag into the agent's OWN bank (RFC §0 constraint 2 forbids a
2059
+ # cross-agent person bank). Computed on `_stripped` like the directive
2060
+ # nudge, so the `<channel …>` wrapper never trips it. Both nudges may fire
2061
+ # on one turn (e.g. "I prefer …" is both preference and profile-shaped) —
2062
+ # they give different advice (create_directive vs profile:ken retain) and
2063
+ # are combined independently at emit time. On by default;
2064
+ # HINDSIGHT_PROFILE_CAPTURE_NUDGE=false (or memory.profile_capture_nudge:
2065
+ # false) turns it off. No model callsite (claude-native invariant).
2066
+ profile_nudge_block = None
2067
+ if config.get("profileCaptureNudge", True) and looks_like_profile_statement(_stripped):
2068
+ profile_nudge_block = _PROFILE_CAPTURE_NUDGE
2069
+ debug_log(config, "Profile-capture nudge: inbound states a durable operator fact")
2070
+
1782
2071
  session_id = hook_input.get("session_id") or ""
1783
2072
 
1784
2073
  # Switchroom #303 — push a "📚 recalling memories" status to the
@@ -1888,7 +2177,11 @@ def main():
1888
2177
  # #2848 — append the nudge to the cached context at emit time
1889
2178
  # (the cache stores nudge-free context; the nudge is re-derived
1890
2179
  # from the current prompt, so a hit can't replay a stale one).
1891
- _emit_cached_context(_combine_context(cached_context, nudge_block))
2180
+ _emit_cached_context(
2181
+ _combine_context(
2182
+ _combine_context(cached_context, nudge_block), profile_nudge_block
2183
+ )
2184
+ )
1892
2185
  _write_recall_log({
1893
2186
  "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1894
2187
  "session_id": (session_id or "")[:32],
@@ -1900,6 +2193,10 @@ def main():
1900
2193
  "directive_count": None,
1901
2194
  # No directives block is built on a cache hit.
1902
2195
  "directives_omitted": None,
2196
+ # Same reason — a cache hit replays a formatted context
2197
+ # block, not a fetched directive list, so there is nothing
2198
+ # to derive the injected id set from this turn.
2199
+ "directive_ids": None,
1903
2200
  "demoted_count": 0,
1904
2201
  # #3837 score-floor fields, present for a uniformly queryable
1905
2202
  # schema. A cache hit replays a formatted context block, not a
@@ -1957,6 +2254,15 @@ def main():
1957
2254
  "active_topic_alias": active_topic_alias,
1958
2255
  "topic_filter_mode": _topic_filter_mode(),
1959
2256
  "directive_nudge": bool(nudge_block),
2257
+ # Switchroom P2 — the ISO ask-time anchor sent to recall, or
2258
+ # null when the inbound had no temporal phrase. Logged on cache
2259
+ # hits too (no bank ran, so nothing was sent this turn) for a
2260
+ # uniformly queryable schema and to measure the firing rate
2261
+ # from day one (precedent: `directive_nudge` above).
2262
+ "query_timestamp": query_timestamp,
2263
+ # RFC P3 — profile-capture nudge firing rate, measurable from
2264
+ # day one (mirrors the directive_nudge precedent).
2265
+ "profile_nudge": bool(profile_nudge_block),
1960
2266
  # E1 / PR8 (#3369) — no banks ran on a cache hit, so the
1961
2267
  # transcript fallback never fires; carry the zeroed fields for a
1962
2268
  # uniformly queryable schema.
@@ -2102,6 +2408,12 @@ def main():
2102
2408
 
2103
2409
  def _make_bank_task(target_bank_id, b_tags, b_tags_match, b_tag_groups, timeout_override=None):
2104
2410
  def _bank_task():
2411
+ # Switchroom P2 — include the ask-time anchor ONLY when the prompt
2412
+ # was time-relative. Passing it conditionally (not as `=None`) keeps
2413
+ # the client CALL — not just the wire body — byte-identical on the
2414
+ # common non-temporal turn, so a caller/fake with a narrower recall
2415
+ # signature is never handed a kwarg it did not have before.
2416
+ qts_kwarg = {"query_timestamp": query_timestamp} if query_timestamp else {}
2105
2417
  return client.recall(
2106
2418
  bank_id=target_bank_id,
2107
2419
  query=search_query,
@@ -2143,6 +2455,8 @@ def main():
2143
2455
  if timeout_override is None
2144
2456
  else timeout_override
2145
2457
  ),
2458
+ # Switchroom P2 — present only on a time-relative turn (see above).
2459
+ **qts_kwarg,
2146
2460
  )
2147
2461
  return _bank_task
2148
2462
 
@@ -2620,6 +2934,15 @@ def main():
2620
2934
  # never reached the agent. >0 here means the bank is over cap and the
2621
2935
  # doctor's directive-count check will be FAILing too.
2622
2936
  "directives_omitted": count_omitted_directives(directives),
2937
+ # Switchroom memory-redesign step 1 (E-45 recommendation (b)) — WHICH
2938
+ # directives actually reached the prompt this turn, in the same
2939
+ # priority-descending order `format_active_directives_block` rendered
2940
+ # them. `directive_count`/`directives_omitted` above are volume-only
2941
+ # (how many fetched, how many the cap dropped); this is the queryable
2942
+ # record of identity, so directive exposure — including "never once
2943
+ # injected" — is measurable before any change to what gets injected.
2944
+ # Purely additive: does not change `directives_block` composition.
2945
+ "directive_ids": injected_directive_ids(directives),
2623
2946
  "demoted_count": demoted_count,
2624
2947
  # Switchroom #3837 — score-floor telemetry, deliberately alongside the
2625
2948
  # `injected_score_*` fields below: those are what the floor was derived
@@ -2721,6 +3044,14 @@ def main():
2721
3044
  "topic_filter_mode": topic_filter_mode,
2722
3045
  "topic_dropped": topic_dropped,
2723
3046
  "directive_nudge": bool(nudge_block),
3047
+ # Switchroom P2 — the ISO ask-time anchor actually sent to recall this
3048
+ # turn (null when the inbound had no temporal phrase), so the field's
3049
+ # firing rate is measurable from day one against the RFC's falsification
3050
+ # window (precedent: `directive_nudge` above).
3051
+ "query_timestamp": query_timestamp,
3052
+ # RFC P3 — profile-capture nudge firing rate, measurable from day one
3053
+ # (mirrors the directive_nudge precedent).
3054
+ "profile_nudge": bool(profile_nudge_block),
2724
3055
  # Switchroom hindsight-leverage E1 / PR8 (#3369) — transcript-grep
2725
3056
  # fallback telemetry so its firing (and its bounds) are visible per turn
2726
3057
  # in recall_log.jsonl. `transcript_fallback` True only on an all-zero,
@@ -2761,10 +3092,19 @@ def main():
2761
3092
  # is precisely the turn on which the agent must not assume it remembers.
2762
3093
  # #3837: so is a set the score floor withheld entirely.
2763
3094
  if not directives_block and not memories_block and not transcript_fallback_block:
2764
- if degraded_block or withheld_block or nudge_block:
3095
+ if degraded_block or withheld_block or nudge_block or profile_nudge_block:
2765
3096
  _emit_cached_context(
2766
3097
  "\n\n".join(
2767
- [b for b in (degraded_block, withheld_block, nudge_block) if b]
3098
+ [
3099
+ b
3100
+ for b in (
3101
+ degraded_block,
3102
+ withheld_block,
3103
+ nudge_block,
3104
+ profile_nudge_block,
3105
+ )
3106
+ if b
3107
+ ]
2768
3108
  )
2769
3109
  )
2770
3110
  return
@@ -2822,9 +3162,13 @@ def main():
2822
3162
  "hookEventName": "UserPromptSubmit",
2823
3163
  "additionalContext": _combine_context(
2824
3164
  _combine_context(
2825
- _combine_context(degraded_block, withheld_block), context_message
3165
+ _combine_context(
3166
+ _combine_context(degraded_block, withheld_block),
3167
+ context_message,
3168
+ ),
3169
+ nudge_block,
2826
3170
  ),
2827
- nudge_block,
3171
+ profile_nudge_block,
2828
3172
  ),
2829
3173
  }
2830
3174
  }
@@ -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:
@@ -366,6 +366,7 @@ def select_retain_window(
366
366
  overlap_turns: int,
367
367
  all_messages: list,
368
368
  force: bool = False,
369
+ since_uuid: str | None = None,
369
370
  ) -> tuple:
370
371
  """Decide which messages to retain and whether to send as a full window.
371
372
 
@@ -396,6 +397,17 @@ def select_retain_window(
396
397
  the whole session even if per-turn windowing had an edge. This costs a
397
398
  full sweep only ONCE per session (at end), not per turn.
398
399
 
400
+ ``since_uuid`` (memory-RFC P1) makes that final sweep INCREMENTAL: when
401
+ ``force=True`` and a committed watermark uuid is supplied, only the
402
+ transcript tail after it is retained, instead of re-storing the whole
403
+ session on top of the per-window documents that already carry it. The value
404
+ is resolved by the caller (``run_retain`` reads the watermark, wrapped) and
405
+ passed in — this function stays pure and IO-free. ``since_uuid=None``
406
+ reproduces the whole-session sweep exactly, which is what the vendor
407
+ full-session path and the ``retainEveryNTurns==1`` path pass. The slice
408
+ degrades to the whole transcript on a missing/compacted anchor
409
+ (``watermark.tail_after``), so a forced sweep never emits an empty slice.
410
+
399
411
  Durability invariant (jtbd-memory-survives-restart UAT): the window
400
412
  always extends to the END of the transcript (``slice_last_turns_by_user_boundary``
401
413
  returns ``messages[start:]``), so the turn that just completed — the one
@@ -412,8 +424,21 @@ def select_retain_window(
412
424
  window_turns = max(retain_every_n, 1) + overlap_turns
413
425
  messages_to_retain = slice_last_turns_by_user_boundary(all_messages, window_turns)
414
426
  return messages_to_retain, True
427
+ # Forced (SessionEnd) chunked sweep with a committed watermark uuid: retain
428
+ # only the transcript tail after it, instead of re-storing the whole session
429
+ # on top of the N per-window documents that already carry the same content
430
+ # (memory-RFC P1). ``since_uuid`` is None on the vendor full-session path and
431
+ # at retainEveryNTurns==1 (the caller withholds it there — see run_retain),
432
+ # which preserves today's whole-session behaviour byte-for-byte.
433
+ #
434
+ # tail_after is PURE and IO-free (the watermark READ happens in run_retain,
435
+ # wrapped) and degrades to the whole transcript on a missing/compacted
436
+ # anchor, so this branch can never emit an empty slice into the retain seam.
437
+ if force and since_uuid is not None:
438
+ return watermark.tail_after(all_messages, since_uuid), True
415
439
  # Full session: vendor full-session mode, OR a forced (SessionEnd) chunked
416
- # sweep. Retain all messages, always as a full window.
440
+ # sweep with no watermark to slice against. Retain all messages, always as a
441
+ # full window.
417
442
  return list(all_messages), True
418
443
 
419
444
 
@@ -798,8 +823,37 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
798
823
  # select_retain_window() for the switchroom-divergence rationale
799
824
  # (Phase 6b: chunked window-slicing now works at retainEveryNTurns=1).
800
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
+ since_uuid = None
842
+ if force and retain_mode == "chunked" and retain_every_n > 1:
843
+ try:
844
+ _wm = watermark.load(session_id)
845
+ since_uuid = _wm.get("last_uuid") if _wm else None
846
+ except Exception as e: # noqa: BLE001 - degrade, never raise into this seam
847
+ print(
848
+ "[Hindsight] watermark read failed for incremental sweep; "
849
+ f"falling back to full-session sweep: {e}",
850
+ file=sys.stderr,
851
+ )
852
+ since_uuid = None
853
+
801
854
  messages_to_retain, retain_full_window = select_retain_window(
802
- retain_mode, retain_every_n, overlap_turns, all_messages, force=force
855
+ retain_mode, retain_every_n, overlap_turns, all_messages, force=force,
856
+ since_uuid=since_uuid,
803
857
  )
804
858
  if retain_mode == "chunked" and not force:
805
859
  window_turns = max(retain_every_n, 1) + overlap_turns
@@ -808,9 +862,11 @@ def run_retain(hook_input: dict, force: bool = False) -> dict:
808
862
  f"Chunked retain firing (window: {window_turns} human turns, {len(messages_to_retain)} messages)",
809
863
  )
810
864
  elif retain_mode == "chunked" and force:
865
+ _swept = "incremental (tail after watermark)" if since_uuid is not None else "full-session"
811
866
  debug_log(
812
867
  config,
813
- f"Chunked retain, forced full-session sweep (SessionEnd): {len(all_messages)} messages",
868
+ f"Chunked retain, forced {_swept} sweep (SessionEnd): "
869
+ f"{len(messages_to_retain)}/{len(all_messages)} messages",
814
870
  )
815
871
  else:
816
872
  debug_log(config, f"Full session retain: {len(all_messages)} messages")
@@ -0,0 +1,98 @@
1
+ """Switchroom — `retainToolCalls` must have an env channel, default unchanged.
2
+
3
+ RFC memory-redesign P4. `retainToolCalls` (whether retain stores tool_use
4
+ inputs + tool_result content) had a DEFAULTS entry of True but NO entry in
5
+ `ENV_OVERRIDES` and no scaffold stamp, so an operator could not reach it: env
6
+ is the TOP of the plugin's config precedence chain (DEFAULTS -> settings.json
7
+ -> ~/.hindsight/claude-code.json -> env), and without an env key
8
+ `HINDSIGHT_RETAIN_TOOL_CALLS` could not steer the plugin at all — including a
9
+ docker-exec'd retain/backfill that does not inherit the supervised settings.
10
+
11
+ This PR ships the SETTER ONLY. The invariants under test:
12
+
13
+ 1. `HINDSIGHT_RETAIN_TOOL_CALLS` is wired in `ENV_OVERRIDES` and maps to the
14
+ `retainToolCalls` config key.
15
+ 2. The shipped default is unchanged — `True` — so absent any override the
16
+ fleet behaves byte-identically (the load resolves True with no env set).
17
+ 3. Setting the env `false` actually LANDS as Python `False` over the True
18
+ default (proving `false` is reachable, which is the whole point of the
19
+ knob), and `true`/`1`/`yes` resolve back to True.
20
+
21
+ Stdlib-only.
22
+ """
23
+
24
+ import os
25
+ import sys
26
+ import unittest
27
+ from unittest import mock
28
+
29
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
30
+ if SCRIPTS_DIR not in sys.path:
31
+ sys.path.insert(0, SCRIPTS_DIR)
32
+
33
+ from lib.config import DEFAULTS, ENV_OVERRIDES, load_config # noqa: E402
34
+
35
+ ENV_NAME = "HINDSIGHT_RETAIN_TOOL_CALLS"
36
+ CONFIG_KEY = "retainToolCalls"
37
+
38
+
39
+ def _load_with(env):
40
+ """load_config() with a hermetic environment (no plugin/user settings)."""
41
+ with mock.patch.dict(os.environ, env, clear=True):
42
+ os.environ["CLAUDE_PLUGIN_ROOT"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
43
+ os.environ["HOME"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
44
+ return load_config()
45
+
46
+
47
+ class RetainToolCallsHasAChannel(unittest.TestCase):
48
+ def test_env_name_is_wired(self):
49
+ self.assertIn(
50
+ ENV_NAME,
51
+ ENV_OVERRIDES,
52
+ f"{ENV_NAME} exported nowhere / never read — the drift P4 closes",
53
+ )
54
+
55
+ def test_env_name_maps_to_the_expected_config_key(self):
56
+ self.assertEqual(ENV_OVERRIDES[ENV_NAME][0], CONFIG_KEY)
57
+
58
+ def test_env_name_is_typed_bool(self):
59
+ self.assertIs(ENV_OVERRIDES[ENV_NAME][1], bool)
60
+
61
+ def test_target_key_exists_in_defaults(self):
62
+ self.assertIn(CONFIG_KEY, DEFAULTS)
63
+
64
+
65
+ class DefaultIsUnchanged(unittest.TestCase):
66
+ """The critical P4 invariant: default stays True, byte-identical fleet."""
67
+
68
+ def test_shipped_default_is_true(self):
69
+ self.assertIs(DEFAULTS[CONFIG_KEY], True)
70
+
71
+ def test_no_env_reproduces_the_true_default(self):
72
+ cfg = _load_with({})
73
+ self.assertIs(cfg[CONFIG_KEY], True)
74
+
75
+
76
+ class ValuesActuallyLand(unittest.TestCase):
77
+ """The setter must be reachable: env `false` lands as Python False."""
78
+
79
+ def test_false_env_overrides_the_true_default(self):
80
+ cfg = _load_with({ENV_NAME: "false"})
81
+ self.assertIs(cfg[CONFIG_KEY], False)
82
+ self.assertNotEqual(cfg[CONFIG_KEY], DEFAULTS[CONFIG_KEY])
83
+
84
+ def test_zero_and_no_also_resolve_false(self):
85
+ for falsey in ("0", "no", "False"):
86
+ with self.subTest(value=falsey):
87
+ cfg = _load_with({ENV_NAME: falsey})
88
+ self.assertIs(cfg[CONFIG_KEY], False)
89
+
90
+ def test_true_env_resolves_true(self):
91
+ for truthy in ("true", "1", "yes", "TRUE"):
92
+ with self.subTest(value=truthy):
93
+ cfg = _load_with({ENV_NAME: truthy})
94
+ self.assertIs(cfg[CONFIG_KEY], True)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()