@cotal-ai/connector-hermes 0.1.0 → 0.3.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.
package/bin/install.mjs CHANGED
@@ -2,44 +2,143 @@
2
2
  /**
3
3
  * `npx @cotal-ai/connector-hermes install`
4
4
  *
5
- * Installs the Cotal plugin into an existing Hermes so the gateway can join a Cotal mesh.
6
- * Copies this package's `plugin/cotal` (including the bundled `_sidecar/standalone.cjs`) into
7
- * `<HERMES_HOME>/plugins/cotal` and enables it.
5
+ * Guided install of the Cotal plugin into an existing Hermes so its gateway can join a Cotal mesh.
6
+ * Copies this package's `plugin/cotal` (incl. the bundled `_sidecar/standalone.cjs`) into
7
+ * `<HERMES_HOME>/plugins/cotal`, enables it, optionally writes your mesh config to
8
+ * `<HERMES_HOME>/.env`, and checks the mesh is reachable.
8
9
  *
9
- * House rule: no fallbacks. If Hermes isn't installed we fail loudly and write NOTHING — no
10
- * orphaned `~/.hermes/plugins/`. We resolve the Hermes home honestly (HERMES_HOME first), never
11
- * assuming a fixed path.
10
+ * Interactive when run in a TTY; non-interactive with `--yes` (or no TTY), taking config from
11
+ * `--link/--space/--name/--server` flags or the matching `COTAL_*` env vars.
12
+ *
13
+ * House rule: no fallbacks. If Hermes isn't installed we fail loudly and write NOTHING.
12
14
  */
13
15
  import { execFileSync } from "node:child_process";
14
- import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
16
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
17
+ import { createConnection } from "node:net";
15
18
  import { homedir } from "node:os";
16
19
  import { join, sep } from "node:path";
20
+ import { createInterface } from "node:readline/promises";
17
21
  import { fileURLToPath } from "node:url";
18
22
 
19
23
  const PKG_ROOT = fileURLToPath(new URL("..", import.meta.url));
20
24
  const PLUGIN_SRC = join(PKG_ROOT, "plugin", "cotal");
21
25
  const SIDECAR = join(PLUGIN_SRC, "_sidecar", "standalone.cjs");
22
26
 
23
- function die(msg) {
27
+ const DEFAULTS = { space: "demo", name: "my-hermes", server: "nats://127.0.0.1:4222" };
28
+
29
+ const die = (msg) => {
24
30
  process.stderr.write(`✗ ${msg}\n`);
25
31
  process.exit(1);
32
+ };
33
+ const info = (msg) => process.stdout.write(`${msg}\n`);
34
+
35
+ function usage() {
36
+ info("usage: npx @cotal-ai/connector-hermes install [--yes] [--link <url> | --space <s> --name <n> --server <url>]");
26
37
  }
27
- function info(msg) {
28
- process.stdout.write(`${msg}\n`);
38
+
39
+ // ---- arg parsing ------------------------------------------------------------
40
+ function parseFlags(argv) {
41
+ const f = {};
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a === "--yes" || a === "-y") f.yes = true;
45
+ else if (a.startsWith("--")) {
46
+ const [k, inline] = a.slice(2).split("=");
47
+ f[k] = inline ?? (argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true);
48
+ }
49
+ }
50
+ return f;
29
51
  }
30
52
 
31
- function usage() {
32
- info("usage: npx @cotal-ai/connector-hermes install");
33
- info(" Installs the Cotal plugin into your Hermes (HERMES_HOME or ~/.hermes).");
53
+ // ---- mesh config gathering --------------------------------------------------
54
+ async function promptConfig() {
55
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
56
+ try {
57
+ info("\nCotal mesh — paste a join link (cotal://token@host/space), or press Enter to set it manually:");
58
+ const link = (await rl.question(" link: ")).trim();
59
+ if (link) {
60
+ const name = (await rl.question(` name [${DEFAULTS.name}]: `)).trim() || DEFAULTS.name;
61
+ return { COTAL_LINK: link, COTAL_NAME: name };
62
+ }
63
+ const space = (await rl.question(` space [${DEFAULTS.space}]: `)).trim() || DEFAULTS.space;
64
+ const name = (await rl.question(` name [${DEFAULTS.name}]: `)).trim() || DEFAULTS.name;
65
+ const server = (await rl.question(` server [${DEFAULTS.server}]: `)).trim() || DEFAULTS.server;
66
+ return { COTAL_SPACE: space, COTAL_NAME: name, COTAL_SERVERS: server };
67
+ } finally {
68
+ rl.close();
69
+ }
70
+ }
71
+
72
+ function configFromFlags(flags) {
73
+ const link = flags.link || process.env.COTAL_LINK;
74
+ if (link) return { COTAL_LINK: link, COTAL_NAME: flags.name || process.env.COTAL_NAME || DEFAULTS.name };
75
+ const space = flags.space || process.env.COTAL_SPACE;
76
+ const server = flags.server || process.env.COTAL_SERVERS;
77
+ if (!space && !server) return {}; // nothing supplied → skip .env, print manual next steps
78
+ return {
79
+ COTAL_SPACE: space || DEFAULTS.space,
80
+ COTAL_NAME: flags.name || process.env.COTAL_NAME || DEFAULTS.name,
81
+ COTAL_SERVERS: server || DEFAULTS.server,
82
+ };
83
+ }
84
+
85
+ // ---- ~/.hermes/.env editing (idempotent) ------------------------------------
86
+ function writeEnv(file, set) {
87
+ // Link mode and manual mode are mutually exclusive; comment out the other mode's keys so a
88
+ // re-run never leaves a stale COTAL_SPACE overriding a new COTAL_LINK (individual vars win in
89
+ // configFromEnv).
90
+ const drop = set.COTAL_LINK ? ["COTAL_SPACE", "COTAL_SERVERS"] : ["COTAL_LINK"];
91
+ const lines = existsSync(file) ? readFileSync(file, "utf8").split("\n") : [];
92
+ const keyOf = (l) => l.replace(/^#\s*/, "").split("=")[0].trim();
93
+ for (const [k, v] of Object.entries(set)) {
94
+ const line = `${k}=${v}`;
95
+ const i = lines.findIndex((l) => keyOf(l) === k);
96
+ if (i >= 0) lines[i] = line;
97
+ else lines.push(line);
98
+ }
99
+ for (let i = 0; i < lines.length; i++) {
100
+ if (drop.includes(keyOf(lines[i])) && !lines[i].trimStart().startsWith("#")) lines[i] = `# ${lines[i]}`;
101
+ }
102
+ writeFileSync(file, lines.join("\n").replace(/\n*$/, "\n"));
103
+ }
104
+
105
+ // ---- mesh reachability ------------------------------------------------------
106
+ function hostPort(server) {
107
+ try {
108
+ const u = new URL(server.replace(/^(cotals?|nats):\/\//, "http://"));
109
+ return { host: u.hostname, port: Number(u.port || 4222) };
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ function reachable(server) {
115
+ const hp = hostPort(server);
116
+ if (!hp) return Promise.resolve(false);
117
+ return new Promise((res) => {
118
+ const s = createConnection({ host: hp.host, port: hp.port });
119
+ const done = (ok) => {
120
+ try {
121
+ s.destroy();
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ res(ok);
126
+ };
127
+ s.setTimeout(2000);
128
+ s.once("connect", () => done(true));
129
+ s.once("timeout", () => done(false));
130
+ s.once("error", () => done(false));
131
+ });
34
132
  }
35
133
 
36
- const cmd = process.argv[2];
37
- if (cmd !== "install") {
134
+ // ---- main -------------------------------------------------------------------
135
+ const args = process.argv.slice(2);
136
+ if (args[0] !== "install") {
38
137
  usage();
39
- process.exit(cmd ? 1 : 0);
138
+ process.exit(args[0] ? 1 : 0);
40
139
  }
140
+ const flags = parseFlags(args.slice(1));
41
141
 
42
- // 1. Require a working Hermes — refuse (writing nothing) if absent.
43
142
  function hermesVersion() {
44
143
  try {
45
144
  return execFileSync("hermes", ["--version"], { encoding: "utf8" }).trim();
@@ -47,6 +146,7 @@ function hermesVersion() {
47
146
  return undefined;
48
147
  }
49
148
  }
149
+
50
150
  const version = hermesVersion();
51
151
  if (!version) {
52
152
  die(
@@ -55,45 +155,56 @@ if (!version) {
55
155
  " then re-run: npx @cotal-ai/connector-hermes install",
56
156
  );
57
157
  }
58
-
59
- // 2. The bundled sidecar must be present (it ships in the npm package; a raw source checkout
60
- // needs `pnpm --filter @cotal-ai/connector-hermes build` first). No silent half-install.
61
158
  if (!existsSync(SIDECAR)) {
62
159
  die(
63
160
  `bundled sidecar missing at ${SIDECAR}\n` +
64
161
  " (running from source? build it first: pnpm --filter @cotal-ai/connector-hermes build)",
65
162
  );
66
163
  }
164
+ info(`✓ Hermes detected — ${version.split("·")[0].trim()}`);
67
165
 
68
- // 3. Resolve the Hermes home honestly: HERMES_HOME wins, else ~/.hermes.
69
166
  const hermesHome = process.env.HERMES_HOME?.trim() || join(homedir(), ".hermes");
167
+ const interactive = Boolean(process.stdin.isTTY) && !flags.yes;
168
+ const meshEnv = interactive ? await promptConfig() : configFromFlags(flags);
169
+
170
+ // Write mesh config to <home>/.env (if any was gathered).
171
+ if (Object.keys(meshEnv).length) {
172
+ const envFile = join(hermesHome, ".env");
173
+ mkdirSync(hermesHome, { recursive: true });
174
+ writeEnv(envFile, meshEnv);
175
+ info(`✓ Wrote ${Object.keys(meshEnv).join(", ")} to ${envFile}`);
176
+
177
+ // Best-effort reachability check.
178
+ const server = meshEnv.COTAL_SERVERS || meshEnv.COTAL_LINK;
179
+ if (server) {
180
+ const ok = await reachable(server);
181
+ info(ok ? `✓ Mesh reachable` : `✗ Mesh not reachable at ${hostPort(server)?.host}:${hostPort(server)?.port} — start it (e.g. \`nats-server -js\`) before \`hermes gateway run\``);
182
+ }
183
+ }
184
+
185
+ // Copy the plugin + enable it.
70
186
  const pluginsDir = join(hermesHome, "plugins");
71
187
  const dest = join(pluginsDir, "cotal");
72
-
73
- info(`Hermes: ${version}`);
74
- info(`Installing the Cotal plugin into ${dest} ...`);
75
188
  mkdirSync(pluginsDir, { recursive: true });
76
- rmSync(dest, { recursive: true, force: true }); // clean reinstall (overwrite stale code)
189
+ rmSync(dest, { recursive: true, force: true });
77
190
  cpSync(PLUGIN_SRC, dest, {
78
191
  recursive: true,
79
192
  filter: (src) => !src.includes(`${sep}__pycache__`) && !src.endsWith(".pyc"),
80
193
  });
81
-
82
- // 4. Enable it via Hermes' own command (it's discovered now that it's in plugins/).
83
194
  try {
84
195
  execFileSync("hermes", ["plugins", "enable", "cotal"], { stdio: "inherit" });
85
196
  } catch {
86
197
  die(
87
198
  "copied the plugin but `hermes plugins enable cotal` failed.\n" +
88
- " Enable it manually: `hermes plugins enable cotal` (or add `cotal` to plugins.enabled in config.yaml).",
199
+ " Enable it manually: `hermes plugins enable cotal`.",
89
200
  );
90
201
  }
91
202
 
92
203
  info("\n✓ Cotal plugin installed + enabled.\n");
93
- info("Next: point it at a mesh, then run the gateway:");
94
- info(" # add to ~/.hermes/.env (or the matching HERMES_HOME)");
95
- info(" COTAL_SPACE=demo");
96
- info(" COTAL_NAME=my-hermes");
97
- info(" COTAL_SERVERS=nats://127.0.0.1:4222");
98
- info("");
99
- info(" hermes gateway run");
204
+ if (Object.keys(meshEnv).length) {
205
+ info("Run it on the mesh:");
206
+ info(" hermes gateway run");
207
+ } else {
208
+ info("Next: set your mesh in ~/.hermes/.env (COTAL_LINK, or COTAL_SPACE/COTAL_NAME/COTAL_SERVERS),");
209
+ info(" then: hermes gateway run");
210
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cotal-ai/connector-hermes",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,14 +22,14 @@
22
22
  "dependencies": {
23
23
  "tsx": "^4.22.4",
24
24
  "zod": "^4.4.3",
25
- "@cotal-ai/connector-core": "0.2.0"
25
+ "@cotal-ai/connector-core": "0.3.0"
26
26
  },
27
27
  "peerDependencies": {
28
- "@cotal-ai/core": "0.1.3"
28
+ "@cotal-ai/core": ">=0.1.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "esbuild": "^0.28.0",
32
- "@cotal-ai/core": "0.1.3"
32
+ "@cotal-ai/core": "0.3.0"
33
33
  },
34
34
  "files": [
35
35
  "bin",
@@ -17919,6 +17919,15 @@ var MeshAgent = class extends import_node_events2.EventEmitter {
17919
17919
  args: { name, graceful: opts?.graceful ?? true }
17920
17920
  });
17921
17921
  }
17922
+ /** Ask the manager to purge the space's retained chat backlog (its `purge` op). Cleanup only —
17923
+ * it doesn't touch live agents or the anycast work queue. `includeDms` also clears DM history. */
17924
+ async purgeHistory(opts) {
17925
+ this.assertConnected();
17926
+ return this.ep.requestControl("manager", {
17927
+ op: "purge",
17928
+ args: { includeDms: opts?.includeDms ?? false }
17929
+ });
17930
+ }
17922
17931
  /** Define a persona and persist it as config (the manager's `definePersona` op writes
17923
17932
  * .cotal/agents/<name>.md). On success, announce it on the channel — the "send it out"
17924
17933
  * half — so peers see the new persona; `spawn(name)` then launches an agent wearing it. */
@@ -32870,6 +32879,27 @@ ${info}${caught}`);
32870
32879
  }
32871
32880
  }
32872
32881
  },
32882
+ {
32883
+ name: "cotal_purge",
32884
+ title: "Cotal: clear chat history",
32885
+ description: "Ask the manager to purge this space's retained chat backlog (channel history). Set includeDms to also clear direct-message history. Cleanup only \u2014 it does not affect live agents or the anycast work queue. Irreversible.",
32886
+ schema: {
32887
+ includeDms: external_exports.boolean().optional().describe("Default false: channel history only. true = also purge DM history.")
32888
+ },
32889
+ async run(agent, _config, { includeDms }) {
32890
+ try {
32891
+ const reply = await agent.purgeHistory({ includeDms });
32892
+ if (!reply.ok)
32893
+ return err(`Couldn't purge history: ${reply.error ?? "manager refused"}`);
32894
+ const d = reply.data;
32895
+ const chat = d?.chat ?? 0;
32896
+ const dm = d?.dm;
32897
+ return ok(`Cleared ${chat} channel message${chat === 1 ? "" : "s"}${dm === void 0 ? "" : ` and ${dm} DM${dm === 1 ? "" : "s"}`} from "${_config.space}".`);
32898
+ } catch (e) {
32899
+ return err(`Couldn't purge history: no manager reachable (${e.message}). Is the manager running?`);
32900
+ }
32901
+ }
32902
+ },
32873
32903
  {
32874
32904
  name: "cotal_persona",
32875
32905
  title: "Cotal: define a persona",