prompt-contract 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -7
- package/README.zh-CN.md +6 -6
- package/package.json +8 -7
- package/packages/cli/bin/contract.js +9 -9
- package/packages/cli/test/cli.test.js +0 -89
- package/packages/cli/test/spike-0.test.js +0 -260
- package/packages/core/bench/bench.js +0 -48
- package/packages/core/package.json +0 -11
- package/packages/core/test/clean.test.js +0 -44
- package/packages/core/test/lang.test.js +0 -21
- package/packages/core/test/pipeline.test.js +0 -85
- package/packages/core/test/profile.test.js +0 -40
- package/packages/core/test/rules.test.js +0 -53
- package/packages/mcp-server/package.json +0 -10
- package/packages/mcp-server/test/mcp.test.js +0 -167
- package/packages/providers/package.json +0 -7
- package/packages/providers/test/providers.test.js +0 -64
package/README.md
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
English · [简体中文](README.zh-CN.md)
|
|
9
9
|
|
|
10
|
-

|
|
12
11
|
|
|
13
12
|
**PromptContract is a deterministic prompt-contract layer for AI coding agents.** It compiles a vague one-line request into a structured task specification — goal, scope, constraints, acceptance criteria — and verifies the result against six hard rules before you ever see it.
|
|
14
13
|
|
|
@@ -17,7 +16,7 @@ It is *not* a smarter brain: your model does the thinking, PromptContract makes
|
|
|
17
16
|
## Why PromptContract
|
|
18
17
|
|
|
19
18
|
- **Stable agent inputs** — a profile plus hard constraints turn "a website for my dog" into goal / scope / acceptance criteria / explicit non-goals. Reduced retries and scope drift are hypotheses measured by the task-level harness, not current product claims.
|
|
20
|
-
- **Six deterministic guardrails** — language consistency, enhanced-text-only, length & completeness, expand-don't-answer, no hallucinated tech. Every enhancement can be asserted with `contract check`; the same spec drives templates and tests.
|
|
19
|
+
- **Six deterministic guardrails** — language consistency, enhanced-text-only, length & completeness, expand-don't-answer, no hallucinated tech. Every enhancement can be asserted with `prompt-contract check`; the same spec drives templates and tests.
|
|
21
20
|
- **One engine, three surfaces** — a CLI, an MCP server (agent-invoked **tool** + user-invoked **slash prompts**), and a browser playground. All share one zero-dependency core.
|
|
22
21
|
- **Private by architecture** — bring your own key, no server in the middle, no telemetry, offline-capable via Ollama.
|
|
23
22
|
|
|
@@ -37,10 +36,10 @@ Install: `npm i -g prompt-contract` — or zero-install: `npx prompt-contract "y
|
|
|
37
36
|
|
|
38
37
|
```bash
|
|
39
38
|
export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # any OpenAI-compatible endpoint (DeepSeek, Qwen, GLM, vLLM…)
|
|
40
|
-
contract "A website for my dog"
|
|
39
|
+
prompt-contract "A website for my dog"
|
|
41
40
|
|
|
42
41
|
export CONTRACT_PROVIDER=ollama CONTRACT_MODEL=qwen3:4b # fully local/offline; keep_alive pins the model in RAM
|
|
43
|
-
contract "帮我写一封请假邮件"
|
|
42
|
+
prompt-contract "帮我写一封请假邮件"
|
|
44
43
|
```
|
|
45
44
|
|
|
46
45
|
Or write `~/.prompt-contract/config.json` once: `{ "provider": "openai", "baseUrl": "…", "apiKey": "…", "model": "…" }`.
|
|
@@ -88,8 +87,8 @@ raw input → script/scenario detect → profile + hard constraints + strength (
|
|
|
88
87
|
|
|
89
88
|
## Status & roadmap — stated honestly
|
|
90
89
|
|
|
91
|
-
- **Shipped:** engine, CLI (`contract` / `check` / `doctor` / `profiles` / `spike-0`), MCP server (tool + zero-key prompts), playground, 3 profiles, eval cases, CI matrix.
|
|
92
|
-
- **Gated:** `contract watch` (global-hotkey resident mode) remains intentionally unavailable. `contract spike-0` measures macOS capture/clipboard safety and dry-run focus eligibility, but never pastes or unlocks watch by itself; see [docs/SPIKE-0.md](docs/SPIKE-0.md).
|
|
90
|
+
- **Shipped:** engine, CLI (`prompt-contract` / `check` / `doctor` / `profiles` / `spike-0`), MCP server (tool + zero-key prompts), playground, 3 profiles, eval cases, CI matrix.
|
|
91
|
+
- **Gated:** `prompt-contract watch` (global-hotkey resident mode) remains intentionally unavailable. `prompt-contract spike-0` measures macOS capture/clipboard safety and dry-run focus eligibility, but never pastes or unlocks watch by itself; see [docs/SPIKE-0.md](docs/SPIKE-0.md).
|
|
93
92
|
- **Open validation:** task-level outcome evaluation remains an evidence-gathering task. The harness and curated fixture set exist, but PromptContract's downstream effectiveness is still a hypothesis until a declared runner produces reviewed results.
|
|
94
93
|
- Deferred: animated demo asset, IDE plugins, LLM-as-judge as *one* scorer inside the task-level eval.
|
|
95
94
|
|
package/README.zh-CN.md
CHANGED
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
|
|
7
7
|
[English](README.md) · 简体中文
|
|
8
8
|
|
|
9
|
+

|
|
10
|
+
|
|
9
11
|
**PromptContract 是面向 AI 编码 agent 的确定性 prompt 契约层**:把模糊的一句话编译成结构化任务规范(目标 / 范围 / 约束 / 验收标准),并在你看到结果之前用六条硬规则完成校验。
|
|
10
12
|
|
|
11
13
|
它不是「更聪明的大脑」:思考由你的模型完成,PromptContract 让 agent 的输入变得稳定、可审查、可跨模型与跨工具迁移。
|
|
12
14
|
|
|
13
|
-

|
|
14
|
-
|
|
15
15
|
## 为什么
|
|
16
16
|
|
|
17
17
|
- **稳定的 agent 输入**——profile + 硬约束把「帮我做个网站」编译成目标 / 范围 / 验收标准 / 明确不做的事;重试与范围漂移是否减少,仍须由任务级 harness 验证,当前不作产品结论
|
|
18
|
-
- **六条确定性护栏**——语言一致性、只输出增强文本、长度与完整性、扩写而非回答、无幻觉技术栈;`contract check` 随时可断言,模板与测试共用同一份规格
|
|
18
|
+
- **六条确定性护栏**——语言一致性、只输出增强文本、长度与完整性、扩写而非回答、无幻觉技术栈;`prompt-contract check` 随时可断言,模板与测试共用同一份规格
|
|
19
19
|
- **一个引擎、三个形态**——CLI、MCP server(agent 调用的 tool + 用户调用的斜杠 prompts)、浏览器 Playground,共享同一个零依赖内核
|
|
20
20
|
- **隐私即架构**——BYOK、无中间服务、零遥测,Ollama 全本地可用
|
|
21
21
|
|
|
@@ -35,10 +35,10 @@ node packages/playground/serve.js # → http://127.0.0.1:8
|
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
37
|
export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # 任意 OpenAI 兼容端点(DeepSeek/Qwen/GLM/vLLM…)
|
|
38
|
-
contract "A website for my dog"
|
|
38
|
+
prompt-contract "A website for my dog"
|
|
39
39
|
|
|
40
40
|
export CONTRACT_PROVIDER=ollama CONTRACT_MODEL=qwen3:4b # 全本地;keep_alive 把模型钉在内存
|
|
41
|
-
contract "帮我写一封请假邮件"
|
|
41
|
+
prompt-contract "帮我写一封请假邮件"
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
或一次写入 `~/.prompt-contract/config.json`。
|
|
@@ -64,7 +64,7 @@ npm run bench # 引擎自身开销 P50 ≈ 0.005ms(预算 <5ms)
|
|
|
64
64
|
## 状态与路线图(诚实版)
|
|
65
65
|
|
|
66
66
|
- **已交付**:引擎、CLI(含 `spike-0`)、MCP server(tool + 零 key prompts)、Playground、3 个 profiles、eval 用例、CI 矩阵
|
|
67
|
-
- **被门控**:`contract watch`(全局热键常驻)仍不可用。`contract spike-0` 只测 macOS 取词、剪贴板恢复和焦点校验的 dry-run,不发送粘贴,也不会自行解锁 watch;见 [docs/SPIKE-0.md](docs/SPIKE-0.md)
|
|
67
|
+
- **被门控**:`prompt-contract watch`(全局热键常驻)仍不可用。`prompt-contract spike-0` 只测 macOS 取词、剪贴板恢复和焦点校验的 dry-run,不发送粘贴,也不会自行解锁 watch;见 [docs/SPIKE-0.md](docs/SPIKE-0.md)
|
|
68
68
|
- **开放验证**:任务级效果评测的 harness 与任务 fixture 已交付,但还没有声明 runner 产生并复核结果;长期价值在此之前仍是假设
|
|
69
69
|
- **推迟**:动画 demo 资产、IDE 插件、LLM-as-judge(作为任务级评测中的评分器之一)
|
|
70
70
|
|
package/package.json
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prompt-contract",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "One-key prompt enhancement — turn vague ideas into structured task specs anywhere you type to an AI. CLI + MCP server. Zero dependencies, BYOK, offline-capable (Ollama).",
|
|
5
8
|
"license": "Apache-2.0",
|
|
6
9
|
"type": "module",
|
|
7
10
|
"engines": {
|
|
8
11
|
"node": ">=20"
|
|
9
12
|
},
|
|
10
13
|
"bin": {
|
|
11
|
-
"contract": "./packages/cli/bin/contract.js",
|
|
12
14
|
"prompt-contract": "./packages/cli/bin/contract.js",
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
+
"contract": "./packages/cli/bin/contract.js",
|
|
16
|
+
"prompt-contract-mcp": "./packages/mcp-server/bin/prompt-contract-mcp.js"
|
|
15
17
|
},
|
|
16
18
|
"keywords": [
|
|
17
19
|
"prompt",
|
|
18
|
-
"prompt-contract",
|
|
19
20
|
"prompt-engineering",
|
|
20
21
|
"llm",
|
|
21
22
|
"cli",
|
|
@@ -36,7 +37,7 @@
|
|
|
36
37
|
"packages/",
|
|
37
38
|
"profiles/",
|
|
38
39
|
"README.md",
|
|
39
|
-
"README.
|
|
40
|
+
"README.zh-CN.md",
|
|
40
41
|
"LICENSE"
|
|
41
42
|
]
|
|
42
43
|
}
|
|
@@ -24,18 +24,18 @@ if (basename(process.argv[1] || '') === 'contract') {
|
|
|
24
24
|
process.stderr.write('[deprecated] this CLI is now `contract` (prompt-contract); the `contract` command will be removed in a future release.\n');
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const VERSION = '0.2.
|
|
28
|
-
const USAGE = `contract — one-key prompt enhancement (PromptContract v${VERSION})
|
|
27
|
+
const VERSION = '0.2.1';
|
|
28
|
+
const USAGE = `prompt-contract — one-key prompt enhancement (PromptContract v${VERSION})
|
|
29
29
|
|
|
30
30
|
Usage:
|
|
31
|
-
contract "build me a website for my dog" enhance a prompt (prints enhanced text to stdout)
|
|
32
|
-
cat prompt.txt | contract enhance from stdin
|
|
33
|
-
contract profiles list built-in profiles
|
|
34
|
-
contract check --original "..." --enhanced "..."
|
|
31
|
+
prompt-contract "build me a website for my dog" enhance a prompt (prints enhanced text to stdout)
|
|
32
|
+
cat prompt.txt | prompt-contract enhance from stdin
|
|
33
|
+
prompt-contract profiles list built-in profiles
|
|
34
|
+
prompt-contract check --original "..." --enhanced "..."
|
|
35
35
|
run the six hard-constraint rule assertions
|
|
36
|
-
contract doctor verify config, provider reachability, profiles
|
|
37
|
-
contract spike-0 macOS-only capture/restore compatibility diagnostic (dry-run)
|
|
38
|
-
contract watch NOT BUILT — gated by decision D7 (Spike-0 first); see docs/SPIKE-0.md
|
|
36
|
+
prompt-contract doctor verify config, provider reachability, profiles
|
|
37
|
+
prompt-contract spike-0 macOS-only capture/restore compatibility diagnostic (dry-run)
|
|
38
|
+
prompt-contract watch NOT BUILT — gated by decision D7 (Spike-0 first); see docs/SPIKE-0.md
|
|
39
39
|
|
|
40
40
|
Options:
|
|
41
41
|
-p, --profile <name> scenario profile (default: coding-agent)
|
|
@@ -1,89 +0,0 @@
|
|
|
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, repoRoot;
|
|
9
|
-
const PB = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'contract.js');
|
|
10
|
-
|
|
11
|
-
before(async () => {
|
|
12
|
-
mock = createMockServer({});
|
|
13
|
-
const port = await mock.listen();
|
|
14
|
-
base = `http://127.0.0.1:${port}`;
|
|
15
|
-
repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
after(async () => { await mock.close(); });
|
|
19
|
-
|
|
20
|
-
function runPb(args, { env = {}, input } = {}) {
|
|
21
|
-
return new Promise((resolveRun) => {
|
|
22
|
-
const child = spawn(process.execPath, [PB, ...args], {
|
|
23
|
-
cwd: repoRoot,
|
|
24
|
-
env: { ...process.env, ...env }
|
|
25
|
-
});
|
|
26
|
-
let stdout = '', stderr = '';
|
|
27
|
-
child.stdout.on('data', (d) => { stdout += d; });
|
|
28
|
-
child.stderr.on('data', (d) => { stderr += d; });
|
|
29
|
-
if (input !== undefined) child.stdin.end(input); else child.stdin.end();
|
|
30
|
-
child.on('close', (code) => resolveRun({ code, stdout, stderr }));
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const OPENAI_ENV = { CONTRACT_PROVIDER: 'openai', CONTRACT_BASE_URL: `${base}/v1`, CONTRACT_API_KEY: 'test-key-123', CONTRACT_MODEL: 'mock-model' };
|
|
35
|
-
|
|
36
|
-
test('e2e: contract "..." enhances via openai-compatible upstream and passes rule assertions', async () => {
|
|
37
|
-
const { code, stdout, stderr } = await runPb(['--json', '--provider', 'openai', '--base-url', `${base}/v1`, '--api-key', 'test-key-123', '--model', 'mock-model', '帮我做一个展示我家狗的网站']);
|
|
38
|
-
assert.equal(code, 0, stderr);
|
|
39
|
-
const out = JSON.parse(stdout);
|
|
40
|
-
assert.equal(out.original, '帮我做一个展示我家狗的网站');
|
|
41
|
-
assert.equal(out.enhanced, ZH_RESULT);
|
|
42
|
-
assert.equal(out.meta.profile, 'coding-agent');
|
|
43
|
-
assert.equal(out.meta.model, 'mock-model');
|
|
44
|
-
assert.equal(out.rules.pass, true, JSON.stringify(out.rules.results));
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test('e2e: contract reads prompt from stdin (pipe mode)', async () => {
|
|
48
|
-
const { code, stdout } = await runPb(
|
|
49
|
-
['--no-stream', '--provider', 'openai', '--base-url', `${base}/v1`, '--api-key', 'test-key-123', '--model', 'mock-model'],
|
|
50
|
-
{ input: '帮我做一个展示我家狗的网站' }
|
|
51
|
-
);
|
|
52
|
-
assert.equal(code, 0);
|
|
53
|
-
assert.equal(stdout.trim(), ZH_RESULT);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('contract profiles lists the three built-in profiles', async () => {
|
|
57
|
-
const { code, stdout } = await runPb(['profiles']);
|
|
58
|
-
assert.equal(code, 0);
|
|
59
|
-
for (const name of ['coding-agent', 'writing', 'image-gen']) assert.match(stdout, new RegExp(name));
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('contract check exits 1 on a failing pair (gate mode) and 0 on a good pair', async () => {
|
|
63
|
-
const bad = await runPb(['check', '--original', '做个博客', '--enhanced', '好的,以下是实现方案:先安装依赖。']);
|
|
64
|
-
assert.equal(bad.code, 1);
|
|
65
|
-
assert.match(bad.stderr, /FAIL/);
|
|
66
|
-
const good = await runPb(['check', '--original', '帮我做一个展示我家狗的网站', '--enhanced', ZH_RESULT]);
|
|
67
|
-
assert.equal(good.code, 0, good.stderr);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('contract watch is gated by decision D7', async () => {
|
|
71
|
-
const { code, stderr } = await runPb(['watch']);
|
|
72
|
-
assert.equal(code, 2);
|
|
73
|
-
assert.match(stderr, /D7/);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test('contract spike-0 exposes the macOS dry-run diagnostic', async () => {
|
|
77
|
-
const { code, stdout } = await runPb(['spike-0', '--help']);
|
|
78
|
-
assert.equal(code, 0);
|
|
79
|
-
assert.match(stdout, /dry-run/);
|
|
80
|
-
assert.match(stdout, /Chrome/);
|
|
81
|
-
assert.match(stdout, /PyCharm/);
|
|
82
|
-
assert.match(stdout, /iTerm/);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test('contract doctor reports provider problems honestly', async () => {
|
|
86
|
-
const { code, stderr } = await runPb(['doctor', '--provider', 'openai', '--base-url', `${base}/v1`, '--api-key', 'wrong-key', '--model', 'mock-model']);
|
|
87
|
-
assert.equal(code, 1);
|
|
88
|
-
assert.match(stderr, /FAIL/);
|
|
89
|
-
});
|
|
@@ -1,260 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,48 +0,0 @@
|
|
|
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)');
|
|
@@ -1,11 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,85 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,40 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,10 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,167 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
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
|
-
});
|