switchroom 0.19.26 → 0.19.27

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.
Files changed (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -1,6 +1,9 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { runStatusPinReconcile } from "../gateway/status-pin-retarget.js";
3
- import type { DesiredPin, PinState } from "../status-pin.js";
2
+ import {
3
+ runStatusPinReconcile,
4
+ type StatusPinClaim,
5
+ } from "../gateway/status-pin-retarget.js";
6
+ import type { PinLegAction, PinState } from "../status-pin.js";
4
7
 
5
8
  /**
6
9
  * The orchestrator the gateway's `reconcileStatusPinInner` delegates to.
@@ -14,16 +17,19 @@ import type { DesiredPin, PinState } from "../status-pin.js";
14
17
  * turn AND deleted the durable row, putting it out of reach of both the
15
18
  * mid-session reaper and the next boot's sweep.
16
19
  *
17
- * These tests drive the module with a fake leg runner and assert the LEG
18
- * SEQUENCE — the thing that was wrong plus the registry bookkeeping.
20
+ * #3831: the expansion ALSO existed inside `status-pin-driver.ts`, decided from
21
+ * the driver's own `decidePinAction` call and lacking the per-leg persistence.
22
+ * That copy is gone — the driver now takes a `PinLegAction`, which cannot
23
+ * express a repin — so this module is the only decider. These tests assert the
24
+ * LEG SEQUENCE the driver now receives, plus the claim bookkeeping.
19
25
  */
20
26
 
21
27
  /** Records every leg and returns a scripted outcome for it. */
22
28
  function legRecorder(outcomes: Array<PinState | null>) {
23
- const legs: Array<{ from: PinState | null; want: DesiredPin }> = [];
29
+ const legs: Array<{ from: PinState | null; action: PinLegAction }> = [];
24
30
  let i = 0;
25
- const runPin = async (from: PinState | null, want: DesiredPin) => {
26
- legs.push({ from, want });
31
+ const runPin = async (action: PinLegAction, from: PinState | null) => {
32
+ legs.push({ from, action });
27
33
  const out = outcomes[i];
28
34
  i += 1;
29
35
  return out ?? null;
@@ -31,18 +37,10 @@ function legRecorder(outcomes: Array<PinState | null>) {
31
37
  return { legs, runPin };
32
38
  }
33
39
 
34
- function registries(seed?: { key: string; state: PinState; chatId: string; at: number }) {
35
- const r = {
36
- state: new Map<string, PinState>(),
37
- chatIds: new Map<string, string>(),
38
- pinnedAt: new Map<string, number>(),
39
- };
40
- if (seed) {
41
- r.state.set(seed.key, seed.state);
42
- r.chatIds.set(seed.key, seed.chatId);
43
- r.pinnedAt.set(seed.key, seed.at);
44
- }
45
- return r;
40
+ function claimsOf(seed?: { key: string; claim: StatusPinClaim }) {
41
+ const claims = new Map<string, StatusPinClaim>();
42
+ if (seed) claims.set(seed.key, seed.claim);
43
+ return claims;
46
44
  }
47
45
 
48
46
  const KEY = "fg:c:7";
@@ -51,7 +49,10 @@ const CHAT = "-100123";
51
49
  describe("runStatusPinReconcile — RETARGET", () => {
52
50
  it("runs BOTH legs: unpins the stale claim, then pins the new message", async () => {
53
51
  const { legs, runPin } = legRecorder([null, { messageId: 901 }]);
54
- const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
52
+ const claims = claimsOf({
53
+ key: KEY,
54
+ claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
55
+ });
55
56
 
56
57
  await runStatusPinReconcile({
57
58
  pinKey: KEY,
@@ -60,19 +61,19 @@ describe("runStatusPinReconcile — RETARGET", () => {
60
61
  desired: { pinned: true, messageId: 901 },
61
62
  persist: null,
62
63
  runPin,
63
- registries: reg,
64
+ claims,
64
65
  });
65
66
 
66
67
  // The pre-fix behaviour was legs === [unpin] and nothing pinned.
67
68
  expect(legs).toEqual([
68
- { from: { messageId: 900 }, want: { pinned: false } },
69
- { from: null, want: { pinned: true, messageId: 901 } },
69
+ { from: { messageId: 900 }, action: { kind: "unpin", messageId: 900 } },
70
+ { from: null, action: { kind: "pin", messageId: 901 } },
70
71
  ]);
71
- expect(reg.state.get(KEY)).toEqual({ messageId: 901 });
72
- expect(reg.chatIds.get(KEY)).toBe(CHAT);
72
+ expect(claims.get(KEY)?.messageId).toBe(901);
73
+ expect(claims.get(KEY)?.chatId).toBe(CHAT);
73
74
  });
74
75
 
75
- it("leg 2 starts from a NULL claim, so it decides a real `pin` (not a noop)", async () => {
76
+ it("leg 2 starts from a NULL claim, so the driver pins rather than no-opping", async () => {
76
77
  const { legs, runPin } = legRecorder([null, { messageId: 901 }]);
77
78
  await runStatusPinReconcile({
78
79
  pinKey: KEY,
@@ -81,14 +82,18 @@ describe("runStatusPinReconcile — RETARGET", () => {
81
82
  desired: { pinned: true, messageId: 901 },
82
83
  persist: null,
83
84
  runPin,
84
- registries: registries(),
85
+ claims: claimsOf(),
85
86
  });
86
87
  expect(legs[1].from).toBeNull();
88
+ expect(legs[1].action).toEqual({ kind: "pin", messageId: 901 });
87
89
  });
88
90
 
89
91
  it("re-stamps pinnedAt for the NEW card, so the reaper ages the live message", async () => {
90
92
  const { runPin } = legRecorder([null, { messageId: 901 }]);
91
- const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
93
+ const claims = claimsOf({
94
+ key: KEY,
95
+ claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
96
+ });
92
97
  await runStatusPinReconcile({
93
98
  pinKey: KEY,
94
99
  chatId: CHAT,
@@ -96,18 +101,22 @@ describe("runStatusPinReconcile — RETARGET", () => {
96
101
  desired: { pinned: true, messageId: 901 },
97
102
  persist: null,
98
103
  runPin,
99
- registries: reg,
104
+ claims,
105
+ now: () => 5000,
100
106
  });
101
107
  // Leg 1 cleared the claim (dropping the old stamp), leg 2 took a fresh one.
102
- expect(reg.pinnedAt.get(KEY)).toBeGreaterThan(1000);
108
+ expect(claims.get(KEY)?.pinnedAt).toBe(5000);
103
109
  });
104
110
 
105
111
  it("NEVER-CONFIRMED unpin (#3664 Defect B): SKIPS the pin leg and retains the OLD claim", async () => {
106
- // A non-null leg-1 result means reconcilePin deliberately kept the claim —
112
+ // A non-null leg-1 result means executePinLeg deliberately kept the claim —
107
113
  // the old message is provably still pinned. Pinning the new one now would
108
114
  // leave TWO pins in the chat with a durable record of one.
109
115
  const { legs, runPin } = legRecorder([{ messageId: 900 }]);
110
- const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
116
+ const claims = claimsOf({
117
+ key: KEY,
118
+ claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
119
+ });
111
120
 
112
121
  await runStatusPinReconcile({
113
122
  pinKey: KEY,
@@ -116,19 +125,24 @@ describe("runStatusPinReconcile — RETARGET", () => {
116
125
  desired: { pinned: true, messageId: 901 },
117
126
  persist: null,
118
127
  runPin,
119
- registries: reg,
128
+ claims,
120
129
  });
121
130
 
122
131
  expect(legs).toHaveLength(1);
123
- expect(reg.state.get(KEY)).toEqual({ messageId: 900 });
124
- expect(reg.chatIds.get(KEY)).toBe(CHAT);
125
- expect(reg.pinnedAt.get(KEY)).toBe(1000); // original age preserved for the TTL gate
132
+ expect(claims.get(KEY)).toEqual({
133
+ messageId: 900,
134
+ chatId: CHAT,
135
+ pinnedAt: 1000, // original age preserved for the TTL gate
136
+ });
126
137
  });
127
138
 
128
139
  it("a FAILED pin leg drops the claim entirely (nothing is pinned, nothing is tracked)", async () => {
129
- // reconcilePin returns its `from` on a failed pin — here `null`.
140
+ // executePinLeg returns its `from` on a failed pin — here `null`.
130
141
  const { runPin } = legRecorder([null, null]);
131
- const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
142
+ const claims = claimsOf({
143
+ key: KEY,
144
+ claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
145
+ });
132
146
  await runStatusPinReconcile({
133
147
  pinKey: KEY,
134
148
  chatId: CHAT,
@@ -136,18 +150,16 @@ describe("runStatusPinReconcile — RETARGET", () => {
136
150
  desired: { pinned: true, messageId: 901 },
137
151
  persist: null,
138
152
  runPin,
139
- registries: reg,
153
+ claims,
140
154
  });
141
- expect(reg.state.has(KEY)).toBe(false);
142
- expect(reg.chatIds.has(KEY)).toBe(false);
143
- expect(reg.pinnedAt.has(KEY)).toBe(false);
155
+ expect(claims.has(KEY)).toBe(false);
144
156
  });
145
157
  });
146
158
 
147
159
  describe("runStatusPinReconcile — single-leg actions are unchanged", () => {
148
- it("first pin: one leg, claim + chat + age recorded", async () => {
160
+ it("first pin: one leg, claim + chat + age recorded together", async () => {
149
161
  const { legs, runPin } = legRecorder([{ messageId: 900 }]);
150
- const reg = registries();
162
+ const claims = claimsOf();
151
163
  await runStatusPinReconcile({
152
164
  pinKey: KEY,
153
165
  chatId: CHAT,
@@ -155,16 +167,26 @@ describe("runStatusPinReconcile — single-leg actions are unchanged", () => {
155
167
  desired: { pinned: true, messageId: 900 },
156
168
  persist: null,
157
169
  runPin,
158
- registries: reg,
170
+ claims,
171
+ now: () => 4242,
172
+ });
173
+ expect(legs).toEqual([
174
+ { from: null, action: { kind: "pin", messageId: 900 } },
175
+ ]);
176
+ // #3809: the claim is ONE record — a chat-less claim cannot be represented.
177
+ expect(claims.get(KEY)).toEqual({
178
+ messageId: 900,
179
+ chatId: CHAT,
180
+ pinnedAt: 4242,
159
181
  });
160
- expect(legs).toEqual([{ from: null, want: { pinned: true, messageId: 900 } }]);
161
- expect(reg.state.get(KEY)).toEqual({ messageId: 900 });
162
- expect(reg.pinnedAt.has(KEY)).toBe(true);
163
182
  });
164
183
 
165
- it("turn-end unpin: one leg, all three registries cleared", async () => {
184
+ it("turn-end unpin: one leg, the whole claim is cleared", async () => {
166
185
  const { legs, runPin } = legRecorder([null]);
167
- const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
186
+ const claims = claimsOf({
187
+ key: KEY,
188
+ claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
189
+ });
168
190
  await runStatusPinReconcile({
169
191
  pinKey: KEY,
170
192
  chatId: CHAT,
@@ -172,19 +194,22 @@ describe("runStatusPinReconcile — single-leg actions are unchanged", () => {
172
194
  desired: { pinned: false },
173
195
  persist: null,
174
196
  runPin,
175
- registries: reg,
197
+ claims,
176
198
  });
177
- expect(legs).toEqual([{ from: { messageId: 900 }, want: { pinned: false } }]);
178
- expect(reg.state.has(KEY)).toBe(false);
179
- expect(reg.chatIds.has(KEY)).toBe(false);
180
- expect(reg.pinnedAt.has(KEY)).toBe(false);
199
+ expect(legs).toEqual([
200
+ { from: { messageId: 900 }, action: { kind: "unpin", messageId: 900 } },
201
+ ]);
202
+ expect(claims.has(KEY)).toBe(false);
181
203
  });
182
204
 
183
205
  it("steady-state re-pin of the SAME id: still one leg, and the ORIGINAL age is kept", async () => {
184
206
  // 20 edits/turn each re-drive this; the reaper's TTL must measure from the
185
207
  // first pin, not be reset by every edit.
186
208
  const { legs, runPin } = legRecorder([{ messageId: 900 }]);
187
- const reg = registries({ key: KEY, state: { messageId: 900 }, chatId: CHAT, at: 1000 });
209
+ const claims = claimsOf({
210
+ key: KEY,
211
+ claim: { messageId: 900, chatId: CHAT, pinnedAt: 1000 },
212
+ });
188
213
  await runStatusPinReconcile({
189
214
  pinKey: KEY,
190
215
  chatId: CHAT,
@@ -192,15 +217,17 @@ describe("runStatusPinReconcile — single-leg actions are unchanged", () => {
192
217
  desired: { pinned: true, messageId: 900 },
193
218
  persist: null,
194
219
  runPin,
195
- registries: reg,
220
+ claims,
221
+ now: () => 9_999_999,
196
222
  });
197
223
  expect(legs).toHaveLength(1);
198
- expect(reg.pinnedAt.get(KEY)).toBe(1000);
224
+ expect(legs[0].action.kind).toBe("noop");
225
+ expect(claims.get(KEY)?.pinnedAt).toBe(1000);
199
226
  });
200
227
 
201
- it("unpin with no claim: one no-op leg, registries stay empty", async () => {
228
+ it("unpin with no claim: one no-op leg, the registry stays empty", async () => {
202
229
  const { legs, runPin } = legRecorder([null]);
203
- const reg = registries();
230
+ const claims = claimsOf();
204
231
  await runStatusPinReconcile({
205
232
  pinKey: KEY,
206
233
  chatId: CHAT,
@@ -208,9 +235,10 @@ describe("runStatusPinReconcile — single-leg actions are unchanged", () => {
208
235
  desired: { pinned: false },
209
236
  persist: null,
210
237
  runPin,
211
- registries: reg,
238
+ claims,
212
239
  });
213
240
  expect(legs).toHaveLength(1);
214
- expect(reg.state.size).toBe(0);
241
+ expect(legs[0].action.kind).toBe("noop");
242
+ expect(claims.size).toBe(0);
215
243
  });
216
244
  });
@@ -43,13 +43,17 @@ describe('status-pin service-message suppression', () => {
43
43
  // message (which would nuke manual/operator pins too) — AND it must be
44
44
  // chat-scoped (pinnedMessageIsOurs requires chatId + messageId), never a
45
45
  // messageId-only match.
46
- expect(MODULE_SRC).toContain('deps.statusPinState')
46
+ expect(MODULE_SRC).toContain('deps.statusPinClaims')
47
47
  expect(MODULE_SRC).toMatch(/pinned_message\?\.message_id/)
48
48
  expect(MODULE_SRC).toContain('pinnedMessageIsOurs(')
49
49
  // The chatId computed from the update must be fed to the guard.
50
50
  expect(MODULE_SRC).toMatch(/pinnedMessageIsOurs\(trackedPins\(\), chatId, pinnedId\)/)
51
- // And the tracked entries must carry their chat association.
52
- expect(MODULE_SRC).toContain('deps.statusPinChatIds.get(pinKey)')
51
+ // And the tracked entries must carry their chat association. Since #3809
52
+ // that association is STRUCTURAL — one claim record per key carrying both
53
+ // messageId and chatId — so the guard reads it off the claim itself and a
54
+ // chat-less tracked pin can no longer be constructed.
55
+ expect(MODULE_SRC).toMatch(/chatId: c\.chatId/)
56
+ expect(MODULE_SRC).toMatch(/messageId: c\.messageId/)
53
57
  })
54
58
 
55
59
  it('bails out when the pinned message is not one of ours', () => {