opencode-prompt-expand 0.0.0-stage → 0.1.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/dist/model.js ADDED
@@ -0,0 +1,104 @@
1
+ export function composerToRef(model) {
2
+ return {
3
+ providerID: model.providerID,
4
+ id: model.modelID,
5
+ ...(model.variant ? { variant: model.variant } : {}),
6
+ };
7
+ }
8
+ export function modelToRef(model) {
9
+ return {
10
+ providerID: model.providerID,
11
+ id: model.id,
12
+ };
13
+ }
14
+ export function modelLabel(model) {
15
+ return `${model.providerID}/${model.id}`;
16
+ }
17
+ export function parseExplicitModel(value) {
18
+ const index = value.indexOf("/");
19
+ if (index <= 0 || index === value.length - 1)
20
+ return null;
21
+ return { providerID: value.slice(0, index), id: value.slice(index + 1) };
22
+ }
23
+ export function findModel(models, ref) {
24
+ return models.find((model) => model.providerID === ref.providerID && (model.id === ref.id || model.modelID === ref.id));
25
+ }
26
+ function baseCost(model) {
27
+ if (!model.cost || model.cost.length === 0)
28
+ return undefined;
29
+ return model.cost.find((tier) => !tier.tier) ?? model.cost[0];
30
+ }
31
+ export function isCheapChat(model) {
32
+ if (model.enabled === false)
33
+ return false;
34
+ if (model.status === "deprecated")
35
+ return false;
36
+ if (model.type && model.type !== "chat")
37
+ return false;
38
+ const cost = baseCost(model);
39
+ if (!cost)
40
+ return true;
41
+ return cost.input < 1 && cost.output < 3;
42
+ }
43
+ function cheapestChat(models) {
44
+ return models
45
+ .filter((model) => isCheapChat(model))
46
+ .slice()
47
+ .sort((left, right) => {
48
+ const leftCost = baseCost(left);
49
+ const rightCost = baseCost(right);
50
+ const leftScore = leftCost ? leftCost.input + leftCost.output : 0;
51
+ const rightScore = rightCost ? rightCost.input + rightCost.output : 0;
52
+ return leftScore - rightScore;
53
+ })[0];
54
+ }
55
+ function anyChat(models) {
56
+ return models.find((model) => model.enabled !== false &&
57
+ model.status !== "deprecated" &&
58
+ (!model.type || model.type === "chat"));
59
+ }
60
+ export function pickModel(input) {
61
+ const preference = input.preference.trim();
62
+ if (preference && preference !== "auto" && preference !== "composer") {
63
+ const explicit = parseExplicitModel(preference);
64
+ if (explicit) {
65
+ const found = findModel(input.models, explicit);
66
+ return { model: found ? modelToRef(found) : explicit, source: "explicit" };
67
+ }
68
+ }
69
+ const composerRef = input.composerModel ? composerToRef(input.composerModel) : undefined;
70
+ if (preference === "composer") {
71
+ if (composerRef)
72
+ return { model: composerRef, source: "composer" };
73
+ if (input.sessionModel)
74
+ return { model: input.sessionModel, source: "session" };
75
+ if (input.defaultModel)
76
+ return { model: modelToRef(input.defaultModel), source: "default" };
77
+ const fallback = anyChat(input.models);
78
+ return fallback ? { model: modelToRef(fallback), source: "cheap" } : null;
79
+ }
80
+ // auto: composer model first, but avoid expensive models.
81
+ if (composerRef) {
82
+ const info = findModel(input.models, composerRef);
83
+ if (!info || isCheapChat(info)) {
84
+ return { model: composerRef, source: "composer" };
85
+ }
86
+ }
87
+ if (input.sessionModel) {
88
+ const info = findModel(input.models, input.sessionModel);
89
+ if (!info || isCheapChat(info)) {
90
+ return { model: input.sessionModel, source: "session" };
91
+ }
92
+ }
93
+ // With no composer/session model (Home or a draft), the server default is
94
+ // a better guess than the cheapest model: free models often have hard usage
95
+ // limits, which would make the fallback button fail immediately.
96
+ if (input.defaultModel && input.defaultModel.enabled !== false && input.defaultModel.status !== "deprecated") {
97
+ return { model: modelToRef(input.defaultModel), source: "default" };
98
+ }
99
+ const cheap = cheapestChat(input.models);
100
+ if (cheap)
101
+ return { model: modelToRef(cheap), source: "cheap" };
102
+ const fallback = anyChat(input.models);
103
+ return fallback ? { model: modelToRef(fallback), source: "cheap" } : null;
104
+ }
@@ -0,0 +1,86 @@
1
+ const STRUCTURE_MARKERS = [/目标/, /范围/, /要求/, /验收/];
2
+ const CONFIRMATION = /^(继续|好的|收到|谢谢|可以|ok|okay|yes|no|go|continue|thanks?)[。.!!]?$/i;
3
+ const MENTION = /(^|\s)@[\w./\\-]{2,}/;
4
+ export function detectMention(text) {
5
+ return MENTION.test(text);
6
+ }
7
+ export function countStructureMarkers(text) {
8
+ return STRUCTURE_MARKERS.filter((pattern) => pattern.test(text)).length;
9
+ }
10
+ export function hasStructure(text) {
11
+ return countStructureMarkers(text) >= 3;
12
+ }
13
+ export function shouldSkip(text, config) {
14
+ const trimmed = text.trim();
15
+ const structured = hasStructure(trimmed);
16
+ if (!trimmed)
17
+ return { skip: true, reason: "empty", notify: false, structured };
18
+ if (/^\s*[\/!]/.test(trimmed)) {
19
+ return { skip: true, reason: "command", notify: false, structured };
20
+ }
21
+ if (CONFIRMATION.test(trimmed)) {
22
+ return { skip: true, reason: "confirmation", notify: false, structured };
23
+ }
24
+ if (countChars(trimmed) < config.minChars) {
25
+ return { skip: true, reason: "too_short", notify: true, structured };
26
+ }
27
+ if (countChars(trimmed) > config.maxPromptChars) {
28
+ return { skip: true, reason: "too_long", notify: false, structured };
29
+ }
30
+ if (config.skipPatterns.some((pattern) => pattern.test(trimmed))) {
31
+ return { skip: true, reason: "pattern", notify: false, structured };
32
+ }
33
+ if (config.skipOnMentions && detectMention(trimmed)) {
34
+ return { skip: true, reason: "mention", notify: true, structured };
35
+ }
36
+ return { skip: false, notify: false, structured };
37
+ }
38
+ export function countChars(text) {
39
+ return Array.from(text).length;
40
+ }
41
+ function stripCodeFences(text) {
42
+ if (!text.includes("```"))
43
+ return text;
44
+ return text
45
+ .replace(/^\s*```[^\n]*\n?/i, "")
46
+ .replace(/\n?```\s*$/i, "")
47
+ .replace(/^\s*```[^\n]*$/gm, "")
48
+ .trim();
49
+ }
50
+ function stripLeadingLabel(text) {
51
+ return text.replace(/^(优化后的提示词|增强后的提示词|改写后的提示词|增强结果|优化结果|expanded prompt|optimized prompt)\s*[::]\s*/i, "");
52
+ }
53
+ function stripWrappingQuotes(text) {
54
+ const pairs = [
55
+ ['"', '"'],
56
+ ["'", "'"],
57
+ ["“", "”"],
58
+ ["‘", "’"],
59
+ ["「", "」"],
60
+ ["『", "』"],
61
+ ];
62
+ for (const [open, close] of pairs) {
63
+ if (text.startsWith(open) && text.endsWith(close) && text.length >= open.length + close.length) {
64
+ return text.slice(open.length, -close.length).trim();
65
+ }
66
+ }
67
+ return text;
68
+ }
69
+ export function cleanOutput(text) {
70
+ let result = text.trim();
71
+ result = stripCodeFences(result);
72
+ result = stripLeadingLabel(result);
73
+ result = stripWrappingQuotes(result);
74
+ return result.trim();
75
+ }
76
+ export function validateOutput(output, original, config) {
77
+ const cleaned = output.trim();
78
+ if (!cleaned)
79
+ return { ok: false, reason: "empty" };
80
+ if (cleaned === original.trim())
81
+ return { ok: false, reason: "unchanged" };
82
+ if (countChars(cleaned) > countChars(original) * config.maxGrowth && countChars(cleaned) > 2000) {
83
+ return { ok: false, reason: "too_large" };
84
+ }
85
+ return { ok: true };
86
+ }
@@ -0,0 +1,121 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export const RULE_FILES = [
4
+ "AGENTS.md",
5
+ "CLAUDE.md",
6
+ "GEMINI.md",
7
+ ".cursorrules",
8
+ ".windsurfrules",
9
+ "CONVENTIONS.md",
10
+ ".github/copilot-instructions.md",
11
+ ];
12
+ const SENSITIVE = /(^|[\\/])(\.env|.*\.pem|.*\.key|credentials.*)$/i;
13
+ const cache = new Map();
14
+ const CACHE_MS = 5 * 60 * 1000;
15
+ export function clearProjectContextCache() {
16
+ cache.clear();
17
+ }
18
+ function truncate(value, limit) {
19
+ if (limit <= 0)
20
+ return "";
21
+ return Array.from(value).slice(0, limit).join("");
22
+ }
23
+ function isSensitive(path) {
24
+ return SENSITIVE.test(path);
25
+ }
26
+ function packageSummary(packageJson) {
27
+ if (!packageJson || typeof packageJson !== "object")
28
+ return "";
29
+ const data = packageJson;
30
+ const lines = [];
31
+ if (typeof data.name === "string")
32
+ lines.push(`name: ${data.name}`);
33
+ if (typeof data.description === "string")
34
+ lines.push(`description: ${data.description}`);
35
+ const dependencies = data.dependencies;
36
+ if (dependencies && typeof dependencies === "object" && !Array.isArray(dependencies)) {
37
+ const names = Object.keys(dependencies);
38
+ if (names.length > 0)
39
+ lines.push(`dependencies: ${names.join(", ")}`);
40
+ }
41
+ return lines.join("\n");
42
+ }
43
+ async function readPackageSummary(directory) {
44
+ const path = join(directory, "package.json");
45
+ if (isSensitive(path))
46
+ return "";
47
+ try {
48
+ const raw = await readFile(path, "utf8");
49
+ return packageSummary(JSON.parse(raw));
50
+ }
51
+ catch {
52
+ return "";
53
+ }
54
+ }
55
+ async function readRules(directory) {
56
+ const chunks = [];
57
+ for (const file of RULE_FILES) {
58
+ const path = join(directory, file);
59
+ if (isSensitive(path))
60
+ continue;
61
+ try {
62
+ const content = await readFile(path, "utf8");
63
+ const trimmed = content.trim();
64
+ if (trimmed)
65
+ chunks.push(`# ${file}\n${trimmed}`);
66
+ }
67
+ catch {
68
+ // Missing rule file is expected.
69
+ }
70
+ }
71
+ return chunks.join("\n\n");
72
+ }
73
+ export function resolveProjectDirectory(input) {
74
+ return (input.sessionDirectory?.trim() ||
75
+ input.requestDirectory?.trim() ||
76
+ input.pluginDirectory?.trim() ||
77
+ "");
78
+ }
79
+ export function formatProjectContext(parts, budget) {
80
+ if (budget <= 0)
81
+ return "";
82
+ const sections = [];
83
+ if (parts.packageSummary?.trim()) {
84
+ sections.push(`- 技术栈:\n${truncate(parts.packageSummary.trim(), 400)}`);
85
+ }
86
+ if (parts.rules?.trim()) {
87
+ sections.push(`- 规则摘要:\n${truncate(parts.rules.trim(), 1200)}`);
88
+ }
89
+ const gitLines = [];
90
+ if (parts.branch)
91
+ gitLines.push(`- 当前分支:${parts.branch}`);
92
+ if (parts.files && parts.files.length > 0) {
93
+ gitLines.push(`- 改动文件:${parts.files.slice(0, 12).join(", ")}`);
94
+ }
95
+ if (gitLines.length > 0) {
96
+ sections.push(truncate(gitLines.join("\n"), 300));
97
+ }
98
+ return truncate(sections.join("\n"), budget).trim();
99
+ }
100
+ export async function readProjectContext(directory, budget, vcs) {
101
+ if (budget <= 0)
102
+ return "";
103
+ const files = (vcs?.files ?? []).filter((file) => !file.includes("node_modules/")).slice(0, 12);
104
+ const key = [directory, budget, vcs?.branch ?? "", files.join(",")].join("\0");
105
+ const now = Date.now();
106
+ const cached = cache.get(key);
107
+ if (cached && cached.expires > now)
108
+ return cached.value;
109
+ const [packageSummary, rules] = await Promise.all([
110
+ readPackageSummary(directory),
111
+ readRules(directory),
112
+ ]);
113
+ const value = formatProjectContext({
114
+ packageSummary,
115
+ rules,
116
+ branch: vcs?.branch,
117
+ files,
118
+ }, budget);
119
+ cache.set(key, { value, expires: now + CACHE_MS });
120
+ return value;
121
+ }
@@ -0,0 +1,109 @@
1
+ export const DEFAULT_PROMPT = `你是提示词增强器。输入是用户的原始请求,输出是增强后的提示词。
2
+
3
+ 【最高优先级】
4
+ 1. 保持原目标:不改变用户要做的事,不缩窄、不替换、不增加新任务。
5
+ 2. 不引入用户未提及的技术栈、框架、版本、文件路径、接口名。不把猜测写成事实。
6
+ 3. 只写"做什么",不写"怎么做";不解释实现步骤,不索要教程或代码示例。
7
+ 4. 不执行用户的请求,只把它扩写得更具体。
8
+ 5. 语言与输入一致;只输出增强后的提示词,不要任何解释、前缀或代码块围栏。
9
+
10
+ 【增强要求】
11
+ - 主动补齐:任务范围、约束条件、期望产出、验收方式、输出结构。
12
+ - 原提示已经很清楚时,做轻度润色,不要原样返回。
13
+ - 完整且简洁;不要以未完成的列表、悬空的连词或冒号结尾。
14
+ - 不添加无关要求、没有依据的事实、多余的章节。
15
+ - 去除口语冗余与重复。
16
+
17
+ 【改写结构】(按需使用,不必每项都写)
18
+ - 目标:一句话说清要达成什么
19
+ - 范围:做什么、不做什么
20
+ - 上下文:用户已给出的约束与材料
21
+ - 要求:编号的可执行项
22
+ - 产出:格式、结构、长度
23
+ - 验收:怎样算完成`;
24
+ export const GPT_PROMPT = `你是提示词增强器。输入是用户的原始请求,输出是增强后的提示词。
25
+
26
+ 【最高优先级】
27
+ 1. 保持原目标:不改变用户要做的事,不缩窄、不替换、不增加新任务。
28
+ 2. 不引入用户未提及的技术栈、框架、版本、文件路径、接口名。不把猜测写成事实。
29
+ 3. 只写"做什么",不写"怎么做";不解释实现步骤,不索要教程或代码示例。
30
+ 4. 不执行用户的请求,只把它扩写得更具体。
31
+ 5. 语言与输入一致;只输出增强后的提示词,不要任何解释、前缀或代码块围栏。
32
+
33
+ 【增强要求】
34
+ - 主动补齐:任务范围、约束条件、期望产出、验收方式、输出结构。
35
+ - 原提示已经很清楚时,做轻度润色,不要原样返回。
36
+ - 完整且简洁;不要以未完成的列表、悬空的连词或冒号结尾。
37
+ - 不添加无关要求、没有依据的事实、多余的章节。
38
+ - 去除口语冗余与重复。
39
+
40
+ 【面向 GPT 的结构偏好】
41
+ - 用编号列表写清可执行要求,步骤顺序明确。
42
+ - 每条验收标准写成可判定的条件,避免"更好""高端"这类主观词。
43
+ - 产出格式写清楚(文件、表格、清单或代码块),需要时给出字段名。`;
44
+ export const CLAUDE_PROMPT = `你是提示词增强器。输入是用户的原始请求,输出是增强后的提示词。
45
+
46
+ 【最高优先级】
47
+ 1. 保持原目标:不改变用户要做的事,不缩窄、不替换、不增加新任务。
48
+ 2. 不引入用户未提及的技术栈、框架、版本、文件路径、接口名。不把猜测写成事实。
49
+ 3. 只写"做什么",不写"怎么做";不解释实现步骤,不索要教程或代码示例。
50
+ 4. 不执行用户的请求,只把它扩写得更具体。
51
+ 5. 语言与输入一致;只输出增强后的提示词,不要任何解释、前缀或代码块围栏。
52
+
53
+ 【增强要求】
54
+ - 主动补齐:任务范围、约束条件、期望产出、验收方式、输出结构。
55
+ - 原提示已经很清楚时,做轻度润色,不要原样返回。
56
+ - 完整且简洁;不要以未完成的列表、悬空的连词或冒号结尾。
57
+ - 不添加无关要求、没有依据的事实、多余的章节。
58
+ - 去除口语冗余与重复。
59
+
60
+ 【面向 Claude 的结构偏好】
61
+ - 优先写清目标、边界和约束,用连贯段落表达,不必强行机械编号。
62
+ - 明确说明哪些内容不在范围内,减少越界改动。
63
+ - 验收方式写成"完成时应满足什么",而不是步骤清单。`;
64
+ export const DEFAULT_PROMPTS = {
65
+ default: DEFAULT_PROMPT,
66
+ "*gpt*": GPT_PROMPT,
67
+ "*claude*": CLAUDE_PROMPT,
68
+ };
69
+ export function matchesGlob(pattern, value) {
70
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
71
+ return new RegExp(`^${escaped}$`, "i").test(value);
72
+ }
73
+ export function selectPrompt(modelID, prompts) {
74
+ if (modelID) {
75
+ for (const [pattern, prompt] of Object.entries(prompts)) {
76
+ if (pattern === "default")
77
+ continue;
78
+ if (matchesGlob(pattern, modelID))
79
+ return prompt;
80
+ }
81
+ }
82
+ return prompts.default ?? DEFAULT_PROMPT;
83
+ }
84
+ export function buildPrompt(input) {
85
+ const parts = [selectPrompt(input.targetModelID, input.prompts)];
86
+ if (input.mode === "concise") {
87
+ parts.push("【长度】简洁模式:目标约 800 字符以内,信息完整优先于凑长度。");
88
+ }
89
+ else {
90
+ parts.push("【长度】充分展开模式:不限长度,但不要添加没有依据的内容。");
91
+ }
92
+ if (input.structured) {
93
+ parts.push("【轻量处理】原提示已包含多个结构标记,只做轻度润色,不要重排或增加新章节。");
94
+ }
95
+ if (input.round >= 2 && input.previous) {
96
+ parts.push([
97
+ `这是第 ${input.round} 次生成。上一次的结果是:`,
98
+ "---",
99
+ input.previous,
100
+ "---",
101
+ "请换一种写法:如果上一版太啰嗦就压缩,太笼统就具体化;保持原目标不变,不要与上一版雷同。",
102
+ ].join("\n"));
103
+ }
104
+ if (input.projectContext?.trim()) {
105
+ parts.push(`【项目上下文】\n${input.projectContext.trim()}\n【/项目上下文】`);
106
+ }
107
+ parts.push(`【待改写】\n${input.text}`);
108
+ return parts.join("\n\n");
109
+ }
package/dist/rpc.js ADDED
@@ -0,0 +1,47 @@
1
+ import { Rpc } from "@opencode/plugin/rpc";
2
+ const composerModelSchema = {
3
+ type: "object",
4
+ properties: {
5
+ providerID: { type: "string" },
6
+ modelID: { type: "string" },
7
+ variant: { type: "string" },
8
+ },
9
+ required: ["providerID", "modelID"],
10
+ additionalProperties: false,
11
+ };
12
+ export const PromptExpand = Rpc.define({
13
+ id: "prompt-expand",
14
+ events: {},
15
+ methods: {
16
+ optimize: {
17
+ input: {
18
+ type: "object",
19
+ properties: {
20
+ text: { type: "string" },
21
+ mode: { enum: ["concise", "full"] },
22
+ round: { type: "integer", minimum: 1 },
23
+ previous: { type: "string" },
24
+ sessionID: { type: "string" },
25
+ directory: { type: "string" },
26
+ composerModel: composerModelSchema,
27
+ modelPreference: { type: "string" },
28
+ projectContext: { type: "boolean" },
29
+ },
30
+ required: ["text", "mode", "round"],
31
+ additionalProperties: false,
32
+ },
33
+ output: {
34
+ type: "object",
35
+ properties: {
36
+ ok: { type: "boolean" },
37
+ text: { type: "string" },
38
+ model: { type: "string" },
39
+ code: { type: "string" },
40
+ message: { type: "string" },
41
+ },
42
+ required: ["ok"],
43
+ additionalProperties: false,
44
+ },
45
+ },
46
+ },
47
+ });
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "opencode-prompt-expand",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Expand rough prompts into structured prompts inside the OpenCode Desktop composer.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "opencode-prompt-expand contributors",
8
+ "keywords": [
9
+ "opencode",
10
+ "opencode-plugin",
11
+ "prompt",
12
+ "prompt-engineering",
13
+ "desktop"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/ConnorYyy/opencode-prompt-expand.git"
18
+ },
19
+ "homepage": "https://github.com/ConnorYyy/opencode-prompt-expand#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/ConnorYyy/opencode-prompt-expand/issues"
22
+ },
23
+ "main": "./dist/index.js",
24
+ "exports": {
25
+ ".": "./dist/index.js",
26
+ "./rpc": "./dist/rpc.js",
27
+ "./gui-experimental": "./dist/gui-experimental.cjs"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "assets",
32
+ "CHANGELOG.md",
33
+ "README.zh-CN.md"
34
+ ],
35
+ "scripts": {
36
+ "build": "npm run build:server && npm run build:gui",
37
+ "build:server": "tsc -p tsconfig.server.json",
38
+ "build:gui": "vite build",
39
+ "prepare": "npm run build",
40
+ "dev": "vite build --watch",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest",
44
+ "smoke:gui": "node scripts/smoke-gui.cjs",
45
+ "verify": "npm run typecheck && npm test && npm run build && npm run smoke:gui",
46
+ "prepublishOnly": "npm run verify"
47
+ },
48
+ "dependencies": {
49
+ "@opencode/plugin": "2.2.5"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^24.10.0",
53
+ "effect": "4.0.6",
54
+ "solid-js": "1.9.17",
55
+ "typescript": "^5.9.3",
56
+ "vite": "7.3.7",
57
+ "vite-plugin-solid": "2.11.10",
58
+ "vitest": "^3.2.4"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public"
62
+ },
63
+ "engines": {
64
+ "node": "^20.19.0 || >=22.12.0"
65
+ }
66
+ }