switchroom 0.20.21 → 0.20.22
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/auth-broker/index.js +1 -1
- package/dist/cli/switchroom.js +2 -2
- package/dist/host-control/main.js +2 -2
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +5 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +88 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +112 -11
- package/vendor/hindsight-memory/scripts/recall.py +11 -2
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +36 -0
- package/vendor/hindsight-memory/scripts/retain.py +6 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_retain_env.py +99 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_types_filter.py +81 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +113 -0
- package/vendor/hindsight-memory/settings.json +2 -2
- package/vendor/hindsight-memory/tests/test_config.py +8 -3
- package/vendor/hindsight-memory/tests/test_hooks.py +10 -1
- package/vendor/hindsight-memory/tests/test_retain_context.py +69 -0
|
@@ -19033,7 +19033,7 @@ function allocateAgentUid(name) {
|
|
|
19033
19033
|
var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
|
|
19034
19034
|
var HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT = "Relevant memories from past conversations (prioritize recent when " + "conflicting). Only use memories that are directly useful to continue " + "this conversation; ignore the rest:";
|
|
19035
19035
|
var RECALL_PASSTHROUGH_DEFAULTS = Object.freeze({
|
|
19036
|
-
budget: "
|
|
19036
|
+
budget: "mid",
|
|
19037
19037
|
maxTokens: 1024,
|
|
19038
19038
|
preferObservations: true,
|
|
19039
19039
|
contextTurns: 2,
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.20.
|
|
2123
|
+
var VERSION = "0.20.22", COMMIT_SHA = "ad3f50bd";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -21194,7 +21194,7 @@ var init_hindsight_recall_passthrough = __esm(() => {
|
|
|
21194
21194
|
RECALL_BUDGETS = ["low", "mid", "high"];
|
|
21195
21195
|
RECALL_TAGS_MATCH_MODES = ["any", "all", "any_strict", "all_strict"];
|
|
21196
21196
|
RECALL_PASSTHROUGH_DEFAULTS = Object.freeze({
|
|
21197
|
-
budget: "
|
|
21197
|
+
budget: "mid",
|
|
21198
21198
|
maxTokens: 1024,
|
|
21199
21199
|
preferObservations: true,
|
|
21200
21200
|
contextTurns: 2,
|
|
@@ -21565,13 +21565,13 @@ function allocateAgentUid(name) {
|
|
|
21565
21565
|
}
|
|
21566
21566
|
|
|
21567
21567
|
// src/build-info.ts
|
|
21568
|
-
var VERSION = "0.20.
|
|
21568
|
+
var VERSION = "0.20.22";
|
|
21569
21569
|
|
|
21570
21570
|
// src/setup/hindsight-recall-passthrough.ts
|
|
21571
21571
|
var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
|
|
21572
21572
|
var HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT = "Relevant memories from past conversations (prioritize recent when " + "conflicting). Only use memories that are directly useful to continue " + "this conversation; ignore the rest:";
|
|
21573
21573
|
var RECALL_PASSTHROUGH_DEFAULTS = Object.freeze({
|
|
21574
|
-
budget: "
|
|
21574
|
+
budget: "mid",
|
|
21575
21575
|
maxTokens: 1024,
|
|
21576
21576
|
preferObservations: true,
|
|
21577
21577
|
contextTurns: 2,
|
|
@@ -19466,7 +19466,7 @@ function allocateAgentUid(name) {
|
|
|
19466
19466
|
var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
|
|
19467
19467
|
var HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT = "Relevant memories from past conversations (prioritize recent when " + "conflicting). Only use memories that are directly useful to continue " + "this conversation; ignore the rest:";
|
|
19468
19468
|
var RECALL_PASSTHROUGH_DEFAULTS = Object.freeze({
|
|
19469
|
-
budget: "
|
|
19469
|
+
budget: "mid",
|
|
19470
19470
|
maxTokens: 1024,
|
|
19471
19471
|
preferObservations: true,
|
|
19472
19472
|
contextTurns: 2,
|
|
@@ -19707,7 +19707,7 @@ function allocateAgentUid(name) {
|
|
|
19707
19707
|
var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
|
|
19708
19708
|
var HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT = "Relevant memories from past conversations (prioritize recent when " + "conflicting). Only use memories that are directly useful to continue " + "this conversation; ignore the rest:";
|
|
19709
19709
|
var RECALL_PASSTHROUGH_DEFAULTS = Object.freeze({
|
|
19710
|
-
budget: "
|
|
19710
|
+
budget: "mid",
|
|
19711
19711
|
maxTokens: 1024,
|
|
19712
19712
|
preferObservations: true,
|
|
19713
19713
|
contextTurns: 2,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.20.
|
|
4
|
+
"version": "0.20.22",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -87850,7 +87850,7 @@ init_generation_stamp();
|
|
|
87850
87850
|
var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
|
|
87851
87851
|
var HINDSIGHT_RECALL_PROMPT_PREAMBLE_DEFAULT = "Relevant memories from past conversations (prioritize recent when " + "conflicting). Only use memories that are directly useful to continue " + "this conversation; ignore the rest:";
|
|
87852
87852
|
var RECALL_PASSTHROUGH_DEFAULTS = Object.freeze({
|
|
87853
|
-
budget: "
|
|
87853
|
+
budget: "mid",
|
|
87854
87854
|
maxTokens: 1024,
|
|
87855
87855
|
preferObservations: true,
|
|
87856
87856
|
contextTurns: 2,
|
|
@@ -103483,10 +103483,10 @@ function startOutboxSweep(deps) {
|
|
|
103483
103483
|
}
|
|
103484
103484
|
|
|
103485
103485
|
// ../src/build-info.ts
|
|
103486
|
-
var VERSION2 = "0.20.
|
|
103487
|
-
var COMMIT_SHA = "
|
|
103488
|
-
var COMMIT_DATE = "2026-08-
|
|
103489
|
-
var LATEST_PR =
|
|
103486
|
+
var VERSION2 = "0.20.22";
|
|
103487
|
+
var COMMIT_SHA = "ad3f50bd";
|
|
103488
|
+
var COMMIT_DATE = "2026-08-09T18:15:22Z";
|
|
103489
|
+
var LATEST_PR = 4577;
|
|
103490
103490
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
103491
103491
|
|
|
103492
103492
|
// gateway/boot-version.ts
|
|
@@ -171,6 +171,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
171
171
|
|
|
172
172
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
173
173
|
|
|
174
|
+
from lib import watermark
|
|
174
175
|
from lib.client import HindsightClient
|
|
175
176
|
from lib.config import debug_log, load_config
|
|
176
177
|
from lib.pending import (
|
|
@@ -770,6 +771,72 @@ def _record_failure(
|
|
|
770
771
|
return err_class
|
|
771
772
|
|
|
772
773
|
|
|
774
|
+
def _reconcile_index(entries: list[tuple[str, dict]]) -> dict:
|
|
775
|
+
"""``session_id -> set(abs_path)`` for reconcile-sourced queue entries.
|
|
776
|
+
|
|
777
|
+
Powers the no-loss sibling guard in ``_commit_reconcile_watermark``. Only
|
|
778
|
+
entries reconcile stamped (``reconcile_session_id``, switchroom #4571) are
|
|
779
|
+
indexed; everything else is ignored, so a non-reconcile queue entry for the
|
|
780
|
+
same session never blocks — its turns are covered by the reconcile slice's
|
|
781
|
+
own document, not by it.
|
|
782
|
+
"""
|
|
783
|
+
idx: dict[str, set[str]] = {}
|
|
784
|
+
for path, entry in entries:
|
|
785
|
+
sid = entry.get("reconcile_session_id")
|
|
786
|
+
if sid:
|
|
787
|
+
idx.setdefault(sid, set()).add(os.path.abspath(path))
|
|
788
|
+
return idx
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _commit_reconcile_watermark(entry: dict, path: str, index: dict) -> None:
|
|
792
|
+
"""Advance the transcript watermark for a just-confirmed reconcile entry.
|
|
793
|
+
|
|
794
|
+
switchroom #4571. Called ONLY from a confirmed-durable retire branch — a
|
|
795
|
+
synchronous (``async_processing=False``) retain 200, or a presence GET that
|
|
796
|
+
returned True — so reaching here means this entry's content is durable. A
|
|
797
|
+
no-op for a non-reconcile entry (no ``reconcile_session_id``).
|
|
798
|
+
|
|
799
|
+
NO-LOSS GUARD. The watermark is a single "last contiguously-committed entry"
|
|
800
|
+
pointer, so it may only ever advance to the TRANSCRIPT TAIL, and only once
|
|
801
|
+
NO other slice of the same session is still queued:
|
|
802
|
+
|
|
803
|
+
* ``reconcile_is_tail`` False → an older turn-cap-split remainder that ends
|
|
804
|
+
mid-transcript; advancing to it would skip the newer turns after it.
|
|
805
|
+
* a live sibling in ``index`` → an unconfirmed earlier remainder OR a
|
|
806
|
+
size-split part of this same tail; advancing now could jump the watermark
|
|
807
|
+
past turns that are not yet durable.
|
|
808
|
+
|
|
809
|
+
Either way we leave the watermark and accept a redundant re-enqueue on the
|
|
810
|
+
next boot. ``watermark.commit`` is monotonic + idempotent and refuses
|
|
811
|
+
backward/compacted moves, so over-conservatism only ever costs repeat work,
|
|
812
|
+
never a lost turn — and that is the side to err on.
|
|
813
|
+
"""
|
|
814
|
+
sid = entry.get("reconcile_session_id")
|
|
815
|
+
if not sid:
|
|
816
|
+
return
|
|
817
|
+
live = index.get(sid)
|
|
818
|
+
if live is not None:
|
|
819
|
+
# This entry is now durable + archived; drop it from the live set so a
|
|
820
|
+
# sibling that drains AFTER it is no longer blocked by it.
|
|
821
|
+
live.discard(os.path.abspath(path))
|
|
822
|
+
if not entry.get("reconcile_is_tail"):
|
|
823
|
+
return
|
|
824
|
+
last_uuid = entry.get("reconcile_last_uuid")
|
|
825
|
+
if not last_uuid:
|
|
826
|
+
return
|
|
827
|
+
if live: # an earlier remainder / split part of this session is still queued
|
|
828
|
+
return
|
|
829
|
+
try:
|
|
830
|
+
watermark.commit(
|
|
831
|
+
sid,
|
|
832
|
+
last_uuid,
|
|
833
|
+
entry.get("document_id", ""),
|
|
834
|
+
ordered_uuids=entry.get("reconcile_ordered_uuids"),
|
|
835
|
+
)
|
|
836
|
+
except Exception: # pragma: no cover - a watermark write must never fail a drain
|
|
837
|
+
pass
|
|
838
|
+
|
|
839
|
+
|
|
773
840
|
def _new_summary() -> dict:
|
|
774
841
|
return {
|
|
775
842
|
"drained": 0,
|
|
@@ -892,7 +959,12 @@ def _drain_inhook_impl(config: dict, force: bool = False) -> dict:
|
|
|
892
959
|
# behind them — see ``_drain_order``. Matters even more here than in the
|
|
893
960
|
# backlog drain: the in-hook budget is ~4s, so a single entry at the head
|
|
894
961
|
# that always burns its clamped timeout consumes the entire run.
|
|
895
|
-
|
|
962
|
+
all_entries = iter_entries()
|
|
963
|
+
# Built from the FULL queue (before parking): a parked reconcile entry is
|
|
964
|
+
# still queued and unconfirmed, so it must still block a sibling's watermark
|
|
965
|
+
# advance (#4571).
|
|
966
|
+
reconcile_index = _reconcile_index(all_entries)
|
|
967
|
+
entries = _park_broken(_drain_order(all_entries), summary, force)
|
|
896
968
|
if not entries:
|
|
897
969
|
debug_log(
|
|
898
970
|
config,
|
|
@@ -938,6 +1010,7 @@ def _drain_inhook_impl(config: dict, force: bool = False) -> dict:
|
|
|
938
1010
|
if _document_state(entry, timeout=_clamp(timeout, budget, started)) is True:
|
|
939
1011
|
if archive_reconciled(path):
|
|
940
1012
|
summary["reconciled"] += 1
|
|
1013
|
+
_commit_reconcile_watermark(entry, path, reconcile_index)
|
|
941
1014
|
else:
|
|
942
1015
|
# Archive unwritable: the entry is STILL QUEUED (it is
|
|
943
1016
|
# never deleted), so calling it reconciled would be a lie.
|
|
@@ -1003,6 +1076,7 @@ def _drain_inhook_impl(config: dict, force: bool = False) -> dict:
|
|
|
1003
1076
|
# horizon costs.
|
|
1004
1077
|
if archive_reconciled(path):
|
|
1005
1078
|
summary["drained"] += 1
|
|
1079
|
+
_commit_reconcile_watermark(entry, path, reconcile_index)
|
|
1006
1080
|
else:
|
|
1007
1081
|
summary["archive_failed"] += 1
|
|
1008
1082
|
consecutive_failures = 0
|
|
@@ -1056,7 +1130,7 @@ def _phase_failed(summary: dict, phase: str, e: BaseException, cost: str) -> Non
|
|
|
1056
1130
|
)
|
|
1057
1131
|
|
|
1058
1132
|
|
|
1059
|
-
def _reconcile_phase(config: dict, summary: dict, dry_run: bool) -> None:
|
|
1133
|
+
def _reconcile_phase(config: dict, summary: dict, dry_run: bool, reconcile_index: dict) -> None:
|
|
1060
1134
|
"""PHASE 1 — free pass: drop entries whose document already exists.
|
|
1061
1135
|
|
|
1062
1136
|
This is the phase that makes backlog replay affordable. 70.4% of a
|
|
@@ -1082,8 +1156,11 @@ def _reconcile_phase(config: dict, summary: dict, dry_run: bool) -> None:
|
|
|
1082
1156
|
continue
|
|
1083
1157
|
state = _document_state(entry)
|
|
1084
1158
|
if state is True:
|
|
1085
|
-
if dry_run
|
|
1159
|
+
if dry_run:
|
|
1160
|
+
summary["reconciled"] += 1
|
|
1161
|
+
elif archive_reconciled(path):
|
|
1086
1162
|
summary["reconciled"] += 1
|
|
1163
|
+
_commit_reconcile_watermark(entry, path, reconcile_index)
|
|
1087
1164
|
else:
|
|
1088
1165
|
summary["archive_failed"] += 1
|
|
1089
1166
|
elif state is None:
|
|
@@ -1264,8 +1341,14 @@ def _drain_backlog_impl(
|
|
|
1264
1341
|
f"archived, not deleted"
|
|
1265
1342
|
)
|
|
1266
1343
|
|
|
1344
|
+
# Built AFTER the pre-drain phases (collapse / re-split may have changed the
|
|
1345
|
+
# queue) and shared across the reconcile pass and phase 2, so a sibling that
|
|
1346
|
+
# phase 1 retires is dropped from the live set before phase 2 evaluates the
|
|
1347
|
+
# tail's watermark advance (#4571).
|
|
1348
|
+
reconcile_index = _reconcile_index(iter_entries())
|
|
1349
|
+
|
|
1267
1350
|
if phase in ("reconcile", "both"):
|
|
1268
|
-
_reconcile_phase(config, summary, dry_run)
|
|
1351
|
+
_reconcile_phase(config, summary, dry_run, reconcile_index)
|
|
1269
1352
|
if phase == "reconcile":
|
|
1270
1353
|
return summary
|
|
1271
1354
|
|
|
@@ -1356,6 +1439,7 @@ def _drain_backlog_impl(
|
|
|
1356
1439
|
if _document_state(entry) is True:
|
|
1357
1440
|
if archive_reconciled(path):
|
|
1358
1441
|
summary["drained"] += 1
|
|
1442
|
+
_commit_reconcile_watermark(entry, path, reconcile_index)
|
|
1359
1443
|
else:
|
|
1360
1444
|
summary["archive_failed"] += 1
|
|
1361
1445
|
else:
|
|
@@ -48,16 +48,19 @@ DEFAULT_VOLATILE_SCOPE_PATTERNS = (
|
|
|
48
48
|
DEFAULTS = {
|
|
49
49
|
# Recall
|
|
50
50
|
"autoRecall": True,
|
|
51
|
-
# Switchroom default: "
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
"
|
|
51
|
+
# Switchroom fleet default: "mid". The budget sets candidate DEPTH — how
|
|
52
|
+
# many nodes the engine pulls across all TEMPR retrieval stages before
|
|
53
|
+
# ranking (recall_budget_fixed_low=100 / _mid=300 / _high=1000 units,
|
|
54
|
+
# hindsight_api engine/memory_engine.py). It does NOT gate the reranker:
|
|
55
|
+
# the cross-encoder runs at EVERY budget level and is bounded separately by
|
|
56
|
+
# RERANKER_MAX_CANDIDATES (HINDSIGHT_API_RERANKER_MAX_CANDIDATES, default
|
|
57
|
+
# 300), so "low" is not "vector-only, no rerank" — it is a shallower
|
|
58
|
+
# candidate pool feeding the same rerank+score pipeline. "mid" (300 nodes)
|
|
59
|
+
# is upstream's own default and the balanced point: deeper recall than
|
|
60
|
+
# "low" without "high"'s 1000-node cold-page tail. Operators who want the
|
|
61
|
+
# shallow/fast pool back set HINDSIGHT_RECALL_BUDGET=low via per-agent env
|
|
62
|
+
# or write `recallBudget: "low"` into the user config file.
|
|
63
|
+
"recallBudget": "mid",
|
|
61
64
|
"recallMaxTokens": 1024,
|
|
62
65
|
# Switchroom-local: cap on the number of memories injected into the
|
|
63
66
|
# `<hindsight_memories>` block, regardless of token budget. Plugin v0.4.0
|
|
@@ -206,7 +209,17 @@ DEFAULTS = {
|
|
|
206
209
|
"retainEveryNTurns": 10,
|
|
207
210
|
"retainOverlapTurns": 2,
|
|
208
211
|
"retainToolCalls": True,
|
|
209
|
-
|
|
212
|
+
# Switchroom — speaker-aware retain context. Resolved per-retain via
|
|
213
|
+
# build_retain_payload's _resolve_template, which fills {agent} from
|
|
214
|
+
# SWITCHROOM_AGENT_NAME and {bank_id} from the target bank. Tells the
|
|
215
|
+
# consolidation LLM who is speaking on each line so first-person agent
|
|
216
|
+
# actions ("experience") are not confused with the operator's world facts.
|
|
217
|
+
"retainContext": (
|
|
218
|
+
"Transcript of Claude Code agent '{agent}' ({bank_id}). "
|
|
219
|
+
"'assistant'/tool lines are the agent's own first-person actions "
|
|
220
|
+
"(experience); 'user' lines are the human operator speaking (their "
|
|
221
|
+
"statements are world facts)."
|
|
222
|
+
),
|
|
210
223
|
"retainTags": [],
|
|
211
224
|
"retainMetadata": {},
|
|
212
225
|
# Switchroom-local: per-row Hindsight `observation_scopes` on every retain.
|
|
@@ -396,6 +409,23 @@ ENV_OVERRIDES = {
|
|
|
396
409
|
"HINDSIGHT_AUTO_RECALL": ("autoRecall", bool),
|
|
397
410
|
"HINDSIGHT_AUTO_RETAIN": ("autoRetain", bool),
|
|
398
411
|
"HINDSIGHT_RETAIN_MODE": ("retainMode", str),
|
|
412
|
+
# Switchroom-local: auto-retain cadence knobs. These had a DEFAULTS entry and
|
|
413
|
+
# a settings.json stamp (applyHindsightSettingsOverrides) but NO env channel,
|
|
414
|
+
# so env — the TOP of the config precedence chain (DEFAULTS → settings.json →
|
|
415
|
+
# ~/.hindsight/claude-code.json → env) — could not reach them at all. That
|
|
416
|
+
# broke parity with the recall knobs and left the only override paths as a
|
|
417
|
+
# settings.json rewrite (scaffold-time) or a hand-edit that `switchroom apply`
|
|
418
|
+
# re-copies away. Adding the env keys lets `memory.retain.*` (or an agent
|
|
419
|
+
# `env:` map, or a docker-exec'd retain that does not inherit the supervised
|
|
420
|
+
# env) drive them, and makes the env value authoritative when set.
|
|
421
|
+
# `retainEveryNTurns` / `retainOverlapTurns` mirror the yaml surface
|
|
422
|
+
# (`memory.retain.every_n_turns` / `.overlap_turns`); `retainContext` /
|
|
423
|
+
# `retainTags` have no yaml surface yet but gain the same env channel as the
|
|
424
|
+
# other retain knobs for consistency and exec-path overrides.
|
|
425
|
+
"HINDSIGHT_RETAIN_EVERY_N_TURNS": ("retainEveryNTurns", int),
|
|
426
|
+
"HINDSIGHT_RETAIN_OVERLAP_TURNS": ("retainOverlapTurns", int),
|
|
427
|
+
"HINDSIGHT_RETAIN_CONTEXT": ("retainContext", str),
|
|
428
|
+
"HINDSIGHT_RETAIN_TAGS": ("retainTags", list),
|
|
399
429
|
# Switchroom-local: per-row observation scope on retains. Set by start.sh
|
|
400
430
|
# from agents.<name>.memory.observation_scopes (cascading through
|
|
401
431
|
# defaults.memory.observation_scopes) ONLY when the operator set it; unset
|
|
@@ -520,6 +550,77 @@ ENV_OVERRIDES = {
|
|
|
520
550
|
OBSERVATION_SCOPES_VALUES = ("per_tag", "combined", "all_combinations", "shared")
|
|
521
551
|
|
|
522
552
|
|
|
553
|
+
#: Switchroom-local: the fact types Hindsight's recall endpoint accepts. Sending
|
|
554
|
+
#: any other value makes the 0.9.0 engine return HTTP 422 ("Invalid fact type(s):
|
|
555
|
+
#: … Must be one of: experience, observation, world") — a validation that was
|
|
556
|
+
#: silently tolerated before vectorize-io/hindsight#3062. A 422 fails the WHOLE
|
|
557
|
+
#: recall for the turn, so an operator typo in `memory.recall.types` (e.g.
|
|
558
|
+
#: "observations", "fact") would otherwise kill memory injection on EVERY turn.
|
|
559
|
+
#: Verified against the live engine's 422 detail string and /openapi.json
|
|
560
|
+
#: RecallRequest; widening this set means widening it server-side too.
|
|
561
|
+
RECALL_FACT_TYPES = ("world", "experience", "observation")
|
|
562
|
+
|
|
563
|
+
#: The recall types used when a configured `recallTypes` filters down to empty
|
|
564
|
+
#: (mirrors the `recallTypes` DEFAULTS entry). Falling back to this — rather than
|
|
565
|
+
#: sending an empty/invalid set — keeps recall running with the shipped behaviour
|
|
566
|
+
#: instead of degrading to nothing.
|
|
567
|
+
DEFAULT_RECALL_FACT_TYPES = ("world", "experience")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def filter_recall_types(config: dict):
|
|
571
|
+
"""Filter ``recallTypes`` to the set Hindsight's recall endpoint accepts.
|
|
572
|
+
|
|
573
|
+
Returns the value to send as the recall ``types`` argument. THIS FUNCTION
|
|
574
|
+
MUST NEVER RAISE: an invalid ``memory.recall.types`` value is a
|
|
575
|
+
misconfiguration, and both raising here and passing the bad value through
|
|
576
|
+
have the same catastrophic outcome — a 422 that fails the recall and drops
|
|
577
|
+
memory injection for the turn. This mirrors the degrade-don't-raise contract
|
|
578
|
+
of :func:`compute_observation_scopes` (a bad config degrades the FEATURE,
|
|
579
|
+
never loses the turn).
|
|
580
|
+
|
|
581
|
+
* ``None`` / unset → ``None``: omit the field entirely, letting the engine
|
|
582
|
+
apply its own default (world + experience). Byte-identical to the wire
|
|
583
|
+
body a pre-filter client sent.
|
|
584
|
+
* a list/tuple → keep the members in :data:`RECALL_FACT_TYPES` (order
|
|
585
|
+
and de-duplicated), dropping every unknown value WITH a stderr warning
|
|
586
|
+
that names it. If nothing valid survives, fall back to
|
|
587
|
+
:data:`DEFAULT_RECALL_FACT_TYPES` so recall still runs.
|
|
588
|
+
* anything else → shout and fall back to :data:`DEFAULT_RECALL_FACT_TYPES`.
|
|
589
|
+
"""
|
|
590
|
+
raw = config.get("recallTypes")
|
|
591
|
+
if raw is None:
|
|
592
|
+
return None
|
|
593
|
+
if not isinstance(raw, (list, tuple)):
|
|
594
|
+
print(
|
|
595
|
+
f"[Hindsight] recallTypes={raw!r} is not a list; falling back to "
|
|
596
|
+
f"{list(DEFAULT_RECALL_FACT_TYPES)}. Set it via `memory.recall.types` "
|
|
597
|
+
"in switchroom.yaml.",
|
|
598
|
+
file=sys.stderr,
|
|
599
|
+
)
|
|
600
|
+
return list(DEFAULT_RECALL_FACT_TYPES)
|
|
601
|
+
valid = []
|
|
602
|
+
for fact_type in raw:
|
|
603
|
+
if isinstance(fact_type, str) and fact_type in RECALL_FACT_TYPES:
|
|
604
|
+
if fact_type not in valid:
|
|
605
|
+
valid.append(fact_type)
|
|
606
|
+
else:
|
|
607
|
+
print(
|
|
608
|
+
f"[Hindsight] recallTypes entry {fact_type!r} is not a valid "
|
|
609
|
+
f"Hindsight fact type ({', '.join(RECALL_FACT_TYPES)}); dropping "
|
|
610
|
+
"it. An invalid type 422s the recall and drops memory injection "
|
|
611
|
+
"for the turn — fix it via `memory.recall.types` in switchroom.yaml.",
|
|
612
|
+
file=sys.stderr,
|
|
613
|
+
)
|
|
614
|
+
if not valid:
|
|
615
|
+
print(
|
|
616
|
+
f"[Hindsight] recallTypes={raw!r} left no valid fact types after "
|
|
617
|
+
f"filtering; falling back to {list(DEFAULT_RECALL_FACT_TYPES)}.",
|
|
618
|
+
file=sys.stderr,
|
|
619
|
+
)
|
|
620
|
+
return list(DEFAULT_RECALL_FACT_TYPES)
|
|
621
|
+
return valid
|
|
622
|
+
|
|
623
|
+
|
|
523
624
|
def classify_observation_scopes(config: dict):
|
|
524
625
|
"""Classify ``observationScopes`` WITHOUT raising: ``(value, error)``.
|
|
525
626
|
|
|
@@ -59,7 +59,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
59
59
|
|
|
60
60
|
from lib.bank import derive_bank_id, ensure_bank_mission
|
|
61
61
|
from lib.client import HindsightClient
|
|
62
|
-
from lib.config import debug_log, load_config
|
|
62
|
+
from lib.config import debug_log, filter_recall_types, load_config
|
|
63
63
|
from lib.content import (
|
|
64
64
|
_extract_text_content,
|
|
65
65
|
compose_recall_query,
|
|
@@ -2091,6 +2091,15 @@ def main():
|
|
|
2091
2091
|
if recall_request_timeout <= 0:
|
|
2092
2092
|
recall_request_timeout = 12.0
|
|
2093
2093
|
|
|
2094
|
+
# Fail-safe the recall `types` BEFORE any bank task runs: the 0.9.0 engine
|
|
2095
|
+
# 422s an invalid fact type (e.g. an operator typo "observations"/"fact" in
|
|
2096
|
+
# memory.recall.types), which fails the whole recall and drops memory
|
|
2097
|
+
# injection for the turn. filter_recall_types drops unknowns (shouting on
|
|
2098
|
+
# stderr) and falls back to the default set if the filter empties it — it
|
|
2099
|
+
# never raises. Computed once here so every bank in the fan-out sends the
|
|
2100
|
+
# same validated set.
|
|
2101
|
+
resolved_recall_types = filter_recall_types(config)
|
|
2102
|
+
|
|
2094
2103
|
def _make_bank_task(target_bank_id, b_tags, b_tags_match, b_tag_groups, timeout_override=None):
|
|
2095
2104
|
def _bank_task():
|
|
2096
2105
|
return client.recall(
|
|
@@ -2098,7 +2107,7 @@ def main():
|
|
|
2098
2107
|
query=search_query,
|
|
2099
2108
|
max_tokens=config.get("recallMaxTokens", 1024),
|
|
2100
2109
|
budget=config.get("recallBudget", "mid"),
|
|
2101
|
-
types=
|
|
2110
|
+
types=resolved_recall_types,
|
|
2102
2111
|
# Upstream 962140eef — optional per-bank tag filters (resolved
|
|
2103
2112
|
# above the cache check; part of the cache key).
|
|
2104
2113
|
tags=b_tags,
|
|
@@ -127,6 +127,36 @@ def _session_id_from_path(path: str) -> str:
|
|
|
127
127
|
return os.path.splitext(os.path.basename(path))[0]
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
def _annotate_reconcile_payload(payload: dict, session_id: str, built: dict) -> dict:
|
|
131
|
+
"""Stamp watermark-anchoring fields onto a QUEUED reconcile payload.
|
|
132
|
+
|
|
133
|
+
switchroom #4571 — the recurring ``pending-retains`` spike. A slice that
|
|
134
|
+
reconcile enqueues (over-budget / out-of-lookback) or defers (turn-cap
|
|
135
|
+
split) is later drained by ``drain_pending.py`` on a confirmed 200, but the
|
|
136
|
+
drain had no way to know WHICH session/uuid the entry anchored, so it never
|
|
137
|
+
advanced the transcript watermark for these paths. Reconcile's gap detection
|
|
138
|
+
is watermark-keyed, so on the next boot it re-derives the identical tail and
|
|
139
|
+
re-enqueues it — a false-alarm queue spike that recurs every boot (no memory
|
|
140
|
+
is lost: enqueue dedupe + the archive protect it, but the depth trips the
|
|
141
|
+
memory-queue watchdog). These fields let the drain advance the watermark once
|
|
142
|
+
the slice is confirmed durable, under the no-loss guard in
|
|
143
|
+
``drain_pending._commit_reconcile_watermark``.
|
|
144
|
+
|
|
145
|
+
``reconcile_is_tail`` is the load-bearing one: only a slice whose last uuid
|
|
146
|
+
is the TRANSCRIPT tail may ever anchor the watermark. An older remainder
|
|
147
|
+
(turn-cap split) ends mid-transcript, so advancing to it would skip the
|
|
148
|
+
newer turns that follow it.
|
|
149
|
+
"""
|
|
150
|
+
ordered = built.get("ordered_uuids") or []
|
|
151
|
+
last_uuid = built.get("last_uuid")
|
|
152
|
+
tail_uuid = ordered[-1] if ordered else None
|
|
153
|
+
payload["reconcile_session_id"] = session_id
|
|
154
|
+
payload["reconcile_last_uuid"] = last_uuid
|
|
155
|
+
payload["reconcile_ordered_uuids"] = ordered
|
|
156
|
+
payload["reconcile_is_tail"] = bool(last_uuid) and last_uuid == tail_uuid
|
|
157
|
+
return payload
|
|
158
|
+
|
|
159
|
+
|
|
130
160
|
def reconcile(config: dict | None = None, hook_input: dict | None = None) -> dict:
|
|
131
161
|
"""Diff watermarks vs transcript tails and recover un-committed work.
|
|
132
162
|
|
|
@@ -297,6 +327,11 @@ def _post_inline(
|
|
|
297
327
|
return "skip"
|
|
298
328
|
payload = built["payload"]
|
|
299
329
|
document_id = built["document_id"]
|
|
330
|
+
# Stamp the watermark-anchoring fields so that IF this slice falls through
|
|
331
|
+
# to the pending queue (lock busy / POST failed) the drain can advance the
|
|
332
|
+
# watermark once it lands, instead of leaving reconcile to re-enqueue it
|
|
333
|
+
# every boot (switchroom #4571).
|
|
334
|
+
_annotate_reconcile_payload(payload, session_id, built)
|
|
300
335
|
|
|
301
336
|
with inflight_lock(blocking=True) as acquired:
|
|
302
337
|
if not acquired: # pragma: no cover - blocking acquire fails open
|
|
@@ -350,6 +385,7 @@ def _enqueue_slice(config, session_id, path, all_messages, slice_messages, bank_
|
|
|
350
385
|
)
|
|
351
386
|
if built is None:
|
|
352
387
|
return False
|
|
388
|
+
_annotate_reconcile_payload(built["payload"], session_id, built)
|
|
353
389
|
queued = pending_enqueue(built["payload"], RuntimeError("reconcile deferred (bound/budget)"))
|
|
354
390
|
if queued is None:
|
|
355
391
|
debug_log(config, "reconcile_tail: pending-retains full, could not enqueue remainder")
|
|
@@ -549,6 +549,11 @@ def build_retain_payload(
|
|
|
549
549
|
"bank_id": bank_id,
|
|
550
550
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
551
551
|
"user_id": os.environ.get("HINDSIGHT_USER_ID", ""),
|
|
552
|
+
# Switchroom — the agent's own name, so a speaker-aware retainContext
|
|
553
|
+
# template can name whose first-person experience this transcript is.
|
|
554
|
+
# Empty outside switchroom (no SWITCHROOM_AGENT_NAME); a template that
|
|
555
|
+
# references {agent} then renders an empty slot, which is harmless.
|
|
556
|
+
"agent": os.environ.get("SWITCHROOM_AGENT_NAME", ""),
|
|
552
557
|
}
|
|
553
558
|
|
|
554
559
|
def _resolve_template(value: str) -> str:
|
|
@@ -682,7 +687,7 @@ def build_retain_payload(
|
|
|
682
687
|
"bank_id": bank_id,
|
|
683
688
|
"content": transcript,
|
|
684
689
|
"document_id": document_id,
|
|
685
|
-
"context": config.get("retainContext", "claude-code"),
|
|
690
|
+
"context": _resolve_template(config.get("retainContext", "claude-code")),
|
|
686
691
|
"metadata": metadata,
|
|
687
692
|
"tags": tags,
|
|
688
693
|
"observation_scopes": scope,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Switchroom — the auto-retain cadence knobs must have an env channel.
|
|
2
|
+
|
|
3
|
+
`retainEveryNTurns`, `retainOverlapTurns`, `retainContext`, and `retainTags`
|
|
4
|
+
had a DEFAULTS entry and (for the cadence pair) a settings.json stamp, but NO
|
|
5
|
+
entry in `ENV_OVERRIDES`. Env is the TOP of the plugin's config precedence
|
|
6
|
+
chain (DEFAULTS -> settings.json -> ~/.hindsight/claude-code.json -> env), so
|
|
7
|
+
without an env key an operator's `HINDSIGHT_RETAIN_*` could not reach the
|
|
8
|
+
plugin at all, and a docker-exec'd retain that does not inherit the supervised
|
|
9
|
+
settings could not be steered either.
|
|
10
|
+
|
|
11
|
+
The outcome under test: `HINDSIGHT_RETAIN_EVERY_N_TURNS` (and its siblings)
|
|
12
|
+
actually OVERRIDE the resolved config value, and env wins over the shipped
|
|
13
|
+
default.
|
|
14
|
+
|
|
15
|
+
Stdlib-only.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import unittest
|
|
21
|
+
from unittest import mock
|
|
22
|
+
|
|
23
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
24
|
+
if SCRIPTS_DIR not in sys.path:
|
|
25
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
26
|
+
|
|
27
|
+
from lib.config import DEFAULTS, ENV_OVERRIDES, load_config # noqa: E402
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Every retain-cadence env var, with the config key it must reach.
|
|
31
|
+
RETAIN_ENV = {
|
|
32
|
+
"HINDSIGHT_RETAIN_EVERY_N_TURNS": "retainEveryNTurns",
|
|
33
|
+
"HINDSIGHT_RETAIN_OVERLAP_TURNS": "retainOverlapTurns",
|
|
34
|
+
"HINDSIGHT_RETAIN_CONTEXT": "retainContext",
|
|
35
|
+
"HINDSIGHT_RETAIN_TAGS": "retainTags",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_with(env):
|
|
40
|
+
"""load_config() with a hermetic environment (no plugin/user settings)."""
|
|
41
|
+
with mock.patch.dict(os.environ, env, clear=True):
|
|
42
|
+
os.environ["CLAUDE_PLUGIN_ROOT"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
|
|
43
|
+
os.environ["HOME"] = os.path.join(SCRIPTS_DIR, "does-not-exist")
|
|
44
|
+
return load_config()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class EveryRetainNameHasAChannel(unittest.TestCase):
|
|
48
|
+
def test_all_retain_env_names_are_wired(self):
|
|
49
|
+
missing = [name for name in RETAIN_ENV if name not in ENV_OVERRIDES]
|
|
50
|
+
self.assertEqual(missing, [], f"exported but never read: {missing}")
|
|
51
|
+
|
|
52
|
+
def test_each_name_maps_to_the_expected_config_key(self):
|
|
53
|
+
for name, key in RETAIN_ENV.items():
|
|
54
|
+
with self.subTest(name=name):
|
|
55
|
+
self.assertEqual(ENV_OVERRIDES[name][0], key)
|
|
56
|
+
|
|
57
|
+
def test_every_target_key_exists_in_defaults(self):
|
|
58
|
+
for name, key in RETAIN_ENV.items():
|
|
59
|
+
with self.subTest(name=name):
|
|
60
|
+
self.assertIn(key, DEFAULTS)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ValuesActuallyLand(unittest.TestCase):
|
|
64
|
+
"""The outcome that matters: the loaded config carries the exported value."""
|
|
65
|
+
|
|
66
|
+
def test_every_n_turns_env_overrides_the_resolved_value(self):
|
|
67
|
+
# The headline outcome: HINDSIGHT_RETAIN_EVERY_N_TURNS wins over the
|
|
68
|
+
# shipped default, and lands as an int.
|
|
69
|
+
override = DEFAULTS["retainEveryNTurns"] + 5
|
|
70
|
+
cfg = _load_with({"HINDSIGHT_RETAIN_EVERY_N_TURNS": str(override)})
|
|
71
|
+
self.assertEqual(cfg["retainEveryNTurns"], override)
|
|
72
|
+
self.assertIsInstance(cfg["retainEveryNTurns"], int)
|
|
73
|
+
self.assertNotEqual(cfg["retainEveryNTurns"], DEFAULTS["retainEveryNTurns"])
|
|
74
|
+
|
|
75
|
+
def test_overlap_turns_env_overrides_the_resolved_value(self):
|
|
76
|
+
cfg = _load_with({"HINDSIGHT_RETAIN_OVERLAP_TURNS": "4"})
|
|
77
|
+
self.assertEqual(cfg["retainOverlapTurns"], 4)
|
|
78
|
+
|
|
79
|
+
def test_context_env_overrides_the_resolved_value(self):
|
|
80
|
+
cfg = _load_with({"HINDSIGHT_RETAIN_CONTEXT": "codex"})
|
|
81
|
+
self.assertEqual(cfg["retainContext"], "codex")
|
|
82
|
+
|
|
83
|
+
def test_tags_env_accepts_a_json_array(self):
|
|
84
|
+
cfg = _load_with({"HINDSIGHT_RETAIN_TAGS": '["source:transcript"]'})
|
|
85
|
+
self.assertEqual(cfg["retainTags"], ["source:transcript"])
|
|
86
|
+
|
|
87
|
+
def test_tags_env_accepts_a_comma_separated_list(self):
|
|
88
|
+
cfg = _load_with({"HINDSIGHT_RETAIN_TAGS": "a,b"})
|
|
89
|
+
self.assertEqual(cfg["retainTags"], ["a", "b"])
|
|
90
|
+
|
|
91
|
+
def test_no_retain_env_reproduces_the_default(self):
|
|
92
|
+
baseline = _load_with({})
|
|
93
|
+
for key in RETAIN_ENV.values():
|
|
94
|
+
with self.subTest(key=key):
|
|
95
|
+
self.assertEqual(baseline[key], DEFAULTS[key])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
unittest.main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Switchroom — recall `types` must be fail-safe against an invalid fact type.
|
|
2
|
+
|
|
3
|
+
The 0.9.0 Hindsight engine returns HTTP 422 ("Invalid fact type(s): … Must be
|
|
4
|
+
one of: experience, observation, world") for an unknown recall `fact_type`,
|
|
5
|
+
which fails the WHOLE recall for the turn. Before this guard, recall.py sent
|
|
6
|
+
`types=config.get("recallTypes")` unvalidated, so an operator typo in
|
|
7
|
+
`memory.recall.types` (e.g. "observations", "fact") would 422 and drop memory
|
|
8
|
+
injection on every turn.
|
|
9
|
+
|
|
10
|
+
The outcome under test: a config carrying an invalid type resolves — via
|
|
11
|
+
`filter_recall_types` — to a FILTERED, valid `types` list (never a 422-bound
|
|
12
|
+
call), and never raises. Mirrors the degrade-don't-raise contract of
|
|
13
|
+
`compute_observation_scopes`.
|
|
14
|
+
|
|
15
|
+
Stdlib-only.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import unittest
|
|
21
|
+
|
|
22
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
23
|
+
if SCRIPTS_DIR not in sys.path:
|
|
24
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
25
|
+
|
|
26
|
+
from lib.config import ( # noqa: E402
|
|
27
|
+
DEFAULT_RECALL_FACT_TYPES,
|
|
28
|
+
RECALL_FACT_TYPES,
|
|
29
|
+
filter_recall_types,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FilterRecallTypes(unittest.TestCase):
|
|
34
|
+
def test_valid_set_matches_the_engine(self):
|
|
35
|
+
# Guards against client/server drift: verified against the live engine's
|
|
36
|
+
# own 422 detail string ("experience, observation, world").
|
|
37
|
+
self.assertEqual(set(RECALL_FACT_TYPES), {"world", "experience", "observation"})
|
|
38
|
+
|
|
39
|
+
def test_invalid_type_is_dropped_leaving_a_valid_list(self):
|
|
40
|
+
# The exact defect: an operator typo mixed with a valid type. The bad
|
|
41
|
+
# value is dropped; the good one survives — NOT a 422-bound call.
|
|
42
|
+
resolved = filter_recall_types({"recallTypes": ["observations", "world"]})
|
|
43
|
+
self.assertEqual(resolved, ["world"])
|
|
44
|
+
for t in resolved:
|
|
45
|
+
self.assertIn(t, RECALL_FACT_TYPES)
|
|
46
|
+
|
|
47
|
+
def test_all_invalid_falls_back_to_the_default_set(self):
|
|
48
|
+
# If nothing valid survives, recall must still RUN — fall back to the
|
|
49
|
+
# shipped default rather than send an empty/invalid set.
|
|
50
|
+
resolved = filter_recall_types({"recallTypes": ["fact", "observations"]})
|
|
51
|
+
self.assertEqual(resolved, list(DEFAULT_RECALL_FACT_TYPES))
|
|
52
|
+
|
|
53
|
+
def test_valid_types_pass_through_deduplicated_in_order(self):
|
|
54
|
+
resolved = filter_recall_types(
|
|
55
|
+
{"recallTypes": ["world", "experience", "world", "observation"]}
|
|
56
|
+
)
|
|
57
|
+
self.assertEqual(resolved, ["world", "experience", "observation"])
|
|
58
|
+
|
|
59
|
+
def test_unset_returns_none_so_the_field_is_omitted(self):
|
|
60
|
+
# None -> omit `types` entirely, letting the engine apply its own default.
|
|
61
|
+
self.assertIsNone(filter_recall_types({}))
|
|
62
|
+
self.assertIsNone(filter_recall_types({"recallTypes": None}))
|
|
63
|
+
|
|
64
|
+
def test_non_list_value_falls_back_without_raising(self):
|
|
65
|
+
# A scalar where a list was expected is a config mistake, not a crash.
|
|
66
|
+
resolved = filter_recall_types({"recallTypes": "observation"})
|
|
67
|
+
self.assertEqual(resolved, list(DEFAULT_RECALL_FACT_TYPES))
|
|
68
|
+
|
|
69
|
+
def test_non_string_members_are_dropped(self):
|
|
70
|
+
resolved = filter_recall_types({"recallTypes": [None, 42, "observation"]})
|
|
71
|
+
self.assertEqual(resolved, ["observation"])
|
|
72
|
+
|
|
73
|
+
def test_never_raises_on_pathological_input(self):
|
|
74
|
+
for bad in ({}, {"recallTypes": {}}, {"recallTypes": 0}, {"recallTypes": [[]]}):
|
|
75
|
+
with self.subTest(bad=bad):
|
|
76
|
+
# Must return a value, never propagate an exception.
|
|
77
|
+
filter_recall_types(bad)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
unittest.main()
|
|
@@ -565,6 +565,119 @@ class TestObservationScopes(DurabilityTestBase):
|
|
|
565
565
|
self.assertTrue(all(s is None for s in self.daemon.observation_scopes_seen))
|
|
566
566
|
|
|
567
567
|
|
|
568
|
+
class TestDrainAdvancesWatermark(DurabilityTestBase):
|
|
569
|
+
"""switchroom #4571 — the drain must advance the transcript watermark for a
|
|
570
|
+
reconcile-sourced slice it confirms durable, so reconcile stops re-deriving
|
|
571
|
+
and re-enqueueing the identical tail on every boot (the recurring
|
|
572
|
+
``pending-retains`` spike). And it must NOT advance while an earlier sibling
|
|
573
|
+
of the same session is still queued (the no-loss guard).
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
# -- the fix: an enqueued tail, once drained, advances the watermark --------
|
|
577
|
+
def test_enqueued_tail_drained_advances_watermark_and_stops_reenqueue(self):
|
|
578
|
+
# This test FAILS on HEAD: the drain never commits the watermark for an
|
|
579
|
+
# enqueued reconcile slice, so `watermark.load` stays None after the
|
|
580
|
+
# drain and the second reconcile re-enqueues the same tail.
|
|
581
|
+
session = "drainA"
|
|
582
|
+
tpath = os.path.join(self.transcripts, f"{session}.jsonl")
|
|
583
|
+
_write_transcript(tpath, 3, session_prefix=session)
|
|
584
|
+
hook = {"session_id": session, "transcript_path": tpath, "cwd": "/x"}
|
|
585
|
+
|
|
586
|
+
# Force the out-of-lookback ENQUEUE path (no inline POST): lookback=0
|
|
587
|
+
# makes every transcript "too old", so reconcile enqueues the full tail
|
|
588
|
+
# and defers it to the drain — exactly the path that never committed.
|
|
589
|
+
with mock.patch.dict(os.environ, {"HINDSIGHT_RECONCILE_LOOKBACK_H": "0"}):
|
|
590
|
+
s1 = reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
591
|
+
self.assertEqual(s1["enqueued"], 1)
|
|
592
|
+
self.assertEqual(len(self._pending_entries()), 1)
|
|
593
|
+
# Nothing posted inline, so the watermark is still unset.
|
|
594
|
+
self.assertIsNone(watermark.load(session))
|
|
595
|
+
|
|
596
|
+
# Drain against a healthy daemon: the tail lands durably AND (the fix)
|
|
597
|
+
# the watermark advances to the transcript tail.
|
|
598
|
+
from drain_pending import drain
|
|
599
|
+
drain(self._config())
|
|
600
|
+
self.assertEqual(len(self._pending_entries()), 0)
|
|
601
|
+
wm = watermark.load(session)
|
|
602
|
+
self.assertIsNotNone(
|
|
603
|
+
wm,
|
|
604
|
+
"drain must advance the watermark for a confirmed-durable reconcile "
|
|
605
|
+
"tail (#4571) — without it, reconcile re-enqueues every boot",
|
|
606
|
+
)
|
|
607
|
+
self.assertEqual(wm["last_uuid"], f"{session}-a2")
|
|
608
|
+
|
|
609
|
+
# Second reconcile pass (still out of lookback) is now skipped_clean:
|
|
610
|
+
# the tail is watermarked, so it is NOT re-derived or re-enqueued.
|
|
611
|
+
with mock.patch.dict(os.environ, {"HINDSIGHT_RECONCILE_LOOKBACK_H": "0"}):
|
|
612
|
+
s2 = reconcile_tail.reconcile(self._config(), hook_input=hook)
|
|
613
|
+
self.assertEqual(s2["skipped_clean"], 1)
|
|
614
|
+
self.assertEqual(s2["enqueued"], 0)
|
|
615
|
+
self.assertEqual(len(self._pending_entries()), 0)
|
|
616
|
+
|
|
617
|
+
# -- the no-loss guard: an earlier sibling blocks the tail's advance --------
|
|
618
|
+
def test_tail_drain_does_not_advance_while_earlier_sibling_queued(self):
|
|
619
|
+
session = "drainB"
|
|
620
|
+
ordered = [
|
|
621
|
+
f"{session}-u0", f"{session}-a0", f"{session}-u1",
|
|
622
|
+
f"{session}-a1", f"{session}-u2", f"{session}-a2",
|
|
623
|
+
]
|
|
624
|
+
older_doc = f"{session}-r{session}-u0-{session}-a1"
|
|
625
|
+
tail_doc = f"{session}-r{session}-u0-{session}-a2"
|
|
626
|
+
|
|
627
|
+
from lib import pending
|
|
628
|
+
|
|
629
|
+
def _payload(doc, content, last_uuid, is_tail):
|
|
630
|
+
return {
|
|
631
|
+
"api_url": "http://fake", "api_token": None, "bank_id": "test-bank",
|
|
632
|
+
"content": content, "document_id": doc, "context": "claude-code",
|
|
633
|
+
"metadata": {}, "tags": [], "observation_scopes": None,
|
|
634
|
+
"reconcile_session_id": session,
|
|
635
|
+
"reconcile_last_uuid": last_uuid,
|
|
636
|
+
"reconcile_ordered_uuids": ordered,
|
|
637
|
+
"reconcile_is_tail": is_tail,
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
# The earlier remainder (mid-transcript last_uuid) is enqueued FIRST so
|
|
641
|
+
# it sorts ahead of the tail in the oldest-first drain order.
|
|
642
|
+
pending.enqueue(
|
|
643
|
+
_payload(older_doc, "older remainder turns", f"{session}-a1", False),
|
|
644
|
+
RuntimeError("reconcile deferred"),
|
|
645
|
+
)
|
|
646
|
+
import time as _t
|
|
647
|
+
_t.sleep(0.002)
|
|
648
|
+
pending.enqueue(
|
|
649
|
+
_payload(tail_doc, "tail turns", f"{session}-a2", True),
|
|
650
|
+
RuntimeError("reconcile deferred"),
|
|
651
|
+
)
|
|
652
|
+
self.assertEqual(len(self._pending_entries()), 2)
|
|
653
|
+
|
|
654
|
+
# Upstream: the tail commits, but the earlier remainder still fails, so
|
|
655
|
+
# it stays queued through the whole drain run.
|
|
656
|
+
posted = []
|
|
657
|
+
|
|
658
|
+
def fake_retain(self_c, bank_id=None, content=None,
|
|
659
|
+
document_id="conversation", **kw):
|
|
660
|
+
posted.append(document_id)
|
|
661
|
+
if document_id == older_doc:
|
|
662
|
+
raise RuntimeError("earlier remainder still failing upstream")
|
|
663
|
+
return {"ok": True}
|
|
664
|
+
|
|
665
|
+
from drain_pending import drain
|
|
666
|
+
with mock.patch.object(HindsightClient, "retain", fake_retain):
|
|
667
|
+
drain(self._config())
|
|
668
|
+
|
|
669
|
+
# The tail POST succeeded and its entry was retired ...
|
|
670
|
+
self.assertIn(tail_doc, posted)
|
|
671
|
+
remaining = [e["document_id"] for _p, e in self._pending_entries()]
|
|
672
|
+
self.assertNotIn(tail_doc, remaining)
|
|
673
|
+
# ... but the watermark did NOT advance: an earlier, still-unconfirmed
|
|
674
|
+
# sibling of the same session is queued, and advancing past it would
|
|
675
|
+
# risk silent loss of the remainder (#4571 no-loss guard).
|
|
676
|
+
self.assertIsNone(watermark.load(session))
|
|
677
|
+
# The earlier remainder is still queued (its POST failed).
|
|
678
|
+
self.assertIn(older_doc, remaining)
|
|
679
|
+
|
|
680
|
+
|
|
568
681
|
def _stdin(obj):
|
|
569
682
|
import io
|
|
570
683
|
return io.StringIO(json.dumps(obj))
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"autoRecall": true,
|
|
7
7
|
"autoRetain": true,
|
|
8
8
|
"retainMode": "full-session",
|
|
9
|
-
"recallBudget": "
|
|
9
|
+
"recallBudget": "mid",
|
|
10
10
|
"recallMaxTokens": 1024,
|
|
11
11
|
"recallMaxMemories": 12,
|
|
12
12
|
"recallTypes": ["world", "experience"],
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"retainToolCalls": true,
|
|
27
27
|
"retainTags": ["{session_id}"],
|
|
28
28
|
"retainMetadata": {},
|
|
29
|
-
"retainContext": "
|
|
29
|
+
"retainContext": "Transcript of Claude Code agent '{agent}' ({bank_id}). 'assistant'/tool lines are the agent's own first-person actions (experience); 'user' lines are the human operator speaking (their statements are world facts).",
|
|
30
30
|
"hindsightApiToken": null,
|
|
31
31
|
"apiPort": 9077,
|
|
32
32
|
"daemonIdleTimeout": 0,
|
|
@@ -42,8 +42,13 @@ 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"] == "mid"
|
|
46
46
|
assert cfg["retainEveryNTurns"] == 10
|
|
47
|
+
# Switchroom — speaker-aware retain context template (resolved
|
|
48
|
+
# per-retain by build_retain_payload). Must carry the {agent} and
|
|
49
|
+
# {bank_id} slots so the consolidation LLM can attribute speakers.
|
|
50
|
+
assert "{agent}" in cfg["retainContext"]
|
|
51
|
+
assert "{bank_id}" in cfg["retainContext"]
|
|
47
52
|
|
|
48
53
|
def test_settings_json_overrides_defaults(self, tmp_path, monkeypatch):
|
|
49
54
|
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
|
|
@@ -75,7 +80,7 @@ class TestLoadConfig:
|
|
|
75
80
|
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
|
|
76
81
|
(tmp_path / "settings.json").write_text("not valid json{{")
|
|
77
82
|
cfg = load_config()
|
|
78
|
-
assert cfg["recallBudget"] == "
|
|
83
|
+
assert cfg["recallBudget"] == "mid" # default still applies
|
|
79
84
|
|
|
80
85
|
def test_null_values_in_settings_json_not_applied(self, tmp_path, monkeypatch):
|
|
81
86
|
monkeypatch.setenv("CLAUDE_PLUGIN_ROOT", str(tmp_path))
|
|
@@ -112,7 +117,7 @@ class TestLoadConfig:
|
|
|
112
117
|
# HOME points to tmp_path where no .hindsight/claude-code.json exists
|
|
113
118
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
114
119
|
cfg = load_config()
|
|
115
|
-
assert cfg["recallBudget"] == "
|
|
120
|
+
assert cfg["recallBudget"] == "mid" # default
|
|
116
121
|
|
|
117
122
|
def test_env_var_wins_over_user_config(self, tmp_path, monkeypatch):
|
|
118
123
|
plugin_root = tmp_path / "plugin"
|
|
@@ -833,6 +833,11 @@ class TestRetainHook:
|
|
|
833
833
|
assert captured["body"].get("async") is False
|
|
834
834
|
|
|
835
835
|
def test_retain_includes_context_label(self, monkeypatch, tmp_path):
|
|
836
|
+
# Switchroom — the default retainContext is now a speaker-aware
|
|
837
|
+
# template resolved per-retain: {agent} from SWITCHROOM_AGENT_NAME
|
|
838
|
+
# and {bank_id} from the target bank. Pin a known agent name so the
|
|
839
|
+
# resolved label is deterministic regardless of the ambient env.
|
|
840
|
+
monkeypatch.setenv("SWITCHROOM_AGENT_NAME", "testbot")
|
|
836
841
|
messages = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "world"}]
|
|
837
842
|
transcript = make_transcript_file(tmp_path, messages)
|
|
838
843
|
hook_input = make_hook_input(transcript_path=transcript)
|
|
@@ -846,7 +851,11 @@ class TestRetainHook:
|
|
|
846
851
|
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)
|
|
847
852
|
|
|
848
853
|
if "body" in captured:
|
|
849
|
-
|
|
854
|
+
context = captured["body"]["items"][0]["context"]
|
|
855
|
+
# Template placeholders must be resolved (no literal braces left)
|
|
856
|
+
# and the agent name must be filled in from the env.
|
|
857
|
+
assert "{" not in context and "}" not in context
|
|
858
|
+
assert "agent 'testbot'" in context
|
|
850
859
|
|
|
851
860
|
def test_disabled_auto_retain_does_not_call_api(self, monkeypatch, tmp_path):
|
|
852
861
|
(tmp_path / "plugin_root").mkdir(exist_ok=True)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tests for build_retain_payload's speaker-aware retainContext template.
|
|
2
|
+
|
|
3
|
+
Switchroom — retainContext is no longer the opaque constant "claude-code".
|
|
4
|
+
It is a template resolved per-retain by build_retain_payload's
|
|
5
|
+
_resolve_template, filling {agent} from SWITCHROOM_AGENT_NAME and {bank_id}
|
|
6
|
+
from the target bank so the consolidation LLM knows whose first-person
|
|
7
|
+
experience each transcript line is.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from retain import build_retain_payload
|
|
13
|
+
|
|
14
|
+
MESSAGES = [
|
|
15
|
+
{"role": "user", "content": "human fact"},
|
|
16
|
+
{"role": "assistant", "content": "agent action"},
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build(config, monkeypatch, agent="klanker", bank_id="klanker-main"):
|
|
21
|
+
if agent is None:
|
|
22
|
+
monkeypatch.delenv("SWITCHROOM_AGENT_NAME", raising=False)
|
|
23
|
+
else:
|
|
24
|
+
monkeypatch.setenv("SWITCHROOM_AGENT_NAME", agent)
|
|
25
|
+
result = build_retain_payload(
|
|
26
|
+
config,
|
|
27
|
+
session_id="sess-1",
|
|
28
|
+
messages_to_retain=MESSAGES,
|
|
29
|
+
all_messages=MESSAGES,
|
|
30
|
+
bank_id=bank_id,
|
|
31
|
+
api_url="http://localhost:9077",
|
|
32
|
+
api_token=None,
|
|
33
|
+
)
|
|
34
|
+
assert result is not None
|
|
35
|
+
return result["payload"]["context"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_context_template_resolves_agent_and_bank(monkeypatch):
|
|
39
|
+
context = _build(
|
|
40
|
+
{"retainContext": "agent '{agent}' ({bank_id})"},
|
|
41
|
+
monkeypatch,
|
|
42
|
+
agent="klanker",
|
|
43
|
+
bank_id="klanker-main",
|
|
44
|
+
)
|
|
45
|
+
assert context == "agent 'klanker' (klanker-main)"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_context_template_agent_empty_outside_switchroom(monkeypatch):
|
|
49
|
+
context = _build(
|
|
50
|
+
{"retainContext": "agent '{agent}'"},
|
|
51
|
+
monkeypatch,
|
|
52
|
+
agent=None,
|
|
53
|
+
)
|
|
54
|
+
assert context == "agent ''"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_context_default_is_speaker_aware_and_resolved(monkeypatch):
|
|
58
|
+
# No retainContext in config → build_retain_payload falls back to the
|
|
59
|
+
# "claude-code" literal, which carries no template vars and is returned
|
|
60
|
+
# verbatim. The speaker-aware default lives in settings.json / config.py
|
|
61
|
+
# DEFAULTS and is exercised by test_config; here we assert the fallback
|
|
62
|
+
# path stays byte-stable.
|
|
63
|
+
context = _build({}, monkeypatch)
|
|
64
|
+
assert context == "claude-code"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_context_plain_string_passthrough(monkeypatch):
|
|
68
|
+
context = _build({"retainContext": "just plain text"}, monkeypatch)
|
|
69
|
+
assert context == "just plain text"
|