diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index b0361bec..e2b420f7 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -300,20 +300,9 @@ in }; options.hyperhive.matrixAccounts = lib.mkOption { - type = lib.types.listOf ( + type = lib.types.attrsOf ( lib.types.submodule { options = { - name = lib.mkOption { - type = lib.types.str; - example = "ccc"; - description = '' - Logical account name the agent addresses this account by - (the `account` argument on the matrix MCP tools). Must be - unique within the agent. The FIRST account in the list is - the agent's primary account --- the one selected when a - tool call omits `account`. - ''; - }; tokenFile = lib.mkOption { type = lib.types.str; example = "/agents/dmatrix/state/matrix-token-ccc"; @@ -350,21 +339,50 @@ in }; } ); - default = [ ]; + default = { }; + example = lib.literalExpression '' + { + main = { + tokenFile = "/agents/dmatrix/state/matrix-token"; + sessionDir = "/agents/dmatrix/state/matrix-sdk-state"; + }; + ccc = { + tokenFile = "/agents/dmatrix/state/matrix-token-ccc"; + sessionDir = "/agents/dmatrix/state/matrix-sdk-state-ccc"; + homeserver = "https://matrix.example.org"; + }; + } + ''; description = '' Declare multiple matrix accounts served by a single `hive-matrix-daemon` (one matrix-sdk Client + sync loop each), replacing the wasteful "one MCP server + daemon per account" - pattern. Each entry becomes a routable account the matrix MCP - tools can target via their `account` argument; omitting `account` - on a tool call selects the first (primary) entry. + pattern. The attribute name keys each account (unique by + construction) and is the handle the matrix MCP tools target via + their `account` argument; omitting `account` on a tool call selects + `hyperhive.matrixPrimaryAccount`. Leave empty (the default) for the common single-account case: the daemon then synthesizes one account from `hyperhive.matrix.url` + `/matrix-token` + `/matrix-sdk-state`, so existing - agents need no change. When non-empty, this list is serialized to - the daemon's `HIVE_MATRIX_ACCOUNTS` environment variable and the - legacy single-account fallback is bypassed. + agents need no change. When non-empty, the set is serialized to the + daemon's `HIVE_MATRIX_ACCOUNTS` environment variable + (primary-account-first) and the legacy single-account fallback is + bypassed. + ''; + }; + + options.hyperhive.matrixPrimaryAccount = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "main"; + description = '' + Which `hyperhive.matrixAccounts` key is the agent's primary + account --- the one the matrix MCP tools act as when a tool call + omits its `account` argument. May be left null when exactly one + account is declared (that sole account is then primary); it is + required (asserted) when more than one account is declared. Ignored + when `matrixAccounts` is empty. ''; }; @@ -756,6 +774,28 @@ in assertion = config.hyperhive.icon == null || lib.hasSuffix ".svg" (toString config.hyperhive.icon); message = "hyperhive.icon must point to an .svg file"; } + # With more than one matrix account, the primary must be named + # explicitly --- there is no inherent order in the attrset to pick + # one from. + { + assertion = + builtins.length (builtins.attrNames config.hyperhive.matrixAccounts) <= 1 + || config.hyperhive.matrixPrimaryAccount != null; + message = + "hyperhive.matrixPrimaryAccount must be set when more than one " + + "hyperhive.matrixAccounts entry is declared (it selects the account " + + "used when a matrix tool call omits `account`)."; + } + # When set, the primary must name an actual declared account. + { + assertion = + config.hyperhive.matrixPrimaryAccount == null + || builtins.hasAttr config.hyperhive.matrixPrimaryAccount config.hyperhive.matrixAccounts; + message = + "hyperhive.matrixPrimaryAccount (\"${toString config.hyperhive.matrixPrimaryAccount}\") " + + "must be one of the hyperhive.matrixAccounts keys " + + "([ ${lib.concatStringsSep " " (builtins.attrNames config.hyperhive.matrixAccounts)} ])."; + } # hyperhive.frontend.extraFiles[*].target is concatenated into # $out during the mergedDist build. The option's strMatching # type already rejects leading `/`, leading `.`, and the @@ -1313,22 +1353,41 @@ in # daemon parses (`accounts::configured`). Only set when accounts # are declared, so the single-account agents (the common case) # keep hitting the daemon's legacy fallback (which is bypassed the - # moment HIVE_MATRIX_ACCOUNTS is present). Keys match the daemon's - # `AccountCfg` serde shape: name / token_file / state_dir / - # optional homeserver. - // lib.optionalAttrs (config.hyperhive.matrixAccounts != [ ]) { - HIVE_MATRIX_ACCOUNTS = builtins.toJSON ( - map ( - a: + # moment HIVE_MATRIX_ACCOUNTS is present). The `matrixAccounts` + # attrset is keyed by account name (unique by construction); we + # emit a primary-first JSON array (the daemon treats index 0 as the + # primary) with each entry in the daemon's `AccountCfg` serde shape: + # name / token_file / state_dir / optional homeserver. + // lib.optionalAttrs (config.hyperhive.matrixAccounts != { }) ( + let + accts = config.hyperhive.matrixAccounts; + names = builtins.attrNames accts; + # Primary: the explicit option, or the sole account's name when + # only one is declared. The assertions below guarantee this + # resolves to a real key. + primary = + if config.hyperhive.matrixPrimaryAccount != null then + config.hyperhive.matrixPrimaryAccount + else + builtins.head names; + # Primary first, then the remaining names (attrNames is sorted). + ordered = [ primary ] ++ builtins.filter (n: n != primary) names; + toEntry = + name: + let + a = accts.${name}; + in { - inherit (a) name; + inherit name; token_file = a.tokenFile; state_dir = a.sessionDir; } - // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; } - ) config.hyperhive.matrixAccounts - ); - }; + // lib.optionalAttrs (a.homeserver != null) { inherit (a) homeserver; }; + in + { + HIVE_MATRIX_ACCOUNTS = builtins.toJSON (map toEntry ordered); + } + ); serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/hive-matrix-daemon"; Restart = "on-failure";