@holdyourvoice/hyv 3.0.1 → 3.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.
- package/Readme.md +19 -4
- package/dist/cli.js +49 -11
- package/dist/cli.test.js +41 -11
- package/dist/learning.js +211 -0
- package/dist/learning.test.js +125 -0
- package/dist/mcp-tools.js +8 -4
- package/dist/mcp-tools.test.js +15 -4
- package/dist/mcp.js +3 -3
- package/dist/mcp.test.js +41 -3
- package/dist/pipeline.js +5 -1
- package/dist/pipeline.test.js +11 -0
- package/dist/release-audit.test.js +16 -0
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -55,7 +55,7 @@ To contribute, clone this repository, run `npm install`, then run `npm test` and
|
|
|
55
55
|
|
|
56
56
|
### Use it in Claude Desktop
|
|
57
57
|
|
|
58
|
-
Build the fully local Claude Desktop extension with `npm run pack:claude`, then install `dist/hold-your-voice.mcpb` from **Settings → Extensions → Advanced settings → Install Extension**. The extension accepts text and portable profile JSON in the current conversation only.
|
|
58
|
+
Build the fully local Claude Desktop extension with `npm run pack:claude`, then install `dist/hold-your-voice.mcpb` from **Settings → Extensions → Advanced settings → Install Extension**. The extension accepts text and portable profile JSON in the current conversation only. A successful verification records resolved finding IDs in local learning state; it never retains writing text or makes network requests. See the [Claude Desktop guide](docs/CLAUDE-DESKTOP.md).
|
|
59
59
|
|
|
60
60
|
### Build a local VoiceDNA profile
|
|
61
61
|
|
|
@@ -97,7 +97,19 @@ Give the brief and draft to a human editor or any model you trust. This reposito
|
|
|
97
97
|
npx @holdyourvoice/hyv verify draft.md candidate.md profile.json
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
`verify` returns the original and candidate reports, identifies newly introduced findings, calculates a coarse preservation score, and exits with status `2` when the candidate fails the dual gate. It exits with `1` for a usage or runtime error. Treat status `2` as a release signal in scripts or CI.
|
|
100
|
+
`verify` returns the original and candidate reports, identifies newly introduced findings, calculates a coarse preservation score, and exits with status `2` when the candidate fails the dual gate. A passing verification automatically records only the resolved finding IDs for that profile in local learning state. It exits with `1` for a usage or runtime error. Treat status `2` as a release signal in scripts or CI.
|
|
101
|
+
|
|
102
|
+
### Local voice memory
|
|
103
|
+
|
|
104
|
+
Learning is on by default. After a successful `verify`, Hold Your Voice records resolved rule IDs under `~/.hyv/learning/`, scoped to a fingerprint of the portable profile. It stores no draft or candidate text. The next `rewrite-prompt` uses a bounded list of those verified repairs.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
hyv learning show profile.json
|
|
108
|
+
hyv learning add profile.json "Keep the direct opening."
|
|
109
|
+
hyv learning clear profile.json
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`show` lets you inspect the exact local preferences. `clear` removes only that profile's learning file. Set `HYV_HOME` to place this local state elsewhere.
|
|
101
113
|
|
|
102
114
|
## The editing loop
|
|
103
115
|
|
|
@@ -187,6 +199,7 @@ The preservation score is a guardrail based on retained original words longer th
|
|
|
187
199
|
| `hyv analyze <draft> <profile.json>` | Draft and profile | Analysis JSON | You need both reports before editing. |
|
|
188
200
|
| `hyv rewrite-prompt <draft> <profile.json>` | Draft and profile | Markdown editing brief | You need a constrained request for an editor or model. |
|
|
189
201
|
| `hyv verify <original> <candidate> <profile.json>` | Original, candidate, profile | Verification JSON and exit code | You need the candidate gate. |
|
|
202
|
+
| `hyv learning <show\|add\|clear> <profile.json>` | Profile and optional instruction | Local learning JSON | You need to inspect or manage profile-scoped learning. |
|
|
190
203
|
| `hyv patterns` | None | Ruleset JSON | You need the exact enabled rules. |
|
|
191
204
|
|
|
192
205
|
Every file argument can be `-` when the command accepts text input from standard input. Profile output is always written to the path you give it. Use `npx @holdyourvoice/hyv <command>` in place of `hyv <command>` when you have not installed the CLI globally.
|
|
@@ -199,6 +212,7 @@ Every file argument can be `-` when the command accepts text input from standard
|
|
|
199
212
|
| `src/text.ts` | Sentence, paragraph, word, and basic statistics helpers. |
|
|
200
213
|
| `src/voice-dna.ts` | Builds profiles and runs VoiceDNA checks. |
|
|
201
214
|
| `src/ai-editor.ts` | Owns the versioned deterministic editorial rules. |
|
|
215
|
+
| `src/learning.ts` | Stores text-free, profile-scoped verified repairs and composes bounded local preferences. |
|
|
202
216
|
| `src/pipeline.ts` | Combines pass states, makes briefs, and verifies candidates. |
|
|
203
217
|
| `src/cli.ts` | Local file and standard-input command adapter. |
|
|
204
218
|
| `src/pipeline.test.ts` | Contract and regression tests. |
|
|
@@ -209,9 +223,9 @@ Every file argument can be `-` when the command accepts text input from standard
|
|
|
209
223
|
|
|
210
224
|
## Privacy and data rights
|
|
211
225
|
|
|
212
|
-
The runtime uses files on your machine. Samples, drafts, profiles, candidates,
|
|
226
|
+
The runtime uses files on your machine. Samples, drafts, profiles, candidates, and client data stay there. Successful verification writes a text-free local learning event under `~/.hyv/learning/`: profile fingerprint, finding IDs, severities, counts, timestamp, and an opaque one-way candidate digest for retry deduplication. An instruction added through `hyv learning add` is stored as entered.
|
|
213
227
|
|
|
214
|
-
Keep writing samples, edit histories, client text,
|
|
228
|
+
The package does not upload writing, use embeddings, or make runtime network requests. Keep writing samples, edit histories, client text, local learning files, and datasets out of public commits unless you hold explicit rights and a provenance record. A profile is aggregated JSON and can still reveal vocabulary and preferences. Store private profiles outside public repositories.
|
|
215
229
|
|
|
216
230
|
See the [privacy guide](https://github.com/shashank-sn/holdyourvoice/wiki/Privacy-and-Data-Rights) for maintainer and contributor boundaries.
|
|
217
231
|
|
|
@@ -228,6 +242,7 @@ Treat those as dated reference material. A reproducible benchmark needs rights-c
|
|
|
228
242
|
| [The complete Wiki](https://github.com/shashank-sn/holdyourvoice/wiki) | Product, workflow, and contributor documentation. |
|
|
229
243
|
| [Thesis](docs/THESIS.md) | The design argument for two independent engines. |
|
|
230
244
|
| [Architecture](docs/ARCHITECTURE.md) | Source boundaries and extension rules. |
|
|
245
|
+
| [Local voice memory](docs/wiki/Local-Voice-Memory.md) | What default local learning stores, uses, and never changes. |
|
|
231
246
|
| [Prompt contract](docs/PROMPT-CONTRACT.md) | The tier order and editing constraints. |
|
|
232
247
|
| [Pattern taxonomy](docs/PATTERN-TAXONOMY.md) | The catalog/executable-rule boundary. |
|
|
233
248
|
| [Support](SUPPORT.md) | Funding without a feature gate. |
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { rules, RULESET_VERSION } from './ai-editor.js';
|
|
4
|
+
import { addLearningInstruction, clearLearning, composeLearning, profileFingerprint, recordVerifiedCandidate } from './learning.js';
|
|
4
5
|
import { analyze, rewritePrompt, verify } from './pipeline.js';
|
|
5
6
|
import { parseProfile } from './profile.js';
|
|
6
7
|
import { buildProfile } from './voice-dna.js';
|
|
7
|
-
const usage = 'Commands: profile, analyze, rewrite-prompt, verify, patterns';
|
|
8
|
+
const usage = 'Commands: profile, analyze, rewrite-prompt, verify, learning, patterns, mcp';
|
|
8
9
|
function input(path) {
|
|
9
10
|
return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
|
|
10
11
|
}
|
|
@@ -33,7 +34,7 @@ function profileArguments(args) {
|
|
|
33
34
|
throw new Error('Usage: hyv profile profile.json sample-a.md sample-b.md [sample-c.md] [--avoid=phrase]');
|
|
34
35
|
return { output, samples, avoid };
|
|
35
36
|
}
|
|
36
|
-
export function runCli(args) {
|
|
37
|
+
export async function runCli(args) {
|
|
37
38
|
const [command, ...rest] = args;
|
|
38
39
|
if (command === 'profile') {
|
|
39
40
|
const { output, samples, avoid } = profileArguments(rest);
|
|
@@ -51,27 +52,64 @@ export function runCli(args) {
|
|
|
51
52
|
const [draft, profilePath] = rest;
|
|
52
53
|
if (!draft || !profilePath)
|
|
53
54
|
throw new Error('Usage: hyv rewrite-prompt draft.md profile.json');
|
|
54
|
-
|
|
55
|
+
const profile = readProfile(profilePath);
|
|
56
|
+
console.log(rewritePrompt(input(draft), profile, composeLearning(profile)));
|
|
55
57
|
return 0;
|
|
56
58
|
}
|
|
57
59
|
if (command === 'verify') {
|
|
58
60
|
const [original, candidate, profilePath] = rest;
|
|
59
61
|
if (!original || !candidate || !profilePath)
|
|
60
62
|
throw new Error('Usage: hyv verify original.md candidate.md profile.json');
|
|
61
|
-
const
|
|
63
|
+
const profile = readProfile(profilePath);
|
|
64
|
+
const originalText = input(original);
|
|
65
|
+
const candidateText = input(candidate);
|
|
66
|
+
const result = verify(originalText, candidateText, profile);
|
|
67
|
+
const learning = recordVerifiedCandidate(profile, result, candidateText);
|
|
68
|
+
if (learning === 'write_failed')
|
|
69
|
+
console.error('Warning: verification passed, but local learning could not be saved.');
|
|
62
70
|
json(result);
|
|
63
71
|
return result.passed ? 0 : 2;
|
|
64
72
|
}
|
|
73
|
+
if (command === 'learning') {
|
|
74
|
+
const [action, profilePath, ...instruction] = rest;
|
|
75
|
+
if (!action || !profilePath)
|
|
76
|
+
throw new Error('Usage: hyv learning <show|add|clear> profile.json [instruction]');
|
|
77
|
+
const profile = readProfile(profilePath);
|
|
78
|
+
if (action === 'show') {
|
|
79
|
+
json({ profile: profileFingerprint(profile), preferences: composeLearning(profile) });
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
if (action === 'add') {
|
|
83
|
+
const text = instruction.join(' ').trim();
|
|
84
|
+
if (!text)
|
|
85
|
+
throw new Error('Usage: hyv learning add profile.json "instruction"');
|
|
86
|
+
json({ added: addLearningInstruction(profile, text) });
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
if (action === 'clear') {
|
|
90
|
+
json({ cleared: clearLearning(profile) });
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
throw new Error('Usage: hyv learning <show|add|clear> profile.json [instruction]');
|
|
94
|
+
}
|
|
65
95
|
if (command === 'patterns') {
|
|
66
96
|
json({ version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) });
|
|
67
97
|
return 0;
|
|
68
98
|
}
|
|
99
|
+
if (command === 'mcp') {
|
|
100
|
+
if (rest.length > 0)
|
|
101
|
+
throw new Error('Usage: hyv mcp');
|
|
102
|
+
await import('./mcp.js');
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
69
105
|
throw new Error(`${usage}.`);
|
|
70
106
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
107
|
+
void (async () => {
|
|
108
|
+
try {
|
|
109
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
}
|
|
115
|
+
})();
|
package/dist/cli.test.js
CHANGED
|
@@ -5,8 +5,8 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
const cli = new URL('./cli.js', import.meta.url).pathname;
|
|
8
|
-
function run(
|
|
9
|
-
return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' });
|
|
8
|
+
function run(args, env = process.env) {
|
|
9
|
+
return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env });
|
|
10
10
|
}
|
|
11
11
|
test('creates an explicit local avoid list and exposes the ruleset', () => {
|
|
12
12
|
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
@@ -16,10 +16,10 @@ test('creates an explicit local avoid list and exposes the ruleset', () => {
|
|
|
16
16
|
const profile = join(directory, 'profile.json');
|
|
17
17
|
writeFileSync(first, 'i write plainly. i name the work.');
|
|
18
18
|
writeFileSync(second, 'i keep the mechanism clear. i avoid filler.');
|
|
19
|
-
const created = run('profile', profile, first, second, '--avoid=unlock');
|
|
19
|
+
const created = run(['profile', profile, first, second, '--avoid=unlock']);
|
|
20
20
|
assert.equal(created.status, 0, created.stderr);
|
|
21
21
|
assert.deepEqual(JSON.parse(readFileSync(profile, 'utf8')).avoid, ['unlock']);
|
|
22
|
-
const patterns = run('patterns');
|
|
22
|
+
const patterns = run(['patterns']);
|
|
23
23
|
assert.equal(patterns.status, 0, patterns.stderr);
|
|
24
24
|
assert.ok(JSON.parse(patterns.stdout).rules.every((rule) => rule.id && rule.severity && rule.reason && rule.suggestion));
|
|
25
25
|
}
|
|
@@ -39,11 +39,12 @@ test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
|
|
|
39
39
|
writeFileSync(second, 'i keep the mechanism clear. i avoid filler.');
|
|
40
40
|
writeFileSync(original, 'i name the work.');
|
|
41
41
|
writeFileSync(candidate, 'i unlock the answer.');
|
|
42
|
-
assert.equal(run('profile', profile, first, second, '--avoid=unlock').status, 0);
|
|
43
|
-
const verification = run('verify', original, candidate, profile);
|
|
42
|
+
assert.equal(run(['profile', profile, first, second, '--avoid=unlock']).status, 0);
|
|
43
|
+
const verification = run(['verify', original, candidate, profile]);
|
|
44
44
|
assert.equal(verification.status, 2);
|
|
45
45
|
assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'original', 'passed', 'preservationScore', 'regressions', 'version']);
|
|
46
|
-
assert.equal(run('unknown-command').status, 1);
|
|
46
|
+
assert.equal(run(['unknown-command']).status, 1);
|
|
47
|
+
assert.equal(run(['mcp', 'unexpected']).status, 1);
|
|
47
48
|
}
|
|
48
49
|
finally {
|
|
49
50
|
rmSync(directory, { recursive: true, force: true });
|
|
@@ -56,7 +57,7 @@ test('rejects a malformed hand-edited profile before analysis', () => {
|
|
|
56
57
|
const profile = join(directory, 'profile.json');
|
|
57
58
|
writeFileSync(draft, 'i name the work.');
|
|
58
59
|
writeFileSync(profile, JSON.stringify({ version: '2', sampleCount: 2, metrics: {}, avoid: [1] }));
|
|
59
|
-
const result = run('analyze', draft, profile);
|
|
60
|
+
const result = run(['analyze', draft, profile]);
|
|
60
61
|
assert.equal(result.status, 1);
|
|
61
62
|
assert.match(result.stderr, /not a valid Hold Your Voice version 2 profile/);
|
|
62
63
|
}
|
|
@@ -90,7 +91,7 @@ test('rejects malformed profile enum values and punctuation', () => {
|
|
|
90
91
|
},
|
|
91
92
|
avoid: [''],
|
|
92
93
|
}));
|
|
93
|
-
assert.equal(run('analyze', draft, profile).status, 1);
|
|
94
|
+
assert.equal(run(['analyze', draft, profile]).status, 1);
|
|
94
95
|
}
|
|
95
96
|
finally {
|
|
96
97
|
rmSync(directory, { recursive: true, force: true });
|
|
@@ -106,12 +107,41 @@ test('rejects hand-edited metrics outside their semantic bounds', () => {
|
|
|
106
107
|
writeFileSync(first, 'i write plainly.');
|
|
107
108
|
writeFileSync(second, 'i name the work.');
|
|
108
109
|
writeFileSync(draft, 'i name the work.');
|
|
109
|
-
assert.equal(run('profile', profile, first, second).status, 0);
|
|
110
|
+
assert.equal(run(['profile', profile, first, second]).status, 0);
|
|
110
111
|
const malformed = JSON.parse(readFileSync(profile, 'utf8'));
|
|
111
112
|
malformed.sampleCount = 2.5;
|
|
112
113
|
malformed.metrics.questionRate = 1.2;
|
|
113
114
|
writeFileSync(profile, JSON.stringify(malformed));
|
|
114
|
-
assert.equal(run('analyze', draft, profile).status, 1);
|
|
115
|
+
assert.equal(run(['analyze', draft, profile]).status, 1);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
rmSync(directory, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
test('learns from a successful local verification by default and exposes local controls', () => {
|
|
122
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
123
|
+
try {
|
|
124
|
+
const first = join(directory, 'first.md');
|
|
125
|
+
const second = join(directory, 'second.md');
|
|
126
|
+
const profile = join(directory, 'profile.json');
|
|
127
|
+
const original = join(directory, 'original.md');
|
|
128
|
+
const candidate = join(directory, 'candidate.md');
|
|
129
|
+
const env = { ...process.env, HYV_HOME: join(directory, 'state') };
|
|
130
|
+
writeFileSync(first, 'I write plainly. I name the work.');
|
|
131
|
+
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
132
|
+
writeFileSync(original, 'I leverage the answer with useful detail and clear mechanism.');
|
|
133
|
+
writeFileSync(candidate, 'I use the answer with useful detail and clear mechanism.');
|
|
134
|
+
assert.equal(run(['profile', profile, first, second, '--avoid=leverage'], env).status, 0);
|
|
135
|
+
assert.equal(run(['verify', original, candidate, profile], env).status, 0);
|
|
136
|
+
const brief = run(['rewrite-prompt', candidate, profile], env);
|
|
137
|
+
assert.equal(brief.status, 0, brief.stderr);
|
|
138
|
+
assert.match(brief.stdout, /Learned local preferences/);
|
|
139
|
+
assert.match(brief.stdout, /ai\\_editor\/ai\.leverage/);
|
|
140
|
+
const learned = run(['learning', 'show', profile], env);
|
|
141
|
+
assert.equal(learned.status, 0, learned.stderr);
|
|
142
|
+
assert.ok(JSON.parse(learned.stdout).preferences.some((item) => item.text.includes('ai_editor/ai.leverage')));
|
|
143
|
+
assert.equal(run(['learning', 'clear', profile], env).status, 0);
|
|
144
|
+
assert.deepEqual(JSON.parse(run(['learning', 'show', profile], env).stdout).preferences, []);
|
|
115
145
|
}
|
|
116
146
|
finally {
|
|
117
147
|
rmSync(directory, { recursive: true, force: true });
|
package/dist/learning.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const MAX_PREFERENCES = 10;
|
|
6
|
+
const MAX_EVENTS = 40;
|
|
7
|
+
const MAX_RESOLVED_FINDINGS = 20;
|
|
8
|
+
const MAX_INSTRUCTION_CHARACTERS = 240;
|
|
9
|
+
const MAX_COMPOSED_CHARACTERS = 1_200;
|
|
10
|
+
const MAX_STORAGE_BYTES = 64 * 1024;
|
|
11
|
+
function canonicalJson(value) {
|
|
12
|
+
if (Array.isArray(value))
|
|
13
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
14
|
+
if (value && typeof value === 'object') {
|
|
15
|
+
const record = value;
|
|
16
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
export function profileFingerprint(profile) {
|
|
21
|
+
return createHash('sha256').update(canonicalJson(profile)).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
function learningDirectory(options = {}) {
|
|
24
|
+
return join(options.root ?? process.env.HYV_HOME ?? join(homedir(), '.hyv'), 'learning');
|
|
25
|
+
}
|
|
26
|
+
function eventFile(profile, options = {}) {
|
|
27
|
+
return join(learningDirectory(options), `${profileFingerprint(profile)}.jsonl`);
|
|
28
|
+
}
|
|
29
|
+
function normalizeInstruction(instruction) {
|
|
30
|
+
return instruction.replace(/\s+/g, ' ').trim();
|
|
31
|
+
}
|
|
32
|
+
function isResolvedFinding(value) {
|
|
33
|
+
if (!value || typeof value !== 'object')
|
|
34
|
+
return false;
|
|
35
|
+
const finding = value;
|
|
36
|
+
return (finding.engine === 'voice_dna' || finding.engine === 'ai_editor')
|
|
37
|
+
&& typeof finding.id === 'string' && finding.id.length > 0
|
|
38
|
+
&& (finding.severity === 'red' || finding.severity === 'yellow')
|
|
39
|
+
&& Number.isInteger(finding.count) && (finding.count ?? 0) > 0;
|
|
40
|
+
}
|
|
41
|
+
function parseEvent(line) {
|
|
42
|
+
try {
|
|
43
|
+
const event = JSON.parse(line);
|
|
44
|
+
if (!event || event.version !== '1' || typeof event.timestamp !== 'string')
|
|
45
|
+
return undefined;
|
|
46
|
+
if (event.kind === 'instruction' && typeof event.instruction === 'string') {
|
|
47
|
+
const instruction = normalizeInstruction(event.instruction);
|
|
48
|
+
if (instruction.length > 0 && instruction.length <= MAX_INSTRUCTION_CHARACTERS)
|
|
49
|
+
return { version: '1', timestamp: event.timestamp, kind: 'instruction', instruction };
|
|
50
|
+
}
|
|
51
|
+
const resolved = event.resolved;
|
|
52
|
+
if (event.kind === 'verified_candidate' && Array.isArray(resolved) && resolved.length > 0 && resolved.every(isResolvedFinding) && typeof event.outcome === 'string') {
|
|
53
|
+
return { version: '1', timestamp: event.timestamp, kind: 'verified_candidate', resolved, outcome: event.outcome };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
function readRecentText(file) {
|
|
62
|
+
try {
|
|
63
|
+
const size = statSync(file).size;
|
|
64
|
+
const length = Math.min(size, MAX_STORAGE_BYTES);
|
|
65
|
+
const descriptor = openSync(file, 'r');
|
|
66
|
+
try {
|
|
67
|
+
const buffer = Buffer.alloc(length);
|
|
68
|
+
readSync(descriptor, buffer, 0, length, Math.max(0, size - length));
|
|
69
|
+
return buffer.toString('utf8');
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
closeSync(descriptor);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function readEvents(profile, options = {}) {
|
|
80
|
+
const file = eventFile(profile, options);
|
|
81
|
+
return readRecentText(file).split('\n').flatMap((line) => {
|
|
82
|
+
const event = parseEvent(line);
|
|
83
|
+
return event ? [event] : [];
|
|
84
|
+
}).slice(-MAX_EVENTS);
|
|
85
|
+
}
|
|
86
|
+
function serialize(events) {
|
|
87
|
+
return `${events.map((event) => JSON.stringify(event)).join('\n')}\n`;
|
|
88
|
+
}
|
|
89
|
+
function withCompactionLock(file, operation) {
|
|
90
|
+
const lock = `${file}.lock`;
|
|
91
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
92
|
+
try {
|
|
93
|
+
const descriptor = openSync(lock, 'wx', 0o600);
|
|
94
|
+
try {
|
|
95
|
+
return operation();
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
closeSync(descriptor);
|
|
99
|
+
unlinkSync(lock);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error.code !== 'EEXIST')
|
|
104
|
+
return undefined;
|
|
105
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
function appendEvent(profile, event, options = {}) {
|
|
111
|
+
try {
|
|
112
|
+
const directory = learningDirectory(options);
|
|
113
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
114
|
+
const file = eventFile(profile, options);
|
|
115
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
116
|
+
const events = readEvents(profile, options);
|
|
117
|
+
const needsCompaction = events.length >= MAX_EVENTS || (statSync(file, { throwIfNoEntry: false })?.size ?? 0) + Buffer.byteLength(line) > MAX_STORAGE_BYTES;
|
|
118
|
+
if (needsCompaction) {
|
|
119
|
+
const compacted = withCompactionLock(file, () => {
|
|
120
|
+
const retained = [...readEvents(profile, options), event].slice(-MAX_EVENTS);
|
|
121
|
+
while (Buffer.byteLength(serialize(retained)) > MAX_STORAGE_BYTES)
|
|
122
|
+
retained.shift();
|
|
123
|
+
writeFileSync(file, serialize(retained), { encoding: 'utf8', mode: 0o600 });
|
|
124
|
+
});
|
|
125
|
+
if (compacted === undefined)
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
appendFileSync(file, line, { encoding: 'utf8', mode: 0o600 });
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function countFindings(findings) {
|
|
138
|
+
const counted = new Map();
|
|
139
|
+
for (const finding of findings) {
|
|
140
|
+
const key = `${finding.engine}:${finding.id}:${finding.severity}`;
|
|
141
|
+
const entry = counted.get(key);
|
|
142
|
+
if (entry)
|
|
143
|
+
entry.count += 1;
|
|
144
|
+
else
|
|
145
|
+
counted.set(key, { finding, count: 1 });
|
|
146
|
+
}
|
|
147
|
+
return counted;
|
|
148
|
+
}
|
|
149
|
+
export function recordVerifiedCandidate(profile, verification, candidate, options = {}) {
|
|
150
|
+
if (!verification.passed)
|
|
151
|
+
return 'nothing_to_learn';
|
|
152
|
+
const original = countFindings([...verification.original.voiceDna.findings, ...verification.original.aiEditor.findings]);
|
|
153
|
+
const candidateFindings = countFindings([...verification.candidate.voiceDna.findings, ...verification.candidate.aiEditor.findings]);
|
|
154
|
+
const resolved = [...original].flatMap(([key, entry]) => {
|
|
155
|
+
const count = entry.count - (candidateFindings.get(key)?.count ?? 0);
|
|
156
|
+
return count > 0 ? [{ engine: entry.finding.engine, id: entry.finding.id, severity: entry.finding.severity, count }] : [];
|
|
157
|
+
});
|
|
158
|
+
if (!resolved.length)
|
|
159
|
+
return 'nothing_to_learn';
|
|
160
|
+
const bounded = resolved.slice(0, MAX_RESOLVED_FINDINGS);
|
|
161
|
+
const outcome = createHash('sha256').update(`${profileFingerprint(profile)}\0${candidate}`).digest('hex');
|
|
162
|
+
if (readEvents(profile, options).some((event) => event.kind === 'verified_candidate' && event.outcome === outcome))
|
|
163
|
+
return 'nothing_to_learn';
|
|
164
|
+
return appendEvent(profile, { version: '1', timestamp: new Date().toISOString(), kind: 'verified_candidate', resolved: bounded, outcome }, options) ? 'recorded' : 'write_failed';
|
|
165
|
+
}
|
|
166
|
+
export function addLearningInstruction(profile, instruction, options = {}) {
|
|
167
|
+
const trimmed = normalizeInstruction(instruction);
|
|
168
|
+
if (!trimmed)
|
|
169
|
+
throw new Error('Learning instructions cannot be empty.');
|
|
170
|
+
if (trimmed.length > MAX_INSTRUCTION_CHARACTERS)
|
|
171
|
+
throw new Error(`Learning instructions must be ${MAX_INSTRUCTION_CHARACTERS} characters or fewer.`);
|
|
172
|
+
return appendEvent(profile, { version: '1', timestamp: new Date().toISOString(), kind: 'instruction', instruction: trimmed }, options);
|
|
173
|
+
}
|
|
174
|
+
export function composeLearning(profile, options = {}) {
|
|
175
|
+
const preferences = new Map();
|
|
176
|
+
for (const event of readEvents(profile, options)) {
|
|
177
|
+
const texts = event.kind === 'instruction' && event.instruction
|
|
178
|
+
? [{ text: event.instruction, count: 1 }]
|
|
179
|
+
: (event.resolved ?? []).map((finding) => ({ text: `Previously verified repair: ${finding.engine}/${finding.id}.`, count: finding.count }));
|
|
180
|
+
for (const { text, count } of texts) {
|
|
181
|
+
const current = preferences.get(text) ?? { count: 0, lastSeen: event.timestamp };
|
|
182
|
+
current.count += count;
|
|
183
|
+
if (event.timestamp > current.lastSeen)
|
|
184
|
+
current.lastSeen = event.timestamp;
|
|
185
|
+
preferences.set(text, current);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const result = [];
|
|
189
|
+
let characters = 0;
|
|
190
|
+
for (const [text, value] of [...preferences.entries()]
|
|
191
|
+
.sort(([firstText, first], [secondText, second]) => second.count - first.count || second.lastSeen.localeCompare(first.lastSeen) || firstText.localeCompare(secondText))
|
|
192
|
+
.slice(0, MAX_PREFERENCES)) {
|
|
193
|
+
if (characters + text.length > MAX_COMPOSED_CHARACTERS)
|
|
194
|
+
break;
|
|
195
|
+
result.push({ text, count: value.count });
|
|
196
|
+
characters += text.length;
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
export function clearLearning(profile, options = {}) {
|
|
201
|
+
const file = eventFile(profile, options);
|
|
202
|
+
try {
|
|
203
|
+
unlinkSync(file);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error.code === 'ENOENT')
|
|
208
|
+
return false;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { addLearningInstruction, clearLearning, composeLearning, profileFingerprint, recordVerifiedCandidate } from './learning.js';
|
|
7
|
+
import { verify } from './pipeline.js';
|
|
8
|
+
import { buildProfile } from './voice-dna.js';
|
|
9
|
+
const profile = buildProfile([
|
|
10
|
+
'I write plainly. I name the work.',
|
|
11
|
+
'I keep the mechanism clear. I avoid filler.',
|
|
12
|
+
], ['leverage']);
|
|
13
|
+
function directory() {
|
|
14
|
+
return mkdtempSync(join(tmpdir(), 'holdyourvoice-learning-'));
|
|
15
|
+
}
|
|
16
|
+
test('records only resolved findings from successful verification without draft text', () => {
|
|
17
|
+
const root = directory();
|
|
18
|
+
try {
|
|
19
|
+
const original = 'I leverage the answer with useful detail and clear mechanism.';
|
|
20
|
+
const candidate = 'I use the answer with useful detail and clear mechanism.';
|
|
21
|
+
const result = verify(original, candidate, profile);
|
|
22
|
+
assert.equal(result.passed, true);
|
|
23
|
+
assert.equal(recordVerifiedCandidate(profile, result, candidate, { root }), 'recorded');
|
|
24
|
+
const eventFile = join(root, 'learning', `${profileFingerprint(profile)}.jsonl`);
|
|
25
|
+
const stored = readFileSync(eventFile, 'utf8');
|
|
26
|
+
assert.match(stored, /ai\.leverage/);
|
|
27
|
+
assert.doesNotMatch(stored, /I leverage the answer/);
|
|
28
|
+
assert.doesNotMatch(stored, /I use the answer/);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
rmSync(root, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
test('keeps learning isolated by profile and bounds composed preferences', () => {
|
|
35
|
+
const root = directory();
|
|
36
|
+
try {
|
|
37
|
+
const other = buildProfile(['We explain the mechanism.', 'We keep the useful detail.']);
|
|
38
|
+
for (let index = 0; index < 12; index += 1)
|
|
39
|
+
addLearningInstruction(profile, `Keep preference ${index}.`, { root });
|
|
40
|
+
addLearningInstruction(other, 'Use a different preference.', { root });
|
|
41
|
+
const composed = composeLearning(profile, { root });
|
|
42
|
+
assert.equal(composed.length, 10);
|
|
43
|
+
assert.ok(composed.every((item) => item.text.startsWith('Keep preference')));
|
|
44
|
+
assert.ok(!composed.some((item) => item.text === 'Use a different preference.'));
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
rmSync(root, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
test('does not create an event for a failed candidate and clears a single profile', () => {
|
|
51
|
+
const root = directory();
|
|
52
|
+
try {
|
|
53
|
+
const failed = verify('I name the work.', 'I leverage the work.', profile);
|
|
54
|
+
assert.equal(failed.passed, false);
|
|
55
|
+
assert.equal(recordVerifiedCandidate(profile, failed, 'I leverage the work.', { root }), 'nothing_to_learn');
|
|
56
|
+
assert.equal(existsSync(join(root, 'learning', `${profileFingerprint(profile)}.jsonl`)), false);
|
|
57
|
+
addLearningInstruction(profile, 'Keep it direct.', { root });
|
|
58
|
+
assert.equal(clearLearning(profile, { root }), true);
|
|
59
|
+
assert.deepEqual(composeLearning(profile, { root }), []);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
rmSync(root, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
test('bounds retained events and local instruction size', () => {
|
|
66
|
+
const root = directory();
|
|
67
|
+
try {
|
|
68
|
+
for (let index = 0; index < 45; index += 1)
|
|
69
|
+
addLearningInstruction(profile, `Keep preference ${index}.`, { root });
|
|
70
|
+
const preferences = composeLearning(profile, { root });
|
|
71
|
+
assert.ok(preferences.every((item) => Number(item.text.match(/\d+/)?.[0]) >= 5));
|
|
72
|
+
assert.throws(() => addLearningInstruction(profile, 'x'.repeat(241), { root }), /240 characters/);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
rmSync(root, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
test('skips malformed local events without breaking composition', () => {
|
|
79
|
+
const root = directory();
|
|
80
|
+
try {
|
|
81
|
+
addLearningInstruction(profile, 'Keep it direct.', { root });
|
|
82
|
+
appendFileSync(join(root, 'learning', `${profileFingerprint(profile)}.jsonl`), '{"version":"1","timestamp":"now","kind":"verified_candidate","resolved":{}}\n');
|
|
83
|
+
assert.deepEqual(composeLearning(profile, { root }), [{ text: 'Keep it direct.', count: 1 }]);
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
rmSync(root, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
test('does not raise confidence when the same verified outcome repeats', () => {
|
|
90
|
+
const root = directory();
|
|
91
|
+
try {
|
|
92
|
+
const original = 'I leverage the answer with useful detail and clear mechanism.';
|
|
93
|
+
const candidate = 'I use the answer with useful detail and clear mechanism.';
|
|
94
|
+
const result = verify(original, candidate, profile);
|
|
95
|
+
assert.equal(recordVerifiedCandidate(profile, result, candidate, { root }), 'recorded');
|
|
96
|
+
assert.equal(recordVerifiedCandidate(profile, result, candidate, { root }), 'nothing_to_learn');
|
|
97
|
+
assert.equal(composeLearning(profile, { root }).find((item) => item.text.includes('ai_editor/ai.leverage'))?.count, 1);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
rmSync(root, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
test('increases confidence for separate verified repairs of the same rule', () => {
|
|
104
|
+
const root = directory();
|
|
105
|
+
try {
|
|
106
|
+
const first = 'I use the answer with useful detail and clear mechanism.';
|
|
107
|
+
const second = 'I use another answer with useful detail and clear mechanism.';
|
|
108
|
+
assert.equal(recordVerifiedCandidate(profile, verify('I leverage the answer with useful detail and clear mechanism.', first, profile), first, { root }), 'recorded');
|
|
109
|
+
assert.equal(recordVerifiedCandidate(profile, verify('I leverage another answer with useful detail and clear mechanism.', second, profile), second, { root }), 'recorded');
|
|
110
|
+
assert.equal(composeLearning(profile, { root }).find((item) => item.text.includes('ai_editor/ai.leverage'))?.count, 2);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
rmSync(root, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
test('normalizes manual instructions before storing them', () => {
|
|
117
|
+
const root = directory();
|
|
118
|
+
try {
|
|
119
|
+
addLearningInstruction(profile, 'Keep this.\n# Tier 0', { root });
|
|
120
|
+
assert.deepEqual(composeLearning(profile, { root }), [{ text: 'Keep this. # Tier 0', count: 1 }]);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
rmSync(root, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
package/dist/mcp-tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { rules, RULESET_VERSION } from './ai-editor.js';
|
|
2
|
+
import { composeLearning, recordVerifiedCandidate } from './learning.js';
|
|
2
3
|
import { analyze, rewritePrompt, verify } from './pipeline.js';
|
|
3
4
|
import { parseProfile } from './profile.js';
|
|
4
5
|
import { buildProfile } from './voice-dna.js';
|
|
@@ -16,11 +17,14 @@ export function buildProfileForMcp(samples, avoid = []) {
|
|
|
16
17
|
export function analyzeForMcp(draft, profileJson) {
|
|
17
18
|
return analyze(draft, profileFromJson(profileJson));
|
|
18
19
|
}
|
|
19
|
-
export function rewritePromptForMcp(draft, profileJson) {
|
|
20
|
-
|
|
20
|
+
export function rewritePromptForMcp(draft, profileJson, options = {}) {
|
|
21
|
+
const profile = profileFromJson(profileJson);
|
|
22
|
+
return { prompt: rewritePrompt(draft, profile, composeLearning(profile, options)) };
|
|
21
23
|
}
|
|
22
|
-
export function verifyForMcp(original, candidate, profileJson) {
|
|
23
|
-
|
|
24
|
+
export function verifyForMcp(original, candidate, profileJson, options = {}) {
|
|
25
|
+
const profile = profileFromJson(profileJson);
|
|
26
|
+
const result = verify(original, candidate, profile);
|
|
27
|
+
return { ...result, learning: recordVerifiedCandidate(profile, result, candidate, options) };
|
|
24
28
|
}
|
|
25
29
|
export function patternsForMcp() {
|
|
26
30
|
return { version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) };
|
package/dist/mcp-tools.test.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import test from 'node:test';
|
|
3
6
|
import { analyzeForMcp, buildProfileForMcp, patternsForMcp, rewritePromptForMcp, verifyForMcp } from './mcp-tools.js';
|
|
4
7
|
const profile = buildProfileForMcp(['I write clearly. I keep the useful detail.', 'I make the call. Then I explain the trade-off.'], ['leverage']);
|
|
@@ -13,10 +16,18 @@ test('keeps the dual-engine analysis shape through MCP tools', () => {
|
|
|
13
16
|
assert.equal(result.aiEditor.engine, 'ai_editor');
|
|
14
17
|
});
|
|
15
18
|
test('creates and verifies an editing loop through MCP tools', () => {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-'));
|
|
20
|
+
try {
|
|
21
|
+
const result = verifyForMcp('I leverage the answer with useful detail and clear mechanism.', 'I use the answer with useful detail and clear mechanism.', profileJson, { root });
|
|
22
|
+
const brief = rewritePromptForMcp('I leverage a clear plan.', profileJson, { root });
|
|
23
|
+
assert.match(brief.prompt, /Tier 0/);
|
|
24
|
+
assert.match(brief.prompt, /Learned local preferences/);
|
|
25
|
+
assert.equal(result.preservationScore >= 70, true);
|
|
26
|
+
assert.equal(result.learning, 'recorded');
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
rmSync(root, { recursive: true, force: true });
|
|
30
|
+
}
|
|
20
31
|
});
|
|
21
32
|
test('exposes the executable pattern IDs through MCP tools', () => {
|
|
22
33
|
assert.ok(patternsForMcp().rules.some((rule) => rule.id === 'ai.leverage'));
|
package/dist/mcp.js
CHANGED
|
@@ -12,7 +12,7 @@ function json(value) {
|
|
|
12
12
|
function failure(error) {
|
|
13
13
|
return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
|
|
14
14
|
}
|
|
15
|
-
const server = new McpServer({ name: 'hold-your-voice', version: '3.0
|
|
15
|
+
const server = new McpServer({ name: 'hold-your-voice', version: '3.1.0' });
|
|
16
16
|
server.registerTool('hyv_build_profile', {
|
|
17
17
|
description: 'Build a portable VoiceDNA profile from at least two writing samples. The samples stay in memory and are not saved.',
|
|
18
18
|
inputSchema: { samples, avoid },
|
|
@@ -50,9 +50,9 @@ server.registerTool('hyv_rewrite_prompt', {
|
|
|
50
50
|
}
|
|
51
51
|
});
|
|
52
52
|
server.registerTool('hyv_verify', {
|
|
53
|
-
description: 'Verify a revised candidate against an original draft and portable profile.
|
|
53
|
+
description: 'Verify a revised candidate against an original draft and portable profile. On a successful check, it stores only resolved finding IDs in local profile-scoped learning state; it never retains either text.',
|
|
54
54
|
inputSchema: { original: writing, candidate: writing, profile_json: profileJson },
|
|
55
|
-
annotations: { readOnlyHint:
|
|
55
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
56
56
|
}, async ({ original, candidate, profile_json }) => {
|
|
57
57
|
try {
|
|
58
58
|
return json(verifyForMcp(original, candidate, profile_json));
|
package/dist/mcp.test.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { once } from 'node:events';
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
4
7
|
import test from 'node:test';
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
import { profileFingerprint } from './learning.js';
|
|
9
|
+
import { buildProfile } from './voice-dna.js';
|
|
10
|
+
test('serves local Claude tools over stdio', async () => {
|
|
11
|
+
const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
7
12
|
let stdout = '';
|
|
8
13
|
let stderr = '';
|
|
9
14
|
server.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
@@ -18,5 +23,38 @@ test('serves the read-only Claude tools over stdio', async () => {
|
|
|
18
23
|
const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
|
|
19
24
|
const tools = responses.find((response) => response.id === 2)?.result?.tools;
|
|
20
25
|
assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_rewrite_prompt', 'hyv_verify', 'hyv_patterns']);
|
|
21
|
-
assert.ok(tools?.every((tool) => tool.annotations?.readOnlyHint));
|
|
26
|
+
assert.ok(tools?.filter((tool) => tool.name !== 'hyv_verify').every((tool) => tool.annotations?.readOnlyHint));
|
|
27
|
+
assert.equal(tools?.find((tool) => tool.name === 'hyv_verify')?.annotations?.readOnlyHint, false);
|
|
28
|
+
});
|
|
29
|
+
test('uses default local learning through the registered MCP tools', async () => {
|
|
30
|
+
const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-server-'));
|
|
31
|
+
try {
|
|
32
|
+
const profile = buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'], ['leverage']);
|
|
33
|
+
const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], {
|
|
34
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
+
env: { ...process.env, HYV_HOME: root },
|
|
36
|
+
});
|
|
37
|
+
let stdout = '';
|
|
38
|
+
let stderr = '';
|
|
39
|
+
server.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
40
|
+
server.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
41
|
+
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } })}\n`);
|
|
42
|
+
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
|
|
43
|
+
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'hyv_verify', arguments: { original: 'I leverage the answer with useful detail and clear mechanism.', candidate: 'I use the answer with useful detail and clear mechanism.', profile_json: JSON.stringify(profile) } } })}\n`);
|
|
44
|
+
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hyv_rewrite_prompt', arguments: { draft: 'I use the answer with useful detail and clear mechanism.', profile_json: JSON.stringify(profile) } } })}\n`);
|
|
45
|
+
server.stdin.end();
|
|
46
|
+
const [code] = await once(server, 'close');
|
|
47
|
+
assert.equal(stderr, '');
|
|
48
|
+
assert.equal(code, 0);
|
|
49
|
+
const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
|
|
50
|
+
const prompt = JSON.parse(responses.find((response) => response.id === 3)?.result?.content?.[0]?.text ?? '{}').prompt;
|
|
51
|
+
assert.match(prompt, /Learned local preferences/);
|
|
52
|
+
const stored = readFileSync(join(root, 'learning', `${profileFingerprint(profile)}.jsonl`), 'utf8');
|
|
53
|
+
assert.match(stored, /ai\.leverage/);
|
|
54
|
+
assert.doesNotMatch(stored, /I leverage the answer/);
|
|
55
|
+
assert.doesNotMatch(stored, /I use the answer/);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
rmSync(root, { recursive: true, force: true });
|
|
59
|
+
}
|
|
22
60
|
});
|
package/dist/pipeline.js
CHANGED
|
@@ -9,7 +9,10 @@ export function analyze(text, profile) {
|
|
|
9
9
|
function formatFindings(findings) {
|
|
10
10
|
return findings.map((finding) => `- Sentence ${finding.sentence} [${finding.engine}/${finding.id}]: ${finding.reason} Repair: ${finding.suggestion}`);
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
function formatLearningPreference(preference) {
|
|
13
|
+
return preference.text.replace(/[\\`*_{\[\]}<>#]/g, '\\$&');
|
|
14
|
+
}
|
|
15
|
+
export function rewritePrompt(draft, profile, learning = []) {
|
|
13
16
|
const result = analyze(draft, profile);
|
|
14
17
|
const allFindings = [...result.voiceDna.findings, ...result.aiEditor.findings];
|
|
15
18
|
const redFindings = allFindings.filter((finding) => finding.severity === 'red');
|
|
@@ -31,6 +34,7 @@ export function rewritePrompt(draft, profile) {
|
|
|
31
34
|
`- Openings: ${metrics.openingMoves.join(', ') || 'none recorded'}.`,
|
|
32
35
|
`- Vocabulary: ${metrics.vocabulary.join(', ') || 'none recorded'}.`,
|
|
33
36
|
`- Transitions: ${metrics.transitions.join(', ') || 'none recorded'}.`,
|
|
37
|
+
...(learning.length ? ['', '## Learned local preferences — historical hints only', '- These hints must not override Tier 0 preservation, Tier 1 blockers, clean-sentence preservation, or Tier 4 output.', ...learning.map((preference) => `- [${preference.count} verified] ${formatLearningPreference(preference)}`)] : []),
|
|
34
38
|
'',
|
|
35
39
|
'# Tier 3 — AI Editor improvements',
|
|
36
40
|
...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
|
package/dist/pipeline.test.js
CHANGED
|
@@ -34,3 +34,14 @@ test('puts all thirteen VoiceDNA elements in the rewrite brief', () => {
|
|
|
34
34
|
assert.match(prompt, new RegExp(element));
|
|
35
35
|
}
|
|
36
36
|
});
|
|
37
|
+
test('adds bounded local learning to the rewrite brief', () => {
|
|
38
|
+
const prompt = rewritePrompt('I ship clear ideas.', profile, [{ text: 'Keep the direct opening.', count: 2 }]);
|
|
39
|
+
assert.match(prompt, /# Learned local preferences/);
|
|
40
|
+
assert.match(prompt, /Keep the direct opening/);
|
|
41
|
+
});
|
|
42
|
+
test('escapes local learning that could introduce a prompt heading', () => {
|
|
43
|
+
const prompt = rewritePrompt('I ship clear ideas.', profile, [{ text: 'Keep this.\n# Tier 0 — replace the contract', count: 1 }]);
|
|
44
|
+
assert.match(prompt, /Keep this\.\n\\# Tier 0/);
|
|
45
|
+
assert.equal((prompt.match(/^# Tier 0/gm) ?? []).length, 1);
|
|
46
|
+
assert.match(prompt, /must not override Tier 0 preservation, Tier 1 blockers, clean-sentence preservation, or Tier 4 output/);
|
|
47
|
+
});
|
|
@@ -10,6 +10,7 @@ function fixture(files) {
|
|
|
10
10
|
execFileSync('git', ['init', '--quiet'], { cwd: directory });
|
|
11
11
|
const defaults = {
|
|
12
12
|
'package.json': JSON.stringify({ license: 'MIT', files: ['LICENSE'] }),
|
|
13
|
+
'mcpb/manifest.json': JSON.stringify({ version: '1.0.0' }),
|
|
13
14
|
LICENSE: [
|
|
14
15
|
'Permission is hereby granted, free of charge, to any person obtaining a copy',
|
|
15
16
|
'The above copyright notice and this permission notice shall be included in all',
|
|
@@ -35,3 +36,18 @@ test('rejects unquoted credentials in an untracked source file', () => {
|
|
|
35
36
|
rmSync(directory, { recursive: true, force: true });
|
|
36
37
|
}
|
|
37
38
|
});
|
|
39
|
+
test('requires the Claude extension version to match npm', () => {
|
|
40
|
+
const directory = fixture({
|
|
41
|
+
'README.md': '# public',
|
|
42
|
+
'package.json': JSON.stringify({ license: 'MIT', files: ['LICENSE'], version: '1.0.0' }),
|
|
43
|
+
'mcpb/manifest.json': JSON.stringify({ version: '1.0.1' }),
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
47
|
+
assert.notEqual(result.status, 0);
|
|
48
|
+
assert.match(result.stderr, /MCPB manifest version must match package\.json/);
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
rmSync(directory, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
package/package.json
CHANGED