switchroom 0.20.21 → 0.21.0

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 (36) hide show
  1. package/dist/auth-broker/index.js +1 -1
  2. package/dist/cli/switchroom.js +1953 -1539
  3. package/dist/host-control/main.js +286 -122
  4. package/dist/vault/approvals/kernel-server.js +1 -1
  5. package/dist/vault/broker/server.js +1 -1
  6. package/package.json +1 -1
  7. package/skills/switchroom-release/SKILL.md +12 -1
  8. package/telegram-plugin/dist/gateway/gateway.js +1405 -851
  9. package/telegram-plugin/gateway/always-allow-persist-queue.ts +2 -2
  10. package/telegram-plugin/gateway/boot-beacon.ts +9 -2
  11. package/telegram-plugin/gateway/gateway-heartbeat.ts +4 -3
  12. package/telegram-plugin/gateway/gateway.ts +42 -15
  13. package/telegram-plugin/gateway/inbound-router.ts +31 -6
  14. package/telegram-plugin/gateway/missed-approvals-store.ts +2 -2
  15. package/telegram-plugin/gateway/pending-card-store.ts +2 -2
  16. package/telegram-plugin/gateway/privacy-state.ts +2 -2
  17. package/telegram-plugin/gateway/scoped-grant-store.ts +2 -2
  18. package/telegram-plugin/gateway/system-message-observer.ts +242 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +3 -4
  20. package/telegram-plugin/history.ts +178 -11
  21. package/telegram-plugin/registry/turns-schema.ts +8 -2
  22. package/telegram-plugin/tests/buzz-mirror.test.ts +12 -1
  23. package/telegram-plugin/tests/card-history-lane.test.ts +394 -0
  24. package/telegram-plugin/tests/system-message-observer.test.ts +216 -0
  25. package/vendor/hindsight-memory/scripts/drain_pending.py +88 -4
  26. package/vendor/hindsight-memory/scripts/lib/config.py +112 -11
  27. package/vendor/hindsight-memory/scripts/recall.py +11 -2
  28. package/vendor/hindsight-memory/scripts/reconcile_tail.py +36 -0
  29. package/vendor/hindsight-memory/scripts/retain.py +6 -1
  30. package/vendor/hindsight-memory/scripts/tests/test_config_retain_env.py +99 -0
  31. package/vendor/hindsight-memory/scripts/tests/test_recall_types_filter.py +81 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +113 -0
  33. package/vendor/hindsight-memory/settings.json +2 -2
  34. package/vendor/hindsight-memory/tests/test_config.py +8 -3
  35. package/vendor/hindsight-memory/tests/test_hooks.py +10 -1
  36. package/vendor/hindsight-memory/tests/test_retain_context.py +69 -0
@@ -38,15 +38,14 @@
38
38
  import {
39
39
  closeSync,
40
40
  existsSync,
41
- mkdirSync,
42
41
  openSync,
43
42
  readFileSync,
44
43
  statSync,
45
44
  unlinkSync,
46
45
  utimesSync,
47
- writeFileSync,
48
46
  } from "node:fs";
49
47
  import { join } from "node:path";
48
+ import { mkdirStateSync, writeStateFileSync } from "../../src/util/state-owner.js";
50
49
 
51
50
  export const TURN_ACTIVE_MARKER_FILE = "turn-active.json";
52
51
 
@@ -82,8 +81,8 @@ export interface TurnActiveMarker {
82
81
  */
83
82
  export function writeTurnActiveMarker(stateDir: string, marker: TurnActiveMarker): void {
84
83
  try {
85
- mkdirSync(stateDir, { recursive: true });
86
- writeFileSync(
84
+ mkdirStateSync(stateDir, { recursive: true });
85
+ writeStateFileSync(
87
86
  join(stateDir, TURN_ACTIVE_MARKER_FILE),
88
87
  JSON.stringify(marker, null, 2) + "\n",
89
88
  { mode: 0o600 },
@@ -25,8 +25,9 @@
25
25
  * chat exists in the DB.
26
26
  */
27
27
 
28
- import { chmodSync, existsSync, mkdirSync } from 'fs'
28
+ import { chmodSync, existsSync } from 'fs'
29
29
  import { join } from 'path'
30
+ import { adoptSqliteOwnership, mkdirStateSync } from '../src/util/state-owner.js'
30
31
  import { redact } from './secret-detect/redact.js'
31
32
 
32
33
  /**
@@ -81,7 +82,22 @@ function loadDatabaseClass(): SqliteDatabaseConstructor {
81
82
  }
82
83
  }
83
84
 
84
- export type MessageRole = 'user' | 'assistant'
85
+ /**
86
+ * `system` is the CARD lane (#4571): a bot→user message the gateway posted as
87
+ * a UI surface rather than as an answer — the mid-turn activity card, the
88
+ * status pin, approval / boot / issues / worker-feed cards, restart notices,
89
+ * `progress_update` lines. Those consume real Telegram message ids, so the
90
+ * operator can (and does) quote-reply to one; before this lane existed the
91
+ * row simply did not exist and the reply resolved to nothing.
92
+ *
93
+ * They are deliberately NOT `assistant`: every delivery-accounting predicate
94
+ * in this file keys on `role = 'assistant'` meaning "the user was actually
95
+ * answered" (`getRecentOutboundCount`, `hasOutboundDeliveredSince`,
96
+ * `hasOutboundWithText`), and calling a card an answer would silently suppress
97
+ * the silence/over-ping/represent safety nets. A distinct value keeps every
98
+ * existing predicate byte-identical while making the id resolvable.
99
+ */
100
+ export type MessageRole = 'user' | 'assistant' | 'system'
85
101
 
86
102
  export interface RecordedMessage {
87
103
  chat_id: string
@@ -127,6 +143,14 @@ export interface RecordedMessage {
127
143
  forwarded_from_id: string | null
128
144
  forwarded_date: string | null
129
145
  forwarded_message_id: number | null
146
+ /**
147
+ * Discriminator for a `system` (card) row: the send's `verb` tag as it was
148
+ * passed to the gateway's retry wrapper, normalised (`activity-summary.send`
149
+ * → `activity-summary`). Null for `user` / `assistant` rows and for an
150
+ * untagged send. Surfaced to the agent as `reply_to_kind` when a native
151
+ * reply points at one of these messages.
152
+ */
153
+ kind: string | null
130
154
  }
131
155
 
132
156
  export interface QueryOptions {
@@ -134,6 +158,14 @@ export interface QueryOptions {
134
158
  thread_id?: number | null
135
159
  limit?: number
136
160
  before_message_id?: number
161
+ /**
162
+ * Include `system` (card) rows in the result. DEFAULT FALSE, deliberately:
163
+ * cards are ephemeral UI, and `get_recent_messages` renders straight into
164
+ * the operator-visible model context — a wall of activity-card rows there
165
+ * would be a regression. Card rows stay RESOLVABLE by id (see
166
+ * {@link lookupMessageRoleAndText}) without being LISTED.
167
+ */
168
+ include_system?: boolean
137
169
  }
138
170
 
139
171
  const DEFAULT_LIMIT = 10
@@ -185,7 +217,7 @@ function isValidMessageId(id: unknown): id is number {
185
217
  export function initHistory(stateDir: string, retentionDays = 30): void {
186
218
  if (db != null) return
187
219
  const Database = loadDatabaseClass()
188
- mkdirSync(stateDir, { recursive: true, mode: 0o700 })
220
+ mkdirStateSync(stateDir, { recursive: true, mode: 0o700 })
189
221
  const path = join(stateDir, 'history.db')
190
222
  dbPath = path
191
223
  db = new Database(path, { create: true })
@@ -212,6 +244,7 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
212
244
  group_id INTEGER,
213
245
  reply_to_message_id INTEGER,
214
246
  reply_to_text TEXT,
247
+ kind TEXT,
215
248
  PRIMARY KEY (chat_id, thread_id, message_id)
216
249
  )
217
250
  `)
@@ -232,6 +265,10 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
232
265
  "forwarded_from_id TEXT",
233
266
  "forwarded_date TEXT",
234
267
  "forwarded_message_id INTEGER",
268
+ // #4571 — card/system-surface discriminator. Nullable with no default, so
269
+ // the ALTER is instant on a multi-thousand-row live DB and every existing
270
+ // row keeps its exact contents (SQLite backfills NULL without a rewrite).
271
+ "kind TEXT",
235
272
  ]) {
236
273
  try {
237
274
  db.exec(`ALTER TABLE messages ADD COLUMN ${column}`)
@@ -300,6 +337,12 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
300
337
  const f = path + suffix
301
338
  if (existsSync(f)) { try { chmodSync(f, 0o644) } catch { /* ignore */ } }
302
339
  }
340
+ // Ownership rides the same hook as the mode, for the same reason. SQLite
341
+ // creates `-wal`/`-shm` itself, in C, so no `node:fs` write helper can ever
342
+ // see them — this is the only place we get to fix their owner. A root
343
+ // gateway would otherwise leave root:root DB files in an agent-owned state
344
+ // dir and EACCES every non-root reader. No-op off the root path.
345
+ adoptSqliteOwnership(path)
303
346
 
304
347
  if (retentionDays > 0) {
305
348
  const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400
@@ -422,6 +465,9 @@ export function checkpointWal(): boolean {
422
465
  const f = dbPath + suffix
423
466
  if (existsSync(f)) { try { chmodSync(f, 0o644) } catch { /* ignore */ } }
424
467
  }
468
+ // …and ownership with it: a TRUNCATE checkpoint DELETES and RE-CREATES
469
+ // the sidecars, so the owner we set at init is gone by here.
470
+ adoptSqliteOwnership(dbPath)
425
471
  }
426
472
  return true
427
473
  } catch {
@@ -609,12 +655,29 @@ export function recordOutbound(args: RecordOutboundArgs): void {
609
655
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
610
656
  VALUES (?, ?, ?, 'assistant', NULL, NULL, ?, ?, ?, ?)
611
657
  `)
658
+ // #4571 — a real reply ALWAYS wins over a provisional card row.
659
+ //
660
+ // The system-lane observer (gateway `robustApiCall`) records every sent
661
+ // message the moment the API resolves, which is BEFORE the caller reaches
662
+ // its own `recordOutbound`. `INSERT OR REPLACE` alone would not reliably
663
+ // overwrite that row: the unique key folds `thread_id`, and the observer
664
+ // derives the thread from the Telegram response (which sets
665
+ // `message_thread_id` on reply chains in plain supergroups) while this
666
+ // writer uses the gateway's own `threadId` — a mismatch would leave BOTH
667
+ // rows and let the card row shadow the answer for id lookups. Deleting on
668
+ // (chat_id, message_id) — unique within a chat regardless of thread, the
669
+ // same assumption `recordEdit` / `recordReaction` already make — makes the
670
+ // promotion exact and is a no-op when no observer row exists.
671
+ const dropSystem = requireDb().prepare(
672
+ `DELETE FROM messages WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
673
+ )
612
674
  // bun:sqlite has a transaction() helper. Cheap insurance against partial
613
675
  // writes if the process dies mid-loop. The transaction signature is
614
676
  // typed as variadic-unknown for genericity; cast the typed callback
615
677
  // through the wider shape.
616
678
  const tx = requireDb().transaction(((rows: Array<{ id: number; text: string; attachKind: string | null }>) => {
617
679
  for (const r of rows) {
680
+ dropSystem.run(args.chat_id, r.id)
618
681
  stmt.run(args.chat_id, args.thread_id ?? null, r.id, ts, r.text, r.attachKind, groupId)
619
682
  }
620
683
  }) as (...args: unknown[]) => unknown)
@@ -634,6 +697,97 @@ export function recordOutbound(args: RecordOutboundArgs): void {
634
697
  }
635
698
  }
636
699
 
700
+ export interface RecordSystemOutboundArgs {
701
+ chat_id: string
702
+ thread_id: number | null | undefined
703
+ message_id: number
704
+ /** Normalised send verb (`activity-summary`, `approval-card`, …) or null. */
705
+ kind: string | null | undefined
706
+ /** Rendered text as Telegram echoed it back. May be empty. */
707
+ text: string
708
+ /** Unix SECONDS. Defaults to now. */
709
+ ts?: number
710
+ }
711
+
712
+ /**
713
+ * Record a CARD / system-surface outbound (#4571).
714
+ *
715
+ * Conditional insert on (chat_id, message_id): if ANY row already exists for
716
+ * that id — a real `assistant` reply recorded by `recordOutbound`, an inbound,
717
+ * or a previously-recorded card — this is a no-op and returns false. That is
718
+ * what makes the call safe to fire from a blanket send observer that cannot
719
+ * distinguish a fresh send from an edit of a message it already knows: an edit
720
+ * returns the SAME `message_id`, hits the `NOT EXISTS` guard, and falls through
721
+ * to {@link updateSystemOutboundText} instead of duplicating a row per edit.
722
+ *
723
+ * Never throws — a card row is a nice-to-have, and a failure here must not
724
+ * break the send path it is observing. Failures are logged via `warnHistory`.
725
+ *
726
+ * Returns true iff a new row was inserted.
727
+ */
728
+ export function recordSystemOutbound(args: RecordSystemOutboundArgs): boolean {
729
+ if (!isValidMessageId(args.message_id)) return false
730
+ // History disabled / not yet initialised: stay silent. This is called from a
731
+ // blanket send observer, so a warn here would be one stderr line per API call.
732
+ if (db == null) return false
733
+ try {
734
+ const res = requireDb()
735
+ .prepare(`
736
+ INSERT INTO messages
737
+ (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, kind)
738
+ SELECT ?, ?, ?, 'system', NULL, NULL, ?, ?, NULL, NULL, ?
739
+ WHERE NOT EXISTS (
740
+ SELECT 1 FROM messages WHERE chat_id = ? AND message_id = ?
741
+ )
742
+ `)
743
+ .run(
744
+ args.chat_id,
745
+ args.thread_id ?? null,
746
+ args.message_id,
747
+ args.ts ?? Math.floor(Date.now() / 1000),
748
+ // Same outbound redaction chokepoint as recordOutbound: a card body is
749
+ // rendered from live tool activity and can quote a secret.
750
+ redact(args.text),
751
+ args.kind ?? null,
752
+ args.chat_id,
753
+ args.message_id,
754
+ ) as { changes?: number }
755
+ return (res?.changes ?? 0) > 0
756
+ } catch (err) {
757
+ warnHistory(
758
+ `recordSystemOutbound: INSERT failed (chat=${args.chat_id} id=${args.message_id}): ` +
759
+ `${err instanceof Error ? err.message : String(err)} — a posted card will not be ` +
760
+ `resolvable if the operator quote-replies to it`,
761
+ )
762
+ return false
763
+ }
764
+ }
765
+
766
+ /**
767
+ * Refresh a card row's stored text in place (#4571). Only ever touches a
768
+ * `system` row, so an edit of a real reply can never be rewritten through this
769
+ * path (`recordEdit` owns that).
770
+ *
771
+ * `ts` is deliberately NOT bumped: it is the card's POST time, which is what
772
+ * retention prunes on. Never throws. Returns true iff a row was updated.
773
+ */
774
+ export function updateSystemOutboundText(args: {
775
+ chat_id: string
776
+ message_id: number
777
+ text: string
778
+ }): boolean {
779
+ try {
780
+ const res = requireDb()
781
+ .prepare(
782
+ `UPDATE messages SET text = ? WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
783
+ )
784
+ .run(redact(args.text), args.chat_id, args.message_id) as { changes?: number }
785
+ return (res?.changes ?? 0) > 0
786
+ } catch {
787
+ return false
788
+ }
789
+ }
790
+
637
791
  interface RecordEditArgs {
638
792
  chat_id: string
639
793
  message_id: number
@@ -781,16 +935,27 @@ export function getLatestInboundMessageId(
781
935
  export function lookupMessageRoleAndText(
782
936
  chatId: string,
783
937
  messageId: number,
784
- ): { role: 'user' | 'assistant'; text: string } | null {
785
- const row = requireDb()
786
- .prepare(
787
- `SELECT role, text FROM messages WHERE chat_id = ? AND message_id = ? LIMIT 1`,
788
- )
789
- .get(chatId, messageId) as
790
- | { role: 'user' | 'assistant'; text: string | null }
938
+ opts?: {
939
+ /**
940
+ * Also resolve `system` (card) rows. DEFAULT FALSE so the two
941
+ * authorship-sensitive callers the reaction trigger's
942
+ * `role === 'assistant'` bot-authored predicate and the
943
+ * `reaction_dispatch` preview — keep their exact pre-#4571 behaviour
944
+ * (a card was invisible to them, and still is). The reply-antecedent
945
+ * resolver opts IN: that is the whole point of the card lane.
946
+ */
947
+ includeSystem?: boolean
948
+ },
949
+ ): { role: MessageRole; text: string; kind: string | null } | null {
950
+ const sql =
951
+ `SELECT role, text, kind FROM messages WHERE chat_id = ? AND message_id = ?` +
952
+ (opts?.includeSystem === true ? '' : ` AND role <> 'system'`) +
953
+ ` LIMIT 1`
954
+ const row = requireDb().prepare(sql).get(chatId, messageId) as
955
+ | { role: MessageRole; text: string | null; kind: string | null }
791
956
  | undefined
792
957
  if (!row) return null
793
- return { role: row.role, text: row.text ?? '' }
958
+ return { role: row.role, text: row.text ?? '', kind: row.kind ?? null }
794
959
  }
795
960
 
796
961
  export function getRecentOutboundCount(
@@ -1007,6 +1172,8 @@ export function query(opts: QueryOptions): RecordedMessage[] {
1007
1172
  const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
1008
1173
  const params: unknown[] = [opts.chat_id]
1009
1174
  let sql = 'SELECT * FROM messages WHERE chat_id = ?'
1175
+ // #4571 — card rows are resolvable, not listable. See QueryOptions.include_system.
1176
+ if (opts.include_system !== true) sql += " AND role <> 'system'"
1010
1177
  if (opts.thread_id !== undefined) {
1011
1178
  if (opts.thread_id === null) {
1012
1179
  sql += ' AND thread_id IS NULL'
@@ -36,8 +36,9 @@
36
36
  * otherwise; the gateway then resumes or reports accordingly.
37
37
  */
38
38
 
39
- import { chmodSync, mkdirSync } from 'fs'
39
+ import { chmodSync } from 'fs'
40
40
  import { join } from 'path'
41
+ import { adoptSqliteOwnership, mkdirStateSync } from '../../src/util/state-owner.js'
41
42
 
42
43
  // ---------------------------------------------------------------------------
43
44
  // bun:sqlite lazy-loader (same pattern as history.ts)
@@ -302,7 +303,7 @@ function applySchema(db: SqliteDatabase): void {
302
303
  export function openTurnsDb(agentDir: string): SqliteDatabase {
303
304
  const Database = loadDatabaseClass()
304
305
  const dir = join(agentDir, 'telegram')
305
- mkdirSync(dir, { recursive: true, mode: 0o700 })
306
+ mkdirStateSync(dir, { recursive: true, mode: 0o700 })
306
307
  const path = join(dir, 'registry.db')
307
308
  const db = new Database(path, { create: true })
308
309
  applySchema(db)
@@ -318,6 +319,11 @@ export function openTurnsDb(agentDir: string): SqliteDatabase {
318
319
  } catch {
319
320
  /* ignore — chmod not supported on some FUSE mounts */
320
321
  }
322
+ // Same reasoning as the chmod above, for the owner rather than the mode:
323
+ // a root-running gateway must not leave root:root DB files in an
324
+ // agent-owned state dir. `-wal`/`-shm` are created by SQLite in C, so this
325
+ // hook is the only place we can reach them. No-op off the root path.
326
+ adoptSqliteOwnership(path)
321
327
  return db
322
328
  }
323
329
 
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
1
+ import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest'
2
2
  import { mkdtempSync, rmSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
@@ -29,6 +29,17 @@ function mirrorWith(sender: (m: OutboundToBuzzMessage) => boolean, opts?: { defa
29
29
 
30
30
  const BUZZ_COORDS = { channelId: 'chan-A', eventId: 'evt-1', threadRoot: 'root-1' }
31
31
 
32
+ // Cross-FILE isolation. `getBuzzMirror()` is a module-level singleton and the
33
+ // last describe here boots one (`maybeBootBuzzMirror`) with no trailing reset —
34
+ // every `beforeEach` in this file resets on the way IN, nothing resets on the
35
+ // way OUT. `bun test` runs every file in ONE process with no guaranteed file
36
+ // order, so a booted mirror leaks into whichever suite runs next: sendReply's
37
+ // Buzz hook (outbound-send-path.ts:2658) then fires in suites that never wired
38
+ // its deps and dies on `findLatestTurnForChat is not a function` (observed:
39
+ // 43 failures in send-reply-golden.test.ts, purely from readdir order).
40
+ // Unconditional teardown here is the only ordering-proof fix.
41
+ afterAll(() => __resetBuzzMirrorForTests())
42
+
32
43
  describe('BuzzMirror.mirrorReplyDelivered — routing + S1 owner guard', () => {
33
44
  beforeEach(() => __resetBuzzMirrorForTests())
34
45