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.
- package/bin/rules-sentinel-hook.sh +101 -0
- package/dist/agent-scheduler/index.js +7 -2
- package/dist/auth-broker/index.js +7 -2
- package/dist/cli/notion-write-pretool.mjs +7 -2
- package/dist/cli/switchroom.js +2601 -1009
- package/dist/host-control/main.js +8 -3
- package/dist/vault/approvals/kernel-server.js +7 -2
- package/dist/vault/broker/server.js +7 -2
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +9 -0
- package/profiles/_shared/delegation-golden-rule.md.hbs +2 -0
- package/skills/mental-model-curator/SKILL.md +187 -56
- package/telegram-plugin/dist/gateway/gateway.js +11 -6
- package/vendor/hindsight-memory/hooks/hooks.json +10 -0
- 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 +45 -7
- package/vendor/hindsight-memory/scripts/lib/recall_buffer.py +236 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +27 -0
- package/vendor/hindsight-memory/scripts/prefetch.py +156 -0
- package/vendor/hindsight-memory/scripts/recall.py +520 -5
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +4 -12
- package/vendor/hindsight-memory/scripts/retain.py +167 -28
- package/vendor/hindsight-memory/scripts/tests/test_config_retain_tool_calls_env.py +98 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +52 -0
- package/vendor/hindsight-memory/scripts/tests/test_incremental_sweep.py +293 -0
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +247 -0
- package/vendor/hindsight-memory/scripts/tests/test_profile_capture_nudge.py +335 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer.py +143 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +193 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_cap_truncation.py +133 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_junk_gate.py +168 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_no_score_floor.py +124 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_query_timestamp.py +376 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_delta.py +304 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_stop_hook_prefetch_gate.py +109 -0
|
@@ -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(
|
|
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
|
-
[
|
|
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(
|
|
3350
|
+
_combine_context(
|
|
3351
|
+
_combine_context(degraded_block, withheld_block),
|
|
3352
|
+
context_message,
|
|
3353
|
+
),
|
|
3354
|
+
nudge_block,
|
|
2840
3355
|
),
|
|
2841
|
-
|
|
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
|
-
|
|
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:
|