opencode-core-rules-injector 1.2.3 → 1.2.5
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 +80 -49
- package/core-rules.md +1 -1
- package/index.js +29 -33
- package/package.json +6 -4
- package/postinstall.mjs +280 -38
- package/src/injector.js +105 -0
- package/src/logging.js +44 -0
- package/src/paths.js +26 -0
- package/src/runtime.js +58 -0
package/README.md
CHANGED
|
@@ -1,79 +1,110 @@
|
|
|
1
1
|
# opencode-core-rules-injector
|
|
2
2
|
|
|
3
|
-
OpenCode 插件:在每次 LLM
|
|
3
|
+
OpenCode 插件:在每次 LLM 调用前持续注入核心规则,降低主 Agent 长时间自动运行时偏离工作流的风险。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 目标
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`AGENTS.md` 和 `instructions` 适合会话启动时加载规则,但它们不是每轮 LLM 调用前的动态注入机制。本插件的目标是在主 Agent 连续执行工具调用、调度子 Agent、长时间自动推进任务时,仍然让模型持续看到 `core-rules.md`。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## 注入路径
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
主路径:`experimental.chat.system.transform`
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
4. 首次安装时自动创建 `~/.config/opencode/core-rules.md`(含默认工作流)
|
|
17
|
-
5. 通过 `postinstall` 脚本自动注册到 `opencode.jsonc/opencode.json` 的 `"plugin"` 数组
|
|
13
|
+
- 每次 LLM 调用前触发。
|
|
14
|
+
- 将 `core-rules.md` 追加到 system prompt 数组。
|
|
15
|
+
- 这是长任务自动运行期间的核心保障。
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
辅助路径:`chat.message`
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
- 用户每次发送新消息时触发。
|
|
20
|
+
- 将 `core-rules.md` 插入到用户消息 parts 头部。
|
|
21
|
+
- 这是用户消息级兜底,不替代主路径。
|
|
22
|
+
|
|
23
|
+
不使用:`chat.params`
|
|
24
|
+
|
|
25
|
+
- OpenCode 当前源码中 `chat.params` 只能修改 temperature、topP、topK、maxOutputTokens 和 options。
|
|
26
|
+
- 它不能修改 system prompt、messages 或 message parts。
|
|
27
|
+
|
|
28
|
+
## 运行态目录
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
~/.config/opencode/plugins/opencode-core-rules-injector/
|
|
32
|
+
├── config.toml
|
|
33
|
+
├── core-rules.md
|
|
34
|
+
└── logs/
|
|
35
|
+
└── injector-YYYY-MM-DD.log
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`config.toml` 默认内容:
|
|
39
|
+
|
|
40
|
+
```toml
|
|
41
|
+
[git]
|
|
42
|
+
user_name = "liumenglife"
|
|
43
|
+
user_email = "liumenglife@163.com"
|
|
44
|
+
default_branch = "main"
|
|
45
|
+
|
|
46
|
+
[logging]
|
|
47
|
+
daily_rotation = true
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 日志状态
|
|
51
|
+
|
|
52
|
+
日志按天滚动,写入:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
~/.config/opencode/plugins/opencode-core-rules-injector/logs/injector-YYYY-MM-DD.log
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
状态码:
|
|
59
|
+
|
|
60
|
+
| 状态 | 含义 |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `BOTH_OK` | 主路径和辅助路径都成功;这是状态计算能力和未来聚合日志保留状态,当前单条 hook 日志通常不会出现 |
|
|
63
|
+
| `PRIMARY_ONLY` | 主路径成功,辅助路径失败或未触发;表示主路径正在工作,长任务保障成立 |
|
|
64
|
+
| `FALLBACK_ONLY` | 主路径失败或未触发,仅辅助路径成功;只能说明用户消息级兜底生效,不代表长任务保障成立 |
|
|
65
|
+
| `BOTH_FAILED` | 主路径和辅助路径都失败或未触发 |
|
|
66
|
+
|
|
67
|
+
当前实现按 hook 写单条日志,不会在同一条日志里同时执行两条路径:`experimental.chat.system.transform` 日志中的 fallback 固定为 `skipped`,主路径成功时通常记录为 `PRIMARY_ONLY`;`chat.message` 日志中的 primary 固定为 `skipped`,辅助路径成功时通常记录为 `FALLBACK_ONLY`。因此不需要看到 `BOTH_OK` 才算正常,长任务判断重点看 `experimental.chat.system.transform` 日志是否出现 `PRIMARY_ONLY`。
|
|
68
|
+
|
|
69
|
+
示例:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
2026-06-13T10:00:00.000Z session=ab12cd34 hook=experimental.chat.system.transform status=PRIMARY_ONLY primary=ok(chars=2451,mtime=1780000000000) fallback=skipped(no_user_message_this_turn)
|
|
73
|
+
```
|
|
26
74
|
|
|
27
75
|
## 安装
|
|
28
76
|
|
|
29
77
|
```bash
|
|
30
|
-
# 使用 bun(推荐,更快),--trust 确保 postinstall 脚本执行
|
|
31
78
|
bun install -g opencode-core-rules-injector --trust
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
或:
|
|
32
82
|
|
|
33
|
-
|
|
83
|
+
```bash
|
|
34
84
|
npm install -g opencode-core-rules-injector
|
|
35
85
|
```
|
|
36
86
|
|
|
37
|
-
安装后重启 OpenCode
|
|
87
|
+
安装后重启 OpenCode。OpenCode 配置不会热重载。
|
|
38
88
|
|
|
39
|
-
|
|
40
|
-
- 创建 `~/.config/opencode/core-rules.md`(含默认的语言、工具偏好和工作流规则)
|
|
89
|
+
## 迁移
|
|
41
90
|
|
|
42
|
-
|
|
91
|
+
安装或升级时会自动迁移:
|
|
43
92
|
|
|
44
|
-
|
|
93
|
+
- `~/.config/opencode/core-rules.md` → `~/.config/opencode/plugins/opencode-core-rules-injector/core-rules.md`
|
|
94
|
+
- `~/.config/opencode-agent/config.toml` → `~/.config/opencode/plugins/opencode-core-rules-injector/config.toml`
|
|
45
95
|
|
|
46
|
-
|
|
47
|
-
## 语言
|
|
48
|
-
始终使用简体中文输出,包括工具调用描述、思考过程等全部内容。
|
|
96
|
+
如果新路径文件已存在,安装脚本不会覆盖。
|
|
49
97
|
|
|
50
|
-
##
|
|
51
|
-
- **文件内容搜索**: `rg` 而非 `grep`
|
|
52
|
-
- **文件名搜索**: `fd` 而非 `find`
|
|
53
|
-
- **内容替换**: `sd` 而非 `sed`
|
|
54
|
-
- **JSON 处理**: `jq`
|
|
55
|
-
- **HTTP 请求**: `httpie` 而非 `curl`
|
|
56
|
-
- **文件查看**: `bat` 而非 `cat`
|
|
57
|
-
- **包管理**: Python 用 `uv` 而非 `pip3`
|
|
58
|
-
- Rust 三板斧:`rg` + `fd` + `sd`
|
|
98
|
+
## 验证
|
|
59
99
|
|
|
60
|
-
|
|
61
|
-
|
|
100
|
+
```bash
|
|
101
|
+
npm test
|
|
62
102
|
```
|
|
63
103
|
|
|
64
|
-
|
|
104
|
+
运行 OpenCode 后检查日志:
|
|
65
105
|
|
|
66
106
|
```bash
|
|
67
|
-
|
|
68
|
-
# 或
|
|
69
|
-
npm install -g opencode-core-rules-injector@latest
|
|
107
|
+
rg "status=" ~/.config/opencode/plugins/opencode-core-rules-injector/logs
|
|
70
108
|
```
|
|
71
109
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
| 版本 | 说明 |
|
|
75
|
-
|---|---|
|
|
76
|
-
| 1.0.0 | 基础插件:每轮注入 core-rules.md |
|
|
77
|
-
| 1.0.1 | 新增 postinstall 自动注册 |
|
|
78
|
-
| 1.1.0 | 添加完整工作流规则 |
|
|
79
|
-
| 1.1.3 | 拆分 Specs/Plans 目录,明确人工确认边界 |
|
|
110
|
+
`PRIMARY_ONLY` 表示主路径正在工作,长任务注入保障成立。
|
package/core-rules.md
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
- QA 审核
|
|
55
55
|
|
|
56
56
|
## 注入确认
|
|
57
|
-
|
|
57
|
+
本段核心规则被注入后,你的第一条输出必须以**已收到核心规则注入,开始执行工作流**开头(注意加粗渲染,须用实际 Markdown ** 包裹),然后才能进行思考、调用工具或进行其他操作。
|
|
58
58
|
|
|
59
59
|
## 通信规范
|
|
60
60
|
所有代理(含主代理、子代理)的评审、测试、审核结论必须基于事实表达:
|
package/index.js
CHANGED
|
@@ -1,43 +1,39 @@
|
|
|
1
|
-
import { readFileSync
|
|
2
|
-
import {
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const DEFAULT_RULES = join(__dirname, 'core-rules.md');
|
|
9
|
-
|
|
10
|
-
function ensureDefaultRules() {
|
|
11
|
-
if (existsSync(RULES_FILE)) return;
|
|
12
|
-
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
13
|
-
if (existsSync(DEFAULT_RULES)) {
|
|
14
|
-
writeFileSync(RULES_FILE, readFileSync(DEFAULT_RULES, 'utf-8'));
|
|
15
|
-
}
|
|
16
|
-
}
|
|
5
|
+
import { ensureRuntimeFiles, createRuleLoader } from './src/runtime.js';
|
|
6
|
+
import { createLogger } from './src/logging.js';
|
|
7
|
+
import { injectPrimarySystem, injectFallbackMessage } from './src/injector.js';
|
|
17
8
|
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const DEFAULT_RULES_FILE = join(__dirname, 'core-rules.md');
|
|
20
11
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
if (!existsSync(RULES_FILE)) return '';
|
|
24
|
-
const { mtimeMs } = statSync(RULES_FILE);
|
|
25
|
-
if (rulesCache && mtimeMs === rulesMtime) return rulesCache;
|
|
26
|
-
rulesMtime = mtimeMs;
|
|
27
|
-
rulesCache = readFileSync(RULES_FILE, 'utf-8').trim();
|
|
28
|
-
return rulesCache;
|
|
29
|
-
} catch {
|
|
30
|
-
return '';
|
|
31
|
-
}
|
|
12
|
+
function readDefaultRules() {
|
|
13
|
+
return readFileSync(DEFAULT_RULES_FILE, 'utf8');
|
|
32
14
|
}
|
|
33
15
|
|
|
34
|
-
|
|
16
|
+
export const CoreRulesInjector = async (options = {}) => {
|
|
17
|
+
const paths = ensureRuntimeFiles({ home: options.home, defaultRules: readDefaultRules() });
|
|
18
|
+
const loadRules = options.loadRules ?? createRuleLoader(paths.rulesFile);
|
|
19
|
+
const logger = options.logger ?? createLogger({ logsDir: paths.logsDir });
|
|
35
20
|
|
|
36
|
-
export const CoreRulesInjector = async () => {
|
|
37
21
|
return {
|
|
38
|
-
'experimental.chat.system.transform': async (
|
|
39
|
-
|
|
40
|
-
|
|
22
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
23
|
+
try {
|
|
24
|
+
injectPrimarySystem({ input, output, loadRules, logger });
|
|
25
|
+
} catch {
|
|
26
|
+
// 兜底保护 OpenCode 主流程;正常错误日志由 injector 内部处理。
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
'chat.message': async (input, output) => {
|
|
30
|
+
try {
|
|
31
|
+
injectFallbackMessage({ input, output, loadRules, logger });
|
|
32
|
+
} catch {
|
|
33
|
+
// 兜底保护 OpenCode 主流程;正常错误日志由 injector 内部处理。
|
|
34
|
+
}
|
|
41
35
|
},
|
|
42
36
|
};
|
|
43
37
|
};
|
|
38
|
+
|
|
39
|
+
export default CoreRulesInjector;
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-core-rules-injector",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.5",
|
|
4
4
|
"description": "OpenCode plugin: injects core rules into every LLM call",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
|
-
"files": ["index.js", "core-rules.md", "postinstall.mjs"],
|
|
7
|
+
"files": ["index.js", "src", "core-rules.md", "postinstall.mjs", "README.md"],
|
|
8
8
|
"keywords": ["opencode", "plugin", "rules"],
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"scripts": {
|
|
11
|
-
"postinstall": "node postinstall.mjs"
|
|
12
|
-
|
|
11
|
+
"postinstall": "node postinstall.mjs",
|
|
12
|
+
"test": "node --test"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {}
|
|
13
15
|
}
|
package/postinstall.mjs
CHANGED
|
@@ -1,45 +1,287 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { ensureRuntimeFiles } from './src/runtime.js';
|
|
4
6
|
|
|
5
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
7
|
-
const CONFIG_DIR = join(HOME, '.config', 'opencode');
|
|
8
8
|
const PLUGIN_NAME = 'opencode-core-rules-injector';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
10
|
+
function homeDir(explicitHome) {
|
|
11
|
+
const home = explicitHome || process.env.HOME || process.env.USERPROFILE;
|
|
12
|
+
if (!home) throw new Error('HOME or USERPROFILE is required');
|
|
13
|
+
return home;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function configDir(home) {
|
|
17
|
+
return join(home, '.config', 'opencode');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stripJsonComments(content) {
|
|
21
|
+
let result = '';
|
|
22
|
+
let inString = false;
|
|
23
|
+
let escaped = false;
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
26
|
+
const char = content[i];
|
|
27
|
+
const next = content[i + 1];
|
|
28
|
+
|
|
29
|
+
if (inString) {
|
|
30
|
+
result += char;
|
|
31
|
+
if (escaped) {
|
|
32
|
+
escaped = false;
|
|
33
|
+
} else if (char === '\\') {
|
|
34
|
+
escaped = true;
|
|
35
|
+
} else if (char === '"') {
|
|
36
|
+
inString = false;
|
|
37
|
+
}
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (char === '"') {
|
|
42
|
+
inString = true;
|
|
43
|
+
result += char;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (char === '/' && next === '/') {
|
|
48
|
+
while (i < content.length && content[i] !== '\n') i += 1;
|
|
49
|
+
result += '\n';
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (char === '/' && next === '*') {
|
|
54
|
+
i += 2;
|
|
55
|
+
while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) {
|
|
56
|
+
if (content[i] === '\n') result += '\n';
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
i += 1;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
result += char;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stripTrailingCommas(content) {
|
|
70
|
+
return content.replace(/,\s*([}\]])/g, '$1');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseConfig(content) {
|
|
74
|
+
return JSON.parse(stripTrailingCommas(stripJsonComments(content)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findMatchingBracket(content, openIndex) {
|
|
78
|
+
let depth = 0;
|
|
79
|
+
let inString = false;
|
|
80
|
+
let escaped = false;
|
|
81
|
+
let lineComment = false;
|
|
82
|
+
let blockComment = false;
|
|
83
|
+
|
|
84
|
+
for (let i = openIndex; i < content.length; i += 1) {
|
|
85
|
+
const char = content[i];
|
|
86
|
+
const next = content[i + 1];
|
|
87
|
+
|
|
88
|
+
if (lineComment) {
|
|
89
|
+
if (char === '\n') lineComment = false;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (blockComment) {
|
|
94
|
+
if (char === '*' && next === '/') {
|
|
95
|
+
blockComment = false;
|
|
96
|
+
i += 1;
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (inString) {
|
|
102
|
+
if (escaped) {
|
|
103
|
+
escaped = false;
|
|
104
|
+
} else if (char === '\\') {
|
|
105
|
+
escaped = true;
|
|
106
|
+
} else if (char === '"') {
|
|
107
|
+
inString = false;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (char === '/' && next === '/') {
|
|
113
|
+
lineComment = true;
|
|
114
|
+
i += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (char === '/' && next === '*') {
|
|
119
|
+
blockComment = true;
|
|
120
|
+
i += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (char === '"') {
|
|
125
|
+
inString = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (char === '[') depth += 1;
|
|
130
|
+
if (char === ']') {
|
|
131
|
+
depth -= 1;
|
|
132
|
+
if (depth === 0) return i;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return -1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function findPluginArray(content) {
|
|
140
|
+
let inString = false;
|
|
141
|
+
let escaped = false;
|
|
142
|
+
let lineComment = false;
|
|
143
|
+
let blockComment = false;
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i < content.length; i += 1) {
|
|
146
|
+
const char = content[i];
|
|
147
|
+
const next = content[i + 1];
|
|
148
|
+
|
|
149
|
+
if (lineComment) {
|
|
150
|
+
if (char === '\n') lineComment = false;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (blockComment) {
|
|
155
|
+
if (char === '*' && next === '/') {
|
|
156
|
+
blockComment = false;
|
|
157
|
+
i += 1;
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (inString) {
|
|
163
|
+
if (escaped) {
|
|
164
|
+
escaped = false;
|
|
165
|
+
} else if (char === '\\') {
|
|
166
|
+
escaped = true;
|
|
167
|
+
} else if (char === '"') {
|
|
168
|
+
inString = false;
|
|
26
169
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (char === '/' && next === '/') {
|
|
174
|
+
lineComment = true;
|
|
175
|
+
i += 1;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (char === '/' && next === '*') {
|
|
180
|
+
blockComment = true;
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (content.startsWith('"plugin"', i)) {
|
|
186
|
+
let j = i + '"plugin"'.length;
|
|
187
|
+
while (/\s/.test(content[j])) j += 1;
|
|
188
|
+
if (content[j] !== ':') continue;
|
|
189
|
+
j += 1;
|
|
190
|
+
while (/\s/.test(content[j])) j += 1;
|
|
191
|
+
if (content[j] !== '[') continue;
|
|
192
|
+
const end = findMatchingBracket(content, j);
|
|
193
|
+
if (end !== -1) return { start: j, end };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (char === '"') inString = true;
|
|
32
197
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function indentationBefore(content, index) {
|
|
203
|
+
const lineStart = content.lastIndexOf('\n', index) + 1;
|
|
204
|
+
const match = content.slice(lineStart, index).match(/^\s*/);
|
|
205
|
+
return match ? match[0] : '';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatPluginArray(content, range, plugins) {
|
|
209
|
+
const arrayText = content.slice(range.start, range.end + 1);
|
|
210
|
+
if (!arrayText.includes('\n')) return `[${plugins.map((plugin) => JSON.stringify(plugin)).join(', ')}]`;
|
|
211
|
+
|
|
212
|
+
const baseIndent = indentationBefore(content, range.start);
|
|
213
|
+
const itemIndent = `${baseIndent} `;
|
|
214
|
+
const lines = plugins.map((plugin, index) => {
|
|
215
|
+
const comma = index === plugins.length - 1 ? '' : ',';
|
|
216
|
+
return `${itemIndent}${JSON.stringify(plugin)}${comma}`;
|
|
217
|
+
});
|
|
218
|
+
return `[\n${lines.join('\n')}\n${baseIndent}]`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function addPluginProperty(content) {
|
|
222
|
+
const closeIndex = content.lastIndexOf('}');
|
|
223
|
+
if (closeIndex === -1) return content;
|
|
224
|
+
|
|
225
|
+
const beforeClose = content.slice(0, closeIndex);
|
|
226
|
+
const afterClose = content.slice(closeIndex);
|
|
227
|
+
const hasFields = Object.keys(parseConfig(content)).length > 0;
|
|
228
|
+
const baseIndent = indentationBefore(content, closeIndex);
|
|
229
|
+
const itemIndent = `${baseIndent} `;
|
|
230
|
+
|
|
231
|
+
if (!content.includes('\n')) {
|
|
232
|
+
const comma = hasFields ? ', ' : '';
|
|
233
|
+
return `${beforeClose}${comma}"plugin": ["${PLUGIN_NAME}"]${afterClose}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const trimmedBeforeClose = beforeClose.replace(/[\s,]*$/, '');
|
|
237
|
+
const comma = hasFields ? ',' : '';
|
|
238
|
+
return `${trimmedBeforeClose}${comma}\n${itemIndent}"plugin": ["${PLUGIN_NAME}"]\n${afterClose}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function appendPlugin(content) {
|
|
242
|
+
const cfg = parseConfig(content);
|
|
243
|
+
if (!Array.isArray(cfg.plugin)) cfg.plugin = [];
|
|
244
|
+
if (cfg.plugin.includes(PLUGIN_NAME)) return content;
|
|
245
|
+
|
|
246
|
+
const plugins = [...cfg.plugin, PLUGIN_NAME];
|
|
247
|
+
const range = findPluginArray(content);
|
|
248
|
+
if (!range) return addPluginProperty(content);
|
|
249
|
+
|
|
250
|
+
return `${content.slice(0, range.start)}${formatPluginArray(content, range, plugins)}${content.slice(range.end + 1)}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function ensureOpenCodeConfig(explicitHome) {
|
|
254
|
+
const home = homeDir(explicitHome);
|
|
255
|
+
const dir = configDir(home);
|
|
256
|
+
mkdirSync(dir, { recursive: true });
|
|
257
|
+
const candidates = [join(dir, 'opencode.jsonc'), join(dir, 'opencode.json')];
|
|
258
|
+
const configFile = candidates.find((file) => existsSync(file)) || candidates[1];
|
|
259
|
+
|
|
260
|
+
if (!existsSync(configFile)) {
|
|
261
|
+
writeFileSync(configFile, JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2), 'utf8');
|
|
262
|
+
return configFile;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const original = readFileSync(configFile, 'utf8');
|
|
266
|
+
let next;
|
|
267
|
+
try {
|
|
268
|
+
next = appendPlugin(original);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.warn(`[postinstall] Warning: could not update OpenCode config ${configFile}: ${error.message}`);
|
|
271
|
+
return configFile;
|
|
272
|
+
}
|
|
273
|
+
if (next !== original) writeFileSync(configFile, next, 'utf8');
|
|
274
|
+
return configFile;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function runPostinstall(explicitHome) {
|
|
278
|
+
const defaultRules = readFileSync(join(__dirname, 'core-rules.md'), 'utf8');
|
|
279
|
+
const paths = ensureRuntimeFiles({ home: explicitHome, defaultRules });
|
|
280
|
+
const configFile = ensureOpenCodeConfig(explicitHome);
|
|
281
|
+
console.log(`[postinstall] Runtime directory: ${paths.runtimeDir}`);
|
|
282
|
+
console.log(`[postinstall] OpenCode config: ${configFile}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
286
|
+
runPostinstall();
|
|
45
287
|
}
|
package/src/injector.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { statusFromResults } from './logging.js';
|
|
2
|
+
|
|
3
|
+
const RULES_START = '<opencode-core-rules-injector>';
|
|
4
|
+
const RULES_END = '</opencode-core-rules-injector>';
|
|
5
|
+
|
|
6
|
+
function okDetail(ruleInfo) {
|
|
7
|
+
return `ok(chars=${ruleInfo.chars},mtime=${ruleInfo.mtimeMs})`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function errorDetail(reason) {
|
|
11
|
+
return `error(${reason})`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function wrappedRules(ruleInfo) {
|
|
15
|
+
return `${RULES_START}\n${ruleInfo.text}\n${RULES_END}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fallbackPartMetadata(input, output) {
|
|
19
|
+
const firstPart = output.parts[0];
|
|
20
|
+
const sessionID = firstPart?.sessionID || output.message?.sessionID || input?.sessionID;
|
|
21
|
+
const messageID = firstPart?.messageID || output.message?.messageID || output.message?.id || input?.messageID;
|
|
22
|
+
|
|
23
|
+
if (typeof sessionID !== 'string' || typeof messageID !== 'string') {
|
|
24
|
+
throw new Error('part_metadata_missing');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
id: `prt_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
|
29
|
+
sessionID,
|
|
30
|
+
messageID,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function injectPrimarySystem({ input, output, loadRules, logger }) {
|
|
35
|
+
let primary = 'error';
|
|
36
|
+
let primaryDetail = '';
|
|
37
|
+
let error = '';
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (!Array.isArray(output.system)) throw new Error('output.system_missing');
|
|
41
|
+
const ruleInfo = loadRules();
|
|
42
|
+
if (!ruleInfo.text) throw new Error('rules_file_empty');
|
|
43
|
+
if (output.system.some((part) => typeof part === 'string' && part.includes(RULES_START))) {
|
|
44
|
+
primary = 'skipped';
|
|
45
|
+
primaryDetail = 'skipped(already_injected)';
|
|
46
|
+
} else {
|
|
47
|
+
output.system.push(wrappedRules(ruleInfo));
|
|
48
|
+
primary = 'ok';
|
|
49
|
+
primaryDetail = okDetail(ruleInfo);
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
error = err instanceof Error ? err.message : String(err);
|
|
53
|
+
primaryDetail = errorDetail(error);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const fallback = 'skipped';
|
|
57
|
+
const fallbackDetail = 'skipped(no_user_message_this_turn)';
|
|
58
|
+
const status = statusFromResults({ primary, fallback });
|
|
59
|
+
logger.write({
|
|
60
|
+
sessionID: input?.sessionID,
|
|
61
|
+
hook: 'experimental.chat.system.transform',
|
|
62
|
+
status,
|
|
63
|
+
primary: primaryDetail,
|
|
64
|
+
fallback: fallbackDetail,
|
|
65
|
+
error,
|
|
66
|
+
});
|
|
67
|
+
return { ok: primary === 'ok', status };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function injectFallbackMessage({ input, output, loadRules, logger }) {
|
|
71
|
+
let fallback = 'error';
|
|
72
|
+
let fallbackDetail = '';
|
|
73
|
+
let error = '';
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
if (!Array.isArray(output.parts)) throw new Error('output.parts_missing');
|
|
77
|
+
const ruleInfo = loadRules();
|
|
78
|
+
if (!ruleInfo.text) throw new Error('rules_file_empty');
|
|
79
|
+
if (output.parts.some((part) => part?.type === 'text' && part.text?.includes(RULES_START))) {
|
|
80
|
+
fallback = 'skipped';
|
|
81
|
+
fallbackDetail = 'skipped(already_injected)';
|
|
82
|
+
} else {
|
|
83
|
+
const metadata = fallbackPartMetadata(input, output);
|
|
84
|
+
output.parts.unshift({ ...metadata, type: 'text', text: wrappedRules(ruleInfo) });
|
|
85
|
+
fallback = 'ok';
|
|
86
|
+
fallbackDetail = okDetail(ruleInfo);
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
error = err instanceof Error ? err.message : String(err);
|
|
90
|
+
fallbackDetail = errorDetail(error);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const primary = 'skipped';
|
|
94
|
+
const primaryDetail = 'skipped(primary_not_triggered_this_hook)';
|
|
95
|
+
const status = statusFromResults({ primary, fallback });
|
|
96
|
+
logger.write({
|
|
97
|
+
sessionID: input?.sessionID,
|
|
98
|
+
hook: 'chat.message',
|
|
99
|
+
status,
|
|
100
|
+
primary: primaryDetail,
|
|
101
|
+
fallback: fallbackDetail,
|
|
102
|
+
error,
|
|
103
|
+
});
|
|
104
|
+
return { ok: fallback === 'ok', status };
|
|
105
|
+
}
|
package/src/logging.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function statusFromResults({ primary, fallback }) {
|
|
5
|
+
const primaryOk = primary === 'ok';
|
|
6
|
+
const fallbackOk = fallback === 'ok';
|
|
7
|
+
if (primaryOk && fallbackOk) return 'BOTH_OK';
|
|
8
|
+
if (primaryOk) return 'PRIMARY_ONLY';
|
|
9
|
+
if (fallbackOk) return 'FALLBACK_ONLY';
|
|
10
|
+
return 'BOTH_FAILED';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function datePart(date) {
|
|
14
|
+
return date.toISOString().slice(0, 10);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cleanField(value) {
|
|
18
|
+
return String(value ?? '').replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sessionPart(sessionID) {
|
|
22
|
+
return (cleanField(sessionID) || 'unknown').slice(0, 8);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createLogger({ logsDir, now = () => new Date() }) {
|
|
26
|
+
mkdirSync(logsDir, { recursive: true });
|
|
27
|
+
return {
|
|
28
|
+
write(entry) {
|
|
29
|
+
const date = now();
|
|
30
|
+
const file = join(logsDir, `injector-${datePart(date)}.log`);
|
|
31
|
+
const line = [
|
|
32
|
+
date.toISOString(),
|
|
33
|
+
`session=${sessionPart(entry.sessionID)}`,
|
|
34
|
+
`hook=${cleanField(entry.hook)}`,
|
|
35
|
+
`status=${cleanField(entry.status)}`,
|
|
36
|
+
`primary=${cleanField(entry.primary)}`,
|
|
37
|
+
`fallback=${cleanField(entry.fallback)}`,
|
|
38
|
+
entry.error ? `error=${cleanField(entry.error)}` : '',
|
|
39
|
+
].filter(Boolean).join(' ');
|
|
40
|
+
appendFileSync(file, `${line}\n`, 'utf8');
|
|
41
|
+
return file;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const PLUGIN_DIR_NAME = 'opencode-core-rules-injector';
|
|
4
|
+
|
|
5
|
+
export function getHome(explicitHome) {
|
|
6
|
+
const home = explicitHome || process.env.HOME || process.env.USERPROFILE;
|
|
7
|
+
if (!home) throw new Error('HOME or USERPROFILE is required');
|
|
8
|
+
return home;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getPaths(explicitHome) {
|
|
12
|
+
const home = getHome(explicitHome);
|
|
13
|
+
const opencodeDir = join(home, '.config', 'opencode');
|
|
14
|
+
const runtimeDir = join(opencodeDir, 'plugins', PLUGIN_DIR_NAME);
|
|
15
|
+
return {
|
|
16
|
+
home,
|
|
17
|
+
opencodeDir,
|
|
18
|
+
runtimeDir,
|
|
19
|
+
rulesFile: join(runtimeDir, 'core-rules.md'),
|
|
20
|
+
configFile: join(runtimeDir, 'config.toml'),
|
|
21
|
+
logsDir: join(runtimeDir, 'logs'),
|
|
22
|
+
oldRulesFile: join(opencodeDir, 'core-rules.md'),
|
|
23
|
+
oldAgentConfigDir: join(home, '.config', 'opencode-agent'),
|
|
24
|
+
oldAgentConfigFile: join(home, '.config', 'opencode-agent', 'config.toml'),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { getPaths } from './paths.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_CONFIG = `# OpenCode Core Rules Injector runtime config
|
|
5
|
+
|
|
6
|
+
[git]
|
|
7
|
+
user_name = "liumenglife"
|
|
8
|
+
user_email = "liumenglife@163.com"
|
|
9
|
+
default_branch = "main"
|
|
10
|
+
|
|
11
|
+
[logging]
|
|
12
|
+
daily_rotation = true
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
function moveIfMissing(source, target) {
|
|
16
|
+
if (existsSync(target) || !existsSync(source)) return false;
|
|
17
|
+
renameSync(source, target);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function removeIfEmpty(dir) {
|
|
22
|
+
if (!existsSync(dir)) return;
|
|
23
|
+
if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ensureRuntimeFiles({ home, defaultRules }) {
|
|
27
|
+
const paths = getPaths(home);
|
|
28
|
+
mkdirSync(paths.runtimeDir, { recursive: true });
|
|
29
|
+
mkdirSync(paths.logsDir, { recursive: true });
|
|
30
|
+
|
|
31
|
+
moveIfMissing(paths.oldRulesFile, paths.rulesFile);
|
|
32
|
+
moveIfMissing(paths.oldAgentConfigFile, paths.configFile);
|
|
33
|
+
|
|
34
|
+
if (!existsSync(paths.rulesFile)) writeFileSync(paths.rulesFile, defaultRules, 'utf8');
|
|
35
|
+
if (!existsSync(paths.configFile)) writeFileSync(paths.configFile, DEFAULT_CONFIG, 'utf8');
|
|
36
|
+
|
|
37
|
+
removeIfEmpty(paths.oldAgentConfigDir);
|
|
38
|
+
return paths;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createRuleLoader(rulesFile) {
|
|
42
|
+
let cached = null;
|
|
43
|
+
let cachedMtimeNs = 0n;
|
|
44
|
+
let cachedMtimeMs = 0;
|
|
45
|
+
|
|
46
|
+
return function loadRules() {
|
|
47
|
+
if (!existsSync(rulesFile)) return { text: '', mtimeMs: 0, chars: 0 };
|
|
48
|
+
const { mtime, mtimeNs } = statSync(rulesFile, { bigint: true });
|
|
49
|
+
const mtimeMs = mtime.getTime();
|
|
50
|
+
if (cached !== null && cachedMtimeNs === mtimeNs) {
|
|
51
|
+
return { text: cached, mtimeMs: cachedMtimeMs, chars: cached.length };
|
|
52
|
+
}
|
|
53
|
+
cached = readFileSync(rulesFile, 'utf8').trim();
|
|
54
|
+
cachedMtimeNs = mtimeNs;
|
|
55
|
+
cachedMtimeMs = mtimeMs;
|
|
56
|
+
return { text: cached, mtimeMs, chars: cached.length };
|
|
57
|
+
};
|
|
58
|
+
}
|