@holdyourvoice/hyv 2.9.28 → 3.0.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.
- package/LICENSE +17 -22
- package/Readme.md +254 -0
- package/dist/ai-editor.js +9 -0
- package/dist/ai-editor.test.js +61 -0
- package/dist/cli.js +77 -0
- package/dist/cli.test.js +119 -0
- package/dist/contracts.js +1 -0
- package/dist/mcp-tools.js +27 -0
- package/dist/mcp-tools.test.js +23 -0
- package/dist/mcp.js +69 -0
- package/dist/mcp.test.js +22 -0
- package/dist/pipeline.js +64 -0
- package/dist/pipeline.test.js +36 -0
- package/dist/profile.js +31 -0
- package/dist/release-audit.test.js +37 -0
- package/dist/text.js +42 -0
- package/dist/text.test.js +16 -0
- package/dist/voice-dna.js +77 -0
- package/dist/voice-dna.test.js +32 -0
- package/package.json +15 -74
- package/CHANGELOG.md +0 -241
- package/README.md +0 -218
- package/agents/AGENTS.md +0 -82
- package/agents/README.md +0 -20
- package/agents/chatgpt.md +0 -47
- package/agents/claude-code.md +0 -45
- package/agents/codex.md +0 -31
- package/agents/cursor.md +0 -36
- package/agents/generic.md +0 -49
- package/agents/windsurf.md +0 -20
- package/assets/FREE-PAID.md +0 -46
- package/assets/README.md +0 -20
- package/assets/ai-eliminator-rules.md +0 -140
- package/assets/chatgpt-instructions.txt +0 -8
- package/assets/detection-rules.json +0 -18
- package/assets/economic-drift-voice.md +0 -42
- package/assets/voice-dna-template.md +0 -88
- package/assets/voice-profile-schema.json +0 -28
- package/dist/index.js +0 -20911
- package/scripts/README.md +0 -23
- package/scripts/check-no-duplicates.js +0 -32
- package/scripts/install.ps1 +0 -101
- package/scripts/install.sh +0 -155
- package/scripts/postinstall-lib.js +0 -796
- package/scripts/postinstall.js +0 -35
- package/skills/README.md +0 -20
- package/skills/ai-writing-eliminator/SKILL.md +0 -63
- package/skills/hold-your-voice/SKILL.md +0 -174
- package/skills/voice-matcher/SKILL.md +0 -57
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.1' });
|
|
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());
|
package/dist/mcp.test.js
ADDED
|
@@ -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('./mcp.js', import.meta.url).pathname], { 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
|
+
});
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { analyzeAiEditor } from './ai-editor.js';
|
|
2
|
+
import { analyzeVoiceDna } from './voice-dna.js';
|
|
3
|
+
import { words } from './text.js';
|
|
4
|
+
export function analyze(text, profile) {
|
|
5
|
+
const voiceDna = analyzeVoiceDna(text, profile);
|
|
6
|
+
const aiEditor = analyzeAiEditor(text);
|
|
7
|
+
return { version: '2', voiceDna, aiEditor, passed: voiceDna.passed && aiEditor.passed };
|
|
8
|
+
}
|
|
9
|
+
function formatFindings(findings) {
|
|
10
|
+
return findings.map((finding) => `- Sentence ${finding.sentence} [${finding.engine}/${finding.id}]: ${finding.reason} Repair: ${finding.suggestion}`);
|
|
11
|
+
}
|
|
12
|
+
export function rewritePrompt(draft, profile) {
|
|
13
|
+
const result = analyze(draft, profile);
|
|
14
|
+
const allFindings = [...result.voiceDna.findings, ...result.aiEditor.findings];
|
|
15
|
+
const redFindings = allFindings.filter((finding) => finding.severity === 'red');
|
|
16
|
+
const yellowFindings = allFindings.filter((finding) => finding.severity === 'yellow');
|
|
17
|
+
const metrics = profile.metrics;
|
|
18
|
+
return [
|
|
19
|
+
'# Tier 0 — non-negotiable preservation',
|
|
20
|
+
'Preserve facts, names, numbers, claims, and every unflagged sentence exactly. Do not add claims, examples, sections, hooks, or CTAs.',
|
|
21
|
+
'',
|
|
22
|
+
'# Tier 1 — release blockers',
|
|
23
|
+
`VoiceDNA: ${result.voiceDna.score}/100 (${result.voiceDna.passed ? 'pass' : 'fail'}).`,
|
|
24
|
+
`AI Editor: ${result.aiEditor.score}/100 (${result.aiEditor.passed ? 'pass' : 'fail'}).`,
|
|
25
|
+
...profile.avoid.map((phrase) => `- Never use: ${phrase}`),
|
|
26
|
+
...(redFindings.length ? formatFindings(redFindings) : ['- None.']),
|
|
27
|
+
'',
|
|
28
|
+
'# Tier 2 — VoiceDNA fidelity',
|
|
29
|
+
`- Sentence length: ${metrics.sentenceLength}; sentence variation: ${metrics.sentenceVariation}; sentence structure: ${metrics.sentenceStructure.join(', ') || 'none recorded'}; rhythm: ${metrics.rhythm}.`,
|
|
30
|
+
`- Paragraph length: ${metrics.paragraphLength}; lexical density: ${metrics.lexicalDensity}; point of view: ${metrics.pointOfView}; punctuation: ${Object.entries(metrics.punctuation).map(([mark, count]) => `${mark} ${count}`).join(', ')}; case style: ${metrics.caseStyle}; question rate: ${metrics.questionRate}.`,
|
|
31
|
+
`- Openings: ${metrics.openingMoves.join(', ') || 'none recorded'}.`,
|
|
32
|
+
`- Vocabulary: ${metrics.vocabulary.join(', ') || 'none recorded'}.`,
|
|
33
|
+
`- Transitions: ${metrics.transitions.join(', ') || 'none recorded'}.`,
|
|
34
|
+
'',
|
|
35
|
+
'# Tier 3 — AI Editor improvements',
|
|
36
|
+
...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
|
|
37
|
+
'',
|
|
38
|
+
'# Tier 4 — output contract',
|
|
39
|
+
'Return only replacement sentences keyed by sentence number. Do not rewrite clean sentences. The candidate will be checked again by both engines.',
|
|
40
|
+
'',
|
|
41
|
+
'# Draft',
|
|
42
|
+
draft,
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
function preservationScore(original, candidate) {
|
|
46
|
+
const baseline = new Set(words(original.toLowerCase()).filter((word) => word.length > 4));
|
|
47
|
+
const rewritten = new Set(words(candidate.toLowerCase()));
|
|
48
|
+
return baseline.size ? Math.round([...baseline].filter((word) => rewritten.has(word)).length / baseline.size * 100) : 100;
|
|
49
|
+
}
|
|
50
|
+
export function verify(original, candidate, profile) {
|
|
51
|
+
const baseline = analyze(original, profile);
|
|
52
|
+
const checked = analyze(candidate, profile);
|
|
53
|
+
const known = new Set([...baseline.voiceDna.findings, ...baseline.aiEditor.findings].map((finding) => `${finding.engine}:${finding.id}:${finding.sentence}`));
|
|
54
|
+
const regressions = [...checked.voiceDna.findings, ...checked.aiEditor.findings].filter((finding) => !known.has(`${finding.engine}:${finding.id}:${finding.sentence}`));
|
|
55
|
+
const preservation = preservationScore(original, candidate);
|
|
56
|
+
return {
|
|
57
|
+
version: '2',
|
|
58
|
+
original: baseline,
|
|
59
|
+
candidate: checked,
|
|
60
|
+
preservationScore: preservation,
|
|
61
|
+
regressions,
|
|
62
|
+
passed: checked.passed && !regressions.some((finding) => finding.severity === 'red') && preservation >= 70,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { analyze, rewritePrompt, verify } from './pipeline.js';
|
|
4
|
+
import { buildProfile } from './voice-dna.js';
|
|
5
|
+
const profile = buildProfile([
|
|
6
|
+
'I ship clear ideas. The details stay concrete. I explain the mechanism without fuss.',
|
|
7
|
+
'I write short sentences. Then I explain the mechanism. My work stays plain and specific.',
|
|
8
|
+
], ['leverage']);
|
|
9
|
+
test('keeps the two engine scores independent', () => {
|
|
10
|
+
const result = analyze('Firstly, we leverage a holistic strategy.', profile);
|
|
11
|
+
assert.equal(result.aiEditor.passed, false);
|
|
12
|
+
assert.equal(typeof result.voiceDna.score, 'number');
|
|
13
|
+
});
|
|
14
|
+
test('builds all thirteen VoiceDNA measurements', () => {
|
|
15
|
+
assert.deepEqual(Object.keys(profile.metrics), ['sentenceLength', 'sentenceVariation', 'sentenceStructure', 'rhythm', 'paragraphLength', 'openingMoves', 'vocabulary', 'lexicalDensity', 'pointOfView', 'punctuation', 'caseStyle', 'questionRate', 'transitions']);
|
|
16
|
+
});
|
|
17
|
+
test('orders rewrite instructions by importance tier', () => {
|
|
18
|
+
const prompt = rewritePrompt('Firstly, we leverage a holistic strategy.', profile);
|
|
19
|
+
assert.ok(prompt.indexOf('# Tier 0') < prompt.indexOf('# Tier 1'));
|
|
20
|
+
assert.ok(prompt.indexOf('# Tier 1') < prompt.indexOf('# Tier 2'));
|
|
21
|
+
assert.ok(prompt.indexOf('# Tier 2') < prompt.indexOf('# Tier 3'));
|
|
22
|
+
assert.ok(prompt.indexOf('# Tier 3') < prompt.indexOf('# Tier 4'));
|
|
23
|
+
});
|
|
24
|
+
test('post gate reports a new AI regression', () => {
|
|
25
|
+
const result = verify('I ship clear ideas.', 'I ship clear ideas — a game-changer.', profile);
|
|
26
|
+
assert.equal(result.passed, false);
|
|
27
|
+
assert.ok(result.regressions.length > 0);
|
|
28
|
+
assert.equal(result.original.aiEditor.passed, true);
|
|
29
|
+
assert.equal(result.candidate.aiEditor.passed, false);
|
|
30
|
+
});
|
|
31
|
+
test('puts all thirteen VoiceDNA elements in the rewrite brief', () => {
|
|
32
|
+
const prompt = rewritePrompt('I ship clear ideas.', profile);
|
|
33
|
+
for (const element of ['Sentence length', 'sentence variation', 'sentence structure', 'rhythm', 'Paragraph length', 'lexical density', 'point of view', 'punctuation', 'case style', 'question rate', 'Openings', 'Vocabulary', 'Transitions']) {
|
|
34
|
+
assert.match(prompt, new RegExp(element));
|
|
35
|
+
}
|
|
36
|
+
});
|
package/dist/profile.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
const audit = new URL('../scripts/release-audit.mjs', import.meta.url).pathname;
|
|
8
|
+
function fixture(files) {
|
|
9
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-audit-'));
|
|
10
|
+
execFileSync('git', ['init', '--quiet'], { cwd: directory });
|
|
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 })) {
|
|
20
|
+
const path = join(directory, file);
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
writeFileSync(path, text);
|
|
23
|
+
}
|
|
24
|
+
execFileSync('git', ['add', 'README.md'], { cwd: directory });
|
|
25
|
+
return directory;
|
|
26
|
+
}
|
|
27
|
+
test('rejects unquoted credentials in an untracked source file', () => {
|
|
28
|
+
const directory = fixture({ 'README.md': '# public', 'src/unsafe.ts': ['const ', 'API_KEY', '=topsecret;'].join('') });
|
|
29
|
+
try {
|
|
30
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
31
|
+
assert.notEqual(result.status, 0);
|
|
32
|
+
assert.match(result.stderr, /credential marker: src\/unsafe\.ts/);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
rmSync(directory, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const words = (text) => text.match(/\p{L}[\p{L}\p{M}'’-]*/gu) ?? [];
|
|
2
|
+
export const mean = (values) => values.length ? values.reduce((total, value) => total + value, 0) / values.length : 0;
|
|
3
|
+
export const deviation = (values, average = mean(values)) => values.length ? Math.sqrt(mean(values.map((value) => (value - average) ** 2))) : 0;
|
|
4
|
+
export function sentences(text) {
|
|
5
|
+
const output = [];
|
|
6
|
+
const abbreviations = new Set(['dr', 'mr', 'mrs', 'ms', 'prof', 'sr', 'jr', 'vs', 'etc', 'fig', 'no', 'inc', 'ltd', 'co', 'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct', 'nov', 'dec']);
|
|
7
|
+
let start = 0;
|
|
8
|
+
const add = (end) => {
|
|
9
|
+
const raw = text.slice(start, end);
|
|
10
|
+
const value = raw.trim();
|
|
11
|
+
if (!words(value).length)
|
|
12
|
+
return;
|
|
13
|
+
const offset = start + raw.indexOf(value);
|
|
14
|
+
output.push({ index: output.length + 1, start: offset, end: offset + value.length, text: value });
|
|
15
|
+
};
|
|
16
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
17
|
+
const character = text[index];
|
|
18
|
+
if (character === '\n') {
|
|
19
|
+
add(index);
|
|
20
|
+
start = index + 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!'.!?'.includes(character))
|
|
24
|
+
continue;
|
|
25
|
+
if (character === '.') {
|
|
26
|
+
const previous = text[index - 1] ?? '';
|
|
27
|
+
const next = text[index + 1] ?? '';
|
|
28
|
+
const previousWord = text.slice(start, index).match(/(\p{L}+)$/u)?.[1]?.toLowerCase();
|
|
29
|
+
if ((/\d/.test(previous) && /\d/.test(next)) || /\p{L}/u.test(next) || (previousWord && abbreviations.has(previousWord)))
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
let end = index + 1;
|
|
33
|
+
while (end < text.length && '.!?'.includes(text[end]))
|
|
34
|
+
end += 1;
|
|
35
|
+
add(end);
|
|
36
|
+
start = end;
|
|
37
|
+
index = end - 1;
|
|
38
|
+
}
|
|
39
|
+
add(text.length);
|
|
40
|
+
return output;
|
|
41
|
+
}
|
|
42
|
+
export const paragraphs = (text) => text.split(/\n\s*\n/).map((part) => part.trim()).filter(Boolean);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { sentences, words } from './text.js';
|
|
4
|
+
test('keeps decimals and common abbreviations inside a sentence', () => {
|
|
5
|
+
assert.deepEqual(sentences('Dr. Shah raised $3.5m. It funded the work.').map((sentence) => sentence.text), ['Dr. Shah raised $3.5m.', 'It funded the work.']);
|
|
6
|
+
});
|
|
7
|
+
test('records exact sentence offsets across newlines', () => {
|
|
8
|
+
const text = 'First line\nSecond line.';
|
|
9
|
+
assert.deepEqual(sentences(text), [
|
|
10
|
+
{ index: 1, start: 0, end: 10, text: 'First line' },
|
|
11
|
+
{ index: 2, start: 11, end: 23, text: 'Second line.' },
|
|
12
|
+
]);
|
|
13
|
+
});
|
|
14
|
+
test('counts Unicode writing as words', () => {
|
|
15
|
+
assert.deepEqual(words('café नमस्ते दुनिया'), ['café', 'नमस्ते', 'दुनिया']);
|
|
16
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { deviation, mean, paragraphs, sentences, words } from './text.js';
|
|
2
|
+
const STOP_WORDS = new Set(['the', 'and', 'that', 'with', 'this', 'from', 'your', 'have', 'were', 'they', 'will', 'into', 'about', 'what', 'when', 'where']);
|
|
3
|
+
const TRANSITIONS = ['but', 'because', 'instead', 'then', 'still', 'so', 'yet', 'therefore'];
|
|
4
|
+
function top(items, limit) {
|
|
5
|
+
const counts = new Map();
|
|
6
|
+
for (const item of items.filter(Boolean))
|
|
7
|
+
counts.set(item, (counts.get(item) ?? 0) + 1);
|
|
8
|
+
return [...counts].sort((left, right) => right[1] - left[1]).slice(0, limit).map(([item]) => item);
|
|
9
|
+
}
|
|
10
|
+
function profileMetrics(text) {
|
|
11
|
+
const draftSentences = sentences(text);
|
|
12
|
+
const draftWords = words(text);
|
|
13
|
+
const lengths = draftSentences.map((sentence) => words(sentence.text).length);
|
|
14
|
+
const starters = draftSentences.map((sentence) => words(sentence.text)[0]?.toLowerCase() ?? '');
|
|
15
|
+
const structures = draftSentences.map((sentence) => words(sentence.text).slice(0, 3).map((word) => word.toLowerCase()).join(' '));
|
|
16
|
+
const lowerWords = draftWords.map((word) => word.toLowerCase());
|
|
17
|
+
const nonStop = lowerWords.filter((word) => word.length > 3 && !STOP_WORDS.has(word));
|
|
18
|
+
const first = lowerWords.filter((word) => ['i', 'we', 'my', 'our', 'us'].includes(word)).length;
|
|
19
|
+
const second = lowerWords.filter((word) => ['you', 'your', 'yours'].includes(word)).length;
|
|
20
|
+
const third = lowerWords.filter((word) => ['he', 'she', 'they', 'their', 'them'].includes(word)).length;
|
|
21
|
+
const pov = first > second && first > third ? 'first_person' : second > first && second > third ? 'second_person' : third > first && third > second ? 'third_person' : 'mixed';
|
|
22
|
+
const paragraphSizes = paragraphs(text).map((paragraph) => sentences(paragraph).length);
|
|
23
|
+
const punctuation = Object.fromEntries(['!', '?', ';', ':', '—'].map((mark) => [mark, (text.match(new RegExp(mark.replace(/[?*+^$.[\]\\(){}|-]/g, '\\$&'), 'g')) ?? []).length]));
|
|
24
|
+
const sentenceLength = mean(lengths);
|
|
25
|
+
const sentenceVariation = deviation(lengths, sentenceLength);
|
|
26
|
+
const rhythm = lengths.length > 1 ? mean(lengths.slice(1).map((length, index) => Math.abs(length - lengths[index]))) : 0;
|
|
27
|
+
const uppercaseStarts = draftSentences.filter((sentence) => /^\p{Lu}/u.test(sentence.text)).length;
|
|
28
|
+
const caseStyle = uppercaseStarts / Math.max(1, draftSentences.length) > 0.8 ? 'standard' : uppercaseStarts === 0 ? 'lowercase' : 'mixed';
|
|
29
|
+
return {
|
|
30
|
+
sentenceLength: Number(sentenceLength.toFixed(2)),
|
|
31
|
+
sentenceVariation: Number(sentenceVariation.toFixed(2)),
|
|
32
|
+
sentenceStructure: top(structures, 8),
|
|
33
|
+
rhythm: Number(rhythm.toFixed(2)),
|
|
34
|
+
paragraphLength: Number(mean(paragraphSizes).toFixed(2)),
|
|
35
|
+
openingMoves: top(starters, 8),
|
|
36
|
+
vocabulary: top(nonStop, 20),
|
|
37
|
+
lexicalDensity: Number((nonStop.length / Math.max(1, lowerWords.length)).toFixed(3)),
|
|
38
|
+
pointOfView: pov,
|
|
39
|
+
punctuation,
|
|
40
|
+
caseStyle,
|
|
41
|
+
questionRate: Number((draftSentences.filter((sentence) => sentence.text.endsWith('?')).length / Math.max(1, draftSentences.length)).toFixed(3)),
|
|
42
|
+
transitions: top(lowerWords.filter((word) => TRANSITIONS.includes(word)), 8),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function buildProfile(samples, avoid = []) {
|
|
46
|
+
if (samples.length < 2)
|
|
47
|
+
throw new Error('Provide at least two local writing samples.');
|
|
48
|
+
if (samples.some((sample) => !words(sample).length))
|
|
49
|
+
throw new Error('Every local writing sample must contain writing.');
|
|
50
|
+
return { version: '2', sampleCount: samples.length, metrics: profileMetrics(samples.join('\n\n')), avoid };
|
|
51
|
+
}
|
|
52
|
+
function finding(id, severity, sentence, excerpt, reason, suggestion) {
|
|
53
|
+
return { engine: 'voice_dna', id, severity, sentence, excerpt, reason, suggestion };
|
|
54
|
+
}
|
|
55
|
+
export function analyzeVoiceDna(text, profile) {
|
|
56
|
+
const metrics = profileMetrics(text);
|
|
57
|
+
const findings = [];
|
|
58
|
+
const tolerance = Math.max(8, profile.metrics.sentenceVariation * 2.2);
|
|
59
|
+
for (const sentence of sentences(text)) {
|
|
60
|
+
const count = words(sentence.text).length;
|
|
61
|
+
if (Math.abs(count - profile.metrics.sentenceLength) > tolerance)
|
|
62
|
+
findings.push(finding('dna.sentence-length', 'yellow', sentence.index, sentence.text, `Sentence length (${count}) is outside the profile band around ${profile.metrics.sentenceLength}.`, 'Restore the writer’s usual sentence length where it improves clarity.'));
|
|
63
|
+
for (const banned of profile.avoid)
|
|
64
|
+
if (sentence.text.toLowerCase().includes(banned.toLowerCase()))
|
|
65
|
+
findings.push(finding('dna.avoid-list', 'red', sentence.index, sentence.text, `Uses profile avoid-list phrase: ${banned}.`, 'Replace it with the writer’s natural language.'));
|
|
66
|
+
}
|
|
67
|
+
if (Math.abs(metrics.questionRate - profile.metrics.questionRate) > 0.25)
|
|
68
|
+
findings.push(finding('dna.question-rate', 'yellow', 1, text.slice(0, 160), 'Question frequency differs materially from the profile.', 'Match the writer’s usual use of questions.'));
|
|
69
|
+
if (metrics.caseStyle !== profile.metrics.caseStyle)
|
|
70
|
+
findings.push(finding('dna.case-style', 'yellow', 1, text.slice(0, 160), `Draft uses ${metrics.caseStyle} casing; profile uses ${profile.metrics.caseStyle}.`, 'Use the profile’s normal casing.'));
|
|
71
|
+
if (metrics.pointOfView !== profile.metrics.pointOfView && profile.metrics.pointOfView !== 'mixed')
|
|
72
|
+
findings.push(finding('dna.point-of-view', 'yellow', 1, text.slice(0, 160), `Draft point of view is ${metrics.pointOfView}; profile is ${profile.metrics.pointOfView}.`, 'Restore the writer’s normal narrative distance.'));
|
|
73
|
+
const red = findings.filter((item) => item.severity === 'red').length;
|
|
74
|
+
const yellow = findings.length - red;
|
|
75
|
+
const score = Math.max(0, 100 - red * 25 - yellow * 7);
|
|
76
|
+
return { engine: 'voice_dna', version: '2', score, passed: red === 0, findings };
|
|
77
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { analyzeVoiceDna, buildProfile } from './voice-dna.js';
|
|
4
|
+
test('requires two local samples before creating a profile', () => {
|
|
5
|
+
assert.throws(() => buildProfile(['One sample is not enough.']), /at least two local writing samples/);
|
|
6
|
+
});
|
|
7
|
+
test('requires every local sample to contain writing', () => {
|
|
8
|
+
assert.throws(() => buildProfile(['', ' ']), /must contain writing/);
|
|
9
|
+
});
|
|
10
|
+
test('reports local avoid-list matches as red sentence findings', () => {
|
|
11
|
+
const profile = buildProfile(['i write plainly. i name the work.', 'i keep the mechanism clear. i avoid filler.'], ['unlock']);
|
|
12
|
+
const report = analyzeVoiceDna('i unlock the answer. i name the work.', profile);
|
|
13
|
+
assert.deepEqual(report.findings.filter((finding) => finding.id === 'dna.avoid-list').map((finding) => [finding.severity, finding.sentence]), [['red', 1]]);
|
|
14
|
+
assert.equal(report.passed, false);
|
|
15
|
+
});
|
|
16
|
+
test('keeps a case-style mismatch as a yellow review cue', () => {
|
|
17
|
+
const profile = buildProfile(['i write plainly. i name the work.', 'i keep the mechanism clear. i avoid filler.']);
|
|
18
|
+
const report = analyzeVoiceDna('This sentence starts in standard case.', profile);
|
|
19
|
+
assert.ok(report.findings.some((finding) => finding.id === 'dna.case-style' && finding.severity === 'yellow'));
|
|
20
|
+
assert.equal(report.passed, true);
|
|
21
|
+
});
|
|
22
|
+
test('keeps the documented VoiceDNA review checks as yellow findings', () => {
|
|
23
|
+
const profile = buildProfile([
|
|
24
|
+
'i write short sentences. i ask no questions. i name the mechanism.',
|
|
25
|
+
'i keep the language plain. i explain the work. i use first person.',
|
|
26
|
+
]);
|
|
27
|
+
const report = analyzeVoiceDna('You explain a much longer sentence with several extra words so the reader can follow the full mechanism in detail. Do you agree?', profile);
|
|
28
|
+
for (const id of ['dna.sentence-length', 'dna.question-rate', 'dna.point-of-view']) {
|
|
29
|
+
assert.ok(report.findings.some((finding) => finding.id === id && finding.severity === 'yellow'), id);
|
|
30
|
+
}
|
|
31
|
+
assert.equal(report.passed, true);
|
|
32
|
+
});
|
package/package.json
CHANGED
|
@@ -1,77 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holdyourvoice/hyv",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
},
|
|
10
|
-
"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"test": "npm run build && vitest run",
|
|
19
|
-
"test:smoke": "npm run build && bash scripts/smoke-test.sh",
|
|
20
|
-
"test:watch": "vitest",
|
|
21
|
-
"release:patch": "npm version patch && npm publish --access public",
|
|
22
|
-
"release:minor": "npm version minor && npm publish --access public",
|
|
23
|
-
"release:major": "npm version major && npm publish --access public"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"voice",
|
|
27
|
-
"writing",
|
|
28
|
-
"ai",
|
|
29
|
-
"cli",
|
|
30
|
-
"brand-voice",
|
|
31
|
-
"content-gate",
|
|
32
|
-
"hyv",
|
|
33
|
-
"cursor",
|
|
34
|
-
"claude",
|
|
35
|
-
"mcp",
|
|
36
|
-
"ai-agent",
|
|
37
|
-
"ai-writing",
|
|
38
|
-
"voice-profile"
|
|
39
|
-
],
|
|
40
|
-
"author": "Hold Your Voice",
|
|
41
|
-
"license": "UNLICENSED",
|
|
42
|
-
"private": false,
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"canvas": "^3.2.3",
|
|
45
|
-
"chalk": "^4.1.2",
|
|
46
|
-
"commander": "^12.1.0",
|
|
47
|
-
"glob": "^13.0.6",
|
|
48
|
-
"open": "^8.4.2"
|
|
49
|
-
},
|
|
50
|
-
"devDependencies": {
|
|
51
|
-
"@types/node": "^20.19.42",
|
|
52
|
-
"esbuild": "^0.20.0",
|
|
53
|
-
"typescript": "^5.3.3",
|
|
54
|
-
"vitest": "^2.0.0"
|
|
55
|
-
},
|
|
56
|
-
"engines": {
|
|
57
|
-
"node": ">=18"
|
|
58
|
-
},
|
|
59
|
-
"files": [
|
|
60
|
-
"dist/",
|
|
61
|
-
"scripts/postinstall.js",
|
|
62
|
-
"scripts/postinstall-lib.js",
|
|
63
|
-
"scripts/install.sh",
|
|
64
|
-
"scripts/install.ps1",
|
|
65
|
-
"scripts/check-no-duplicates.js",
|
|
66
|
-
"assets/",
|
|
67
|
-
"skills/",
|
|
68
|
-
"agents/",
|
|
69
|
-
"README.md",
|
|
70
|
-
"CHANGELOG.md",
|
|
71
|
-
"LICENSE"
|
|
72
|
-
],
|
|
73
|
-
"repository": {
|
|
74
|
-
"type": "git",
|
|
75
|
-
"url": "git+https://github.com/shashank-sn/hold-your-voice-app.git"
|
|
76
|
-
}
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "A local-first dual-engine writing gate that protects voice and catches generic AI patterns.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "hyv": "dist/cli.js" },
|
|
7
|
+
"files": ["dist", "Readme.md", "LICENSE"],
|
|
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
|
+
"engines": { "node": ">=20" },
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"keywords": ["ai-writing", "cli", "editing", "voice", "writing"],
|
|
12
|
+
"repository": { "type": "git", "url": "git+https://github.com/shashank-sn/holdyourvoice.git" },
|
|
13
|
+
"bugs": { "url": "https://github.com/shashank-sn/holdyourvoice/issues" },
|
|
14
|
+
"homepage": "https://github.com/shashank-sn/holdyourvoice#readme",
|
|
15
|
+
"publishConfig": { "access": "public" },
|
|
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" }
|
|
77
18
|
}
|