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,167 @@
1
+ import { test, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+ import { createMockServer, ZH_RESULT } from '../../../mock/server.js';
7
+
8
+ let mock, base;
9
+ const SERVER = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'server.js');
10
+
11
+ before(async () => {
12
+ mock = createMockServer({});
13
+ const port = await mock.listen();
14
+ base = `http://127.0.0.1:${port}`;
15
+ });
16
+
17
+ after(async () => { await mock.close(); });
18
+
19
+ function startServer() {
20
+ const child = spawn(process.execPath, [SERVER], {
21
+ env: { ...process.env, CONTRACT_PROVIDER: 'openai', CONTRACT_BASE_URL: `${base}/v1`, CONTRACT_API_KEY: 'test-key-123', CONTRACT_MODEL: 'mock-model' }
22
+ });
23
+ const pending = [];
24
+ let buffer = '';
25
+ const waiters = [];
26
+ child.stdout.setEncoding('utf8');
27
+ child.stdout.on('data', (d) => {
28
+ buffer += d;
29
+ let nl;
30
+ while ((nl = buffer.indexOf('\n')) >= 0) {
31
+ const line = buffer.slice(0, nl).trim();
32
+ buffer = buffer.slice(nl + 1);
33
+ if (!line) continue;
34
+ const msg = JSON.parse(line);
35
+ const w = waiters.shift();
36
+ if (w) w(msg);
37
+ else pending.push(msg);
38
+ }
39
+ });
40
+ let stderr = '';
41
+ child.stderr.on('data', (d) => { stderr += d; });
42
+ const request = (obj) => new Promise((resolveReq) => {
43
+ const waiter = (msg) => resolveReq(msg);
44
+ if (pending.length) waiter(pending.shift()); else waiters.push(waiter);
45
+ child.stdin.write(JSON.stringify(obj) + '\n');
46
+ });
47
+ const done = () => new Promise((r) => child.on('close', r));
48
+ const kill = () => { child.stdin.end(); };
49
+ return { child, request, done, kill, getStderr: () => stderr };
50
+ }
51
+
52
+ test('MCP: initialize → tools → tool call → prompts, full handshake', async () => {
53
+ const s = startServer();
54
+ try {
55
+ const init = await s.request({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } });
56
+ assert.equal(init.result.serverInfo.name, 'prompt-contract');
57
+ assert.ok(init.result.capabilities.tools);
58
+ assert.ok(init.result.capabilities.prompts);
59
+
60
+ const tools = await s.request({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
61
+ assert.equal(tools.result.tools.length, 1);
62
+ assert.equal(tools.result.tools[0].name, 'enhance_prompt');
63
+ assert.deepEqual(tools.result.tools[0].inputSchema.required, ['text']);
64
+
65
+ const call = await s.request({
66
+ jsonrpc: '2.0', id: 3, method: 'tools/call',
67
+ params: { name: 'enhance_prompt', arguments: { text: '帮我做一个展示我家狗的网站' } }
68
+ });
69
+ assert.equal(call.result.isError, undefined);
70
+ assert.equal(call.result.content[0].text, ZH_RESULT);
71
+
72
+ const callProfiled = await s.request({
73
+ jsonrpc: '2.0', id: 4, method: 'tools/call',
74
+ params: { name: 'enhance_prompt', arguments: { text: 'A website for my dog', profile: 'writing', strength: 'polish' } }
75
+ });
76
+ assert.equal(callProfiled.result.isError, undefined);
77
+ assert.ok(callProfiled.result.content[0].text.length > 0);
78
+
79
+ const prompts = await s.request({ jsonrpc: '2.0', id: 5, method: 'prompts/list' });
80
+ assert.deepEqual(prompts.result.prompts.map((p) => p.name), ['contract-coding-agent', 'contract-image-gen', 'contract-writing']);
81
+
82
+ const get = await s.request({
83
+ jsonrpc: '2.0', id: 6, method: 'prompts/get',
84
+ params: { name: 'contract-writing', arguments: { text: '帮我写一封请假邮件' } }
85
+ });
86
+ const promptText = get.result.messages[0].content.text;
87
+ assert.match(promptText, /USER INPUT:\s*\n+帮我写一封请假邮件/);
88
+ assert.match(promptText, /HARD CONSTRAINTS:/);
89
+
90
+ const unknown = await s.request({ jsonrpc: '2.0', id: 7, method: 'bogus/method' });
91
+ assert.equal(unknown.error.code, -32601);
92
+
93
+ const badTool = await s.request({
94
+ jsonrpc: '2.0', id: 8, method: 'tools/call',
95
+ params: { name: 'enhance_prompt', arguments: { text: '' } }
96
+ });
97
+ assert.equal(badTool.result.isError, true);
98
+ assert.match(badTool.result.content[0].text, /empty_input/);
99
+ } finally {
100
+ s.kill();
101
+ await s.done();
102
+ }
103
+ });
104
+
105
+ test('MCP: notification messages get no response frame', async () => {
106
+ const s = startServer();
107
+ try {
108
+ child_notify(s);
109
+ const res = await s.request({ jsonrpc: '2.0', id: 100, method: 'ping' });
110
+ assert.deepEqual(res.result, {});
111
+ } finally {
112
+ s.kill();
113
+ await s.done();
114
+ }
115
+ });
116
+
117
+ test('MCP: zero-key startup — prompts work, tool calls report config_error honestly (R12 caveat fixed)', async () => {
118
+ // strip every config source so nothing can satisfy the tool mode
119
+ const cleanEnv = { ...process.env };
120
+ for (const k of Object.keys(cleanEnv)) if (k.startsWith('PB_')) delete cleanEnv[k];
121
+ cleanEnv.CONTRACT_CONFIG = '/tmp/definitely-missing-contract-config.json';
122
+ const child = spawn(process.execPath, [SERVER], { env: cleanEnv });
123
+ const pending = [];
124
+ let buffer = '';
125
+ child.stdout.setEncoding('utf8');
126
+ child.stdout.on('data', (d) => {
127
+ buffer += d;
128
+ let nl;
129
+ while ((nl = buffer.indexOf('\n')) >= 0) {
130
+ const line = buffer.slice(0, nl).trim();
131
+ buffer = buffer.slice(nl + 1);
132
+ if (line) pending.push(JSON.parse(line));
133
+ }
134
+ });
135
+ const request = (obj) => new Promise((resolveReq) => {
136
+ const check = () => {
137
+ if (pending.length) resolveReq(pending.shift());
138
+ else setTimeout(check, 20);
139
+ };
140
+ child.stdin.write(JSON.stringify(obj) + '\n');
141
+ check();
142
+ });
143
+ try {
144
+ const init = await request({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
145
+ assert.equal(init.result.serverInfo.name, 'prompt-contract');
146
+
147
+ const get = await request({
148
+ jsonrpc: '2.0', id: 2, method: 'prompts/get',
149
+ params: { name: 'contract-coding-agent', arguments: { text: '做个网站' } }
150
+ });
151
+ assert.match(get.result.messages[0].content.text, /USER INPUT:\s*\n+做个网站/);
152
+
153
+ const call = await request({
154
+ jsonrpc: '2.0', id: 3, method: 'tools/call',
155
+ params: { name: 'enhance_prompt', arguments: { text: '做个网站' } }
156
+ });
157
+ assert.equal(call.result.isError, true);
158
+ assert.match(call.result.content[0].text, /config_error/);
159
+ } finally {
160
+ child.stdin.end();
161
+ await new Promise((r) => child.on('close', r));
162
+ }
163
+ });
164
+
165
+ function child_notify(s) {
166
+ s.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
167
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "@prompt-contract/providers",
3
+ "version": "0.1.0",
4
+ "description": "Provider adapters — OpenAI-compatible SSE and Ollama (with keep_alive model pinning). Fetch-based, browser-safe.",
5
+ "type": "module",
6
+ "license": "Apache-2.0"
7
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Ollama provider — native /api/chat (ndjson streaming).
3
+ * Key detail (PRD §7.6-1 / risk R9): every request carries `keep_alive` so the small model stays
4
+ * resident in memory — a cold model load takes seconds and would kill the hotkey experience.
5
+ */
6
+ import { PromptContractError } from '../../core/src/errors.js';
7
+
8
+ export function createOllamaProvider({ baseUrl = 'http://localhost:11434', keepAlive = '60m', fetchImpl = globalThis.fetch } = {}) {
9
+ const root = String(baseUrl).replace(/\/+$/, '');
10
+ return {
11
+ name: 'ollama',
12
+ keepAlive,
13
+ /** Preload the model into memory so the first hotkey press doesn't pay the cold-load cost. */
14
+ async warmup({ model, signal } = {}) {
15
+ if (!model) return;
16
+ try {
17
+ await fetchImpl(`${root}/api/generate`, {
18
+ method: 'POST',
19
+ signal,
20
+ headers: { 'content-type': 'application/json' },
21
+ body: JSON.stringify({ model, prompt: '', keep_alive: keepAlive })
22
+ });
23
+ } catch { /* best effort — doctor/doctor-report surfaces connectivity */ }
24
+ },
25
+ async complete({ system, user, model, signal, onDelta, maxTokens = 1024, timeoutMs = 30000, temperature = 0.7 }) {
26
+ if (!model) throw new PromptContractError('config_error', 'ollama provider requires a model');
27
+ let timer = null;
28
+ let timeoutCtrl = null;
29
+ let fullSignal = signal;
30
+ if (!signal && timeoutMs) {
31
+ timeoutCtrl = new AbortController();
32
+ timer = setTimeout(() => timeoutCtrl.abort(new DOMException('timeout', 'TimeoutError')), timeoutMs);
33
+ fullSignal = timeoutCtrl.signal;
34
+ }
35
+ let res;
36
+ try {
37
+ res = await fetchImpl(`${root}/api/chat`, {
38
+ method: 'POST',
39
+ signal: fullSignal,
40
+ headers: { 'content-type': 'application/json' },
41
+ body: JSON.stringify({
42
+ model,
43
+ stream: true,
44
+ keep_alive: keepAlive,
45
+ options: { num_predict: maxTokens, temperature },
46
+ messages: [
47
+ { role: 'system', content: system },
48
+ { role: 'user', content: user }
49
+ ]
50
+ })
51
+ });
52
+ } catch (err) {
53
+ if (timer) clearTimeout(timer);
54
+ throw err;
55
+ }
56
+ if (!res.ok) {
57
+ if (timer) clearTimeout(timer);
58
+ let detail = '';
59
+ try { detail = (await res.text()).slice(0, 300); } catch { /* body unreadable */ }
60
+ throw new PromptContractError('provider_unavailable', `ollama HTTP ${res.status}${detail ? `: ${detail}` : ''}`);
61
+ }
62
+ let text = '';
63
+ const reader = res.body.getReader();
64
+ const decoder = new TextDecoder();
65
+ let buffer = '';
66
+ try {
67
+ for (;;) {
68
+ const { done, value } = await reader.read();
69
+ if (done) break;
70
+ buffer += decoder.decode(value, { stream: true });
71
+ let nl;
72
+ while ((nl = buffer.indexOf('\n')) >= 0) {
73
+ const line = buffer.slice(0, nl).trim();
74
+ buffer = buffer.slice(nl + 1);
75
+ if (!line) continue;
76
+ let json;
77
+ try { json = JSON.parse(line); } catch { continue; }
78
+ const delta = json.message?.content;
79
+ if (delta) { text += delta; if (onDelta) onDelta(delta); }
80
+ if (json.done) return { text };
81
+ }
82
+ }
83
+ } finally {
84
+ if (timer) clearTimeout(timer);
85
+ }
86
+ return { text };
87
+ }
88
+ };
89
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * OpenAI-compatible provider — the universal protocol (covers OpenAI, DeepSeek, Qwen, GLM, Moonshot,
3
+ * OpenRouter, vLLM, LM Studio, Ollama's /v1 …). Single streaming POST, no handshake (PRD §7.6-4).
4
+ */
5
+ import { PromptContractError } from '../../core/src/errors.js';
6
+
7
+ /** Combine caller signal + internal timeout. Caller abort must always stay effective (ADR-017). */
8
+ function withTimeout(signal, timeoutMs) {
9
+ const timeoutCtrl = new AbortController();
10
+ const timer = timeoutMs
11
+ ? setTimeout(() => timeoutCtrl.abort(new DOMException('timeout', 'TimeoutError')), timeoutMs)
12
+ : null;
13
+ let combined = timeoutCtrl.signal;
14
+ if (signal) {
15
+ if (typeof AbortSignal.any === 'function') {
16
+ combined = AbortSignal.any([signal, timeoutCtrl.signal]);
17
+ } else {
18
+ signal.addEventListener('abort', () => timeoutCtrl.abort(signal.reason), { once: true });
19
+ }
20
+ }
21
+ return { signal: combined, cleanup: () => { if (timer) clearTimeout(timer); } };
22
+ }
23
+
24
+ export function createOpenAIProvider({ baseUrl = 'https://api.openai.com/v1', apiKey = '', fetchImpl = globalThis.fetch } = {}) {
25
+ const root = String(baseUrl).replace(/\/+$/, '');
26
+ return {
27
+ name: 'openai',
28
+ /** Open one TLS/TCP connection early so the first real request pays no handshake (PRD §7.6-1). */
29
+ async warmup({ signal } = {}) {
30
+ try { await fetchImpl(`${root}/models`, { headers: { authorization: `Bearer ${apiKey}` }, signal }); } catch { /* best effort */ }
31
+ },
32
+ async complete({ system, user, model, signal, onDelta, maxTokens = 1024, timeoutMs = 30000, temperature = 0.7 }) {
33
+ const { signal: fullSignal, cleanup } = withTimeout(signal, timeoutMs);
34
+ let res;
35
+ try {
36
+ res = await fetchImpl(`${root}/chat/completions`, {
37
+ method: 'POST',
38
+ signal: fullSignal,
39
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
40
+ body: JSON.stringify({
41
+ model,
42
+ stream: true,
43
+ max_tokens: maxTokens,
44
+ temperature,
45
+ messages: [
46
+ { role: 'system', content: system },
47
+ { role: 'user', content: user }
48
+ ]
49
+ })
50
+ });
51
+ } catch (err) {
52
+ cleanup();
53
+ throw err; // pipeline normalizes AbortError / network errors
54
+ }
55
+ if (!res.ok) {
56
+ cleanup();
57
+ let detail = '';
58
+ try { detail = (await res.text()).slice(0, 300); } catch { /* body unreadable */ }
59
+ throw new PromptContractError('provider_unavailable', `provider HTTP ${res.status}${detail ? `: ${detail}` : ''}`);
60
+ }
61
+ let text = '';
62
+ const reader = res.body.getReader();
63
+ const decoder = new TextDecoder();
64
+ let buffer = '';
65
+ try {
66
+ for (;;) {
67
+ const { done, value } = await reader.read();
68
+ if (done) break;
69
+ buffer += decoder.decode(value, { stream: true });
70
+ let nl;
71
+ while ((nl = buffer.indexOf('\n')) >= 0) {
72
+ const line = buffer.slice(0, nl).trim();
73
+ buffer = buffer.slice(nl + 1);
74
+ if (!line.startsWith('data:')) continue;
75
+ const data = line.slice(5).trim();
76
+ if (!data || data === '[DONE]') continue;
77
+ let json;
78
+ try { json = JSON.parse(data); } catch { continue; }
79
+ const delta = json.choices?.[0]?.delta?.content;
80
+ if (delta) { text += delta; if (onDelta) onDelta(delta); }
81
+ }
82
+ }
83
+ } finally {
84
+ cleanup();
85
+ }
86
+ return { text };
87
+ }
88
+ };
89
+ }
@@ -0,0 +1,64 @@
1
+ import { test, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createMockServer, EN_RESULT, ZH_RESULT } from '../../../mock/server.js';
4
+ import { createOpenAIProvider } from '../src/openai.js';
5
+ import { createOllamaProvider } from '../src/ollama.js';
6
+ import { PromptContractError } from '../../core/src/errors.js';
7
+
8
+ let mock, base;
9
+ before(async () => {
10
+ mock = createMockServer({});
11
+ const port = await mock.listen();
12
+ base = `http://127.0.0.1:${port}`;
13
+ });
14
+ after(async () => { await mock.close(); });
15
+
16
+ test('openai provider reassembles SSE chunks in order', async () => {
17
+ const p = createOpenAIProvider({ baseUrl: `${base}/v1`, apiKey: 'test-key-123' });
18
+ let deltas = 0;
19
+ const { text } = await p.complete({ system: 's', user: 'USER INPUT:\n帮我做一个展示我家狗的网站', model: 'mock-model', onDelta: () => deltas++ });
20
+ assert.equal(text, ZH_RESULT);
21
+ assert.ok(deltas > 3, `expected multiple deltas, got ${deltas}`);
22
+ });
23
+
24
+ test('openai provider handles english input', async () => {
25
+ const p = createOpenAIProvider({ baseUrl: `${base}/v1`, apiKey: 'test-key-123' });
26
+ const { text } = await p.complete({ system: 's', user: 'USER INPUT:\nA website for my dog', model: 'mock-model' });
27
+ assert.equal(text, EN_RESULT);
28
+ });
29
+
30
+ test('openai provider maps non-200 to provider_unavailable', async () => {
31
+ const p = createOpenAIProvider({ baseUrl: base, apiKey: 'test-key-123' }); // /chat/completions → 404
32
+ await assert.rejects(
33
+ () => p.complete({ system: 's', user: 'u', model: 'm' }),
34
+ (e) => e instanceof PromptContractError && e.code === 'provider_unavailable'
35
+ );
36
+ });
37
+
38
+ test('openai provider: caller abort cancels the stream (ADR-017)', async () => {
39
+ const p = createOpenAIProvider({ baseUrl: `${base}/v1`, apiKey: 'test-key-123' });
40
+ const ctrl = new AbortController();
41
+ const promise = p.complete({
42
+ system: 's', user: 'USER INPUT:\nA website for my dog', model: 'm',
43
+ signal: ctrl.signal,
44
+ onDelta: () => ctrl.abort()
45
+ });
46
+ await assert.rejects(promise, (e) => e.name === 'AbortError');
47
+ await new Promise((r) => setTimeout(r, 50));
48
+ assert.ok(mock.state.aborts >= 1, 'mock should have observed the aborted connection');
49
+ });
50
+
51
+ test('ollama provider sends keep_alive so the model stays warm (R10)', async () => {
52
+ const p = createOllamaProvider({ baseUrl: base, keepAlive: '60m' });
53
+ const { text } = await p.complete({ system: 's', user: 'USER INPUT:\n帮我做一个展示我家狗的网站', model: 'mock-model', maxTokens: 960 });
54
+ assert.equal(text, ZH_RESULT);
55
+ assert.equal(mock.state.lastChatBody.keep_alive, '60m');
56
+ assert.equal(mock.state.lastChatBody.options.num_predict, 960);
57
+ });
58
+
59
+ test('ollama warmup preloads the model via /api/generate (R10)', async () => {
60
+ const p = createOllamaProvider({ baseUrl: base, keepAlive: '30m' });
61
+ await p.warmup({ model: 'mock-model' });
62
+ assert.equal(mock.state.lastGenerateBody.model, 'mock-model');
63
+ assert.equal(mock.state.lastGenerateBody.keep_alive, '30m');
64
+ });
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: coding-agent
3
+ domain: 编码 agent 任务规范化(Claude Code / Cursor / Cline 等)
4
+ maxChars: 800
5
+ noUnmentionedTech: true
6
+ ---
7
+ You are a prompt rewriting specialist for requests submitted to an AI coding assistant. Your only job is to rewrite the user's vague request into a precise, executable task specification while preserving its intent and language.
8
+
9
+ A good specification states: what to build or change, the scope and boundaries, observable acceptance criteria, relevant constraints, and what is explicitly out of scope. It describes outcomes rather than implementation steps, and it never introduces tools, frameworks, or requirements the requester did not mention.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: image-gen
3
+ domain: 图像生成提示词(Midjourney / 即梦 / SD 类)
4
+ maxChars: 600
5
+ noUnmentionedTech: true
6
+ ---
7
+ You are a prompt rewriting specialist for text-to-image requests. Your only job is to expand the user's vague image idea into a vivid, unambiguous generation prompt, in the same language as the request.
8
+
9
+ A good image prompt states: the main subject and its action, composition and framing, lighting and atmosphere, style and medium, and level of detail. Keep only qualities the user implied or that naturally serve the subject; never inject named artists, brands, or proprietary style names the user did not mention.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: writing
3
+ domain: 通用写作与改写(语气、结构、受众)
4
+ maxChars: 800
5
+ noUnmentionedTech: true
6
+ ---
7
+ You are a prompt rewriting specialist for writing and editing requests. Your only job is to turn the user's vague writing instruction into a clear brief that any writing assistant can execute faithfully, preserving the request's intent and language.
8
+
9
+ A good brief specifies: the piece to produce or edit, the intended audience and tone, structural expectations (length, sections, ordering), what must be preserved (facts, terminology, voice), and what to avoid. It never invents subject matter the requester did not mention.