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.
- package/dist/agent-scheduler/index.js +37 -1
- package/dist/auth-broker/index.js +37 -1
- package/dist/cli/notion-write-pretool.mjs +37 -1
- package/dist/cli/switchroom.js +194 -9
- package/dist/host-control/main.js +37 -1
- package/dist/vault/approvals/kernel-server.js +38 -2
- package/dist/vault/broker/server.js +38 -2
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +6 -0
- package/telegram-plugin/dist/gateway/gateway.js +43 -7
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +29 -0
- package/vendor/hindsight-memory/scripts/recall.py +65 -2
- package/vendor/hindsight-memory/scripts/tests/test_sender_routing.py +127 -0
|
@@ -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
|
@@ -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.
|
|
54599
|
-
var COMMIT_SHA = "
|
|
54600
|
-
var COMMIT_DATE = "2026-06-
|
|
54601
|
-
var LATEST_PR =
|
|
54602
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
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()
|