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.
- package/dist/agent-scheduler/index.js +6 -2
- package/dist/auth-broker/index.js +6 -2
- package/dist/cli/notion-write-pretool.mjs +6 -2
- package/dist/cli/switchroom.js +1608 -699
- package/dist/host-control/main.js +22 -16
- package/dist/vault/approvals/kernel-server.js +6 -2
- package/dist/vault/broker/server.js +132 -11
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +9 -0
- package/profiles/_shared/agent-self-service.md.hbs +32 -86
- package/profiles/_shared/vault-protocol.md.hbs +17 -62
- package/profiles/default/CLAUDE.md.hbs +76 -74
- package/skills/switchroom-runtime/SKILL.md +32 -0
- package/telegram-plugin/dist/gateway/gateway.js +10 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +22 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +63 -7
- package/vendor/hindsight-memory/scripts/lib/watermark.py +27 -0
- package/vendor/hindsight-memory/scripts/recall.py +349 -5
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +4 -12
- package/vendor/hindsight-memory/scripts/retain.py +59 -3
- package/vendor/hindsight-memory/scripts/tests/test_config_retain_tool_calls_env.py +98 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +98 -0
- package/vendor/hindsight-memory/scripts/tests/test_incremental_sweep.py +293 -0
- package/vendor/hindsight-memory/scripts/tests/test_profile_capture_nudge.py +335 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +53 -0
- 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(
|
|
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
|
-
[
|
|
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(
|
|
3165
|
+
_combine_context(
|
|
3166
|
+
_combine_context(degraded_block, withheld_block),
|
|
3167
|
+
context_message,
|
|
3168
|
+
),
|
|
3169
|
+
nudge_block,
|
|
2826
3170
|
),
|
|
2827
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
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()
|