switchroom 0.19.2 → 0.19.3
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 +2 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, appendFileSync, statSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
computeFirstAttachCursor,
|
|
7
|
+
getProjectsDirForCwd,
|
|
8
|
+
startSessionTail,
|
|
9
|
+
type SessionEvent,
|
|
10
|
+
} from '../session-tail.js'
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* computeFirstAttachCursor: on first attach to a transcript, seek to EOF
|
|
@@ -63,3 +68,111 @@ describe('computeFirstAttachCursor', () => {
|
|
|
63
68
|
expect(computeFirstAttachCursor(missing, 0)).toBe(0)
|
|
64
69
|
})
|
|
65
70
|
})
|
|
71
|
+
|
|
72
|
+
// ── #3427 H2: first-attach replay marks `model` events as replayed ───────────
|
|
73
|
+
//
|
|
74
|
+
// A /model relaunch during an active turn is EXACTLY the shape that triggers
|
|
75
|
+
// the in-flight-turn replay above: the new gateway attaches to the OLD
|
|
76
|
+
// session's JSONL and replays its enqueue + assistant lines — which carry the
|
|
77
|
+
// PRE-relaunch model. Those replayed `model` observations must be flagged so
|
|
78
|
+
// the divergence tripwire (session-model-source) ignores them; live lines
|
|
79
|
+
// appended after attach must NOT be flagged. Outcome-asserted against the
|
|
80
|
+
// real tailer, not the source.
|
|
81
|
+
|
|
82
|
+
describe('startSessionTail — replayed model events are flagged (#3427 H2)', () => {
|
|
83
|
+
const tempDirs: string[] = []
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
for (const d of tempDirs) {
|
|
86
|
+
try { rmSync(d, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
87
|
+
}
|
|
88
|
+
tempDirs.length = 0
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
function mkProjectsDir(): { claudeHome: string; cwd: string; projectsDir: string } {
|
|
92
|
+
const base = mkdtempSync(join(tmpdir(), 'first-attach-replayed-'))
|
|
93
|
+
tempDirs.push(base)
|
|
94
|
+
const cwd = join(base, 'agent')
|
|
95
|
+
const claudeHome = join(base, 'claude-home')
|
|
96
|
+
const projectsDir = getProjectsDirForCwd(cwd, claudeHome)
|
|
97
|
+
mkdirSync(projectsDir, { recursive: true })
|
|
98
|
+
return { claudeHome, cwd, projectsDir }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
102
|
+
|
|
103
|
+
const assistantModelLine = (model: string): string =>
|
|
104
|
+
JSON.stringify({
|
|
105
|
+
type: 'assistant',
|
|
106
|
+
message: { model, content: [{ type: 'text', text: 'hi' }] },
|
|
107
|
+
}) + '\n'
|
|
108
|
+
|
|
109
|
+
function modelEvents(events: SessionEvent[]): Array<{ model: string; replayed?: boolean }> {
|
|
110
|
+
return events.filter((e): e is Extract<SessionEvent, { kind: 'model' }> => e.kind === 'model')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
it('mid-turn restart: replayed OLD-model lines carry replayed:true; a live line appended after attach does not', async () => {
|
|
114
|
+
const { claudeHome, cwd, projectsDir } = mkProjectsDir()
|
|
115
|
+
const file = join(projectsDir, 'sess.jsonl')
|
|
116
|
+
// The pre-relaunch session ended mid-turn: enqueue with no turn_duration,
|
|
117
|
+
// followed by an assistant line served by the OLD model.
|
|
118
|
+
writeFileSync(
|
|
119
|
+
file,
|
|
120
|
+
ENQUEUE + '\n' + DEQUEUE + '\n' + assistantModelLine('claude-opus-4-8'),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const events: SessionEvent[] = []
|
|
124
|
+
const handle = startSessionTail({
|
|
125
|
+
cwd,
|
|
126
|
+
claudeHome,
|
|
127
|
+
rescanIntervalMs: 50,
|
|
128
|
+
onEvent: (ev) => { events.push(ev) },
|
|
129
|
+
})
|
|
130
|
+
try {
|
|
131
|
+
await wait(200) // attach + replay the in-flight turn
|
|
132
|
+
const replayedBatch = modelEvents(events)
|
|
133
|
+
expect(replayedBatch.length).toBeGreaterThan(0)
|
|
134
|
+
// THE H2 false-positive shape: the old model arrives AFTER boot — but
|
|
135
|
+
// flagged, so the divergence tripwire skips it.
|
|
136
|
+
expect(replayedBatch[0]).toMatchObject({ model: 'claude-opus-4-8', replayed: true })
|
|
137
|
+
|
|
138
|
+
// The live session now writes its first assistant line (new model).
|
|
139
|
+
appendFileSync(file, assistantModelLine('claude-sonnet-5'))
|
|
140
|
+
await wait(200)
|
|
141
|
+
const all = modelEvents(events)
|
|
142
|
+
const live = all[all.length - 1]
|
|
143
|
+
expect(live.model).toBe('claude-sonnet-5')
|
|
144
|
+
expect(live.replayed).not.toBe(true)
|
|
145
|
+
} finally {
|
|
146
|
+
handle.stop()
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('completed-turn attach (no replay): appended model lines are never flagged', async () => {
|
|
151
|
+
const { claudeHome, cwd, projectsDir } = mkProjectsDir()
|
|
152
|
+
const file = join(projectsDir, 'sess.jsonl')
|
|
153
|
+
writeFileSync(
|
|
154
|
+
file,
|
|
155
|
+
ENQUEUE + '\n' + assistantModelLine('claude-opus-4-8') + TURN_DURATION + '\n',
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const events: SessionEvent[] = []
|
|
159
|
+
const handle = startSessionTail({
|
|
160
|
+
cwd,
|
|
161
|
+
claudeHome,
|
|
162
|
+
rescanIntervalMs: 50,
|
|
163
|
+
onEvent: (ev) => { events.push(ev) },
|
|
164
|
+
})
|
|
165
|
+
try {
|
|
166
|
+
await wait(200) // attach seeks to EOF — no replay
|
|
167
|
+
expect(modelEvents(events)).toHaveLength(0)
|
|
168
|
+
appendFileSync(file, assistantModelLine('claude-sonnet-5'))
|
|
169
|
+
await wait(200)
|
|
170
|
+
const all = modelEvents(events)
|
|
171
|
+
expect(all.length).toBeGreaterThan(0)
|
|
172
|
+
expect(all[all.length - 1].model).toBe('claude-sonnet-5')
|
|
173
|
+
expect(all[all.length - 1].replayed).not.toBe(true)
|
|
174
|
+
} finally {
|
|
175
|
+
handle.stop()
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
})
|
|
@@ -4,6 +4,108 @@
|
|
|
4
4
|
|
|
5
5
|
### Changed (switchroom divergence)
|
|
6
6
|
|
|
7
|
+
- **`recallContextTurns` default `1` → `2`** (switchroom hindsight-leverage
|
|
8
|
+
PR2, workstream A2). A bare follow-up user message ("and the port?", "what
|
|
9
|
+
about staging?") now embeds together with its antecedent human turn in the
|
|
10
|
+
recall query, instead of recalling on the pronoun alone. Depends on PR1's
|
|
11
|
+
(#3435) `<channel>` envelope strip so the composed 2-turn query stays
|
|
12
|
+
envelope-free (both the trailing latest segment and the `Prior context:`
|
|
13
|
+
lines). The composition is bounded by `recallMaxQueryChars` (800) —
|
|
14
|
+
`truncate_recall_query` preserves the latest turn and drops oldest context
|
|
15
|
+
first, so a large antecedent can never blow the recall query budget.
|
|
16
|
+
- **New `recallTranscriptTailBytes` (default `262144`)** — latency bound for
|
|
17
|
+
multi-turn recall. With `recallContextTurns > 1` now the default, every
|
|
18
|
+
recall reads the transcript to slice prior turns; `read_transcript_messages`
|
|
19
|
+
now byte-tail-bounds that read (seek to `EOF - tail_bytes`, discard the
|
|
20
|
+
partial first line, parse only complete trailing lines) so the added
|
|
21
|
+
per-recall read stays O(1) regardless of session `.jsonl` size. `0` reads the
|
|
22
|
+
whole file (rollback lever). Env: `HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES`.
|
|
23
|
+
|
|
24
|
+
### Added (switchroom divergence)
|
|
25
|
+
|
|
26
|
+
- **retain.py + recall.py: lesson/anti-pattern tagging → recall demotion**
|
|
27
|
+
(switchroom hindsight-leverage PR9, workstream E2, #398). Closes the corpus-
|
|
28
|
+
hygiene half of #398: a retained transcript that captures a self-recognised
|
|
29
|
+
lesson ("lesson learned", "note to self:") or a failure mode ("anti-pattern:",
|
|
30
|
+
"what not to do") is now deterministically tagged (`lesson` / `anti-pattern`)
|
|
31
|
+
at retain time by `retain.detect_lesson_tags` — a case-insensitive substring
|
|
32
|
+
match against the configurable `lessonTagMarkers` map (NOT model-dependent),
|
|
33
|
+
wired into `build_retain_payload` so it applies to both Stop-hook and sidechain
|
|
34
|
+
retains without clobbering configured `retainTags`. Recall then DEMOTES those
|
|
35
|
+
tags via the PR5 score-penalty weight map: `recall._effective_tag_weights`
|
|
36
|
+
merges built-in `lessonDemotionWeights` (`{lesson: 0.85, anti-pattern: 0.5}`)
|
|
37
|
+
UNDER `recallTagWeights`, so a failure-mode-adjacent transcript ranks below a
|
|
38
|
+
clean equal-score session memory yet is NEVER hard-dropped (re-rank, not the
|
|
39
|
+
demote-tag DROP filter) and still surfaces when it is the only relevant hit.
|
|
40
|
+
Precedence: an explicit `recallTagWeights` entry wins over the built-in for the
|
|
41
|
+
same tag; the PR5 `sidechain: 0.8` seed composes cleanly. Toggles:
|
|
42
|
+
`HINDSIGHT_LESSON_TAGGING=false` (retain side), `HINDSIGHT_LESSON_DEMOTION=false`
|
|
43
|
+
(recall side) as rollback levers; `HINDSIGHT_LESSON_TAG_MARKERS` /
|
|
44
|
+
`HINDSIGHT_LESSON_DEMOTION_WEIGHTS` (JSON) for overrides. NON-GOAL
|
|
45
|
+
(epic-recorded): the historical corpus is NOT re-tagged — this fires on NEW
|
|
46
|
+
retains only. Acceptance: `scripts/tests/test_lesson_tagging.py`.
|
|
47
|
+
|
|
48
|
+
- **recall.py: parallel multi-bank recall under one shared deadline**
|
|
49
|
+
(switchroom hindsight-leverage PR3, workstream A3 stage 2). The directives
|
|
50
|
+
fetch and every bank recall (own + additional/profile/shared/sender banks)
|
|
51
|
+
now run CONCURRENTLY in daemon threads via `lib/parallel_recall.py`
|
|
52
|
+
(`run_parallel`), bounded by ONE shared deadline
|
|
53
|
+
(`recallParallelDeadlineSeconds`, default 10 = the 12s UserPromptSubmit hook
|
|
54
|
+
ceiling minus 2s headroom). Serially the round-trips SUM, so a heavy
|
|
55
|
+
multi-bank agent could breach the ceiling and drop recall entirely; parallel
|
|
56
|
+
makes the critical path the SLOWEST slot. A slot still running at the deadline
|
|
57
|
+
is abandoned (daemon thread, reaped on process exit) and marked `timed_out` —
|
|
58
|
+
a straggler bank can never hold the hook open past its ceiling; `recall.py`'s
|
|
59
|
+
`__main__` additionally `os._exit(0)`s (after a stdout flush) as a
|
|
60
|
+
belt-and-suspenders. The directives slot is dedicated and composes with the A4
|
|
61
|
+
directives cache (a cache HIT returns near-instantly with no HTTP). Env-gated
|
|
62
|
+
rollback: `HINDSIGHT_RECALL_PARALLEL=false` restores the pre-A3 serial path.
|
|
63
|
+
The `deadline_hit` telemetry field (shipped interim in PR1) is FINALIZED:
|
|
64
|
+
True when any bank raised a hard per-request timeout OR any bank/directives
|
|
65
|
+
slot was abandoned at the shared deadline; serial mode reduces to the pre-A3
|
|
66
|
+
per-bank-only form so both modes' `recall_log.jsonl` rows stay comparable in
|
|
67
|
+
the breach baseline. New log fields: `recall_mode`, `deadline_budget_ms`
|
|
68
|
+
(the CONFIGURED budget), `deadline_effective_ms` (the smaller wait the slots
|
|
69
|
+
actually got after pre-fan-out spend; null in serial mode / on cache hits),
|
|
70
|
+
`directives_timed_out`. Acceptance: `scripts/tests/test_recall_parallel_deadline.py`
|
|
71
|
+
(stub-timing tests proving a 3s bank cannot breach a 0.6s deadline).
|
|
72
|
+
|
|
73
|
+
- **SubagentStop sidechain retain** (switchroom hindsight-leverage PR5). New
|
|
74
|
+
`scripts/subagent_retain.py`, registered on the `SubagentStop` event in
|
|
75
|
+
`hooks/hooks.json` (async, 15s). Delegated (Task-tool / sub-agent) work was
|
|
76
|
+
the biggest systematic memory hole — the main-session Stop retain only reads
|
|
77
|
+
the parent `transcript_path`, so a worker's process facts reached memory only
|
|
78
|
+
as its terse final report. This hook retains a bounded window (last 40 human
|
|
79
|
+
turns) of the *sidechain* transcript, tagged `sidechain` +
|
|
80
|
+
`parent_session:<id>`, with a deterministic content-derived `document_id` in a
|
|
81
|
+
distinct namespace (`{session}-sub-{agent}-r{start}-{end}`) so re-fires upsert.
|
|
82
|
+
Failures enqueue to the same `pending-retains` durability queue the Stop retain
|
|
83
|
+
uses. A **volume gate** (< 6 human turns OR < 2,000 chars of non-tool-result
|
|
84
|
+
text) skips trivial forks (every Task fires SubagentStop, including
|
|
85
|
+
10-second ones). Empirically probed on Claude Code 2.1.215: the hook input
|
|
86
|
+
carries a first-class `agent_transcript_path` pointing at
|
|
87
|
+
`<project>/<session>/subagents/agent-<agent_id>.jsonl` (used as the primary
|
|
88
|
+
path), with a directory-scan of the newest `isSidechain:true` jsonl as the
|
|
89
|
+
fallback for CLIs that omit the field. `reconcile_tail.py` and any transcript
|
|
90
|
+
sweeper now **skip** sidechain transcripts (shared
|
|
91
|
+
`content.transcript_first_line_is_sidechain` predicate) so the boot reconciler
|
|
92
|
+
cannot re-retain a sub-agent fork as a pseudo-session — untagged, at full
|
|
93
|
+
recall weight, bypassing the volume gate — which its recursive `**/*.jsonl`
|
|
94
|
+
glob would otherwise do one restart after any worker.
|
|
95
|
+
|
|
96
|
+
- **recall.py: `recallTagWeights` per-tag score penalty** (switchroom
|
|
97
|
+
hindsight-leverage PR5). A `{tag: multiplier}` config map (default `{}`,
|
|
98
|
+
env `HINDSIGHT_RECALL_TAG_WEIGHTS`) applied to each result's `scores.final`
|
|
99
|
+
immediately before the relevance sort. Unlike the demote-tag DROP filter
|
|
100
|
+
(`_is_demoted_memory`), which removes a tagged memory from recall entirely,
|
|
101
|
+
this DEMOTES (down-ranks) a memory while keeping it recallable when it is the
|
|
102
|
+
only relevant hit — the "reduced weight" the drop filter cannot express.
|
|
103
|
+
Switchroom's scaffold seeds `{"sidechain": 0.8}` so delegated-worker
|
|
104
|
+
process-memories rank just below first-party session memory. **Candidate to
|
|
105
|
+
upstream** — a general recall-shaping primitive, not switchroom-specific.
|
|
106
|
+
|
|
107
|
+
### Changed (switchroom divergence)
|
|
108
|
+
|
|
7
109
|
- **retain.py: decouple chunked window-slicing from the `retainEveryNTurns > 1`
|
|
8
110
|
throttle** (switchroom Phase 6b). Previously the chunked sliding-window only
|
|
9
111
|
applied when `retainEveryNTurns > 1`; with `retainEveryNTurns=1` (switchroom
|
|
@@ -183,8 +183,9 @@ Auto-recall runs on every user prompt. It queries Hindsight for relevant memorie
|
|
|
183
183
|
| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Controls how hard Hindsight searches for memories. `"low"` = fast, fewer strategies; `"mid"` = balanced; `"high"` = thorough, slower. Affects latency directly. |
|
|
184
184
|
| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Maximum number of tokens in the recalled memory block. Lower values reduce context usage but may truncate relevant memories. |
|
|
185
185
|
| `recallTypes` | — | `["world", "experience"]` | Which memory types to retrieve. `"world"` = general facts; `"experience"` = personal experiences; `"observation"` = raw observations. |
|
|
186
|
-
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `
|
|
186
|
+
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `2` | How many prior conversation turns to include when composing the recall query. `1` = only the latest user message; `2` (default) also embeds the antecedent human turn so a bare follow-up ("and the port?") recalls with context instead of on the pronoun alone. Higher values give more context but may dilute the query. The composed query is always bounded by `recallMaxQueryChars` (latest turn preserved, oldest context dropped first). |
|
|
187
187
|
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Maximum character length of the query sent to Hindsight. Longer queries are truncated. |
|
|
188
|
+
| `recallTranscriptTailBytes` | `HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES` | `262144` | Latency bound for multi-turn recall (`recallContextTurns > 1`): read only the last N bytes of the session transcript when slicing prior turns, so the per-recall read stays cheap on long sessions. `0` reads the whole file. |
|
|
188
189
|
| `recallRoles` | — | `["user", "assistant"]` | Which message roles to include when building the recall query from prior turns. |
|
|
189
190
|
| `recallTags` | `HINDSIGHT_RECALL_TAGS` | `[]` | Optional tags to pass to the recall API, such as `["memory_type:rule"]`. The env var accepts JSON or a comma-separated list. |
|
|
190
191
|
| `recallTagsMatch` | `HINDSIGHT_RECALL_TAGS_MATCH` | `"any"` | Tag matching mode used with `recallTags` or `recallTagGroups`: `"any"`, `"all"`, `"any_strict"`, or `"all_strict"`. |
|
|
@@ -43,6 +43,18 @@
|
|
|
43
43
|
]
|
|
44
44
|
}
|
|
45
45
|
],
|
|
46
|
+
"SubagentStop": [
|
|
47
|
+
{
|
|
48
|
+
"hooks": [
|
|
49
|
+
{
|
|
50
|
+
"type": "command",
|
|
51
|
+
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/subagent_retain.py\"",
|
|
52
|
+
"timeout": 15,
|
|
53
|
+
"async": true
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
],
|
|
46
58
|
"SessionEnd": [
|
|
47
59
|
{
|
|
48
60
|
"hooks": [
|
|
@@ -59,6 +59,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
59
59
|
|
|
60
60
|
from lib.config import debug_log, load_config # noqa: E402
|
|
61
61
|
from lib.directives import ( # noqa: E402
|
|
62
|
+
DIRECTIVES_CACHE_TTL_SECONDS,
|
|
63
|
+
invalidate_directives_cache,
|
|
62
64
|
parse_active_directives_block,
|
|
63
65
|
rule_already_captured,
|
|
64
66
|
)
|
|
@@ -317,6 +319,70 @@ def directive_recorded_after(messages: list, start_index: int) -> bool:
|
|
|
317
319
|
return False
|
|
318
320
|
|
|
319
321
|
|
|
322
|
+
# --- Directives-cache invalidation (switchroom hindsight-leverage A4) ---------
|
|
323
|
+
#
|
|
324
|
+
# recall.py caches the bank's active-directives list with a short TTL to skip an
|
|
325
|
+
# HTTP round-trip on the critical path. That cache would otherwise stay stale
|
|
326
|
+
# until the TTL elapsed — so this Stop hook, which already re-reads the turn's
|
|
327
|
+
# transcript, deletes the cache whenever the just-ended turn performed a
|
|
328
|
+
# directive WRITE (create/update/delete). The next recall then re-fetches, so an
|
|
329
|
+
# in-session directive change is visible in the very next turn's
|
|
330
|
+
# <active_directives> block (cross-process writes still rely on TTL alone).
|
|
331
|
+
|
|
332
|
+
# Matches the hindsight directive-write MCP tools by their bare or namespaced
|
|
333
|
+
# name, e.g. "create_directive", "mcp__hindsight__update_directive",
|
|
334
|
+
# "delete_directive". Read-only "list_directives" is deliberately excluded.
|
|
335
|
+
_DIRECTIVE_WRITE_TOOL_RE = re.compile(r"(?:create|update|delete)_directive")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _is_directive_write_tool(name) -> bool:
|
|
339
|
+
return isinstance(name, str) and bool(_DIRECTIVE_WRITE_TOOL_RE.search(name))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def turn_contains_directive_write(messages: list, start_index: int) -> bool:
|
|
343
|
+
"""True if any assistant turn after ``start_index`` issued a directive-write
|
|
344
|
+
tool_use (create/update/delete). ``start_index = -1`` scans all messages
|
|
345
|
+
(used when there is no human turn, e.g. a synthetic/cron inbound that still
|
|
346
|
+
wrote a directive). Pure inspection; no API call."""
|
|
347
|
+
for msg in messages[start_index + 1:]:
|
|
348
|
+
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
|
349
|
+
continue
|
|
350
|
+
content = msg.get("content")
|
|
351
|
+
if not isinstance(content, list):
|
|
352
|
+
continue
|
|
353
|
+
for p in content:
|
|
354
|
+
if (
|
|
355
|
+
isinstance(p, dict)
|
|
356
|
+
and p.get("type") == "tool_use"
|
|
357
|
+
and _is_directive_write_tool(p.get("name", ""))
|
|
358
|
+
):
|
|
359
|
+
return True
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def invalidate_cache_on_directive_write(messages: list, config: dict) -> None:
|
|
364
|
+
"""Delete the directives cache if this turn wrote a directive.
|
|
365
|
+
|
|
366
|
+
Takes the ALREADY-READ transcript ``messages`` (main() reads the transcript
|
|
367
|
+
exactly once and shares it with the capture verifier — a Stop hook must not
|
|
368
|
+
parse a multi-MB session twice). Only the tail after the last human turn is
|
|
369
|
+
scanned, since only writes issued THIS turn matter. Runs independently of
|
|
370
|
+
the capture-verify decision (and of the directiveCaptureNudge knob) — the
|
|
371
|
+
cache is a separate feature. Best-effort; never raises, so a bug here can
|
|
372
|
+
never wedge Stop.
|
|
373
|
+
"""
|
|
374
|
+
try:
|
|
375
|
+
if not messages:
|
|
376
|
+
return
|
|
377
|
+
idx, _text = find_last_human_turn(messages)
|
|
378
|
+
start = idx if idx is not None else -1
|
|
379
|
+
if turn_contains_directive_write(messages, start):
|
|
380
|
+
invalidate_directives_cache()
|
|
381
|
+
debug_log(config, "Directives cache invalidated — turn wrote a directive")
|
|
382
|
+
except Exception as e: # pragma: no cover - defensive; Stop must not wedge
|
|
383
|
+
debug_log(config, f"Directives cache invalidation skipped (error): {e}")
|
|
384
|
+
|
|
385
|
+
|
|
320
386
|
def read_transcript(transcript_path: str) -> list:
|
|
321
387
|
"""Read a JSONL transcript into a list of message dicts (role/content).
|
|
322
388
|
|
|
@@ -348,11 +414,15 @@ def read_transcript(transcript_path: str) -> list:
|
|
|
348
414
|
return messages
|
|
349
415
|
|
|
350
416
|
|
|
351
|
-
def evaluate(hook_input: dict, config: dict) -> str | None:
|
|
417
|
+
def evaluate(hook_input: dict, config: dict, messages: list | None = None) -> str | None:
|
|
352
418
|
"""Core decision. Returns a block reason string, or None to allow stop.
|
|
353
419
|
|
|
354
420
|
None → the turn is allowed to end (no-op). A non-empty string → block the
|
|
355
421
|
stop once and feed the string back to the model.
|
|
422
|
+
|
|
423
|
+
``messages`` is the pre-read transcript when the caller already parsed it
|
|
424
|
+
(main() reads once and shares it); when None, the transcript is read here so
|
|
425
|
+
direct callers/tests keep the old single-arg contract.
|
|
356
426
|
"""
|
|
357
427
|
# Same knob as Stage B — disabling the nudge disables this verification.
|
|
358
428
|
if not config.get("directiveCaptureNudge", True):
|
|
@@ -371,7 +441,8 @@ def evaluate(hook_input: dict, config: dict) -> str | None:
|
|
|
371
441
|
debug_log(config, "Directive-capture verify: stop_hook_active, not re-blocking")
|
|
372
442
|
return None
|
|
373
443
|
|
|
374
|
-
messages
|
|
444
|
+
if messages is None:
|
|
445
|
+
messages = read_transcript(hook_input.get("transcript_path", ""))
|
|
375
446
|
if not messages:
|
|
376
447
|
return None
|
|
377
448
|
|
|
@@ -423,8 +494,34 @@ def main():
|
|
|
423
494
|
config = load_config()
|
|
424
495
|
except Exception:
|
|
425
496
|
return
|
|
497
|
+
|
|
498
|
+
# Read the transcript AT MOST ONCE and share it with both consumers below
|
|
499
|
+
# (a Stop hook must not parse a multi-MB session twice, nor at all when
|
|
500
|
+
# nothing here needs it). Two features want the transcript:
|
|
501
|
+
# * A4 directives-cache invalidation — only when the cache is enabled
|
|
502
|
+
# (TTL > 0); with the cache off there is nothing to invalidate.
|
|
503
|
+
# * capture-verify (evaluate) — only when the nudge+verify knobs are on
|
|
504
|
+
# and this is not an already-blocked re-fire.
|
|
505
|
+
ttl = config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS)
|
|
506
|
+
cache_on = isinstance(ttl, (int, float)) and ttl > 0
|
|
507
|
+
verify_maybe = (
|
|
508
|
+
config.get("directiveCaptureNudge", True)
|
|
509
|
+
and config.get("directiveCaptureVerify", True)
|
|
510
|
+
and not hook_input.get("stop_hook_active")
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
messages: list = []
|
|
514
|
+
if cache_on or verify_maybe:
|
|
515
|
+
messages = read_transcript(hook_input.get("transcript_path", ""))
|
|
516
|
+
|
|
517
|
+
# A4: invalidate the directives cache when this turn wrote a directive, so
|
|
518
|
+
# the change is visible on the very next recall. Independent of the
|
|
519
|
+
# capture-verify decision below and self-guarded — runs on every Stop.
|
|
520
|
+
if cache_on:
|
|
521
|
+
invalidate_cache_on_directive_write(messages, config)
|
|
522
|
+
|
|
426
523
|
try:
|
|
427
|
-
reason = evaluate(hook_input, config)
|
|
524
|
+
reason = evaluate(hook_input, config, messages=messages)
|
|
428
525
|
except Exception as e: # never wedge a turn on a verify bug
|
|
429
526
|
debug_log(config, f"Directive-capture verify error (allowing stop): {e}")
|
|
430
527
|
return
|
|
@@ -71,8 +71,28 @@ DEFAULTS = {
|
|
|
71
71
|
# per-agent via memory.directive_capture_verify=false →
|
|
72
72
|
# HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
|
|
73
73
|
"directiveCaptureVerify": True,
|
|
74
|
-
|
|
74
|
+
# Switchroom hindsight-leverage A4 — TTL (seconds) for the directives-list
|
|
75
|
+
# cache on the recall critical path (see lib/directives.py). The list is
|
|
76
|
+
# re-fetched at most once per TTL window for no-write turns; in-session
|
|
77
|
+
# directive writes invalidate the cache immediately via directive_verify.py.
|
|
78
|
+
# 0 disables the cache (live fetch every turn — the A4 rollback lever).
|
|
79
|
+
"directivesCacheTtlSeconds": 120,
|
|
80
|
+
# Switchroom hindsight-leverage A2 (PR2): default 2 so a bare follow-up
|
|
81
|
+
# user message ("and the port?", "what about staging?") embeds together
|
|
82
|
+
# with its antecedent turn, instead of recalling on the pronoun alone.
|
|
83
|
+
# Bounded by recallMaxQueryChars (truncate_recall_query preserves the
|
|
84
|
+
# latest turn and drops oldest context first) so the composition can never
|
|
85
|
+
# blow the recall query budget; the transcript read is byte-tail-bounded
|
|
86
|
+
# by recallTranscriptTailBytes so the added per-turn read stays O(1).
|
|
87
|
+
"recallContextTurns": 2,
|
|
75
88
|
"recallMaxQueryChars": 800,
|
|
89
|
+
# Switchroom hindsight-leverage A2 (PR2) — latency bound for the multi-turn
|
|
90
|
+
# composition. With recallContextTurns>1 now the default, EVERY recall reads
|
|
91
|
+
# the transcript to slice the last N human turns. A long session's .jsonl can
|
|
92
|
+
# grow to many MB, so read only the last N bytes (complete trailing lines);
|
|
93
|
+
# the last 2-3 human turns always live at the tail. 0 disables the bound
|
|
94
|
+
# (read the whole file — the pre-A2 behaviour / rollback lever).
|
|
95
|
+
"recallTranscriptTailBytes": 262144,
|
|
76
96
|
"recallRoles": ["user", "assistant"],
|
|
77
97
|
# Upstream 962140eef — optional recall tag filters passed through to the
|
|
78
98
|
# recall API, plus per-additional-bank overrides keyed by bank ID.
|
|
@@ -80,6 +100,19 @@ DEFAULTS = {
|
|
|
80
100
|
"recallTagsMatch": "any",
|
|
81
101
|
"recallTagGroups": None,
|
|
82
102
|
"recallAdditionalBankFilters": {},
|
|
103
|
+
# Switchroom (hindsight-leverage PR5) — per-tag recall score weights. A map
|
|
104
|
+
# of ``{tag: multiplier}`` applied to each result's ``scores.final`` just
|
|
105
|
+
# before the final relevance sort/cap in recall.py. A weight < 1.0 DEMOTES
|
|
106
|
+
# (down-ranks) memories carrying that tag without DROPPING them — distinct
|
|
107
|
+
# from the hard demote-tag drop filter (`_is_demoted_memory`), which removes
|
|
108
|
+
# a memory from recall entirely. This is the "reduced weight" the drop
|
|
109
|
+
# filter cannot express: a demoted memory still surfaces when it is the only
|
|
110
|
+
# relevant hit, just below equal-scoring untagged memories. Default {} =
|
|
111
|
+
# no-op (identity weighting). Switchroom's scaffold seeds
|
|
112
|
+
# ``{"sidechain": 0.8}`` so delegated sub-agent process-memories rank just
|
|
113
|
+
# under first-party session memories. Env: HINDSIGHT_RECALL_TAG_WEIGHTS
|
|
114
|
+
# (JSON object).
|
|
115
|
+
"recallTagWeights": {},
|
|
83
116
|
"recallPromptPreamble": (
|
|
84
117
|
"Relevant memories from past conversations (prioritize recent when "
|
|
85
118
|
"conflicting). Only use memories that are directly useful to continue "
|
|
@@ -95,6 +128,56 @@ DEFAULTS = {
|
|
|
95
128
|
"retainContext": "claude-code",
|
|
96
129
|
"retainTags": [],
|
|
97
130
|
"retainMetadata": {},
|
|
131
|
+
# Switchroom hindsight-leverage E2 / PR9 (#398) — lesson & anti-pattern
|
|
132
|
+
# tagging at retain time. When on (default), build_retain_payload scans the
|
|
133
|
+
# formatted transcript slice for explicit lesson / anti-pattern markers and
|
|
134
|
+
# attaches the matching tag(s) to the retain. This is the retain-side half of
|
|
135
|
+
# #398: a transcript that captures a failure mode ("anti-pattern:", "what not
|
|
136
|
+
# to do") or a self-recognised lesson ("lesson learned", "note to self:") is
|
|
137
|
+
# tagged so the recall-side score-penalty weight map (recallTagWeights, PR5)
|
|
138
|
+
# can DEMOTE it below clean first-party session memories — without ever hard-
|
|
139
|
+
# dropping it. NON-GOAL (epic-recorded): historical corpus is NOT re-tagged;
|
|
140
|
+
# this fires on NEW retains only. Detection is deterministic substring match
|
|
141
|
+
# (case-insensitive), never model-dependent. Disable via
|
|
142
|
+
# HINDSIGHT_LESSON_TAGGING=false (rollback lever).
|
|
143
|
+
"lessonTagging": True,
|
|
144
|
+
# Marker map {tag: [substrings]}. A slice whose lower-cased text contains ANY
|
|
145
|
+
# of a tag's substrings gets that tag. Deliberately explicit prefixes to keep
|
|
146
|
+
# false positives low — a passing mention of the word "lesson" should not tag
|
|
147
|
+
# a whole transcript; an explicit "lesson learned" / "anti-pattern:" should.
|
|
148
|
+
# Operators can extend/replace via HINDSIGHT_LESSON_TAG_MARKERS (JSON object).
|
|
149
|
+
"lessonTagMarkers": {
|
|
150
|
+
"lesson": [
|
|
151
|
+
"lesson learned",
|
|
152
|
+
"lessons learned",
|
|
153
|
+
"lesson:",
|
|
154
|
+
"note to self:",
|
|
155
|
+
"for next time:",
|
|
156
|
+
"takeaway:",
|
|
157
|
+
"key takeaway",
|
|
158
|
+
],
|
|
159
|
+
"anti-pattern": [
|
|
160
|
+
"anti-pattern:",
|
|
161
|
+
"anti pattern:",
|
|
162
|
+
"antipattern:",
|
|
163
|
+
"what not to do",
|
|
164
|
+
"do not do this again",
|
|
165
|
+
"don't do this again",
|
|
166
|
+
"failure mode:",
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
# Switchroom hindsight-leverage E2 / PR9 (#398) — recall-side demotion weights
|
|
170
|
+
# for the lesson/anti-pattern tags above. Merged UNDER recallTagWeights at
|
|
171
|
+
# recall time (an explicit recallTagWeights entry for the same tag WINS), so
|
|
172
|
+
# lesson/anti-pattern transcripts are down-ranked out of the box while the
|
|
173
|
+
# PR5 sidechain seed and any operator override still compose cleanly. A raw
|
|
174
|
+
# transcript that merely discusses a failure mode should rank below a clean
|
|
175
|
+
# session memory of equal engine score, yet still surface when it is the only
|
|
176
|
+
# relevant hit (re-rank, never drop). Set HINDSIGHT_LESSON_DEMOTION=false to
|
|
177
|
+
# disable the built-in weights (rollback lever); override individual weights
|
|
178
|
+
# via recallTagWeights / HINDSIGHT_RECALL_TAG_WEIGHTS.
|
|
179
|
+
"lessonDemotion": True,
|
|
180
|
+
"lessonDemotionWeights": {"lesson": 0.85, "anti-pattern": 0.5},
|
|
98
181
|
# Switchroom #3244 — boot reconciliation of un-committed transcript tails.
|
|
99
182
|
# On by default; the load-bearing recovery for work an abrupt session death
|
|
100
183
|
# (SIGKILL/OOM/watchdog) skipped. Disable per-agent via
|
|
@@ -102,6 +185,46 @@ DEFAULTS = {
|
|
|
102
185
|
# HINDSIGHT_RECONCILE_{LOOKBACK_H,MAX_TURNS,BUDGET_S} bounds (read directly).
|
|
103
186
|
"reconcileOnStart": True,
|
|
104
187
|
"recallAdditionalBanks": [],
|
|
188
|
+
# Switchroom hindsight-leverage A3 — parallelise multi-bank recall.
|
|
189
|
+
# When on (default), the directives fetch and every bank recall run
|
|
190
|
+
# concurrently in daemon threads under ONE shared deadline
|
|
191
|
+
# (recallParallelDeadlineSeconds), so total critical-path latency is the
|
|
192
|
+
# SLOWEST slot instead of their SUM. Set false
|
|
193
|
+
# (HINDSIGHT_RECALL_PARALLEL=false) to restore the pre-A3 serial path —
|
|
194
|
+
# the rollback lever if the parallel path ever misbehaves.
|
|
195
|
+
"recallParallel": True,
|
|
196
|
+
# Shared deadline (seconds) for the whole parallel recall section. Sized
|
|
197
|
+
# at the UserPromptSubmit hook ceiling (12s, hooks.json) MINUS 2s headroom
|
|
198
|
+
# for block formatting + cache write + stdout flush, so a straggler bank
|
|
199
|
+
# can never push the hook past its ceiling. Slots still unfinished when the
|
|
200
|
+
# deadline elapses are abandoned (daemon threads) and marked timed_out.
|
|
201
|
+
"recallParallelDeadlineSeconds": 10,
|
|
202
|
+
# Switchroom hindsight-leverage E1 / PR8 (#3369) — bounded transcript-grep
|
|
203
|
+
# fallback. Boot reconciliation (reconcile_tail.py) closes the crash-loss
|
|
204
|
+
# window at the NEXT SessionStart, but between an abrupt kill and that boot,
|
|
205
|
+
# live recall returns nothing for the lost turns because the fact layer was
|
|
206
|
+
# never told about them. When on (default) AND every bank returned zero
|
|
207
|
+
# results AND no bank/directives slot hit its deadline (deadline_hit False —
|
|
208
|
+
# so a timed-out bank can't masquerade as a genuinely empty fact layer, the
|
|
209
|
+
# #3369 sequencing constraint that depends on A3's shared-deadline telemetry),
|
|
210
|
+
# recall greps the CURRENT session's transcript tail for turns that mention
|
|
211
|
+
# the query's terms and injects them as a clearly-labelled, lower-confidence
|
|
212
|
+
# fallback block. Everything is bounded: the tail read (…MaxBytes), the number
|
|
213
|
+
# of matched turns (…MaxTurns), the emitted characters (…MaxChars), and the
|
|
214
|
+
# grep wall-time (…DeadlineMs). Set false (HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK
|
|
215
|
+
# =false) to disable — the rollback lever.
|
|
216
|
+
"recallTranscriptFallback": True,
|
|
217
|
+
# Tail bytes read from the session transcript for the grep. 256 KiB covers
|
|
218
|
+
# many turns of recent conversation while keeping the read well inside the
|
|
219
|
+
# hook budget; only the LAST max_bytes are read (partial first line dropped).
|
|
220
|
+
"recallTranscriptFallbackMaxBytes": 262144,
|
|
221
|
+
# Hard ceiling on matched turns injected into the fallback block.
|
|
222
|
+
"recallTranscriptFallbackMaxTurns": 6,
|
|
223
|
+
# Hard ceiling on the fallback block's excerpt characters.
|
|
224
|
+
"recallTranscriptFallbackMaxChars": 2000,
|
|
225
|
+
# Wall-clock bound (ms) on the grep itself — abandons scanning older turns
|
|
226
|
+
# once exceeded so the fallback can never eat the recall critical path.
|
|
227
|
+
"recallTranscriptFallbackDeadlineMs": 1500,
|
|
105
228
|
# Connection
|
|
106
229
|
"hindsightApiUrl": None,
|
|
107
230
|
"hindsightApiToken": None,
|
|
@@ -140,6 +263,12 @@ ENV_OVERRIDES = {
|
|
|
140
263
|
"HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
|
|
141
264
|
"HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
|
|
142
265
|
"HINDSIGHT_RETAIN_MODE": ("retainMode", str),
|
|
266
|
+
# Switchroom hindsight-leverage E2 / PR9 (#398) — lesson/anti-pattern tagging
|
|
267
|
+
# + recall demotion toggles and overrides.
|
|
268
|
+
"HINDSIGHT_LESSON_TAGGING": ("lessonTagging", bool),
|
|
269
|
+
"HINDSIGHT_LESSON_TAG_MARKERS": ("lessonTagMarkers", dict),
|
|
270
|
+
"HINDSIGHT_LESSON_DEMOTION": ("lessonDemotion", bool),
|
|
271
|
+
"HINDSIGHT_LESSON_DEMOTION_WEIGHTS": ("lessonDemotionWeights", dict),
|
|
143
272
|
# Switchroom #3244 — boot reconciliation on/off (default on).
|
|
144
273
|
"HINDSIGHT_RECONCILE_ON_START": ("reconcileOnStart", bool),
|
|
145
274
|
"HINDSIGHT_RECALL_BUDGET": ("recallBudget", str),
|
|
@@ -171,13 +300,33 @@ ENV_OVERRIDES = {
|
|
|
171
300
|
# agents.<name>.memory.directive_capture_verify only when the operator
|
|
172
301
|
# overrode it; the switchroom default is on.
|
|
173
302
|
"HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
|
|
303
|
+
# Switchroom hindsight-leverage A4 — directives-list cache TTL (seconds).
|
|
304
|
+
# 0 disables the cache (rollback lever).
|
|
305
|
+
"HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS": ("directivesCacheTtlSeconds", int),
|
|
306
|
+
# Switchroom hindsight-leverage A3 — parallel multi-bank recall toggle +
|
|
307
|
+
# shared deadline. HINDSIGHT_RECALL_PARALLEL=false is the serial rollback
|
|
308
|
+
# lever; the deadline is the ceiling-minus-2s hard budget (see DEFAULTS).
|
|
309
|
+
"HINDSIGHT_RECALL_PARALLEL": ("recallParallel", bool),
|
|
310
|
+
"HINDSIGHT_RECALL_PARALLEL_DEADLINE_SECONDS": ("recallParallelDeadlineSeconds", int),
|
|
311
|
+
# Switchroom hindsight-leverage E1 / PR8 (#3369) — transcript-grep fallback
|
|
312
|
+
# toggle + bounds. HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK=false is the rollback
|
|
313
|
+
# lever; the others tune the byte / turn / char / time bounds.
|
|
314
|
+
"HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK": ("recallTranscriptFallback", bool),
|
|
315
|
+
"HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_BYTES": ("recallTranscriptFallbackMaxBytes", int),
|
|
316
|
+
"HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_TURNS": ("recallTranscriptFallbackMaxTurns", int),
|
|
317
|
+
"HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_MAX_CHARS": ("recallTranscriptFallbackMaxChars", int),
|
|
318
|
+
"HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK_DEADLINE_MS": ("recallTranscriptFallbackDeadlineMs", int),
|
|
174
319
|
"HINDSIGHT_RECALL_MAX_QUERY_CHARS": ("recallMaxQueryChars", int),
|
|
175
320
|
"HINDSIGHT_RECALL_CONTEXT_TURNS": ("recallContextTurns", int),
|
|
321
|
+
# Switchroom hindsight-leverage A2 — byte-tail bound for the multi-turn
|
|
322
|
+
# transcript read (0 = read whole file / rollback lever).
|
|
323
|
+
"HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES": ("recallTranscriptTailBytes", int),
|
|
176
324
|
# Upstream 962140eef — recall tag filters. The tags env var accepts JSON
|
|
177
325
|
# or a comma-separated list; the others must be JSON.
|
|
178
326
|
"HINDSIGHT_RECALL_TAGS": ("recallTags", list),
|
|
179
327
|
"HINDSIGHT_RECALL_TAGS_MATCH": ("recallTagsMatch", str),
|
|
180
328
|
"HINDSIGHT_RECALL_TAG_GROUPS": ("recallTagGroups", dict),
|
|
329
|
+
"HINDSIGHT_RECALL_TAG_WEIGHTS": ("recallTagWeights", dict),
|
|
181
330
|
"HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS": ("recallAdditionalBankFilters", dict),
|
|
182
331
|
"HINDSIGHT_API_PORT": ("apiPort", int),
|
|
183
332
|
"HINDSIGHT_DAEMON_IDLE_TIMEOUT": ("daemonIdleTimeout", int),
|