verbalcoding 0.2.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 (85) hide show
  1. package/.env.example +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +157 -0
  4. package/app-node/agent_adapters.mjs +576 -0
  5. package/app-node/agent_adapters.test.mjs +455 -0
  6. package/app-node/agent_contract.mjs +45 -0
  7. package/app-node/barge_in.mjs +148 -0
  8. package/app-node/barge_in.test.mjs +179 -0
  9. package/app-node/bridge_logger.mjs +66 -0
  10. package/app-node/bridge_logger.test.mjs +73 -0
  11. package/app-node/bridge_state.mjs +104 -0
  12. package/app-node/bridge_state.test.mjs +64 -0
  13. package/app-node/cli_install.test.mjs +97 -0
  14. package/app-node/deferred_queue.mjs +12 -0
  15. package/app-node/deferred_queue.test.mjs +20 -0
  16. package/app-node/discord_invite_cli.test.mjs +31 -0
  17. package/app-node/discord_text.mjs +29 -0
  18. package/app-node/discord_text.test.mjs +32 -0
  19. package/app-node/hermes_profiles.mjs +164 -0
  20. package/app-node/hermes_profiles.test.mjs +276 -0
  21. package/app-node/install_config.mjs +263 -0
  22. package/app-node/install_config.test.mjs +205 -0
  23. package/app-node/instance_doctor.mjs +137 -0
  24. package/app-node/instance_doctor.test.mjs +128 -0
  25. package/app-node/instance_profile_lifecycle.mjs +16 -0
  26. package/app-node/instances.mjs +153 -0
  27. package/app-node/instances.test.mjs +102 -0
  28. package/app-node/language_config.mjs +73 -0
  29. package/app-node/language_config.test.mjs +51 -0
  30. package/app-node/latency_metrics.mjs +133 -0
  31. package/app-node/latency_metrics.test.mjs +71 -0
  32. package/app-node/main.mjs +1771 -0
  33. package/app-node/mcp_tools.mjs +198 -0
  34. package/app-node/mcp_tools.test.mjs +39 -0
  35. package/app-node/progress_cache.mjs +7 -0
  36. package/app-node/progress_cache.test.mjs +23 -0
  37. package/app-node/progress_speech.mjs +102 -0
  38. package/app-node/progress_speech.test.mjs +48 -0
  39. package/app-node/project_sessions.mjs +148 -0
  40. package/app-node/project_sessions.test.mjs +77 -0
  41. package/app-node/restart_notice.mjs +57 -0
  42. package/app-node/restart_notice.test.mjs +37 -0
  43. package/app-node/restart_policy.mjs +27 -0
  44. package/app-node/restart_policy.test.mjs +33 -0
  45. package/app-node/text_routing.mjs +8 -0
  46. package/app-node/text_routing.test.mjs +18 -0
  47. package/app-node/tts_backends.mjs +251 -0
  48. package/app-node/tts_backends.test.mjs +400 -0
  49. package/app-node/tts_chunks.mjs +57 -0
  50. package/app-node/tts_chunks.test.mjs +35 -0
  51. package/app-node/tts_prefetch.mjs +38 -0
  52. package/app-node/tts_prefetch.test.mjs +49 -0
  53. package/app-node/tts_settings.mjs +72 -0
  54. package/app-node/tts_settings.test.mjs +127 -0
  55. package/app-node/tts_voice_config.mjs +127 -0
  56. package/app-node/tts_voice_config.test.mjs +64 -0
  57. package/app-node/voice_clone_capture.mjs +76 -0
  58. package/app-node/voice_clone_capture.test.mjs +51 -0
  59. package/app-node/voice_messages.mjs +62 -0
  60. package/app-node/voice_messages.test.mjs +33 -0
  61. package/docs/CONFIGURATION.md +183 -0
  62. package/docs/FRESH_INSTALL.md +193 -0
  63. package/docs/MULTI_INSTANCE.md +183 -0
  64. package/docs/RELEASE.md +72 -0
  65. package/docs/USAGE.md +108 -0
  66. package/docs/assets/figures/verbalcoding-flow.svg +63 -0
  67. package/docs/i18n/README.es.md +121 -0
  68. package/docs/i18n/README.fr.md +121 -0
  69. package/docs/i18n/README.ja.md +121 -0
  70. package/docs/i18n/README.ko.md +121 -0
  71. package/docs/i18n/README.ru.md +121 -0
  72. package/docs/i18n/README.zh.md +121 -0
  73. package/package.json +58 -0
  74. package/run.sh +82 -0
  75. package/scripts/bootstrap_prereqs.sh +193 -0
  76. package/scripts/cli.mjs +369 -0
  77. package/scripts/docker_ubuntu_smoke.sh +76 -0
  78. package/scripts/doctor.mjs +134 -0
  79. package/scripts/install.mjs +108 -0
  80. package/scripts/install.sh +44 -0
  81. package/scripts/mcp-server.mjs +84 -0
  82. package/scripts/openvoice_smoke.py +34 -0
  83. package/scripts/openvoice_synth.py +103 -0
  84. package/scripts/setup_openvoice.sh +34 -0
  85. package/scripts/setup_supertonic.sh +18 -0
@@ -0,0 +1,32 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { sendDiscordText, splitDiscordMessage } from './discord_text.mjs';
5
+
6
+ test('splitDiscordMessage chunks long text for Discord', () => {
7
+ const chunks = splitDiscordMessage('x'.repeat(4001), 1900);
8
+ assert.deepEqual(chunks.map(c => c.length), [1900, 1900, 201]);
9
+ });
10
+
11
+ test('sendDiscordText returns false when target is not text based', async () => {
12
+ const warnings = [];
13
+ const delivered = await sendDiscordText({
14
+ channelId: '123',
15
+ text: 'final answer',
16
+ client: { channels: { fetch: async () => ({ isTextBased: () => false }) } },
17
+ warn: (...args) => warnings.push(args.join(' ')),
18
+ });
19
+ assert.equal(delivered, false);
20
+ assert.match(warnings.join('\n'), /not text based/);
21
+ });
22
+
23
+ test('sendDiscordText sends every chunk and returns true', async () => {
24
+ const sent = [];
25
+ const delivered = await sendDiscordText({
26
+ channelId: '123',
27
+ text: 'abcdef',
28
+ client: { channels: { fetch: async () => ({ isTextBased: () => true, send: async chunk => sent.push(chunk) }) } },
29
+ });
30
+ assert.equal(delivered, true);
31
+ assert.deepEqual(sent, ['abcdef']);
32
+ });
@@ -0,0 +1,164 @@
1
+ export class InvalidProfileName extends Error {
2
+ constructor(name) {
3
+ super(`invalid Hermes profile name ${JSON.stringify(name)}: must match ^[a-z0-9][a-z0-9_-]{0,63}$`);
4
+ this.name = 'InvalidProfileName';
5
+ }
6
+ }
7
+
8
+ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
9
+
10
+ export function validateProfileName(name) {
11
+ if (typeof name !== 'string' || !PROFILE_NAME_RE.test(name)) {
12
+ throw new InvalidProfileName(name);
13
+ }
14
+ return name;
15
+ }
16
+
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ function defaultDeps(deps = {}) {
22
+ return {
23
+ homedir: deps.homedir || os.homedir,
24
+ fs: deps.fs || fs,
25
+ env: deps.env || process.env,
26
+ };
27
+ }
28
+
29
+ export function hermesProfilesRoot(deps = {}) {
30
+ const { homedir, env } = defaultDeps(deps);
31
+ if (env && env.HERMES_PROFILES_ROOT) return env.HERMES_PROFILES_ROOT;
32
+ return path.join(homedir(), '.hermes', 'profiles');
33
+ }
34
+
35
+ export function hermesProfileDir(name, deps = {}) {
36
+ validateProfileName(name);
37
+ return path.join(hermesProfilesRoot(deps), name);
38
+ }
39
+
40
+ export function profileExists(name, deps = {}) {
41
+ const { fs: fsDep } = defaultDeps(deps);
42
+ const dir = hermesProfileDir(name, deps);
43
+ return fsDep.existsSync(path.join(dir, 'config.yaml'));
44
+ }
45
+
46
+ import { execFile as nodeRunner } from 'node:child_process';
47
+ import { promisify } from 'node:util';
48
+
49
+ export class HermesCliMissing extends Error {
50
+ constructor() {
51
+ super('hermes CLI not found on PATH; install Hermes (>= 0.6.0) and re-run `vc instance setup`');
52
+ this.name = 'HermesCliMissing';
53
+ }
54
+ }
55
+
56
+ function resolveRunner(deps = {}) {
57
+ const raw = deps.execFile || nodeRunner;
58
+ return promisify(raw);
59
+ }
60
+
61
+ export async function assertHermesAvailable(deps = {}) {
62
+ const run = resolveRunner(deps);
63
+ try {
64
+ await run('hermes', ['--version'], { timeout: 5000 });
65
+ } catch (err) {
66
+ if (err && err.code === 'ENOENT') throw new HermesCliMissing();
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ async function runHermes(run, args, { extraEnv } = {}) {
72
+ const env = { ...process.env, ...(extraEnv || {}) };
73
+ return run('hermes', args, { env, timeout: 60000 });
74
+ }
75
+
76
+ export class ProfileBoundElsewhere extends Error {
77
+ constructor(name, expected, actual) {
78
+ super(`Hermes profile ${name} already binds terminal.cwd to ${actual}; expected ${expected}. Pick a different instance name or rebind with hermes config set terminal.cwd in that profile.`);
79
+ this.name = 'ProfileBoundElsewhere';
80
+ this.expected = expected;
81
+ this.actual = actual;
82
+ }
83
+ }
84
+
85
+ export class ProfileConfigFailed extends Error {
86
+ constructor(stderr) {
87
+ super(`hermes config set terminal.cwd failed: ${stderr}`);
88
+ this.name = 'ProfileConfigFailed';
89
+ }
90
+ }
91
+
92
+ async function readTerminalCwd(run, dir) {
93
+ try {
94
+ const out = await run('hermes', ['config', 'get', 'terminal.cwd'], { env: { ...process.env, HERMES_HOME: dir }, timeout: 10000 });
95
+ return String(out.stdout || '').trim();
96
+ } catch {
97
+ return '';
98
+ }
99
+ }
100
+
101
+ export async function ensureHermesProfile({ name, workdir, projectContext, cloneFrom = 'default', deps = {} } = {}) {
102
+ validateProfileName(name);
103
+ const { fs: fsDep, homedir, env } = defaultDeps(deps);
104
+ const run = resolveRunner(deps);
105
+ const dir = hermesProfileDir(name, { homedir, env });
106
+ const warnings = [];
107
+
108
+ if (profileExists(name, { homedir, env, fs: fsDep })) {
109
+ const actualCwd = await readTerminalCwd(run, dir);
110
+ if (actualCwd && actualCwd !== workdir) {
111
+ throw new ProfileBoundElsewhere(name, workdir, actualCwd);
112
+ }
113
+ if (projectContext) applyProjectContextToSoul(path.join(dir, 'SOUL.md'), String(projectContext), fsDep);
114
+ return { created: false, dir, name, configPath: path.join(dir, 'config.yaml'), updatedConfig: false, warnings };
115
+ }
116
+
117
+ await assertHermesAvailable({ execFile: run });
118
+
119
+ try {
120
+ await runHermes(run, ['profile', 'create', name, '--clone-from', cloneFrom]);
121
+ } catch (err) {
122
+ if (cloneFrom === 'default') {
123
+ warnings.push(`hermes profile create --clone-from default failed (${err.message || err.code}); retrying without clone`);
124
+ await runHermes(run, ['profile', 'create', name]);
125
+ } else {
126
+ throw err;
127
+ }
128
+ }
129
+
130
+ try {
131
+ await runHermes(run, ['config', 'set', 'terminal.cwd', workdir], { extraEnv: { HERMES_HOME: dir } });
132
+ } catch (err) {
133
+ throw new ProfileConfigFailed(String(err.stderr || err.message || err.code));
134
+ }
135
+
136
+ const soulPath = path.join(dir, 'SOUL.md');
137
+ if (projectContext) applyProjectContextToSoul(soulPath, String(projectContext), fsDep);
138
+
139
+ return { created: true, dir, name, configPath: path.join(dir, 'config.yaml'), updatedConfig: true, warnings };
140
+ }
141
+
142
+ export const VC_SOUL_MARKER_START = '<!-- vc:project-context:start -->';
143
+ export const VC_SOUL_MARKER_END = '<!-- vc:project-context:end -->';
144
+
145
+ export function applyProjectContextToSoul(soulPath, projectContext, fsDep = fs) {
146
+ const trimmed = String(projectContext || '').trim();
147
+ if (!trimmed) return;
148
+ const block = `${VC_SOUL_MARKER_START}\n## Project context\n\n${trimmed}\n${VC_SOUL_MARKER_END}`;
149
+ let body;
150
+ if (fsDep.existsSync(soulPath)) {
151
+ const existing = fsDep.readFileSync(soulPath, 'utf8');
152
+ const re = new RegExp(`${escapeRegExp(VC_SOUL_MARKER_START)}[\\s\\S]*?${escapeRegExp(VC_SOUL_MARKER_END)}`);
153
+ body = re.test(existing)
154
+ ? existing.replace(re, block)
155
+ : `${existing.replace(/\s*$/, '')}\n\n${block}\n`;
156
+ } else {
157
+ body = `${block}\n`;
158
+ }
159
+ fsDep.writeFileSync(soulPath, body);
160
+ }
161
+
162
+ function escapeRegExp(s) {
163
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
164
+ }
@@ -0,0 +1,276 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { validateProfileName, InvalidProfileName } from './hermes_profiles.mjs';
4
+
5
+ test('validateProfileName accepts canonical names', () => {
6
+ for (const name of ['acme', 'llm-wiki', 'verbalcoding', 'a', 'a1_b-c']) {
7
+ validateProfileName(name);
8
+ }
9
+ });
10
+
11
+ test('validateProfileName rejects invalid names', () => {
12
+ const bad = ['', 'Acme', 'llm.wiki', 'has space', '_leading', '-leading', '한글', 'a'.repeat(65)];
13
+ for (const name of bad) {
14
+ assert.throws(() => validateProfileName(name), InvalidProfileName, `expected ${JSON.stringify(name)} to throw`);
15
+ }
16
+ });
17
+
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ import { hermesProfilesRoot, hermesProfileDir, profileExists } from './hermes_profiles.mjs';
23
+
24
+ function tempHome() {
25
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'vc-hermes-home-'));
26
+ }
27
+
28
+ test('hermesProfilesRoot resolves under HOME', () => {
29
+ const home = '/tmp/fake-home';
30
+ assert.equal(hermesProfilesRoot({ homedir: () => home }), '/tmp/fake-home/.hermes/profiles');
31
+ });
32
+
33
+ test('hermesProfileDir joins root and validated name', () => {
34
+ const deps = { homedir: () => '/tmp/fake-home' };
35
+ assert.equal(hermesProfileDir('llm-wiki', deps), '/tmp/fake-home/.hermes/profiles/llm-wiki');
36
+ });
37
+
38
+ test('hermesProfileDir throws on invalid name', () => {
39
+ const deps = { homedir: () => '/tmp/fake-home' };
40
+ assert.throws(() => hermesProfileDir('Bad Name', deps), { name: 'InvalidProfileName' });
41
+ });
42
+
43
+ test('profileExists returns false when config.yaml missing', () => {
44
+ const home = tempHome();
45
+ const deps = { homedir: () => home };
46
+ assert.equal(profileExists('llm-wiki', deps), false);
47
+ });
48
+
49
+ test('profileExists returns true when config.yaml present', () => {
50
+ const home = tempHome();
51
+ const dir = path.join(home, '.hermes', 'profiles', 'llm-wiki');
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ fs.writeFileSync(path.join(dir, 'config.yaml'), 'terminal:\n cwd: /tmp\n');
54
+ const deps = { homedir: () => home };
55
+ assert.equal(profileExists('llm-wiki', deps), true);
56
+ });
57
+
58
+ import { assertHermesAvailable, HermesCliMissing } from './hermes_profiles.mjs';
59
+
60
+ function fakeRunnerOk() {
61
+ return (cmd, args, opts, cb) => cb(null, { stdout: 'hermes 0.6.1\n', stderr: '' });
62
+ }
63
+
64
+ function fakeRunnerEnoent() {
65
+ return (cmd, args, opts, cb) => {
66
+ const err = Object.assign(new Error('not found'), { code: 'ENOENT' });
67
+ cb(err);
68
+ };
69
+ }
70
+
71
+ test('assertHermesAvailable resolves when hermes --version succeeds', async () => {
72
+ await assertHermesAvailable({ execFile: fakeRunnerOk() });
73
+ });
74
+
75
+ test('assertHermesAvailable throws HermesCliMissing on ENOENT', async () => {
76
+ await assert.rejects(
77
+ () => assertHermesAvailable({ execFile: fakeRunnerEnoent() }),
78
+ HermesCliMissing,
79
+ );
80
+ });
81
+
82
+ import { ensureHermesProfile } from './hermes_profiles.mjs';
83
+
84
+ function recordingRunner(handlers = {}) {
85
+ const calls = [];
86
+ const fn = (cmd, args, opts, cb) => {
87
+ calls.push({ cmd, args, opts: opts || {} });
88
+ const key = `${cmd} ${args.join(' ')}`;
89
+ const handler = handlers[key];
90
+ if (handler) return handler(cb);
91
+ cb(null, { stdout: '', stderr: '' });
92
+ };
93
+ fn.calls = calls;
94
+ return fn;
95
+ }
96
+
97
+ test('ensureHermesProfile creates a missing profile', async () => {
98
+ const home = tempHome();
99
+ const dir = path.join(home, '.hermes', 'profiles', 'llm-wiki');
100
+ const runner = recordingRunner({
101
+ 'hermes --version': cb => cb(null, { stdout: 'hermes 0.6.1\n', stderr: '' }),
102
+ 'hermes profile create llm-wiki --clone-from default': cb => {
103
+ fs.mkdirSync(dir, { recursive: true });
104
+ fs.writeFileSync(path.join(dir, 'config.yaml'), 'terminal:\n cwd: /tmp/old\n');
105
+ cb(null, { stdout: '', stderr: '' });
106
+ },
107
+ 'hermes config set terminal.cwd /workdir/llm-wiki': cb => {
108
+ fs.writeFileSync(path.join(dir, 'config.yaml'), 'terminal:\n cwd: /workdir/llm-wiki\n');
109
+ cb(null, { stdout: '', stderr: '' });
110
+ },
111
+ 'hermes config get terminal.cwd': cb => cb(null, { stdout: '/workdir/llm-wiki\n', stderr: '' }),
112
+ });
113
+ const result = await ensureHermesProfile({
114
+ name: 'llm-wiki',
115
+ workdir: '/workdir/llm-wiki',
116
+ projectContext: 'LLM-Wiki backend agent',
117
+ deps: { execFile: runner, homedir: () => home, fs },
118
+ });
119
+ assert.equal(result.created, true);
120
+ assert.equal(result.dir, dir);
121
+ assert.equal(result.name, 'llm-wiki');
122
+ assert.equal(fs.existsSync(path.join(dir, 'SOUL.md')), true);
123
+ const soul = fs.readFileSync(path.join(dir, 'SOUL.md'), 'utf8');
124
+ assert.match(soul, /<!-- vc:project-context:start -->/);
125
+ assert.match(soul, /LLM-Wiki backend agent/);
126
+ assert.match(soul, /<!-- vc:project-context:end -->/);
127
+ const setCall = runner.calls.find(c => c.args[0] === 'config' && c.args[1] === 'set');
128
+ assert.equal(setCall.opts.env.HERMES_HOME, dir);
129
+ });
130
+
131
+ test('ensureHermesProfile reuses an existing profile with matching cwd', async () => {
132
+ const home = tempHome();
133
+ const dir = path.join(home, '.hermes', 'profiles', 'llm-wiki');
134
+ fs.mkdirSync(dir, { recursive: true });
135
+ fs.writeFileSync(path.join(dir, 'config.yaml'), 'terminal:\n cwd: /workdir/llm-wiki\n');
136
+
137
+ const runner = recordingRunner({
138
+ 'hermes config get terminal.cwd': cb => cb(null, { stdout: '/workdir/llm-wiki\n', stderr: '' }),
139
+ });
140
+ const result = await ensureHermesProfile({
141
+ name: 'llm-wiki',
142
+ workdir: '/workdir/llm-wiki',
143
+ projectContext: 'unused',
144
+ deps: { execFile: runner, homedir: () => home, fs },
145
+ });
146
+ assert.equal(result.created, false);
147
+ assert.equal(result.updatedConfig, false);
148
+ assert.equal(runner.calls.some(c => c.args[0] === 'profile' && c.args[1] === 'create'), false);
149
+ });
150
+
151
+ test('ensureHermesProfile throws ProfileBoundElsewhere on cwd mismatch', async () => {
152
+ const home = tempHome();
153
+ const dir = path.join(home, '.hermes', 'profiles', 'llm-wiki');
154
+ fs.mkdirSync(dir, { recursive: true });
155
+ fs.writeFileSync(path.join(dir, 'config.yaml'), 'terminal:\n cwd: /elsewhere\n');
156
+
157
+ const runner = recordingRunner({
158
+ 'hermes config get terminal.cwd': cb => cb(null, { stdout: '/elsewhere\n', stderr: '' }),
159
+ });
160
+ await assert.rejects(
161
+ () => ensureHermesProfile({
162
+ name: 'llm-wiki',
163
+ workdir: '/workdir/llm-wiki',
164
+ projectContext: '',
165
+ deps: { execFile: runner, homedir: () => home, fs },
166
+ }),
167
+ { name: 'ProfileBoundElsewhere', expected: '/workdir/llm-wiki', actual: '/elsewhere' },
168
+ );
169
+ });
170
+
171
+ test('ensureHermesProfile falls back to plain create when clone fails', async () => {
172
+ const home = tempHome();
173
+ const dir = path.join(home, '.hermes', 'profiles', 'fresh-app');
174
+ let cloneAttempted = false;
175
+ let plainCreateAttempted = false;
176
+
177
+ const runner = recordingRunner({
178
+ 'hermes --version': cb => cb(null, { stdout: 'hermes 0.6.1\n', stderr: '' }),
179
+ 'hermes profile create fresh-app --clone-from default': cb => {
180
+ cloneAttempted = true;
181
+ cb(Object.assign(new Error('no default profile'), { stderr: 'no default profile' }));
182
+ },
183
+ 'hermes profile create fresh-app': cb => {
184
+ plainCreateAttempted = true;
185
+ fs.mkdirSync(dir, { recursive: true });
186
+ fs.writeFileSync(path.join(dir, 'config.yaml'), 'terminal:\n cwd: ""\n');
187
+ cb(null, { stdout: '', stderr: '' });
188
+ },
189
+ 'hermes config set terminal.cwd /workdir/fresh-app': cb => {
190
+ fs.writeFileSync(path.join(dir, 'config.yaml'), 'terminal:\n cwd: /workdir/fresh-app\n');
191
+ cb(null, { stdout: '', stderr: '' });
192
+ },
193
+ });
194
+
195
+ const result = await ensureHermesProfile({
196
+ name: 'fresh-app',
197
+ workdir: '/workdir/fresh-app',
198
+ projectContext: '',
199
+ deps: { execFile: runner, homedir: () => home, fs },
200
+ });
201
+ assert.equal(cloneAttempted, true);
202
+ assert.equal(plainCreateAttempted, true);
203
+ assert.equal(result.created, true);
204
+ assert.match(result.warnings[0], /clone-from default failed/);
205
+ });
206
+
207
+ import { applyProjectContextToSoul, VC_SOUL_MARKER_START, VC_SOUL_MARKER_END } from './hermes_profiles.mjs';
208
+
209
+ test('applyProjectContextToSoul appends a marker block to existing SOUL.md', () => {
210
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
211
+ const soulPath = path.join(tmp, 'SOUL.md');
212
+ const persona = 'You are Hermes Agent, an intelligent AI assistant.';
213
+ fs.writeFileSync(soulPath, persona);
214
+ applyProjectContextToSoul(soulPath, 'LLM-Wiki backend agent for the wiki repo.');
215
+ const out = fs.readFileSync(soulPath, 'utf8');
216
+ assert.ok(out.startsWith(persona), 'cloned persona must be preserved');
217
+ assert.match(out, new RegExp(VC_SOUL_MARKER_START));
218
+ assert.match(out, /## Project context/);
219
+ assert.match(out, /LLM-Wiki backend agent for the wiki repo\./);
220
+ assert.match(out, new RegExp(VC_SOUL_MARKER_END));
221
+ });
222
+
223
+ test('applyProjectContextToSoul updates an existing marker block in place (idempotent)', () => {
224
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
225
+ const soulPath = path.join(tmp, 'SOUL.md');
226
+ fs.writeFileSync(soulPath, 'Persona text.');
227
+ applyProjectContextToSoul(soulPath, 'first context');
228
+ applyProjectContextToSoul(soulPath, 'second context');
229
+ const out = fs.readFileSync(soulPath, 'utf8');
230
+ const startCount = (out.match(/<!-- vc:project-context:start -->/g) || []).length;
231
+ const endCount = (out.match(/<!-- vc:project-context:end -->/g) || []).length;
232
+ assert.equal(startCount, 1, 'must not duplicate marker block on re-apply');
233
+ assert.equal(endCount, 1);
234
+ assert.match(out, /second context/);
235
+ assert.doesNotMatch(out, /first context/);
236
+ });
237
+
238
+ test('applyProjectContextToSoul writes a fresh SOUL.md when none exists', () => {
239
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
240
+ const soulPath = path.join(tmp, 'SOUL.md');
241
+ applyProjectContextToSoul(soulPath, 'fresh project context');
242
+ const out = fs.readFileSync(soulPath, 'utf8');
243
+ assert.match(out, /fresh project context/);
244
+ assert.match(out, new RegExp(VC_SOUL_MARKER_START));
245
+ assert.match(out, new RegExp(VC_SOUL_MARKER_END));
246
+ });
247
+
248
+ test('applyProjectContextToSoul is a no-op when projectContext is empty', () => {
249
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-soul-'));
250
+ const soulPath = path.join(tmp, 'SOUL.md');
251
+ fs.writeFileSync(soulPath, 'persona');
252
+ applyProjectContextToSoul(soulPath, ' ');
253
+ assert.equal(fs.readFileSync(soulPath, 'utf8'), 'persona');
254
+ });
255
+
256
+ test('ensureHermesProfile refreshes SOUL.md on reuse with new projectContext', async () => {
257
+ const home = tempHome();
258
+ const dir = path.join(home, '.hermes', 'profiles', 'llm-wiki');
259
+ fs.mkdirSync(dir, { recursive: true });
260
+ fs.writeFileSync(path.join(dir, 'config.yaml'), 'terminal:\n cwd: /workdir/llm-wiki\n');
261
+ fs.writeFileSync(path.join(dir, 'SOUL.md'), 'persona\n\n<!-- vc:project-context:start -->\n## Project context\n\nold context\n<!-- vc:project-context:end -->\n');
262
+
263
+ const runner = recordingRunner({
264
+ 'hermes config get terminal.cwd': cb => cb(null, { stdout: '/workdir/llm-wiki\n', stderr: '' }),
265
+ });
266
+ await ensureHermesProfile({
267
+ name: 'llm-wiki',
268
+ workdir: '/workdir/llm-wiki',
269
+ projectContext: 'fresh context',
270
+ deps: { execFile: runner, homedir: () => home, fs },
271
+ });
272
+ const soul = fs.readFileSync(path.join(dir, 'SOUL.md'), 'utf8');
273
+ assert.match(soul, /fresh context/);
274
+ assert.doesNotMatch(soul, /old context/);
275
+ assert.equal((soul.match(/<!-- vc:project-context:start -->/g) || []).length, 1);
276
+ });