prompt-contract 0.2.1 → 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 -11
- package/README.zh-CN.md +14 -9
- package/package.json +1 -1
- package/packages/cli/bin/contract.js +39 -22
- 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/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
English · [简体中文](README.zh-CN.md)
|
|
9
9
|
|
|
10
|
-

|
|
10
|
+

|
|
11
11
|
|
|
12
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.
|
|
13
13
|
|
|
@@ -16,7 +16,7 @@ It is *not* a smarter brain: your model does the thinking, PromptContract makes
|
|
|
16
16
|
## Why PromptContract
|
|
17
17
|
|
|
18
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.
|
|
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.
|
|
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-prompt-contract check`; the same spec drives templates and tests.
|
|
20
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.
|
|
21
21
|
- **Private by architecture** — bring your own key, no server in the middle, no telemetry, offline-capable via Ollama.
|
|
22
22
|
|
|
@@ -30,16 +30,21 @@ node packages/cli/bin/contract.js "帮我做一个展示我家狗的网站" \
|
|
|
30
30
|
node packages/playground/serve.js # → http://127.0.0.1:8123/ (or ?demo=1 for the self-running demo)
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Install: `npm i -g prompt-contract` — or zero-install: `npx prompt-contract "your vague idea"`.
|
|
33
|
+
Install: `npm i -g prompt-contract` — or zero-install: `npx prompt-prompt-prompt-contract "your vague idea"`.
|
|
34
34
|
|
|
35
35
|
## Your real model
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # any OpenAI-compatible endpoint (
|
|
39
|
-
prompt-contract "A website for my dog"
|
|
38
|
+
export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # any OpenAI-compatible endpoint (vLLM, OpenRouter, your gateway…)
|
|
39
|
+
prompt-prompt-contract "A website for my dog"
|
|
40
|
+
|
|
41
|
+
export CONTRACT_PROVIDER=deepseek CONTRACT_API_KEY=sk-… # vendor presets carry the base URL + a suggested model
|
|
42
|
+
export CONTRACT_PROVIDER=qwen # also: glm, moonshot, groq, openrouter, lmstudio (keyless)
|
|
43
|
+
export CONTRACT_PROVIDER=anthropic CONTRACT_API_KEY=sk-ant-… # native Messages API — thinking deltas dropped in transport
|
|
44
|
+
prompt-prompt-contract "帮我写一封请假邮件"
|
|
40
45
|
|
|
41
46
|
export CONTRACT_PROVIDER=ollama CONTRACT_MODEL=qwen3:4b # fully local/offline; keep_alive pins the model in RAM
|
|
42
|
-
prompt-contract "帮我写一封请假邮件"
|
|
47
|
+
prompt-prompt-prompt-contract "帮我写一封请假邮件"
|
|
43
48
|
```
|
|
44
49
|
|
|
45
50
|
Or write `~/.prompt-contract/config.json` once: `{ "provider": "openai", "baseUrl": "…", "apiKey": "…", "model": "…" }`.
|
|
@@ -68,7 +73,7 @@ Community profiles are the main contribution surface — a PR adding `profiles/<
|
|
|
68
73
|
## Quality gates — and their honest limits
|
|
69
74
|
|
|
70
75
|
```bash
|
|
71
|
-
npm test #
|
|
76
|
+
npm test # 102 tests (serial): engine units + SSE/ndjson streaming + CLI/MCP e2e against a local mock
|
|
72
77
|
npm run eval # 9 deterministic cases over the six hard-constraint assertions
|
|
73
78
|
npm run eval:tasks -- --format-only # validate the coding-agent task fixture format gate
|
|
74
79
|
npm run bench # engine overhead: P50 ≈ 0.005ms (budget < 5ms)
|
|
@@ -81,20 +86,20 @@ npm run bench # engine overhead: P50 ≈ 0.005ms (budget < 5ms)
|
|
|
81
86
|
```
|
|
82
87
|
raw input → script/scenario detect → profile + hard constraints + strength (+ optional context)
|
|
83
88
|
→ single streaming LLM call (small fast model by default; the model does the thinking)
|
|
84
|
-
→ deterministic cleaning (quotes/fences/length clamp, empty → llm_error)
|
|
89
|
+
→ deterministic cleaning (reasoning-block strip, quotes/fences/length clamp, empty → llm_error)
|
|
85
90
|
→ { enhanced, original, meta } — original always preserved, one-key revert in every surface
|
|
86
91
|
```
|
|
87
92
|
|
|
88
93
|
## Status & roadmap — stated honestly
|
|
89
94
|
|
|
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
|
-
- **
|
|
95
|
+
- **Shipped:** engine, CLI (`prompt-contract` / `check` / `doctor` / `profiles` / `spike-0` / `watch`), MCP server (tool + zero-key prompts), playground, 3 profiles, eval cases, CI matrix.
|
|
96
|
+
- **Resident mode:** `prompt-prompt-contract watch` — select text anywhere on macOS, press ⌥B, and the enhanced prompt replaces your selection: clipboard backed up and restored, focus re-validated before pasting, gated by `prompt-prompt-contract spike-0` evidence (decision D7). See [docs/WATCH.md](docs/WATCH.md). `prompt-prompt-contract spike-0` itself remains a dry-run diagnostic and never pastes; see [docs/SPIKE-0.md](docs/SPIKE-0.md).
|
|
92
97
|
- **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.
|
|
93
98
|
- Deferred: animated demo asset, IDE plugins, LLM-as-judge as *one* scorer inside the task-level eval.
|
|
94
99
|
|
|
95
100
|
## Layout
|
|
96
101
|
|
|
97
|
-
|
|
102
|
+
docs/ACCEPTANCE.md` · `docs/SPIKE-0.md` · `docs/WATCH.md` (requirements → implementation → acceptance + evidence boundaries)
|
|
98
103
|
|
|
99
104
|
## Contributing & License
|
|
100
105
|
|
package/README.zh-CN.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[English](README.md) · 简体中文
|
|
8
8
|
|
|
9
|
-

|
|
9
|
+

|
|
10
10
|
|
|
11
11
|
**PromptContract 是面向 AI 编码 agent 的确定性 prompt 契约层**:把模糊的一句话编译成结构化任务规范(目标 / 范围 / 约束 / 验收标准),并在你看到结果之前用六条硬规则完成校验。
|
|
12
12
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
## 为什么
|
|
16
16
|
|
|
17
17
|
- **稳定的 agent 输入**——profile + 硬约束把「帮我做个网站」编译成目标 / 范围 / 验收标准 / 明确不做的事;重试与范围漂移是否减少,仍须由任务级 harness 验证,当前不作产品结论
|
|
18
|
-
- **六条确定性护栏**——语言一致性、只输出增强文本、长度与完整性、扩写而非回答、无幻觉技术栈;`prompt-contract check` 随时可断言,模板与测试共用同一份规格
|
|
18
|
+
- **六条确定性护栏**——语言一致性、只输出增强文本、长度与完整性、扩写而非回答、无幻觉技术栈;`prompt-prompt-contract check` 随时可断言,模板与测试共用同一份规格
|
|
19
19
|
- **一个引擎、三个形态**——CLI、MCP server(agent 调用的 tool + 用户调用的斜杠 prompts)、浏览器 Playground,共享同一个零依赖内核
|
|
20
20
|
- **隐私即架构**——BYOK、无中间服务、零遥测,Ollama 全本地可用
|
|
21
21
|
|
|
@@ -29,16 +29,21 @@ node packages/cli/bin/contract.js "帮我做一个展示我家狗的网站" \
|
|
|
29
29
|
node packages/playground/serve.js # → http://127.0.0.1:8123/(?demo=1 为自运行演示)
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
安装:`npm i -g prompt-contract`;或零安装体验:`npx prompt-contract "你的模糊想法"`。
|
|
32
|
+
安装:`npm i -g prompt-contract`;或零安装体验:`npx prompt-prompt-prompt-contract "你的模糊想法"`。
|
|
33
33
|
|
|
34
34
|
## 接入真实模型
|
|
35
35
|
|
|
36
36
|
```bash
|
|
37
|
-
export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # 任意 OpenAI 兼容端点(
|
|
38
|
-
prompt-contract "A website for my dog"
|
|
37
|
+
export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # 任意 OpenAI 兼容端点(vLLM、OpenRouter、自家网关…)
|
|
38
|
+
prompt-prompt-contract "A website for my dog"
|
|
39
|
+
|
|
40
|
+
export CONTRACT_PROVIDER=deepseek CONTRACT_API_KEY=sk-… # 厂商预设:自带 base URL 与建议默认模型
|
|
41
|
+
export CONTRACT_PROVIDER=qwen # 另有 glm、moonshot、groq、openrouter、lmstudio(免 key)
|
|
42
|
+
export CONTRACT_PROVIDER=anthropic CONTRACT_API_KEY=sk-ant-… # Anthropic 原生 Messages API——思考增量在传输层即被丢弃
|
|
43
|
+
prompt-prompt-contract "帮我写一封请假邮件"
|
|
39
44
|
|
|
40
45
|
export CONTRACT_PROVIDER=ollama CONTRACT_MODEL=qwen3:4b # 全本地;keep_alive 把模型钉在内存
|
|
41
|
-
prompt-contract "帮我写一封请假邮件"
|
|
46
|
+
prompt-prompt-prompt-contract "帮我写一封请假邮件"
|
|
42
47
|
```
|
|
43
48
|
|
|
44
49
|
或一次写入 `~/.prompt-contract/config.json`。
|
|
@@ -53,7 +58,7 @@ prompt-contract "帮我写一封请假邮件"
|
|
|
53
58
|
## 质量门禁与诚实边界
|
|
54
59
|
|
|
55
60
|
```bash
|
|
56
|
-
npm test #
|
|
61
|
+
npm test # 102 项(串行):引擎单测 + SSE/ndjson 流式 + CLI/MCP 端到端(对本地 mock)
|
|
57
62
|
npm run eval # 9 个确定性用例(六条硬约束断言)
|
|
58
63
|
npm run eval:tasks -- --format-only # 校验 coding-agent 任务 fixture 的格式门禁
|
|
59
64
|
npm run bench # 引擎自身开销 P50 ≈ 0.005ms(预算 <5ms)
|
|
@@ -63,8 +68,8 @@ npm run bench # 引擎自身开销 P50 ≈ 0.005ms(预算 <5ms)
|
|
|
63
68
|
|
|
64
69
|
## 状态与路线图(诚实版)
|
|
65
70
|
|
|
66
|
-
- **已交付**:引擎、CLI(含 `spike-0
|
|
67
|
-
-
|
|
71
|
+
- **已交付**:引擎、CLI(含 `prompt-prompt-contract spike-0`、`prompt-prompt-contract watch` 常驻模式)、MCP server(tool + 零 key prompts)、Playground、3 个 profiles、eval 用例、CI 矩阵
|
|
72
|
+
- **常驻模式**:`prompt-prompt-contract watch`——在 macOS 任意应用选中一段粗糙 prompt,按 ⌥B,增强结果原地替换选区:剪贴板先备份后恢复、回贴前焦点复验(漂移即放弃)、以 `prompt-prompt-contract spike-0` 证据为启动门控(决策 D7);详见 [docs/WATCH.md](docs/WATCH.md)。`prompt-prompt-contract spike-0` 本身仍是 dry-run 诊断,永不发送粘贴;见 [docs/SPIKE-0.md](docs/SPIKE-0.md)
|
|
68
73
|
- **开放验证**:任务级效果评测的 harness 与任务 fixture 已交付,但还没有声明 runner 产生并复核结果;长期价值在此之前仍是假设
|
|
69
74
|
- **推迟**:动画 demo 资产、IDE 插件、LLM-as-judge(作为任务级评测中的评分器之一)
|
|
70
75
|
|
package/package.json
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* contract — PromptContract CLI (PRD §4 P0). Physical file kept as contract.js for path stability.
|
|
4
4
|
* One-shot enhance (arg or stdin), profiles, check (rule assertions), doctor.
|
|
5
|
-
*
|
|
5
|
+
* One-shot enhance (arg or stdin), profiles, check (rule assertions), doctor,
|
|
6
|
+
* spike-0 (capture diagnostic), watch (resident hotkey mode — see docs/WATCH.md).
|
|
6
7
|
*/
|
|
7
8
|
import { enhance, checkRules, PromptContractError, normalizeError } from '../../core/src/index.js';
|
|
8
9
|
import { loadProfiles, loadProfile, resolveConfig } from '../../core/src/node.js';
|
|
9
10
|
import { createOpenAIProvider } from '../../providers/src/openai.js';
|
|
11
|
+
import { createAnthropicProvider } from '../../providers/src/anthropic.js';
|
|
10
12
|
import { createOllamaProvider } from '../../providers/src/ollama.js';
|
|
11
13
|
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
12
14
|
import { basename } from 'node:path';
|
|
@@ -18,30 +20,32 @@ import {
|
|
|
18
20
|
resolveSpikeTargets,
|
|
19
21
|
runSpike0,
|
|
20
22
|
} from '../src/spike-0.js';
|
|
23
|
+
import { runWatch } from '../src/watch.js';
|
|
21
24
|
|
|
22
|
-
// `contract` was the command name before the prompt-contract rename; warn while the alias ships.
|
|
25
|
+
// `prompt-contract` was the command name before the prompt-contract rename; warn while the alias ships.
|
|
23
26
|
if (basename(process.argv[1] || '') === 'contract') {
|
|
24
|
-
process.stderr.write('[deprecated] this CLI is now `contract` (prompt-contract); the `contract` command will be removed in a future release.\n');
|
|
27
|
+
process.stderr.write('[deprecated] this CLI is now `prompt-contract` (prompt-contract); the `prompt-contract` command will be removed in a future release.\n');
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
const VERSION = '0.
|
|
30
|
+
const VERSION = '0.3.0';
|
|
28
31
|
const USAGE = `prompt-contract — one-key prompt enhancement (PromptContract v${VERSION})
|
|
29
32
|
|
|
30
33
|
Usage:
|
|
31
|
-
prompt-contract "build me a website for my dog" enhance a prompt (prints enhanced text to stdout)
|
|
34
|
+
prompt-prompt-prompt-contract "build me a website for my dog" enhance a prompt (prints enhanced text to stdout)
|
|
32
35
|
cat prompt.txt | prompt-contract enhance from stdin
|
|
33
|
-
prompt-contract profiles list built-in profiles
|
|
34
|
-
prompt-contract check --original "..." --enhanced "..."
|
|
36
|
+
prompt-prompt-contract profiles list built-in profiles
|
|
37
|
+
prompt-prompt-prompt-contract check --original "..." --enhanced "..."
|
|
35
38
|
run the six hard-constraint rule assertions
|
|
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
|
|
39
|
+
prompt-prompt-contract doctor verify config, provider reachability, profiles
|
|
40
|
+
prompt-prompt-prompt-contract spike-0 macOS-only capture/restore compatibility diagnostic (dry-run)
|
|
41
|
+
prompt-prompt-prompt-contract watch resident mode: select text → hotkey → enhanced text replaces it (macOS; docs/WATCH.md)
|
|
39
42
|
|
|
40
43
|
Options:
|
|
41
44
|
-p, --profile <name> scenario profile (default: coding-agent)
|
|
42
45
|
-s, --strength <mode> polish | standard (default) | expand
|
|
43
46
|
-m, --model <model> model override
|
|
44
|
-
--provider <name> openai (default) | ollama
|
|
47
|
+
--provider <name> openai (default) | anthropic | ollama | vendor presets:
|
|
48
|
+
deepseek qwen glm moonshot groq openrouter lmstudio
|
|
45
49
|
--base-url <url> OpenAI-compatible base URL (or CONTRACT_BASE_URL)
|
|
46
50
|
--api-key <key> API key (or CONTRACT_API_KEY; local ollama needs none)
|
|
47
51
|
--context <text> background context to assemble into the prompt
|
|
@@ -54,19 +58,30 @@ Options:
|
|
|
54
58
|
--setup-delay-ms <ms> Spike-0 delay before each target capture
|
|
55
59
|
--no-prompt Spike-0 do not wait for target/app setup
|
|
56
60
|
--output <path> write Spike-0 JSON report to a file
|
|
61
|
+
--hotkey <spec> watch trigger combo: ctrl=⌃ alt/option=⌥ cmd=⌘ shift=⇧ + key,
|
|
62
|
+
e.g. "ctrl+alt+b" = hold ⌃⌥ and press B (default alt+b; persist via "hotkey"
|
|
63
|
+
in ~/.prompt-contract/config.json)
|
|
64
|
+
--trigger <name> watch trigger source: hotkey (default) | stdin (each Enter = trigger, q quits)
|
|
65
|
+
--report <path> Spike-0 JSON report satisfying the watch evidence gate (D7)
|
|
66
|
+
--force run watch without Spike-0 evidence (at your own risk)
|
|
67
|
+
--dry-run watch: capture + enhance but never paste
|
|
68
|
+
--paste-delay-ms <ms> watch: wait between ⌘V and clipboard restore (default: 1000)
|
|
69
|
+
--cooldown-ms <ms> watch: minimum gap between cycles (default: 800)
|
|
57
70
|
--json machine-readable output {original, enhanced, meta, rules}
|
|
58
71
|
--no-stream buffer instead of streaming progress
|
|
59
72
|
-h, --help show this help`;
|
|
60
73
|
|
|
61
74
|
function parseArgs(argv) {
|
|
62
75
|
const flags = { _: [] };
|
|
63
|
-
const needsValue = new Set(['--profile', '-p', '--strength', '-s', '--model', '-m', '--provider', '--base-url', '--api-key', '--context', '--max-chars', '--timeout', '--app', '--iterations', '--settle-ms', '--pause-ms', '--setup-delay-ms', '--output', '--original', '--enhanced']);
|
|
76
|
+
const needsValue = new Set(['--profile', '-p', '--strength', '-s', '--model', '-m', '--provider', '--base-url', '--api-key', '--context', '--max-chars', '--timeout', '--app', '--iterations', '--settle-ms', '--pause-ms', '--setup-delay-ms', '--output', '--original', '--enhanced', '--hotkey', '--trigger', '--report', '--paste-delay-ms', '--cooldown-ms']);
|
|
64
77
|
const camel = (k) => k.replace(/^--?/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
65
78
|
for (let i = 0; i < argv.length; i++) {
|
|
66
79
|
const a = argv[i];
|
|
67
80
|
if (a === '--json') flags.json = true;
|
|
68
81
|
else if (a === '--no-stream') flags.noStream = true;
|
|
69
82
|
else if (a === '--no-prompt') flags.noPrompt = true;
|
|
83
|
+
else if (a === '--dry-run') flags.dryRun = true;
|
|
84
|
+
else if (a === '--force') flags.force = true;
|
|
70
85
|
else if (a === '--help' || a === '-h') flags.help = true;
|
|
71
86
|
else if (a === '--version') flags.version = true;
|
|
72
87
|
else if (needsValue.has(a)) flags[camel(a)] = argv[++i];
|
|
@@ -77,9 +92,9 @@ function parseArgs(argv) {
|
|
|
77
92
|
}
|
|
78
93
|
|
|
79
94
|
function buildProvider(cfg) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
if (cfg.provider === 'ollama') return createOllamaProvider({ baseUrl: cfg.baseUrl });
|
|
96
|
+
if (cfg.provider === 'anthropic') return createAnthropicProvider({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey });
|
|
97
|
+
return createOpenAIProvider({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey });
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
function makeProvider(cfg, { warm = false } = {}) {
|
|
@@ -133,7 +148,7 @@ async function cmdEnhance(flags) {
|
|
|
133
148
|
if (!flags.noStream) {
|
|
134
149
|
process.stderr.write(`\n— ${res.meta.profile} · ${res.meta.model} · ${res.meta.ms}ms · ${res.meta.chars} chars\n`);
|
|
135
150
|
if (!rules.pass) {
|
|
136
|
-
process.stderr.write('rule assertions (advisory — run `contract check` for gate mode):\n');
|
|
151
|
+
process.stderr.write('rule assertions (advisory — run `prompt-prompt-prompt-contract check` for gate mode):\n');
|
|
137
152
|
for (const r of rules.results.filter((r) => !r.pass)) {
|
|
138
153
|
process.stderr.write(` [FAIL] ${r.title}${r.detail ? ` — ${r.detail}` : ''}\n`);
|
|
139
154
|
}
|
|
@@ -168,7 +183,7 @@ function cmdCheck(flags) {
|
|
|
168
183
|
}
|
|
169
184
|
}
|
|
170
185
|
if (original === undefined || enhanced === undefined) {
|
|
171
|
-
process.stderr.write('contract check requires --original and --enhanced (or a JSON {original, enhanced} line on stdin)\n');
|
|
186
|
+
process.stderr.write('prompt-prompt-contract check requires --original and --enhanced (or a JSON {original, enhanced} line on stdin)\n');
|
|
172
187
|
return 2;
|
|
173
188
|
}
|
|
174
189
|
const rules = checkRules(original, enhanced, { maxChars: flags.maxChars ? parseInt(flags.maxChars, 10) : 800 });
|
|
@@ -204,7 +219,7 @@ async function cmdDoctor(flags) {
|
|
|
204
219
|
|
|
205
220
|
async function cmdSpike0(flags) {
|
|
206
221
|
if (!isMacOS) {
|
|
207
|
-
process.stderr.write('contract spike-0 is macOS-only: requires pbpaste, pbcopy, and Accessibility-backed System Events.\n');
|
|
222
|
+
process.stderr.write('prompt-prompt-contract spike-0 is macOS-only: requires pbpaste, pbcopy, and Accessibility-backed System Events.\n');
|
|
208
223
|
return 2;
|
|
209
224
|
}
|
|
210
225
|
|
|
@@ -216,7 +231,7 @@ async function cmdSpike0(flags) {
|
|
|
216
231
|
const pauseMs = flags.pauseMs === undefined ? 0 : Number.parseInt(flags.pauseMs, 10);
|
|
217
232
|
const setupDelayMs = flags.setupDelayMs === undefined ? 0 : Number.parseInt(flags.setupDelayMs, 10);
|
|
218
233
|
if (!Number.isInteger(iterations) || iterations < 1 || !Number.isInteger(settleMs) || settleMs < 0 || !Number.isInteger(pauseMs) || pauseMs < 0 || !Number.isInteger(setupDelayMs) || setupDelayMs < 0) {
|
|
219
|
-
process.stderr.write('contract spike-0 requires non-negative integer --settle-ms/--pause-ms/--setup-delay-ms and positive integer --iterations\n');
|
|
234
|
+
process.stderr.write('prompt-prompt-contract spike-0 requires non-negative integer --settle-ms/--pause-ms/--setup-delay-ms and positive integer --iterations\n');
|
|
220
235
|
return 2;
|
|
221
236
|
}
|
|
222
237
|
|
|
@@ -237,6 +252,10 @@ async function cmdSpike0(flags) {
|
|
|
237
252
|
return report.decision.pass ? 0 : 1;
|
|
238
253
|
}
|
|
239
254
|
|
|
255
|
+
async function cmdWatch(flags) {
|
|
256
|
+
return runWatch(flags);
|
|
257
|
+
}
|
|
258
|
+
|
|
240
259
|
async function main() {
|
|
241
260
|
const flags = parseArgs(process.argv.slice(2));
|
|
242
261
|
if (flags.help) { process.stdout.write(USAGE + '\n'); return 0; }
|
|
@@ -249,9 +268,7 @@ async function main() {
|
|
|
249
268
|
case 'check': return cmdCheck(flags);
|
|
250
269
|
case 'doctor': return await cmdDoctor(flags);
|
|
251
270
|
case 'spike-0': return await cmdSpike0(flags);
|
|
252
|
-
case 'watch':
|
|
253
|
-
process.stderr.write('contract watch is gated by decision D7: run `contract spike-0` and review its evidence before implementing watch.\nThe watch implementation remains intentionally unavailable in this Spike-0-only change.\n');
|
|
254
|
-
return 2;
|
|
271
|
+
case 'watch': return await cmdWatch(flags);
|
|
255
272
|
default:
|
|
256
273
|
// treat unknown first word as prompt text
|
|
257
274
|
flags._.unshift(cmd);
|
|
@@ -36,6 +36,9 @@ export const APP_TARGETS = Object.freeze({
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
const COPY_SCRIPT = 'tell application "System Events" to keystroke "c" using {command down}';
|
|
39
|
+
// Used only by prompt-contract watch. The Spike-0 diagnostic itself never issues ⌘V; its
|
|
40
|
+
// safety contract is enforced in captureSelectedText/validatePasteBackDryRun.
|
|
41
|
+
const PASTE_SCRIPT = 'tell application "System Events" to keystroke "v" using {command down}';
|
|
39
42
|
|
|
40
43
|
// The report deliberately excludes AXValue: it can contain the user's prompt or
|
|
41
44
|
// other sensitive text. These attributes are enough to conservatively detect a
|
|
@@ -212,6 +215,12 @@ export function createMacOSAdapter({ run = runCommand } = {}) {
|
|
|
212
215
|
if (result.code !== 0) throw commandError('osascript-copy', result);
|
|
213
216
|
},
|
|
214
217
|
|
|
218
|
+
async pasteSelection() {
|
|
219
|
+
const result = await run('/usr/bin/osascript', ['-e', PASTE_SCRIPT]);
|
|
220
|
+
if (result.error) throw result.error;
|
|
221
|
+
if (result.code !== 0) throw commandError('osascript-paste', result);
|
|
222
|
+
},
|
|
223
|
+
|
|
215
224
|
async getFocusIdentity() {
|
|
216
225
|
const result = await run('/usr/bin/osascript', ['-e', CONTEXT_SCRIPT], { timeoutMs: 5000 });
|
|
217
226
|
if (result.error) throw result.error;
|
|
@@ -275,7 +284,7 @@ function fingerprintForContext(context) {
|
|
|
275
284
|
return createHash('sha256').update(fields.join('\u001f')).digest('hex').slice(0, 16);
|
|
276
285
|
}
|
|
277
286
|
|
|
278
|
-
function sameFocusIdentity(left, right) {
|
|
287
|
+
export function sameFocusIdentity(left, right) {
|
|
279
288
|
if (!left || !right) return false;
|
|
280
289
|
const appStable = normalized(left.processName) === normalized(right.processName)
|
|
281
290
|
&& normalized(left.bundleId) === normalized(right.bundleId)
|
|
@@ -309,8 +318,17 @@ export async function captureSelectedText(adapter, { settleMs = 75 } = {}) {
|
|
|
309
318
|
clipboardUntouched = false;
|
|
310
319
|
await adapter.copySelection();
|
|
311
320
|
await adapter.sleep(settleMs);
|
|
312
|
-
|
|
313
|
-
|
|
321
|
+
let copiedText = await adapter.readClipboard();
|
|
322
|
+
// ⌘C is delivered asynchronously: a read that still shows the pre-copy
|
|
323
|
+
// clipboard means the copy has not landed — poll briefly instead of
|
|
324
|
+
// mistaking the stale clipboard for the selection. A read identical to the
|
|
325
|
+
// pre-copy clipboard after polling reports an empty selection (the
|
|
326
|
+
// clipboard fallback then covers the intentional selection==clipboard case).
|
|
327
|
+
for (let polls = 0; copiedText === originalClipboard && polls < 6; polls++) {
|
|
328
|
+
await adapter.sleep(60);
|
|
329
|
+
copiedText = await adapter.readClipboard();
|
|
330
|
+
}
|
|
331
|
+
selectedText = typeof copiedText === 'string' && copiedText.trim() && copiedText !== originalClipboard ? copiedText : null;
|
|
314
332
|
contextAfterCapture = await adapter.getFocusIdentity();
|
|
315
333
|
} catch (captureError) {
|
|
316
334
|
error = captureError;
|
|
@@ -457,7 +475,7 @@ export function buildCompatibilityReport({
|
|
|
457
475
|
pass: reasons.length === 0,
|
|
458
476
|
reasons,
|
|
459
477
|
watchGate: 'closed',
|
|
460
|
-
note: 'A dry-run cannot prove actual paste landing; contract watch
|
|
478
|
+
note: 'A dry-run cannot prove actual paste landing; this report is evidence for prompt-contract watch, never an authorization by itself.',
|
|
461
479
|
},
|
|
462
480
|
};
|
|
463
481
|
}
|
|
@@ -575,7 +593,7 @@ export function formatSpike0Summary(report) {
|
|
|
575
593
|
`capture ${summary.captureSuccesses}/${summary.attempts} (${summary.captureSuccessRate})`,
|
|
576
594
|
`clipboard restore ${summary.clipboardRestoreSuccesses}/${summary.attempts} (${summary.clipboardRestoreSuccessRate})`,
|
|
577
595
|
`dry-run paste-back eligibility ${summary.dryRunPasteBackSuccesses}/${summary.attempts} (${summary.dryRunPasteBackRate})`,
|
|
578
|
-
report.decision.reasons.length ? `reasons: ${report.decision.reasons.join('; ')}` : 'thresholds met; contract watch
|
|
596
|
+
report.decision.reasons.length ? `reasons: ${report.decision.reasons.join('; ')}` : 'thresholds met; evidence usable by prompt-contract watch',
|
|
579
597
|
].join('\n');
|
|
580
598
|
}
|
|
581
599
|
|
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt-contract watch — resident hotkey mode (PRD §7, Snipaste-style).
|
|
3
|
+
* Hotkey → capture selection (clipboard fallback) → enhance → focus re-validation → paste back.
|
|
4
|
+
*
|
|
5
|
+
* Safety contract (carried over from Spike-0, now with a real paste step):
|
|
6
|
+
* - The user's clipboard is snapshotted and restored around every capture and paste.
|
|
7
|
+
* - Paste only fires when the foreground/focus identity still matches capture time;
|
|
8
|
+
* otherwise the cycle aborts with a notification (fail closed).
|
|
9
|
+
* - Evidence gate: `prompt-prompt-contract watch` requires a passing `prompt-prompt-contract spike-0` report (--report) or an
|
|
10
|
+
* explicit --force, honoring decision D7. Spike-0 itself never unlocks anything.
|
|
11
|
+
* - --dry-run exercises capture + enhance but never issues ⌘V.
|
|
12
|
+
*
|
|
13
|
+
* macOS only. The global hotkey comes from a small Swift helper (Carbon
|
|
14
|
+
* RegisterEventHotKey — needs no Accessibility permission) compiled on first run
|
|
15
|
+
* from the embedded source below; capture/paste keystrokes still need Accessibility.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { readFile as readFileAsync } from 'node:fs/promises';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { createInterface } from 'node:readline';
|
|
24
|
+
import { enhance as enhanceCore, PromptContractError } from '../../core/src/index.js';
|
|
25
|
+
import { loadProfile, readUserConfig, resolveConfig } from '../../core/src/node.js';
|
|
26
|
+
import { createOpenAIProvider } from '../../providers/src/openai.js';
|
|
27
|
+
import { createOllamaProvider } from '../../providers/src/ollama.js';
|
|
28
|
+
import {
|
|
29
|
+
captureSelectedText,
|
|
30
|
+
createMacOSAdapter,
|
|
31
|
+
isMacOS,
|
|
32
|
+
sameFocusIdentity,
|
|
33
|
+
} from './spike-0.js';
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_WATCH_OPTIONS = Object.freeze({
|
|
36
|
+
hotkey: 'alt+b',
|
|
37
|
+
settleMs: 75,
|
|
38
|
+
// 1000ms between ⌘V and the clipboard restore — measured on a live TextEdit
|
|
39
|
+
// round-trip: 150ms and 500ms both let the restore beat the target app's
|
|
40
|
+
// paste read (the document then receives the RESTORED content); 1000ms and
|
|
41
|
+
// 2500ms passed. The window cuts both ways (a user ⌘C inside it gets
|
|
42
|
+
// clobbered by the restore), so it is deliberately the tested minimum.
|
|
43
|
+
pasteDelayMs: 1000,
|
|
44
|
+
cooldownMs: 800,
|
|
45
|
+
dryRun: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Carbon key codes (kVK_*) for the keys we accept in --hotkey.
|
|
49
|
+
export const KEY_CODES = Object.freeze({
|
|
50
|
+
a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9, b: 11,
|
|
51
|
+
q: 12, w: 13, e: 14, r: 15, y: 16, t: 17, u: 32, i: 34, o: 31, p: 35,
|
|
52
|
+
l: 37, j: 38, k: 40, n: 45, m: 46,
|
|
53
|
+
0: 29, 1: 18, 2: 19, 3: 20, 4: 21, 5: 23, 6: 22, 7: 26, 8: 28, 9: 25,
|
|
54
|
+
space: 49, return: 36, enter: 36, tab: 48, escape: 53, delete: 51, forwarddelete: 117,
|
|
55
|
+
'=': 24, '-': 27, '[': 33, ']': 30, ';': 41, "'": 39, ',': 43, '.': 47, '/': 44, '\\': 42, '`': 50,
|
|
56
|
+
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100, f9: 101,
|
|
57
|
+
f10: 109, f11: 103, f12: 111,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Carbon modifier masks (cmdKey/shiftKey/optionKey/controlKey).
|
|
61
|
+
export const MODIFIER_MASKS = Object.freeze({
|
|
62
|
+
cmd: 1 << 8,
|
|
63
|
+
shift: 1 << 9,
|
|
64
|
+
alt: 1 << 11,
|
|
65
|
+
ctrl: 1 << 12,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const MODIFIER_ALIASES = Object.freeze({
|
|
69
|
+
cmd: 'cmd', meta: 'cmd', apple: 'cmd',
|
|
70
|
+
alt: 'alt', option: 'alt',
|
|
71
|
+
ctrl: 'ctrl', control: 'ctrl',
|
|
72
|
+
shift: 'shift',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const GLYPHS = Object.freeze({ cmd: '⌘', shift: '⇧', alt: '⌥', ctrl: '⌃' });
|
|
76
|
+
|
|
77
|
+
export function parseHotkey(spec = DEFAULT_WATCH_OPTIONS.hotkey) {
|
|
78
|
+
const parts = String(spec).split('+').map((part) => part.trim()).filter(Boolean);
|
|
79
|
+
if (parts.length < 2) {
|
|
80
|
+
throw new PromptContractError('config_error', `hotkey "${spec}" must combine a modifier with a key, e.g. alt+b (supported modifiers: cmd, alt/option, ctrl, shift)`);
|
|
81
|
+
}
|
|
82
|
+
let modifiers = 0;
|
|
83
|
+
const labelParts = [];
|
|
84
|
+
for (const part of parts.slice(0, -1)) {
|
|
85
|
+
const name = MODIFIER_ALIASES[part.toLowerCase()];
|
|
86
|
+
if (!name) {
|
|
87
|
+
throw new PromptContractError('config_error', `unknown hotkey modifier "${part}" (supported: cmd(⌘), alt/option(⌥), ctrl(⌃), shift(⇧))`);
|
|
88
|
+
}
|
|
89
|
+
const mask = MODIFIER_MASKS[name];
|
|
90
|
+
if (modifiers & mask) {
|
|
91
|
+
throw new PromptContractError('config_error', `duplicate hotkey modifier "${part}"`);
|
|
92
|
+
}
|
|
93
|
+
modifiers |= mask;
|
|
94
|
+
labelParts.push(GLYPHS[name]);
|
|
95
|
+
}
|
|
96
|
+
const keyToken = parts[parts.length - 1].toLowerCase();
|
|
97
|
+
const keyCode = KEY_CODES[keyToken];
|
|
98
|
+
if (keyCode === undefined) {
|
|
99
|
+
throw new PromptContractError('config_error', `unknown hotkey key "${parts[parts.length - 1]}" (supported: a-z, 0-9, space, return, tab, escape, delete, f1-f12, -=[];',./\\)`);
|
|
100
|
+
}
|
|
101
|
+
const keyLabel = keyToken.length === 1 ? keyToken.toUpperCase() : keyToken.charAt(0).toUpperCase() + keyToken.slice(1);
|
|
102
|
+
return { keyCode, modifiers, label: labelParts.join('') + keyLabel };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Trigger precedence: --hotkey flag > "hotkey" in ~/.prompt-contract/config.json > default.
|
|
107
|
+
* The hotkey is pressed deliberately, so everyday ⌘C copies never trigger anything;
|
|
108
|
+
* ⌘C is only sent synthetically after the trigger fires.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveWatchHotkey(flags = {}, config = {}) {
|
|
111
|
+
return String(flags.hotkey ?? config.hotkey ?? DEFAULT_WATCH_OPTIONS.hotkey);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* D7 evidence gate: a passing Spike-0 compatibility report, or an explicit --force.
|
|
116
|
+
*/
|
|
117
|
+
export async function evaluateWatchGate({ force = false, reportPath, readFile } = {}) {
|
|
118
|
+
if (force) return { ok: true, mode: 'forced' };
|
|
119
|
+
if (!reportPath) {
|
|
120
|
+
return { ok: false, reason: 'no Spike-0 evidence provided' };
|
|
121
|
+
}
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = await readFile(reportPath, 'utf8');
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return { ok: false, reason: `cannot read report ${reportPath}: ${err.code || err.message}` };
|
|
127
|
+
}
|
|
128
|
+
let report;
|
|
129
|
+
try {
|
|
130
|
+
report = JSON.parse(raw);
|
|
131
|
+
} catch {
|
|
132
|
+
return { ok: false, reason: `report ${reportPath} is not valid JSON` };
|
|
133
|
+
}
|
|
134
|
+
if (report?.schemaVersion !== 'prompt-contract/spike-0.v1' || report?.kind !== 'compatibility-report') {
|
|
135
|
+
return { ok: false, reason: `${reportPath} is not a Spike-0 compatibility report (schemaVersion/kind mismatch)` };
|
|
136
|
+
}
|
|
137
|
+
if (report.decision?.pass !== true) {
|
|
138
|
+
const reasons = Array.isArray(report.decision?.reasons) && report.decision.reasons.length
|
|
139
|
+
? report.decision.reasons.join('; ')
|
|
140
|
+
: 'decision.pass is false';
|
|
141
|
+
return { ok: false, reason: `Spike-0 report did not pass: ${reasons}` };
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, mode: 'report', report };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read-only startup probe: proves clipboard access and the Accessibility-backed
|
|
148
|
+
* focus query work before watch goes resident. Never writes the clipboard.
|
|
149
|
+
*/
|
|
150
|
+
export async function probeCaptureSafety(adapter) {
|
|
151
|
+
const warnings = [];
|
|
152
|
+
try {
|
|
153
|
+
await adapter.readClipboard();
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return { ok: false, error: `clipboard read failed: ${err.message}` };
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await adapter.checkClipboardRestorable();
|
|
159
|
+
} catch (err) {
|
|
160
|
+
warnings.push(`current clipboard is not text-only (${err.message}); capture refuses to run while rich content is on the pasteboard`);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await adapter.getFocusIdentity();
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return { ok: false, error: `focus query failed: ${err.message} — grant Accessibility to your terminal under System Settings → Privacy & Security → Accessibility` };
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, warnings };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The resident loop. Pure logic: every side effect (capture, enhance, paste,
|
|
172
|
+
* notify) is injected or goes through the adapter, so tests drive it with fakes.
|
|
173
|
+
*/
|
|
174
|
+
export function createWatchService({
|
|
175
|
+
adapter,
|
|
176
|
+
enhance,
|
|
177
|
+
options = {},
|
|
178
|
+
notify = async () => {},
|
|
179
|
+
log = () => {},
|
|
180
|
+
now = () => Date.now(),
|
|
181
|
+
} = {}) {
|
|
182
|
+
const opts = { ...DEFAULT_WATCH_OPTIONS, ...options };
|
|
183
|
+
let busy = false;
|
|
184
|
+
let lastCycleEnd = -Infinity;
|
|
185
|
+
|
|
186
|
+
async function pasteBack(enhancedText) {
|
|
187
|
+
// Snapshot whatever is on the clipboard right now (the user may have copied
|
|
188
|
+
// something while the model was thinking); restore it after the paste lands.
|
|
189
|
+
let saved = null;
|
|
190
|
+
let restorable = false;
|
|
191
|
+
try {
|
|
192
|
+
saved = await adapter.readClipboard();
|
|
193
|
+
await adapter.checkClipboardRestorable();
|
|
194
|
+
restorable = true;
|
|
195
|
+
} catch {
|
|
196
|
+
restorable = false;
|
|
197
|
+
}
|
|
198
|
+
await adapter.writeClipboard(enhancedText);
|
|
199
|
+
await adapter.pasteSelection();
|
|
200
|
+
if (opts.pasteDelayMs > 0) await adapter.sleep(opts.pasteDelayMs);
|
|
201
|
+
if (restorable) {
|
|
202
|
+
await adapter.writeClipboard(saved);
|
|
203
|
+
const restored = await adapter.readClipboard();
|
|
204
|
+
if (restored !== saved) log('watch: clipboard restore could not be verified');
|
|
205
|
+
} else {
|
|
206
|
+
log('watch: clipboard held non-text content; it was not preserved across the paste');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function handleTrigger() {
|
|
211
|
+
if (busy) {
|
|
212
|
+
log('watch: busy — trigger ignored (D8: no queueing on the hotkey path)');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (now() - lastCycleEnd < opts.cooldownMs) {
|
|
216
|
+
log('watch: cooldown — trigger ignored');
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
busy = true;
|
|
220
|
+
try {
|
|
221
|
+
const capture = await captureSelectedText(adapter, { settleMs: opts.settleMs });
|
|
222
|
+
if (capture.error) {
|
|
223
|
+
await notify({ title: 'PromptContract watch', message: `capture failed: ${capture.error}` });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
let text = capture.selectedText;
|
|
227
|
+
let source = 'selection';
|
|
228
|
+
if (!text) {
|
|
229
|
+
// PRD §7.3: with nothing selected, fall back to the clipboard content
|
|
230
|
+
// (captureSelectedText has already restored the pre-capture clipboard).
|
|
231
|
+
const clip = await adapter.readClipboard();
|
|
232
|
+
if (typeof clip === 'string' && clip.trim()) {
|
|
233
|
+
text = clip;
|
|
234
|
+
source = 'clipboard';
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!text) {
|
|
238
|
+
await notify({ title: 'PromptContract watch', message: 'No selected text and an empty clipboard — select text first.' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
log(`watch: captured ${text.length} chars from ${source}`);
|
|
242
|
+
const res = await enhance(text);
|
|
243
|
+
log(`watch: enhanced ${text.length} → ${res.text.length} chars · ${res.meta?.model ?? 'model'} · ${res.meta?.ms ?? '?'}ms`);
|
|
244
|
+
if (opts.dryRun) {
|
|
245
|
+
log('watch: dry-run — nothing pasted, clipboard untouched. Enhanced text:');
|
|
246
|
+
log(res.text);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!capture.contextBefore) {
|
|
250
|
+
await notify({ title: 'PromptContract watch', message: 'Focus identity unavailable at capture time — paste aborted.' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const focusNow = await adapter.getFocusIdentity();
|
|
254
|
+
if (!sameFocusIdentity(capture.contextBefore, focusNow)) {
|
|
255
|
+
await notify({
|
|
256
|
+
title: 'PromptContract watch',
|
|
257
|
+
message: `Focus moved to ${focusNow?.processName ?? 'another app'}; return to ${capture.contextBefore.processName} and press the hotkey again.`,
|
|
258
|
+
});
|
|
259
|
+
log('watch: focus drift detected — paste aborted (fail closed)');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
await pasteBack(res.text);
|
|
263
|
+
log('watch: pasted enhanced text');
|
|
264
|
+
} catch (err) {
|
|
265
|
+
await notify({ title: 'PromptContract watch', message: `error: ${err.code || ''} ${err.message}`.trim() });
|
|
266
|
+
log(`watch: cycle failed: ${err.code || ''} ${err.message}`.trim());
|
|
267
|
+
} finally {
|
|
268
|
+
lastCycleEnd = now();
|
|
269
|
+
busy = false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
handleTrigger,
|
|
275
|
+
stop: () => { busy = false; },
|
|
276
|
+
isBusy: () => busy,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Swift helper: registers one global hotkey via Carbon and prints a line-based
|
|
281
|
+
// protocol on stdout. Kept interpolation-free so it can live in a JS template
|
|
282
|
+
// literal. Compiled on demand to the cache dir; source is embedded here so the
|
|
283
|
+
// compiled binary is always reproducible from this audited file.
|
|
284
|
+
export const HELPER_SOURCE = `import AppKit
|
|
285
|
+
import Carbon.HIToolbox
|
|
286
|
+
import Foundation
|
|
287
|
+
|
|
288
|
+
// pb hotkey helper — embedded in packages/cli/src/watch.js; audit changes there.
|
|
289
|
+
// argv: <keyCode> <modifierMask>. Prints READY, then one TRIGGER line per press.
|
|
290
|
+
let args = CommandLine.arguments
|
|
291
|
+
guard args.count >= 3, let keyCode = UInt32(args[1]), let modifiers = UInt32(args[2]) else {
|
|
292
|
+
fputs("usage: pb-hotkey-helper <keyCode> <modifierMask>\\n", stderr)
|
|
293
|
+
exit(2)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let signature = OSType(0x5042_484B) // 'PBHK'
|
|
297
|
+
var hotKeyID = EventHotKeyID(signature: signature, id: 1)
|
|
298
|
+
var hotKeyRef: EventHotKeyRef?
|
|
299
|
+
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
|
|
300
|
+
|
|
301
|
+
func hotKeyHandler(_ callRef: EventHandlerCallRef?, _ event: EventRef?, _ data: UnsafeMutableRawPointer?) -> OSStatus {
|
|
302
|
+
var id = EventHotKeyID()
|
|
303
|
+
let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout<EventHotKeyID>.size, nil, &id)
|
|
304
|
+
if status == noErr && id.signature == signature && id.id == 1 {
|
|
305
|
+
fputs("TRIGGER\\n", stdout)
|
|
306
|
+
fflush(stdout)
|
|
307
|
+
}
|
|
308
|
+
return status
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let installStatus = InstallEventHandler(GetApplicationEventTarget(), hotKeyHandler, 1, &eventType, nil, nil)
|
|
312
|
+
let registerStatus = RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)
|
|
313
|
+
if installStatus != noErr || registerStatus != noErr {
|
|
314
|
+
fputs("hotkey registration failed\\n", stderr)
|
|
315
|
+
exit(1)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let app = NSApplication.shared
|
|
319
|
+
app.setActivationPolicy(.accessory) // invisible resident: no Dock icon, no window
|
|
320
|
+
fputs("READY\\n", stdout)
|
|
321
|
+
fflush(stdout)
|
|
322
|
+
app.run()
|
|
323
|
+
`;
|
|
324
|
+
|
|
325
|
+
export function defaultCacheDir() {
|
|
326
|
+
return process.env.CONTRACT_CACHE_DIR || join(homedir(), '.cache', 'prompt-boost');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function runProcess(child, { timeoutMs = 120000 } = {}) {
|
|
330
|
+
return new Promise((resolve) => {
|
|
331
|
+
let stdout = '';
|
|
332
|
+
let stderr = '';
|
|
333
|
+
let timedOut = false;
|
|
334
|
+
const timer = setTimeout(() => {
|
|
335
|
+
timedOut = true;
|
|
336
|
+
child.kill('SIGTERM');
|
|
337
|
+
}, timeoutMs);
|
|
338
|
+
child.stdout?.setEncoding('utf8');
|
|
339
|
+
child.stderr?.setEncoding('utf8');
|
|
340
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk; });
|
|
341
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk; });
|
|
342
|
+
child.on('error', (error) => {
|
|
343
|
+
clearTimeout(timer);
|
|
344
|
+
resolve({ code: null, stdout, stderr, error });
|
|
345
|
+
});
|
|
346
|
+
child.on('close', (code) => {
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function ensureHelperBinary({ cacheDir, spawnFn = spawn, log = () => {} }) {
|
|
354
|
+
const hash = createHash('sha256').update(HELPER_SOURCE).digest('hex').slice(0, 12);
|
|
355
|
+
const binPath = join(cacheDir, `pb-hotkey-helper-${hash}`);
|
|
356
|
+
if (existsSync(binPath)) return binPath;
|
|
357
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
358
|
+
const srcPath = `${binPath}.swift`;
|
|
359
|
+
// HELPER_SOURCE is written verbatim: its \n sequences are Swift string
|
|
360
|
+
// escapes and must reach the compiler untouched.
|
|
361
|
+
writeFileSync(srcPath, HELPER_SOURCE, 'utf8');
|
|
362
|
+
log('watch: compiling global-hotkey helper (one-time, up to ~30s, needs the Xcode Command Line Tools)…');
|
|
363
|
+
const result = await runProcess(spawnFn('swiftc', ['-O', srcPath, '-o', binPath], { stdio: ['ignore', 'pipe', 'pipe'] }), { timeoutMs: 180000 });
|
|
364
|
+
if (result.error || result.code !== 0) {
|
|
365
|
+
const detail = String(result.stderr || result.error?.message || '').trim().split('\n').slice(0, 5).join('\n');
|
|
366
|
+
const error = new Error(`failed to compile the hotkey helper (swiftc${result.timedOut ? ' timed out' : ` exited ${result.code ?? 'n/a'}`}). Is the Xcode Command Line Tools installed (xcode-select --install)?\n${detail}`);
|
|
367
|
+
error.code = 'hotkey_helper_compile_failed';
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
log('watch: helper compiled');
|
|
371
|
+
return binPath;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Global hotkey via the Swift helper. Registration itself needs no permission;
|
|
376
|
+
* only the later ⌘C/⌘V keystrokes require Accessibility.
|
|
377
|
+
*/
|
|
378
|
+
export function createSwiftHotkeySource({
|
|
379
|
+
hotkeySpec = DEFAULT_WATCH_OPTIONS.hotkey,
|
|
380
|
+
cacheDir = defaultCacheDir(),
|
|
381
|
+
spawnFn = spawn,
|
|
382
|
+
readyTimeoutMs = 30000,
|
|
383
|
+
log = () => {},
|
|
384
|
+
} = {}) {
|
|
385
|
+
let child = null;
|
|
386
|
+
let triggerCb = null;
|
|
387
|
+
let exitCb = null;
|
|
388
|
+
let stopped = false;
|
|
389
|
+
|
|
390
|
+
async function start() {
|
|
391
|
+
const { keyCode, modifiers, label } = parseHotkey(hotkeySpec);
|
|
392
|
+
const binPath = await ensureHelperBinary({ cacheDir, spawnFn, log });
|
|
393
|
+
child = spawnFn(binPath, [String(keyCode), String(modifiers)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
394
|
+
child.stdout.setEncoding('utf8');
|
|
395
|
+
child.stderr.setEncoding('utf8');
|
|
396
|
+
const ready = new Promise((resolve, reject) => {
|
|
397
|
+
const timer = setTimeout(() => reject(new Error(`hotkey helper not READY within ${readyTimeoutMs}ms`)), readyTimeoutMs);
|
|
398
|
+
let readySeen = false;
|
|
399
|
+
const onLine = (line) => {
|
|
400
|
+
if (line === 'READY' && !readySeen) {
|
|
401
|
+
readySeen = true;
|
|
402
|
+
clearTimeout(timer);
|
|
403
|
+
resolve(label);
|
|
404
|
+
} else if (line === 'TRIGGER') {
|
|
405
|
+
triggerCb?.();
|
|
406
|
+
} else if (line) {
|
|
407
|
+
log(`watch helper: ${line}`);
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
createInterface({ input: child.stdout }).on('line', onLine);
|
|
411
|
+
createInterface({ input: child.stderr }).on('line', (line) => { if (line) log(`watch helper: ${line}`); });
|
|
412
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
413
|
+
child.on('close', (code) => {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
if (!readySeen) reject(new Error(`hotkey helper exited before READY (code ${code})`));
|
|
416
|
+
else exitCb?.({ code, clean: stopped });
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
return ready;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
start,
|
|
424
|
+
label: parseHotkey(hotkeySpec).label,
|
|
425
|
+
onTrigger: (cb) => { triggerCb = cb; },
|
|
426
|
+
onExit: (cb) => { exitCb = cb; },
|
|
427
|
+
async stop() {
|
|
428
|
+
stopped = true;
|
|
429
|
+
if (!child || child.exitCode !== null) return;
|
|
430
|
+
child.kill('SIGTERM');
|
|
431
|
+
await new Promise((resolve) => {
|
|
432
|
+
const timer = setTimeout(() => {
|
|
433
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
434
|
+
resolve();
|
|
435
|
+
}, 2000);
|
|
436
|
+
child.on('close', () => { clearTimeout(timer); resolve(); });
|
|
437
|
+
});
|
|
438
|
+
},
|
|
439
|
+
get running() { return !stopped && child && child.exitCode === null; },
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Portable fallback trigger (any platform): each Enter on stdin fires the
|
|
445
|
+
* cycle; the line "q" quits. Useful without a desktop session or for manual testing.
|
|
446
|
+
*/
|
|
447
|
+
export function createStdinTriggerSource({ input = process.stdin, log = () => {} } = {}) {
|
|
448
|
+
let triggerCb = null;
|
|
449
|
+
let exitCb = null;
|
|
450
|
+
let rl = null;
|
|
451
|
+
return {
|
|
452
|
+
label: 'Enter',
|
|
453
|
+
onTrigger: (cb) => { triggerCb = cb; },
|
|
454
|
+
onExit: (cb) => { exitCb = cb; },
|
|
455
|
+
async start() {
|
|
456
|
+
rl = createInterface({ input });
|
|
457
|
+
rl.on('line', (line) => {
|
|
458
|
+
const trimmed = line.trim().toLowerCase();
|
|
459
|
+
if (trimmed === 'q') {
|
|
460
|
+
rl.close();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
triggerCb?.();
|
|
464
|
+
});
|
|
465
|
+
rl.on('close', () => exitCb?.({ code: 0, clean: true }));
|
|
466
|
+
return 'Enter';
|
|
467
|
+
},
|
|
468
|
+
async stop() {
|
|
469
|
+
rl?.close();
|
|
470
|
+
},
|
|
471
|
+
get running() { return Boolean(rl); },
|
|
472
|
+
log,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function buildProvider(cfg) {
|
|
477
|
+
return cfg.provider === 'ollama'
|
|
478
|
+
? createOllamaProvider({ baseUrl: cfg.baseUrl })
|
|
479
|
+
: createOpenAIProvider({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function defaultNotify({ title, message }) {
|
|
483
|
+
const escaped = String(message).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
484
|
+
const escapedTitle = String(title).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
485
|
+
await runProcess(spawn('/usr/bin/osascript', ['-e', `display notification "${escaped}" with title "${escapedTitle}"`], { stdio: ['ignore', 'ignore', 'pipe'] }), { timeoutMs: 5000 });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* `prompt-prompt-contract watch` entry point. Deps are injectable for tests; flags come from contract.js parseArgs.
|
|
490
|
+
*/
|
|
491
|
+
export async function runWatch(flags = {}, deps = {}) {
|
|
492
|
+
const {
|
|
493
|
+
isMacOSPlatform = isMacOS,
|
|
494
|
+
adapter = null,
|
|
495
|
+
enhance = null,
|
|
496
|
+
source = null,
|
|
497
|
+
notify = defaultNotify,
|
|
498
|
+
log = (message) => process.stderr.write(`${message}\n`),
|
|
499
|
+
readFile = readFileAsync,
|
|
500
|
+
config = null,
|
|
501
|
+
registerSignals = true,
|
|
502
|
+
processRef = process,
|
|
503
|
+
} = deps;
|
|
504
|
+
|
|
505
|
+
if (!isMacOSPlatform) {
|
|
506
|
+
log('prompt-contract watch is macOS-only: it drives ⌘C/⌘V through System Events and needs the macOS clipboard. See docs/WATCH.md for the platform matrix.');
|
|
507
|
+
return 2;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const gate = await evaluateWatchGate({
|
|
511
|
+
force: Boolean(flags.force),
|
|
512
|
+
reportPath: flags.report,
|
|
513
|
+
readFile,
|
|
514
|
+
});
|
|
515
|
+
if (!gate.ok) {
|
|
516
|
+
log(`prompt-prompt-contract watch refused to start (decision D7 evidence gate): ${gate.reason}
|
|
517
|
+
Watch pastes over your selection, so it runs only with measured evidence. Either:
|
|
518
|
+
- run prompt-contract spike-0 --json --output ~/.cache/prompt-contract/spike-0.json first, then
|
|
519
|
+
prompt-contract watch --report ~/.cache/prompt-contract/spike-0.json
|
|
520
|
+
- or pass --force to accept the risk without evidence.`);
|
|
521
|
+
return 2;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Fail fast on a bad hotkey before touching the clipboard or going resident.
|
|
525
|
+
const hotkeySpec = resolveWatchHotkey(flags, config ?? readUserConfig(flags.configPath));
|
|
526
|
+
try {
|
|
527
|
+
parseHotkey(hotkeySpec);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
log(`prompt-prompt-contract watch: ${err.message}
|
|
530
|
+
Fix it with --hotkey <spec> or the "hotkey" field in ~/.prompt-contract/config.json (e.g. "hotkey": "ctrl+alt+b").`);
|
|
531
|
+
return 2;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const macAdapter = adapter ?? createMacOSAdapter();
|
|
535
|
+
const probe = await probeCaptureSafety(macAdapter);
|
|
536
|
+
if (!probe.ok) {
|
|
537
|
+
log(`prompt-prompt-contract watch startup probe failed: ${probe.error}`);
|
|
538
|
+
return 2;
|
|
539
|
+
}
|
|
540
|
+
for (const warning of probe.warnings) log(`watch: warning — ${warning}`);
|
|
541
|
+
|
|
542
|
+
// D8 low-latency charter: load config/profile and warm the provider once at
|
|
543
|
+
// startup; the hotkey path below is pure async with no further initialization.
|
|
544
|
+
// (Skipped when a test injects enhance directly.)
|
|
545
|
+
let enhanceFn = enhance;
|
|
546
|
+
let cfg = null;
|
|
547
|
+
if (!enhanceFn) {
|
|
548
|
+
const profile = loadProfile(flags.profile || 'coding-agent');
|
|
549
|
+
cfg = resolveConfig(flags);
|
|
550
|
+
const provider = buildProvider(cfg);
|
|
551
|
+
provider.warmup?.({ model: cfg.model });
|
|
552
|
+
enhanceFn = (text) => enhanceCore(text, {
|
|
553
|
+
profile,
|
|
554
|
+
provider,
|
|
555
|
+
model: cfg.model,
|
|
556
|
+
strength: flags.strength,
|
|
557
|
+
context: flags.context,
|
|
558
|
+
maxChars: flags.maxChars ? parseInt(flags.maxChars, 10) : undefined,
|
|
559
|
+
timeoutMs: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const triggerSource = source ?? (flags.trigger === 'stdin'
|
|
564
|
+
? createStdinTriggerSource({ log })
|
|
565
|
+
: createSwiftHotkeySource({ hotkeySpec, log }));
|
|
566
|
+
|
|
567
|
+
const service = createWatchService({
|
|
568
|
+
adapter: macAdapter,
|
|
569
|
+
enhance: enhanceFn,
|
|
570
|
+
options: {
|
|
571
|
+
settleMs: flags.settleMs !== undefined ? parseInt(flags.settleMs, 10) : undefined,
|
|
572
|
+
pasteDelayMs: flags.pasteDelayMs !== undefined ? parseInt(flags.pasteDelayMs, 10) : undefined,
|
|
573
|
+
cooldownMs: flags.cooldownMs !== undefined ? parseInt(flags.cooldownMs, 10) : undefined,
|
|
574
|
+
dryRun: Boolean(flags.dryRun),
|
|
575
|
+
},
|
|
576
|
+
notify,
|
|
577
|
+
log,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
let stopReason = null;
|
|
581
|
+
const stopped = new Promise((resolve) => {
|
|
582
|
+
triggerSource.onTrigger(() => { service.handleTrigger(); });
|
|
583
|
+
// Sources classify their own exit: { clean: true } for a shutdown we (or
|
|
584
|
+
// the user via `q`) initiated, { clean: false } for a crash.
|
|
585
|
+
triggerSource.onExit((info) => {
|
|
586
|
+
if (info?.clean === false) stopReason = stopReason ?? `trigger source exited (code ${info?.code ?? '?'})`;
|
|
587
|
+
resolve();
|
|
588
|
+
});
|
|
589
|
+
if (registerSignals) {
|
|
590
|
+
const shutdown = () => { stopReason = stopReason ?? 'interrupted'; resolve(); };
|
|
591
|
+
processRef.once?.('SIGINT', shutdown);
|
|
592
|
+
processRef.once?.('SIGTERM', shutdown);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const label = await triggerSource.start();
|
|
597
|
+
const profileName = flags.profile || 'coding-agent';
|
|
598
|
+
const providerLabel = cfg ? `${cfg.provider} model=${cfg.model}` : 'injected enhance fn';
|
|
599
|
+
const suffix = flags.dryRun ? ' · DRY-RUN (never pastes)' : '';
|
|
600
|
+
const usingStdin = !source && flags.trigger === 'stdin';
|
|
601
|
+
const triggerDesc = usingStdin ? label : `${label} [${hotkeySpec}]`;
|
|
602
|
+
log(`prompt-prompt-contract watch resident — ${triggerDesc} enhances the selection · profile=${profileName} provider=${providerLabel}${suffix}
|
|
603
|
+
Ctrl+C to quit. Paste replaces the selected text; the clipboard is restored afterwards. See docs/WATCH.md.`);
|
|
604
|
+
|
|
605
|
+
await stopped;
|
|
606
|
+
await triggerSource.stop();
|
|
607
|
+
log(`watch: stopped${stopReason ? ` — ${stopReason}` : ''}`);
|
|
608
|
+
return stopReason && /exited/.test(stopReason) ? 1 : 0;
|
|
609
|
+
}
|
|
@@ -11,6 +11,28 @@ const QUOTE_PAIRS = [
|
|
|
11
11
|
['\u300c', '\u300d'] // 「 」
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Strip reasoning-model blocks (<think>/<thinking>/<reasoning>/<thought>) so chain-of-thought
|
|
16
|
+
* from models like DeepSeek-R1, Qwen3-thinking, or Hermes never reaches a user surface
|
|
17
|
+
* (critical for prompt-prompt-contract watch: the cleaned text is pasted into the user's document).
|
|
18
|
+
* A reasoning tag opened but never closed is cut to end-of-text (truncated streams).
|
|
19
|
+
*/
|
|
20
|
+
const REASONING_TAGS = ['think', 'thinking', 'reasoning', 'thought'];
|
|
21
|
+
|
|
22
|
+
export function stripReasoningBlocks(t) {
|
|
23
|
+
let prev;
|
|
24
|
+
do {
|
|
25
|
+
prev = t;
|
|
26
|
+
for (const tag of REASONING_TAGS) {
|
|
27
|
+
const tagPattern = new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, 'gi');
|
|
28
|
+
t = t.replace(tagPattern, '');
|
|
29
|
+
const openPattern = new RegExp(`<${tag}>[\\s\\S]*$`, 'i');
|
|
30
|
+
t = t.replace(openPattern, '');
|
|
31
|
+
}
|
|
32
|
+
} while (t !== prev);
|
|
33
|
+
return t;
|
|
34
|
+
}
|
|
35
|
+
|
|
14
36
|
/** Remove wrapping quote pairs, repeatedly (WorkBuddy stripWrappingQuotes, generalized). */
|
|
15
37
|
export function stripWrappingQuotes(t) {
|
|
16
38
|
let prev;
|
|
@@ -50,6 +72,7 @@ export function clampChars(t, maxChars) {
|
|
|
50
72
|
|
|
51
73
|
export function postprocess(raw, maxChars) {
|
|
52
74
|
let t = String(raw ?? '');
|
|
75
|
+
t = stripReasoningBlocks(t);
|
|
53
76
|
t = stripFences(t);
|
|
54
77
|
t = stripWrappingQuotes(t);
|
|
55
78
|
t = t.trim();
|
|
@@ -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)
|