gosalyn: row 221 (C2-20, #1722) Bean and Chet persona import on 213 r4 2b3e67e36: stage and install script, personaTextFault exported from bundle.mjs, yaml 2.9.0 exact, fixture Hermes home and read trace, 18 singles 417/0 (sage-1836, sage-1840, sage-1851, sage-1863)
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
# Row 221 build notes, C2-20 Bean and Chet persona import (#1722, Vikunja #416)
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md "C2-20", blob 16847e94ae17 (sage-1834). Claimed rev 2736.
|
||||
Rulings: sage-1836 (fence, pairing) and sage-1840 (YAML parser, handoff).
|
||||
|
||||
## Base
|
||||
|
||||
Built on Morgana's row 213 r4 tip 2b3e67e36 (origin/morgana/row213-r4), since the brief says "After:
|
||||
C2-08" and 221 exports 213's fence check. Before review it goes onto 213's landing under a new branch
|
||||
name, and its tests run again there.
|
||||
|
||||
## What changed
|
||||
|
||||
- `scripts/case2-persona-import.mjs`, new. Verbs `stage` and `install`, usage in its header.
|
||||
- `packages/cli/tests/case2-persona-import.test.mjs`, new, and `packages/cli/tests/fixtures/case2-persona-import/`,
|
||||
a fixture Hermes home plus `trace.mjs`, the read trace.
|
||||
- `packages/harness/src/bundle.mjs`: the three text rules in `readPersona` move into an exported
|
||||
`personaTextFault(text)`, which returns null or the reason, and `readPersona` calls it. Nothing it
|
||||
checks changes and the messages are byte for byte the same, so 213's creative tests hold. There is
|
||||
one copy of the rules and the import calls it (sage-1836).
|
||||
- `package.json` and `package-lock.json`: `yaml` 2.9.0 becomes an exact direct dependency (sage-1840 Q3).
|
||||
|
||||
## Rulings recorded
|
||||
|
||||
Profile to name (sage-1836, answers DESIGN.md:1322 as an implementation ruling, not a PRD change).
|
||||
Hermes at df5700ebe has no field holding a profile's own name. `profile.yaml` has only `description`
|
||||
and `description_auto` (hermes_cli/profiles.py:798-850), and `agent_name` at cli.py:3494 is skin
|
||||
branding with the default "Hermes Agent". So the human passes the pairing as `--pair default=Bean
|
||||
--pair chet=Chet`. The script checks both profile directories exist, each name is exactly Bean or Chet
|
||||
and the two differ, then prints the pairing with each SOUL.md's byte count and sha256 before it writes.
|
||||
The brief's "confirm profile to name" is met by the human supplying and confirming the pairing.
|
||||
|
||||
The persona is `<profile dir>/SOUL.md`, where Hermes loads it (agent/prompt_builder.py:1829-1855).
|
||||
The default profile's directory is the Hermes root, and others are `profiles/<name>`, with names
|
||||
checked against hermes_cli/profiles.py:37. A missing or blank SOUL.md is "no persona", as in Hermes.
|
||||
The file is staged byte for byte. Hermes strips it and runs its own scan before use, but this import
|
||||
copies her text unchanged.
|
||||
|
||||
Handoff between the steps (sage-1840 Q4). `stage` writes 0600 files owned by whoever ran it. `install`
|
||||
runs as `<user>` and refuses a staging file that isn't its own or has a group or other bit. The script
|
||||
changes no owner. It prints the root step, `chown -R <user>: <staging>`, as an instruction. **Item for
|
||||
Jason's go:** who runs stage on `<host>`, and the root chown between the two steps.
|
||||
|
||||
## Word list
|
||||
|
||||
69 tool names: every literal `name="..."` in a `registry.register(` call under hermes-agent's
|
||||
`tools/*.py` at df5700ebe, plus Hermes and Discord. Two more registrations, in tools/mcp_tool.py:4997
|
||||
and :5034, and plugin tools (hermes_cli/plugins.py:430) get their names at run time, so a fixed list
|
||||
can't hold them. Matching is whole word, any case. `memory`, `terminal`, `todo`, `process`, `patch` and
|
||||
`clarify` are ordinary words, so a persona line using them in prose counts, and the count leans high.
|
||||
It is a hint for Jason and changes nothing in the import.
|
||||
|
||||
## Channel prompts
|
||||
|
||||
Hermes takes a platform's block from `<platform>` at the top level of config.yaml, else from
|
||||
`platforms.<platform>` or `gateway.platforms.<platform>` (gateway/config.py:1159-1178), and its
|
||||
prompts from `channel_prompts` (:1233). The script lists all three by platform, channel id and byte
|
||||
count, and says "not text" for a value that isn't a string. The dry run doesn't read config.yaml.
|
||||
|
||||
yaml parses with `intAsBigInt`, so an unquoted 18 or 19 digit Discord id prints exactly instead of
|
||||
rounding as a double, and with `uniqueKeys: false`, so a repeated key keeps the last value as Python's
|
||||
`safe_load` does. `logLevel: "silent"` stops yaml's warnings, which quote config text. yaml reads YAML
|
||||
1.2 and Hermes's PyYAML reads 1.1. They differ on scalars such as `yes` and `0123`, but neither
|
||||
changes a channel prompt's key or its byte count for a quoted or decimal id.
|
||||
|
||||
## yaml dependency (sage-1840 Q3)
|
||||
|
||||
Made with `npm install --save-exact [email protected] --package-lock-only --ignore-scripts` at the worktree
|
||||
root. `git config core.hooksPath` was unset before and after. npm moved nothing else:
|
||||
|
||||
```
|
||||
package-lock.json | 3 ++-
|
||||
package.json | 3 ++-
|
||||
2 files changed, 4 insertions(+), 2 deletions(-)
|
||||
```
|
||||
|
||||
```
|
||||
@@ -10,7 +10,8 @@
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
- "oauth4webapi": "3.8.8"
|
||||
+ "oauth4webapi": "3.8.8",
|
||||
+ "yaml": "2.9.0"
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
node_modules/yaml is 2.9.0, from the pinned pi packages (package-lock.json:502 and :556).
|
||||
|
||||
Where it runs. `scripts/case2-persona-import.mjs` isn't in stack-copy's COPY_PATHS, and a copy carries
|
||||
no node_modules of its own beyond its `npm ci`. So the script runs from a checkout at the reviewed
|
||||
commit after `npm ci`, where `import "yaml"` resolves through the root package.json and
|
||||
`../packages/harness/src/bundle.mjs` is beside it. Whoever runs `stage` needs read access to that
|
||||
checkout and to her Hermes home, which is part of the item for Jason's go above.
|
||||
|
||||
## Tests
|
||||
|
||||
Round 1, on 213 r4 2b3e67e36, slot grants sage-1851 and sage-1863. One `node --test` per file.
|
||||
|
||||
Run 1 started at 08:19:44Z. A 600 s tool timeout on my wrapper killed it at about 08:29:45Z, inside
|
||||
stack-release-external, after stack-release took 494 s. One test in this row's file failed in run 1.
|
||||
Its filter `includes("per-channel prompt")` also matched the "profile chet: no per-channel prompts"
|
||||
line that the next assert checks on its own. That was a test defect, and the script's output was
|
||||
right. The filter is now `includes(": per-channel prompt ")`. The kill orphaned one fixture
|
||||
holder.cjs, which I killed, and I removed its tmp dirs. Sage counts this as one failure under mos-0823.
|
||||
|
||||
The rerun, 08:31:11Z to 08:33:31Z, covered this row's file, stack-release-external, stack-upgrade
|
||||
and the fixture dry run. The 15 files that run 1 finished green stand on code the fix didn't touch. Their counts come from run 1.
|
||||
|
||||
| File | pass | fail |
|
||||
|---|---|---|
|
||||
| packages/business/tests/launch-settings.test.mjs | 16 | 0 |
|
||||
| packages/cli/tests/stack-copy.test.mjs | 16 | 0 |
|
||||
| packages/cli/tests/stack-release.test.mjs | 195 | 0 |
|
||||
| packages/conversation/tests/cohort.test.mjs | 37 | 0 |
|
||||
| packages/harness/tests/bundle.test.mjs | 22 | 0 |
|
||||
| packages/harness/tests/claude-gate.test.mjs | 3 | 0 |
|
||||
| packages/harness/tests/claude-session.test.mjs | 8 | 0 |
|
||||
| packages/harness/tests/creative.test.mjs | 7 | 0 |
|
||||
| packages/harness/tests/mcp-server.test.mjs | 5 | 0 |
|
||||
| packages/harness/tests/pi-session.test.mjs | 8 | 0 |
|
||||
| packages/harness/tests/skills.test.mjs | 10 | 0 |
|
||||
| packages/plugins/tests/harness.test.mjs | 9 | 0 |
|
||||
| packages/seat/tests/sandbox.test.mjs | 10 | 0 |
|
||||
| packages/webui/tests/editor.test.mjs | 5 | 0 |
|
||||
| packages/webui/tests/pinned-copy.test.mjs | 10 | 0 |
|
||||
| packages/cli/tests/case2-persona-import.test.mjs | 11 | 0 |
|
||||
| packages/cli/tests/stack-release-external.test.mjs | 25 | 0 |
|
||||
| packages/cli/tests/stack-upgrade.test.mjs | 20 | 0 |
|
||||
|
||||
Total over the 18 files: 417 pass, 0 fail, 0 cancelled, summed by awk.
|
||||
|
||||
The dry run on the fixture, `stage --hermes-root packages/cli/tests/fixtures/case2-persona-import/hermes
|
||||
--pair default=Bean --pair chet=Chet --dry-run`, exited 0 and printed:
|
||||
|
||||
```
|
||||
files 2
|
||||
profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0
|
||||
profile chet: lines 2, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0
|
||||
```
|
||||
Generated
+2
-1
@@ -10,7 +10,8 @@
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
"oauth4webapi": "3.8.8",
|
||||
"yaml": "2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
"oauth4webapi": "3.8.8",
|
||||
"yaml": "2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// Case 2 row C2-20 (#1722): scripts/case2-persona-import.mjs takes Bean's and Chet's personas from
|
||||
// two Hermes profiles into Mosaic persona files. Brief: docs/plans/2026-10-20_case2-rows.md "C2-20".
|
||||
// Every case runs on a scratch copy of fixtures/case2-persona-import/hermes, a scratch staging
|
||||
// directory and a scratch config directory. Nothing here reads or writes ~/.hermes, ~/.mosaic-dev or
|
||||
// ~/.config/mosaic-dev, and no fixture value is real. Fixture strings starting "marker-" must never
|
||||
// reach the script's output.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { PERSONA_CHARS, personaTextFault } from "../../harness/src/bundle.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const SCRIPT = join(REPO, "scripts", "case2-persona-import.mjs");
|
||||
const FIXTURE = join(REPO, "packages", "cli", "tests", "fixtures", "case2-persona-import");
|
||||
const TRACE = join(FIXTURE, "trace.mjs");
|
||||
const PAIRS = ["--pair", "default=Bean", "--pair", "chet=Chet"];
|
||||
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
||||
const mode = (path) => statSync(path).mode & 0o777;
|
||||
|
||||
// A scratch Hermes home from the fixture, with the two .env files git won't carry, and empty
|
||||
// staging and config parents.
|
||||
function scratch(t) {
|
||||
const root = tmp(t, "mosaic-persona-");
|
||||
const hermes = join(root, "hermes");
|
||||
cpSync(join(FIXTURE, "hermes"), hermes, { recursive: true });
|
||||
writeFileSync(join(hermes, ".env"), "DISCORD_BOT_TOKEN=marker-env-default-3b58\n");
|
||||
writeFileSync(join(hermes, "profiles", "chet", ".env"), "DISCORD_BOT_TOKEN=marker-env-chet-7e44\n");
|
||||
const configDir = join(root, "config");
|
||||
mkdirSync(configDir, { mode: 0o700 });
|
||||
chmodSync(configDir, 0o700);
|
||||
return { root, hermes, configDir, staging: join(root, "staging"), trace: join(root, "trace.log") };
|
||||
}
|
||||
|
||||
function run(argv, { trace } = {}) {
|
||||
const pre = trace ? ["--import", TRACE] : [];
|
||||
const env = { ...process.env, ...(trace ? { PERSONA_TRACE: trace } : {}) };
|
||||
const r = spawnSync(process.execPath, [...pre, SCRIPT, ...argv], { encoding: "utf8", env, timeout: 30_000 });
|
||||
return { status: r.status, out: r.stdout, err: r.stderr, all: r.stdout + r.stderr };
|
||||
}
|
||||
|
||||
const stage = (s, extra = [], opts) => run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, ...extra], opts);
|
||||
const install = (s, business = "case2") => run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", business]);
|
||||
|
||||
// Every fixture marker and every line of fixture text the script reads.
|
||||
function assertNoText(s, output) {
|
||||
const markers = new Set();
|
||||
const walk = (dir) => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else for (const m of readFileSync(p, "utf8").match(/marker-[a-z]+(?:-[a-z]+)*-[0-9a-f]{4}/g) ?? []) markers.add(m);
|
||||
}
|
||||
};
|
||||
walk(s.hermes);
|
||||
assert.ok(markers.size >= 20, `only ${markers.size} markers found`);
|
||||
for (const m of markers) assert.ok(!output.includes(m), `output holds ${m}`);
|
||||
for (const line of ["You speak warmly", "Dry humour", "Help with the novel", "Ünïcode prompt"]) {
|
||||
assert.ok(!output.includes(line), `output holds "${line}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function personaTree(s, { bean, chet }) {
|
||||
if (bean !== undefined) writeFileSync(join(s.hermes, "SOUL.md"), bean);
|
||||
if (chet !== undefined) writeFileSync(join(s.hermes, "profiles", "chet", "SOUL.md"), chet);
|
||||
}
|
||||
|
||||
test("stage takes each profile's SOUL.md byte for byte and prints the pairing with sizes and digests", (t) => {
|
||||
const s = scratch(t);
|
||||
const bean = readFileSync(join(s.hermes, "SOUL.md"));
|
||||
const chet = readFileSync(join(s.hermes, "profiles", "chet", "SOUL.md"));
|
||||
const r = stage(s);
|
||||
assert.equal(r.status, 0, r.all);
|
||||
assert.deepEqual(readFileSync(join(s.staging, "bean.md")), bean);
|
||||
assert.deepEqual(readFileSync(join(s.staging, "chet.md")), chet);
|
||||
assert.ok(r.out.includes(`pairing: profile default is Bean (instance bean), SOUL.md ${bean.length} bytes, sha256 ${sha256(bean)}`), r.out);
|
||||
assert.ok(r.out.includes(`pairing: profile chet is Chet (instance chet), SOUL.md ${chet.length} bytes, sha256 ${sha256(chet)}`), r.out);
|
||||
// The pairing comes before the first file written.
|
||||
assert.ok(r.out.indexOf("pairing:") < r.out.indexOf("staged:"), r.out);
|
||||
const manifest = JSON.parse(readFileSync(join(s.staging, "manifest.json"), "utf8"));
|
||||
assert.deepEqual(manifest, {
|
||||
version: 1,
|
||||
personas: [
|
||||
{ instance: "bean", name: "Bean", bytes: bean.length, sha256: sha256(bean) },
|
||||
{ instance: "chet", name: "Chet", bytes: chet.length, sha256: sha256(chet) },
|
||||
],
|
||||
});
|
||||
assert.match(r.out, /next, as root: give .* to <user>/);
|
||||
assertNoText(s, r.all);
|
||||
});
|
||||
|
||||
// gateway/config.py at hermes-agent df5700ebe: a platform's block is <platform> at the top level
|
||||
// (:1159), else platforms.<platform> or gateway.platforms.<platform> (:1172-1178), and the prompts are
|
||||
// its channel_prompts (:1233). The fixture config.yaml has one of each.
|
||||
test("stage lists the per-channel prompts in all three places by name and byte count only", (t) => {
|
||||
const s = scratch(t);
|
||||
const r = stage(s);
|
||||
assert.equal(r.status, 0, r.all);
|
||||
const lines = r.out.split("\n").filter((l) => l.includes(": per-channel prompt "));
|
||||
assert.deepEqual(lines, [
|
||||
`profile default: per-channel prompt discord 111111111111111111, ${Buffer.byteLength("marker-prompt-top-5d02 Help with the novel.")} bytes`,
|
||||
`profile default: per-channel prompt discord 222222222222222222, ${Buffer.byteLength("Ünïcode prompt")} bytes`,
|
||||
// Unquoted in the fixture, and past 2 ** 53: the id prints exactly.
|
||||
"profile default: per-channel prompt discord 1333333333333333333, 5 bytes",
|
||||
`profile default: per-channel prompt platforms.slack C0FIXTURE, ${Buffer.byteLength("marker-prompt-platforms-8e3a")} bytes`,
|
||||
"profile default: per-channel prompt gateway.platforms.telegram -1001, 29 bytes",
|
||||
"profile default: per-channel prompt gateway.platforms.telegram -1002, not text",
|
||||
]);
|
||||
assert.ok(r.out.includes("profile chet: no per-channel prompts"), r.out);
|
||||
assertNoText(s, r.all);
|
||||
});
|
||||
|
||||
test("a read trace shows stage and the dry run open SOUL.md and config.yaml and never memory, sessions or secrets", (t) => {
|
||||
const s = scratch(t);
|
||||
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
|
||||
rmSync(s.trace, { force: true });
|
||||
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, ...argv], { trace: s.trace });
|
||||
assert.equal(r.status, 0, r.all);
|
||||
const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1)).filter((p) => p.startsWith(s.hermes));
|
||||
// The control: the trace sees the reads the script does make.
|
||||
assert.ok(opened.includes(join(s.hermes, "SOUL.md")), opened.join("\n"));
|
||||
assert.ok(opened.includes(join(s.hermes, "profiles", "chet", "SOUL.md")), opened.join("\n"));
|
||||
const allowed = new Set([
|
||||
join(s.hermes, "SOUL.md"), join(s.hermes, "config.yaml"),
|
||||
join(s.hermes, "profiles", "chet", "SOUL.md"), join(s.hermes, "profiles", "chet", "config.yaml"),
|
||||
]);
|
||||
for (const p of opened) assert.ok(allowed.has(p), `opened ${p}`);
|
||||
if (argv[0] === "--dry-run") assert.ok(!opened.some((p) => p.endsWith("config.yaml")), "the dry run read config.yaml");
|
||||
else assert.ok(opened.includes(join(s.hermes, "config.yaml")), opened.join("\n"));
|
||||
}
|
||||
});
|
||||
|
||||
test("the pairing refuses anything but two existing profiles naming Bean and Chet once each, and writes nothing", (t) => {
|
||||
const s = scratch(t);
|
||||
const cases = [
|
||||
[["--pair", "default=Bean", "--pair", "chet=Bob"], 1, /profile chet names "Bob", not Bean or Chet/],
|
||||
[["--pair", "default=bean", "--pair", "chet=Chet"], 1, /names "bean", not Bean or Chet/],
|
||||
[["--pair", "default=Bean", "--pair", "chet=Bean"], 1, /Bean is named twice/],
|
||||
[["--pair", "chet=Bean", "--pair", "chet=Chet"], 1, /profile chet is paired twice/],
|
||||
[["--pair", "default=Bean", "--pair", "nobody=Chet"], 1, /profile nobody: .* doesn't exist/],
|
||||
[["--pair", "default=Bean", "--pair", "../chet=Chet"], 1, /isn't a Hermes profile name/],
|
||||
[["--pair", "default=Bean"], 2, /exactly two --pair/],
|
||||
[["--pair", "default=Bean", "--pair", "chet=Chet", "--pair", "x=Chet"], 2, /exactly two --pair/],
|
||||
];
|
||||
for (const [pairs, status, message] of cases) {
|
||||
const r = run(["stage", "--hermes-root", s.hermes, ...pairs, "--staging", s.staging]);
|
||||
assert.equal(r.status, status, `${pairs.join(" ")}: ${r.all}`);
|
||||
assert.match(r.err, message);
|
||||
assert.ok(!existsSync(s.staging), `${pairs.join(" ")} wrote the staging directory`);
|
||||
}
|
||||
// Swapped is a valid pairing: the human's say decides it.
|
||||
const r = run(["stage", "--hermes-root", s.hermes, "--pair", "default=Chet", "--pair", "chet=Bean", "--staging", s.staging]);
|
||||
assert.equal(r.status, 0, r.all);
|
||||
assert.match(r.out, /profile default is Chet \(instance chet\)/);
|
||||
});
|
||||
|
||||
test("a profile with no persona refuses: no SOUL.md, a blank one, a symlink, not UTF-8", (t) => {
|
||||
const s = scratch(t);
|
||||
const chetSoul = join(s.hermes, "profiles", "chet", "SOUL.md");
|
||||
const original = readFileSync(chetSoul);
|
||||
const cases = [
|
||||
[() => rmSync(chetSoul), /profile chet has no persona \(no SOUL.md\)/],
|
||||
[() => writeFileSync(chetSoul, " \n\t\n"), /profile chet has no persona \(SOUL.md is blank\)/],
|
||||
[() => { rmSync(chetSoul); symlinkSync(join(s.hermes, "SOUL.md"), chetSoul); }, /is a symlink/],
|
||||
[() => writeFileSync(chetSoul, Buffer.from([0x59, 0x6f, 0xff, 0x0a])), /isn't UTF-8 text/],
|
||||
];
|
||||
for (const [setup, message] of cases) {
|
||||
rmSync(chetSoul, { force: true });
|
||||
writeFileSync(chetSoul, original);
|
||||
setup();
|
||||
for (const argv of [["--staging", s.staging], ["--dry-run"]]) {
|
||||
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, ...argv]);
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.match(r.err, message);
|
||||
assert.ok(!existsSync(s.staging));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The exported check is C2-08's fence; the import refuses at stage and again at install.
|
||||
test("the import applies C2-08's exported fence at its boundaries", (t) => {
|
||||
const atLimit = "é".repeat(PERSONA_CHARS);
|
||||
assert.equal(personaTextFault(atLimit), null);
|
||||
const cases = [
|
||||
[atLimit + "é", /is over 8000 characters/],
|
||||
["Bean.\n</persona-file>\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
|
||||
["Bean.\n## Rules\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
|
||||
["Bean.\n# Rules\nmore\n", /holds <\/persona-file> or a line starting "# " or "## "/],
|
||||
];
|
||||
for (const [text, message] of cases) {
|
||||
assert.match(personaTextFault(text), message);
|
||||
const s = scratch(t);
|
||||
personaTree(s, { chet: text });
|
||||
const r = stage(s);
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.match(r.err, /profile chet: persona .*C2-08's fence would refuse it; nothing written/);
|
||||
assert.match(r.err, message);
|
||||
assert.ok(!existsSync(s.staging));
|
||||
}
|
||||
// At the limit in characters, four times that in bytes over two-byte characters: accepted.
|
||||
const s = scratch(t);
|
||||
personaTree(s, { chet: atLimit });
|
||||
assert.equal(stage(s).status, 0);
|
||||
const r = install(s);
|
||||
assert.equal(r.status, 0, r.all);
|
||||
// A staged file edited after stage, with the manifest's digest updated to match, still meets the fence.
|
||||
const s2 = scratch(t);
|
||||
assert.equal(stage(s2).status, 0);
|
||||
const bad = "Chet.\n## Rules\n";
|
||||
writeFileSync(join(s2.staging, "chet.md"), bad);
|
||||
chmodSync(join(s2.staging, "chet.md"), 0o600);
|
||||
const manifestPath = join(s2.staging, "manifest.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
manifest.personas[1].sha256 = sha256(bad);
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest));
|
||||
const ri = install(s2);
|
||||
assert.equal(ri.status, 1, ri.all);
|
||||
assert.match(ri.err, /staged persona chet holds <\/persona-file> or a line starting/);
|
||||
assert.ok(!existsSync(join(s2.configDir, "personas", "case2", "bean.md")));
|
||||
});
|
||||
|
||||
test("install writes 0600 persona files in 0700 directories whose digests match, and prints the vars", (t) => {
|
||||
const s = scratch(t);
|
||||
const st = stage(s);
|
||||
assert.equal(st.status, 0, st.all);
|
||||
assert.equal(mode(s.staging), 0o700);
|
||||
for (const f of ["bean.md", "chet.md", "manifest.json"]) assert.equal(mode(join(s.staging, f)), 0o600, f);
|
||||
const r = install(s);
|
||||
assert.equal(r.status, 0, r.all);
|
||||
const dir = join(s.configDir, "personas", "case2");
|
||||
assert.equal(mode(join(s.configDir, "personas")), 0o700);
|
||||
assert.equal(mode(dir), 0o700);
|
||||
for (const [instance, name, src] of [["bean", "Bean", join(s.hermes, "SOUL.md")], ["chet", "Chet", join(s.hermes, "profiles", "chet", "SOUL.md")]]) {
|
||||
const path = join(dir, `${instance}.md`);
|
||||
assert.equal(mode(path), 0o600);
|
||||
const data = readFileSync(path);
|
||||
assert.deepEqual(data, readFileSync(src));
|
||||
assert.ok(r.out.includes(`instance ${instance}: persona.name ${name}, persona.sha256 ${sha256(data)}`), r.out);
|
||||
}
|
||||
assertNoText(s, st.all + r.all);
|
||||
});
|
||||
|
||||
test("install refuses an open or foreign staging file, a digest off the manifest and an existing persona", (t) => {
|
||||
const s = scratch(t);
|
||||
assert.equal(stage(s).status, 0);
|
||||
const chet = join(s.staging, "chet.md");
|
||||
chmodSync(chet, 0o640);
|
||||
let r = install(s);
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.match(r.err, /staged persona chet: .* has group or other mode bits/);
|
||||
chmodSync(chet, 0o600);
|
||||
chmodSync(s.staging, 0o750);
|
||||
r = install(s);
|
||||
assert.match(r.err, /staging has group or other mode bits/);
|
||||
chmodSync(s.staging, 0o700);
|
||||
|
||||
writeFileSync(chet, "You are someone else.\n");
|
||||
r = install(s);
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.match(r.err, /staged persona chet isn't the file the manifest names/);
|
||||
assert.ok(!existsSync(join(s.configDir, "personas", "case2", "bean.md")), "bean was written before chet was checked");
|
||||
|
||||
const s2 = scratch(t);
|
||||
assert.equal(stage(s2).status, 0);
|
||||
const dir = join(s2.configDir, "personas", "case2");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(s2.configDir, "personas"), 0o700);
|
||||
writeFileSync(join(dir, "chet.md"), "already here\n", { mode: 0o600 });
|
||||
r = install(s2);
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.match(r.err, /chet.md already exists; nothing written/);
|
||||
assert.equal(readFileSync(join(dir, "chet.md"), "utf8"), "already here\n");
|
||||
assert.ok(!existsSync(join(dir, "bean.md")));
|
||||
|
||||
for (const business of ["../x", "Case2", ""]) {
|
||||
r = install(s2, business);
|
||||
assert.notEqual(r.status, 0, business);
|
||||
}
|
||||
const s3 = scratch(t);
|
||||
assert.equal(stage(s3).status, 0);
|
||||
writeFileSync(join(s3.staging, "manifest.json"), JSON.stringify({ version: 1, personas: [{ instance: "bob", name: "Bob" }] }));
|
||||
r = install(s3);
|
||||
assert.equal(r.status, 1, r.all);
|
||||
assert.match(r.err, /the manifest doesn't list two personas/);
|
||||
});
|
||||
|
||||
test("--dry-run writes nothing and prints only counts, two heading lines in a fixture counted", (t) => {
|
||||
const s = scratch(t);
|
||||
personaTree(s, { chet: "You are Chet. marker-chet-dry-7d21\n# Voice\nShort.\r\n## Habits\nNot # a heading, nor #nospace.\n" });
|
||||
const before = readdirSync(s.root).sort();
|
||||
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
|
||||
assert.equal(r.status, 0, r.all);
|
||||
assert.deepEqual(readdirSync(s.root).sort(), before);
|
||||
assert.deepEqual(r.out.split("\n").filter(Boolean), [
|
||||
"files 2",
|
||||
'profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0',
|
||||
'profile chet: lines 5, lines starting "# " or "## " 2, lines naming a Hermes tool or channel 0',
|
||||
]);
|
||||
assert.equal(r.err, "");
|
||||
assertNoText(s, r.all);
|
||||
assert.ok(!r.all.includes("Voice") && !r.all.includes("Habits"));
|
||||
});
|
||||
|
||||
test("--dry-run counts three lines naming a Hermes tool or channel and prints none of the words", (t) => {
|
||||
const s = scratch(t);
|
||||
personaTree(s, {
|
||||
bean: [
|
||||
"You are Bean.",
|
||||
"Use web_search when she asks for facts.",
|
||||
"You live on Discord, in her server.",
|
||||
"Keep a TODO list for her chapters.",
|
||||
"Web searching and discords of music don't count, nor memory_bank.",
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
const r = run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]);
|
||||
assert.equal(r.status, 0, r.all);
|
||||
assert.match(r.out, /profile default: lines 5, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 3/);
|
||||
for (const word of ["web_search", "Discord", "discord", "TODO", "todo", "memory"]) assert.ok(!r.all.includes(word), `output holds ${word}`);
|
||||
});
|
||||
|
||||
test("usage: stage needs one of --staging and --dry-run, install takes only its own options", (t) => {
|
||||
const s = scratch(t);
|
||||
assert.equal(run(["stage", "--hermes-root", s.hermes, ...PAIRS]).status, 2);
|
||||
assert.equal(run(["stage", "--hermes-root", s.hermes, ...PAIRS, "--staging", s.staging, "--dry-run"]).status, 2);
|
||||
assert.equal(run(["stage", ...PAIRS, "--dry-run"]).status, 2);
|
||||
assert.equal(run(["install", "--staging", s.staging, "--config-dir", s.configDir]).status, 2);
|
||||
assert.equal(run(["install", "--staging", s.staging, "--config-dir", s.configDir, "--business", "case2", "--dry-run"]).status, 2);
|
||||
assert.equal(run(["import"]).status, 2);
|
||||
assert.equal(run(["stage", "--bogus", "x"]).status, 2);
|
||||
assert.ok(!existsSync(s.staging));
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
Fixture Hermes home for packages/cli/tests/case2-persona-import.test.mjs (row 221, C2-20).
|
||||
The layout follows hermes-agent df5700ebe: the default profile is the root, other profiles sit under
|
||||
profiles/<name>, each persona is SOUL.md and channel prompts are in config.yaml. Every value is made up.
|
||||
Strings starting "marker-" must never reach the script's output, and files under memories/, sessions/
|
||||
and skills/, state.db, profile.yaml, auth.json and .env (the test writes .env, which git ignores) must never be opened.
|
||||
@@ -0,0 +1,3 @@
|
||||
You are Bean. marker-bean-soul-71c4
|
||||
You speak warmly and keep replies short.
|
||||
You like sketches, coffee and long walks.
|
||||
@@ -0,0 +1 @@
|
||||
{"token": "marker-auth-default-6e21"}
|
||||
@@ -0,0 +1,21 @@
|
||||
model:
|
||||
default: marker-model-name-2b91
|
||||
provider: openrouter
|
||||
discord:
|
||||
require_mention: true
|
||||
channel_prompts:
|
||||
"111111111111111111": "marker-prompt-top-5d02 Help with the novel."
|
||||
"222222222222222222": "Ünïcode prompt"
|
||||
1333333333333333333: "short"
|
||||
platforms:
|
||||
slack:
|
||||
channel_prompts:
|
||||
C0FIXTURE: "marker-prompt-platforms-8e3a"
|
||||
gateway:
|
||||
platforms:
|
||||
telegram:
|
||||
channel_prompts:
|
||||
"-1001": "marker-prompt-gateway-0f6b ok"
|
||||
"-1002":
|
||||
nested: not a string
|
||||
api_key: marker-token-default-9a17
|
||||
@@ -0,0 +1 @@
|
||||
marker-memory-default-1f8e
|
||||
@@ -0,0 +1 @@
|
||||
description: marker-profile-description-44de
|
||||
@@ -0,0 +1,2 @@
|
||||
You are Chet. marker-chet-soul-38e0
|
||||
Dry humour, quick with a pun.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
model:
|
||||
default: marker-model-chet-6d13
|
||||
+1
@@ -0,0 +1 @@
|
||||
marker-memory-chet-d913
|
||||
+1
@@ -0,0 +1 @@
|
||||
description: marker-profile-chet-0c55
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"text": "marker-session-chet-08bc"}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
marker-skill-chet-6a70
|
||||
+1
@@ -0,0 +1 @@
|
||||
marker-state-chet-52aa
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"text": "marker-session-default-a4b2"}
|
||||
@@ -0,0 +1 @@
|
||||
marker-state-default-c90d
|
||||
@@ -0,0 +1,20 @@
|
||||
// Read trace for case2-persona-import.test.mjs: loaded with --import, it logs every path the script
|
||||
// opens, reads or lists to $PERSONA_TRACE, one "op path" line each. The log is written through
|
||||
// the original functions so the trace doesn't trace itself.
|
||||
import fs from "node:fs";
|
||||
import { syncBuiltinESMExports } from "node:module";
|
||||
|
||||
const { openSync, writeSync } = fs;
|
||||
const log = openSync(process.env.PERSONA_TRACE, "a", 0o600);
|
||||
const record = (op, p) => {
|
||||
if (typeof p === "string" || p instanceof URL || Buffer.isBuffer(p)) writeSync(log, `${op} ${String(p)}\n`);
|
||||
};
|
||||
for (const name of ["openSync", "readFileSync", "readdirSync", "opendirSync", "createReadStream", "open", "readFile", "readdir", "opendir"]) {
|
||||
const original = fs[name];
|
||||
fs[name] = function traced(p, ...rest) { record(name, p); return original.call(this, p, ...rest); };
|
||||
}
|
||||
for (const name of ["open", "readFile", "readdir", "opendir"]) {
|
||||
const original = fs.promises[name];
|
||||
fs.promises[name] = function traced(p, ...rest) { record(`promises.${name}`, p); return original.call(this, p, ...rest); };
|
||||
}
|
||||
syncBuiltinESMExports();
|
||||
@@ -188,6 +188,13 @@ function skillSection(skills) {
|
||||
// digest still refuses any bytes but the pinned ones.
|
||||
export const PERSONA_CHARS = 8000;
|
||||
const PERSONA_END = "</persona-file>";
|
||||
// The persona text rules, one copy for the bundle and the persona import (row 221,
|
||||
// scripts/case2-persona-import.mjs): null for text that passes, else the reason it doesn't.
|
||||
export function personaTextFault(text) {
|
||||
if ([...text].length > PERSONA_CHARS) return `is over ${PERSONA_CHARS} characters`;
|
||||
if (text.includes(PERSONA_END) || /^##? /m.test(text)) return `holds ${PERSONA_END} or a line starting "# " or "## "`;
|
||||
return null;
|
||||
}
|
||||
function readPersona(resolved, personaDir) {
|
||||
const digest = resolved.vars?.["persona.sha256"];
|
||||
if (!personaDir) {
|
||||
@@ -221,10 +228,8 @@ function readPersona(resolved, personaDir) {
|
||||
}
|
||||
let text;
|
||||
try { text = utf8.decode(data); } catch { throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't UTF-8 text`, "persona-invalid"); }
|
||||
if ([...text].length > PERSONA_CHARS) throw new BundleError(`instance ${resolved.instance}: persona file ${path} is over ${PERSONA_CHARS} characters`, "persona-invalid");
|
||||
if (text.includes(PERSONA_END) || /^##? /m.test(text)) {
|
||||
throw new BundleError(`instance ${resolved.instance}: persona file ${path} holds ${PERSONA_END} or a line starting "# " or "## "`, "persona-invalid");
|
||||
}
|
||||
const fault = personaTextFault(text);
|
||||
if (fault) throw new BundleError(`instance ${resolved.instance}: persona file ${path} ${fault}`, "persona-invalid");
|
||||
return { name: resolved.vars["persona.name"], sha256: digest, text };
|
||||
}
|
||||
// Between the contract and "## This session". The contract wins on a conflict, and the broker's
|
||||
|
||||
Executable
+319
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env node
|
||||
// Case 2 row C2-20 (#1722): Bean's and Chet's personas from her two Hermes profiles into Mosaic
|
||||
// persona files (design 4 and 9.1, brief docs/plans/2026-10-20_case2-rows.md "C2-20").
|
||||
//
|
||||
// case2-persona-import.mjs stage --hermes-root DIR --pair PROFILE=NAME --pair PROFILE=NAME
|
||||
// (--staging DIR | --dry-run)
|
||||
// case2-persona-import.mjs install --staging DIR --config-dir DIR --business ID
|
||||
//
|
||||
// stage runs as whoever can read her Hermes home. Per profile it reads SOUL.md, the persona Hermes
|
||||
// loads (agent/prompt_builder.py:1829-1855 at df5700ebe), and config.yaml for the per-channel prompt
|
||||
// names. Nothing else in the profile is opened: no memory store, state.db, session or message.
|
||||
// Hermes has no field holding a profile's own name (profile.yaml carries only description,
|
||||
// hermes_cli/profiles.py:798-850), so the human passes the pairing and the script prints it with each
|
||||
// SOUL.md's byte count and sha256 before anything is written (sage-1836). NAME is Bean or Chet and
|
||||
// the instance is its lower case. stage writes each persona, byte for byte, to a 0600 file in a new
|
||||
// 0700 staging directory with a manifest of names and digests. Handing the directory to <user> is a
|
||||
// root step between the two runs; the script changes no owner. install runs as <user>, checks each
|
||||
// staged file against the manifest and C2-08's fence, and writes
|
||||
// <configDir>/personas/<business>/<instance>.md at 0600. It never overwrites.
|
||||
//
|
||||
// Output is names, byte counts and sha256, never persona or prompt text. --dry-run writes nothing
|
||||
// and prints counts only: files, lines, lines starting "# " or "## ", and lines naming a Hermes tool
|
||||
// or channel from HERMES_WORDS. A fence fault there is counted, not refused.
|
||||
//
|
||||
// Exit codes: 0 ok, 1 refused, 2 usage.
|
||||
|
||||
import { chmodSync, closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { PERSONA_CHARS, personaTextFault } from "../packages/harness/src/bundle.mjs";
|
||||
|
||||
// The tool names registered in hermes-agent's tools/*.py at df5700ebe (literal `name="..."` in
|
||||
// registry.register calls; the MCP and plugin tools get their names at run time), plus Hermes and
|
||||
// Discord (design 9.1). Matched as whole words, any case.
|
||||
const HERMES_WORDS = Object.freeze([
|
||||
"browser_back", "browser_cdp", "browser_click", "browser_console", "browser_dialog", "browser_get_images",
|
||||
"browser_navigate", "browser_press", "browser_scroll", "browser_snapshot", "browser_type", "browser_vision",
|
||||
"clarify", "close_terminal", "computer_use", "cronjob", "delegate_task", "discord", "discord_admin",
|
||||
"execute_code", "feishu_doc_read", "feishu_drive_add_comment", "feishu_drive_list_comment_replies",
|
||||
"feishu_drive_list_comments", "feishu_drive_reply_comment", "ha_call_service", "ha_get_state",
|
||||
"ha_list_entities", "ha_list_services", "image_generate", "kanban_block", "kanban_comment", "kanban_complete",
|
||||
"kanban_create", "kanban_heartbeat", "kanban_link", "kanban_list", "kanban_show", "kanban_unblock", "memory",
|
||||
"patch", "process", "project_create", "project_list", "project_switch", "read_file", "read_terminal",
|
||||
"search_files", "session_search", "skill_manage", "skill_view", "skills_list", "terminal", "text_to_speech",
|
||||
"todo", "video_analyze", "video_generate", "vision_analyze", "web_extract", "web_search", "write_file",
|
||||
"x_search", "xai_video_edit", "xai_video_extend", "yb_query_group_info", "yb_query_group_members",
|
||||
"yb_search_sticker", "yb_send_dm", "yb_send_sticker",
|
||||
"Hermes", "Discord",
|
||||
]);
|
||||
const WORD_LINE = new RegExp(`\\b(?:${HERMES_WORDS.join("|")})\\b`, "i");
|
||||
|
||||
const NAMES = Object.freeze(["Bean", "Chet"]);
|
||||
const PROFILE_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/; // hermes_cli/profiles.py:37
|
||||
const BUSINESS_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const MANIFEST = "manifest.json";
|
||||
const CONFIG_MAX = 4 * 1024 * 1024;
|
||||
// The same split the fence's /^...$/m uses, so a counted heading line is one the fence refuses.
|
||||
const LINE_BREAK = /\r\n|[\n\r\u2028\u2029]/;
|
||||
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
||||
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
||||
|
||||
class Refusal extends Error {}
|
||||
class Usage extends Error {}
|
||||
|
||||
function args(argv) {
|
||||
const out = { pairs: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const flag = argv[i];
|
||||
if (flag === "--dry-run") { out.dryRun = true; continue; }
|
||||
const value = argv[i + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Usage(`${flag} needs a value`);
|
||||
i++;
|
||||
if (flag === "--pair") out.pairs.push(value);
|
||||
else if (flag === "--hermes-root") out.hermesRoot = value;
|
||||
else if (flag === "--staging") out.staging = value;
|
||||
else if (flag === "--config-dir") out.configDir = value;
|
||||
else if (flag === "--business") out.business = value;
|
||||
else throw new Usage(`unknown option ${flag}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Exactly two pairs, two distinct profiles, Bean and Chet once each.
|
||||
function pairing(pairs, hermesRoot) {
|
||||
if (pairs.length !== 2) throw new Usage("give exactly two --pair PROFILE=NAME");
|
||||
const out = pairs.map((p) => {
|
||||
const m = /^([^=]+)=(.*)$/.exec(p);
|
||||
if (!m) throw new Usage(`--pair ${p} isn't PROFILE=NAME`);
|
||||
const [, profile, name] = m;
|
||||
if (!PROFILE_ID.test(profile)) throw new Refusal(`profile ${profile} isn't a Hermes profile name`);
|
||||
if (!NAMES.includes(name)) throw new Refusal(`profile ${profile} names ${JSON.stringify(name)}, not Bean or Chet`);
|
||||
const dir = profile === "default" ? hermesRoot : join(hermesRoot, "profiles", profile);
|
||||
const s = lstatSync(dir, { throwIfNoEntry: false });
|
||||
if (!s) throw new Refusal(`profile ${profile}: ${dir} doesn't exist`);
|
||||
if (!s.isDirectory()) throw new Refusal(`profile ${profile}: ${dir} isn't a directory`);
|
||||
return { profile, name, instance: name.toLowerCase(), dir };
|
||||
});
|
||||
if (out[0].profile === out[1].profile) throw new Refusal(`profile ${out[0].profile} is paired twice`);
|
||||
if (out[0].name === out[1].name) throw new Refusal(`${out[0].name} is named twice`);
|
||||
return out;
|
||||
}
|
||||
|
||||
// One regular file, opened without following a last-component symlink, at most max bytes.
|
||||
function readFile(path, max, what) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return null;
|
||||
if (e.code === "ELOOP") throw new Refusal(`${what} ${path} is a symlink`);
|
||||
throw new Refusal(`${what} ${path} can't be opened (${e.code})`);
|
||||
}
|
||||
try {
|
||||
const s = fstatSync(fd);
|
||||
if (!s.isFile()) throw new Refusal(`${what} ${path} isn't a regular file`);
|
||||
if (s.size > max) throw new Refusal(`${what} ${path} is over ${max} bytes`);
|
||||
return { data: readFileSync(fd), stat: s };
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// The persona Hermes loads: <profile dir>/SOUL.md. Hermes treats a missing or blank one as no
|
||||
// persona, and so does this.
|
||||
function soul(p) {
|
||||
const path = join(p.dir, "SOUL.md");
|
||||
const file = readFile(path, PERSONA_CHARS * 4, `profile ${p.profile}: persona`);
|
||||
if (!file) throw new Refusal(`profile ${p.profile} has no persona (no SOUL.md)`);
|
||||
let text;
|
||||
try { text = utf8.decode(file.data); } catch { throw new Refusal(`profile ${p.profile}: persona ${path} isn't UTF-8 text`); }
|
||||
if (!text.trim()) throw new Refusal(`profile ${p.profile} has no persona (SOUL.md is blank)`);
|
||||
return { data: file.data, text };
|
||||
}
|
||||
|
||||
// Per-channel prompts, by platform and channel id, from the three places Hermes takes a platform's
|
||||
// block (gateway/config.py:1159-1178 at df5700ebe): <platform>, platforms.<platform> and
|
||||
// gateway.platforms.<platform>, each with channel_prompts (:1233). Names and byte counts only.
|
||||
function channelPrompts(config) {
|
||||
const out = [];
|
||||
const scan = (where, block) => {
|
||||
if (!block || typeof block !== "object" || Array.isArray(block)) return;
|
||||
for (const [platform, cfg] of Object.entries(block)) {
|
||||
const prompts = cfg && typeof cfg === "object" ? cfg.channel_prompts : undefined;
|
||||
if (!prompts || typeof prompts !== "object" || Array.isArray(prompts)) continue;
|
||||
for (const [channel, value] of Object.entries(prompts)) {
|
||||
out.push({ where: `${where}${platform}`, channel: String(channel), bytes: typeof value === "string" ? Buffer.byteLength(value) : null });
|
||||
}
|
||||
}
|
||||
};
|
||||
if (config && typeof config === "object") {
|
||||
const { platforms, gateway, ...top } = config;
|
||||
scan("", top);
|
||||
scan("platforms.", platforms);
|
||||
scan("gateway.platforms.", gateway?.platforms);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function config(p) {
|
||||
const path = join(p.dir, "config.yaml");
|
||||
const file = readFile(path, CONFIG_MAX, `profile ${p.profile}: config`);
|
||||
if (!file) return [];
|
||||
let doc;
|
||||
// A Discord channel id is 18 or 19 digits, past a double's exact range, so unquoted ids parse as
|
||||
// BigInt. Python's safe_load keeps the last of two equal keys, and so does this. yaml's warnings
|
||||
// quote config text, so they are off.
|
||||
try { doc = parseYaml(utf8.decode(file.data), { intAsBigInt: true, uniqueKeys: false, logLevel: "silent" }); } catch { throw new Refusal(`profile ${p.profile}: ${path} isn't YAML`); }
|
||||
return channelPrompts(doc);
|
||||
}
|
||||
|
||||
function counts(text) {
|
||||
const lines = text.split(LINE_BREAK);
|
||||
if (lines.at(-1) === "") lines.pop();
|
||||
return {
|
||||
lines: lines.length,
|
||||
headings: lines.filter((l) => /^##? /.test(l)).length,
|
||||
words: lines.filter((l) => WORD_LINE.test(l)).length,
|
||||
};
|
||||
}
|
||||
|
||||
// A new 0700 directory, or a check that an existing one is ours and closed to group and other.
|
||||
function privateDir(path, { create }) {
|
||||
const s = lstatSync(path, { throwIfNoEntry: false });
|
||||
if (!s) {
|
||||
if (!create) throw new Refusal(`${path} doesn't exist`);
|
||||
mkdirSync(path, { mode: 0o700 });
|
||||
chmodSync(path, 0o700);
|
||||
return;
|
||||
}
|
||||
if (!s.isDirectory()) throw new Refusal(`${path} isn't a directory`);
|
||||
if (s.uid !== process.getuid()) throw new Refusal(`${path} isn't owned by uid ${process.getuid()}`);
|
||||
if (s.mode & 0o077) throw new Refusal(`${path} has group or other mode bits`);
|
||||
}
|
||||
|
||||
// A new 0600 file; refuses one that exists.
|
||||
function writeNew(path, data) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
} catch (e) {
|
||||
if (e.code === "EEXIST") throw new Refusal(`${path} already exists`);
|
||||
throw new Refusal(`${path} can't be created (${e.code})`);
|
||||
}
|
||||
try {
|
||||
fchmodSync(fd, 0o600);
|
||||
writeSync(fd, data);
|
||||
} catch (e) {
|
||||
closeSync(fd);
|
||||
unlinkSync(path);
|
||||
throw e;
|
||||
}
|
||||
closeSync(fd);
|
||||
}
|
||||
|
||||
function stage(o, say) {
|
||||
if (!o.hermesRoot) throw new Usage("stage needs --hermes-root");
|
||||
if (!o.dryRun === !o.staging) throw new Usage("stage needs exactly one of --staging and --dry-run");
|
||||
const pairs = pairing(o.pairs, o.hermesRoot);
|
||||
const personas = pairs.map((p) => ({ ...p, ...soul(p) }));
|
||||
|
||||
if (o.dryRun) {
|
||||
say(`files ${personas.length}`);
|
||||
for (const p of personas) {
|
||||
const c = counts(p.text);
|
||||
say(`profile ${p.profile}: lines ${c.lines}, lines starting "# " or "## " ${c.headings}, lines naming a Hermes tool or channel ${c.words}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of personas) {
|
||||
say(`pairing: profile ${p.profile} is ${p.name} (instance ${p.instance}), SOUL.md ${p.data.length} bytes, sha256 ${sha256(p.data)}`);
|
||||
}
|
||||
for (const p of pairs) {
|
||||
const prompts = config(p);
|
||||
if (!prompts.length) say(`profile ${p.profile}: no per-channel prompts`);
|
||||
for (const c of prompts) say(`profile ${p.profile}: per-channel prompt ${c.where} ${c.channel}, ${c.bytes === null ? "not text" : `${c.bytes} bytes`}`);
|
||||
}
|
||||
for (const p of personas) {
|
||||
const fault = personaTextFault(p.text);
|
||||
if (fault) throw new Refusal(`profile ${p.profile}: persona ${fault}, so C2-08's fence would refuse it; nothing written`);
|
||||
}
|
||||
|
||||
privateDir(o.staging, { create: true });
|
||||
const manifest = { version: 1, personas: [] };
|
||||
for (const p of personas) {
|
||||
writeNew(join(o.staging, `${p.instance}.md`), p.data);
|
||||
manifest.personas.push({ instance: p.instance, name: p.name, bytes: p.data.length, sha256: sha256(p.data) });
|
||||
}
|
||||
writeNew(join(o.staging, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
for (const m of manifest.personas) say(`staged: ${join(o.staging, `${m.instance}.md`)}, ${m.bytes} bytes, sha256 ${m.sha256}`);
|
||||
say(`next, as root: give ${o.staging} and its files to <user> (chown -R <user>: ${o.staging}), then as <user>:`);
|
||||
say(` scripts/case2-persona-import.mjs install --staging ${o.staging} --config-dir <configDir> --business <business>`);
|
||||
}
|
||||
|
||||
function staged(path, what) {
|
||||
const file = readFile(path, PERSONA_CHARS * 4 + 64 * 1024, what);
|
||||
if (!file) throw new Refusal(`${what} ${path} doesn't exist`);
|
||||
if (file.stat.uid !== process.getuid()) throw new Refusal(`${what} ${path} isn't owned by uid ${process.getuid()}`);
|
||||
if (file.stat.mode & 0o077) throw new Refusal(`${what} ${path} has group or other mode bits`);
|
||||
return file.data;
|
||||
}
|
||||
|
||||
function install(o, say) {
|
||||
if (!o.staging || !o.configDir || !o.business) throw new Usage("install needs --staging, --config-dir and --business");
|
||||
if (o.pairs.length || o.hermesRoot || o.dryRun) throw new Usage("install takes only --staging, --config-dir and --business");
|
||||
if (!BUSINESS_ID.test(o.business)) throw new Refusal(`business ${o.business} isn't a business id`);
|
||||
privateDir(o.staging, { create: false });
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(staged(join(o.staging, MANIFEST), "manifest")); } catch (e) {
|
||||
if (e instanceof Refusal) throw e;
|
||||
throw new Refusal(`manifest ${join(o.staging, MANIFEST)} isn't JSON`);
|
||||
}
|
||||
const entries = manifest?.version === 1 && Array.isArray(manifest.personas) ? manifest.personas : null;
|
||||
if (!entries || entries.length !== 2) throw new Refusal("the manifest doesn't list two personas");
|
||||
const seen = new Set();
|
||||
const personas = entries.map((m) => {
|
||||
if (!NAMES.includes(m?.name) || m.instance !== m.name.toLowerCase() || seen.has(m.name)) throw new Refusal("the manifest doesn't pair Bean and Chet once each");
|
||||
seen.add(m.name);
|
||||
const data = staged(join(o.staging, `${m.instance}.md`), `staged persona ${m.instance}:`);
|
||||
if (sha256(data) !== m.sha256) throw new Refusal(`staged persona ${m.instance} isn't the file the manifest names`);
|
||||
let text;
|
||||
try { text = utf8.decode(data); } catch { throw new Refusal(`staged persona ${m.instance} isn't UTF-8 text`); }
|
||||
const fault = personaTextFault(text);
|
||||
if (fault) throw new Refusal(`staged persona ${m.instance} ${fault}, so C2-08's fence would refuse it`);
|
||||
return { ...m, data };
|
||||
});
|
||||
|
||||
privateDir(o.configDir, { create: false });
|
||||
const dir = join(o.configDir, "personas", o.business);
|
||||
privateDir(join(o.configDir, "personas"), { create: true });
|
||||
privateDir(dir, { create: true });
|
||||
for (const p of personas) {
|
||||
const target = join(dir, `${p.instance}.md`);
|
||||
if (lstatSync(target, { throwIfNoEntry: false })) throw new Refusal(`${target} already exists; nothing written`);
|
||||
}
|
||||
for (const p of personas) writeNew(join(dir, `${p.instance}.md`), p.data);
|
||||
for (const p of personas) {
|
||||
say(`installed: ${join(dir, `${p.instance}.md`)}, ${p.data.length} bytes`);
|
||||
say(`instance ${p.instance}: persona.name ${p.name}, persona.sha256 ${p.sha256}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv, say = (line) => process.stdout.write(`${line}\n`)) {
|
||||
const [verb, ...rest] = argv;
|
||||
try {
|
||||
const o = args(rest);
|
||||
if (verb === "stage") stage(o, say);
|
||||
else if (verb === "install") install(o, say);
|
||||
else throw new Usage("verb is stage or install");
|
||||
return 0;
|
||||
} catch (e) {
|
||||
if (e instanceof Usage) { process.stderr.write(`case2-persona-import: ${e.message}\n`); return 2; }
|
||||
if (e instanceof Refusal) { process.stderr.write(`case2-persona-import: refused: ${e.message}\n`); return 1; }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
Reference in New Issue
Block a user