@holdyourvoice/hyv 3.0.0 → 3.0.2

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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # Hold Your Voice
2
2
 
3
+ [![npm downloads](https://img.shields.io/npm/dt/%40holdyourvoice%2Fhyv?label=npm%20downloads&color=2f81f7)](https://www.npmjs.com/package/@holdyourvoice/hyv)
4
+
5
+
3
6
  Hold Your Voice is an MIT-licensed, local-first writing gate for people who want AI help without losing the parts of their writing that make it theirs.
4
7
 
5
8
  It checks a draft through two separate programs:
@@ -9,7 +12,7 @@ It checks a draft through two separate programs:
9
12
 
10
13
  Those programs keep separate findings, scores, and pass states. A strong result from one never cancels a failure in the other. The tool creates a tiered editing brief, then checks the candidate again before you accept it.
11
14
 
12
- Everything runs from local files: accounts, API calls, MCP servers, telemetry, payment collection, and runtime network requests stay out of the core path.
15
+ Everything in the CLI runs from local files: accounts, API calls, telemetry, payment collection, and runtime network requests stay out of the core path. The optional Claude extension adds a local stdio MCP adapter around that same engine; it is not a hosted service.
13
16
 
14
17
  > **Status:** the public CLI is published as [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv). It runs locally and makes no runtime network requests.
15
18
 
@@ -50,6 +53,21 @@ hyv patterns
50
53
 
51
54
  To contribute, clone this repository, run `npm install`, then run `npm test` and `npm run check:release`.
52
55
 
56
+ ### Use it in Claude Desktop
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. It does not read or write files, make network requests, or retain writing. See the [Claude Desktop guide](docs/CLAUDE-DESKTOP.md).
59
+
60
+ ### Use it in Claude Code
61
+
62
+ Hold Your Voice is also a free Claude Code plugin. It starts the same local MCP server through the public npm package; drafts, samples, and profiles stay on your machine.
63
+
64
+ ```text
65
+ /plugin marketplace add shashank-sn/holdyourvoice
66
+ /plugin install hold-your-voice@hold-your-voice
67
+ ```
68
+
69
+ It requires Node.js 20 or newer and npm. See the [Claude Code guide](docs/CLAUDE-CODE.md).
70
+
53
71
  ### Build a local VoiceDNA profile
54
72
 
55
73
  ```bash
package/dist/cli.js CHANGED
@@ -2,43 +2,14 @@
2
2
  import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { rules, RULESET_VERSION } from './ai-editor.js';
4
4
  import { analyze, rewritePrompt, verify } from './pipeline.js';
5
+ import { parseProfile } from './profile.js';
5
6
  import { buildProfile } from './voice-dna.js';
6
- const usage = 'Commands: profile, analyze, rewrite-prompt, verify, patterns';
7
+ const usage = 'Commands: profile, analyze, rewrite-prompt, verify, patterns, mcp';
7
8
  function input(path) {
8
9
  return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
9
10
  }
10
- function isNumberRecord(value) {
11
- if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype)
12
- return false;
13
- return Object.values(value).every((item) => typeof item === 'number' && Number.isFinite(item));
14
- }
15
- function isPunctuation(value) {
16
- const marks = ['!', '?', ';', ':', '—'];
17
- return isNumberRecord(value) && Object.values(value).every((item) => item >= 0) && Object.keys(value).length === marks.length && marks.every((mark) => mark in value);
18
- }
19
- function isMetrics(value) {
20
- if (!value || typeof value !== 'object')
21
- return false;
22
- const metrics = value;
23
- const numbers = [metrics.sentenceLength, metrics.sentenceVariation, metrics.rhythm, metrics.paragraphLength, metrics.lexicalDensity, metrics.questionRate];
24
- const stringArrays = [metrics.sentenceStructure, metrics.openingMoves, metrics.vocabulary, metrics.transitions];
25
- return numbers.every((item) => typeof item === 'number' && Number.isFinite(item) && item >= 0)
26
- && typeof metrics.lexicalDensity === 'number' && metrics.lexicalDensity <= 1
27
- && typeof metrics.questionRate === 'number' && metrics.questionRate <= 1
28
- && ['first_person', 'second_person', 'third_person', 'mixed'].includes(metrics.pointOfView ?? '')
29
- && ['lowercase', 'standard', 'mixed'].includes(metrics.caseStyle ?? '')
30
- && stringArrays.every((items) => Array.isArray(items) && items.every((item) => typeof item === 'string'))
31
- && isPunctuation(metrics.punctuation);
32
- }
33
11
  function readProfile(path) {
34
- const value = JSON.parse(input(path));
35
- if (!value || typeof value !== 'object')
36
- throw new Error('Profile must be a JSON object.');
37
- const profile = value;
38
- if (profile.version !== '2' || typeof profile.sampleCount !== 'number' || !Number.isInteger(profile.sampleCount) || profile.sampleCount < 2 || !isMetrics(profile.metrics) || !Array.isArray(profile.avoid) || !profile.avoid.every((item) => typeof item === 'string' && item.trim().length > 0)) {
39
- throw new Error('Profile is not a valid Hold Your Voice version 2 profile. Rebuild it with the profile command.');
40
- }
41
- return profile;
12
+ return parseProfile(JSON.parse(input(path)));
42
13
  }
43
14
  function json(value) {
44
15
  console.log(JSON.stringify(value, null, 2));
@@ -62,7 +33,7 @@ function profileArguments(args) {
62
33
  throw new Error('Usage: hyv profile profile.json sample-a.md sample-b.md [sample-c.md] [--avoid=phrase]');
63
34
  return { output, samples, avoid };
64
35
  }
65
- export function runCli(args) {
36
+ export async function runCli(args) {
66
37
  const [command, ...rest] = args;
67
38
  if (command === 'profile') {
68
39
  const { output, samples, avoid } = profileArguments(rest);
@@ -95,12 +66,20 @@ export function runCli(args) {
95
66
  json({ version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) });
96
67
  return 0;
97
68
  }
69
+ if (command === 'mcp') {
70
+ if (rest.length > 0)
71
+ throw new Error('Usage: hyv mcp');
72
+ await import('./mcp.js');
73
+ return 0;
74
+ }
98
75
  throw new Error(`${usage}.`);
99
76
  }
100
- try {
101
- process.exitCode = runCli(process.argv.slice(2));
102
- }
103
- catch (error) {
104
- console.error(error instanceof Error ? error.message : String(error));
105
- process.exitCode = 1;
106
- }
77
+ void (async () => {
78
+ try {
79
+ process.exitCode = await runCli(process.argv.slice(2));
80
+ }
81
+ catch (error) {
82
+ console.error(error instanceof Error ? error.message : String(error));
83
+ process.exitCode = 1;
84
+ }
85
+ })();
package/dist/cli.test.js CHANGED
@@ -44,6 +44,7 @@ test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
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
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 });
@@ -0,0 +1,27 @@
1
+ import { rules, RULESET_VERSION } from './ai-editor.js';
2
+ import { analyze, rewritePrompt, verify } from './pipeline.js';
3
+ import { parseProfile } from './profile.js';
4
+ import { buildProfile } from './voice-dna.js';
5
+ function profileFromJson(profileJson) {
6
+ try {
7
+ return parseProfile(JSON.parse(profileJson));
8
+ }
9
+ catch (error) {
10
+ throw new Error(error instanceof Error ? error.message : 'Profile is not valid JSON.');
11
+ }
12
+ }
13
+ export function buildProfileForMcp(samples, avoid = []) {
14
+ return buildProfile(samples, avoid);
15
+ }
16
+ export function analyzeForMcp(draft, profileJson) {
17
+ return analyze(draft, profileFromJson(profileJson));
18
+ }
19
+ export function rewritePromptForMcp(draft, profileJson) {
20
+ return { prompt: rewritePrompt(draft, profileFromJson(profileJson)) };
21
+ }
22
+ export function verifyForMcp(original, candidate, profileJson) {
23
+ return verify(original, candidate, profileFromJson(profileJson));
24
+ }
25
+ export function patternsForMcp() {
26
+ return { version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) };
27
+ }
@@ -0,0 +1,23 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { analyzeForMcp, buildProfileForMcp, patternsForMcp, rewritePromptForMcp, verifyForMcp } from './mcp-tools.js';
4
+ const profile = buildProfileForMcp(['I write clearly. I keep the useful detail.', 'I make the call. Then I explain the trade-off.'], ['leverage']);
5
+ const profileJson = JSON.stringify(profile);
6
+ test('builds a portable profile for MCP without files', () => {
7
+ assert.equal(profile.version, '2');
8
+ assert.equal(profile.sampleCount, 2);
9
+ });
10
+ test('keeps the dual-engine analysis shape through MCP tools', () => {
11
+ const result = analyzeForMcp('I leverage a clear plan.', profileJson);
12
+ assert.equal(result.voiceDna.engine, 'voice_dna');
13
+ assert.equal(result.aiEditor.engine, 'ai_editor');
14
+ });
15
+ 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);
20
+ });
21
+ test('exposes the executable pattern IDs through MCP tools', () => {
22
+ assert.ok(patternsForMcp().rules.some((rule) => rule.id === 'ai.leverage'));
23
+ });
package/dist/mcp.js ADDED
@@ -0,0 +1,69 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { z } from 'zod';
4
+ import { analyzeForMcp, buildProfileForMcp, patternsForMcp, rewritePromptForMcp, verifyForMcp } from './mcp-tools.js';
5
+ const writing = z.string().min(1).max(100_000);
6
+ const profileJson = z.string().min(1).max(50_000);
7
+ const samples = z.array(writing).min(2).max(20);
8
+ const avoid = z.array(z.string().min(1).max(200)).max(50).optional();
9
+ function json(value) {
10
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
11
+ }
12
+ function failure(error) {
13
+ return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
14
+ }
15
+ const server = new McpServer({ name: 'hold-your-voice', version: '3.0.2' });
16
+ server.registerTool('hyv_build_profile', {
17
+ description: 'Build a portable VoiceDNA profile from at least two writing samples. The samples stay in memory and are not saved.',
18
+ inputSchema: { samples, avoid },
19
+ annotations: { readOnlyHint: true },
20
+ }, async ({ samples: writingSamples, avoid: phrases }) => {
21
+ try {
22
+ return json(buildProfileForMcp(writingSamples, phrases));
23
+ }
24
+ catch (error) {
25
+ return failure(error);
26
+ }
27
+ });
28
+ server.registerTool('hyv_analyze', {
29
+ 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 },
31
+ annotations: { readOnlyHint: true },
32
+ }, async ({ draft, profile_json }) => {
33
+ try {
34
+ return json(analyzeForMcp(draft, profile_json));
35
+ }
36
+ catch (error) {
37
+ return failure(error);
38
+ }
39
+ });
40
+ server.registerTool('hyv_rewrite_prompt', {
41
+ description: 'Create a constrained editing brief. It does not rewrite the draft or call a model.',
42
+ inputSchema: { draft: writing, profile_json: profileJson },
43
+ annotations: { readOnlyHint: true },
44
+ }, async ({ draft, profile_json }) => {
45
+ try {
46
+ return json(rewritePromptForMcp(draft, profile_json));
47
+ }
48
+ catch (error) {
49
+ return failure(error);
50
+ }
51
+ });
52
+ 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 },
55
+ annotations: { readOnlyHint: true },
56
+ }, async ({ original, candidate, profile_json }) => {
57
+ try {
58
+ return json(verifyForMcp(original, candidate, profile_json));
59
+ }
60
+ catch (error) {
61
+ return failure(error);
62
+ }
63
+ });
64
+ server.registerTool('hyv_patterns', {
65
+ description: 'List the exact AI Editor rules that run in this extension.',
66
+ inputSchema: {},
67
+ annotations: { readOnlyHint: true },
68
+ }, async () => json(patternsForMcp()));
69
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,22 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { once } from 'node:events';
4
+ import test from 'node:test';
5
+ test('serves the read-only Claude tools over stdio', async () => {
6
+ const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
7
+ let stdout = '';
8
+ let stderr = '';
9
+ server.stdout.on('data', (chunk) => { stdout += chunk; });
10
+ server.stderr.on('data', (chunk) => { stderr += chunk; });
11
+ 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`);
12
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
13
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`);
14
+ server.stdin.end();
15
+ const [code] = await once(server, 'close');
16
+ assert.equal(stderr, '');
17
+ assert.equal(code, 0);
18
+ const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
19
+ const tools = responses.find((response) => response.id === 2)?.result?.tools;
20
+ 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));
22
+ });
@@ -0,0 +1,31 @@
1
+ function isNumberRecord(value) {
2
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
3
+ && Object.values(value).every((item) => typeof item === 'number' && Number.isFinite(item));
4
+ }
5
+ function isPunctuation(value) {
6
+ const marks = ['!', '?', ';', ':', '—'];
7
+ return isNumberRecord(value) && Object.values(value).every((item) => item >= 0) && Object.keys(value).length === marks.length && marks.every((mark) => mark in value);
8
+ }
9
+ function isMetrics(value) {
10
+ if (!value || typeof value !== 'object')
11
+ return false;
12
+ const metrics = value;
13
+ const numbers = [metrics.sentenceLength, metrics.sentenceVariation, metrics.rhythm, metrics.paragraphLength, metrics.lexicalDensity, metrics.questionRate];
14
+ const stringArrays = [metrics.sentenceStructure, metrics.openingMoves, metrics.vocabulary, metrics.transitions];
15
+ return numbers.every((item) => typeof item === 'number' && Number.isFinite(item) && item >= 0)
16
+ && typeof metrics.lexicalDensity === 'number' && metrics.lexicalDensity <= 1
17
+ && typeof metrics.questionRate === 'number' && metrics.questionRate <= 1
18
+ && ['first_person', 'second_person', 'third_person', 'mixed'].includes(metrics.pointOfView ?? '')
19
+ && ['lowercase', 'standard', 'mixed'].includes(metrics.caseStyle ?? '')
20
+ && stringArrays.every((items) => Array.isArray(items) && items.every((item) => typeof item === 'string'))
21
+ && isPunctuation(metrics.punctuation);
22
+ }
23
+ export function parseProfile(value) {
24
+ if (!value || typeof value !== 'object')
25
+ throw new Error('Profile must be a JSON object.');
26
+ const profile = value;
27
+ if (profile.version !== '2' || typeof profile.sampleCount !== 'number' || !Number.isInteger(profile.sampleCount) || profile.sampleCount < 2 || !isMetrics(profile.metrics) || !Array.isArray(profile.avoid) || !profile.avoid.every((item) => typeof item === 'string' && item.trim().length > 0)) {
28
+ throw new Error('Profile is not a valid Hold Your Voice version 2 profile. Rebuild it with the profile command.');
29
+ }
30
+ return profile;
31
+ }
@@ -8,7 +8,15 @@ const audit = new URL('../scripts/release-audit.mjs', import.meta.url).pathname;
8
8
  function fixture(files) {
9
9
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-audit-'));
10
10
  execFileSync('git', ['init', '--quiet'], { cwd: directory });
11
- for (const [file, text] of Object.entries(files)) {
11
+ const defaults = {
12
+ 'package.json': JSON.stringify({ license: 'MIT', files: ['LICENSE'] }),
13
+ LICENSE: [
14
+ 'Permission is hereby granted, free of charge, to any person obtaining a copy',
15
+ 'The above copyright notice and this permission notice shall be included in all',
16
+ 'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND',
17
+ ].join('\n'),
18
+ };
19
+ for (const [file, text] of Object.entries({ ...defaults, ...files })) {
12
20
  const path = join(directory, file);
13
21
  mkdirSync(dirname(path), { recursive: true });
14
22
  writeFileSync(path, text);
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@holdyourvoice/hyv",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "A local-first dual-engine writing gate that protects voice and catches generic AI patterns.",
5
5
  "type": "module",
6
6
  "bin": { "hyv": "dist/cli.js" },
7
7
  "files": ["dist", "Readme.md", "LICENSE"],
8
- "scripts": { "build": "tsc -p tsconfig.json", "test": "npm run build && node --test dist/**/*.test.js", "check:release": "node scripts/release-audit.mjs", "prepack": "npm run check:release && npm test" },
8
+ "scripts": { "build": "tsc -p tsconfig.json", "bundle:claude": "esbuild src/mcp.ts --bundle --platform=node --format=esm --target=node20 --outfile=mcpb/server/index.js", "build:claude": "npm run build && npm run bundle:claude", "pack:claude": "npm run build:claude && node scripts/pack-claude.mjs", "test": "npm run build && node --test dist/**/*.test.js", "check:release": "node scripts/release-audit.mjs", "prepack": "npm run check:release && npm test" },
9
9
  "engines": { "node": ">=20" },
10
10
  "license": "MIT",
11
11
  "keywords": ["ai-writing", "cli", "editing", "voice", "writing"],
@@ -13,5 +13,6 @@
13
13
  "bugs": { "url": "https://github.com/shashank-sn/holdyourvoice/issues" },
14
14
  "homepage": "https://github.com/shashank-sn/holdyourvoice#readme",
15
15
  "publishConfig": { "access": "public" },
16
- "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.7.0" }
16
+ "devDependencies": { "@types/node": "^22.0.0", "@types/yazl": "^3.3.1", "esbuild": "^0.28.1", "typescript": "^5.7.0", "yazl": "^3.3.1" },
17
+ "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", "zod": "^4.4.3" }
17
18
  }