@holdyourvoice/hyv 3.0.2 → 3.1.1

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.
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { analyzeBatch, analyzeEditorial, parseWritingBrief } from './editorial-packs.js';
4
+ test('uses only the selected format pack and keeps advisory format findings non-blocking', () => {
5
+ const brief = parseWritingBrief({
6
+ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social',
7
+ });
8
+ const report = analyzeEditorial('A pattern I keep seeing in founder posts is vague advice.', brief);
9
+ assert.equal(report.engine, 'editorial');
10
+ assert.equal(report.passed, true);
11
+ assert.deepEqual(report.findings.map((finding) => [finding.id, finding.severity, finding.sentence]), [['editorial.social.generic-opener', 'yellow', 1]]);
12
+ });
13
+ test('enforces explicit local disclosure terms independently from format guidance', () => {
14
+ const brief = parseWritingBrief({
15
+ version: '1', audience: 'operators', intent: 'write an update', format: 'general', prohibitedTerms: ['internal contract value'],
16
+ });
17
+ const report = analyzeEditorial('The internal contract value is $12,000.', brief);
18
+ assert.equal(report.passed, false);
19
+ assert.deepEqual(report.findings.map((finding) => [finding.id, finding.severity]), [['editorial.prohibited-term', 'red']]);
20
+ });
21
+ test('matches prohibited terms as complete normalized words or phrases', () => {
22
+ const brief = parseWritingBrief({ version: '1', audience: 'operators', intent: 'write an update', format: 'general', prohibitedTerms: ['art'] });
23
+ assert.equal(analyzeEditorial('We start with the delivery date.', brief).passed, true);
24
+ assert.equal(analyzeEditorial('The art needs a signed owner.', brief).passed, false);
25
+ });
26
+ test('covers the remaining contextual format checks without applying packs outside their format', () => {
27
+ const social = parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'social' });
28
+ assert.deepEqual(analyzeEditorial('One point.\n\nSecond point.\n\nThird point.', social).findings.map((item) => item.id), ['editorial.social.one-line-run']);
29
+ const deck = parseWritingBrief({ version: '1', audience: 'buyers', intent: 'present', format: 'deck', title: '3 ways to improve delivery' });
30
+ assert.deepEqual(analyzeEditorial('We make delivery simpler.', deck).findings.map((item) => item.id), ['editorial.deck.first-slide-we', 'editorial.deck.numeric-title']);
31
+ const outreach = parseWritingBrief({ version: '1', audience: 'operators', intent: 'start a conversation', format: 'outreach' });
32
+ assert.deepEqual(analyzeEditorial('Does that sound interesting?', outreach).findings.map((item) => item.id), ['editorial.outreach.generic-question-cta']);
33
+ assert.deepEqual(analyzeEditorial('We make delivery simpler.', outreach).findings, []);
34
+ });
35
+ test('detects exact repeated openings and endings across a batch without judging deliberate variation', () => {
36
+ const report = analyzeBatch([
37
+ 'The launch needs a clear owner. Start with the dependency map.',
38
+ 'The launch needs a clear owner. Start with the dependency map.',
39
+ 'The invoice needs a clear owner. Check the due date.',
40
+ ]);
41
+ assert.deepEqual(report.findings.map((finding) => [finding.id, finding.draftIndexes]), [
42
+ ['batch.repeated-opening', [1, 2]],
43
+ ['batch.repeated-ending', [1, 2]],
44
+ ]);
45
+ });
46
+ test('rejects malformed writing briefs before they activate editorial checks', () => {
47
+ assert.throws(() => parseWritingBrief({ version: '1', audience: '', intent: 'write', format: 'social' }), /WritingBrief/);
48
+ assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'unknown' }), /WritingBrief/);
49
+ });
@@ -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,6 +1,10 @@
1
1
  import { rules, RULESET_VERSION } from './ai-editor.js';
2
- import { analyze, rewritePrompt, verify } from './pipeline.js';
2
+ import { parseCopySpec } from './copy-spec.js';
3
+ import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
4
+ import { composeLearning, recordVerifiedCandidate } from './learning.js';
5
+ import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
3
6
  import { parseProfile } from './profile.js';
7
+ import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
4
8
  import { buildProfile } from './voice-dna.js';
5
9
  function profileFromJson(profileJson) {
6
10
  try {
@@ -10,18 +14,53 @@ function profileFromJson(profileJson) {
10
14
  throw new Error(error instanceof Error ? error.message : 'Profile is not valid JSON.');
11
15
  }
12
16
  }
17
+ function copySpecFromJson(copySpecJson) {
18
+ try {
19
+ return parseCopySpec(JSON.parse(copySpecJson));
20
+ }
21
+ catch (error) {
22
+ throw new Error(error instanceof Error ? error.message : 'CopySpec is not valid JSON.');
23
+ }
24
+ }
25
+ function writingBriefFromJson(writingBriefJson) {
26
+ if (!writingBriefJson)
27
+ return undefined;
28
+ try {
29
+ return parseWritingBrief(JSON.parse(writingBriefJson));
30
+ }
31
+ catch (error) {
32
+ throw new Error(error instanceof Error ? error.message : 'WritingBrief is not valid JSON.');
33
+ }
34
+ }
13
35
  export function buildProfileForMcp(samples, avoid = []) {
14
36
  return buildProfile(samples, avoid);
15
37
  }
16
- export function analyzeForMcp(draft, profileJson) {
17
- return analyze(draft, profileFromJson(profileJson));
38
+ export function analyzeForMcp(draft, profileJson, writingBriefJson) {
39
+ return analyze(draft, profileFromJson(profileJson), writingBriefFromJson(writingBriefJson));
40
+ }
41
+ export function rewritePromptForMcp(draft, profileJson, options = {}, writingBriefJson) {
42
+ const profile = profileFromJson(profileJson);
43
+ return { prompt: rewritePrompt(draft, profile, composeLearning(profile, options), writingBriefFromJson(writingBriefJson)) };
18
44
  }
19
- export function rewritePromptForMcp(draft, profileJson) {
20
- return { prompt: rewritePrompt(draft, profileFromJson(profileJson)) };
45
+ export function prepareRewriteForMcp(draft, profileJson, copySpecJson, writingBriefJson) {
46
+ return prepareRewriteTask(draft, profileFromJson(profileJson), copySpecJson ? copySpecFromJson(copySpecJson) : undefined, writingBriefFromJson(writingBriefJson));
21
47
  }
22
- export function verifyForMcp(original, candidate, profileJson) {
23
- return verify(original, candidate, profileFromJson(profileJson));
48
+ export function applyRewriteForMcp(taskJson, responseJson, profileJson) {
49
+ return evaluateRewriteResponse(parseRewriteTask(JSON.parse(taskJson)), responseJson, profileFromJson(profileJson));
50
+ }
51
+ export function verifyForMcp(original, candidate, profileJson, options = {}, writingBriefJson) {
52
+ const profile = profileFromJson(profileJson);
53
+ const result = verify(original, candidate, profile, writingBriefFromJson(writingBriefJson));
54
+ return { ...result, learning: recordVerifiedCandidate(profile, result, candidate, options) };
55
+ }
56
+ export function verifyCopySpecForMcp(original, candidate, profileJson, copySpecJson, options = {}, writingBriefJson) {
57
+ const profile = profileFromJson(profileJson);
58
+ const result = verifyWithCopySpec(original, candidate, profile, copySpecFromJson(copySpecJson), writingBriefFromJson(writingBriefJson));
59
+ return { ...result, learning: result.passed ? recordVerifiedCandidate(profile, result, candidate, options) : 'nothing_to_learn' };
24
60
  }
25
61
  export function patternsForMcp() {
26
62
  return { version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) };
27
63
  }
64
+ export function analyzeBatchForMcp(drafts) {
65
+ return analyzeBatch(drafts);
66
+ }
@@ -1,6 +1,9 @@
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
- import { analyzeForMcp, buildProfileForMcp, patternsForMcp, rewritePromptForMcp, verifyForMcp } from './mcp-tools.js';
6
+ import { analyzeBatchForMcp, analyzeForMcp, applyRewriteForMcp, buildProfileForMcp, patternsForMcp, prepareRewriteForMcp, rewritePromptForMcp, verifyCopySpecForMcp, 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']);
5
8
  const profileJson = JSON.stringify(profile);
6
9
  test('builds a portable profile for MCP without files', () => {
@@ -12,12 +15,44 @@ test('keeps the dual-engine analysis shape through MCP tools', () => {
12
15
  assert.equal(result.voiceDna.engine, 'voice_dna');
13
16
  assert.equal(result.aiEditor.engine, 'ai_editor');
14
17
  });
18
+ test('accepts optional WritingBrief context and exposes batch findings through MCP helpers', () => {
19
+ const brief = JSON.stringify({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' });
20
+ const analysis = analyzeForMcp('A pattern I keep seeing in founder posts is vague advice.', profileJson, brief);
21
+ assert.equal(analysis.editorial?.findings[0]?.id, 'editorial.social.generic-opener');
22
+ const batch = analyzeBatchForMcp(['The launch needs a clear owner.', 'The launch needs a clear owner.']);
23
+ assert.equal(batch.findings.length, 2);
24
+ });
15
25
  test('creates and verifies an editing loop through MCP tools', () => {
16
- const brief = rewritePromptForMcp('I leverage a clear plan.', profileJson);
17
- const result = verifyForMcp('I make the call.', 'I make the call.', profileJson);
18
- assert.match(brief.prompt, /Tier 0/);
19
- assert.equal(result.preservationScore, 100);
26
+ const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-'));
27
+ try {
28
+ 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 });
29
+ const brief = rewritePromptForMcp('I leverage a clear plan.', profileJson, { root });
30
+ assert.match(brief.prompt, /Tier 0/);
31
+ assert.match(brief.prompt, /Learned local preferences/);
32
+ assert.equal(result.preservationScore >= 70, true);
33
+ assert.equal(result.learning, 'recorded');
34
+ }
35
+ finally {
36
+ rmSync(root, { recursive: true, force: true });
37
+ }
20
38
  });
21
39
  test('exposes the executable pattern IDs through MCP tools', () => {
22
40
  assert.ok(patternsForMcp().rules.some((rule) => rule.id === 'ai.leverage'));
23
41
  });
42
+ test('fails closed on changed CopySpec claims through MCP tools', () => {
43
+ const result = verifyCopySpecForMcp('The launch is on 14 August.', 'The launch is next month.', profileJson, JSON.stringify({
44
+ version: '1', audience: 'operators', intent: 'explain', channel: 'email',
45
+ claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar.' }],
46
+ }));
47
+ assert.equal(result.passed, false);
48
+ assert.equal(result.claims.failures[0]?.code, 'missing_immutable_claim');
49
+ assert.equal(result.learning, 'nothing_to_learn');
50
+ });
51
+ test('prepares and applies the rewrite task through MCP helpers', () => {
52
+ const task = prepareRewriteForMcp('I leverage the answer with useful detail and clear mechanism.', profileJson);
53
+ const result = applyRewriteForMcp(JSON.stringify(task), JSON.stringify({
54
+ version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer with useful detail and clear mechanism.' }],
55
+ }), profileJson);
56
+ assert.equal(result.status, 'needs_semantic_review');
57
+ assert.equal(result.candidate, 'I use the answer with useful detail and clear mechanism.');
58
+ });
package/dist/mcp.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
- import { analyzeForMcp, buildProfileForMcp, patternsForMcp, rewritePromptForMcp, verifyForMcp } from './mcp-tools.js';
4
+ import { analyzeBatchForMcp, analyzeForMcp, applyRewriteForMcp, buildProfileForMcp, patternsForMcp, prepareRewriteForMcp, rewritePromptForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
5
5
  const writing = z.string().min(1).max(100_000);
6
6
  const profileJson = z.string().min(1).max(50_000);
7
+ const copySpecJson = z.string().min(1).max(250_000);
8
+ const writingBriefJson = z.string().min(1).max(50_000);
7
9
  const samples = z.array(writing).min(2).max(20);
8
10
  const avoid = z.array(z.string().min(1).max(200)).max(50).optional();
9
11
  function json(value) {
@@ -12,7 +14,7 @@ function json(value) {
12
14
  function failure(error) {
13
15
  return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
14
16
  }
15
- const server = new McpServer({ name: 'hold-your-voice', version: '3.0.2' });
17
+ const server = new McpServer({ name: 'hold-your-voice', version: '3.1.1' });
16
18
  server.registerTool('hyv_build_profile', {
17
19
  description: 'Build a portable VoiceDNA profile from at least two writing samples. The samples stay in memory and are not saved.',
18
20
  inputSchema: { samples, avoid },
@@ -27,11 +29,11 @@ server.registerTool('hyv_build_profile', {
27
29
  });
28
30
  server.registerTool('hyv_analyze', {
29
31
  description: 'Run the separate VoiceDNA and AI Editor checks against a draft using a portable profile JSON string.',
30
- inputSchema: { draft: writing, profile_json: profileJson },
32
+ inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
31
33
  annotations: { readOnlyHint: true },
32
- }, async ({ draft, profile_json }) => {
34
+ }, async ({ draft, profile_json, writing_brief_json }) => {
33
35
  try {
34
- return json(analyzeForMcp(draft, profile_json));
36
+ return json(analyzeForMcp(draft, profile_json, writing_brief_json));
35
37
  }
36
38
  catch (error) {
37
39
  return failure(error);
@@ -39,23 +41,71 @@ server.registerTool('hyv_analyze', {
39
41
  });
40
42
  server.registerTool('hyv_rewrite_prompt', {
41
43
  description: 'Create a constrained editing brief. It does not rewrite the draft or call a model.',
42
- inputSchema: { draft: writing, profile_json: profileJson },
44
+ inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
43
45
  annotations: { readOnlyHint: true },
44
- }, async ({ draft, profile_json }) => {
46
+ }, async ({ draft, profile_json, writing_brief_json }) => {
45
47
  try {
46
- return json(rewritePromptForMcp(draft, profile_json));
48
+ return json(rewritePromptForMcp(draft, profile_json, {}, writing_brief_json));
49
+ }
50
+ catch (error) {
51
+ return failure(error);
52
+ }
53
+ });
54
+ server.registerTool('hyv_prepare_rewrite', {
55
+ description: 'Prepare a local, versioned rewrite task. The caller may forward it to a provider; doing so shares the draft and must be an explicit choice.',
56
+ inputSchema: { draft: writing, profile_json: profileJson, copy_spec_json: copySpecJson.optional(), writing_brief_json: writingBriefJson.optional() },
57
+ annotations: { readOnlyHint: true },
58
+ }, async ({ draft, profile_json, copy_spec_json, writing_brief_json }) => {
59
+ try {
60
+ return json(prepareRewriteForMcp(draft, profile_json, copy_spec_json, writing_brief_json));
61
+ }
62
+ catch (error) {
63
+ return failure(error);
64
+ }
65
+ });
66
+ server.registerTool('hyv_apply_rewrite', {
67
+ description: 'Validate and apply a model response to a prepared task, then run the local gates. It never calls a provider or stores source or candidate text.',
68
+ inputSchema: { task_json: z.string().min(1).max(250_000), response_json: z.string().min(1).max(100_000), profile_json: profileJson },
69
+ annotations: { readOnlyHint: true },
70
+ }, async ({ task_json, response_json, profile_json }) => {
71
+ try {
72
+ return json(applyRewriteForMcp(task_json, response_json, profile_json));
47
73
  }
48
74
  catch (error) {
49
75
  return failure(error);
50
76
  }
51
77
  });
52
78
  server.registerTool('hyv_verify', {
53
- description: 'Verify a revised candidate against an original draft and portable profile. Reports regressions and preservation without saving either text.',
54
- inputSchema: { original: writing, candidate: writing, profile_json: profileJson },
79
+ 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.',
80
+ inputSchema: { original: writing, candidate: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
81
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
82
+ }, async ({ original, candidate, profile_json, writing_brief_json }) => {
83
+ try {
84
+ return json(verifyForMcp(original, candidate, profile_json, {}, writing_brief_json));
85
+ }
86
+ catch (error) {
87
+ return failure(error);
88
+ }
89
+ });
90
+ server.registerTool('hyv_verify_copy_spec', {
91
+ description: 'Verify a candidate against the existing voice gates and a local CopySpec. Immutable claims must remain verbatim and each carries local evidence; prohibited claims fail closed.',
92
+ inputSchema: { original: writing, candidate: writing, profile_json: profileJson, copy_spec_json: copySpecJson, writing_brief_json: writingBriefJson.optional() },
93
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
94
+ }, async ({ original, candidate, profile_json, copy_spec_json, writing_brief_json }) => {
95
+ try {
96
+ return json(verifyCopySpecForMcp(original, candidate, profile_json, copy_spec_json, {}, writing_brief_json));
97
+ }
98
+ catch (error) {
99
+ return failure(error);
100
+ }
101
+ });
102
+ server.registerTool('hyv_batch_analyze', {
103
+ description: 'Inspect two to one hundred drafts for repeated opening and closing sentences. It returns advisory batch findings and does not store the drafts.',
104
+ inputSchema: { drafts: z.array(writing).min(2).max(100) },
55
105
  annotations: { readOnlyHint: true },
56
- }, async ({ original, candidate, profile_json }) => {
106
+ }, async ({ drafts }) => {
57
107
  try {
58
- return json(verifyForMcp(original, candidate, profile_json));
108
+ return json(analyzeBatchForMcp(drafts));
59
109
  }
60
110
  catch (error) {
61
111
  return failure(error);