switchroom 0.15.40 → 0.15.42

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.
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+ import {
3
+ buildContextOccupancy,
4
+ writeContextOccupancySnapshot,
5
+ TIGHT_FRACTION,
6
+ } from '../gateway/context-occupancy.js'
7
+
8
+ describe('buildContextOccupancy', () => {
9
+ it('computes headroom + pct + ok state under the cap', () => {
10
+ const s = buildContextOccupancy(47000, 300000, 123)
11
+ expect(s).toMatchObject({ occupancy: 47000, cap: 300000, headroom: 253000, state: 'ok', computedAt: 123 })
12
+ expect(s.pct).toBeCloseTo(0.1567, 3)
13
+ })
14
+
15
+ it('flags "tight" at/above TIGHT_FRACTION of the cap', () => {
16
+ const atThreshold = buildContextOccupancy(Math.ceil(300000 * TIGHT_FRACTION), 300000, 1)
17
+ expect(atThreshold.state).toBe('tight')
18
+ expect(buildContextOccupancy(250000, 300000, 1).state).toBe('tight') // 83% ≥ 80%
19
+ expect(buildContextOccupancy(231000, 300000, 1).state).toBe('ok') // 77% < 80%
20
+ expect(buildContextOccupancy(239999, 300000, 1).state).toBe('ok') // just under 80%
21
+ })
22
+
23
+ it('no cap → ok, null ratio (occupancy known, no ceiling)', () => {
24
+ const s = buildContextOccupancy(50000, null, 1)
25
+ expect(s).toMatchObject({ occupancy: 50000, cap: null, headroom: null, pct: null, state: 'ok' })
26
+ expect(buildContextOccupancy(50000, 0, 1).cap).toBeNull() // cap<=0 treated as none
27
+ })
28
+
29
+ it('unmeasurable occupancy → unknown', () => {
30
+ expect(buildContextOccupancy(NaN, 300000, 1).state).toBe('unknown')
31
+ expect(buildContextOccupancy(-5, 300000, 1).state).toBe('unknown')
32
+ })
33
+ })
34
+
35
+ describe('writeContextOccupancySnapshot', () => {
36
+ it('writes <stateDir>/context-occupancy.json via injected fs', () => {
37
+ let path = ''
38
+ let data = ''
39
+ writeContextOccupancySnapshot('/state/agent', buildContextOccupancy(47000, 300000, 1), {
40
+ mkdir: () => {},
41
+ writeFile: (p, d) => { path = p; data = d },
42
+ })
43
+ expect(path).toBe('/state/agent/context-occupancy.json')
44
+ expect(JSON.parse(data).occupancy).toBe(47000)
45
+ })
46
+
47
+ it('never throws when the write fails (best-effort)', () => {
48
+ expect(() =>
49
+ writeContextOccupancySnapshot('/x', buildContextOccupancy(1, 2, 1), {
50
+ mkdir: () => {},
51
+ writeFile: () => { throw new Error('EACCES') },
52
+ }),
53
+ ).not.toThrow()
54
+ })
55
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ decideIdleClear,
4
+ idleDurationToMs,
5
+ DEFAULT_IDLE_CLEAR_MS,
6
+ type IdleClearState,
7
+ } from '../gateway/idle-clear.js'
8
+
9
+ const H = 3_600_000
10
+ function state(p: Partial<IdleClearState>): IdleClearState {
11
+ return { lastActivityAt: 0, idleClearMs: 3 * H, alreadyCleared: false, turnInFlight: false, ...p }
12
+ }
13
+
14
+ describe('decideIdleClear', () => {
15
+ it('fires once the idle window has elapsed', () => {
16
+ expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H).clear).toBe(true)
17
+ expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H + 1).clear).toBe(true)
18
+ })
19
+
20
+ it('does NOT fire before the window', () => {
21
+ expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H - 1).clear).toBe(false)
22
+ })
23
+
24
+ it('never fires mid-turn (turnInFlight)', () => {
25
+ expect(decideIdleClear(state({ lastActivityAt: 0, turnInFlight: true }), 10 * H).clear).toBe(false)
26
+ })
27
+
28
+ it('fires once per idle period (alreadyCleared guard)', () => {
29
+ expect(decideIdleClear(state({ lastActivityAt: 0, alreadyCleared: true }), 10 * H).clear).toBe(false)
30
+ })
31
+
32
+ it('is disabled when idleClearMs <= 0', () => {
33
+ expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: 0 }), 10 * H).clear).toBe(false)
34
+ expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: -1 }), 10 * H).clear).toBe(false)
35
+ })
36
+
37
+ it('re-arms after activity (fresh lastActivityAt + alreadyCleared=false → waits again)', () => {
38
+ // Simulated: after a clear, activity resets lastActivityAt to `now` and the flag.
39
+ const now = 100 * H
40
+ const reArmed = state({ lastActivityAt: now, alreadyCleared: false })
41
+ expect(decideIdleClear(reArmed, now + 3 * H - 1).clear).toBe(false) // not yet
42
+ expect(decideIdleClear(reArmed, now + 3 * H).clear).toBe(true) // again, one window later
43
+ })
44
+ })
45
+
46
+ describe('idleDurationToMs', () => {
47
+ it('parses s/m/h', () => {
48
+ expect(idleDurationToMs('3h')).toBe(3 * H)
49
+ expect(idleDurationToMs('90m')).toBe(90 * 60_000)
50
+ expect(idleDurationToMs('7200s')).toBe(7_200_000)
51
+ expect(idleDurationToMs('0s')).toBe(0) // disable sentinel
52
+ })
53
+ it('returns null on malformed input (caller falls back to default)', () => {
54
+ expect(idleDurationToMs('3')).toBeNull()
55
+ expect(idleDurationToMs('3d')).toBeNull()
56
+ expect(idleDurationToMs('abc')).toBeNull()
57
+ expect(idleDurationToMs('')).toBeNull()
58
+ })
59
+ it('default is 3h', () => {
60
+ expect(DEFAULT_IDLE_CLEAR_MS).toBe(3 * H)
61
+ })
62
+ })
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ recoverFromPollStall,
4
+ POLL_STALL_EXIT_CODE,
5
+ } from '../gateway/poll-stall-recovery.js'
6
+
7
+ describe('recoverFromPollStall', () => {
8
+ it('exits with code 1 — and NEVER 78 (78 = permanent quarantine)', () => {
9
+ const codes: number[] = []
10
+ recoverFromPollStall({ exit: (c) => { codes.push(c) }, log: () => {} })
11
+ expect(codes).toEqual([1])
12
+ expect(POLL_STALL_EXIT_CODE).toBe(1)
13
+ expect(codes).not.toContain(78) // the sharp edge: quarantine on a transient stall
14
+ })
15
+
16
+ it('logs a recovery line carrying the agent name + the reason (no stop() await)', () => {
17
+ const lines: string[] = []
18
+ recoverFromPollStall({ exit: () => {}, log: (m) => { lines.push(m) }, agentName: 'carrie' })
19
+ expect(lines).toHaveLength(1)
20
+ expect(lines[0]).toContain('stall_recovery')
21
+ expect(lines[0]).toContain('agent=carrie')
22
+ expect(lines[0]).toContain('code=1')
23
+ // documents WHY we don't await stop() — guards against a future revert
24
+ expect(lines[0]).toMatch(/backoff|stop\(\)/)
25
+ })
26
+
27
+ it('exits exactly once', () => {
28
+ let calls = 0
29
+ recoverFromPollStall({ exit: () => { calls++ }, log: () => {} })
30
+ expect(calls).toBe(1)
31
+ })
32
+ })
@@ -12,7 +12,6 @@ import {
12
12
  switchroomHelpCommandNames,
13
13
  restartAckText,
14
14
  newSessionAckText,
15
- resetSessionAckText,
16
15
  TELEGRAM_BASE_COMMANDS,
17
16
  TELEGRAM_SWITCHROOM_COMMANDS,
18
17
  TELEGRAM_MENU_COMMANDS,
@@ -137,7 +136,8 @@ describe("helpText", () => {
137
136
  expect(out).toContain("/deny");
138
137
  expect(out).toContain("/pending");
139
138
  expect(out).toContain("/new");
140
- expect(out).toContain("/reset");
139
+ expect(out).toContain("/compact");
140
+ expect(out).toContain("/clear");
141
141
  });
142
142
  it("points at the richer /commands", () => {
143
143
  expect(helpText("assistant")).toContain("/commands");
@@ -347,9 +347,11 @@ describe("switchroomHelpText + switchroomHelpCommandNames", () => {
347
347
  expect(out).toContain("<b>Auth &amp; config</b>");
348
348
  });
349
349
  it("the name array contains the Sprint 2/3 additions", () => {
350
- for (const needed of ["new", "reset", "approve", "deny", "pending"]) {
350
+ for (const needed of ["new", "compact", "clear", "approve", "deny", "pending"]) {
351
351
  expect(switchroomHelpCommandNames).toContain(needed);
352
352
  }
353
+ // /reset was removed (it was a pure alias of /new).
354
+ expect(switchroomHelpCommandNames).not.toContain("reset");
353
355
  });
354
356
  });
355
357
 
@@ -370,9 +372,11 @@ describe("TELEGRAM_MENU_COMMANDS (slash-menu shape)", () => {
370
372
  it("menu includes the session-control commands (the most-used trio)", () => {
371
373
  const names = TELEGRAM_MENU_COMMANDS.map(c => c.command);
372
374
  // These MUST be in the menu — they're the primary mobile UX flows
373
- for (const must of ["new", "reset", "approve", "deny", "pending", "restart", "logs", "commands"]) {
375
+ for (const must of ["new", "compact", "clear", "approve", "deny", "pending", "restart", "logs", "commands"]) {
374
376
  expect(names, `missing /${must} from Telegram menu`).toContain(must);
375
377
  }
378
+ // /reset removed (alias of /new).
379
+ expect(names, "/reset should be gone from the menu").not.toContain("reset");
376
380
  });
377
381
 
378
382
  it("menu drops the ops primitives that cluttered the old catalogue", () => {
@@ -451,7 +455,7 @@ describe("TELEGRAM_MENU_COMMANDS (slash-menu shape)", () => {
451
455
  });
452
456
  });
453
457
 
454
- describe("restart / new / reset ack text", () => {
458
+ describe("restart / new ack text", () => {
455
459
  it("restartAckText is consistent", () => {
456
460
  expect(restartAckText("assistant")).toBe("🔄 Restarting <b>assistant</b>…");
457
461
  });
@@ -463,13 +467,8 @@ describe("restart / new / reset ack text", () => {
463
467
  expect(newSessionAckText("assistant", false))
464
468
  .toBe("🆕 Started fresh session for <b>assistant</b> · restarting…");
465
469
  });
466
- it("resetSessionAckText with flush", () => {
467
- expect(resetSessionAckText("assistant", true))
468
- .toBe("🔄 Reset session for <b>assistant</b> · flushed handoff · restarting…");
469
- });
470
- it("HTML-escapes agent name in all three", () => {
470
+ it("HTML-escapes agent name in both", () => {
471
471
  expect(restartAckText("<x>")).toContain("&lt;x&gt;");
472
472
  expect(newSessionAckText("<x>", true)).toContain("&lt;x&gt;");
473
- expect(resetSessionAckText("<x>", true)).toContain("&lt;x&gt;");
474
473
  });
475
474
  });
@@ -170,7 +170,7 @@ export function helpText(agentName: string): string {
170
170
  ``,
171
171
  `This bot is the <b>${escapeHtml(agentName)}</b> agent. Text and photos route through to it; replies, reactions and progress cards come back.`,
172
172
  ``,
173
- `Tool approvals surface as inline buttons (✅ / ❌) or via <code>/approve</code>, <code>/deny</code>, <code>/pending</code>. Start a fresh session with <code>/new</code> or <code>/reset</code>.`,
173
+ `Tool approvals surface as inline buttons (✅ / ❌) or via <code>/approve</code>, <code>/deny</code>, <code>/pending</code>. Start a fresh session with <code>/new</code>, or trim/clear context with <code>/compact</code> / <code>/clear</code>.`,
174
174
  ``,
175
175
  `<code>/start</code> — pairing instructions`,
176
176
  `<code>/status</code> — agent, model, auth`,
@@ -265,7 +265,7 @@ export function statusUnpairedText(): string {
265
265
  */
266
266
  export const switchroomHelpCommandNames = [
267
267
  // Session & approvals
268
- "new", "reset", "approve", "deny", "pending", "interrupt",
268
+ "new", "compact", "clear", "approve", "deny", "pending", "interrupt",
269
269
  // Agents
270
270
  "agents", "agentstart", "stop", "restart", "logs", "memory",
271
271
  // Auth & config — consolidated onto the `/auth` dashboard.
@@ -298,7 +298,8 @@ export const TELEGRAM_MENU_COMMANDS = [
298
298
  { command: "status", description: "Agent, model, auth" },
299
299
  // Session control (most-used)
300
300
  { command: "new", description: "Fresh session (flush handoff, restart)" },
301
- { command: "reset", description: "Alias of /new" },
301
+ { command: "compact", description: "Compact context (summarize, keep the thread)" },
302
+ { command: "clear", description: "Clear context (fresh slate; memory in Hindsight)" },
302
303
  // Inline approvals
303
304
  { command: "approve", description: "Approve pending tool permission" },
304
305
  { command: "deny", description: "Deny pending tool permission" },
@@ -312,8 +313,10 @@ export const TELEGRAM_MENU_COMMANDS = [
312
313
  // #725 Phase 2 — inject a Claude Code REPL slash command into the agent's
313
314
  // tmux pane (allowlisted: /cost, /status, /model, /clear, /compact,
314
315
  // /memory, /hooks). Requires the tmux supervisor (the default — refused
315
- // when the agent has experimental.legacy_pty=true).
316
- { command: "inject", description: "Inject a Claude Code slash command (e.g. /cost)" },
316
+ // when the agent has experimental.legacy_pty=true). NOT in the slash-menu
317
+ // (kept the 20-entry mobile cap; the common injects /compact, /clear,
318
+ // /model, /effort are first-class menu commands). Still typable + in
319
+ // /commands.
317
320
  // /model — show or switch the Claude model (session-scoped; rides the
318
321
  // same inject primitive as `/inject /model` but with a typed argument,
319
322
  // so it never opens the undriveable no-arg picker modal).
@@ -356,7 +359,8 @@ export function switchroomHelpText(agentName: string): string {
356
359
  ``,
357
360
  `<b>Session &amp; approvals</b>`,
358
361
  `<code>/new</code> — fresh session (flush handoff, restart)`,
359
- `<code>/reset</code> — alias of /new`,
362
+ `<code>/compact</code> — compact context (summarize, keep the thread)`,
363
+ `<code>/clear</code> — clear context (fresh slate; memory in Hindsight)`,
360
364
  `<code>/approve [id]</code> — approve pending tool permission`,
361
365
  `<code>/deny [id]</code> — deny pending tool permission`,
362
366
  `<code>/pending</code> — list pending permission prompts`,
@@ -404,7 +408,7 @@ export function switchroomHelpText(agentName: string): string {
404
408
  }
405
409
 
406
410
  /**
407
- * Ack shown when a self-targeting /restart (or /new, /reset) kicks off.
411
+ * Ack shown when a self-targeting /restart (or /new) kicks off.
408
412
  * Centralized so gateway and monolith agree on wording.
409
413
  */
410
414
  export function restartAckText(agentName: string): string {
@@ -415,8 +419,3 @@ export function newSessionAckText(agentName: string, flushedHandoff: boolean): s
415
419
  const tail = flushedHandoff ? " · flushed handoff" : "";
416
420
  return `🆕 Started fresh session for <b>${escapeHtml(agentName)}</b>${tail} · restarting…`;
417
421
  }
418
-
419
- export function resetSessionAckText(agentName: string, flushedHandoff: boolean): string {
420
- const tail = flushedHandoff ? " · flushed handoff" : "";
421
- return `🔄 Reset session for <b>${escapeHtml(agentName)}</b>${tail} · restarting…`;
422
- }
@@ -98,6 +98,15 @@ ENV_OVERRIDES = {
98
98
  # [0.0, 1.0]. Set by start.sh from agents.<name>.memory.recall.min_overlap
99
99
  # (cascading through defaults). 0.0 = off (current behaviour).
100
100
  "HINDSIGHT_RECALL_MIN_OVERLAP": ("recallMinOverlap", float),
101
+ # Switchroom-local: recall fact types (comma-separated). Set by start.sh
102
+ # from agents.<name>.memory.recall.types only when the operator overrode
103
+ # the switchroom default (world,experience,observation) — i.e. the
104
+ # opt-out path for the synthesized `observation` tier.
105
+ "HINDSIGHT_RECALL_TYPES": ("recallTypes", list),
106
+ # Switchroom-local: trivial-turn recall skip (Phase 6a). Set by start.sh
107
+ # from agents.<name>.memory.recall.skip_trivial only on override; the
108
+ # switchroom default is on (recall.py falls back to True).
109
+ "HINDSIGHT_RECALL_SKIP_TRIVIAL": ("recallSkipTrivial", bool),
101
110
  "HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
102
111
  "HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
103
112
  "HINDSIGHT_API_PORT": ("apiPort", int),
@@ -121,6 +130,9 @@ def _cast_env(value: str, typ):
121
130
  return int(value)
122
131
  if typ is float:
123
132
  return float(value)
133
+ if typ is list:
134
+ # Comma-separated → list of trimmed, non-empty strings.
135
+ return [t.strip() for t in value.split(",") if t.strip()]
124
136
  return value
125
137
  except (ValueError, AttributeError):
126
138
  return None
@@ -42,6 +42,7 @@ Exit codes:
42
42
  import hashlib
43
43
  import json
44
44
  import os
45
+ import re
45
46
  import sys
46
47
  import time
47
48
 
@@ -466,6 +467,57 @@ def read_transcript_messages(transcript_path: str) -> list:
466
467
  return messages
467
468
 
468
469
 
470
+ # Switchroom Phase 6a — stateless-prompt classifier for the recall skip.
471
+ # Returns True ONLY for prompts that provably never need user memory: the
472
+ # current time/date/day, or a bare greeting. Biased hard toward False —
473
+ # any personal pronoun, memory verb, or context word means "could need
474
+ # memory → recall anyway". Trap case: "what host am I on" reads trivial
475
+ # but needs memory; the "i" token blocks the skip.
476
+ _TRIVIAL_GREETINGS = frozenset({
477
+ "hi", "hello", "hey", "heya", "hiya", "yo", "howdy", "sup",
478
+ "hey there", "hello there", "hi there",
479
+ "morning", "good morning", "good afternoon", "good evening", "evening",
480
+ })
481
+ # Any of these as a whole word → do NOT skip (prompt may depend on stored
482
+ # user / project / session state).
483
+ _STATEFUL_SIGNALS = frozenset({
484
+ "i", "im", "ive", "id", "me", "my", "mine", "myself",
485
+ "we", "our", "ours", "us", "you", "your", "yours",
486
+ "remember", "recall", "forget", "forgot", "remind",
487
+ "last", "earlier", "yesterday", "before", "again", "previously", "recent",
488
+ "project", "task", "status", "config", "setup", "host", "machine",
489
+ "running", "deploy", "agent", "memory", "note", "noted",
490
+ })
491
+ # Matched against an apostrophe-stripped form, so "what's"→"whats",
492
+ # "today's"→"todays". Covers "what time is it", "what's the time",
493
+ # "what day is it", "what's today's date", "current time", "time?".
494
+ _STATELESS_QUESTION_RE = re.compile(
495
+ r"^(?:what(?:s| is)?\s+)?"
496
+ r"(?:the\s+|current\s+|todays\s+)?"
497
+ r"(?:time|date|day(?:\s+of\s+(?:the\s+)?week)?)"
498
+ r"(?:\s+is\s+it)?(?:\s+(?:right\s+now|now|today))?"
499
+ r"\s*\??$"
500
+ )
501
+
502
+
503
+ def _is_trivial_stateless(ack_form, stripped):
504
+ text = (stripped or "").lower().strip()
505
+ core = text.strip(" \t\n\r.,!?…👍👌✅🆗🙏")
506
+ if not core:
507
+ return False
508
+ core_noapos = core.replace("'", "")
509
+ # If any token signals personal / project / session state, bail —
510
+ # apostrophes stripped so "i'm"/"i've" tokenise to im/ive (stateful).
511
+ tokens = re.findall(r"[a-z]+", core_noapos)
512
+ if any(tok in _STATEFUL_SIGNALS for tok in tokens):
513
+ return False
514
+ if core in _TRIVIAL_GREETINGS:
515
+ return True
516
+ if _STATELESS_QUESTION_RE.match(core_noapos):
517
+ return True
518
+ return False
519
+
520
+
469
521
  def main():
470
522
  config = load_config()
471
523
 
@@ -522,6 +574,18 @@ def main():
522
574
  debug_log(config, f"Prompt is ack-only ({_ack_form!r}), skipping recall")
523
575
  return
524
576
 
577
+ # Switchroom Phase 6a (RFC hindsight-synthesis-layers.md) — skip recall
578
+ # on plausibly-stateless trivial asks (time/date/day, bare greetings)
579
+ # when `recallSkipTrivial` is on. Same conservatism as the ack-skip:
580
+ # a false negative (skipping a turn that DID need memory) costs the
581
+ # remember-across-sessions continuity, so `_is_trivial_stateless`
582
+ # bails the instant the prompt carries any personal/stateful signal
583
+ # (a pronoun, a memory verb, "project", etc.). It only skips an exact
584
+ # stateless form — never a content-classifier guess.
585
+ if config.get("recallSkipTrivial", True) and _is_trivial_stateless(_ack_form, _stripped):
586
+ debug_log(config, f"Prompt is trivial/stateless ({_ack_form!r}), skipping recall")
587
+ return
588
+
525
589
  session_id = hook_input.get("session_id") or ""
526
590
 
527
591
  # Switchroom #303 — push a "📚 recalling memories" status to the
@@ -0,0 +1,101 @@
1
+ """Switchroom Phase 6a — unit tests for recall.py's trivial-stateless skip.
2
+
3
+ `_is_trivial_stateless` gates the auto-recall hook: it returns True only
4
+ for prompts that provably never need user memory (current time/date/day,
5
+ bare greetings), so the ~1-2s recall arm + ~1024 injected tokens are
6
+ skipped on those turns. The load-bearing property is the NO-FALSE-NEGATIVE
7
+ guarantee: a prompt that could need memory must never be skipped, because
8
+ skipping it would breach the remember-across-sessions continuity job. The
9
+ KEEP corpus below is that no-regression gate.
10
+
11
+ Stdlib-only.
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import unittest
17
+
18
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
19
+ if SCRIPTS_DIR not in sys.path:
20
+ sys.path.insert(0, SCRIPTS_DIR)
21
+
22
+ from recall import _is_trivial_stateless # noqa: E402
23
+
24
+
25
+ # Prompts that provably never need user/session memory — safe to skip.
26
+ TRIVIAL = [
27
+ "what time is it",
28
+ "what time is it?",
29
+ "what time is it now",
30
+ "what's the time",
31
+ "time?",
32
+ "current time",
33
+ "what's the date",
34
+ "whats the date",
35
+ "what's today's date",
36
+ "what day is it",
37
+ "what day is it?",
38
+ "what day is it today",
39
+ "what day of the week is it",
40
+ "hi",
41
+ "hello",
42
+ "hey",
43
+ "hey there",
44
+ "good morning",
45
+ "morning",
46
+ ]
47
+
48
+ # Prompts that depend on (or might depend on) stored user/project/session
49
+ # state — must NEVER be skipped. This is the no-regression gate.
50
+ NEEDS_MEMORY = [
51
+ "what host am I on?", # reads trivial, but needs memory — the "i" trap
52
+ "what's my config",
53
+ "what was I frustrated about",
54
+ "remind me what we discussed",
55
+ "what's the date for my deadline", # has 'my'
56
+ "what's the date of our last deploy", # 'our'/'last'/'deploy'
57
+ "what projects do I have",
58
+ "do you remember my preference",
59
+ "what's the status of the deploy",
60
+ "summarize our last conversation",
61
+ "hello, can you check my open items", # greeting + real ask
62
+ "what time did I say the meeting was", # 'time' but 'i'/'say'
63
+ "what should I work on",
64
+ "tell me about the project",
65
+ "what's the weather", # stateless-ish but not in the time/date set → recall anyway
66
+ "how do I fix this",
67
+ ]
68
+
69
+
70
+ class TestTrivialStatelessSkip(unittest.TestCase):
71
+ def test_trivial_prompts_are_skipped(self):
72
+ for p in TRIVIAL:
73
+ with self.subTest(prompt=p):
74
+ self.assertTrue(
75
+ _is_trivial_stateless("", p),
76
+ f"expected trivial/stateless skip for {p!r}",
77
+ )
78
+
79
+ def test_memory_prompts_are_never_skipped(self):
80
+ # The critical safety invariant — zero false negatives.
81
+ for p in NEEDS_MEMORY:
82
+ with self.subTest(prompt=p):
83
+ self.assertFalse(
84
+ _is_trivial_stateless("", p),
85
+ f"FALSE NEGATIVE: would skip recall for {p!r} which may need memory",
86
+ )
87
+
88
+ def test_empty_and_whitespace_do_not_skip_here(self):
89
+ # The <5-char / empty guards live earlier in main(); the classifier
90
+ # itself returns False for empty so it never short-circuits oddly.
91
+ self.assertFalse(_is_trivial_stateless("", ""))
92
+ self.assertFalse(_is_trivial_stateless("", " "))
93
+
94
+ def test_personal_pronoun_blocks_skip(self):
95
+ # Even a time question is kept the moment a stateful token appears.
96
+ self.assertTrue(_is_trivial_stateless("", "what time is it"))
97
+ self.assertFalse(_is_trivial_stateless("", "what time is it for my call"))
98
+
99
+
100
+ if __name__ == "__main__":
101
+ unittest.main()
@@ -42,7 +42,7 @@ class TestLoadConfig:
42
42
  cfg = load_config()
43
43
  assert cfg["autoRecall"] is True
44
44
  assert cfg["autoRetain"] is True
45
- assert cfg["recallBudget"] == "mid"
45
+ assert cfg["recallBudget"] == "low"
46
46
  assert cfg["retainEveryNTurns"] == 10
47
47
 
48
48
  def test_settings_json_overrides_defaults(self, tmp_path, monkeypatch):
@@ -75,7 +75,7 @@ class TestLoadConfig:
75
75
  monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
76
76
  (tmp_path / "settings.json").write_text("not valid json{{")
77
77
  cfg = load_config()
78
- assert cfg["recallBudget"] == "mid" # default still applies
78
+ assert cfg["recallBudget"] == "low" # default still applies
79
79
 
80
80
  def test_null_values_in_settings_json_not_applied(self, tmp_path, monkeypatch):
81
81
  monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
@@ -112,7 +112,7 @@ class TestLoadConfig:
112
112
  # HOME points to tmp_path where no .hindsight/claude-code.json exists
113
113
  monkeypatch.setenv("HOME", str(tmp_path))
114
114
  cfg = load_config()
115
- assert cfg["recallBudget"] == "mid" # default
115
+ assert cfg["recallBudget"] == "low" # default
116
116
 
117
117
  def test_env_var_wins_over_user_config(self, tmp_path, monkeypatch):
118
118
  plugin_root = tmp_path / "plugin"