switchroom 0.18.28 → 0.18.29
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/handoff-briefing.sh +8 -1
- package/dist/auth-broker/index.js +0 -57
- package/dist/cli/switchroom.js +501 -497
- package/dist/host-control/main.js +1 -58
- package/dist/vault/approvals/kernel-server.js +0 -57
- package/dist/vault/broker/server.js +0 -57
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +37 -19
- package/telegram-plugin/dist/gateway/gateway.js +578 -580
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +458 -385
- package/telegram-plugin/gateway/model-command.ts +227 -602
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/shared/local-time.ts +56 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +86 -59
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -0
- package/telegram-plugin/tests/model-command.test.ts +317 -1535
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +40 -6
- package/vendor/hindsight-memory/tests/test_content.py +28 -7
|
@@ -629,6 +629,40 @@ describe('selectFlushDeliveryText — deliver the terminal answer, strip only na
|
|
|
629
629
|
expect(selectFlushDeliveryText([])).toBe('')
|
|
630
630
|
})
|
|
631
631
|
|
|
632
|
+
// #3276 guard 8 — the NARRATION_OPENER regex alone misses progress narration
|
|
633
|
+
// that does NOT open with "Let me…/I'll…" but trails off into an ellipsis or
|
|
634
|
+
// colon ("Checking now…"). Pre-fix, `isNarrationBlock` returned false for it,
|
|
635
|
+
// so the whole `narration\n\nanswer` blob was delivered. FAILS pre-fix.
|
|
636
|
+
it('strips a non-opener progress narration ("Checking now…") that precedes the answer', () => {
|
|
637
|
+
const out = selectFlushDeliveryText(['Checking now…', 'The build is green.'])
|
|
638
|
+
expect(out).toBe('The build is green.')
|
|
639
|
+
expect(out).not.toContain('Checking now')
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
it('strips a colon-terminated progress narration ("Pulling the numbers:")', () => {
|
|
643
|
+
const out = selectFlushDeliveryText(['Pulling the numbers:', 'Revenue was 4.2M.'])
|
|
644
|
+
expect(out).toBe('Revenue was 4.2M.')
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('still delivers a SHORT terminal answer after progress narration (no min-char re-gate)', () => {
|
|
648
|
+
// "yes, done" is well under FLUSH_SUBSTANTIVE_MIN_CHARS and MUST still deliver.
|
|
649
|
+
const out = selectFlushDeliveryText(['Looking into that...', 'yes, done'])
|
|
650
|
+
expect(out).toBe('yes, done')
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
// #3276 finding 7 — the narration heuristic only ever strips PRECEDING blocks;
|
|
654
|
+
// the terminal answer block is always preserved. A colon-terminated line that
|
|
655
|
+
// IS the whole answer (e.g. a lead-in the model never continued) must NOT be
|
|
656
|
+
// dropped, whether it stands alone or is the terminal block after narration.
|
|
657
|
+
it('never drops a colon-terminated line that IS the whole answer (single block)', () => {
|
|
658
|
+
expect(selectFlushDeliveryText(['Here are the results:'])).toBe('Here are the results:')
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it('keeps a colon-terminated TERMINAL block (it is the answer, not narration)', () => {
|
|
662
|
+
const out = selectFlushDeliveryText(['Checking now…', 'Here are the results:'])
|
|
663
|
+
expect(out).toBe('Here are the results:')
|
|
664
|
+
})
|
|
665
|
+
|
|
632
666
|
it('decideTurnFlush delivers the narrowed answer, not the whole blob', () => {
|
|
633
667
|
const decision = decideTurnFlush({
|
|
634
668
|
chatId: 'chat1',
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
* account-swap, never by a downgrade.
|
|
21
21
|
*
|
|
22
22
|
* There is NO automatic return to the premium model. The `/model` override is
|
|
23
|
-
* SESSION-SCOPED
|
|
24
|
-
*
|
|
25
|
-
* downgrade SIGTERM
|
|
23
|
+
* SESSION-SCOPED: rev 5 (reference/rfcs/session-model-stickiness.md §0.05) makes
|
|
24
|
+
* every switch a consume-once `.session-model` carrier relaunch, so the override
|
|
25
|
+
* dies on the downgrade SIGTERM (the carrier was consumed on its own apply-boot).
|
|
26
|
+
* The downgrade writes a consume-once `.session-model`
|
|
26
27
|
* carrier for the CONFIGURED DEFAULT: start.sh applies+deletes it on the resume
|
|
27
28
|
* boot, and every subsequent restart boots the configured default too. The
|
|
28
29
|
* premium model is never restored on its own — the user must re-issue
|
|
@@ -193,8 +193,32 @@ export function selectFlushDeliveryText(blocks: string[]): string {
|
|
|
193
193
|
const NARRATION_OPENER =
|
|
194
194
|
/^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* #3276 guard 8 — the `NARRATION_OPENER` regex alone misses common progress
|
|
198
|
+
* narration that opens with a gerund/present-continuous verb and trails off
|
|
199
|
+
* into an ellipsis or colon: "Checking now…", "Pulling the numbers:",
|
|
200
|
+
* "Looking into that…". Relying on the opener regex leaked those blocks into
|
|
201
|
+
* the delivered answer. This recognises a NON-terminal narration line
|
|
202
|
+
* deterministically, WITHOUT re-gating on a min-char floor (a short real
|
|
203
|
+
* answer like "Yes, done." must still deliver): a single short line that ends
|
|
204
|
+
* with an ellipsis or a colon is progress narration, not the terminal answer.
|
|
205
|
+
*
|
|
206
|
+
* Kept conservative on purpose — only a SINGLE-line block (no internal
|
|
207
|
+
* paragraph) under the substantive floor, ending in `…` / `...` / `:`, so a
|
|
208
|
+
* genuine multi-paragraph answer that happens to end a paragraph with a colon
|
|
209
|
+
* is never mistaken for narration.
|
|
210
|
+
*/
|
|
211
|
+
const NARRATION_TRAILER = /(?:\.{3}|…|:)\s*$/
|
|
212
|
+
|
|
213
|
+
function isTrailingNarrationLine(block: string): boolean {
|
|
214
|
+
const t = block.trim()
|
|
215
|
+
if (t.length === 0 || t.length >= FLUSH_SUBSTANTIVE_MIN_CHARS) return false
|
|
216
|
+
if (t.includes('\n')) return false
|
|
217
|
+
return NARRATION_TRAILER.test(t)
|
|
218
|
+
}
|
|
219
|
+
|
|
196
220
|
function isNarrationBlock(block: string): boolean {
|
|
197
|
-
return NARRATION_OPENER.test(block.trimStart())
|
|
221
|
+
return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block)
|
|
198
222
|
}
|
|
199
223
|
|
|
200
224
|
export type FlushDecision =
|
|
@@ -8,8 +8,14 @@ truncateRecallQuery, sliceLastTurnsByUserBoundary, prepareRetentionTranscript,
|
|
|
8
8
|
formatMemories.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import os
|
|
11
12
|
import re
|
|
12
|
-
from datetime import datetime
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
try: # Python 3.9+; present in every switchroom agent image.
|
|
16
|
+
from zoneinfo import ZoneInfo
|
|
17
|
+
except ImportError: # pragma: no cover - defensive only
|
|
18
|
+
ZoneInfo = None # type: ignore[assignment,misc]
|
|
13
19
|
|
|
14
20
|
# ---------------------------------------------------------------------------
|
|
15
21
|
# Memory tag stripping (anti-feedback-loop)
|
|
@@ -254,16 +260,44 @@ def format_memories(results: list) -> str:
|
|
|
254
260
|
return "\n\n".join(lines)
|
|
255
261
|
|
|
256
262
|
|
|
263
|
+
def _resolve_agent_timezone() -> str:
|
|
264
|
+
"""Resolve the agent's configured IANA timezone.
|
|
265
|
+
|
|
266
|
+
Mirrors the switchroom cascade used by ``bin/timezone-hook.sh`` and
|
|
267
|
+
``src/config/timezone.ts``: ``SWITCHROOM_TIMEZONE`` → ``TZ`` → ``UTC``.
|
|
268
|
+
This recall hook runs as a plugin subprocess INSIDE the agent container,
|
|
269
|
+
so it inherits both env vars (compose.ts bakes them onto the container).
|
|
270
|
+
"""
|
|
271
|
+
return os.environ.get("SWITCHROOM_TIMEZONE") or os.environ.get("TZ") or "UTC"
|
|
272
|
+
|
|
273
|
+
|
|
257
274
|
def format_current_time() -> str:
|
|
258
|
-
"""Format current
|
|
275
|
+
"""Format the current time in the agent's LOCAL timezone for recall context.
|
|
259
276
|
|
|
260
|
-
|
|
261
|
-
|
|
277
|
+
Switchroom #tz-fix: previously this emitted ``"%Y-%m-%d %H:%M UTC"``, the
|
|
278
|
+
STRONGEST of several competing UTC "current time" strings the model saw
|
|
279
|
+
each turn — it fought the single correct local-time hint and made agents
|
|
280
|
+
intermittently report UTC / wrong-offset times. We now render the agent's
|
|
281
|
+
LOCAL wall clock in am/pm form (e.g. ``2026-07-16 04:09 PM AEST``) so the
|
|
282
|
+
recall block never injects a UTC "now". Deterministic — the timezone comes
|
|
283
|
+
from the container env, not model discipline.
|
|
262
284
|
|
|
263
285
|
Port of: formatCurrentTimeForRecall() in index.js
|
|
264
286
|
"""
|
|
265
|
-
|
|
266
|
-
|
|
287
|
+
tz_name = _resolve_agent_timezone()
|
|
288
|
+
now = None
|
|
289
|
+
if ZoneInfo is not None:
|
|
290
|
+
try:
|
|
291
|
+
now = datetime.now(ZoneInfo(tz_name))
|
|
292
|
+
except Exception: # unknown/invalid zone — degrade, don't crash recall.
|
|
293
|
+
now = None
|
|
294
|
+
if now is None:
|
|
295
|
+
# No zoneinfo or bad zone: fall back to the process-local clock, which
|
|
296
|
+
# already honours the container's TZ env via libc. am/pm form, no "UTC".
|
|
297
|
+
now = datetime.now().astimezone()
|
|
298
|
+
# "%Y-%m-%d %I:%M %p %Z" → e.g. "2026-07-16 04:09 PM AEST" (mirrors the
|
|
299
|
+
# %I:%M %p %Z tail of bin/timezone-hook.sh's format).
|
|
300
|
+
return now.strftime("%Y-%m-%d %I:%M %p %Z")
|
|
267
301
|
|
|
268
302
|
|
|
269
303
|
# ---------------------------------------------------------------------------
|
|
@@ -585,10 +585,31 @@ class TestPrepareRetentionTranscript:
|
|
|
585
585
|
|
|
586
586
|
|
|
587
587
|
class TestFormatCurrentTime:
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
588
|
+
# switchroom #tz-fix: recall's "Current time - …" line now renders the
|
|
589
|
+
# agent's LOCAL wall clock in am/pm, NOT UTC — it was the strongest of
|
|
590
|
+
# several competing UTC "now" strings the model saw each turn.
|
|
591
|
+
def test_local_am_pm_shape(self, monkeypatch):
|
|
592
|
+
# A real IANA zone → am/pm form with the zone abbreviation, no "UTC".
|
|
593
|
+
monkeypatch.setenv("SWITCHROOM_TIMEZONE", "Australia/Melbourne")
|
|
594
|
+
monkeypatch.delenv("TZ", raising=False)
|
|
595
|
+
out = format_current_time()
|
|
596
|
+
# e.g. "2026-07-16 04:09 PM AEST" / "… AEDT" (DST) — abbrev is 3-5 alpha.
|
|
597
|
+
assert re.fullmatch(
|
|
598
|
+
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM) [A-Za-z]{2,5}", out
|
|
599
|
+
), out
|
|
600
|
+
|
|
601
|
+
def test_never_emits_utc_current_time(self, monkeypatch):
|
|
602
|
+
# The whole point of the fix: no UTC "current time" for a real zone.
|
|
603
|
+
monkeypatch.setenv("SWITCHROOM_TIMEZONE", "Australia/Melbourne")
|
|
604
|
+
monkeypatch.delenv("TZ", raising=False)
|
|
605
|
+
out = format_current_time()
|
|
606
|
+
assert "UTC" not in out
|
|
607
|
+
assert not out.endswith("Z")
|
|
608
|
+
|
|
609
|
+
def test_am_pm_when_no_zone_configured(self, monkeypatch):
|
|
610
|
+
# No SWITCHROOM_TIMEZONE / TZ → still am/pm shape (process-local clock),
|
|
611
|
+
# never the old "%Y-%m-%d %H:%M UTC" 24h form.
|
|
612
|
+
monkeypatch.delenv("SWITCHROOM_TIMEZONE", raising=False)
|
|
613
|
+
monkeypatch.delenv("TZ", raising=False)
|
|
614
|
+
out = format_current_time()
|
|
615
|
+
assert re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM)", out), out
|