prompt-contract 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +102 -0
  3. package/README.zh-CN.md +73 -0
  4. package/package.json +42 -0
  5. package/packages/cli/bin/contract.js +266 -0
  6. package/packages/cli/package.json +10 -0
  7. package/packages/cli/src/spike-0.js +582 -0
  8. package/packages/cli/test/cli.test.js +89 -0
  9. package/packages/cli/test/spike-0.test.js +260 -0
  10. package/packages/core/bench/bench.js +48 -0
  11. package/packages/core/package.json +11 -0
  12. package/packages/core/src/clean.js +58 -0
  13. package/packages/core/src/errors.js +26 -0
  14. package/packages/core/src/index.js +10 -0
  15. package/packages/core/src/lang.js +37 -0
  16. package/packages/core/src/node.js +83 -0
  17. package/packages/core/src/pipeline.js +84 -0
  18. package/packages/core/src/profile.js +50 -0
  19. package/packages/core/src/rules.js +91 -0
  20. package/packages/core/test/clean.test.js +44 -0
  21. package/packages/core/test/lang.test.js +21 -0
  22. package/packages/core/test/pipeline.test.js +85 -0
  23. package/packages/core/test/profile.test.js +40 -0
  24. package/packages/core/test/rules.test.js +53 -0
  25. package/packages/mcp-server/bin/prompt-contract-mcp.js +7 -0
  26. package/packages/mcp-server/package.json +10 -0
  27. package/packages/mcp-server/src/server.js +184 -0
  28. package/packages/mcp-server/test/mcp.test.js +167 -0
  29. package/packages/providers/package.json +7 -0
  30. package/packages/providers/src/ollama.js +89 -0
  31. package/packages/providers/src/openai.js +89 -0
  32. package/packages/providers/test/providers.test.js +64 -0
  33. package/profiles/coding-agent.md +9 -0
  34. package/profiles/image-gen.md +9 -0
  35. package/profiles/writing.md +9 -0
@@ -0,0 +1,260 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ captureSelectedText,
5
+ validatePasteBackDryRun,
6
+ buildCompatibilityReport,
7
+ DEFAULT_SPIKE_THRESHOLDS,
8
+ isPlainTextClipboardInfo,
9
+ runSpike0,
10
+ } from '../src/spike-0.js';
11
+
12
+ function context(overrides = {}) {
13
+ return {
14
+ processName: 'Google Chrome',
15
+ bundleId: 'com.google.Chrome',
16
+ pid: 123,
17
+ windowTitle: 'Prompt test',
18
+ focus: {
19
+ role: 'AXTextField',
20
+ subrole: 'AXStandardWindow',
21
+ identifier: 'prompt-input',
22
+ title: '',
23
+ description: 'Prompt input',
24
+ },
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ function fakeAdapter({
30
+ clipboard = 'keep this clipboard',
31
+ selectedText = 'selected prompt',
32
+ contexts = [context(), context(), context()],
33
+ copyError,
34
+ clipboardCheckError,
35
+ } = {}) {
36
+ const state = { clipboard, events: [], contexts: [...contexts] };
37
+ return {
38
+ state,
39
+ async readClipboard() {
40
+ state.events.push('readClipboard');
41
+ return state.clipboard;
42
+ },
43
+ async checkClipboardRestorable() {
44
+ state.events.push('checkClipboardRestorable');
45
+ if (clipboardCheckError) throw clipboardCheckError;
46
+ },
47
+ async writeClipboard(value) {
48
+ state.events.push(['writeClipboard', value]);
49
+ state.clipboard = value;
50
+ },
51
+ async copySelection() {
52
+ state.events.push('copySelection');
53
+ if (copyError) throw copyError;
54
+ state.clipboard = selectedText;
55
+ },
56
+ async getFocusIdentity() {
57
+ state.events.push('getFocusIdentity');
58
+ return state.contexts.shift() || context();
59
+ },
60
+ async sleep() {
61
+ state.events.push('sleep');
62
+ },
63
+ };
64
+ }
65
+
66
+ test('captureSelectedText restores the original clipboard after a successful copy', async () => {
67
+ const adapter = fakeAdapter();
68
+
69
+ const result = await captureSelectedText(adapter, { settleMs: 0 });
70
+
71
+ assert.equal(result.selectedText, 'selected prompt');
72
+ assert.equal(result.selectedTextLength, 15);
73
+ assert.equal(result.clipboardRestored, true);
74
+ assert.equal(result.userTextMutated, false);
75
+ assert.equal(adapter.state.clipboard, 'keep this clipboard');
76
+ assert.deepEqual(adapter.state.events, [
77
+ 'readClipboard',
78
+ 'checkClipboardRestorable',
79
+ 'getFocusIdentity',
80
+ 'copySelection',
81
+ 'sleep',
82
+ 'readClipboard',
83
+ 'getFocusIdentity',
84
+ ['writeClipboard', 'keep this clipboard'],
85
+ 'readClipboard',
86
+ ]);
87
+ });
88
+
89
+ test('captureSelectedText restores the clipboard when copy fails', async () => {
90
+ const adapter = fakeAdapter({ copyError: new Error('Accessibility denied') });
91
+
92
+ const result = await captureSelectedText(adapter, { settleMs: 0 });
93
+
94
+ assert.equal(result.selectedText, null);
95
+ assert.equal(result.clipboardRestored, true);
96
+ assert.equal(result.userTextMutated, false);
97
+ assert.match(result.error, /Accessibility denied/);
98
+ assert.equal(adapter.state.clipboard, 'keep this clipboard');
99
+ assert.equal(adapter.state.events.some((event) => Array.isArray(event) && event[0] === 'writeClipboard'), true);
100
+ });
101
+
102
+ test('captureSelectedText refuses an unsupported clipboard without rewriting it', async () => {
103
+ const adapter = fakeAdapter({ clipboardCheckError: new Error('clipboard_not_plain_text') });
104
+
105
+ const result = await captureSelectedText(adapter, { settleMs: 0 });
106
+
107
+ assert.equal(result.selectedText, null);
108
+ assert.match(result.error, /clipboard_not_plain_text/);
109
+ assert.equal(result.clipboardRestored, false);
110
+ assert.equal(result.clipboardUntouched, true);
111
+ assert.equal(adapter.state.clipboard, 'keep this clipboard');
112
+ assert.equal(adapter.state.events.includes('copySelection'), false);
113
+ assert.equal(adapter.state.events.some((event) => Array.isArray(event) && event[0] === 'writeClipboard'), false);
114
+ });
115
+
116
+ test('isPlainTextClipboardInfo accepts text-only pasteboards and rejects rich types', () => {
117
+ assert.equal(
118
+ isPlainTextClipboardInfo('«class utf8», 0, «class ut16», 2, string, 0, Unicode text, 0'),
119
+ true,
120
+ );
121
+ assert.equal(
122
+ isPlainTextClipboardInfo('«class utf8», 4, «class HTML», 128'),
123
+ false,
124
+ );
125
+ });
126
+
127
+ test('runSpike0 supports a setup delay before each target without changing capture semantics', async () => {
128
+ const adapter = fakeAdapter();
129
+ const announcements = [];
130
+
131
+ await runSpike0({
132
+ adapter,
133
+ targets: ['Chrome'],
134
+ iterations: 1,
135
+ setupDelayMs: 25,
136
+ interactive: false,
137
+ announce: (message) => announcements.push(message),
138
+ now: () => new Date('2026-09-07T00:00:00.000Z'),
139
+ });
140
+
141
+ assert.deepEqual(announcements, ['Focus Chrome and select text now; capture starts after the setup delay.']);
142
+ assert.equal(adapter.state.events[0], 'sleep');
143
+ assert.equal(adapter.state.events.includes('copySelection'), true);
144
+ });
145
+
146
+ test('validatePasteBackDryRun checks focus stability without issuing paste', async () => {
147
+ const adapter = fakeAdapter();
148
+ const before = context();
149
+
150
+ const result = await validatePasteBackDryRun(adapter, {
151
+ capturedContext: before,
152
+ selectedText: 'selected prompt',
153
+ pauseMs: 0,
154
+ });
155
+
156
+ assert.equal(result.mode, 'dry-run');
157
+ assert.equal(result.executed, false);
158
+ assert.equal(result.wouldPasteBack, true);
159
+ assert.equal(result.userTextMutated, false);
160
+ assert.equal(adapter.state.events.includes('pasteSelection'), false);
161
+ });
162
+
163
+ test('validatePasteBackDryRun rejects focus drift without changing user text', async () => {
164
+ const adapter = fakeAdapter({ contexts: [context({ pid: 456, windowTitle: 'Other app' })] });
165
+
166
+ const result = await validatePasteBackDryRun(adapter, {
167
+ capturedContext: context(),
168
+ selectedText: 'selected prompt',
169
+ pauseMs: 0,
170
+ });
171
+
172
+ assert.equal(result.wouldPasteBack, false);
173
+ assert.equal(result.reason, 'focus_drift');
174
+ assert.equal(result.executed, false);
175
+ assert.equal(result.userTextMutated, false);
176
+ });
177
+
178
+ test('validatePasteBackDryRun tolerates a transiently unavailable window title', async () => {
179
+ const adapter = fakeAdapter({ contexts: [context({ windowTitle: '' })] });
180
+
181
+ const result = await validatePasteBackDryRun(adapter, {
182
+ capturedContext: context(),
183
+ selectedText: 'selected prompt',
184
+ pauseMs: 0,
185
+ });
186
+
187
+ assert.equal(result.wouldPasteBack, true);
188
+ assert.equal(result.focusStable, true);
189
+ assert.equal(result.executed, false);
190
+ });
191
+
192
+ test('buildCompatibilityReport applies the Spike-0 cohort thresholds', () => {
193
+ const runs = [];
194
+ for (const target of ['Chrome', 'PyCharm', 'iTerm']) {
195
+ for (let iteration = 1; iteration <= 20; iteration++) {
196
+ runs.push({
197
+ target,
198
+ iteration,
199
+ capture: {
200
+ selectedTextCaptured: true,
201
+ clipboardRestored: true,
202
+ clipboardRestoreVerified: true,
203
+ focusRecorded: true,
204
+ },
205
+ pasteBack: { mode: 'dry-run', wouldPasteBack: true, contextAtValidation: {} },
206
+ safety: { userTextMutated: false, pasteCommandSent: false },
207
+ });
208
+ }
209
+ }
210
+
211
+ const report = buildCompatibilityReport({
212
+ runs,
213
+ targets: ['Chrome', 'PyCharm', 'iTerm'],
214
+ thresholds: DEFAULT_SPIKE_THRESHOLDS,
215
+ platformInfo: { os: 'darwin', arch: 'arm64' },
216
+ startedAt: '2026-09-07T00:00:00.000Z',
217
+ finishedAt: '2026-09-07T00:00:01.000Z',
218
+ });
219
+
220
+ assert.equal(report.schemaVersion, 'prompt-contract/spike-0.v1');
221
+ assert.equal(report.mode, 'dry-run');
222
+ assert.equal(report.summary.captureSuccessRate, 1);
223
+ assert.equal(report.summary.clipboardRestoreSuccessRate, 1);
224
+ assert.equal(report.decision.pass, true);
225
+ assert.equal(report.decision.watchGate, 'closed');
226
+ });
227
+
228
+ test('buildCompatibilityReport fails below the combined capture threshold', () => {
229
+ const runs = [];
230
+ for (const target of ['Chrome', 'PyCharm', 'iTerm']) {
231
+ for (let iteration = 1; iteration <= 20; iteration++) {
232
+ const failed = target === 'iTerm' && iteration <= 7;
233
+ runs.push({
234
+ target,
235
+ iteration,
236
+ capture: {
237
+ selectedTextCaptured: !failed,
238
+ clipboardRestored: true,
239
+ clipboardRestoreVerified: true,
240
+ focusRecorded: true,
241
+ },
242
+ pasteBack: { mode: 'dry-run', wouldPasteBack: !failed, contextAtValidation: {} },
243
+ safety: { userTextMutated: false, pasteCommandSent: false },
244
+ });
245
+ }
246
+ }
247
+
248
+ const report = buildCompatibilityReport({
249
+ runs,
250
+ targets: ['Chrome', 'PyCharm', 'iTerm'],
251
+ thresholds: DEFAULT_SPIKE_THRESHOLDS,
252
+ platformInfo: { os: 'darwin', arch: 'arm64' },
253
+ startedAt: '2026-09-07T00:00:00.000Z',
254
+ finishedAt: '2026-09-07T00:00:01.000Z',
255
+ });
256
+
257
+ assert.equal(report.summary.captureSuccessRate, 0.8833);
258
+ assert.equal(report.decision.pass, false);
259
+ assert.match(report.decision.reasons.join(' '), /dry-run paste-back/);
260
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Engine overhead benchmark — the part of the latency budget we own (PRD §5.3/§7.6).
3
+ * The model TTFT is external and dominates end-to-end latency; this proves the engine adds
4
+ * effectively nothing on top (budget: P50 < 5ms, in practice microseconds).
5
+ */
6
+ import { assembleMessages } from '../src/pipeline.js';
7
+ import { postprocess } from '../src/clean.js';
8
+ import { checkRules } from '../src/rules.js';
9
+
10
+ const PROFILE = { name: 'coding-agent', maxChars: 800, body: 'You rewrite vague requests for a coding assistant. '.repeat(4) };
11
+ const INPUTS = [
12
+ '帮我做一个展示我家狗的网站',
13
+ 'A website for my dog',
14
+ 'explain this code',
15
+ '把这份周报改正式一点',
16
+ '一只在雪地里的柴犬'
17
+ ];
18
+ const OUTPUT = '做一个展示宠物的小型网站:包含照片画廊、简介页与动态页,导航保持单层,暂不需要评论功能。';
19
+
20
+ const N = 20000;
21
+ const times = [];
22
+ // warmup
23
+ for (let i = 0; i < 500; i++) {
24
+ const { system, user } = assembleMessages(INPUTS[i % INPUTS.length], { profile: PROFILE, strength: 'standard' });
25
+ postprocess(system + user, 800);
26
+ }
27
+ for (let i = 0; i < N; i++) {
28
+ const t0 = performance.now();
29
+ const { system, user } = assembleMessages(INPUTS[i % INPUTS.length], { profile: PROFILE, strength: 'standard' });
30
+ const cleaned = postprocess(`“${OUTPUT}”`, 800);
31
+ checkRules(INPUTS[i % INPUTS.length], cleaned, { maxChars: 800 });
32
+ if (system.length === 0 || user.length === 0) throw new Error('assembly broke');
33
+ times.push(performance.now() - t0);
34
+ }
35
+ times.sort((a, b) => a - b);
36
+ const p50 = times[Math.floor(N * 0.5)];
37
+ const p95 = times[Math.floor(N * 0.95)];
38
+ const mean = times.reduce((a, b) => a + b, 0) / N;
39
+
40
+ console.log(`engine overhead per enhancement (assemble + clean + rules), n=${N}`);
41
+ console.log(` P50: ${p50.toFixed(3)}ms`);
42
+ console.log(` P95: ${p95.toFixed(3)}ms`);
43
+ console.log(` mean: ${mean.toFixed(3)}ms`);
44
+ if (p50 >= 5) {
45
+ console.error('BUDGET VIOLATION: P50 must stay under 5ms');
46
+ process.exit(1);
47
+ }
48
+ console.log('budget check: PASS (P50 < 5ms)');
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@prompt-contract/core",
3
+ "version": "0.1.0",
4
+ "description": "PromptContract engine — profile assembly, single-shot LLM call contract, deterministic cleaning and rule assertions. Zero runtime dependencies, browser-safe.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./node": "./src/node.js"
10
+ }
11
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Deterministic post-processing of LLM output — the layer WorkBuddy calls "清洗" (PRD §1.2).
3
+ * No LLM output reaches a user surface without passing through here.
4
+ */
5
+
6
+ const QUOTE_PAIRS = [
7
+ ['"', '"'], ["'", "'"],
8
+ ['\u201c', '\u201d'], // “ ”
9
+ ['\u2018', '\u2019'], // ‘ ’
10
+ ['\u00ab', '\u00bb'], // « »
11
+ ['\u300c', '\u300d'] // 「 」
12
+ ];
13
+
14
+ /** Remove wrapping quote pairs, repeatedly (WorkBuddy stripWrappingQuotes, generalized). */
15
+ export function stripWrappingQuotes(t) {
16
+ let prev;
17
+ do {
18
+ prev = t;
19
+ t = t.trim();
20
+ for (const [open, close] of QUOTE_PAIRS) {
21
+ if (t.length >= 2 && t.startsWith(open) && t.endsWith(close)) {
22
+ t = t.slice(1, -1).trim();
23
+ break;
24
+ }
25
+ }
26
+ } while (t !== prev);
27
+ return t;
28
+ }
29
+
30
+ /** Strip markdown code fences (hard constraint #2: no fences in output). */
31
+ export function stripFences(t) {
32
+ const fenced = t.match(/^\s*```[\w+-]*\s*\r?\n([\s\S]*?)\r?\n?```\s*$/);
33
+ if (fenced) return fenced[1];
34
+ return t.replace(/^\s*```[\w+-]*\s*\r?\n?/, '').replace(/\r?\n?```\s*$/, '');
35
+ }
36
+
37
+ /**
38
+ * Clamp to maxChars at a sentence boundary; never leave a dangling list marker or trailing colon
39
+ * (hard constraint #3). Counts code points, not UTF-16 units.
40
+ */
41
+ export function clampChars(t, maxChars) {
42
+ const chars = [...t];
43
+ if (chars.length <= maxChars) return t;
44
+ const cut = chars.slice(0, maxChars).join('');
45
+ const sentence = cut.match(/[\s\S]*[.!?。!?;;\n]/);
46
+ let out = (sentence ? sentence[0] : cut).replace(/([-*+]+|[::])\s*$/, '').trimEnd();
47
+ if (!out) out = cut.trimEnd();
48
+ return out;
49
+ }
50
+
51
+ export function postprocess(raw, maxChars) {
52
+ let t = String(raw ?? '');
53
+ t = stripFences(t);
54
+ t = stripWrappingQuotes(t);
55
+ t = t.trim();
56
+ if (!t) return null; // caller maps to llm_error (WorkBuddy: empty result → llm_error)
57
+ return clampChars(t, maxChars);
58
+ }
@@ -0,0 +1,26 @@
1
+ /** Structured error codes — stable public contract across all shells (CLI/MCP/playground). PRD §1.2/§5.2. */
2
+ export const CODES = {
3
+ EMPTY_INPUT: 'empty_input',
4
+ PROVIDER_UNAVAILABLE: 'provider_unavailable',
5
+ LLM_ERROR: 'llm_error',
6
+ ABORTED: 'aborted',
7
+ CONFIG: 'config_error',
8
+ PROFILE_NOT_FOUND: 'profile_not_found'
9
+ };
10
+
11
+ export class PromptContractError extends Error {
12
+ constructor(code, message, { cause } = {}) {
13
+ super(message || code, { cause });
14
+ this.name = 'PromptContractError';
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ /** Normalize any thrown value into a PromptContractError with a stable code (ADR-017 semantics: abort is fast, result is dropped). */
20
+ export function normalizeError(err) {
21
+ if (err instanceof PromptContractError) return err;
22
+ if (err && (err.name === 'AbortError' || err.name === 'TimeoutError' || err.code === 'ABORT_ERR')) {
23
+ return new PromptContractError(CODES.ABORTED, 'aborted by caller', { cause: err });
24
+ }
25
+ return new PromptContractError(CODES.PROVIDER_UNAVAILABLE, err?.message || 'unexpected error', { cause: err });
26
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Browser-safe public API of the engine. Node-only extras (fs profile loading, config resolution)
3
+ * live in ./node.js so this file can be imported directly by the playground.
4
+ */
5
+ export { CODES, PromptContractError, normalizeError } from './errors.js';
6
+ export { detectScriptName } from './lang.js';
7
+ export { stripWrappingQuotes, stripFences, clampChars, postprocess } from './clean.js';
8
+ export { checkRules } from './rules.js';
9
+ export { parseProfile, parseYamlLite } from './profile.js';
10
+ export { enhance, assembleMessages, hardConstraints, STRENGTHS } from './pipeline.js';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Script-level language detection (heuristic, zero-cost — PRD §5.2: no extra LLM call).
3
+ * Used for meta info and the `lang-consistency` rule assertion, not for prompt injection.
4
+ */
5
+
6
+ const SCRIPT_TESTS = [
7
+ ['kana', /[\u3040-\u309f\u30a0-\u30ff]/],
8
+ ['hangul', /[\uac00-\ud7af\u1100-\u11ff]/],
9
+ ['han', /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/],
10
+ ['cyrillic', /[\u0400-\u04ff]/],
11
+ ['arabic', /[\u0600-\u06ff]/],
12
+ ['devanagari', /[\u0900-\u097f]/],
13
+ ['thai', /[\u0e00-\u0e7f]/],
14
+ ['hebrew', /[\u0590-\u05ff]/],
15
+ ['greek', /[\u0370-\u03ff]/]
16
+ ];
17
+
18
+ /**
19
+ * Return the dominant script name of `text`.
20
+ * Kana beats han on purpose: Japanese text mixes both, so any kana presence means Japanese;
21
+ * Chinese input has no kana, which makes zh↔ja confusion detectable by the lang-consistency rule.
22
+ */
23
+ export function detectScriptName(text) {
24
+ if (!text) return 'latin';
25
+ const counts = { kana: 0, hangul: 0, han: 0, cyrillic: 0, arabic: 0, devanagari: 0, thai: 0, hebrew: 0, greek: 0 };
26
+ for (const ch of text) {
27
+ for (const [name, re] of SCRIPT_TESTS) {
28
+ if (re.test(ch)) { counts[name]++; break; }
29
+ }
30
+ }
31
+ if (counts.kana > 0) return 'japanese';
32
+ let best = null, bestN = 0;
33
+ for (const [name] of SCRIPT_TESTS) {
34
+ if (counts[name] > bestN) { best = name; bestN = counts[name]; }
35
+ }
36
+ return best || 'latin';
37
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Node-only helpers: profile directory loading and provider/config resolution.
3
+ * Keeps src/index.js browser-safe for the playground (PRD §5.4: BYOK, key never leaves the machine).
4
+ */
5
+ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname } from 'node:path';
10
+ import { parseProfile } from './profile.js';
11
+ import { PromptContractError, CODES } from './errors.js';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ /** Locate the built-in profiles/ directory (repo root) from wherever the caller lives. */
16
+ export function resolveProfilesDir(explicit) {
17
+ const candidates = [];
18
+ if (explicit) candidates.push(resolve(explicit));
19
+ for (const start of [process.cwd(), __dirname]) {
20
+ let dir = start;
21
+ for (let i = 0; i < 6; i++) {
22
+ candidates.push(join(dir, 'profiles'));
23
+ const parent = dirname(dir);
24
+ if (parent === dir) break;
25
+ dir = parent;
26
+ }
27
+ }
28
+ for (const c of candidates) {
29
+ try {
30
+ if (statSync(c).isDirectory()) return c;
31
+ } catch { /* keep probing */ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ export function loadProfiles(explicitDir) {
37
+ const dir = resolveProfilesDir(explicitDir);
38
+ if (!dir) throw new PromptContractError(CODES.CONFIG, 'profiles directory not found (looked from cwd and package upward)');
39
+ const profiles = [];
40
+ for (const f of readdirSync(dir).sort()) {
41
+ if (!f.endsWith('.md')) continue;
42
+ profiles.push(parseProfile(readFileSync(join(dir, f), 'utf8'), { path: join(dir, f) }));
43
+ }
44
+ if (profiles.length === 0) throw new PromptContractError(CODES.CONFIG, `no .md profiles found in ${dir}`);
45
+ return profiles;
46
+ }
47
+
48
+ export function loadProfile(name, explicitDir) {
49
+ const profiles = loadProfiles(explicitDir);
50
+ const found = profiles.find((p) => p.name === name);
51
+ if (!found) {
52
+ throw new PromptContractError(CODES.PROFILE_NOT_FOUND, `unknown profile "${name}" (available: ${profiles.map((p) => p.name).join(', ')})`);
53
+ }
54
+ return found;
55
+ }
56
+
57
+ /**
58
+ * Config resolution order: explicit flags > env (PB_*) > config file (CONTRACT_CONFIG or ~/.prompt-contract/config.json).
59
+ * Defaults follow PRD §3.2: local Ollama if nothing else is configured (privacy-first).
60
+ * `flags.configPath` / `CONTRACT_CONFIG` exist so tests and embedded shells can isolate the file source.
61
+ */
62
+ export function resolveConfig(flags = {}) {
63
+ const cfgPath = flags.configPath ?? process.env.CONTRACT_CONFIG ?? join(homedir(), '.prompt-contract', 'config.json');
64
+ let file = {};
65
+ try {
66
+ if (existsSync(cfgPath)) file = JSON.parse(readFileSync(cfgPath, 'utf8'));
67
+ } catch (err) {
68
+ throw new PromptContractError(CODES.CONFIG, `invalid config at ${cfgPath}: ${err.message}`);
69
+ }
70
+ const pick = (...sources) => { for (const s of sources) if (s !== undefined && s !== null && s !== '') return s; return undefined; };
71
+
72
+ const provider = pick(flags.provider, process.env.CONTRACT_PROVIDER, file.provider, guessProvider(flags.baseUrl ?? process.env.CONTRACT_BASE_URL ?? file.baseUrl), 'openai');
73
+ const baseUrl = String(pick(flags.baseUrl, process.env.CONTRACT_BASE_URL, file.baseUrl, provider === 'ollama' ? 'http://localhost:11434' : 'https://api.openai.com/v1')).replace(/\/+$/, '');
74
+ const apiKey = pick(flags.apiKey, process.env.CONTRACT_API_KEY, file.apiKey, provider === 'ollama' ? 'ollama' : undefined);
75
+ const model = pick(flags.model, process.env.CONTRACT_MODEL, file.model, provider === 'ollama' ? 'qwen3:4b' : 'gpt-4o-mini');
76
+ if (!apiKey) throw new PromptContractError(CODES.CONFIG, `no API key: set CONTRACT_API_KEY, --api-key, or ~/.prompt-contract/config.json (or use --provider ollama)`);
77
+ return { provider, baseUrl, apiKey, model };
78
+ }
79
+
80
+ function guessProvider(baseUrl) {
81
+ if (baseUrl && /:11434/.test(baseUrl)) return 'ollama';
82
+ return undefined;
83
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Core pipeline: assemble (profile + hard constraints + strength + context) → single LLM call
3
+ * → deterministic postprocess. No middleware, no queues, no cache (PRD §7.6 / decision D8).
4
+ */
5
+ import { PromptContractError, CODES, normalizeError } from './errors.js';
6
+ import { detectScriptName } from './lang.js';
7
+ import { postprocess } from './clean.js';
8
+
9
+ export const STRENGTHS = {
10
+ polish: 'POLISH: The input is already adequate. Fix only wording, grammar, and clarity. Preserve its structure and length. Do not add new content.',
11
+ standard: 'STANDARD: Make a substantive enhancement — clarify the task, its scope, constraints, and the expected output, while preserving the original intent.',
12
+ expand: 'EXPAND: Turn the input into a structured task specification — goal, scope, explicit constraints, acceptance criteria, and edge cases. Stay realistic; do not invent features.'
13
+ };
14
+
15
+ /** Hard constraints appended to every request — mirror of eval/rules.js (PRD appendix: 共用同一份规格). */
16
+ export function hardConstraints({ maxChars, strength }) {
17
+ return [
18
+ 'HARD CONSTRAINTS:',
19
+ '1. LANGUAGE: Respond strictly in the same language as USER INPUT. Never output language labels or meta commentary about the language.',
20
+ '2. OUTPUT ONLY THE ENHANCED PROMPT TEXT — no explanations, no preface, no markdown fences, no quotes around the whole text.',
21
+ `3. Keep it under ${maxChars} characters, complete and concise; never end with a dangling list item or a trailing colon.`,
22
+ '4. EXPAND, DO NOT ANSWER: never answer the request, never ask the user questions, never request code snippets; focus on WHAT is wanted, not HOW.',
23
+ '5. If the input is already clear and specific, only lightly polish it.',
24
+ '6. Do not invent facts and do not add requirements, features, or technologies the input does not mention.'
25
+ ].join('\n');
26
+ }
27
+
28
+ /** Assemble the two-message request. Exported for tests and the MCP zero-key prompt mode. */
29
+ export function assembleMessages(text, { profile, strength = 'standard', context, maxChars } = {}) {
30
+ const chars = maxChars ?? profile?.maxChars ?? 800;
31
+ const strengthName = STRENGTHS[strength] ? strength : 'standard';
32
+ const parts = [];
33
+ if (profile?.body) parts.push(profile.body.trim());
34
+ parts.push(hardConstraints({ maxChars: chars, strength }));
35
+ parts.push(`STRENGTH MODE: ${STRENGTHS[strengthName]}`);
36
+ const system = parts.join('\n\n');
37
+
38
+ let user = `USER INPUT:\n${text}`;
39
+ if (context && String(context).trim()) user += `\n\nCONTEXT (background information, do not enhance this part):\n${String(context).trim()}`;
40
+ return { system, user, strength: strengthName, maxChars: chars };
41
+ }
42
+
43
+ /**
44
+ * @param {string} text raw user prompt
45
+ * @param {object} opts { profile, provider, model, strength, context, maxChars, signal, timeoutMs, onDelta }
46
+ * @returns {Promise<{text, original, meta}>}
47
+ */
48
+ export async function enhance(text, opts = {}) {
49
+ if (!text || !String(text).trim()) throw new PromptContractError(CODES.EMPTY_INPUT, 'empty input');
50
+ if (!opts.provider || typeof opts.provider.complete !== 'function') {
51
+ throw new PromptContractError(CODES.CONFIG, 'no provider configured');
52
+ }
53
+ const { system, user, strength, maxChars } = assembleMessages(text, opts);
54
+ const started = performance.now();
55
+ let raw;
56
+ try {
57
+ const res = await opts.provider.complete({
58
+ system,
59
+ user,
60
+ model: opts.model,
61
+ signal: opts.signal,
62
+ onDelta: opts.onDelta,
63
+ maxTokens: Math.ceil(maxChars * 1.2), // CJK ≈ 1 token/char worst case
64
+ timeoutMs: opts.timeoutMs ?? 30000
65
+ });
66
+ raw = res?.text ?? '';
67
+ } catch (err) {
68
+ throw normalizeError(err);
69
+ }
70
+ const cleaned = postprocess(raw, maxChars);
71
+ if (cleaned === null) throw new PromptContractError(CODES.LLM_ERROR, 'provider returned an empty result');
72
+ return {
73
+ text: cleaned,
74
+ original: text,
75
+ meta: {
76
+ profile: opts.profile?.name ?? null,
77
+ strength,
78
+ model: opts.model ?? null,
79
+ script: detectScriptName(cleaned),
80
+ chars: [...cleaned].length,
81
+ ms: Math.round(performance.now() - started)
82
+ }
83
+ };
84
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Profile format: Markdown + minimal frontmatter — community-contribution surface (PRD §3.2: "PR 即贡献").
3
+ * YAML subset only: `key: value` scalars (string/number/bool) and `key:` + indented `- item` lists.
4
+ */
5
+ import { PromptContractError, CODES } from './errors.js';
6
+
7
+ export function parseYamlLite(src) {
8
+ const out = {};
9
+ let currentListKey = null;
10
+ for (const rawLine of src.split(/\r?\n/)) {
11
+ if (!rawLine.trim() || rawLine.trim().startsWith('#')) continue;
12
+ const listItem = rawLine.match(/^\s+-\s+(.*)$/);
13
+ if (listItem) {
14
+ if (!currentListKey) throw new PromptContractError(CODES.CONFIG, `list item without a parent key: "${rawLine.trim()}"`);
15
+ out[currentListKey].push(coerce(listItem[1].trim()));
16
+ continue;
17
+ }
18
+ const kv = rawLine.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
19
+ if (!kv) throw new PromptContractError(CODES.CONFIG, `unparsable frontmatter line: "${rawLine.trim()}"`);
20
+ const [, key, value] = kv;
21
+ currentListKey = null;
22
+ if (value === '') { out[key] = []; currentListKey = key; } else { out[key] = coerce(value.trim()); }
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function coerce(v) {
28
+ if (/^(true|false)$/.test(v)) return v === 'true';
29
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
30
+ if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v, 10);
31
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1);
32
+ return v;
33
+ }
34
+
35
+ export function parseProfile(markdown, { path } = {}) {
36
+ const m = String(markdown).match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
37
+ if (!m) throw new PromptContractError(CODES.CONFIG, `profile${path ? ` ${path}` : ''}: missing frontmatter block`);
38
+ const meta = parseYamlLite(m[1]);
39
+ if (!meta.name || typeof meta.name !== 'string') {
40
+ throw new PromptContractError(CODES.CONFIG, `profile${path ? ` ${path}` : ''}: frontmatter must declare a string "name"`);
41
+ }
42
+ return {
43
+ name: meta.name,
44
+ domain: typeof meta.domain === 'string' ? meta.domain : '',
45
+ maxChars: Number.isFinite(meta.maxChars) && meta.maxChars > 0 ? meta.maxChars : 800,
46
+ noUnmentionedTech: meta.noUnmentionedTech !== false,
47
+ body: m[2].trim(),
48
+ path
49
+ };
50
+ }