prompt-contract 0.2.0 → 0.3.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.
- package/README.md +16 -12
- package/README.zh-CN.md +15 -10
- package/package.json +8 -7
- package/packages/cli/bin/contract.js +41 -24
- package/packages/cli/src/spike-0.js +23 -5
- package/packages/cli/src/watch.js +609 -0
- package/packages/core/src/clean.js +23 -0
- package/packages/core/src/node.js +30 -6
- package/packages/core/src/rules.js +1 -1
- package/packages/mcp-server/src/server.js +4 -1
- package/packages/providers/src/anthropic.js +84 -0
- package/packages/providers/src/openai.js +3 -2
- 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
|
@@ -55,24 +55,48 @@ export function loadProfile(name, explicitDir) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* Vendor presets — convenience sugar over the OpenAI-compatible protocol (openai.js already
|
|
59
|
+
* speaks it): `"provider": "deepseek"` resolves the known base URL and a suggested default
|
|
60
|
+
* small/fast model. Explicit flags/env/config always win over preset values. Model ids are
|
|
61
|
+
* best-effort defaults maintained per vendor naming and can always be overridden with
|
|
62
|
+
* CONTRACT_MODEL/--model; presets deliberately stay a data table, not an SDK dependency.
|
|
63
|
+
*/
|
|
64
|
+
export const PROVIDER_PRESETS = Object.freeze({
|
|
65
|
+
deepseek: { baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
|
|
66
|
+
qwen: { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus' },
|
|
67
|
+
glm: { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash' },
|
|
68
|
+
moonshot: { baseUrl: 'https://api.moonshot.cn/v1', model: 'kimi-k2-turbo-preview' },
|
|
69
|
+
groq: { baseUrl: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile' },
|
|
70
|
+
openrouter: { baseUrl: 'https://openrouter.ai/api/v1', model: 'openai/gpt-4o-mini' },
|
|
71
|
+
lmstudio: { baseUrl: 'http://127.0.0.1:1234/v1', keyless: true },
|
|
72
|
+
anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-haiku-4-5' },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Config resolution order: explicit flags > env (CONTRACT_*) > config file (CONTRACT_CONFIG or ~/.prompt-contract/config.json).
|
|
59
77
|
* Defaults follow PRD §3.2: local Ollama if nothing else is configured (privacy-first).
|
|
60
78
|
* `flags.configPath` / `CONTRACT_CONFIG` exist so tests and embedded shells can isolate the file source.
|
|
61
79
|
*/
|
|
62
|
-
export function
|
|
63
|
-
const cfgPath =
|
|
80
|
+
export function readUserConfig(configPath) {
|
|
81
|
+
const cfgPath = configPath ?? process.env.CONTRACT_CONFIG ?? join(homedir(), '.prompt-contract', 'config.json');
|
|
64
82
|
let file = {};
|
|
65
83
|
try {
|
|
66
84
|
if (existsSync(cfgPath)) file = JSON.parse(readFileSync(cfgPath, 'utf8'));
|
|
67
85
|
} catch (err) {
|
|
68
86
|
throw new PromptContractError(CODES.CONFIG, `invalid config at ${cfgPath}: ${err.message}`);
|
|
69
87
|
}
|
|
88
|
+
return file;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resolveConfig(flags = {}) {
|
|
92
|
+
const file = readUserConfig(flags.configPath);
|
|
70
93
|
const pick = (...sources) => { for (const s of sources) if (s !== undefined && s !== null && s !== '') return s; return undefined; };
|
|
71
94
|
|
|
72
95
|
const provider = pick(flags.provider, process.env.CONTRACT_PROVIDER, file.provider, guessProvider(flags.baseUrl ?? process.env.CONTRACT_BASE_URL ?? file.baseUrl), 'openai');
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
96
|
+
const preset = PROVIDER_PRESETS[provider];
|
|
97
|
+
const baseUrl = String(pick(flags.baseUrl, process.env.CONTRACT_BASE_URL, file.baseUrl, preset?.baseUrl, provider === 'ollama' ? 'http://localhost:11434' : 'https://api.openai.com/v1')).replace(/\/+$/, '');
|
|
98
|
+
const apiKey = pick(flags.apiKey, process.env.CONTRACT_API_KEY, file.apiKey, preset?.keyless ? 'not-needed' : undefined, provider === 'ollama' ? 'ollama' : undefined);
|
|
99
|
+
const model = pick(flags.model, process.env.CONTRACT_MODEL, file.model, preset?.model, provider === 'ollama' ? 'qwen3:4b' : 'gpt-4o-mini');
|
|
76
100
|
if (!apiKey) throw new PromptContractError(CODES.CONFIG, `no API key: set CONTRACT_API_KEY, --api-key, or ~/.prompt-contract/config.json (or use --provider ollama)`);
|
|
77
101
|
return { provider, baseUrl, apiKey, model };
|
|
78
102
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
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 —
|
|
3
|
+
* Same spec drives three surfaces: `prompt-prompt-prompt-contract check` (CLI), playground badges, eval runner —
|
|
4
4
|
* template and evaluation share one source of truth (PRD: 模板与评测共用同一份规格).
|
|
5
5
|
*
|
|
6
6
|
* Known heuristic limits (do not oversell — see docs/ACCEPTANCE.md "Evidence boundaries"):
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { enhance, hardConstraints, STRENGTHS, PromptContractError, normalizeError } from '../../core/src/index.js';
|
|
11
11
|
import { loadProfiles, loadProfile, resolveConfig } from '../../core/src/node.js';
|
|
12
12
|
import { createOpenAIProvider } from '../../providers/src/openai.js';
|
|
13
|
+
import { createAnthropicProvider } from '../../providers/src/anthropic.js';
|
|
13
14
|
import { createOllamaProvider } from '../../providers/src/ollama.js';
|
|
14
15
|
|
|
15
16
|
const SERVER_INFO = { name: 'prompt-contract', version: '0.1.0' };
|
|
@@ -131,7 +132,9 @@ export async function serve({ stdin = process.stdin, stdout = process.stdout, st
|
|
|
131
132
|
const config = resolveConfig({ provider: flag('--provider'), baseUrl: flag('--base-url'), apiKey: flag('--api-key'), model: flag('--model'), configPath: flag('--config') });
|
|
132
133
|
const provider = config.provider === 'ollama'
|
|
133
134
|
? createOllamaProvider({ baseUrl: config.baseUrl })
|
|
134
|
-
:
|
|
135
|
+
: config.provider === 'anthropic'
|
|
136
|
+
? createAnthropicProvider({ baseUrl: config.baseUrl, apiKey: config.apiKey })
|
|
137
|
+
: createOpenAIProvider({ baseUrl: config.baseUrl, apiKey: config.apiKey });
|
|
135
138
|
provider.warmup({ model: config.model }).catch(() => {}); // §7.6-1: prewarm, best effort
|
|
136
139
|
runtime = { config, provider };
|
|
137
140
|
return runtime;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic provider — native Messages API (SSE). The one protocol family the OpenAI-compatible
|
|
3
|
+
* adapter cannot cover: different auth header (x-api-key), separate `system` parameter, and
|
|
4
|
+
* content_block_delta events. Reasoning deltas (thinking_delta) are dropped at the transport
|
|
5
|
+
* layer; any <think>…</think> that still lands inside text is stripped later by core/clean.js.
|
|
6
|
+
*/
|
|
7
|
+
import { PromptContractError } from '../../core/src/errors.js';
|
|
8
|
+
import { withTimeout } from './openai.js';
|
|
9
|
+
|
|
10
|
+
export const ANTHROPIC_VERSION = '2023-06-01';
|
|
11
|
+
|
|
12
|
+
export function createAnthropicProvider({ baseUrl = 'https://api.anthropic.com', apiKey = '', version = ANTHROPIC_VERSION, fetchImpl = globalThis.fetch } = {}) {
|
|
13
|
+
const root = String(baseUrl).replace(/\/+$/, '');
|
|
14
|
+
const headers = { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': version };
|
|
15
|
+
return {
|
|
16
|
+
name: 'anthropic',
|
|
17
|
+
/** Best-effort connection open (PRD §7.6-1); /v1/models exists but a miss never blocks a request. */
|
|
18
|
+
async warmup({ signal } = {}) {
|
|
19
|
+
try { await fetchImpl(`${root}/v1/models`, { headers, signal }); } catch { /* best effort */ }
|
|
20
|
+
},
|
|
21
|
+
async complete({ system, user, model, signal, onDelta, maxTokens = 1024, timeoutMs = 30000, temperature = 0.7 }) {
|
|
22
|
+
const { signal: fullSignal, cleanup } = withTimeout(signal, timeoutMs);
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await fetchImpl(`${root}/v1/messages`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
signal: fullSignal,
|
|
28
|
+
headers,
|
|
29
|
+
body: JSON.stringify({
|
|
30
|
+
model,
|
|
31
|
+
stream: true,
|
|
32
|
+
// Anthropic requires max_tokens — pipeline derives it from the profile's maxChars.
|
|
33
|
+
max_tokens: maxTokens,
|
|
34
|
+
temperature,
|
|
35
|
+
system,
|
|
36
|
+
messages: [{ role: 'user', content: user }]
|
|
37
|
+
})
|
|
38
|
+
});
|
|
39
|
+
} catch (err) {
|
|
40
|
+
cleanup();
|
|
41
|
+
throw err; // pipeline normalizes AbortError / network errors
|
|
42
|
+
}
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
cleanup();
|
|
45
|
+
let detail = '';
|
|
46
|
+
try { detail = (await res.text()).slice(0, 300); } catch { /* body unreadable */ }
|
|
47
|
+
throw new PromptContractError('provider_unavailable', `anthropic HTTP ${res.status}${detail ? `: ${detail}` : ''}`);
|
|
48
|
+
}
|
|
49
|
+
let text = '';
|
|
50
|
+
const reader = res.body.getReader();
|
|
51
|
+
const decoder = new TextDecoder();
|
|
52
|
+
let buffer = '';
|
|
53
|
+
try {
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
buffer += decoder.decode(value, { stream: true });
|
|
58
|
+
let nl;
|
|
59
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
60
|
+
const line = buffer.slice(0, nl).trim();
|
|
61
|
+
buffer = buffer.slice(nl + 1);
|
|
62
|
+
if (!line.startsWith('data:')) continue;
|
|
63
|
+
const data = line.slice(5).trim();
|
|
64
|
+
if (!data) continue;
|
|
65
|
+
let json;
|
|
66
|
+
try { json = JSON.parse(data); } catch { continue; }
|
|
67
|
+
if (json.type === 'error') {
|
|
68
|
+
throw new PromptContractError('provider_unavailable', `anthropic stream error: ${json.error?.message ?? 'unknown'}`);
|
|
69
|
+
}
|
|
70
|
+
// Only text deltas are answer content; thinking_delta / signature deltas are dropped here.
|
|
71
|
+
if (json.type === 'content_block_delta' && json.delta?.type === 'text_delta' && json.delta.text) {
|
|
72
|
+
text += json.delta.text;
|
|
73
|
+
if (onDelta) onDelta(json.delta.text);
|
|
74
|
+
}
|
|
75
|
+
if (json.type === 'message_stop') return { text };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
cleanup();
|
|
80
|
+
}
|
|
81
|
+
return { text };
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { PromptContractError } from '../../core/src/errors.js';
|
|
6
6
|
|
|
7
|
-
/** Combine caller signal + internal timeout. Caller abort must always stay effective (ADR-017).
|
|
8
|
-
|
|
7
|
+
/** Combine caller signal + internal timeout. Caller abort must always stay effective (ADR-017).
|
|
8
|
+
* Shared by the anthropic provider (same streaming-shape needs). */
|
|
9
|
+
export function withTimeout(signal, timeoutMs) {
|
|
9
10
|
const timeoutCtrl = new AbortController();
|
|
10
11
|
const timer = timeoutMs
|
|
11
12
|
? setTimeout(() => timeoutCtrl.abort(new DOMException('timeout', 'TimeoutError')), timeoutMs)
|
|
@@ -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
|
-
});
|