switchroom 0.19.32 → 0.19.34
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 +1 -0
- package/dist/auth-broker/index.js +11173 -10974
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +10663 -10226
- package/dist/host-control/main.js +9781 -9423
- package/dist/vault/approvals/kernel-server.js +559 -428
- package/dist/vault/broker/server.js +1688 -1566
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +212 -61
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +14 -8
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +106 -1
- package/telegram-plugin/gateway/stale-pin-sweep.ts +156 -47
- package/telegram-plugin/gateway/status-pin-store.ts +44 -11
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/vendor/hindsight-memory/scripts/subagent_retain.py +30 -7
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +100 -2
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -116,12 +116,15 @@ describe("status-pin-store", () => {
|
|
|
116
116
|
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
it("fail-open:
|
|
120
|
-
|
|
121
|
-
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 5, pins: [pin()] }) });
|
|
119
|
+
it("fail-open: a shape that is not an envelope at all loads as []", () => {
|
|
120
|
+
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 1, pins: "nope" }) });
|
|
122
121
|
expect(loadStatusPins(PATH, f1)).toEqual([]);
|
|
123
|
-
const { fs: f2 } = memFs({ [PATH]: JSON.stringify({
|
|
122
|
+
const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ pins: [pin()] }) });
|
|
124
123
|
expect(loadStatusPins(PATH, f2)).toEqual([]);
|
|
124
|
+
const { fs: f3 } = memFs({ [PATH]: JSON.stringify({ v: "4", pins: [pin()] }) });
|
|
125
|
+
expect(loadStatusPins(PATH, f3)).toEqual([]);
|
|
126
|
+
const { fs: f4 } = memFs({ [PATH]: JSON.stringify({ v: 0, pins: [pin()] }) });
|
|
127
|
+
expect(loadStatusPins(PATH, f4)).toEqual([]);
|
|
125
128
|
});
|
|
126
129
|
|
|
127
130
|
it("drops malformed rows but keeps valid ones", () => {
|
|
@@ -604,8 +607,61 @@ describe("status-pin-store — envelope version compat", () => {
|
|
|
604
607
|
expect(loadStatusPins(PATH, fs)).toHaveLength(1);
|
|
605
608
|
});
|
|
606
609
|
|
|
607
|
-
|
|
608
|
-
|
|
610
|
+
/**
|
|
611
|
+
* #3957 — a DOWNGRADE must degrade, not discard.
|
|
612
|
+
*
|
|
613
|
+
* The pre-#3953 reader has already shipped, so the v3→v4 case can never be
|
|
614
|
+
* retroactively fixed; this pins the forward-looking half so the NEXT bump
|
|
615
|
+
* behaves. A build that does not know the envelope version still reads every
|
|
616
|
+
* row it can structurally validate — because for THIS file "discard" is not
|
|
617
|
+
* "do nothing", it is "forget which messages we pinned", which turns every
|
|
618
|
+
* pin the newer build took into a permanent orphan.
|
|
619
|
+
*/
|
|
620
|
+
it("reads an unknown FUTURE envelope version, keeping every row it can validate", () => {
|
|
621
|
+
const { fs } = memFs({
|
|
622
|
+
[PATH]: JSON.stringify({
|
|
623
|
+
v: 99,
|
|
624
|
+
pins: [
|
|
625
|
+
// A future row: known fields valid, plus a field this build has never
|
|
626
|
+
// heard of. Kept — unknown fields are additive by the bump rule.
|
|
627
|
+
{ ...pin({ messageId: 715 }), someFutureField: "v99" },
|
|
628
|
+
{ pinKey: "k", chatId: "c", messageId: "not-a-number" }, // still dropped
|
|
629
|
+
],
|
|
630
|
+
}),
|
|
631
|
+
});
|
|
632
|
+
const loaded = loadStatusPins(PATH, fs);
|
|
633
|
+
expect(loaded).toHaveLength(1);
|
|
634
|
+
expect(loaded[0].messageId).toBe(715);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("a rolled-back build still UNPINS the orphans a newer build recorded (#3957)", async () => {
|
|
638
|
+
// The outcome that matters: not "the rows parse" but "the boot cleanup can
|
|
639
|
+
// still reach them". Against the fail-open reader this unpins nothing.
|
|
640
|
+
const { fs } = memFs({
|
|
641
|
+
[PATH]: JSON.stringify({
|
|
642
|
+
v: 99,
|
|
643
|
+
pins: [
|
|
644
|
+
{ pinKey: "fg:c:3", chatId: "-100123", messageId: 715, unknownV99: 1 },
|
|
645
|
+
{ pinKey: "wk:agent-x", chatId: "-100999", messageId: 42 },
|
|
646
|
+
],
|
|
647
|
+
}),
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const unpinned: Array<[string, number]> = [];
|
|
651
|
+
const res = await runStatusPinBootCleanup({
|
|
652
|
+
path: PATH,
|
|
653
|
+
fs,
|
|
654
|
+
unpin: async (chatId, messageId) => {
|
|
655
|
+
unpinned.push([chatId, messageId]);
|
|
656
|
+
},
|
|
657
|
+
log: () => {},
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
expect(unpinned).toEqual([
|
|
661
|
+
["-100123", 715],
|
|
662
|
+
["-100999", 42],
|
|
663
|
+
]);
|
|
664
|
+
expect(res.cleared).toBe(2);
|
|
609
665
|
expect(idOnly(loadStatusPins(PATH, fs))).toEqual([]);
|
|
610
666
|
});
|
|
611
667
|
|
|
@@ -90,14 +90,37 @@ SIDECHAIN_MAX_READ_BYTES = 8 * 1024 * 1024
|
|
|
90
90
|
SIDECHAIN_SCAN_FRESH_WINDOW_S = 300
|
|
91
91
|
|
|
92
92
|
# Extraction-framing header prepended to the retained content. The retain API
|
|
93
|
-
# has no per-call mission (mission is bank-level), so
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
93
|
+
# has no per-call mission (mission is bank-level), so the only lever available
|
|
94
|
+
# here is a short in-content note. Deterministic text — it does not change the
|
|
95
|
+
# document_id (that is computed from the raw slice before this is prepended).
|
|
96
|
+
#
|
|
97
|
+
# 2026-07-29 rewrite. The original text asked the extractor for "PROCESS facts:
|
|
98
|
+
# files/paths touched, commands that worked, ..." — i.e. it explicitly SOLICITED
|
|
99
|
+
# the exact classes the bank-level mission (`DEFAULT_RETAIN_MISSION` in
|
|
100
|
+
# src/memory/hindsight.ts) enumerates as NEVER-extract: file paths, tool-result
|
|
101
|
+
# exhaust, narration of what the assistant did, volatile state. Because this
|
|
102
|
+
# header sits inside the content the extractor reads, it read as a local
|
|
103
|
+
# override and countermanded the bank mission on the sidechain path. The header
|
|
104
|
+
# now REINFORCES the bank mission instead of fighting it: it keeps the genuinely
|
|
105
|
+
# durable half of the old wording (decisions with their rationale, non-obvious
|
|
106
|
+
# technique, structural dead ends) and names the ephemera to drop.
|
|
107
|
+
# Governing test: would this still be worth knowing in a month?
|
|
97
108
|
SIDECHAIN_MISSION_HEADER = (
|
|
98
|
-
"[sidechain sub-agent work log
|
|
99
|
-
"
|
|
100
|
-
"
|
|
109
|
+
"[sidechain sub-agent work log. Apply the bank's retain mission unchanged; "
|
|
110
|
+
"this is raw work exhaust and most of it is NOT memorable. Extract only "
|
|
111
|
+
"durable knowledge — something that would still be worth knowing in a month: "
|
|
112
|
+
"decisions made and the reasoning behind them; a non-obvious technique or "
|
|
113
|
+
"constraint that would save the next agent real time; a STRUCTURAL dead end "
|
|
114
|
+
"(\"X cannot work because Y\") as opposed to an incidental one (\"the command "
|
|
115
|
+
"failed so I retried\"). "
|
|
116
|
+
"Do NOT extract: the status of in-flight or unfinished work; PR, issue, "
|
|
117
|
+
"branch, review or CI state; counts, metrics, versions, sizes or any other "
|
|
118
|
+
"value that changes; container, process or runtime snapshots; narration of "
|
|
119
|
+
"what the agent did, including lists of the files, paths or commands it "
|
|
120
|
+
"touched; paths under /tmp or a scratchpad directory; session, agent, "
|
|
121
|
+
"request or operation IDs; the restated mission prose; routine tool chatter. "
|
|
122
|
+
"Anything whose truth expires when this session does is not a memory. "
|
|
123
|
+
"If nothing durable remains, extract nothing.]"
|
|
101
124
|
)
|
|
102
125
|
|
|
103
126
|
|
|
@@ -331,8 +331,10 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
331
331
|
self.assertIn("agent_type:worker", captured["tags"])
|
|
332
332
|
# Deterministic id in a DISTINCT namespace from parent retains.
|
|
333
333
|
self.assertTrue(captured["document_id"].startswith("parentsess-sub-af5-r"))
|
|
334
|
-
#
|
|
335
|
-
self.
|
|
334
|
+
# Durable-knowledge framing header is prepended, verbatim and first.
|
|
335
|
+
self.assertTrue(
|
|
336
|
+
captured["content"].startswith(subagent_retain.SIDECHAIN_MISSION_HEADER)
|
|
337
|
+
)
|
|
336
338
|
# Distinct provenance context.
|
|
337
339
|
self.assertEqual(captured["context"], "claude-code-sidechain")
|
|
338
340
|
self.assertEqual(captured["metadata"]["parent_session_id"], "parentsess")
|
|
@@ -459,6 +461,102 @@ class RunSubagentRetain(unittest.TestCase):
|
|
|
459
461
|
self.assertIn(k, result["payload"])
|
|
460
462
|
|
|
461
463
|
|
|
464
|
+
class SidechainMissionHeader(unittest.TestCase):
|
|
465
|
+
"""The header must REINFORCE the bank-level retain mission, not countermand it.
|
|
466
|
+
|
|
467
|
+
The header is prepended into the retained content, where the extractor reads
|
|
468
|
+
it as a local instruction — so anything it solicits is written to memory even
|
|
469
|
+
when `DEFAULT_RETAIN_MISSION` (src/memory/hindsight.ts) lists that same class
|
|
470
|
+
under NEVER extract. The pre-2026-07-29 text asked for "PROCESS facts:
|
|
471
|
+
files/paths touched, commands that worked, ...", which is that exact
|
|
472
|
+
contradiction. These tests pin the fix: the solicit half asks only for
|
|
473
|
+
durable knowledge, and the ephemeral classes appear ONLY as exclusions.
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
SPLIT = "Do NOT extract"
|
|
477
|
+
|
|
478
|
+
def _halves(self):
|
|
479
|
+
header = subagent_retain.SIDECHAIN_MISSION_HEADER
|
|
480
|
+
self.assertIn(
|
|
481
|
+
self.SPLIT,
|
|
482
|
+
header,
|
|
483
|
+
"header must carry an explicit exclusion clause",
|
|
484
|
+
)
|
|
485
|
+
solicit, _, exclude = header.partition(self.SPLIT)
|
|
486
|
+
return header, solicit, exclude
|
|
487
|
+
|
|
488
|
+
def test_header_does_not_solicit_ephemeral_classes(self):
|
|
489
|
+
"""Ephemera may be named as exclusions, never in the 'extract X' half."""
|
|
490
|
+
header = subagent_retain.SIDECHAIN_MISSION_HEADER
|
|
491
|
+
# The pre-fix solicitations, banned anywhere in the header — there is no
|
|
492
|
+
# phrasing of these that is a request for durable knowledge.
|
|
493
|
+
for gone in ("PROCESS facts", "files/paths touched", "commands that worked"):
|
|
494
|
+
self.assertNotIn(
|
|
495
|
+
gone,
|
|
496
|
+
header,
|
|
497
|
+
f"header still asks for {gone!r}, which the bank mission forbids",
|
|
498
|
+
)
|
|
499
|
+
_, solicit, _ = self._halves()
|
|
500
|
+
lowered = solicit.lower()
|
|
501
|
+
for banned in (
|
|
502
|
+
"process fact",
|
|
503
|
+
"files/paths",
|
|
504
|
+
"paths touched",
|
|
505
|
+
"commands that worked",
|
|
506
|
+
"/tmp",
|
|
507
|
+
"status",
|
|
508
|
+
"session id",
|
|
509
|
+
"container",
|
|
510
|
+
"metric",
|
|
511
|
+
):
|
|
512
|
+
self.assertNotIn(
|
|
513
|
+
banned,
|
|
514
|
+
lowered,
|
|
515
|
+
f"header solicits ephemeral class {banned!r}: {solicit!r}",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
def test_header_excludes_every_expiring_class(self):
|
|
519
|
+
_, _, exclude = self._halves()
|
|
520
|
+
lowered = exclude.lower()
|
|
521
|
+
for required in (
|
|
522
|
+
"in-flight",
|
|
523
|
+
"pr,", # PR / issue / branch / CI state
|
|
524
|
+
"ci state",
|
|
525
|
+
"counts",
|
|
526
|
+
"versions",
|
|
527
|
+
"container",
|
|
528
|
+
"narration",
|
|
529
|
+
"/tmp",
|
|
530
|
+
"scratchpad",
|
|
531
|
+
"session, agent, request or operation ids",
|
|
532
|
+
):
|
|
533
|
+
self.assertIn(
|
|
534
|
+
required,
|
|
535
|
+
lowered,
|
|
536
|
+
f"header fails to exclude {required!r}",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def test_header_still_solicits_decisions_with_rationale(self):
|
|
540
|
+
"""The single most valuable sub-agent output must survive the rewrite."""
|
|
541
|
+
_, solicit, _ = self._halves()
|
|
542
|
+
lowered = solicit.lower()
|
|
543
|
+
self.assertIn("decisions made", lowered)
|
|
544
|
+
self.assertTrue(
|
|
545
|
+
any(w in lowered for w in ("reasoning", "rationale", "why")),
|
|
546
|
+
f"decisions are solicited without their rationale: {solicit!r}",
|
|
547
|
+
)
|
|
548
|
+
# And the other two durable classes the old wording earned.
|
|
549
|
+
self.assertIn("dead end", lowered)
|
|
550
|
+
self.assertIn("structural", lowered)
|
|
551
|
+
|
|
552
|
+
def test_header_states_the_durability_bar(self):
|
|
553
|
+
header, solicit, _ = self._halves()
|
|
554
|
+
self.assertIn("durable", solicit.lower())
|
|
555
|
+
self.assertIn("worth knowing in a month", solicit.lower())
|
|
556
|
+
# Bounded: this is prepended to every sidechain retain payload.
|
|
557
|
+
self.assertLess(len(header), 1500)
|
|
558
|
+
|
|
559
|
+
|
|
462
560
|
class HookRegistration(unittest.TestCase):
|
|
463
561
|
def test_subagentstop_registered_in_plugin_hooks_json(self):
|
|
464
562
|
hooks_path = os.path.abspath(
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"hindsightApiUrl": "",
|
|
3
3
|
"bankId": "claude_code",
|
|
4
4
|
"bankMission": "You are a Claude Code AI assistant. Focus on technical discussions, decisions, and context relevant to the user's projects.",
|
|
5
|
-
"retainMission": "Extract durable facts that will still be true and useful weeks from now
|
|
5
|
+
"retainMission": "Extract durable facts that will still be true and useful weeks from now, once this session is forgotten.\n\nDURABILITY GATE. Emit a candidate ONLY if it is one of these five classes:\n- PREFERENCE — what the user likes, wants, or always does; a standing rule or correction.\n- DECISION — a settled choice that changes how future work is done, including a choice NOT to do something. A decision about the mechanics of the CURRENT task (which worker to dispatch, which branch to rebase, which PR to merge now, what to do next) is process narration, not a durable decision — drop it unless it establishes a standing rule or permanently changes a system.\n- FINDING — a root cause, a measurement, or verified behaviour of a system. Include the number.\n- OUTCOME — a completed result that changed the world: what shipped, what a thing turned out to be. Not the act of shipping it.\n- RELATIONSHIP — who a person is, what a project or tool is, and how they connect.\nIf a candidate fits none of the five, it is not a memory. Drop it; do not reword it into one.\n\nA preference revealed by a request is durable — record the preference (what the user likes, wants, or always does), not the request itself.\n\nA TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this\ncandidate a file path, a command/process/agent/session id, a temp directory, or\nthe location where some output was written? If yes, drop it — it is transcript\nexhaust, not memory.\n\nNEVER extract:\n- Tool results verbatim or paraphrased. Concretely, never produce a fact whose\n text resembles any of these: \"File created successfully at /path/to/file\",\n \"A background command with ID bctz4yskm is running, and its output will be\n written to /tmp/...\", \"Async agent a745598ba84e71df1 was launched successfully\n and is running in the background\", \"User executed a Bash command to sleep for\n 200 seconds\", \"The assistant used grep to locate 'truncateSync' in src/foo.ts\".\n- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.\n- Agent tool-use traces or narration of what the assistant did (e.g. \"the\n assistant used X to query Y\", \"ran a search\", \"sent the message\").\n- The act of delegating, dispatching, spawning, launching, steering or merging\n work — including when it succeeded. \"X was dispatched and completed\" is the\n session describing itself. Record only what the work LEARNED or CHANGED.\n- In-flight workflow/process narration (a sub-task started, paused, or is still\n running) — retain the outcome only once the task completes or a decision is made.\n- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.\n- Slash commands the user typed and their effects (e.g. \"User issued /clear to\n reset assistant state\").\n- Hindsight's own errors, retries, backlogs, or internal state — the memory\n system's self-reports are not memories.\n- Restatements of the user's current request or the task in progress.\n- Volatile state written as a timeless assertion. A version, count, size,\n backlog, status, or any \"X is running Y\" / \"X is at Y\" / \"X is currently Y\"\n claim is true only at the instant it was said. Concretely, never produce a\n fact whose text resembles any of these: \"Switchroom fleet is running image\n version v0.18.19\", \"The switchroom repo is at /path/to/fleet, version\n v0.19.5\", \"Bank overlord has 43155 pending consolidations\", \"The build is\n currently green\". If the claim is worth keeping, put the date INSIDE the\n fact text (\"As of 2026-07-19 the fleet was running v0.18.19\"); if you\n cannot date it, drop it. An undated one is recalled forever as though it\n were still true, which is worse than not remembering it at all.\n- Transient state (unread counts, build status, what is running right now) unless\n the fact is explicitly dated, in which case record it as a dated observation.\n- Greetings, acknowledgements, and routine operational chatter.\n\nWrite each fact so it stands alone: name the thing, the number, and the date. A\nsentence that only makes sense while reading this transcript is not durable.\n\nIf a candidate fact matches an exclusion, drop it rather than rewording it. If\nnothing durable remains, return an empty facts list.\n",
|
|
6
6
|
"autoRecall": true,
|
|
7
7
|
"autoRetain": true,
|
|
8
8
|
"retainMode": "full-session",
|