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,91 @@
1
+ /**
2
+ * The six hard constraints from the PRD appendix, as deterministic rule assertions.
3
+ * Same spec drives three surfaces: `contract check` (CLI), playground badges, eval runner —
4
+ * template and evaluation share one source of truth (PRD: 模板与评测共用同一份规格).
5
+ *
6
+ * Known heuristic limits (do not oversell — see docs/ACCEPTANCE.md "Evidence boundaries"):
7
+ * - `lang-consistency` is SCRIPT detection (han/kana/latin…), not language identification;
8
+ * - `no-hallucinated-tech` relies on a fixed denylist;
9
+ * - `expand-not-answer` matches answer-style openers and trailing questions;
10
+ * - `polish-when-clear` (constraint #5) needs semantic judgment and is judge-only (M2).
11
+ * These assert format/edge compliance; they do NOT prove downstream task improvement.
12
+ */
13
+ import { detectScriptName } from './lang.js';
14
+
15
+ /** Common tech names — flagged when they appear in output but were never in the input (hard constraint #6). */
16
+ const TECH_DENYLIST = [
17
+ 'next.js', 'nuxt', 'react', 'vue', 'angular', 'svelte', 'tailwind', 'bootstrap', 'jquery',
18
+ 'django', 'flask', 'fastapi', 'spring', 'rails', 'laravel',
19
+ 'postgresql', 'mysql', 'mongodb', 'redis', 'sqlite',
20
+ 'docker', 'kubernetes', 'graphql', 'grpc', 'typescript', 'javascript', 'python', 'rust',
21
+ 'swift', 'kotlin', 'prisma', 'supabase', 'firebase', 'vercel', 'kafka', 'elasticsearch'
22
+ ];
23
+
24
+ const ANSWER_OPENERS = /^(好的|当然[啦咯]?|没问题|明白了|收到|以下是|这是|here'?s\b|here is\b|sure\b[,!]|certainly\b|i'?(ll| will| can) (help|create|write|generate|provide|design|build))/i;
25
+ const META_LABELS = /^(enhanced\s*prompt|optimized\s*prompt|优化后的?提示?词|增强后的?提示?词|改写后|新提示词)\s*[::]/i;
26
+ const WRAPPED_IN_QUOTES = /^['"“”‘’«»][\s\S]+['"“”‘’«»]$/;
27
+
28
+ function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
29
+
30
+ function result(id, title, pass, detail) {
31
+ return { id, title, pass, detail: detail || '' };
32
+ }
33
+
34
+ /**
35
+ * Assert the hard constraints. `opts.maxChars` defaults to 800.
36
+ * Rule 5 (`polish-when-clear`) is judge-only (needs semantic judgment) and intentionally absent here.
37
+ */
38
+ export function checkRules(original, enhanced, opts = {}) {
39
+ const maxChars = opts.maxChars ?? 800;
40
+ const out = String(enhanced ?? '');
41
+ const trimmed = out.trim();
42
+ const results = [];
43
+
44
+ results.push(result('non-empty', '输出非空', trimmed.length > 0, trimmed.length > 0 ? '' : 'empty output'));
45
+
46
+ const inScript = detectScriptName(original || '');
47
+ const outScript = detectScriptName(out);
48
+ results.push(result(
49
+ 'lang-consistency', '语言一致性',
50
+ inScript === outScript,
51
+ `input=${inScript}, output=${outScript}`
52
+ ));
53
+
54
+ const hasFence = out.includes('```');
55
+ const wrapped = WRAPPED_IN_QUOTES.test(trimmed);
56
+ const hasMeta = META_LABELS.test(trimmed);
57
+ results.push(result(
58
+ 'only-enhanced-text', '只输出增强文本',
59
+ !hasFence && !wrapped && !hasMeta,
60
+ [hasFence && 'markdown fence', wrapped && 'wrapping quotes', hasMeta && 'meta label'].filter(Boolean).join(', ')
61
+ ));
62
+
63
+ const chars = [...trimmed].length;
64
+ const dangling = /([-*+]+|[::])\s*$/.test(trimmed);
65
+ results.push(result(
66
+ 'length-limit', '长度与完整性',
67
+ chars > 0 && chars <= maxChars && !dangling,
68
+ `${chars}/${maxChars} chars${dangling ? ', dangling colon/marker' : ''}`
69
+ ));
70
+
71
+ const endsWithQuestion = /[??]\s*$/.test(trimmed);
72
+ const opener = ANSWER_OPENERS.exec(trimmed);
73
+ results.push(result(
74
+ 'expand-not-answer', '扩写而非回答',
75
+ !opener && !endsWithQuestion,
76
+ [opener && `answer opener "${opener[1]}"`, endsWithQuestion && 'ends with a question'].filter(Boolean).join(', ')
77
+ ));
78
+
79
+ const lowerOut = out.toLowerCase();
80
+ const lowerIn = String(original ?? '').toLowerCase();
81
+ const hallucinated = TECH_DENYLIST.filter((name) => lowerOut.includes(name) && !lowerIn.includes(name));
82
+ results.push(result(
83
+ 'no-hallucinated-tech', '无未提及的技术栈',
84
+ hallucinated.length === 0,
85
+ hallucinated.length ? `added: ${hallucinated.join(', ')}` : ''
86
+ ));
87
+
88
+ return { pass: results.every((r) => r.pass), results };
89
+ }
90
+
91
+ export { escapeRe };
@@ -0,0 +1,44 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { stripWrappingQuotes, stripFences, clampChars, postprocess } from '../src/clean.js';
4
+
5
+ test('stripWrappingQuotes removes paired quotes repeatedly', () => {
6
+ assert.equal(stripWrappingQuotes('"hello"'), 'hello');
7
+ assert.equal(stripWrappingQuotes('“你好世界”'), '你好世界');
8
+ assert.equal(stripWrappingQuotes('‘“嵌套”’'), '嵌套');
9
+ assert.equal(stripWrappingQuotes('「block」'), 'block');
10
+ });
11
+
12
+ test('stripWrappingQuotes keeps inner apostrophes', () => {
13
+ assert.equal(stripWrappingQuotes("it's ok"), "it's ok");
14
+ });
15
+
16
+ test('stripFences removes full and partial fences', () => {
17
+ assert.equal(stripFences('```\ntext\n```'), 'text');
18
+ assert.equal(stripFences('```md\n# title\n```'), '# title');
19
+ assert.equal(stripFences('```\nno closing'), 'no closing');
20
+ assert.equal(stripFences('plain'), 'plain');
21
+ });
22
+
23
+ test('clampChars cuts at sentence boundary under the limit', () => {
24
+ const t = '第一句。第二句。' + '长'.repeat(900);
25
+ const out = clampChars(t, 800);
26
+ assert.ok([...out].length <= 800);
27
+ assert.ok(out.endsWith('。') || out.endsWith('长'));
28
+ });
29
+
30
+ test('clampChars removes dangling colon and list markers', () => {
31
+ const t = '要点如下:' + 'x'.repeat(798);
32
+ const out = clampChars(t, 800);
33
+ assert.ok(!/[::]\s*$/.test(out));
34
+ assert.ok(!/[-*+]\s*$/.test(out));
35
+ });
36
+
37
+ test('clampChars leaves short text untouched', () => {
38
+ assert.equal(clampChars('短文本', 800), '短文本');
39
+ });
40
+
41
+ test('postprocess: null for empty, strips combo of fences + quotes', () => {
42
+ assert.equal(postprocess(' \n\t', 800), null);
43
+ assert.equal(postprocess('```\n“最终文本”\n```', 800), '最终文本');
44
+ });
@@ -0,0 +1,21 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { detectScriptName } from '../src/lang.js';
4
+
5
+ test('dominant script detection', () => {
6
+ assert.equal(detectScriptName('你好世界'), 'han');
7
+ assert.equal(detectScriptName('hello world'), 'latin');
8
+ assert.equal(detectScriptName('こんにちは世界'), 'japanese');
9
+ assert.equal(detectScriptName('Привет мир'), 'cyrillic');
10
+ assert.equal(detectScriptName('안녕하세요'), 'hangul');
11
+ });
12
+
13
+ test('mixed CJK/latin resolves to the CJK script', () => {
14
+ assert.equal(detectScriptName('用 React 重构这个 module'), 'han');
15
+ assert.equal(detectScriptName('refactor this module 用例'), 'han');
16
+ });
17
+
18
+ test('japanese beats han when kana present (enables zh→ja detection)', () => {
19
+ assert.equal(detectScriptName('世界'), 'han');
20
+ assert.equal(detectScriptName('世界です'), 'japanese');
21
+ });
@@ -0,0 +1,85 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { enhance, assembleMessages, hardConstraints } from '../src/pipeline.js';
4
+ import { PromptContractError } from '../src/errors.js';
5
+
6
+ const PROFILE = { name: 'coding-agent', maxChars: 800, body: 'You rewrite vague requests for a coding assistant.' };
7
+
8
+ function fakeProvider(reply, { fail } = {}) {
9
+ const calls = [];
10
+ return {
11
+ calls,
12
+ async complete(opts) {
13
+ calls.push(opts);
14
+ if (fail === 'abort') { const e = new Error('aborted'); e.name = 'AbortError'; throw e; }
15
+ if (fail === 'boom') throw new Error('socket hang up');
16
+ return { text: reply };
17
+ }
18
+ };
19
+ }
20
+
21
+ test('assembleMessages embeds profile body, constraints, strength and context', () => {
22
+ const { system, user } = assembleMessages('做个网站', { profile: PROFILE, strength: 'expand', context: 'repo: pet-project' });
23
+ assert.match(system, /You rewrite vague requests/);
24
+ assert.match(system, /HARD CONSTRAINTS:/);
25
+ assert.match(system, /under 800 characters/);
26
+ assert.match(system, /STRENGTH MODE: EXPAND/);
27
+ assert.match(user, /USER INPUT:\n做个网站/);
28
+ assert.match(user, /CONTEXT \(background[^\n]*\):\nrepo: pet-project/);
29
+ });
30
+
31
+ test('all three strengths render distinct modes', () => {
32
+ for (const s of ['polish', 'standard', 'expand']) {
33
+ const { system } = assembleMessages('x', { profile: PROFILE, strength: s });
34
+ assert.match(system, new RegExp(`STRENGTH MODE: ${s.toUpperCase()}`));
35
+ }
36
+ });
37
+
38
+ test('hardConstraints mirror the six eval rules', () => {
39
+ const hc = hardConstraints({ maxChars: 800, strength: 'standard' });
40
+ assert.match(hc, /same language as USER INPUT/);
41
+ assert.match(hc, /no markdown fences/);
42
+ assert.match(hc, /under 800 characters/);
43
+ assert.match(hc, /EXPAND, DO NOT ANSWER/);
44
+ assert.match(hc, /lightly polish/);
45
+ assert.match(hc, /do not add requirements, features, or technologies/);
46
+ });
47
+
48
+ test('enhance: happy path returns cleaned text + original + meta', async () => {
49
+ const provider = fakeProvider(' “请结构化地说明……” ');
50
+ const res = await enhance('帮我解释', { profile: PROFILE, provider, model: 'm1', strength: 'standard' });
51
+ assert.equal(res.text, '请结构化地说明……');
52
+ assert.equal(res.original, '帮我解释');
53
+ assert.equal(res.meta.profile, 'coding-agent');
54
+ assert.equal(res.meta.model, 'm1');
55
+ assert.equal(typeof res.meta.ms, 'number');
56
+ assert.equal(provider.calls[0].maxTokens, Math.ceil(800 * 1.2));
57
+ });
58
+
59
+ test('enhance: empty input → empty_input', async () => {
60
+ await assert.rejects(
61
+ () => enhance(' ', { profile: PROFILE, provider: fakeProvider('x') }),
62
+ (e) => e instanceof PromptContractError && e.code === 'empty_input'
63
+ );
64
+ });
65
+
66
+ test('enhance: empty provider result → llm_error', async () => {
67
+ await assert.rejects(
68
+ () => enhance('hello', { profile: PROFILE, provider: fakeProvider(' ') }),
69
+ (e) => e.code === 'llm_error'
70
+ );
71
+ });
72
+
73
+ test('enhance: caller abort → aborted (ADR-017 semantics)', async () => {
74
+ await assert.rejects(
75
+ () => enhance('hello', { profile: PROFILE, provider: fakeProvider(null, { fail: 'abort' }) }),
76
+ (e) => e.code === 'aborted'
77
+ );
78
+ });
79
+
80
+ test('enhance: unexpected provider crash → provider_unavailable', async () => {
81
+ await assert.rejects(
82
+ () => enhance('hello', { profile: PROFILE, provider: fakeProvider(null, { fail: 'boom' }) }),
83
+ (e) => e.code === 'provider_unavailable'
84
+ );
85
+ });
@@ -0,0 +1,40 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseProfile, parseYamlLite } from '../src/profile.js';
4
+ import { PromptContractError } from '../src/errors.js';
5
+
6
+ const SAMPLE = `---
7
+ name: test-profile
8
+ domain: 测试场景
9
+ maxChars: 600
10
+ noUnmentionedTech: true
11
+ ---
12
+ You rewrite vague requests. Preserve intent and language.`;
13
+
14
+ test('parseProfile reads frontmatter scalars and body', () => {
15
+ const p = parseProfile(SAMPLE, { path: 'test.md' });
16
+ assert.equal(p.name, 'test-profile');
17
+ assert.equal(p.domain, '测试场景');
18
+ assert.equal(p.maxChars, 600);
19
+ assert.equal(p.noUnmentionedTech, true);
20
+ assert.match(p.body, /Preserve intent and language\.$/);
21
+ });
22
+
23
+ test('parseProfile applies defaults', () => {
24
+ const p = parseProfile('---\nname: minimal\n---\nBody here.');
25
+ assert.equal(p.maxChars, 800);
26
+ assert.equal(p.noUnmentionedTech, true);
27
+ assert.equal(p.domain, '');
28
+ });
29
+
30
+ test('parseProfile rejects malformed input with config_error', () => {
31
+ assert.throws(() => parseProfile('no frontmatter here'), (e) => e instanceof PromptContractError && e.code === 'config_error');
32
+ assert.throws(() => parseProfile('---\ndomain: x\n---\nbody'), (e) => e.code === 'config_error');
33
+ });
34
+
35
+ test('parseYamlLite handles string lists', () => {
36
+ const y = parseYamlLite('items:\n - a\n - b\nflag: false\ncount: 3');
37
+ assert.deepEqual(y.items, ['a', 'b']);
38
+ assert.equal(y.flag, false);
39
+ assert.equal(y.count, 3);
40
+ });
@@ -0,0 +1,53 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { checkRules } from '../src/rules.js';
4
+
5
+ const GOOD_EN = 'Build a small responsive website showcasing one pet: a photo gallery, a biography page, and an update feed. Keep navigation simple and skip comments.';
6
+ const GOOD_ZH = '做一个展示宠物的小型网站:包含照片画廊、简介页与动态页,导航保持单层,暂不需要评论功能。';
7
+
8
+ test('good outputs pass all six assertions', () => {
9
+ for (const [orig, out] of [['A website for my dog', GOOD_EN], ['帮我做一个宠物网站', GOOD_ZH]]) {
10
+ const { pass, results } = checkRules(orig, out);
11
+ assert.equal(pass, true, JSON.stringify(results.filter((r) => !r.pass)));
12
+ }
13
+ });
14
+
15
+ test('lang-consistency fails on script switch', () => {
16
+ const { results } = checkRules('帮我做一个网站', GOOD_EN);
17
+ const r = results.find((r) => r.id === 'lang-consistency');
18
+ assert.equal(r.pass, false);
19
+ });
20
+
21
+ test('only-enhanced-text fails on fences, wrapping quotes and meta labels', () => {
22
+ for (const bad of ['```text\nx\n```', '“结果”', '增强后的提示词:写一封邮件', 'Enhanced prompt: do something']) {
23
+ const { results } = checkRules('写一封邮件', bad, { maxChars: 800 });
24
+ assert.equal(results.find((r) => r.id === 'only-enhanced-text').pass, false, bad);
25
+ }
26
+ });
27
+
28
+ test('length-limit fails on overflow and dangling colon', () => {
29
+ const long = 'x'.repeat(900);
30
+ const { results } = checkRules('x', long);
31
+ assert.equal(results.find((r) => r.id === 'length-limit').pass, false);
32
+ const { results: r2 } = checkRules('x', '要点如下:');
33
+ assert.equal(r2.find((r) => r.id === 'length-limit').pass, false);
34
+ });
35
+
36
+ test('expand-not-answer fails on answer openers and clarifying questions', () => {
37
+ for (const bad of ['好的,以下是实现方案:先装依赖再写组件。', "Here's how you can do it: install the deps first.", '你想用哪种框架?']) {
38
+ const { results } = checkRules('做个网站', bad);
39
+ assert.equal(results.find((r) => r.id === 'expand-not-answer').pass, false, bad);
40
+ }
41
+ });
42
+
43
+ test('no-hallucinated-tech flags tech names absent from input', () => {
44
+ const { results } = checkRules('做一个读书笔记应用', '用 React 和 PostgreSQL 构建笔记应用');
45
+ assert.equal(results.find((r) => r.id === 'no-hallucinated-tech').pass, false);
46
+ const ok = checkRules('用 React 做个笔记应用', '用 React 实现笔记的增删改查,数据存本地文件,支持全文检索');
47
+ assert.equal(ok.results.find((r) => r.id === 'no-hallucinated-tech').pass, true);
48
+ });
49
+
50
+ test('japanese output fails for chinese input (kana detector)', () => {
51
+ const { results } = checkRules('サイトを作って', '做一个展示宠物的网站:包含照片画廊。');
52
+ assert.equal(results.find((r) => r.id === 'lang-consistency').pass, false);
53
+ });
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { serve } from '../src/server.js';
3
+
4
+ serve({ argv: process.argv.slice(2) }).catch((err) => {
5
+ process.stderr.write(`[prompt-contract] fatal: ${err.message}\n`);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@prompt-contract/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "PromptContract MCP server — enhance_prompt tool + boost-* prompts, stdio JSON-RPC, zero dependencies",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "bin": {
8
+ "prompt-contract-mcp": "./bin/prompt-contract-mcp.js"
9
+ }
10
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * PromptContract MCP server (PRD §6) — stdio, newline-delimited JSON-RPC 2.0.
3
+ *
4
+ * Two integration modes, deliberately asymmetric in their configuration needs:
5
+ * - tool `enhance_prompt`: agent-invoked; the server calls the configured provider (BYOK via env/config).
6
+ * - prompt `contract-<profile>`: user-invoked slash command; the rewrite instruction is injected into the
7
+ * client's OWN model — this mode needs NO API key and NO provider config, so provider resolution is
8
+ * lazy: an unconfigured server starts fine and only tool calls report `config_error`.
9
+ */
10
+ import { enhance, hardConstraints, STRENGTHS, PromptContractError, normalizeError } from '../../core/src/index.js';
11
+ import { loadProfiles, loadProfile, resolveConfig } from '../../core/src/node.js';
12
+ import { createOpenAIProvider } from '../../providers/src/openai.js';
13
+ import { createOllamaProvider } from '../../providers/src/ollama.js';
14
+
15
+ const SERVER_INFO = { name: 'prompt-contract', version: '0.1.0' };
16
+
17
+ export function createServer({ profiles, getRuntime }) {
18
+ function promptTextFor(profile, text) {
19
+ // Zero-key mode: hand the client's own model the same spec our engine would use.
20
+ return [
21
+ profile.body.trim(),
22
+ hardConstraints({ maxChars: profile.maxChars, strength: 'standard' }),
23
+ `STRENGTH MODE: ${STRENGTHS.standard}`,
24
+ '',
25
+ 'USER INPUT:',
26
+ text,
27
+ '',
28
+ 'Rewrite the USER INPUT as specified and return only the enhanced prompt text.'
29
+ ].join('\n\n');
30
+ }
31
+
32
+ async function handle(msg) {
33
+ const { id, method, params } = msg;
34
+ if (method === 'initialize') {
35
+ return {
36
+ protocolVersion: params?.protocolVersion ?? '2025-06-18',
37
+ capabilities: { tools: { listChanged: false }, prompts: { listChanged: false } },
38
+ serverInfo: SERVER_INFO
39
+ };
40
+ }
41
+ if (method === 'ping') return {};
42
+
43
+ if (method === 'tools/list') {
44
+ return {
45
+ tools: [{
46
+ name: 'enhance_prompt',
47
+ description: 'Rewrite a vague user prompt into a clear, specific, executable prompt. Returns only the enhanced text. Requires a configured provider (CONTRACT_API_KEY/CONTRACT_PROVIDER or config file).',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ text: { type: 'string', description: 'The raw user prompt to enhance' },
52
+ profile: { type: 'string', enum: profiles.map((p) => p.name), description: 'Scenario profile (default: coding-agent)' },
53
+ strength: { type: 'string', enum: ['polish', 'standard', 'expand'], description: 'Enhancement strength (default: standard)' },
54
+ context: { type: 'string', description: 'Optional background context (e.g. repo summary) assembled into the prompt' }
55
+ },
56
+ required: ['text']
57
+ }
58
+ }]
59
+ };
60
+ }
61
+
62
+ if (method === 'tools/call') {
63
+ if (params?.name !== 'enhance_prompt') {
64
+ throw rpcError(id, -32602, `unknown tool "${params?.name}"`);
65
+ }
66
+ const args = params.arguments ?? {};
67
+ try {
68
+ const { config, provider } = getRuntime(); // lazy — a prompt-only server never pays this
69
+ const profile = args.profile ? loadProfile(args.profile) : profiles.find((p) => p.name === 'coding-agent') ?? profiles[0];
70
+ const res = await enhance(String(args.text ?? ''), {
71
+ profile,
72
+ provider,
73
+ model: config.model,
74
+ strength: args.strength,
75
+ context: args.context,
76
+ timeoutMs: 30000
77
+ });
78
+ return { content: [{ type: 'text', text: res.text }] };
79
+ } catch (err) {
80
+ const e = normalizeError(err);
81
+ return { content: [{ type: 'text', text: `prompt-contract error ${e.code}: ${e.message}` }], isError: true };
82
+ }
83
+ }
84
+
85
+ if (method === 'prompts/list') {
86
+ return {
87
+ prompts: profiles.map((p) => ({
88
+ name: `contract-${p.name}`,
89
+ description: `Enhance a prompt with the "${p.name}" profile (uses this client's own model, no API key needed)`,
90
+ arguments: [{ name: 'text', description: 'The raw prompt to enhance', required: true }]
91
+ }))
92
+ };
93
+ }
94
+
95
+ if (method === 'prompts/get') {
96
+ const name = String(params?.name ?? '');
97
+ const profile = profiles.find((p) => `contract-${p.name}` === name);
98
+ if (!profile) throw rpcError(id, -32602, `unknown prompt "${name}"`);
99
+ const text = String(params?.arguments?.text ?? '');
100
+ if (!text.trim()) throw rpcError(id, -32602, 'argument "text" is required');
101
+ return { description: `PromptContract · ${profile.name}`, messages: [{ role: 'user', content: { type: 'text', text: promptTextFor(profile, text) } }] };
102
+ }
103
+
104
+ throw rpcError(id, -32601, `method not found: ${method}`);
105
+ }
106
+
107
+ function rpcError(id, code, message) {
108
+ const err = new Error(message);
109
+ err.rpcCode = code;
110
+ err.rpcId = id;
111
+ return err;
112
+ }
113
+
114
+ return { handle, state: { profiles } };
115
+ }
116
+
117
+ /** Wire the server to stdin/stdout. One line = one JSON-RPC message. Never log prompt content. */
118
+ export async function serve({ stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, argv = [] } = {}) {
119
+ const flag = (name) => {
120
+ const i = argv.indexOf(name);
121
+ return i >= 0 ? argv[i + 1] : undefined;
122
+ };
123
+ const profiles = loadProfiles(flag('--profiles-dir'));
124
+
125
+ // Provider/config is resolved lazily so the zero-key prompt mode truly needs zero configuration.
126
+ let runtime = null;
127
+ let startupError = null;
128
+ function getRuntime() {
129
+ if (runtime) return runtime;
130
+ if (startupError) throw startupError;
131
+ const config = resolveConfig({ provider: flag('--provider'), baseUrl: flag('--base-url'), apiKey: flag('--api-key'), model: flag('--model'), configPath: flag('--config') });
132
+ const provider = config.provider === 'ollama'
133
+ ? createOllamaProvider({ baseUrl: config.baseUrl })
134
+ : createOpenAIProvider({ baseUrl: config.baseUrl, apiKey: config.apiKey });
135
+ provider.warmup({ model: config.model }).catch(() => {}); // §7.6-1: prewarm, best effort
136
+ runtime = { config, provider };
137
+ return runtime;
138
+ }
139
+ try {
140
+ getRuntime();
141
+ } catch (err) {
142
+ startupError = err;
143
+ stderr.write(`[prompt-contract] tool mode unavailable (${err.code ?? 'error'}: ${err.message}) — prompts (zero-key) remain available\n`);
144
+ }
145
+
146
+ const server = createServer({ profiles, getRuntime });
147
+ stderr.write(`[prompt-contract] mcp server ready (tool mode: ${runtime ? `provider=${runtime.config.provider}` : 'unconfigured'}, profiles=${profiles.length})\n`);
148
+
149
+ let buffer = '';
150
+ stdin.setEncoding('utf8');
151
+ stdin.on('data', (chunk) => {
152
+ buffer += chunk;
153
+ let nl;
154
+ while ((nl = buffer.indexOf('\n')) >= 0) {
155
+ const line = buffer.slice(0, nl).trim();
156
+ buffer = buffer.slice(nl + 1);
157
+ if (!line) continue;
158
+ let msg;
159
+ try { msg = JSON.parse(line); } catch {
160
+ write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
161
+ continue;
162
+ }
163
+ if (msg.id === undefined || msg.id === null) continue; // notification — no response
164
+ server.handle(msg)
165
+ .then((result) => write({ jsonrpc: '2.0', id: msg.id, result }))
166
+ .catch((err) => {
167
+ const code = err.rpcCode ?? -32603;
168
+ write({ jsonrpc: '2.0', id: err.rpcId ?? msg.id, error: { code, message: err.message } });
169
+ });
170
+ }
171
+ });
172
+ stdin.on('end', () => process.exit(0));
173
+
174
+ function write(obj) {
175
+ stdout.write(JSON.stringify(obj) + '\n');
176
+ }
177
+ }
178
+
179
+ if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
180
+ serve({ argv: process.argv.slice(2) }).catch((err) => {
181
+ if (err instanceof PromptContractError) { process.stderr.write(`[prompt-contract] ${err.code}: ${err.message}\n`); process.exit(1); }
182
+ throw err;
183
+ });
184
+ }