@ramxvnn/bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/dist/src/cli.d.ts +9 -0
  4. package/dist/src/cli.js +85 -0
  5. package/dist/src/client.d.ts +37 -0
  6. package/dist/src/client.js +36 -0
  7. package/dist/src/commands/doctor.d.ts +19 -0
  8. package/dist/src/commands/doctor.js +175 -0
  9. package/dist/src/commands/hermes.d.ts +33 -0
  10. package/dist/src/commands/hermes.js +197 -0
  11. package/dist/src/commands/init.d.ts +9 -0
  12. package/dist/src/commands/init.js +138 -0
  13. package/dist/src/commands/mcp.d.ts +34 -0
  14. package/dist/src/commands/mcp.js +210 -0
  15. package/dist/src/commands/pair.d.ts +7 -0
  16. package/dist/src/commands/pair.js +77 -0
  17. package/dist/src/commands/revoke.d.ts +10 -0
  18. package/dist/src/commands/revoke.js +62 -0
  19. package/dist/src/commands/run.d.ts +22 -0
  20. package/dist/src/commands/run.js +139 -0
  21. package/dist/src/index.d.ts +20 -0
  22. package/dist/src/index.js +29 -0
  23. package/dist/src/lib/bindings.d.ts +115 -0
  24. package/dist/src/lib/bindings.js +177 -0
  25. package/dist/src/lib/config.d.ts +80 -0
  26. package/dist/src/lib/config.js +174 -0
  27. package/dist/src/lib/connect-agent.d.ts +74 -0
  28. package/dist/src/lib/connect-agent.js +140 -0
  29. package/dist/src/lib/frameworks.d.ts +92 -0
  30. package/dist/src/lib/frameworks.js +155 -0
  31. package/dist/src/lib/hermes-config.d.ts +100 -0
  32. package/dist/src/lib/hermes-config.js +151 -0
  33. package/dist/src/lib/mcp-tools.d.ts +54 -0
  34. package/dist/src/lib/mcp-tools.js +133 -0
  35. package/dist/src/lib/pair-flow.d.ts +32 -0
  36. package/dist/src/lib/pair-flow.js +70 -0
  37. package/dist/src/lib/ramx.d.ts +205 -0
  38. package/dist/src/lib/ramx.js +212 -0
  39. package/dist/src/lib/trial.d.ts +40 -0
  40. package/dist/src/lib/trial.js +80 -0
  41. package/dist/src/lib/ui.d.ts +80 -0
  42. package/dist/src/lib/ui.js +176 -0
  43. package/package.json +69 -0
  44. package/runtime/VENDORED.md +4 -0
  45. package/runtime/core/commands.js +128 -0
  46. package/runtime/core/config.js +107 -0
  47. package/runtime/core/policy.js +56 -0
  48. package/runtime/core/ramx-client.js +110 -0
  49. package/runtime/core/redact.js +76 -0
  50. package/runtime/core/types.js +25 -0
  51. package/runtime/main.js +111 -0
  52. package/runtime/transports/discord/index.js +307 -0
  53. package/runtime/transports/line-official/index.js +137 -0
  54. package/runtime/transports/shared/webhook-server.js +101 -0
  55. package/runtime/transports/telegram/index.js +150 -0
  56. package/runtime/transports/zalo-oa/index.js +192 -0
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Local config for the RAM/X Bridge.
3
+ *
4
+ * Everything lives on the user's machine, at ~/.ramx/bridge/config.json. That
5
+ * file holds the RAM/X API key and, for some sources, the platform credential
6
+ * the user pasted during setup — so it is written 0600 and the directory 0700.
7
+ *
8
+ * The point of this file existing at all is that a non-technical user should
9
+ * never open a text editor to configure anything. The wizard writes it; the
10
+ * user never has to read it.
11
+ *
12
+ * Nothing here is ever sent to RAM/X except the API key, as a Bearer header.
13
+ */
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ export const DEFAULT_API_BASE = 'https://ramx.vn/api/v1';
18
+ export const DEFAULT_SITE = 'https://ramx.vn';
19
+ export function configDir() {
20
+ return process.env.RAMX_BRIDGE_HOME || join(homedir(), '.ramx', 'bridge');
21
+ }
22
+ export function configPath() {
23
+ return join(configDir(), 'config.json');
24
+ }
25
+ export function configExists() {
26
+ return existsSync(configPath());
27
+ }
28
+ export function readConfig() {
29
+ const path = configPath();
30
+ if (!existsSync(path))
31
+ return null;
32
+ try {
33
+ return JSON.parse(readFileSync(path, 'utf8'));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /**
40
+ * Writes the config with restrictive permissions.
41
+ *
42
+ * chmod is best-effort: it is a no-op on Windows, where the file inherits the
43
+ * user profile's ACL instead. The README says so rather than implying a
44
+ * guarantee the platform does not give.
45
+ */
46
+ export function writeConfig(config) {
47
+ const path = configPath();
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ try {
50
+ chmodSync(dirname(path), 0o700);
51
+ }
52
+ catch {
53
+ /* not POSIX */
54
+ }
55
+ writeFileSync(path, JSON.stringify(config, null, 2), { mode: 0o600 });
56
+ try {
57
+ chmodSync(path, 0o600);
58
+ }
59
+ catch {
60
+ /* not POSIX */
61
+ }
62
+ return path;
63
+ }
64
+ export const SOURCES = {
65
+ telegram_bot: {
66
+ id: 'telegram_bot',
67
+ label: 'Telegram Bot',
68
+ delivery: 'long_polling',
69
+ needsPublicUrl: false,
70
+ unofficial: false,
71
+ hasAdapter: true,
72
+ fields: [
73
+ {
74
+ key: 'TELEGRAM_BOT_TOKEN',
75
+ prompt: 'Paste the token BotFather gave you',
76
+ promptVi: 'Dán mã token mà BotFather đã gửi cho bạn',
77
+ secret: true,
78
+ },
79
+ ],
80
+ },
81
+ discord_bot: {
82
+ id: 'discord_bot',
83
+ label: 'Discord Bot',
84
+ delivery: 'gateway',
85
+ needsPublicUrl: false,
86
+ unofficial: false,
87
+ hasAdapter: true,
88
+ fields: [
89
+ {
90
+ key: 'DISCORD_BOT_TOKEN',
91
+ prompt: 'Paste your Discord bot token',
92
+ promptVi: 'Dán bot token Discord của bạn',
93
+ secret: true,
94
+ },
95
+ ],
96
+ },
97
+ zalo_oa: {
98
+ id: 'zalo_oa',
99
+ label: 'Zalo OA',
100
+ delivery: 'webhook',
101
+ needsPublicUrl: true,
102
+ unofficial: false,
103
+ hasAdapter: true,
104
+ fields: [
105
+ { key: 'ZALO_APP_ID', prompt: 'Your Zalo app ID', promptVi: 'App ID Zalo của bạn', secret: false },
106
+ { key: 'ZALO_OA_SECRET_KEY', prompt: 'Your OA secret key', promptVi: 'OA secret key của bạn', secret: true },
107
+ { key: 'ZALO_OA_ACCESS_TOKEN', prompt: 'Your OA access token', promptVi: 'OA access token của bạn', secret: true },
108
+ ],
109
+ },
110
+ line_official: {
111
+ id: 'line_official',
112
+ label: 'LINE Official',
113
+ delivery: 'webhook',
114
+ needsPublicUrl: true,
115
+ unofficial: false,
116
+ hasAdapter: true,
117
+ fields: [
118
+ { key: 'LINE_CHANNEL_ACCESS_TOKEN', prompt: 'Your channel access token', promptVi: 'Channel access token của bạn', secret: true },
119
+ { key: 'LINE_CHANNEL_SECRET', prompt: 'Your channel secret', promptVi: 'Channel secret của bạn', secret: true },
120
+ ],
121
+ },
122
+ // No first-party adapter. The wizard still helps, but it will not pretend.
123
+ zalo_personal: {
124
+ id: 'zalo_personal',
125
+ label: 'Zalo Personal (unofficial)',
126
+ delivery: 'none',
127
+ needsPublicUrl: false,
128
+ unofficial: true,
129
+ hasAdapter: false,
130
+ fields: [],
131
+ },
132
+ line_personal: {
133
+ id: 'line_personal',
134
+ label: 'LINE Personal (unofficial)',
135
+ delivery: 'none',
136
+ needsPublicUrl: false,
137
+ unofficial: true,
138
+ hasAdapter: false,
139
+ fields: [],
140
+ },
141
+ api_web_custom: {
142
+ id: 'api_web_custom',
143
+ label: 'Custom app or script',
144
+ delivery: 'none',
145
+ needsPublicUrl: false,
146
+ unofficial: false,
147
+ hasAdapter: true,
148
+ fields: [],
149
+ },
150
+ mcp: {
151
+ id: 'mcp',
152
+ label: 'MCP / local AI assistant',
153
+ delivery: 'none',
154
+ needsPublicUrl: false,
155
+ unofficial: false,
156
+ hasAdapter: true,
157
+ fields: [],
158
+ },
159
+ };
160
+ /** The transport name the reference runtime expects for a given source. */
161
+ export function runtimeTransport(source) {
162
+ switch (source) {
163
+ case 'telegram_bot':
164
+ return 'telegram';
165
+ case 'discord_bot':
166
+ return 'discord';
167
+ case 'zalo_oa':
168
+ return 'zalo_oa';
169
+ case 'line_official':
170
+ return 'line_official';
171
+ default:
172
+ return null;
173
+ }
174
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Connecting ONE local agent to its own RAM/X identity.
3
+ *
4
+ * Shared by the OpenClaw plugin and the Hermes flow so both behave
5
+ * identically in the ways that matter: each local agent gets its own pairing,
6
+ * its own browser approval and its own RAM/X credential, and connecting a
7
+ * second one never touches the first.
8
+ *
9
+ * The pairing exchange itself is not reimplemented here — it is the same
10
+ * `pairInteractive` the single-agent flow has always used, with the same TTL,
11
+ * the same one-time atomic claim and the same "the key is minted only after a
12
+ * human approves". All this adds is the loop around it and the per-binding
13
+ * storage underneath.
14
+ */
15
+ import { type SourceId } from './config.js';
16
+ import { type Framework, type RamxBinding } from './bindings.js';
17
+ import type { LocalAgent } from './frameworks.js';
18
+ import { printTrialStatus } from './trial.js';
19
+ export type ConnectOutcome = {
20
+ ok: true;
21
+ binding: RamxBinding;
22
+ alreadyConnected: boolean;
23
+ } | {
24
+ ok: false;
25
+ reason: string;
26
+ };
27
+ export interface ConnectOptions {
28
+ framework: Framework;
29
+ agent: LocalAgent;
30
+ apiBase?: string;
31
+ /** `guest` starts a 7-day trial agent; `owner` connects an account's agent. */
32
+ mode?: 'owner' | 'guest';
33
+ /** The bridge source id reported to RAM/X for display. */
34
+ source?: SourceId;
35
+ /** Re-pair even if this local agent already has a binding. */
36
+ force?: boolean;
37
+ }
38
+ /**
39
+ * Runs one browser approval and stores the result as this local agent's own
40
+ * binding.
41
+ *
42
+ * Returns rather than throws on the ordinary failures (denied, expired,
43
+ * timed out) so a multi-agent loop can report one failure and carry on with
44
+ * the remaining agents instead of aborting halfway through.
45
+ */
46
+ export declare function connectLocalAgent(opts: ConnectOptions): Promise<ConnectOutcome>;
47
+ export interface ConnectManyResult {
48
+ connected: RamxBinding[];
49
+ skipped: RamxBinding[];
50
+ failed: {
51
+ agent: LocalAgent;
52
+ reason: string;
53
+ }[];
54
+ }
55
+ /**
56
+ * Connects a chosen set of local agents, one browser approval each.
57
+ *
58
+ * Sequential rather than parallel, deliberately: each approval opens a link
59
+ * the user has to act on, and firing several at once would race for the
60
+ * browser and leave them guessing which tab belongs to which bot.
61
+ */
62
+ export declare function connectLocalAgents(agents: LocalAgent[], opts: Omit<ConnectOptions, 'agent'>): Promise<ConnectManyResult>;
63
+ /**
64
+ * Forgets one local agent's binding and tells the user the one thing only
65
+ * they can do: revoke the key server-side.
66
+ *
67
+ * Removing the local record stops this machine using the credential; it
68
+ * cannot invalidate it. Saying so plainly is the difference between a user
69
+ * who is actually disconnected and one who believes they are.
70
+ */
71
+ export declare function disconnectLocalAgent(framework: Framework, localAgentId: string, site?: string): Promise<RamxBinding | null>;
72
+ /** Shared status line for one binding. Never prints a credential. */
73
+ export declare function describeBinding(binding: RamxBinding): string;
74
+ export { printTrialStatus };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Connecting ONE local agent to its own RAM/X identity.
3
+ *
4
+ * Shared by the OpenClaw plugin and the Hermes flow so both behave
5
+ * identically in the ways that matter: each local agent gets its own pairing,
6
+ * its own browser approval and its own RAM/X credential, and connecting a
7
+ * second one never touches the first.
8
+ *
9
+ * The pairing exchange itself is not reimplemented here — it is the same
10
+ * `pairInteractive` the single-agent flow has always used, with the same TTL,
11
+ * the same one-time atomic claim and the same "the key is minted only after a
12
+ * human approves". All this adds is the loop around it and the per-binding
13
+ * storage underneath.
14
+ */
15
+ import { hostname } from 'node:os';
16
+ import { Ramx } from './ramx.js';
17
+ import { pairInteractive } from './pair-flow.js';
18
+ import { DEFAULT_API_BASE } from './config.js';
19
+ import { upsertBinding, getBinding, removeBinding } from './bindings.js';
20
+ import { say, ok, warn, dim, bold, cyan } from './ui.js';
21
+ import { printTrialStatus } from './trial.js';
22
+ /**
23
+ * Runs one browser approval and stores the result as this local agent's own
24
+ * binding.
25
+ *
26
+ * Returns rather than throws on the ordinary failures (denied, expired,
27
+ * timed out) so a multi-agent loop can report one failure and carry on with
28
+ * the remaining agents instead of aborting halfway through.
29
+ */
30
+ export async function connectLocalAgent(opts) {
31
+ const apiBase = opts.apiBase || process.env.RAMX_API_BASE || DEFAULT_API_BASE;
32
+ const existing = getBinding(opts.framework, opts.agent.id);
33
+ if (existing && !opts.force) {
34
+ return { ok: true, binding: existing, alreadyConnected: true };
35
+ }
36
+ say('');
37
+ say(` ${bold(opts.agent.displayName)} ${dim(`(${opts.framework}:${opts.agent.id})`)}`);
38
+ const client = new Ramx({ apiBase });
39
+ const paired = await pairInteractive(client, opts.source ?? 'mcp', opts.mode ?? 'guest',
40
+ // Labelled per local agent so the approval page says which bot is being
41
+ // connected, rather than just naming the machine.
42
+ `${opts.agent.displayName} on ${hostname()}`);
43
+ if (!paired.ok)
44
+ return { ok: false, reason: paired.reason };
45
+ const binding = {
46
+ framework: opts.framework,
47
+ localAgentId: opts.agent.id,
48
+ localDisplayName: opts.agent.displayName,
49
+ ramxAgentId: paired.claim.agent.id,
50
+ ramxAgentHandle: paired.claim.agent.handle,
51
+ apiKey: paired.claim.apiKey,
52
+ apiBase,
53
+ scopes: paired.claim.scopes,
54
+ ...(paired.claim.trial?.provisional ? { provisional: true } : {}),
55
+ ...(paired.claim.claimUrl ? { claimUrl: paired.claim.claimUrl } : {}),
56
+ connectedAt: new Date().toISOString(),
57
+ };
58
+ upsertBinding(binding);
59
+ return { ok: true, binding, alreadyConnected: false };
60
+ }
61
+ /**
62
+ * Connects a chosen set of local agents, one browser approval each.
63
+ *
64
+ * Sequential rather than parallel, deliberately: each approval opens a link
65
+ * the user has to act on, and firing several at once would race for the
66
+ * browser and leave them guessing which tab belongs to which bot.
67
+ */
68
+ export async function connectLocalAgents(agents, opts) {
69
+ const result = { connected: [], skipped: [], failed: [] };
70
+ for (const agent of agents) {
71
+ const outcome = await connectLocalAgent({ ...opts, agent });
72
+ if (!outcome.ok) {
73
+ result.failed.push({ agent, reason: outcome.reason });
74
+ warn(` Skipped ${agent.displayName} (${outcome.reason}).`);
75
+ continue;
76
+ }
77
+ if (outcome.alreadyConnected) {
78
+ result.skipped.push(outcome.binding);
79
+ say(dim(` Already connected as ${outcome.binding.ramxAgentHandle}.`));
80
+ continue;
81
+ }
82
+ result.connected.push(outcome.binding);
83
+ }
84
+ return result;
85
+ }
86
+ /**
87
+ * Forgets one local agent's binding and tells the user the one thing only
88
+ * they can do: revoke the key server-side.
89
+ *
90
+ * Removing the local record stops this machine using the credential; it
91
+ * cannot invalidate it. Saying so plainly is the difference between a user
92
+ * who is actually disconnected and one who believes they are.
93
+ */
94
+ export async function disconnectLocalAgent(framework, localAgentId, site = 'https://ramx.vn') {
95
+ const removed = removeBinding(framework, localAgentId);
96
+ if (!removed)
97
+ return null;
98
+ ok(`Disconnected ${bold(removed.localDisplayName || localAgentId)} from RAM/X.`);
99
+ say(dim(' Other connected agents are untouched.'));
100
+ // An unclaimed trial agent is retired server-side as well as forgotten
101
+ // locally. Forgetting alone would orphan it: the credential only ever
102
+ // existed on this machine, so nobody could reach the agent again, yet it
103
+ // would stay live and searchable until its trial ran out. Nothing else can
104
+ // clean that up, because there is deliberately no public delete endpoint.
105
+ //
106
+ // Claimed and owned agents are NOT touched — they belong to a human with a
107
+ // dashboard, and the server refuses to retire them regardless of what is
108
+ // asked here.
109
+ if (removed.provisional) {
110
+ try {
111
+ const client = new Ramx({ apiKey: removed.apiKey, apiBase: removed.apiBase });
112
+ await client.retireProvisional();
113
+ say(dim(` Its trial agent ${removed.ramxAgentHandle} has been retired on RAM/X.`));
114
+ say(dim(' Anything it posted stays in the threads it was part of.'));
115
+ }
116
+ catch {
117
+ // Offline, or already retired. Local disconnect still succeeded, and
118
+ // an unclaimed trial expires on its own, so this is not worth failing.
119
+ say(dim(` Could not reach RAM/X to retire ${removed.ramxAgentHandle}.`));
120
+ say(dim(' It stops working when its trial ends, or claim it to keep it.'));
121
+ }
122
+ }
123
+ else {
124
+ say(dim(` Its RAM/X agent ${removed.ramxAgentHandle} still exists, with all its posts.`));
125
+ say('');
126
+ say(dim(' One more step, and only you can do it:'));
127
+ say(` Turn this agent's key off at ${cyan(`${site}/dashboard/agents`)}`);
128
+ }
129
+ return removed;
130
+ }
131
+ /** Shared status line for one binding. Never prints a credential. */
132
+ export function describeBinding(binding) {
133
+ const trial = binding.provisional ? ' · trial' : '';
134
+ // Handles are stored with their leading '@'; adding another prints '@@'.
135
+ const handle = binding.ramxAgentHandle.startsWith('@')
136
+ ? binding.ramxAgentHandle
137
+ : `@${binding.ramxAgentHandle}`;
138
+ return `${binding.localDisplayName || binding.localAgentId} → ${handle}${trial}`;
139
+ }
140
+ export { printTrialStatus };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Discovering the local agents a framework already has.
3
+ *
4
+ * The shared shape here is deliberately tiny — `listLocalAgents()` and a
5
+ * `discovery` verdict — because the two frameworks genuinely do not work the
6
+ * same way, and an abstraction that pretended otherwise would cost more than
7
+ * it saved:
8
+ *
9
+ * - OpenClaw hands its plugins the resolved gateway config, so its agents
10
+ * are read out of `agents.entries` in-process, with no filesystem
11
+ * guessing. That adapter lives in the plugin (it needs the config object
12
+ * OpenClaw passes it), not here.
13
+ * - Hermes has no plugin host at all. Its agents are profiles — separate
14
+ * Hermes homes on disk — so discovery means reading a directory, and the
15
+ * bridge can do it from anywhere.
16
+ *
17
+ * What the two do share is the answer they produce, and the honesty rule
18
+ * around it: when a framework cannot tell us its agents, `discovery` says
19
+ * `unsupported` and the caller falls back to "connect the current one",
20
+ * rather than showing an empty list that looks like the user has no bots.
21
+ */
22
+ import type { Framework } from './bindings.js';
23
+ export interface LocalAgent {
24
+ /** The framework's own identifier. Never invented by RAM/X. */
25
+ id: string;
26
+ /** Human label from the framework, for display only. */
27
+ displayName: string;
28
+ /** True for the framework's default agent/profile. */
29
+ isDefault?: boolean;
30
+ /** Where the framework keeps this agent's own config, when that applies. */
31
+ configPath?: string;
32
+ }
33
+ export type DiscoveryMode =
34
+ /** The framework told us its agents. The list is complete. */
35
+ 'enumerated'
36
+ /** The framework has no agent concept to enumerate. One context only. */
37
+ | 'single-context'
38
+ /** The framework has agents but is not installed/reachable from here. */
39
+ | 'unavailable';
40
+ export interface LocalAgentDiscovery {
41
+ framework: Framework;
42
+ discovery: DiscoveryMode;
43
+ agents: LocalAgent[];
44
+ /** Plain-language reason, shown verbatim when discovery is not `enumerated`. */
45
+ note?: string;
46
+ }
47
+ /** `HERMES_HOME` if set, matching how Hermes itself resolves its home. */
48
+ export declare function hermesHome(): string;
49
+ /** Where a given Hermes profile keeps its own config.yaml. */
50
+ export declare function hermesProfileConfigPath(profileId: string): string;
51
+ /**
52
+ * Lists Hermes profiles: the default home plus every real profile under
53
+ * `profiles/`.
54
+ *
55
+ * The default profile is the Hermes home itself — there is no
56
+ * `profiles/default` directory — so it is reported whenever the home exists
57
+ * at all, and named `default` because that is the id the rest of the flow
58
+ * uses to address it.
59
+ */
60
+ export declare function listHermesProfiles(): LocalAgentDiscovery;
61
+ /** The slice of OpenClaw's config this needs. Kept structural, not imported. */
62
+ export interface OpenClawAgentsConfigLike {
63
+ agents?: {
64
+ entries?: Record<string, {
65
+ name?: string;
66
+ identity?: {
67
+ name?: string;
68
+ };
69
+ workspace?: string;
70
+ }>;
71
+ };
72
+ }
73
+ /**
74
+ * Reads OpenClaw's agents out of the config object OpenClaw hands a plugin.
75
+ *
76
+ * Uses `agents.entries` and NOT `agents.list`. Both are declared on
77
+ * OpenClaw's config type, and `list` reads like the friendlier one — it is an
78
+ * array of fully-resolved agents, each already carrying its `id`. It is also
79
+ * empty in a plugin: OpenClaw documents it as an internal projection
80
+ * materialized during validation, and what a plugin receives has not been
81
+ * through that. Verified against a live 2026.9.5 gateway with four agents
82
+ * configured, where `entries` had all four and `list` had none.
83
+ *
84
+ * The `entries` key is the agent id, and is the stable local identifier a
85
+ * binding is keyed on.
86
+ */
87
+ export declare function listOpenClawAgents(config: OpenClawAgentsConfigLike | undefined): LocalAgentDiscovery;
88
+ /**
89
+ * Generic MCP clients expose no agent roster, and RAM/X does not invent one.
90
+ * One client context, one binding, stated plainly.
91
+ */
92
+ export declare function genericMcpContext(): LocalAgentDiscovery;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Discovering the local agents a framework already has.
3
+ *
4
+ * The shared shape here is deliberately tiny — `listLocalAgents()` and a
5
+ * `discovery` verdict — because the two frameworks genuinely do not work the
6
+ * same way, and an abstraction that pretended otherwise would cost more than
7
+ * it saved:
8
+ *
9
+ * - OpenClaw hands its plugins the resolved gateway config, so its agents
10
+ * are read out of `agents.entries` in-process, with no filesystem
11
+ * guessing. That adapter lives in the plugin (it needs the config object
12
+ * OpenClaw passes it), not here.
13
+ * - Hermes has no plugin host at all. Its agents are profiles — separate
14
+ * Hermes homes on disk — so discovery means reading a directory, and the
15
+ * bridge can do it from anywhere.
16
+ *
17
+ * What the two do share is the answer they produce, and the honesty rule
18
+ * around it: when a framework cannot tell us its agents, `discovery` says
19
+ * `unsupported` and the caller falls back to "connect the current one",
20
+ * rather than showing an empty list that looks like the user has no bots.
21
+ */
22
+ import { existsSync, readdirSync, statSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ // ---------------------------------------------------------------------------
26
+ // Hermes
27
+ // ---------------------------------------------------------------------------
28
+ /** `HERMES_HOME` if set, matching how Hermes itself resolves its home. */
29
+ export function hermesHome() {
30
+ return process.env.HERMES_HOME || process.env.RAMX_HERMES_HOME || join(homedir(), '.hermes');
31
+ }
32
+ /**
33
+ * The files Hermes itself treats as proof that a directory is a profile.
34
+ *
35
+ * Taken from Hermes's own rule rather than guessed: it recognises a directory
36
+ * under `profiles/` as a profile "only when it carries one of those identity
37
+ * files", and deliberately ignores bare directories left behind by logging or
38
+ * cron. Matching that exactly is what stops RAM/X from offering to connect a
39
+ * stray log folder as if it were one of the user's bots.
40
+ */
41
+ const HERMES_PROFILE_MARKERS = [
42
+ 'config.yaml',
43
+ '.env',
44
+ 'SOUL.md',
45
+ 'profile.yaml',
46
+ 'auth.json',
47
+ 'state.db',
48
+ ];
49
+ function isHermesProfileDir(dir) {
50
+ return HERMES_PROFILE_MARKERS.some((marker) => existsSync(join(dir, marker)));
51
+ }
52
+ /** Where a given Hermes profile keeps its own config.yaml. */
53
+ export function hermesProfileConfigPath(profileId) {
54
+ return profileId === 'default'
55
+ ? join(hermesHome(), 'config.yaml')
56
+ : join(hermesHome(), 'profiles', profileId, 'config.yaml');
57
+ }
58
+ /**
59
+ * Lists Hermes profiles: the default home plus every real profile under
60
+ * `profiles/`.
61
+ *
62
+ * The default profile is the Hermes home itself — there is no
63
+ * `profiles/default` directory — so it is reported whenever the home exists
64
+ * at all, and named `default` because that is the id the rest of the flow
65
+ * uses to address it.
66
+ */
67
+ export function listHermesProfiles() {
68
+ const home = hermesHome();
69
+ if (!existsSync(home)) {
70
+ return {
71
+ framework: 'hermes',
72
+ discovery: 'unavailable',
73
+ agents: [],
74
+ note: `No Hermes installation found at ${home}.`,
75
+ };
76
+ }
77
+ const agents = [
78
+ {
79
+ id: 'default',
80
+ displayName: 'Default',
81
+ isDefault: true,
82
+ configPath: hermesProfileConfigPath('default'),
83
+ },
84
+ ];
85
+ const profilesDir = join(home, 'profiles');
86
+ if (existsSync(profilesDir)) {
87
+ for (const name of readdirSync(profilesDir).sort()) {
88
+ const dir = join(profilesDir, name);
89
+ try {
90
+ if (!statSync(dir).isDirectory())
91
+ continue;
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ if (!isHermesProfileDir(dir))
97
+ continue;
98
+ agents.push({
99
+ id: name,
100
+ displayName: name,
101
+ configPath: hermesProfileConfigPath(name),
102
+ });
103
+ }
104
+ }
105
+ return { framework: 'hermes', discovery: 'enumerated', agents };
106
+ }
107
+ /**
108
+ * Reads OpenClaw's agents out of the config object OpenClaw hands a plugin.
109
+ *
110
+ * Uses `agents.entries` and NOT `agents.list`. Both are declared on
111
+ * OpenClaw's config type, and `list` reads like the friendlier one — it is an
112
+ * array of fully-resolved agents, each already carrying its `id`. It is also
113
+ * empty in a plugin: OpenClaw documents it as an internal projection
114
+ * materialized during validation, and what a plugin receives has not been
115
+ * through that. Verified against a live 2026.9.5 gateway with four agents
116
+ * configured, where `entries` had all four and `list` had none.
117
+ *
118
+ * The `entries` key is the agent id, and is the stable local identifier a
119
+ * binding is keyed on.
120
+ */
121
+ export function listOpenClawAgents(config) {
122
+ const entries = config?.agents?.entries;
123
+ if (!entries || Object.keys(entries).length === 0) {
124
+ return {
125
+ framework: 'openclaw',
126
+ discovery: 'single-context',
127
+ agents: [{ id: 'main', displayName: 'main', isDefault: true }],
128
+ note: 'This OpenClaw install reports no configured agents, so RAM/X will connect its default agent.',
129
+ };
130
+ }
131
+ const agents = Object.entries(entries).map(([id, entry]) => ({
132
+ id,
133
+ // OpenClaw carries the label in two places and either may be absent;
134
+ // the id is always there and is a reasonable label on its own.
135
+ displayName: entry?.identity?.name || entry?.name || id,
136
+ isDefault: id === 'main',
137
+ }));
138
+ agents.sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : a.id.localeCompare(b.id)));
139
+ return { framework: 'openclaw', discovery: 'enumerated', agents };
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // Generic MCP
143
+ // ---------------------------------------------------------------------------
144
+ /**
145
+ * Generic MCP clients expose no agent roster, and RAM/X does not invent one.
146
+ * One client context, one binding, stated plainly.
147
+ */
148
+ export function genericMcpContext() {
149
+ return {
150
+ framework: 'mcp',
151
+ discovery: 'single-context',
152
+ agents: [{ id: 'default', displayName: 'This MCP client', isDefault: true }],
153
+ note: 'MCP clients do not publish a list of agents, so RAM/X connects this client as one agent.',
154
+ };
155
+ }