dsh-baize-rules 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 bvcvb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,228 @@
1
+ # dsh-baize-rules
2
+
3
+ ![npm version](https://img.shields.io/npm/v/dsh-baize-rules)
4
+ ![license](https://img.shields.io/npm/l/dsh-baize-rules)
5
+
6
+ > English: *`dsh-baize-rules` (Baize) is a dsh plugin that injects user-set, durable "must-do / must-not" requirements — plain-text rules — into the model at conversation start as a sourced `user/message`.*
7
+
8
+ [dsh](https://www.npmjs.com/package/@deepseek-ai/dsh) 的**会话 / 全局「必须做 / 不能做」要求**插件。名字取自**白泽**——传说中「通万物之情、晓万物之名、知万物之理」的神兽,用它来承载「用户给模型立下的行为基线」。
9
+
10
+ - 规则是**纯文本**,没有 `must`/`mustNot` 标记——「必须做 / 不能做」由正文语言表达(例如 `用中文写注释。`=必须,`不要删除测试。`=禁止)。
11
+ - 注入发生在**会话起点**:把当前生效规则作为一条**持久** `user/message` 注入模型请求,套用 `<system-reminder>` 框架,来源标记为 `source.kind='plugin'`、`plugin='baize-rules'`。
12
+ - **无规则则不注入**;字节预算小到裁光所有规则时返回 `undefined`,绝不注入空壳 reminder。
13
+
14
+ ---
15
+
16
+ ## 功能特性
17
+
18
+ | 特性 | 说明 |
19
+ |---|---|
20
+ | **三作用域** | `global`(所有会话)/ `session`(当前会话)/ `project`(按会话工作目录,**实验性**) |
21
+ | **持久化** | 全部落盘到 `$DSH_HOME`(默认 `~/.dsh`),跨会话、跨重启 |
22
+ | **具体优先** | 渲染顺序 `project > session > global`;字节预算受压时优先保留更具体的规则 |
23
+ | **去重** | 按渲染文本 SHA-1 digest 抑制重复注入;`injectAtEveryStep` 可选每步强制刷新 |
24
+ | **防逃逸** | 正文里的字面 `</system-reminder>` 会被转义,防止用户文本关闭插件框架 |
25
+ | **命令 + API 同源** | `/baize-rules` 命令与前端面板共用同一套 store/core,改动始终同一真值 |
26
+
27
+ ---
28
+
29
+ ## 安装
30
+
31
+ > dsh 插件从 npm 仓库分发,通过 `dsh plugin` 安装到某个 profile。
32
+
33
+ ```bash
34
+ # 从 npm 安装到 web profile(版本以发布后的实际版本为准)
35
+ dsh plugin --profile web add dsh-baize-rules@0.1.0
36
+ pm2 restart dsh # dsh 由 pm2 托管时重载生效
37
+ dsh --profile web
38
+ ```
39
+
40
+ 安装时依赖的 peer 包(`@deepseek-ai/` 系列、`react` 等)由 dsh profile 提供;若缺少,pnpm 会在 profile 目录里按 `peerDependencies` 解析。
41
+
42
+ ### 本地开发联调(link)
43
+
44
+ 尚未发布或想改源码即时生效时,用本目录作为 link 依赖:
45
+
46
+ ```jsonc
47
+ // /home/abc/.dsh/profiles/web/package.json
48
+ "dependencies": {
49
+ "dsh-baize-rules": "link:/home/abc/work/plugin/dsh-baize-rules"
50
+ }
51
+ ```
52
+
53
+ 随后在 profile 目录执行 `pnpm install`,并把 `dsh-baize-rules` 加进 `dsh.profile.bundles`。
54
+
55
+ ---
56
+
57
+ ## 快速开始
58
+
59
+ ```bash
60
+ /baize-rules # 等价于 /baize-rules list,查看当前生效规则
61
+ /baize-rules add 用中文写注释。 # 加到默认作用域(通常是 session)
62
+ /baize-rules global add 不要删除或改写现有测试。 # 明确加到 global
63
+ /baize-rules list # 显示 global + session(含缩略 id / disabled 标注)
64
+ /baize-rules edit <id> 只用 pnpm 构建。 # 修改某条规则文本
65
+ /baize-rules disable <id> # 停用某条(保留不删)
66
+ /baize-rules enable <id> # 重新启用
67
+ /baize-rules scope global # 之后命令默认写到 global
68
+ /baize-rules clear session # 清空当前会话规则
69
+ /baize-rules export # 导出全部规则为 JSON
70
+ ```
71
+
72
+ ---
73
+
74
+ ## 命令
75
+
76
+ 所有子命令挂在 **`/baize-rules`** 下;无参数时等价于 `list`。
77
+
78
+ ```
79
+ /baize-rules [list|add <text>|remove <id>|edit <id> <text>|enable|disable <id>|scope <global|session|project>|clear <scope>|export]
80
+ ```
81
+
82
+ | 子命令 | 语法 | 作用 |
83
+ |---|---|---|
84
+ | **list** | `/baize-rules list` | 列出合并后的生效规则(`Project`/`Global`/`Session` 节;空时显示 `No active rules.`) |
85
+ | **add** | `/baize-rules add <text>` | 追加一条规则到目标作用域(默认 `scope`);文本即规则 |
86
+ | **remove** | `/baize-rules remove <id>` | 按**完整 id** 删除一条规则 |
87
+ | **edit** | `/baize-rules edit <id> <text>` | 修改某条规则的文本 |
88
+ | **enable** | `/baize-rules enable <id>` | 启用一条被停用的规则 |
89
+ | **disable** | `/baize-rules disable <id>` | 停用一条规则(保留但不生效) |
90
+ | **scope** | `/baize-rules scope <global\|session\|project>` | 切换后续命令的默认作用域(持久到当前进程) |
91
+ | **clear** | `/baize-rules clear <global\|session\|project>` | 清空某作用域的全部规则 |
92
+ | **export** | `/baize-rules export` | 以 JSON 导出 `{ global, session }` |
93
+
94
+ **参数细节**
95
+
96
+ - `<text>`:规则正文,可含空格;是「必须做」还是「不能做」由正文语言表达,无标记。
97
+ - `<id>`:规则的稳定 id(`crypto.randomUUID`)。`list` 显示**前 8 位缩略 id** 便于阅读;
98
+ 执行 `remove`/`edit`/`enable`/`disable` 时请提供**完整 id**(可用 `list` 或 `export` 查看完整 id)。
99
+
100
+ ### 作用域写法
101
+
102
+ `add/remove/edit/enable/disable` 支持**显式作用域**,两种等价写法:
103
+
104
+ - **前缀**:`/baize-rules global add 用中文。`
105
+ - **后缀**:`/baize-rules add 用中文。 global`(仅当作用域是**最后一个 token** 时)
106
+
107
+ > 只有 `add/remove/edit/enable/disable` 会把**末尾**的作用域关键字识别为作用域修饰;
108
+ > `scope`/`clear` 的参数本身就是作用域,不会被吞。因此正文里含 `global`/`session` 不会被误判
109
+ >(例如 `/baize-rules add 用global写`)。
110
+
111
+ 未指定作用域时,用 `/baize-rules scope` 设定的默认值(初始来自配置文件 `Config.scope`,通常 `session`)。
112
+
113
+ ---
114
+
115
+ ## 注入行为(模型上下文如何被改变)
116
+
117
+ - **会话起点基线**:会话开始时,`agent/pre-step`(`prepend:true`)把生效规则作为一条 `user/message` 插入请求,内容为 `<system-reminder>` 框架,`source.kind='plugin'`、`plugin='baize-rules'`、`form='snapshot'`。
118
+ - **具体优先**:`project > session > global`;预算受限时优先裁剪较宽泛的 `global` 规则。
119
+ - **去重**:对渲染文本算 SHA-1 digest,规则不变则不重复注入;`injectAtEveryStep:true` 时每步强制刷新。
120
+ - **转义**:正文里的 `</system-reminder>` 会被 `escapeReminder` 转义。
121
+ - **空 / 全裁**:无规则、或预算裁光所有规则时返回 `undefined`(即不注入该消息)。
122
+
123
+ ### 模型实际看到的形态
124
+
125
+ ```markdown
126
+ <system-reminder>
127
+ The following user requirements apply to every step of this conversation. Obey them.
128
+ More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
129
+
130
+ Session requirements (this conversation only):
131
+ - 插件每次都要隔离测试后才能部署。
132
+
133
+ Global requirements:
134
+ - 用中文写注释。
135
+ - 不要删除或改写现有的测试。
136
+ </system-reminder>
137
+ ```
138
+
139
+ ---
140
+
141
+ ## 配置(`Config`)
142
+
143
+ 插件启动时用 `@deepseek-ai/schemastery` 校验 `Config`;非法值会令插件加载失败。
144
+
145
+ | 配置 | 默认 | 说明 |
146
+ |---|---|---|
147
+ | `scope` | —(必填) | 默认作用域,`/baize-rules` 未指定时使用;仅允许 `global`/`session` |
148
+ | `maxBytes` | —(必填) | 模型可见字节上限;超出时按「具体优先」裁剪 |
149
+ | `globalRulesPath` | `$DSH_HOME/rules/global.json` | 覆盖全局规则文件路径 |
150
+ | `injectAtEveryStep` | `false` | 每步强制重渲(调试用);默认为仅变化时打补丁 |
151
+
152
+ ### 挂载元数据(`cordis.patch.yml`)
153
+
154
+ 发布的 npm 包里携带 `dsh.bundle.patch`,安装 `dsh-baize-rules@<version>` 时由 dsh 自动接入:
155
+ `cordis.patch.yml` 中 `insert` 一行插件,默认 `scope: session`、`maxBytes: 8192`。如需调整默认作用域 / 预算,改这里即可。
156
+
157
+ ---
158
+
159
+ ## 数据落点
160
+
161
+ | 作用域 | 存储位置 | 何时写 | 持久性 |
162
+ |---|---|---|---|
163
+ | global | `$DSH_HOME/rules/global.json` | 任一命令 / API 提交时 | ✅ 跨重启 |
164
+ | session | `$DSH_HOME/rules/sessions/<sessionId>.json` | 同上 | ✅ 跨重启 |
165
+ | project | `$DSH_HOME/rules/projects/<slug>.json`(slug 来自会话 cwd) | 同上 | ✅ 跨重启 |
166
+
167
+ > `$DSH_HOME` 由 `@deepseek-ai/dsh-home-paths` 解析,默认 `~/.dsh`。
168
+ > 读写走 `ctx.fs`(`resolve/stat/readText/writeText`,写时自动建目录);缺失容忍、损坏文件 failing-loud。
169
+ > **注意**:`project` 规则可通过命令管理并落盘,但**当前 pre-step 注入视图只包含 `global` + `session`**,故 `project` 规则暂未进入模型上下文(预留/实验性)。
170
+
171
+ ---
172
+
173
+ ## 客户端面板(可选)
174
+
175
+ 发布包还暴露一个 dsh web 客户端面板(`lib/client.js`,见 `package.json` 的 `exports` 里的 `./client`),通过宿主 HTTP API `/baize-rules.api` 与命令共用同一套 store/core:
176
+
177
+ - `GET /baize-rules.api?sessionId=…&project=…` → `{ global, session, project }`
178
+ - `POST /baize-rules.api`,body `{ sessionId, raw, scope }` → `{ ok, text, view }`
179
+
180
+ ---
181
+
182
+ ## 模块结构
183
+
184
+ ```
185
+ src/rules.ts 纯逻辑:Rule 模型 + 渲染/<system-reminder>/字节预算(具体优先)/digest/escapeReminder
186
+ src/core.ts 纯逻辑:parseCommand/runCommand/作用域解析/CRUD(零依赖,可脱离 dsh 单测)
187
+ src/store.ts 纯逻辑:global/session/project 规则文件持久化(ctx.fs + dshHomePath)
188
+ src/command.ts 薄 dsh 适配:喂 view/defaultScope → core,持久化 nextView/defaultScope
189
+ src/index.ts apply:agent/pre-step 注入 + /baize-rules 命令注册 + API 挂载(inject: agents/commands/fs/webServer/sessions)
190
+ src/api.ts Host HTTP API:GET/POST /baize-rules.api(供前端面板)
191
+ src/invariant.ts dsh-invariants 契约 companion(name/inject/apply)
192
+ scripts/dev-render.ts Loop 0 演示
193
+ test/*.spec.ts rules/core/composition 测试
194
+ cordis.patch.yml 挂载元数据(insert baize-rules 插件行 + 默认配置)
195
+ ```
196
+
197
+ **公共入口**(见 `package.json` 的 `exports`):`.`(index)、`./invariant`、`./client`、`./src/*`。
198
+
199
+ ---
200
+
201
+ ## 开发与即时反馈
202
+
203
+ ```bash
204
+ pnpm dev:render # 打印模型实际会看到的 <system-reminder> 文案(支持预算参数)
205
+ pnpm test # 跑单元 + REAL-composition 测试
206
+ pnpm test:watch # 保存即重跑
207
+ pnpm build # tsc -p tsconfig.build.json → lib/
208
+ pnpm typecheck # npx tsc --noEmit
209
+ ```
210
+
211
+ 改 `src/rules.ts`(渲染)或 `src/core.ts`(命令决策)里的纯函数,重跑 `pnpm dev:render` 即可看到变化——**最快的反馈回路**(亚秒级,不碰 dsh)。
212
+
213
+ ---
214
+
215
+ ## 发布
216
+
217
+ ```bash
218
+ pnpm build && pnpm test # 发布前确认构建与测试通过
219
+ npm publish --access public # package.json 已含 publishConfig.access=public
220
+ ```
221
+
222
+ > 发布后建议补上 `repository` 字段指向公开 git 仓库,供社区查阅与反馈。
223
+
224
+ ---
225
+
226
+ ## 许可证
227
+
228
+ [MIT](./LICENSE)
@@ -0,0 +1,17 @@
1
+ # dsh-baize-rules bundle patch
2
+ #
3
+ # This file is the `dsh.bundle.patch` layer of the published npm package. When
4
+ # installed via `dsh plugin --profile <name> add dsh-baize-rules@<version>`, the
5
+ # command reconciles `dsh.profile.bundles` against installed packages and mounts
6
+ # this patch (a single `insert` of the plugin row) — exactly like the manual
7
+ # cordis.patch.yml mount line. The plugin's own `name='baize-rules'` is used for
8
+ # the injected `user/message` source attribution.
9
+ #
10
+ # To tune default scope / budget, add a `config` to the inserted row
11
+ # (mirrors the plugin `Config` schema).
12
+ - insert:
13
+ - id: baize-rules
14
+ name: 'dsh-baize-rules'
15
+ config:
16
+ scope: session
17
+ maxBytes: 8192
package/lib/api.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Host HTTP API for the rules panel. The client (`lib/client.js`) fetches rules
3
+ * via GET and applies mutations via POST, reusing the same store + core as the
4
+ * `/baize-rules` command (so the panel and command are the same source of truth).
5
+ *
6
+ * Routes (json):
7
+ * GET /baize-rules.api?sessionId=... -> { global, session }
8
+ * POST /baize-rules.api { sessionId, raw, scope } -> { ok, text, view }
9
+ *
10
+ * @module dsh-baize-rules/api
11
+ */
12
+ import { runCommand } from "./core.js";
13
+ import { view, writeGlobal, writeSession, writeProject, readProject } from "./store.js";
14
+ function sendJson(res, status, payload) {
15
+ const r = res;
16
+ r.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
17
+ r.end(JSON.stringify(payload));
18
+ }
19
+ function readBody(req) {
20
+ return new Promise((resolve) => {
21
+ const r = req;
22
+ let body = '';
23
+ r.on('data', (chunk) => { body += chunk; });
24
+ r.on('end', () => resolve(body));
25
+ r.on('error', () => resolve(''));
26
+ });
27
+ }
28
+ function parseBody(text) {
29
+ try {
30
+ return JSON.parse(text || '{}');
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ /** Register the rules panel API on the host web server. */
37
+ export function registerRulesApi(ctx, options = {}) {
38
+ if (ctx.webServer === undefined)
39
+ return () => { };
40
+ return ctx.webServer.register({
41
+ kind: 'exact',
42
+ path: '/baize-rules.api',
43
+ handler: async (req, res) => {
44
+ const method = req.method ?? '';
45
+ const url = req.url ?? '';
46
+ const query = new URLSearchParams(url.split('?')[1] ?? '');
47
+ const sessionId = query.get('sessionId') ?? '';
48
+ const project = await resolveProject(ctx, sessionId, query.get('project') ?? '');
49
+ if (method === 'GET') {
50
+ try {
51
+ sendJson(res, 200, await viewFor(ctx, sessionId, project, options));
52
+ }
53
+ catch (e) {
54
+ sendJson(res, 500, { error: String(e) });
55
+ }
56
+ return;
57
+ }
58
+ if (method === 'POST') {
59
+ const body = parseBody(await readBody(req));
60
+ const pSessionId = String(body.sessionId ?? '');
61
+ const pProject = await resolveProject(ctx, pSessionId, String(body.project ?? ''));
62
+ const raw = String(body.raw ?? '');
63
+ const defaultScope = (body.scope ?? 'session');
64
+ if (!raw.trim()) {
65
+ sendJson(res, 400, { error: 'empty raw' });
66
+ return;
67
+ }
68
+ try {
69
+ const v = await viewFor(ctx, pSessionId, pProject, options);
70
+ const out = runCommand({ raw, view: v, defaultScope });
71
+ if (out.nextView !== undefined) {
72
+ await writeGlobal(ctx, options, out.nextView.global);
73
+ if (pSessionId)
74
+ await writeSession(ctx, pSessionId, out.nextView.session);
75
+ if (pProject)
76
+ await writeProject(ctx, pProject, out.nextView.project ?? []);
77
+ }
78
+ sendJson(res, out.ok ? 200 : 400, { ok: out.ok, text: out.text, view: out.nextView ?? v });
79
+ }
80
+ catch (e) {
81
+ sendJson(res, 500, { error: String(e) });
82
+ }
83
+ return;
84
+ }
85
+ sendJson(res, 405, { error: 'method not allowed' });
86
+ },
87
+ });
88
+ }
89
+ /** Resolve the project key (cwd) from an explicit `project`, else the session's header cwd. */
90
+ async function resolveProject(ctx, sessionId, project) {
91
+ if (project)
92
+ return project;
93
+ if (!sessionId)
94
+ return '';
95
+ try {
96
+ const sess = ctx.sessions?.get?.(sessionId);
97
+ return (sess && sess.header?.cwd) || '';
98
+ }
99
+ catch {
100
+ return '';
101
+ }
102
+ }
103
+ /** Load global (disk) + session (per-session file) + project (per-cwd file) rules. */
104
+ async function viewFor(ctx, sessionId, project, options) {
105
+ const v = await view(ctx, { id: sessionId }, options);
106
+ const proj = project ? await readProject(ctx, project) : [];
107
+ return { ...v, project: proj };
108
+ }
package/lib/client.js ADDED
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Baize rules client — the 规则 button (sidebar footer) + 规则 conversation tab
3
+ * (对话/项目/全局). Hand-authored after @modusensus/dsh-mneme's client bundle:
4
+ * click the footer button → click the matching conversation.view tab; if there's
5
+ * no tab ring (new-chat hero) → open a full-viewport overlay. Data goes through
6
+ * the host HTTP API `/baize-rules.api` (same source of truth as /baize-rules).
7
+ *
8
+ * @module dsh-baize-rules/client
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: 'dsh-baize-rules',
12
+ factory: (require) => {
13
+ var module = { exports: {} };
14
+ var exports = module.exports;
15
+
16
+ const react = require('react');
17
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
18
+ const { useState, useEffect, useCallback } = react;
19
+ const h = react.createElement;
20
+
21
+ const inject = ['slots', 'locale'];
22
+ const NS = 'baize-rules';
23
+
24
+ const dictionaries = {
25
+ zh: {
26
+ 'rules.sidebar.aria': '打开规则',
27
+ 'rules.panel.open': '规则',
28
+ 'rules.view.label': '规则',
29
+ 'rules.scope.session': '对话',
30
+ 'rules.scope.project': '项目',
31
+ 'rules.scope.global': '全局',
32
+ 'rules.add': '添加规则',
33
+ 'rules.remove': '删除',
34
+ 'rules.edit': '编辑',
35
+ 'rules.save': '保存',
36
+ 'rules.cancel': '取消',
37
+ 'rules.empty': '暂无规则',
38
+ 'rules.addHint': '输入规则,回车添加(如:用中文写注释)',
39
+ 'rules.error': '操作失败',
40
+ 'rules.overlay.close': '关闭',
41
+ },
42
+ en: {
43
+ 'rules.sidebar.aria': 'Open rules',
44
+ 'rules.panel.open': 'Rules',
45
+ 'rules.view.label': 'Rules',
46
+ 'rules.scope.session': 'Session',
47
+ 'rules.scope.project': 'Project',
48
+ 'rules.scope.global': 'Global',
49
+ 'rules.add': 'Add rule',
50
+ 'rules.remove': 'Remove',
51
+ 'rules.edit': 'Edit',
52
+ 'rules.save': 'Save',
53
+ 'rules.cancel': 'Cancel',
54
+ 'rules.empty': 'No rules',
55
+ 'rules.addHint': 'Type a rule and press Enter (e.g. write comments in Chinese)',
56
+ 'rules.error': 'Failed',
57
+ 'rules.overlay.close': 'Close',
58
+ },
59
+ };
60
+
61
+ // --- Module-scoped overlay store (mirrors dsh-mneme's useOverlayOpen). ---
62
+ let overlayOpen = false;
63
+ const overlayListeners = new Set();
64
+ function setOverlayOpen(open) { overlayOpen = open; overlayListeners.forEach((fn) => fn(open)); }
65
+ function useRulesOverlay() {
66
+ const [open, setOpen] = useState(overlayOpen);
67
+ useEffect(() => {
68
+ const fn = (v) => setOpen(v);
69
+ overlayListeners.add(fn);
70
+ return () => { overlayListeners.delete(fn); };
71
+ }, []);
72
+ return [open, setOverlayOpen];
73
+ }
74
+
75
+ // --- Themed CSS (mirrors dsh-mneme's .mneme-trigger) + a themed rules icon. ---
76
+ const RULES_CSS = [
77
+ '.baize-trigger{box-sizing:border-box;cursor:pointer;width:calc(100% + 4px);height:42px;color:var(--dsw-alias-label-primary);background:0 0;border:none;border-radius:12px;flex:none;align-items:center;gap:8px;margin:4px -2px;padding:0 10px 0 8px;font-family:inherit;font-size:14px;line-height:22px;display:flex;overflow:hidden}',
78
+ '.baize-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}',
79
+ '.baize-trigger.baize-rail{border-radius:50%;justify-content:center;gap:0;width:36px;height:36px;margin:8px 0 10px;padding:0}',
80
+ '.baize-trigger-label{white-space:nowrap;overflow:hidden}',
81
+ '.baize-seg{box-sizing:border-box;cursor:pointer;padding:5px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;line-height:18px}',
82
+ '.baize-seg:hover{background:var(--dsw-alias-interactive-bg-hover)}',
83
+ '.baize-seg.baize-seg-on{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary);font-weight:600}',
84
+ '.baize-ghost{box-sizing:border-box;cursor:pointer;padding:3px 8px;border-radius:6px;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:12px;line-height:16px}',
85
+ '.baize-ghost:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
86
+ ].join('');
87
+ let stylesInjected = false;
88
+ function injectStyles() {
89
+ if (stylesInjected || typeof document === 'undefined') return;
90
+ stylesInjected = true;
91
+ const tag = document.createElement('style');
92
+ tag.dataset.plugin = 'baize-rules';
93
+ tag.textContent = RULES_CSS;
94
+ document.head.appendChild(tag);
95
+ }
96
+ // Themed rules icon (list w/ checkmarks), stroke currentColor so it blends.
97
+ const RulesIcon = ({ size = 16 }) => h('svg', { width: size, height: size, viewBox: '0 0 16 16', fill: 'none', xmlns: 'http://www.w3.org/2000/svg' },
98
+ h('path', { d: 'M5 4.5 6.2 5.7 8.4 3.4M5 8.5 6.2 9.7 8.4 7.4M5 12.5 6.2 13.7 8.4 11.4', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' }),
99
+ h('path', { d: 'M11 4.5h1.5M11 8.5h1.5M11 12.5h1.5', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' })
100
+ );
101
+
102
+ // --- Tab activation: find the conversation.view tab by label and click it,
103
+ // polling aria-selected (bounded) so a silent React gap isn't read as success. ---
104
+ function findRulesTab(label) {
105
+ const tabs = document.querySelectorAll('[role="tab"]');
106
+ for (const tab of tabs) if ((tab.textContent || '').trim() === label) return tab;
107
+ return null;
108
+ }
109
+ function activateRulesTab(label) {
110
+ return new Promise((resolve) => {
111
+ const tab = findRulesTab(label);
112
+ if (!tab) { resolve(false); return; }
113
+ tab.click();
114
+ const deadline = Date.now() + 400;
115
+ (function check() {
116
+ if (tab.getAttribute('aria-selected') === 'true') { resolve(true); return; }
117
+ if (Date.now() >= deadline) { resolve(false); return; }
118
+ requestAnimationFrame(check);
119
+ })();
120
+ });
121
+ }
122
+
123
+ // --- API (same-origin; host exposes /baize-rules.api). ---
124
+ function rulesQuery(sessionId, project) {
125
+ const q = new URLSearchParams();
126
+ if (sessionId) q.set('sessionId', sessionId);
127
+ if (project) q.set('project', project);
128
+ const s = q.toString();
129
+ return s ? '?' + s : '';
130
+ }
131
+ async function apiGet(sessionId, project) {
132
+ const res = await fetch('/baize-rules.api' + rulesQuery(sessionId, project));
133
+ if (!res.ok) throw new Error('get failed');
134
+ return res.json();
135
+ }
136
+ async function apiPost(sessionId, project, raw, scope) {
137
+ const res = await fetch('/baize-rules.api', {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({ sessionId, project, raw, scope }),
141
+ });
142
+ const body = await res.json();
143
+ if (!res.ok) throw new Error(body.error || body.text || 'post failed');
144
+ return body;
145
+ }
146
+
147
+ // One rule row with inline edit (编辑 → input + 保存/取消), instead of delete+re-add.
148
+ function RuleRow({ rule, onSave, onRemove, t }) {
149
+ const [editing, setEditing] = useState(false);
150
+ const [val, setVal] = useState(rule.text);
151
+ const begin = () => { setVal(rule.text); setEditing(true); };
152
+ const save = () => { if (val.trim()) onSave(rule.id, val.trim()); setEditing(false); };
153
+ return h('li', { style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' } },
154
+ editing
155
+ ? h(react.Fragment, null,
156
+ h('input', { value: val, onChange: (e) => setVal(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false); }, style: { flexGrow: 1, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-primary)' } }),
157
+ h('button', { type: 'button', className: 'baize-ghost', onClick: save }, t('rules.save')),
158
+ h('button', { type: 'button', className: 'baize-ghost', onClick: () => setEditing(false) }, t('rules.cancel'))
159
+ )
160
+ : h(react.Fragment, null,
161
+ h('span', { style: { flexGrow: 1, color: 'var(--dsw-alias-label-primary)' } }, rule.text),
162
+ h('button', { type: 'button', className: 'baize-ghost', onClick: begin }, t('rules.edit')),
163
+ h('button', { type: 'button', className: 'baize-ghost', onClick: () => onRemove(rule.id) }, t('rules.remove'))
164
+ )
165
+ );
166
+ }
167
+
168
+ function ScopeSection({ scopeLabel, rules, onSave, onRemove, t }) {
169
+ return h('div', { style: { margin: '8px 0' } },
170
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, scopeLabel),
171
+ rules.length === 0
172
+ ? h('div', { style: { color: 'var(--dsw-alias-label-secondary, #888)', fontSize: 12 } }, t('rules.empty'))
173
+ : h('ul', { style: { listStyle: 'none', padding: 0, margin: 0 } },
174
+ rules.map(r => h(RuleRow, { key: r.id, rule: r, onSave, onRemove, t }))
175
+ )
176
+ );
177
+ }
178
+
179
+ function RulesPanel({ t, sessionId, project }) {
180
+ const [scope, setScope] = useState('session');
181
+ const [view, setView] = useState({ global: [], session: [], project: [] });
182
+ const [text, setText] = useState('');
183
+ const [err, setErr] = useState('');
184
+
185
+ const load = useCallback(async () => {
186
+ try { setView(await apiGet(sessionId, project)); setErr(''); }
187
+ catch (e) { setErr(String(e)); }
188
+ }, [sessionId]);
189
+ useEffect(() => { void load(); }, [load]);
190
+
191
+ async function add() {
192
+ if (!text.trim()) return;
193
+ try { const r = await apiPost(sessionId, project, 'add ' + text.trim(), scope); setView(r.view); setText(''); setErr(''); }
194
+ catch (e) { setErr(String(e)); }
195
+ }
196
+ async function remove(id) {
197
+ try { const r = await apiPost(sessionId, project, 'remove ' + id, scope); setView(r.view); }
198
+ catch (e) { setErr(String(e)); }
199
+ }
200
+ async function saveRule(id, text) {
201
+ try { const r = await apiPost(sessionId, project, 'edit ' + id + ' ' + text, scope); setView(r.view); setErr(''); }
202
+ catch (e) { setErr(String(e)); }
203
+ }
204
+
205
+ const scopeKeys = [
206
+ ['session', 'rules.scope.session'],
207
+ ['project', 'rules.scope.project'],
208
+ ['global', 'rules.scope.global'],
209
+ ];
210
+ const activeRules = view[scope] || [];
211
+ const activeLabelKey = (scopeKeys.find(([k]) => k === scope) || ['session'])[1];
212
+ return h('div', { style: { padding: 16, fontFamily: 'inherit' } },
213
+ h('div', { style: { display: 'flex', gap: 4, marginBottom: 12 } },
214
+ scopeKeys.map(([k, key]) => h('button', {
215
+ key: k, type: 'button', onClick: () => setScope(k),
216
+ className: 'baize-seg' + (scope === k ? ' baize-seg-on' : ''),
217
+ }, t(key)))
218
+ ),
219
+ h('div', { style: { display: 'flex', gap: 4, marginBottom: 12 } },
220
+ h('input', { value: text, onChange: (e) => setText(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter') add(); }, placeholder: t('rules.addHint'), style: { flexGrow: 1, padding: '6px 8px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-primary)' } }),
221
+ h('button', { type: 'button', onClick: add, className: 'baize-seg baize-seg-on', style: { padding: '6px 12px' } }, t('rules.add'))
222
+ ),
223
+ err ? h('div', { style: { color: 'var(--dsw-alias-state-error, #c33)', fontSize: 12 } }, err) : null,
224
+ h(ScopeSection, { t, scopeLabel: t(activeLabelKey), rules: activeRules, onSave: (id, text) => { void saveRule(id, text); }, onRemove: (id) => { void remove(id); } })
225
+ );
226
+ }
227
+
228
+ // --- Sidebar foot trigger: clicks the rules tab; falls back to overlay. ---
229
+ function RulesTrigger({ wide, t }) {
230
+ const [, setOpen] = useRulesOverlay();
231
+ return h('button', {
232
+ type: 'button',
233
+ className: wide ? 'baize-trigger' : 'baize-trigger baize-rail',
234
+ 'aria-label': t('rules.sidebar.aria'),
235
+ title: t('rules.sidebar.aria'),
236
+ onClick: () => { activateRulesTab(t('rules.view.label')).then((ok) => { if (!ok) setOpen(true); }); },
237
+ },
238
+ h(RulesIcon, { size: wide ? 16 : 18 }),
239
+ wide ? h('span', { className: 'baize-trigger-label' }, t('rules.panel.open')) : null
240
+ );
241
+ }
242
+
243
+ // Full-viewport fallback when there's no tab ring (new-chat hero). Esc closes.
244
+ function RulesOverlay({ t }) {
245
+ const [open, setOpen] = useRulesOverlay();
246
+ useEffect(() => {
247
+ if (!open) return undefined;
248
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
249
+ window.addEventListener('keydown', onKey);
250
+ return () => window.removeEventListener('keydown', onKey);
251
+ }, [open]);
252
+ if (!open) return null;
253
+ return h('div', { style: { position: 'fixed', inset: 0, background: 'var(--dsw-alias-bg-base, #111)', zIndex: 9999, overflow: 'auto' } },
254
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 16px', borderBottom: '1px solid var(--dsw-alias-border-l2, #333)' } },
255
+ h('span', { style: { fontWeight: 600 } }, t('rules.view.label')),
256
+ h('button', { type: 'button', className: 'baize-ghost', onClick: () => setOpen(false) }, t('rules.overlay.close'))
257
+ ),
258
+ h(RulesPanel, { t })
259
+ );
260
+ }
261
+
262
+ function apply(ctx) {
263
+ injectStyles();
264
+ ctx.effect(() => ctx.locale.register(NS, dictionaries), 'baize-rules: dictionaries');
265
+
266
+ ctx.slots.inject('sidebar.footer.action', () => {
267
+ const t = ctx.locale.bind(NS);
268
+ return ctx.slots.register({
269
+ name: 'sidebar.footer.action',
270
+ id: 'baize-rules',
271
+ order: 1,
272
+ label: () => t('rules.panel.open')
273
+ }, (props) => h(react.Fragment, null,
274
+ h(RulesTrigger, { ...props, t }),
275
+ h(RulesOverlay, { t })
276
+ ));
277
+ });
278
+
279
+ ctx.slots.inject('conversation.view', () => {
280
+ const t = ctx.locale.bind(NS);
281
+ return ctx.slots.register({
282
+ name: 'conversation.view',
283
+ id: 'baize-rules-rules',
284
+ order: 30,
285
+ locale: NS,
286
+ label: () => t('rules.view.label')
287
+ }, (props) => h(RulesPanel, { t, sessionId: props?.sessionId ?? '', project: props?.cwd ?? props?.session?.header?.cwd ?? props?.session?.cwd ?? '' }));
288
+ });
289
+ }
290
+
291
+ exports.apply = apply;
292
+ exports.inject = inject;
293
+ return module.exports;
294
+ }
295
+ });