dravoice 0.1.2 → 0.1.3

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/src/v2/prompt.js CHANGED
@@ -1,64 +1,65 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { loadVoicePackV2 } from "./profile.js";
4
-
5
- export function voicePromptPackV2({ voice, format = "agents", outPath }) {
6
- const profile = typeof voice === "string" ? loadVoicePackV2(voice) : voice;
7
- if (format !== "agents" && format !== "claude" && format !== "system") {
8
- throw new Error(`Unsupported prompt format: ${format}`);
9
- }
10
- const rendered = renderPrompt(profile, format);
11
- if (outPath) {
12
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
13
- fs.writeFileSync(outPath, rendered, "utf8");
14
- }
15
- return rendered;
16
- }
17
-
18
- function renderPrompt(profile, format) {
19
- const header = {
20
- agents: "# Dravoice V2 Writing Guidance",
21
- claude: "# CLAUDE.md guidance for Dravoice V2",
22
- system: "System writing guidance: Dravoice V2",
23
- }[format];
24
- const preface = {
25
- agents: "Use this as local, inspectable drafting guidance from the writer's own corpus. It is not an AI detector or a license to imitate a third party.",
26
- claude: "Use these project-local voice notes when drafting or reviewing prose for this repository. Treat them as guidance, not identity proof.",
27
- system: "Follow these local voice constraints when writing prose. Do not expose private source text or claim authorship identity from them.",
28
- }[format];
29
- const lines = [
30
- header,
31
- "",
32
- preface,
33
- "",
34
- "## Summary",
35
- "",
36
- ...profile.guidance.summary.map((item) => `- ${item}`),
37
- "",
38
- "## Feature Families",
39
- "",
40
- ];
41
-
42
- for (const [name, family] of Object.entries(profile.families)) {
43
- lines.push(`- ${name}: ${family.confidence} confidence; ${family.revisionHandles[0]}`);
44
- }
45
-
46
- lines.push("", "## Drafting Rules", "");
47
- for (const rule of profile.guidance.draftingRules) {
48
- lines.push(`- ${rule}`);
49
- }
50
-
51
- lines.push("", "## Avoid", "");
52
- for (const item of profile.guidance.avoid) {
53
- lines.push(`- ${item}`);
54
- }
55
-
56
- if (profile.guidance.examples.length) {
57
- lines.push("", "## Source-Backed Examples", "");
58
- for (const example of profile.guidance.examples) {
59
- lines.push(`- ${example}`);
60
- }
61
- }
62
-
63
- return `${lines.join("\n")}\n`;
64
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { writeUtf8FileSafely } from "./io-utils.js";
4
+ import { loadVoicePackV2 } from "./profile.js";
5
+
6
+ export function voicePromptPackV2({ voice, format = "agents", outPath }) {
7
+ const profile = typeof voice === "string" ? loadVoicePackV2(voice) : voice;
8
+ if (format !== "agents" && format !== "claude" && format !== "system") {
9
+ throw new Error(`Unsupported prompt format: ${format}`);
10
+ }
11
+ const rendered = renderPrompt(profile, format);
12
+ if (outPath) {
13
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
14
+ writeUtf8FileSafely(outPath, rendered);
15
+ }
16
+ return rendered;
17
+ }
18
+
19
+ function renderPrompt(profile, format) {
20
+ const header = {
21
+ agents: "# Dravoice V2 Writing Guidance",
22
+ claude: "# CLAUDE.md guidance for Dravoice V2",
23
+ system: "System writing guidance: Dravoice V2",
24
+ }[format];
25
+ const preface = {
26
+ agents: "Use this as local, inspectable drafting guidance from the writer's own corpus. It is not an AI detector or a license to imitate a third party.",
27
+ claude: "Use these project-local voice notes when drafting or reviewing prose for this repository. Treat them as guidance, not identity proof.",
28
+ system: "Follow these local voice constraints when writing prose. Do not expose private source text or claim authorship identity from them.",
29
+ }[format];
30
+ const lines = [
31
+ header,
32
+ "",
33
+ preface,
34
+ "",
35
+ "## Summary",
36
+ "",
37
+ ...profile.guidance.summary.map((item) => `- ${item}`),
38
+ "",
39
+ "## Feature Families",
40
+ "",
41
+ ];
42
+
43
+ for (const [name, family] of Object.entries(profile.families)) {
44
+ lines.push(`- ${name}: ${family.confidence} confidence; ${family.revisionHandles[0]}`);
45
+ }
46
+
47
+ lines.push("", "## Drafting Rules", "");
48
+ for (const rule of profile.guidance.draftingRules) {
49
+ lines.push(`- ${rule}`);
50
+ }
51
+
52
+ lines.push("", "## Avoid", "");
53
+ for (const item of profile.guidance.avoid) {
54
+ lines.push(`- ${item}`);
55
+ }
56
+
57
+ if (profile.guidance.examples.length) {
58
+ lines.push("", "## Source-Backed Examples", "");
59
+ for (const example of profile.guidance.examples) {
60
+ lines.push(`- ${example}`);
61
+ }
62
+ }
63
+
64
+ return `${lines.join("\n")}\n`;
65
+ }
package/src/v2/review.js CHANGED
@@ -1,173 +1,177 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { parseDocument } from "./document-model.js";
4
- import { buildVoiceProfileV2, loadVoicePackV2 } from "./profile.js";
5
- import { familyDiagnosticsFor, styleDistanceFromDiagnostics } from "./stylometry.js";
6
-
7
- const REVIEW_MODES = {
8
- loose: {
9
- findingThresholds: null,
10
- exitOnDrift: false,
11
- },
12
- balanced: {
13
- findingThresholds: { evidence: 65, rhythm: 55, rhetoricalShape: 50 },
14
- exitOnDrift: false,
15
- },
16
- strict: {
17
- findingThresholds: { evidence: 75, rhythm: 70, rhetoricalShape: 65 },
18
- exitOnDrift: true,
19
- },
20
- };
21
-
22
- export function reviewVoiceDraftV2({ file, voice, cwd = process.cwd(), mode = "balanced" }) {
23
- const reviewMode = normalizeReviewMode(mode);
24
- const sourceProfile = typeof voice === "string" ? loadVoicePackV2(voice) : voice;
25
- const filePath = path.resolve(file);
26
- const draftDocument = parseDocument({
27
- filePath,
28
- rootDir: cwd,
29
- contents: fs.readFileSync(filePath, "utf8"),
30
- });
31
- const draftProfile = buildVoiceProfileV2({ documents: [draftDocument] });
32
-
33
- if (
34
- draftDocument.wordCount < sourceProfile.calibration.minimumDraftSize.words ||
35
- draftDocument.sentences.length < sourceProfile.calibration.minimumDraftSize.sentences
36
- ) {
37
- return {
38
- schemaVersion: 2,
39
- file: displayPath(filePath, cwd),
40
- summary: {
41
- mode: reviewMode,
42
- fit: { band: "insufficient-evidence", distance: 0 },
43
- familyScores: {},
44
- corpusConfidence: sourceProfile.source.confidence,
45
- },
46
- findings: [],
47
- exitCode: 0,
48
- };
49
- }
50
-
51
- const familyDiagnostics = familyDiagnosticsFor(sourceProfile, draftProfile);
52
- const familyScores = Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.score]));
53
- const distance = styleDistanceFromDiagnostics(familyDiagnostics);
54
- const findings = reviewFindings(sourceProfile, draftProfile, familyScores, reviewMode);
55
- const fit = {
56
- band: fitBand(distance, findings, familyDiagnostics),
57
- distance,
58
- };
59
-
60
- return {
61
- schemaVersion: 2,
62
- file: displayPath(filePath, cwd),
63
- summary: {
64
- mode: reviewMode,
65
- fit,
66
- familyScores,
67
- familyDistances: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.distance])),
68
- familyDrift: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.drift])),
69
- thresholds: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.threshold])),
70
- corpusConfidence: sourceProfile.source.confidence,
71
- },
72
- findings,
73
- exitCode: REVIEW_MODES[reviewMode].exitOnDrift && fit.band === "drift" ? 1 : 0,
74
- };
75
- }
76
-
77
- export function renderVoiceReviewV2(result) {
78
- const lines = [
79
- "Voice rewrite notes, not AI detection.",
80
- "",
81
- result.file,
82
- `Voice fit: ${capitalize(result.summary.fit.band)} (${result.summary.fit.distance} distance)`,
83
- `Corpus confidence: ${capitalize(result.summary.corpusConfidence.band)} - ${result.summary.corpusConfidence.message}`,
84
- "Family scores:",
85
- ];
86
-
87
- for (const [family, score] of Object.entries(result.summary.familyScores)) {
88
- lines.push(`- ${family}: ${score}`);
89
- }
90
-
91
- lines.push("");
92
- if (result.findings.length === 0) {
93
- lines.push("No high-confidence V2 voice drift findings.");
94
- lines.push("");
95
- return lines.join("\n");
96
- }
97
-
98
- lines.push("Start here:");
99
- for (const finding of result.findings.slice(0, 6)) {
100
- lines.push(`${finding.priority} ${finding.family} ${finding.id}`);
101
- lines.push(`Why flagged: ${finding.why}`);
102
- lines.push(`Revise by: ${finding.action}`);
103
- lines.push("");
104
- }
105
- return lines.join("\n");
106
- }
107
-
108
- function reviewFindings(source, draft, scores, mode) {
109
- const modeConfig = REVIEW_MODES[mode];
110
- if (source.source?.confidence?.band === "weak" || !modeConfig.findingThresholds) {
111
- return [];
112
- }
113
- const findings = [];
114
- if (scores.evidence < modeConfig.findingThresholds.evidence) {
115
- findings.push({
116
- id: "v2.evidence-drift",
117
- family: "evidence",
118
- priority: "review",
119
- why: `Source evidence sentence rate is ${source.families.evidence.features.evidenceSentenceRate}; draft rate is ${draft.families.evidence.features.evidenceSentenceRate}.`,
120
- action: "Add concrete support before broad claims: a scene, quote, number, citation, URL, sensory detail, or specific example.",
121
- });
122
- }
123
- if (scores.rhythm < modeConfig.findingThresholds.rhythm) {
124
- findings.push({
125
- id: "v2.rhythm-drift",
126
- family: "rhythm",
127
- priority: "consider",
128
- why: `Source median sentence/paragraph length is ${source.families.rhythm.features.sentenceWords.median}/${source.families.rhythm.features.paragraphWords.median}; draft is ${draft.families.rhythm.features.sentenceWords.median}/${draft.families.rhythm.features.paragraphWords.median}.`,
129
- action: "Revise sentence and paragraph pacing toward the learned range.",
130
- });
131
- }
132
- if (scores.rhetoricalShape < modeConfig.findingThresholds.rhetoricalShape) {
133
- findings.push({
134
- id: "v2.shape-drift",
135
- family: "rhetoricalShape",
136
- priority: "consider",
137
- why: `Source opening shape is ${source.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ")}; draft opening shape is ${draft.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ")}.`,
138
- action: "Rework the opening so it uses a compatible scene, claim, contrast, reflection, or example sequence.",
139
- });
140
- }
141
- return findings;
142
- }
143
-
144
- function normalizeReviewMode(mode) {
145
- const normalized = String(mode ?? "balanced").toLowerCase();
146
- if (!Object.hasOwn(REVIEW_MODES, normalized)) {
147
- throw new Error(`Unsupported review mode: ${mode}. Expected loose, balanced, or strict.`);
148
- }
149
- return normalized;
150
- }
151
-
152
- function fitBand(distance, findings, familyDiagnostics) {
153
- const maxDrift = Math.max(0, ...Object.values(familyDiagnostics).map((item) => item.drift));
154
- if (findings.some((finding) => finding.priority === "review") || distance >= 35 || maxDrift >= 1.25) {
155
- return "drift";
156
- }
157
- if (findings.length > 0 || distance >= 20 || maxDrift > 0) {
158
- return "watch";
159
- }
160
- return "close";
161
- }
162
-
163
- function displayPath(filePath, cwd) {
164
- const relative = path.relative(cwd, filePath);
165
- if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
166
- return relative.split(path.sep).join("/");
167
- }
168
- return filePath.split(path.sep).join("/");
169
- }
170
-
171
- function capitalize(value) {
172
- return value.charAt(0).toUpperCase() + value.slice(1);
173
- }
1
+ import path from "node:path";
2
+ import { parseDocument } from "./document-model.js";
3
+ import { readUtf8FileBounded } from "./io-utils.js";
4
+ import { buildVoiceProfileV2, loadVoicePackV2 } from "./profile.js";
5
+ import { familyDiagnosticsFor, styleDistanceFromDiagnostics } from "./stylometry.js";
6
+
7
+ const REVIEW_MODES = {
8
+ loose: {
9
+ findingThresholds: null,
10
+ exitOnDrift: false,
11
+ },
12
+ balanced: {
13
+ findingThresholds: { evidence: 65, rhythm: 55, rhetoricalShape: 50 },
14
+ exitOnDrift: false,
15
+ },
16
+ strict: {
17
+ findingThresholds: { evidence: 75, rhythm: 70, rhetoricalShape: 65 },
18
+ exitOnDrift: true,
19
+ },
20
+ };
21
+
22
+ export function reviewVoiceDraftV2({ file, voice, cwd = process.cwd(), mode = "balanced" }) {
23
+ const reviewMode = normalizeReviewMode(mode);
24
+ const sourceProfile = typeof voice === "string" ? loadVoicePackV2(voice) : voice;
25
+ const filePath = resolvePath(cwd, file);
26
+ const draftDocument = parseDocument({
27
+ filePath,
28
+ rootDir: cwd,
29
+ contents: readUtf8FileBounded(filePath, { label: "Draft file", maxBytes: 2 * 1024 * 1024 }),
30
+ });
31
+ const draftProfile = buildVoiceProfileV2({ documents: [draftDocument] });
32
+
33
+ if (
34
+ draftDocument.wordCount < sourceProfile.calibration.minimumDraftSize.words ||
35
+ draftDocument.sentences.length < sourceProfile.calibration.minimumDraftSize.sentences
36
+ ) {
37
+ return {
38
+ schemaVersion: 2,
39
+ file: displayPath(filePath, cwd),
40
+ summary: {
41
+ mode: reviewMode,
42
+ fit: { band: "insufficient-evidence", distance: 0 },
43
+ familyScores: {},
44
+ corpusConfidence: sourceProfile.source.confidence,
45
+ },
46
+ findings: [],
47
+ exitCode: 0,
48
+ };
49
+ }
50
+
51
+ const familyDiagnostics = familyDiagnosticsFor(sourceProfile, draftProfile);
52
+ const familyScores = Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.score]));
53
+ const distance = styleDistanceFromDiagnostics(familyDiagnostics);
54
+ const findings = reviewFindings(sourceProfile, draftProfile, familyScores, reviewMode);
55
+ const fit = {
56
+ band: fitBand(distance, findings, familyDiagnostics),
57
+ distance,
58
+ };
59
+
60
+ return {
61
+ schemaVersion: 2,
62
+ file: displayPath(filePath, cwd),
63
+ summary: {
64
+ mode: reviewMode,
65
+ fit,
66
+ familyScores,
67
+ familyDistances: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.distance])),
68
+ familyDrift: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.drift])),
69
+ thresholds: Object.fromEntries(Object.entries(familyDiagnostics).map(([family, item]) => [family, item.threshold])),
70
+ corpusConfidence: sourceProfile.source.confidence,
71
+ },
72
+ findings,
73
+ exitCode: REVIEW_MODES[reviewMode].exitOnDrift && fit.band === "drift" ? 1 : 0,
74
+ };
75
+ }
76
+
77
+ export function renderVoiceReviewV2(result) {
78
+ const lines = [
79
+ "Voice rewrite notes, not AI detection.",
80
+ "",
81
+ result.file,
82
+ `Voice fit: ${capitalize(result.summary.fit.band)} (${result.summary.fit.distance} distance)`,
83
+ `Corpus confidence: ${capitalize(result.summary.corpusConfidence.band)} - ${result.summary.corpusConfidence.message}`,
84
+ "Family scores:",
85
+ ];
86
+
87
+ for (const [family, score] of Object.entries(result.summary.familyScores)) {
88
+ lines.push(`- ${family}: ${score}`);
89
+ }
90
+
91
+ lines.push("");
92
+ if (result.findings.length === 0) {
93
+ lines.push("No high-confidence V2 voice drift findings.");
94
+ lines.push("");
95
+ return lines.join("\n");
96
+ }
97
+
98
+ lines.push("Start here:");
99
+ for (const finding of result.findings.slice(0, 6)) {
100
+ lines.push(`${finding.priority} ${finding.family} ${finding.id}`);
101
+ lines.push(`Why flagged: ${finding.why}`);
102
+ lines.push(`Revise by: ${finding.action}`);
103
+ lines.push("");
104
+ }
105
+ return lines.join("\n");
106
+ }
107
+
108
+ function reviewFindings(source, draft, scores, mode) {
109
+ const modeConfig = REVIEW_MODES[mode];
110
+ if (source.source?.confidence?.band === "weak" || !modeConfig.findingThresholds) {
111
+ return [];
112
+ }
113
+ const findings = [];
114
+ if (scores.evidence < modeConfig.findingThresholds.evidence) {
115
+ findings.push({
116
+ id: "v2.evidence-drift",
117
+ family: "evidence",
118
+ priority: "review",
119
+ why: `Source evidence sentence rate is ${source.families.evidence.features.evidenceSentenceRate}; draft rate is ${draft.families.evidence.features.evidenceSentenceRate}.`,
120
+ action: "Add concrete support before broad claims: a scene, quote, number, citation, URL, sensory detail, or specific example.",
121
+ });
122
+ }
123
+ if (scores.rhythm < modeConfig.findingThresholds.rhythm) {
124
+ findings.push({
125
+ id: "v2.rhythm-drift",
126
+ family: "rhythm",
127
+ priority: "consider",
128
+ why: `Source median sentence/paragraph length is ${source.families.rhythm.features.sentenceWords.median}/${source.families.rhythm.features.paragraphWords.median}; draft is ${draft.families.rhythm.features.sentenceWords.median}/${draft.families.rhythm.features.paragraphWords.median}.`,
129
+ action: "Revise sentence and paragraph pacing toward the learned range.",
130
+ });
131
+ }
132
+ if (scores.rhetoricalShape < modeConfig.findingThresholds.rhetoricalShape) {
133
+ findings.push({
134
+ id: "v2.shape-drift",
135
+ family: "rhetoricalShape",
136
+ priority: "consider",
137
+ why: `Source opening shape is ${source.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ")}; draft opening shape is ${draft.families.rhetoricalShape.features.openingMoves.slice(0, 3).join(" -> ")}.`,
138
+ action: "Rework the opening so it uses a compatible scene, claim, contrast, reflection, or example sequence.",
139
+ });
140
+ }
141
+ return findings;
142
+ }
143
+
144
+ function normalizeReviewMode(mode) {
145
+ const normalized = String(mode ?? "balanced").toLowerCase();
146
+ if (!Object.hasOwn(REVIEW_MODES, normalized)) {
147
+ throw new Error(`Unsupported review mode: ${mode}. Expected loose, balanced, or strict.`);
148
+ }
149
+ return normalized;
150
+ }
151
+
152
+ function fitBand(distance, findings, familyDiagnostics) {
153
+ const maxDrift = Math.max(0, ...Object.values(familyDiagnostics).map((item) => item.drift));
154
+ if (findings.some((finding) => finding.priority === "review") || distance >= 35 || maxDrift >= 1.25) {
155
+ return "drift";
156
+ }
157
+ if (findings.length > 0 || distance >= 20 || maxDrift > 0) {
158
+ return "watch";
159
+ }
160
+ return "close";
161
+ }
162
+
163
+ function displayPath(filePath, cwd) {
164
+ const relative = path.relative(cwd, filePath);
165
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
166
+ return relative.split(path.sep).join("/");
167
+ }
168
+ return filePath.split(path.sep).join("/");
169
+ }
170
+
171
+ function resolvePath(cwd, value) {
172
+ return path.isAbsolute(value) ? value : path.resolve(cwd, value);
173
+ }
174
+
175
+ function capitalize(value) {
176
+ return value.charAt(0).toUpperCase() + value.slice(1);
177
+ }