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.
- package/dist/agent-scheduler/index.js +4 -1
- package/dist/auth-broker/index.js +4 -1
- package/dist/cli/notion-write-pretool.mjs +4 -1
- package/dist/cli/switchroom.js +1469 -1247
- package/dist/cli/ui/index.html +31 -0
- package/dist/host-control/main.js +4 -1
- package/dist/vault/approvals/kernel-server.js +4 -1
- package/dist/vault/broker/server.js +23 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +15 -0
- package/telegram-plugin/dist/gateway/gateway.js +400 -227
- package/telegram-plugin/gateway/context-occupancy.ts +91 -0
- package/telegram-plugin/gateway/gateway.ts +204 -63
- package/telegram-plugin/gateway/hostd-dispatch.ts +1 -1
- package/telegram-plugin/gateway/idle-clear.ts +72 -0
- package/telegram-plugin/gateway/poll-health.ts +9 -4
- package/telegram-plugin/gateway/poll-stall-recovery.ts +59 -0
- package/telegram-plugin/tests/context-occupancy.test.ts +55 -0
- package/telegram-plugin/tests/idle-clear.test.ts +62 -0
- package/telegram-plugin/tests/poll-stall-recovery.test.ts +32 -0
- package/telegram-plugin/tests/welcome-text.test.ts +10 -11
- package/telegram-plugin/welcome-text.ts +11 -12
- package/vendor/hindsight-memory/scripts/lib/config.py +12 -0
- package/vendor/hindsight-memory/scripts/recall.py +64 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_trivial_skip.py +101 -0
- package/vendor/hindsight-memory/tests/test_config.py +3 -3
|
@@ -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("/
|
|
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 & config</b>");
|
|
348
348
|
});
|
|
349
349
|
it("the name array contains the Sprint 2/3 additions", () => {
|
|
350
|
-
for (const needed of ["new", "
|
|
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", "
|
|
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
|
|
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("
|
|
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("<x>");
|
|
472
472
|
expect(newSessionAckText("<x>", true)).toContain("<x>");
|
|
473
|
-
expect(resetSessionAckText("<x>", true)).toContain("<x>");
|
|
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
|
|
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", "
|
|
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: "
|
|
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
|
-
|
|
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 & approvals</b>`,
|
|
358
361
|
`<code>/new</code> — fresh session (flush handoff, restart)`,
|
|
359
|
-
`<code>/
|
|
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
|
|
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"] == "
|
|
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"] == "
|
|
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"] == "
|
|
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"
|