switchroom 0.15.42 → 0.15.44

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.
@@ -11299,7 +11299,7 @@ var init_zod = __esm(() => {
11299
11299
  });
11300
11300
 
11301
11301
  // src/config/schema.ts
11302
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11302
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
11303
11303
  var init_schema = __esm(() => {
11304
11304
  init_zod();
11305
11305
  CodeRepoEntrySchema = exports_external.object({
@@ -11420,6 +11420,8 @@ var init_schema = __esm(() => {
11420
11420
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
11421
11421
  min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default — " + "current behaviour). Try 0.10–0.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`."),
11422
11422
  types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] — the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
11423
+ additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
11424
+ sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender → extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username — a leading @ is " + "optional — or numeric user_id), merged " + "into its own results — so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
11423
11425
  skip_trivial: exports_external.boolean().optional().describe("Skip recall on plausibly-stateless trivial turns (time/date/" + "greeting). Switchroom default true — saves the recall arm + " + "injected tokens on turns that never need memory, guarded so it " + "never skips a turn that references user/project/session state. " + "Set false to always run recall."),
11424
11426
  topic_filter_mode: exports_external.enum(["soft-preamble", "hard-filter"]).optional().describe("Supergroup-mode cross-topic memory behaviour. Default " + "(unset) → soft-preamble: recall returns memories from all " + "topics, and a 'Current topic: …' preamble tells the model " + "to self-scope. hard-filter: drop any recalled memory whose " + "metadata.thread_id differs from the active inbound's topic. " + "Flip to hard-filter when the recall_log shows binding " + "failures (model surfacing the right memory but applying " + "it to the wrong topic).")
11425
11427
  }).optional().describe("Auto-recall tuning knobs")
@@ -11633,8 +11635,12 @@ var init_schema = __esm(() => {
11633
11635
  message: "release.channel and release.pin are mutually exclusive"
11634
11636
  });
11635
11637
  NetworkIsolationSchema = exports_external.enum(["host", "strict"]).optional().describe("Container network mode (sec WS6-F1 #1390 / feature #1413). " + "'host' (DEFAULT when unset): `network_mode: host` — the agent " + "shares the host network stack; hindsight 127.0.0.1:18888 and " + "operator-LAN devices are reachable, but there is NO network " + "isolation from sibling agents or host services (the documented, " + "deliberate shared-host tradeoff). 'strict': the agent joins its " + "OWN dedicated docker bridge network instead — it cannot reach " + "sibling agents; host services are reached via " + "`host.docker.internal`. OPT-IN: validate hindsight / operator-" + "LAN / cron / boot-self-test paths for your deployment before " + "adopting fleet-wide (default-flip is deferred to that validation " + "cycle, #1413). Cascades override (agent → profile → defaults).");
11638
+ servesField = exports_external.array(exports_external.string()).optional().describe("Users (keys in the top-level `users:` block) this agent works for. When " + "a served user messages this agent, their profile_bank is recalled " + "(speaker routing → memory.recall.sender_banks). Unions with any explicit " + "memory.recall.sender_banks. NOTE: this does not yet generate access " + "(allowFrom) — pair agent access as today; allowFrom generation is a " + "later phase.");
11639
+ knowsField = exports_external.array(exports_external.string()).optional().describe("Users or banks this agent always knows as subjects — recalled and " + "recall-ranked even when that person is not the speaker (→ " + "memory.recall.additional_banks). A `users:` key resolves to that user's " + "profile_bank; any other string is used as a raw bank name (e.g. a `kids` " + "profile bank with no Telegram identity). Unions with any explicit " + "memory.recall.additional_banks.");
11636
11640
  profileFields = {
11637
11641
  extends: exports_external.string().optional(),
11642
+ serves: servesField,
11643
+ knows: knowsField,
11638
11644
  bot_token: exports_external.string().optional(),
11639
11645
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) — mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
11640
11646
  timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
@@ -11655,7 +11661,9 @@ var init_schema = __esm(() => {
11655
11661
  recall: exports_external.object({
11656
11662
  max_memories: exports_external.number().int().min(0).optional(),
11657
11663
  cache_ttl_secs: exports_external.number().int().min(0).optional(),
11658
- min_overlap: exports_external.number().min(0).max(1).optional()
11664
+ min_overlap: exports_external.number().min(0).max(1).optional(),
11665
+ additional_banks: exports_external.array(exports_external.string()).optional(),
11666
+ sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional()
11659
11667
  }).optional()
11660
11668
  }).optional(),
11661
11669
  schedule: exports_external.array(ScheduleEntrySchema).optional(),
@@ -11700,6 +11708,8 @@ var init_schema = __esm(() => {
11700
11708
  AgentDefaultsSchema = exports_external.object(defaultsFields).optional();
11701
11709
  AgentSchema = exports_external.object({
11702
11710
  extends: exports_external.string().optional().describe("Name of a profile to inherit from (e.g., 'coding', 'health-coach'). " + "Profiles may be defined inline under switchroom.yaml `profiles:` or as a " + "filesystem directory `profiles/<name>/`. Defaults to DEFAULT_PROFILE " + "('default') when unset."),
11711
+ serves: servesField,
11712
+ knows: knowsField,
11703
11713
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
11704
11714
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) — a pinned agent does " + "not inherit the fleet channel, and vice versa."),
11705
11715
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
@@ -11873,6 +11883,11 @@ var init_schema = __esm(() => {
11873
11883
  CronConfigSchema = exports_external.object({
11874
11884
  egress: CronEgressSchema.optional().describe("SSRF/exfil fence for http-diff polls.")
11875
11885
  });
11886
+ UserSchema = exports_external.object({
11887
+ name: exports_external.string().optional().describe("Display name for the user."),
11888
+ telegram_ids: exports_external.array(exports_external.string()).min(1).describe("Telegram username(s) and/or numeric user id(s) identifying this user " + "(a leading @ is optional). Matched against the message sender for " + "per-speaker memory routing."),
11889
+ profile_bank: exports_external.string().describe("Hindsight bank holding this user's memory profile (author via " + "`switchroom memory profile add <bank> ...`).")
11890
+ });
11876
11891
  SwitchroomConfigSchema = exports_external.object({
11877
11892
  switchroom: exports_external.object({
11878
11893
  version: exports_external.literal(1).describe("Config schema version"),
@@ -11919,10 +11934,31 @@ var init_schema = __esm(() => {
11919
11934
  })).optional().describe("RFC #1873: per-Microsoft-account ACL. Maps account email → list of " + "agents permitted to use that account's broker credentials. Written " + "by `switchroom auth microsoft enable|disable`; read by the broker " + "on get-credentials with provider=microsoft."),
11920
11935
  defaults: AgentDefaultsSchema.describe("Implicit bottom-of-cascade profile applied to every agent before " + "per-agent config and `extends:` resolution. Tools, mcp_servers, and " + "schedule are unioned/concatenated; scalars and nested objects are " + "shallow-merged with per-agent values winning."),
11921
11936
  profiles: exports_external.record(exports_external.string(), ProfileSchema).optional().describe("Named profile definitions. Agents reference via `extends: <name>`. " + "Inline profiles declared here take priority over filesystem " + "profiles/<name>/ directories when both exist."),
11937
+ users: exports_external.record(exports_external.string(), UserSchema).optional().describe("Trusted users the fleet serves — each a Telegram identity plus a " + "memory profile bank. Assigned to agents via `serves` / `knows`. The " + "operator's own trusted people (single-tenant), not multi-tenant. See " + "reference/rfcs/user-concept.md."),
11922
11938
  agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
11923
11939
  message: "Agent name must start with a letter/digit, contain only lowercase letters/digits/hyphens/underscores, and be at most 51 characters (Telegram callback_data byte limit)"
11924
11940
  }), AgentSchema).describe("Map of agent name to agent configuration"),
11925
11941
  cron: CronConfigSchema.optional().describe("Cheap-cron settings (reference/rfcs/cheap-cron-sessions.md). Operator-owned " + "egress allowlist + host-pinned secret bindings for Tier-0 http-diff " + "polls (§6.1). Required to enable any http-diff poll; not agent-writable.")
11942
+ }).superRefine((cfg, ctx) => {
11943
+ const userKeys = new Set(Object.keys(cfg.users ?? {}));
11944
+ const checkServes = (serves, path) => {
11945
+ (serves ?? []).forEach((s, i) => {
11946
+ if (!userKeys.has(s)) {
11947
+ ctx.addIssue({
11948
+ code: exports_external.ZodIssueCode.custom,
11949
+ message: `serves references unknown user "${s}" — add it to the top-level ` + "`users:` block (or did you mean `knows` for a raw bank name?)",
11950
+ path: [...path, i]
11951
+ });
11952
+ }
11953
+ });
11954
+ };
11955
+ checkServes(cfg.defaults?.serves, ["defaults", "serves"]);
11956
+ for (const [name, p] of Object.entries(cfg.profiles ?? {})) {
11957
+ checkServes(p.serves, ["profiles", name, "serves"]);
11958
+ }
11959
+ for (const [name, a] of Object.entries(cfg.agents ?? {})) {
11960
+ checkServes(a.serves, ["agents", name, "serves"]);
11961
+ }
11926
11962
  });
11927
11963
  });
11928
11964
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.42",
3
+ "version": "0.15.44",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -355,6 +355,12 @@ export HINDSIGHT_RECALL_SKIP_TRIVIAL={{hindsightRecallSkipTrivial}}
355
355
  {{#if hindsightTopicAliasesJsonQ}}
356
356
  export HINDSIGHT_TOPIC_ALIASES_JSON={{{hindsightTopicAliasesJsonQ}}}
357
357
  {{/if}}
358
+ # Switchroom (per-speaker memory routing): {sender: bank} map so recall.py
359
+ # routes recall to the speaker's profile bank. Emitted only when the agent
360
+ # has a memory.recall.sender_banks map; absent for single-user agents.
361
+ {{#if hindsightSenderBanksJsonQ}}
362
+ export HINDSIGHT_SENDER_BANKS_JSON={{{hindsightSenderBanksJsonQ}}}
363
+ {{/if}}
358
364
  # PR6 — topic filter mode for cross-topic memory recall. Default
359
365
  # "soft-preamble": all topic-tagged memories surface and the model
360
366
  # decides relevance via the preamble. "hard-filter": drop memories
@@ -23802,7 +23802,7 @@ var init_dist = __esm(() => {
23802
23802
  });
23803
23803
 
23804
23804
  // ../src/config/schema.ts
23805
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
23805
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, SwitchroomConfigSchema;
23806
23806
  var init_schema = __esm(() => {
23807
23807
  init_zod();
23808
23808
  CodeRepoEntrySchema = exports_external.object({
@@ -23923,6 +23923,8 @@ var init_schema = __esm(() => {
23923
23923
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
23924
23924
  min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0\u20131.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default \u2014 " + "current behaviour). Try 0.10\u20130.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`."),
23925
23925
  types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] \u2014 the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
23926
+ additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results \u2014 e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
23927
+ sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender \u2192 extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username \u2014 a leading @ is " + "optional \u2014 or numeric user_id), merged " + "into its own results \u2014 so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
23926
23928
  skip_trivial: exports_external.boolean().optional().describe("Skip recall on plausibly-stateless trivial turns (time/date/" + "greeting). Switchroom default true \u2014 saves the recall arm + " + "injected tokens on turns that never need memory, guarded so it " + "never skips a turn that references user/project/session state. " + "Set false to always run recall."),
23927
23929
  topic_filter_mode: exports_external.enum(["soft-preamble", "hard-filter"]).optional().describe("Supergroup-mode cross-topic memory behaviour. Default " + "(unset) \u2192 soft-preamble: recall returns memories from all " + "topics, and a 'Current topic: \u2026' preamble tells the model " + "to self-scope. hard-filter: drop any recalled memory whose " + "metadata.thread_id differs from the active inbound's topic. " + "Flip to hard-filter when the recall_log shows binding " + "failures (model surfacing the right memory but applying " + "it to the wrong topic).")
23928
23930
  }).optional().describe("Auto-recall tuning knobs")
@@ -24136,8 +24138,12 @@ var init_schema = __esm(() => {
24136
24138
  message: "release.channel and release.pin are mutually exclusive"
24137
24139
  });
24138
24140
  NetworkIsolationSchema = exports_external.enum(["host", "strict"]).optional().describe("Container network mode (sec WS6-F1 #1390 / feature #1413). " + "'host' (DEFAULT when unset): `network_mode: host` \u2014 the agent " + "shares the host network stack; hindsight 127.0.0.1:18888 and " + "operator-LAN devices are reachable, but there is NO network " + "isolation from sibling agents or host services (the documented, " + "deliberate shared-host tradeoff). 'strict': the agent joins its " + "OWN dedicated docker bridge network instead \u2014 it cannot reach " + "sibling agents; host services are reached via " + "`host.docker.internal`. OPT-IN: validate hindsight / operator-" + "LAN / cron / boot-self-test paths for your deployment before " + "adopting fleet-wide (default-flip is deferred to that validation " + "cycle, #1413). Cascades override (agent \u2192 profile \u2192 defaults).");
24141
+ servesField = exports_external.array(exports_external.string()).optional().describe("Users (keys in the top-level `users:` block) this agent works for. When " + "a served user messages this agent, their profile_bank is recalled " + "(speaker routing \u2192 memory.recall.sender_banks). Unions with any explicit " + "memory.recall.sender_banks. NOTE: this does not yet generate access " + "(allowFrom) \u2014 pair agent access as today; allowFrom generation is a " + "later phase.");
24142
+ knowsField = exports_external.array(exports_external.string()).optional().describe("Users or banks this agent always knows as subjects \u2014 recalled and " + "recall-ranked even when that person is not the speaker (\u2192 " + "memory.recall.additional_banks). A `users:` key resolves to that user's " + "profile_bank; any other string is used as a raw bank name (e.g. a `kids` " + "profile bank with no Telegram identity). Unions with any explicit " + "memory.recall.additional_banks.");
24139
24143
  profileFields = {
24140
24144
  extends: exports_external.string().optional(),
24145
+ serves: servesField,
24146
+ knows: knowsField,
24141
24147
  bot_token: exports_external.string().optional(),
24142
24148
  release: ReleaseBlock.optional().describe("Release-channel pin / pointer. Either `channel` (dev|rc|latest) or " + "`pin` (sha-<hex>|v<semver>) \u2014 mutually exclusive. Per-agent value " + "REPLACES the root entirely (no field merge)."),
24143
24149
  timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC' " + "(three-letter aliases like EST/PST and bare offsets like UTC+10 are not accepted)").optional().describe("IANA timezone name (e.g. 'Australia/Melbourne', 'America/New_York', " + "'UTC'). Used to generate the per-turn local-time hint the agent's " + "UserPromptSubmit timezone hook emits, and baked into the agent " + "container's `environment.TZ` in compose so subprocess `date`/" + "`Date.now()` are correct. If unset at every cascade layer, switchroom " + "auto-detects from /etc/timezone and warns on `reconcile` when the " + "detected zone is UTC."),
@@ -24158,7 +24164,9 @@ var init_schema = __esm(() => {
24158
24164
  recall: exports_external.object({
24159
24165
  max_memories: exports_external.number().int().min(0).optional(),
24160
24166
  cache_ttl_secs: exports_external.number().int().min(0).optional(),
24161
- min_overlap: exports_external.number().min(0).max(1).optional()
24167
+ min_overlap: exports_external.number().min(0).max(1).optional(),
24168
+ additional_banks: exports_external.array(exports_external.string()).optional(),
24169
+ sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional()
24162
24170
  }).optional()
24163
24171
  }).optional(),
24164
24172
  schedule: exports_external.array(ScheduleEntrySchema).optional(),
@@ -24203,6 +24211,8 @@ var init_schema = __esm(() => {
24203
24211
  AgentDefaultsSchema = exports_external.object(defaultsFields).optional();
24204
24212
  AgentSchema = exports_external.object({
24205
24213
  extends: exports_external.string().optional().describe("Name of a profile to inherit from (e.g., 'coding', 'health-coach'). " + "Profiles may be defined inline under switchroom.yaml `profiles:` or as a " + "filesystem directory `profiles/<name>/`. Defaults to DEFAULT_PROFILE " + "('default') when unset."),
24214
+ serves: servesField,
24215
+ knows: knowsField,
24206
24216
  bot_token: exports_external.string().optional().describe("Per-agent Telegram bot token or vault reference (overrides global telegram.bot_token)"),
24207
24217
  release: ReleaseBlock.optional().describe("Per-agent release-channel pin / pointer. REPLACES the root " + "`release` block entirely (no field merge) \u2014 a pinned agent does " + "not inherit the fleet channel, and vice versa."),
24208
24218
  bot_username: exports_external.string().optional().describe("Per-agent Telegram bot username (without leading @) when it doesn't " + "contain the agent slug. Replaces the default 'username includes slug' " + "preflight check with an exact (case-insensitive) match. Use when an " + "agent and its bot have intentionally divergent names (e.g. agent " + "'lawgpt' paired with bot '@meken_law_bot')."),
@@ -24376,6 +24386,11 @@ var init_schema = __esm(() => {
24376
24386
  CronConfigSchema = exports_external.object({
24377
24387
  egress: CronEgressSchema.optional().describe("SSRF/exfil fence for http-diff polls.")
24378
24388
  });
24389
+ UserSchema = exports_external.object({
24390
+ name: exports_external.string().optional().describe("Display name for the user."),
24391
+ telegram_ids: exports_external.array(exports_external.string()).min(1).describe("Telegram username(s) and/or numeric user id(s) identifying this user " + "(a leading @ is optional). Matched against the message sender for " + "per-speaker memory routing."),
24392
+ profile_bank: exports_external.string().describe("Hindsight bank holding this user's memory profile (author via " + "`switchroom memory profile add <bank> ...`).")
24393
+ });
24379
24394
  SwitchroomConfigSchema = exports_external.object({
24380
24395
  switchroom: exports_external.object({
24381
24396
  version: exports_external.literal(1).describe("Config schema version"),
@@ -24422,10 +24437,31 @@ var init_schema = __esm(() => {
24422
24437
  })).optional().describe("RFC #1873: per-Microsoft-account ACL. Maps account email \u2192 list of " + "agents permitted to use that account's broker credentials. Written " + "by `switchroom auth microsoft enable|disable`; read by the broker " + "on get-credentials with provider=microsoft."),
24423
24438
  defaults: AgentDefaultsSchema.describe("Implicit bottom-of-cascade profile applied to every agent before " + "per-agent config and `extends:` resolution. Tools, mcp_servers, and " + "schedule are unioned/concatenated; scalars and nested objects are " + "shallow-merged with per-agent values winning."),
24424
24439
  profiles: exports_external.record(exports_external.string(), ProfileSchema).optional().describe("Named profile definitions. Agents reference via `extends: <name>`. " + "Inline profiles declared here take priority over filesystem " + "profiles/<name>/ directories when both exist."),
24440
+ users: exports_external.record(exports_external.string(), UserSchema).optional().describe("Trusted users the fleet serves \u2014 each a Telegram identity plus a " + "memory profile bank. Assigned to agents via `serves` / `knows`. The " + "operator's own trusted people (single-tenant), not multi-tenant. See " + "reference/rfcs/user-concept.md."),
24425
24441
  agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
24426
24442
  message: "Agent name must start with a letter/digit, contain only lowercase letters/digits/hyphens/underscores, and be at most 51 characters (Telegram callback_data byte limit)"
24427
24443
  }), AgentSchema).describe("Map of agent name to agent configuration"),
24428
24444
  cron: CronConfigSchema.optional().describe("Cheap-cron settings (reference/rfcs/cheap-cron-sessions.md). Operator-owned " + "egress allowlist + host-pinned secret bindings for Tier-0 http-diff " + "polls (\u00a76.1). Required to enable any http-diff poll; not agent-writable.")
24445
+ }).superRefine((cfg, ctx) => {
24446
+ const userKeys = new Set(Object.keys(cfg.users ?? {}));
24447
+ const checkServes = (serves, path) => {
24448
+ (serves ?? []).forEach((s, i) => {
24449
+ if (!userKeys.has(s)) {
24450
+ ctx.addIssue({
24451
+ code: exports_external.ZodIssueCode.custom,
24452
+ message: `serves references unknown user "${s}" \u2014 add it to the top-level ` + "`users:` block (or did you mean `knows` for a raw bank name?)",
24453
+ path: [...path, i]
24454
+ });
24455
+ }
24456
+ });
24457
+ };
24458
+ checkServes(cfg.defaults?.serves, ["defaults", "serves"]);
24459
+ for (const [name, p] of Object.entries(cfg.profiles ?? {})) {
24460
+ checkServes(p.serves, ["profiles", name, "serves"]);
24461
+ }
24462
+ for (const [name, a] of Object.entries(cfg.agents ?? {})) {
24463
+ checkServes(a.serves, ["agents", name, "serves"]);
24464
+ }
24429
24465
  });
24430
24466
  });
24431
24467
 
@@ -54595,11 +54631,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54595
54631
  }
54596
54632
 
54597
54633
  // ../src/build-info.ts
54598
- var VERSION = "0.15.42";
54599
- var COMMIT_SHA = "2c7e12da";
54600
- var COMMIT_DATE = "2026-06-19T09:09:26+10:00";
54601
- var LATEST_PR = 2434;
54602
- var COMMITS_AHEAD_OF_TAG = 5;
54634
+ var VERSION = "0.15.44";
54635
+ var COMMIT_SHA = "a375378f";
54636
+ var COMMIT_DATE = "2026-06-19T07:17:32Z";
54637
+ var LATEST_PR = 2445;
54638
+ var COMMITS_AHEAD_OF_TAG = 0;
54603
54639
 
54604
54640
  // gateway/boot-version.ts
54605
54641
  function formatRelativeAgo(iso) {
@@ -89,6 +89,35 @@ def extract_topic_from_prompt(
89
89
  return chat_id, thread_id
90
90
 
91
91
 
92
+ # Switchroom (per-speaker memory routing, RFC
93
+ # reference/rfcs/per-speaker-memory-routing.md): extract the sender identity
94
+ # (`user=` attribute) from the channel envelope. The gateway emits
95
+ # `user = from.username ?? String(from.id)`, so this is a username when the
96
+ # sender has one, else their numeric Telegram user id. Used to route recall to
97
+ # the speaker's profile bank — additive recall scoping, never an auth boundary.
98
+ _USER_RE = re.compile(
99
+ r"<channel\b[^>]*\buser=[\"']([^\"']+)[\"']",
100
+ re.IGNORECASE,
101
+ )
102
+
103
+
104
+ def extract_user_from_prompt(prompt: str) -> Optional[str]:
105
+ """Pull the sender `user` out of the `<channel ...>` envelope.
106
+
107
+ Returns None when the prompt isn't channel-wrapped (interactive
108
+ sessions, non-Telegram channels, test fixtures). The value is the
109
+ sender's username when set, else their numeric Telegram user id.
110
+ """
111
+ if not prompt or not isinstance(prompt, str):
112
+ return None
113
+ head = prompt[:1024]
114
+ match = _USER_RE.search(head)
115
+ if not match:
116
+ return None
117
+ user = match.group(1).strip()
118
+ return user or None
119
+
120
+
92
121
  def gateway_socket_path() -> Optional[str]:
93
122
  """Resolve the gateway socket path for the current agent.
94
123
 
@@ -59,7 +59,7 @@ from lib.content import (
59
59
  )
60
60
  from lib.daemon import get_api_url
61
61
  from lib.directives import fetch_active_directives, format_active_directives_block
62
- from lib.gateway_ipc import extract_chat_id_from_prompt, extract_topic_from_prompt, update_placeholder
62
+ from lib.gateway_ipc import extract_chat_id_from_prompt, extract_topic_from_prompt, extract_user_from_prompt, update_placeholder
63
63
  from lib.state import read_state, write_state
64
64
 
65
65
  LAST_RECALL_STATE = "last_recall.json"
@@ -188,12 +188,54 @@ def _cache_ttl_secs() -> int:
188
188
  return 0
189
189
 
190
190
 
191
+ def _normalize_sender(sender: str) -> str:
192
+ """Drop a single leading '@'. The gateway emits a bare username
193
+ (`from.username`), but operators naturally write `@handle` in config —
194
+ normalizing both sides lets either form match."""
195
+ return sender[1:] if sender.startswith("@") else sender
196
+
197
+
198
+ def _resolve_sender_bank(
199
+ sender_banks_json: str,
200
+ active_sender: str | None,
201
+ bank_id: str,
202
+ additional_banks: list,
203
+ ) -> list:
204
+ """Per-speaker memory routing: if `active_sender` maps to a bank in the
205
+ HINDSIGHT_SENDER_BANKS_JSON map, return ``additional_banks`` with that
206
+ bank appended (additive — skips dup/self). A leading '@' on either the
207
+ map keys or the sender is normalized away, so ``@lisa``, ``lisa``, and
208
+ the gateway's bare-emitted ``lisa`` all resolve together. Failure-safe:
209
+ any bad input (missing sender/env, non-dict JSON, decode error) returns
210
+ ``additional_banks`` unchanged. Never replaces the agent's own bank and
211
+ never touches auth — additive recall scoping only (single-tenant).
212
+ """
213
+ if not active_sender or not sender_banks_json:
214
+ return additional_banks
215
+ try:
216
+ sender_banks = json.loads(sender_banks_json)
217
+ except (json.JSONDecodeError, ValueError, TypeError):
218
+ return additional_banks
219
+ if not isinstance(sender_banks, dict):
220
+ return additional_banks
221
+ normalized = {_normalize_sender(str(k)): v for k, v in sender_banks.items()}
222
+ sender_bank = normalized.get(_normalize_sender(active_sender))
223
+ if (
224
+ sender_bank
225
+ and sender_bank != bank_id
226
+ and sender_bank not in additional_banks
227
+ ):
228
+ return [*additional_banks, sender_bank]
229
+ return additional_banks
230
+
231
+
191
232
  def _cache_key(
192
233
  session_id: str,
193
234
  prompt: str,
194
235
  bank_id: str,
195
236
  extra_banks: list,
196
237
  active_thread_id: str | None = None,
238
+ active_sender: str | None = None,
197
239
  ) -> str:
198
240
  """Stable hash for cache keying. Session_id is included so a new
199
241
  session always misses, regardless of the TTL setting. Extra banks
@@ -204,6 +246,11 @@ def _cache_key(
204
246
  but different topic) don't collide on the cache. Empty/None
205
247
  collapses to the empty string — backward-compatible for
206
248
  fleet-shared / DM agents where no thread_id is present.
249
+
250
+ Switchroom (per-speaker routing): `active_sender` is included for the
251
+ same reason — in a multi-user session two speakers sending the same
252
+ prompt resolve to different recall banks, so the sender must be part of
253
+ the key or one speaker's recall would be served to the other.
207
254
  """
208
255
  parts = [
209
256
  session_id or "",
@@ -211,6 +258,7 @@ def _cache_key(
211
258
  bank_id or "",
212
259
  ",".join(sorted(extra_banks or [])),
213
260
  active_thread_id or "",
261
+ active_sender or "",
214
262
  ]
215
263
  payload = "\x1f".join(parts)
216
264
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@@ -640,11 +688,26 @@ def main():
640
688
  bank_id = derive_bank_id(hook_input, config)
641
689
  additional_banks = config.get("recallAdditionalBanks", []) or []
642
690
 
691
+ # Switchroom (per-speaker memory routing, RFC
692
+ # reference/rfcs/per-speaker-memory-routing.md): when this agent serves
693
+ # multiple trusted users, also recall the *speaker's* profile bank. The
694
+ # sender is in the `<channel user="...">` envelope; the sender→bank map is
695
+ # injected as HINDSIGHT_SENDER_BANKS_JSON ({"<username-or-id>": "<bank>"}).
696
+ # Additive — never replaces the agent's own bank, never an auth boundary
697
+ # (single-tenant). Failure-safe + silent on every error path.
698
+ active_sender = extract_user_from_prompt(prompt)
699
+ additional_banks = _resolve_sender_bank(
700
+ os.environ.get("HINDSIGHT_SENDER_BANKS_JSON", ""),
701
+ active_sender,
702
+ bank_id,
703
+ additional_banks,
704
+ )
705
+
643
706
  # Switchroom #424 phase 4.1 — cache check BEFORE any HTTP traffic.
644
707
  # Whole-session-scoped, opt-in via HINDSIGHT_RECALL_CACHE_TTL_SECS.
645
708
  cache_ttl = _cache_ttl_secs()
646
709
  cache_key = (
647
- _cache_key(session_id, prompt, bank_id, additional_banks, active_thread_id)
710
+ _cache_key(session_id, prompt, bank_id, additional_banks, active_thread_id, active_sender)
648
711
  if cache_ttl > 0
649
712
  else ""
650
713
  )
@@ -0,0 +1,127 @@
1
+ """Unit tests for per-speaker memory routing (Switchroom).
2
+
3
+ RFC reference/rfcs/per-speaker-memory-routing.md. Covers:
4
+ - extract_user_from_prompt: sender (`user=`) extraction from the
5
+ <channel ...> envelope (username, numeric id, quote styles, missing).
6
+ - _cache_key: the sender is part of the key, so two speakers sending the
7
+ same prompt in one session don't collide on the recall cache.
8
+
9
+ Stdlib-only.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import unittest
15
+
16
+ SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
17
+ if SCRIPTS_DIR not in sys.path:
18
+ sys.path.insert(0, SCRIPTS_DIR)
19
+
20
+ from lib.gateway_ipc import extract_user_from_prompt # noqa: E402
21
+ import recall # noqa: E402
22
+
23
+
24
+ class ExtractUserTests(unittest.TestCase):
25
+ def test_username_present(self):
26
+ p = '<channel source="telegram" chat_id="-100" user="lisa" ts="1">hi</channel>'
27
+ self.assertEqual(extract_user_from_prompt(p), "lisa")
28
+
29
+ def test_numeric_id_when_no_username(self):
30
+ p = '<channel source="telegram" chat_id="-100" user="987654321">hi</channel>'
31
+ self.assertEqual(extract_user_from_prompt(p), "987654321")
32
+
33
+ def test_single_quotes(self):
34
+ p = "<channel source='telegram' chat_id='-100' user='ken'>hi</channel>"
35
+ self.assertEqual(extract_user_from_prompt(p), "ken")
36
+
37
+ def test_no_envelope_returns_none(self):
38
+ self.assertIsNone(extract_user_from_prompt("a plain interactive prompt"))
39
+
40
+ def test_empty_user_returns_none(self):
41
+ p = '<channel source="telegram" chat_id="-100" user="">hi</channel>'
42
+ self.assertIsNone(extract_user_from_prompt(p))
43
+
44
+ def test_non_string_returns_none(self):
45
+ self.assertIsNone(extract_user_from_prompt(None))
46
+
47
+
48
+ class CacheKeySenderTests(unittest.TestCase):
49
+ """The sender must be part of the cache key so a multi-user session
50
+ doesn't serve one speaker's recall to another."""
51
+
52
+ def test_different_senders_different_keys(self):
53
+ k_ken = recall._cache_key("s1", "what's on today", "clerk", [], None, "ken")
54
+ k_lisa = recall._cache_key("s1", "what's on today", "clerk", [], None, "lisa")
55
+ self.assertNotEqual(k_ken, k_lisa)
56
+
57
+ def test_same_sender_same_key(self):
58
+ a = recall._cache_key("s1", "p", "clerk", [], None, "ken")
59
+ b = recall._cache_key("s1", "p", "clerk", [], None, "ken")
60
+ self.assertEqual(a, b)
61
+
62
+ def test_no_sender_backward_compatible(self):
63
+ # Omitting the sender (DM / fleet-shared, single user) collapses to
64
+ # the empty string — stable, and distinct from any named sender.
65
+ none1 = recall._cache_key("s1", "p", "clerk", [], None)
66
+ none2 = recall._cache_key("s1", "p", "clerk", [], None, None)
67
+ self.assertEqual(none1, none2)
68
+ self.assertNotEqual(
69
+ none1, recall._cache_key("s1", "p", "clerk", [], None, "ken")
70
+ )
71
+
72
+
73
+ class ResolveSenderBankTests(unittest.TestCase):
74
+ """The map lookup. The gateway emits a BARE username (from.username),
75
+ while operators naturally write `@handle` in config — both must resolve.
76
+ Plus additive append, dup/self skip, and fail-safe behaviour."""
77
+
78
+ def test_at_keyed_map_resolves_bare_sender(self):
79
+ # The headline case: operator keys "@lisa", gateway emits "lisa".
80
+ out = recall._resolve_sender_bank('{"@lisa": "lisa-profile"}', "lisa", "clerk", [])
81
+ self.assertEqual(out, ["lisa-profile"])
82
+
83
+ def test_bare_keyed_map_resolves_bare_sender(self):
84
+ out = recall._resolve_sender_bank('{"lisa": "lisa-profile"}', "lisa", "clerk", [])
85
+ self.assertEqual(out, ["lisa-profile"])
86
+
87
+ def test_numeric_id_key(self):
88
+ out = recall._resolve_sender_bank('{"123456789": "ken-profile"}', "123456789", "clerk", [])
89
+ self.assertEqual(out, ["ken-profile"])
90
+
91
+ def test_additive_keeps_existing_extra_banks(self):
92
+ out = recall._resolve_sender_bank('{"@lisa": "lisa-profile"}', "lisa", "clerk", ["shared"])
93
+ self.assertEqual(out, ["shared", "lisa-profile"])
94
+
95
+ def test_skips_self_bank(self):
96
+ out = recall._resolve_sender_bank('{"@lisa": "clerk"}', "lisa", "clerk", [])
97
+ self.assertEqual(out, [])
98
+
99
+ def test_skips_duplicate(self):
100
+ out = recall._resolve_sender_bank('{"@lisa": "lisa-profile"}', "lisa", "clerk", ["lisa-profile"])
101
+ self.assertEqual(out, ["lisa-profile"])
102
+
103
+ def test_unmapped_sender_unchanged(self):
104
+ out = recall._resolve_sender_bank('{"@lisa": "lisa-profile"}', "stranger", "clerk", ["shared"])
105
+ self.assertEqual(out, ["shared"])
106
+
107
+ def test_no_sender_unchanged(self):
108
+ self.assertEqual(recall._resolve_sender_bank('{"@lisa": "x"}', None, "clerk", []), [])
109
+
110
+ def test_empty_env_unchanged(self):
111
+ self.assertEqual(recall._resolve_sender_bank("", "lisa", "clerk", ["a"]), ["a"])
112
+
113
+ def test_bad_json_unchanged(self):
114
+ self.assertEqual(recall._resolve_sender_bank("{not json", "lisa", "clerk", []), [])
115
+
116
+ def test_non_dict_json_unchanged(self):
117
+ self.assertEqual(recall._resolve_sender_bank('["lisa"]', "lisa", "clerk", []), [])
118
+
119
+ def test_does_not_mutate_input_list(self):
120
+ original = ["shared"]
121
+ out = recall._resolve_sender_bank('{"@lisa": "lisa-profile"}', "lisa", "clerk", original)
122
+ self.assertEqual(original, ["shared"]) # input untouched
123
+ self.assertEqual(out, ["shared", "lisa-profile"])
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()