opencode-core-rules-injector 1.2.4 → 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 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
- OpenCode 在长时间自动执行(如 5 小时、100 个任务)时,模型容易忘记用户制定的规则。已有的方式(AGENTS.md、opencode.json instructions)只在会话启动时读取一次,无法防止长任务中的注意力漂移。
7
+ `AGENTS.md` `instructions` 适合会话启动时加载规则,但它们不是每轮 LLM 调用前的动态注入机制。本插件的目标是在主 Agent 连续执行工具调用、调度子 Agent、长时间自动推进任务时,仍然让模型持续看到 `core-rules.md`。
8
8
 
9
- 这个插件在**每次 LLM 调用前**把 `~/.config/opencode/core-rules.md` 的内容注入到 system prompt 中,让模型每轮都能看到规则,确保始终如一地遵守。
9
+ ## 注入路径
10
10
 
11
- ## 实现方法
11
+ 主路径:`experimental.chat.system.transform`
12
12
 
13
- 1. 利用 OpenCode 的 `experimental.chat.system.transform` 钩子,在每个 LLM 调用前修改 system prompt
14
- 2. 读取 `~/.config/opencode/core-rules.md` 的内容并追加到 system prompt 尾部
15
- 3. 带 mtime 缓存,文件无变化时不重复读盘
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
- | **AGENTS.md**(核心指令+工作流) | 会话启动时 | 主 Agent + 所有子 Agent |
24
- | **core-rules.md**(插件注入) | 每次 LLM 调用前 | 主 Agent |
25
- | **AGENTS.md 上下文**(ctx7 等) | 会话启动时 | 主 Agent + 所有子 Agent |
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
- # 或使用 npm
83
+ ```bash
34
84
  npm install -g opencode-core-rules-injector
35
85
  ```
36
86
 
37
- 安装后重启 OpenCode 即可生效。`postinstall` 会自动完成以下操作:
87
+ 安装后重启 OpenCode。OpenCode 配置不会热重载。
38
88
 
39
- - 将 `"opencode-core-rules-injector"` 添加到 `~/.config/opencode/opencode.jsonc` 或 `opencode.json` 的 `"plugin"` 数组
40
- - 创建 `~/.config/opencode/core-rules.md`(含默认的语言、工具偏好和工作流规则)
89
+ ## 迁移
41
90
 
42
- ## 自定义规则
91
+ 安装或升级时会自动迁移:
43
92
 
44
- 编辑 `~/.config/opencode/core-rules.md`,修改立即生效,无需重启:
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
- ```markdown
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
- bun install -g opencode-core-rules-injector@latest --trust
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/index.js CHANGED
@@ -1,43 +1,39 @@
1
- import { readFileSync, statSync, existsSync, mkdirSync, writeFileSync } from 'fs';
2
- import { join, dirname } from 'path';
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
- const __dirname = dirname(fileURLToPath(import.meta.url));
6
- const CONFIG_DIR = join(process.env.HOME || process.env.USERPROFILE, '.config', 'opencode');
7
- const RULES_FILE = join(CONFIG_DIR, 'core-rules.md');
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
- let rulesCache = null;
19
- let rulesMtime = 0;
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const DEFAULT_RULES_FILE = join(__dirname, 'core-rules.md');
20
11
 
21
- function loadRules() {
22
- try {
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
- ensureDefaultRules();
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 (_input, output) => {
39
- const rules = loadRules();
40
- if (rules) output.system.push(rules);
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.4",
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 { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
2
- import { join, dirname } from 'path';
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
- const candidates = ['opencode.jsonc', 'opencode.json'];
11
- let configFile = null;
12
- for (const name of candidates) {
13
- const p = join(CONFIG_DIR, name);
14
- if (existsSync(p)) { configFile = p; break; }
15
- }
16
-
17
- if (configFile) {
18
- let content = readFileSync(configFile, 'utf-8');
19
- if (!content.includes(PLUGIN_NAME)) {
20
- content = content.replace(
21
- /("plugin"\s*:\s*\[)([^\]]*)(\])/,
22
- (_, start, items, end) => {
23
- const trimmed = items.trim();
24
- const sep = trimmed ? ', ' : '';
25
- return `${start}${items}${sep}"${PLUGIN_NAME}"${end}`;
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
- writeFileSync(configFile, content, 'utf-8');
29
- console.log(`[postinstall] Added "${PLUGIN_NAME}" to ${configFile}`);
30
- } else {
31
- console.log(`[postinstall] "${PLUGIN_NAME}" already in ${configFile}`);
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
- } else {
34
- const fallback = join(CONFIG_DIR, 'opencode.json');
35
- if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
36
- writeFileSync(fallback, JSON.stringify({ plugin: [PLUGIN_NAME] }, null, 2), 'utf-8');
37
- console.log(`[postinstall] Created ${fallback} with "${PLUGIN_NAME}"`);
38
- }
39
-
40
- const rulesFile = join(CONFIG_DIR, 'core-rules.md');
41
- const defaultRules = join(__dirname, 'core-rules.md');
42
- if (!existsSync(rulesFile) && existsSync(defaultRules)) {
43
- writeFileSync(rulesFile, readFileSync(defaultRules, 'utf-8'));
44
- console.log(`[postinstall] Created ${rulesFile}`);
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
  }
@@ -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
+ }