@leiyi-works/dsh-keyrule 0.0.0-stage → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -2
- package/client.js +411 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +10 -0
- package/index.js +616 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -5
package/README.md
CHANGED
|
@@ -1,3 +1,121 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 常驻钥则 · dsh-keyrule
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 把用户明确说过的长期约定**每一步注入一次提示**——长会话、上下文压缩、重启之后都不会忘。
|
|
4
|
+
> v1.2 起分三层:**全局** / **工作区** / **本会话**,并且删除会话时自动清掉会话级文件。
|
|
5
|
+
|
|
6
|
+
## 它解决什么
|
|
7
|
+
|
|
8
|
+
典型场景:
|
|
9
|
+
|
|
10
|
+
> 「AI创作」这个会话用得很久了。你之前说过「非视频制作的任务交给团队」,
|
|
11
|
+
> 但最近有一次任务,agent 还是自己从头做完了,你提醒它,它才想起来。
|
|
12
|
+
|
|
13
|
+
根因不是 agent 不会记,而是:
|
|
14
|
+
|
|
15
|
+
1. 那条约定**只存在于对话里**,不是文件;
|
|
16
|
+
2. 长会话 + 上下文压缩/截断后,早期对话被摘要掉了;
|
|
17
|
+
3. 就算没压缩,一句很久以前的话在长上下文里也会"沉底"。
|
|
18
|
+
|
|
19
|
+
这个插件把这类约定变成**文件 + 每步注入**:
|
|
20
|
+
|
|
21
|
+
- 每次组装模型提示时,把规则作为运行时上下文插进去(`systemPrompt.context`,每步一次);
|
|
22
|
+
- 提供 `standing_rules` 工具:agent 听到"以后都……/记住……/别再忘……"时,**立刻写进规则文件**。
|
|
23
|
+
|
|
24
|
+
## 三层规则
|
|
25
|
+
|
|
26
|
+
| 层 | 文件 | 谁看得到 | 适合放什么 |
|
|
27
|
+
|---|---|---|---|
|
|
28
|
+
| 全局 | `<DSH_HOME>/standing-rules.md`(默认 `C:\Users\TL\.dsh\standing-rules.md`) | 这个 profile 下**所有会话** | 通用习惯:「重要结论落文件」「花钱前先问」 |
|
|
29
|
+
| 工作区 | `<会话工作目录>/.dsh-standing-rules.md` | 该工作区的会话 | 项目规矩:「本项目的交付格式」 |
|
|
30
|
+
| 本会话 | `<DSH_HOME>/standing-rules/sessions/<sessionId>.md` | **仅当前会话** | 只在这个会话生效的约定 |
|
|
31
|
+
|
|
32
|
+
- 只有真正往里写规则时才创建文件(插件不预先建空文件);
|
|
33
|
+
- 工作区层按会话的工作目录定位;本会话层按会话 id 定位;
|
|
34
|
+
- **删除会话时,插件监听 `session/disposed` 自动删掉该会话的规则文件**,不留残留;
|
|
35
|
+
- 想关掉某层:`includeWorkspaceRules: false` / `includeSessionRules: false`。
|
|
36
|
+
|
|
37
|
+
## 安装
|
|
38
|
+
|
|
39
|
+
Host-only bundle,无构建、无依赖。用 Harness 的 `plugin_manager`:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
plugin_manager({ action: "install_bundle", target: "<本目录绝对路径>" })
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
profile 级生效:该 profile 下所有会话都会加载。**替换已安装版本后需重启 DSH** 才会加载新代码。
|
|
46
|
+
|
|
47
|
+
## 配置
|
|
48
|
+
|
|
49
|
+
`cordis.patch.yml`:
|
|
50
|
+
|
|
51
|
+
| 字段 | 默认 | 说明 |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `rulesFile` | `~/.dsh/standing-rules.md` | 全局规则文件 |
|
|
54
|
+
| `workspaceRulesFile` | `.dsh-standing-rules.md` | 工作区规则文件名(相对工作目录) |
|
|
55
|
+
| `sessionRulesDir` | `~/.dsh/standing-rules/sessions` | 会话规则目录 |
|
|
56
|
+
| `includeWorkspaceRules` | `true` | 关闭 = 不注入工作区规则 |
|
|
57
|
+
| `includeSessionRules` | `true` | 关闭 = 不注入会话规则 |
|
|
58
|
+
| `includeSubagents` | `true` | 子智能体/团队成员会话是否也注入 |
|
|
59
|
+
| `maxChars` | `2400` | 每层规则正文上限,超出截断 |
|
|
60
|
+
| `order` | `950` | 运行时上下文排序,越大越靠后 |
|
|
61
|
+
| `enableTool` | `true` | 是否注册 `standing_rules` 工具 |
|
|
62
|
+
|
|
63
|
+
## `standing_rules` 工具
|
|
64
|
+
|
|
65
|
+
| 参数 | 说明 |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `action` | `list` / `add` / `remove` |
|
|
68
|
+
| `rule` | add / remove 的规则文本 |
|
|
69
|
+
| `scope` | `global`(默认)/ `workspace` / `session` |
|
|
70
|
+
|
|
71
|
+
示例:把「非视频制作交给团队」记为**只对当前会话**生效:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{ "action": "add", "scope": "session",
|
|
75
|
+
"rule": "非视频制作类任务优先交给团队,主对话只做拆解、验收、汇总。" }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 文件与残留
|
|
79
|
+
|
|
80
|
+
插件只可能写这三个位置的 `.md`,此外不写任何文件(没有缓存、没有日志、没有状态文件):
|
|
81
|
+
|
|
82
|
+
- 全局规则(你自己维护,不随会话删除);
|
|
83
|
+
- 工作区规则(属于工作区,不随会话删除);
|
|
84
|
+
- 会话规则(按会话 id,**会话被删除时自动清理**)。
|
|
85
|
+
|
|
86
|
+
特殊情况:如果插件在某个时刻被禁用/卸载,期间删除的会话其会话规则文件不会被自动清理,可手动删除 `~/.dsh/standing-rules/sessions/` 下对应文件。
|
|
87
|
+
|
|
88
|
+
## 随时查看:左侧菜单「常驻钥则」→ 右侧边栏打开
|
|
89
|
+
|
|
90
|
+
v1.6 起入口在**左侧主菜单**(和「插件」「自动化任务」「上下文洞察」「AI创作」「更多」并列)。
|
|
91
|
+
|
|
92
|
+
- 点一下「常驻钥则」:**保持当前会话**,在**右侧边栏**打开「常驻钥则」标签页(和 H3 插件一样的侧边打开方式,不占会话框);
|
|
93
|
+
- 面板打开即显示 **① 全局规则** + **② 当前会话规则**(含「本工作区规则」和「本会话规则」两块,只显示当前这场会话的);
|
|
94
|
+
- 「刷新」重新读取(改完 md 文件点一下即可);每条路径带「复制路径」;
|
|
95
|
+
- 右侧边栏不可用时,才退回全页面板兜底(此时没有会话上下文,只显示全局规则)。
|
|
96
|
+
|
|
97
|
+
面板只读,不提供编辑框;写规则仍然通过对话("记住……")或直接编辑文件。数据来自宿主只读接口 `GET /__dsh-keyrule/state?session=<会话id>`。
|
|
98
|
+
|
|
99
|
+
## 卸载
|
|
100
|
+
|
|
101
|
+
`plugin_manager({ action: "remove_bundle", target: "@local/dsh-keyrule" })`。
|
|
102
|
+
规则文件不会被删,可自行保留或删除。
|
|
103
|
+
|
|
104
|
+
## 2026-11-08 更新:二级菜单 + 密钥收纳
|
|
105
|
+
|
|
106
|
+
- 面板(右侧栏「常驻钥则」标签页)顶部新增两个二级菜单,点哪个显示哪个界面:
|
|
107
|
+
- **常驻规则**:全局 / 本工作区 / 本会话三层规则(原功能)
|
|
108
|
+
- **常驻密钥**:插件 `secrets/` 目录的文件清单(名称 / 大小 / 更新时间 + 一键复制路径),并展示目录下 README 说明;**不展示私钥内容**
|
|
109
|
+
- 密钥目录 `secrets/` 相对插件目录解析(可在 `cordis.patch.yml` 的 `secretsDir` 改);该目录随插件源码一起被打包,所以换机包会带走
|
|
110
|
+
- 工作区规则文件名改为 `.dsh-keyrule-rules.md`;旧文件 `.dsh-standing-rules.md` 仍会被回落读取(不会丢规则)
|
|
111
|
+
- 主机接口:`GET /__dsh-keyrule/state?session=<id>`、`GET /__dsh-keyrule/secrets`
|
|
112
|
+
## 2026-11-08 更新二:SSH 别名纳入插件自愈
|
|
113
|
+
|
|
114
|
+
- 插件启动时(`apply`)调用 `ensureSshConfig`:
|
|
115
|
+
- 读 `~/.ssh/config`,维护 `# >>> dsh-keyrule managed host >>> … # <<< dsh-keyrule managed host <<<` 标记块
|
|
116
|
+
- 标记块缺失 → 追加;已存在 → 整体替换(路径随插件实际位置更新)
|
|
117
|
+
- 用户自定义同名 `Host` → 不触碰;写入前留 `config.dsh-keyrule.bak`
|
|
118
|
+
- 配置项(`cordis.patch.yml`):
|
|
119
|
+
- `ensureSshConfig: true`
|
|
120
|
+
- `sshHost: { alias, hostName, user, keyFile }`(keyFile 相对 `secretsDir` 解析)
|
|
121
|
+
- 效果:换机后新机一启动 DSH,`ssh jdcloud` 别名自动就位,无需手动补 `~/.ssh/config`
|
package/client.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-keyrule — Client half.
|
|
3
|
+
*
|
|
4
|
+
* 左侧菜单「常驻钥则」入口 + 右侧边栏标签页。
|
|
5
|
+
* 面板顶部两个二级菜单:
|
|
6
|
+
* - 常驻规则:全局 / 本工作区 / 本会话三层规则
|
|
7
|
+
* - 常驻密钥:插件 secrets/ 目录里的密钥清单(不展示私钥内容)
|
|
8
|
+
*/
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: '@local/dsh-keyrule',
|
|
11
|
+
factory(require) {
|
|
12
|
+
const React = require('react')
|
|
13
|
+
const h = React.createElement
|
|
14
|
+
const NS = 'standingRules'
|
|
15
|
+
/** 右侧边栏标签页类型 id(native right Sidebar tab kind)。 */
|
|
16
|
+
const TAB_ID = 'standing-rules-tab'
|
|
17
|
+
/** 左侧菜单行 / 兜底全页面板 id。 */
|
|
18
|
+
const PANEL_ID = 'standing-rules'
|
|
19
|
+
const CSS_TAG_ID = 'dsh-keyrule/client.css'
|
|
20
|
+
const CSS = [
|
|
21
|
+
'.srRoot{height:100%;overflow:auto;box-sizing:border-box;padding:calc(var(--dsh-frame-top-clearance,0px) + 16px) 14px 28px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.55}',
|
|
22
|
+
'.srInner{max-width:860px;margin:0 auto;display:flex;flex-direction:column;gap:12px}',
|
|
23
|
+
'.srTitle{margin:0;font-size:17px;font-weight:600}',
|
|
24
|
+
'.srHint{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px}',
|
|
25
|
+
'.srTabs{display:flex;gap:4px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));padding-bottom:0;margin-top:2px}',
|
|
26
|
+
'.srTab{appearance:none;cursor:pointer;border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;padding:6px 12px;border-bottom:2px solid transparent;margin-bottom:-1px;border-radius:6px 6px 0 0}',
|
|
27
|
+
'.srTab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.08))}',
|
|
28
|
+
'.srTabOn{color:var(--dsw-alias-label-primary);font-weight:600;border-bottom-color:var(--dsw-alias-brand-primary,#4d6bfe)}',
|
|
29
|
+
'.srBar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
30
|
+
'.srBtn{appearance:none;cursor:pointer;border-radius:7px;padding:4px 12px;font-size:12px;line-height:18px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.35));background:transparent;color:var(--dsw-alias-label-primary)}',
|
|
31
|
+
'.srBtn:hover{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.08))}',
|
|
32
|
+
'.srBtn:disabled{opacity:.55;cursor:default}',
|
|
33
|
+
'.srMeta{font-size:11px;color:var(--dsw-alias-label-secondary)}',
|
|
34
|
+
'.srSection{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));border-radius:10px;padding:10px 12px}',
|
|
35
|
+
'.srSectionHead{display:flex;align-items:baseline;justify-content:space-between;gap:8px}',
|
|
36
|
+
'.srLabel{font-size:12px;font-weight:600}',
|
|
37
|
+
'.srPath{font-family:ui-monospace,Consolas,monospace;font-size:10px;color:var(--dsw-alias-label-secondary);word-break:break-all}',
|
|
38
|
+
'.srPre{margin:0;font-family:ui-monospace,Consolas,monospace;font-size:11px;line-height:1.6;white-space:pre-wrap;word-break:break-word;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.08));border-radius:8px;padding:8px;max-height:220px;overflow:auto}',
|
|
39
|
+
'.srEmpty{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
40
|
+
'.srErr{font-size:12px;color:var(--dsw-alias-state-error-primary,#d14a4a)}',
|
|
41
|
+
'.srSecRow{display:flex;align-items:center;gap:10px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));border-radius:8px;padding:7px 10px}',
|
|
42
|
+
'.srSecName{font-family:ui-monospace,Consolas,monospace;font-size:12px;flex:1;word-break:break-all}',
|
|
43
|
+
'.srSecMeta{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
|
|
44
|
+
].join('')
|
|
45
|
+
try {
|
|
46
|
+
if (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css="${CSS_TAG_ID}"]`) === null) {
|
|
47
|
+
const tag = document.createElement('style')
|
|
48
|
+
tag.dataset.plugin = 'dsh-keyrule'
|
|
49
|
+
tag.dataset.pluginCss = CSS_TAG_ID
|
|
50
|
+
tag.textContent = CSS
|
|
51
|
+
document.head.appendChild(tag)
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore */
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const zh = {
|
|
58
|
+
label: '常驻钥则',
|
|
59
|
+
title: '常驻钥则',
|
|
60
|
+
tabRules: '常驻规则',
|
|
61
|
+
tabSecrets: '常驻密钥',
|
|
62
|
+
hint: '这里是每一步都会注入提示的规则。改文件保存后点「刷新」;本会话规则在删除会话时自动清理。',
|
|
63
|
+
secretsHint: '这里是随插件源码一起走(含换机包)的密钥目录。新增密钥直接放进该目录,私钥内容不在界面展示。',
|
|
64
|
+
guide: '查看常驻规则与常驻密钥',
|
|
65
|
+
global: '全局规则',
|
|
66
|
+
workspace: '本工作区规则',
|
|
67
|
+
session: '本会话规则',
|
|
68
|
+
current: '当前会话',
|
|
69
|
+
noSession: '当前没有会话上下文(从右侧栏打开可看到本会话规则)。',
|
|
70
|
+
refresh: '刷新',
|
|
71
|
+
copyPath: '复制路径',
|
|
72
|
+
copied: '已复制',
|
|
73
|
+
missing: '(文件还没创建)',
|
|
74
|
+
loading: '读取中…',
|
|
75
|
+
failed: '读取失败',
|
|
76
|
+
generatedAt: '读取于',
|
|
77
|
+
secretsDir: '密钥目录',
|
|
78
|
+
secretCount: '文件数',
|
|
79
|
+
secretBytes: '合计',
|
|
80
|
+
secretsEmpty: '(目录为空或还不存在)',
|
|
81
|
+
readme: '说明文件',
|
|
82
|
+
copyName: '复制路径',
|
|
83
|
+
}
|
|
84
|
+
const en = {
|
|
85
|
+
label: 'Key rules',
|
|
86
|
+
title: 'Key rules',
|
|
87
|
+
tabRules: 'Rules',
|
|
88
|
+
tabSecrets: 'Secrets',
|
|
89
|
+
hint: 'Rules injected into every model step. Edit a file and press Refresh; session-scoped files are cleaned when the session is deleted.',
|
|
90
|
+
secretsHint: 'Secrets bundled with this plugin source (travel with the migration pack). Add keys by dropping files in this directory; private key contents are not shown here.',
|
|
91
|
+
guide: 'View standing rules and bundled secrets',
|
|
92
|
+
global: 'Global rules',
|
|
93
|
+
workspace: 'Workspace rules',
|
|
94
|
+
session: 'Session rules',
|
|
95
|
+
current: 'Current session',
|
|
96
|
+
noSession: 'No session context. Open this from the right sidebar to see session rules.',
|
|
97
|
+
refresh: 'Refresh',
|
|
98
|
+
copyPath: 'Copy path',
|
|
99
|
+
copied: 'Copied',
|
|
100
|
+
missing: '(file not created yet)',
|
|
101
|
+
loading: 'Loading…',
|
|
102
|
+
failed: 'Failed to load',
|
|
103
|
+
generatedAt: 'Read at',
|
|
104
|
+
secretsDir: 'Secrets directory',
|
|
105
|
+
secretCount: 'Files',
|
|
106
|
+
secretBytes: 'Total',
|
|
107
|
+
secretsEmpty: '(empty or not created yet)',
|
|
108
|
+
readme: 'README',
|
|
109
|
+
copyName: 'Copy path',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function endpointUrl(name, sessionId) {
|
|
113
|
+
const query = sessionId ? `?session=${encodeURIComponent(sessionId)}` : ''
|
|
114
|
+
try {
|
|
115
|
+
return new URL(`__dsh-keyrule/${name}${query}`, document.baseURI).href
|
|
116
|
+
} catch {
|
|
117
|
+
return `/__dsh-keyrule/${name}${query}`
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatBytes(n) {
|
|
122
|
+
const num = Number(n)
|
|
123
|
+
if (!Number.isFinite(num) || num < 0) return '-'
|
|
124
|
+
if (num < 1024) return `${num} B`
|
|
125
|
+
if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KB`
|
|
126
|
+
return `${(num / 1024 / 1024).toFixed(2)} MB`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 左侧菜单行 / 右侧 tab 图标(剪贴板 + 对勾)。 */
|
|
130
|
+
function StandingRulesIcon(props) {
|
|
131
|
+
const size = props && typeof props.size === 'number' && props.size > 0 ? props.size : 18
|
|
132
|
+
return h('svg', {
|
|
133
|
+
width: size,
|
|
134
|
+
height: size,
|
|
135
|
+
viewBox: '0 0 24 24',
|
|
136
|
+
fill: 'none',
|
|
137
|
+
'aria-hidden': true,
|
|
138
|
+
style: { display: 'block' },
|
|
139
|
+
},
|
|
140
|
+
h('rect', {
|
|
141
|
+
x: 4.4, y: 4.2, width: 15.2, height: 16.4, rx: 2.2,
|
|
142
|
+
stroke: 'currentColor', strokeWidth: 1.6, strokeLinejoin: 'round',
|
|
143
|
+
}),
|
|
144
|
+
h('path', {
|
|
145
|
+
d: 'M9 4.4h6M9 3.4h6a1 1 0 0 1 1 1v1.4H8V4.4a1 1 0 0 1 1-1z',
|
|
146
|
+
stroke: 'currentColor', strokeWidth: 1.5, strokeLinejoin: 'round',
|
|
147
|
+
}),
|
|
148
|
+
h('path', { d: 'M8.2 12.2h7.6M8.2 15.6h5.2', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }),
|
|
149
|
+
h('path', { d: 'M14.2 18.1l1.5 1.5 3-3.4', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const TabIcon = (size) => h(StandingRulesIcon, { size })
|
|
153
|
+
|
|
154
|
+
function CopyButton({ path, t }) {
|
|
155
|
+
const [copied, setCopied] = React.useState(false)
|
|
156
|
+
const copy = () => {
|
|
157
|
+
try {
|
|
158
|
+
void navigator.clipboard.writeText(path).then(() => {
|
|
159
|
+
setCopied(true)
|
|
160
|
+
setTimeout(() => setCopied(false), 1200)
|
|
161
|
+
})
|
|
162
|
+
} catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return h('button', { type: 'button', className: 'srBtn', onClick: copy }, copied ? t('copied') : t('copyPath'))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function RulesBlock({ title, path, text, t }) {
|
|
170
|
+
return h('div', { className: 'srSection' },
|
|
171
|
+
h('div', { className: 'srSectionHead' },
|
|
172
|
+
h('span', { className: 'srLabel' }, title),
|
|
173
|
+
h(CopyButton, { path, t })),
|
|
174
|
+
h('span', { className: 'srPath' }, path),
|
|
175
|
+
text && String(text).trim() !== ''
|
|
176
|
+
? h('pre', { className: 'srPre' }, String(text))
|
|
177
|
+
: h('span', { className: 'srEmpty' }, t('missing')))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function RulesView({ sessionId, t }) {
|
|
181
|
+
const [state, setState] = React.useState({ phase: 'loading' })
|
|
182
|
+
const load = React.useCallback(() => {
|
|
183
|
+
setState({ phase: 'loading' })
|
|
184
|
+
fetch(endpointUrl('state', sessionId), { cache: 'no-store' })
|
|
185
|
+
.then((res) => res.json())
|
|
186
|
+
.then((data) => setState({ phase: 'ready', data }))
|
|
187
|
+
.catch((error) => setState({ phase: 'error', error: String(error && error.message ? error.message : error) }))
|
|
188
|
+
}, [sessionId])
|
|
189
|
+
React.useEffect(() => {
|
|
190
|
+
load()
|
|
191
|
+
}, [load])
|
|
192
|
+
|
|
193
|
+
const data = state.phase === 'ready' ? state.data : undefined
|
|
194
|
+
const current = data && data.current ? data.current : null
|
|
195
|
+
return h(React.Fragment, null,
|
|
196
|
+
h('div', { className: 'srBar' },
|
|
197
|
+
h('button', { type: 'button', className: 'srBtn', onClick: load, disabled: state.phase === 'loading' }, t('refresh')),
|
|
198
|
+
state.phase === 'loading' ? h('span', { className: 'srMeta' }, t('loading')) : null,
|
|
199
|
+
state.phase === 'ready' && data ? h('span', { className: 'srMeta' }, `${t('generatedAt')} ${new Date(data.generatedAt).toLocaleString()}`) : null),
|
|
200
|
+
state.phase === 'error' ? h('span', { className: 'srErr' }, `${t('failed')}: ${state.error}`) : null,
|
|
201
|
+
state.phase === 'ready' && data
|
|
202
|
+
? h(React.Fragment, null,
|
|
203
|
+
h(RulesBlock, { title: t('global'), path: data.global.path, text: data.global.text, t }),
|
|
204
|
+
current
|
|
205
|
+
? h(React.Fragment, null,
|
|
206
|
+
current.workspace
|
|
207
|
+
? h(RulesBlock, { title: t('workspace'), path: current.workspace.path, text: current.workspace.text, t })
|
|
208
|
+
: null,
|
|
209
|
+
h(RulesBlock, { title: t('session'), path: current.session.path, text: current.session.text, t }))
|
|
210
|
+
: h('span', { className: 'srEmpty' }, t('noSession')))
|
|
211
|
+
: null)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function SecretsView({ t }) {
|
|
215
|
+
const [state, setState] = React.useState({ phase: 'loading' })
|
|
216
|
+
const load = React.useCallback(() => {
|
|
217
|
+
setState({ phase: 'loading' })
|
|
218
|
+
fetch(endpointUrl('secrets'), { cache: 'no-store' })
|
|
219
|
+
.then((res) => res.json())
|
|
220
|
+
.then((data) => setState({ phase: 'ready', data }))
|
|
221
|
+
.catch((error) => setState({ phase: 'error', error: String(error && error.message ? error.message : error) }))
|
|
222
|
+
}, [])
|
|
223
|
+
React.useEffect(() => {
|
|
224
|
+
load()
|
|
225
|
+
}, [load])
|
|
226
|
+
const data = state.phase === 'ready' ? state.data : undefined
|
|
227
|
+
return h(React.Fragment, null,
|
|
228
|
+
h('div', { className: 'srBar' },
|
|
229
|
+
h('button', { type: 'button', className: 'srBtn', onClick: load, disabled: state.phase === 'loading' }, t('refresh')),
|
|
230
|
+
state.phase === 'loading' ? h('span', { className: 'srMeta' }, t('loading')) : null,
|
|
231
|
+
data
|
|
232
|
+
? h('span', { className: 'srMeta' }, `${t('secretCount')} ${(data.files || []).length} · ${t('secretBytes')} ${formatBytes(data.totalBytes)}`)
|
|
233
|
+
: null),
|
|
234
|
+
state.phase === 'error' ? h('span', { className: 'srErr' }, `${t('failed')}: ${state.error}`) : null,
|
|
235
|
+
data
|
|
236
|
+
? h('div', { className: 'srSection' },
|
|
237
|
+
h('div', { className: 'srSectionHead' },
|
|
238
|
+
h('span', { className: 'srLabel' }, t('secretsDir')),
|
|
239
|
+
h(CopyButton, { path: data.dir, t })),
|
|
240
|
+
h('span', { className: 'srPath' }, data.dir),
|
|
241
|
+
(data.files || []).length === 0
|
|
242
|
+
? h('span', { className: 'srEmpty' }, t('secretsEmpty'))
|
|
243
|
+
: h(React.Fragment, null,
|
|
244
|
+
...(data.files || []).map((file) => h('div', { className: 'srSecRow', key: file.path },
|
|
245
|
+
h('span', { className: 'srSecName' }, file.name),
|
|
246
|
+
h('span', { className: 'srSecMeta' }, formatBytes(file.bytes)),
|
|
247
|
+
h('span', { className: 'srSecMeta' }, new Date(file.mtime).toLocaleString()),
|
|
248
|
+
h(CopyButton, { path: file.path, t })))))
|
|
249
|
+
: null,
|
|
250
|
+
data && data.readme
|
|
251
|
+
? h('div', { className: 'srSection' },
|
|
252
|
+
h('div', { className: 'srSectionHead' },
|
|
253
|
+
h('span', { className: 'srLabel' }, `${t('readme')} · ${data.readmeName}`)),
|
|
254
|
+
h('pre', { className: 'srPre' }, data.readme))
|
|
255
|
+
: null)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function KeyRulePanel(props) {
|
|
259
|
+
const t = (key) => (typeof props.t === 'function' ? props.t(key) : zh[key] !== undefined ? zh[key] : key)
|
|
260
|
+
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
|
|
261
|
+
const [tab, setTab] = React.useState('rules')
|
|
262
|
+
const tabBtn = (id, label) => h('button', {
|
|
263
|
+
type: 'button',
|
|
264
|
+
className: `srTab${tab === id ? ' srTabOn' : ''}`,
|
|
265
|
+
onClick: () => setTab(id),
|
|
266
|
+
}, label)
|
|
267
|
+
return h('div', { className: 'srRoot' },
|
|
268
|
+
h('div', { className: 'srInner' },
|
|
269
|
+
h('h1', { className: 'srTitle' }, t('title')),
|
|
270
|
+
h('div', { className: 'srTabs' },
|
|
271
|
+
tabBtn('rules', t('tabRules')),
|
|
272
|
+
tabBtn('secrets', t('tabSecrets'))),
|
|
273
|
+
h('p', { className: 'srHint' }, tab === 'rules' ? t('hint') : t('secretsHint')),
|
|
274
|
+
tab === 'rules' ? h(RulesView, { sessionId, t }) : h(SecretsView, { t })))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
name: '@local/dsh-keyrule',
|
|
279
|
+
inject: ['slots', 'locale', 'layout'],
|
|
280
|
+
apply(ctx) {
|
|
281
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-keyrule: dictionaries')
|
|
282
|
+
const t = ctx.locale.bind(NS)
|
|
283
|
+
|
|
284
|
+
/** 打开(或聚焦)右侧边栏的常驻钥则 tab。 */
|
|
285
|
+
let nativeOpen = null
|
|
286
|
+
let redirecting = false
|
|
287
|
+
const openTab = () => {
|
|
288
|
+
if (nativeOpen === null) return false
|
|
289
|
+
try {
|
|
290
|
+
nativeOpen()
|
|
291
|
+
return true
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.warn('[dsh-keyrule] native right-sidebar openTab failed:', error)
|
|
294
|
+
return false
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 注册右侧边栏 tab(和 H3 同款原生通道)。
|
|
299
|
+
const mountTab = (inner) => {
|
|
300
|
+
let registry
|
|
301
|
+
let slots
|
|
302
|
+
let sidebarRight
|
|
303
|
+
try {
|
|
304
|
+
registry = inner.sidebarRightTabs ?? inner.get?.('sidebarRightTabs')
|
|
305
|
+
slots = inner.slots
|
|
306
|
+
sidebarRight = inner.sidebarRight ?? inner.get?.('sidebarRight')
|
|
307
|
+
} catch {
|
|
308
|
+
return undefined
|
|
309
|
+
}
|
|
310
|
+
if (registry === undefined || typeof registry.register !== 'function' || slots === undefined) return undefined
|
|
311
|
+
const disposers = []
|
|
312
|
+
try {
|
|
313
|
+
const disposeType = registry.register({
|
|
314
|
+
id: TAB_ID,
|
|
315
|
+
kind: TAB_ID,
|
|
316
|
+
title: () => t('label'),
|
|
317
|
+
guide: [
|
|
318
|
+
{ order: 60, title: () => t('label'), description: () => t('guide'), icon: TabIcon },
|
|
319
|
+
],
|
|
320
|
+
})
|
|
321
|
+
if (typeof disposeType === 'function') disposers.push(disposeType)
|
|
322
|
+
const disposeBody = slots.inject('sidebar.right.pane.tab', () => slots.register({
|
|
323
|
+
name: 'sidebar.right.pane.tab',
|
|
324
|
+
key: TAB_ID,
|
|
325
|
+
locale: NS,
|
|
326
|
+
}, (props) => h(KeyRulePanel, props)))
|
|
327
|
+
if (typeof disposeBody === 'function') disposers.push(disposeBody)
|
|
328
|
+
if (sidebarRight !== undefined && typeof sidebarRight.openTab === 'function') {
|
|
329
|
+
nativeOpen = () => sidebarRight.openTab(TAB_ID)
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
for (const dispose of disposers.reverse()) {
|
|
333
|
+
try { dispose() } catch { /* ignore */ }
|
|
334
|
+
}
|
|
335
|
+
return undefined
|
|
336
|
+
}
|
|
337
|
+
return () => {
|
|
338
|
+
for (const dispose of disposers.reverse()) {
|
|
339
|
+
try { dispose() } catch { /* ignore */ }
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
ctx.inject(['sidebarRightTabs', 'sidebarRight', 'slots'], mountTab)
|
|
345
|
+
} catch {
|
|
346
|
+
mountTab(ctx)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 兜底:右侧边栏打不开时,仍可用全页面板查看。
|
|
350
|
+
ctx.effect(
|
|
351
|
+
() => ctx.slots.inject('main', () => ctx.slots.register({
|
|
352
|
+
name: 'main',
|
|
353
|
+
key: PANEL_ID,
|
|
354
|
+
locale: NS,
|
|
355
|
+
}, KeyRulePanel)),
|
|
356
|
+
'dsh-keyrule: main panel (fallback full page)',
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
// 左侧菜单入口。
|
|
360
|
+
ctx.effect(
|
|
361
|
+
() => ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
362
|
+
name: 'sidebar.panellist',
|
|
363
|
+
id: PANEL_ID,
|
|
364
|
+
order: 44,
|
|
365
|
+
label: () => t('label'),
|
|
366
|
+
locale: NS,
|
|
367
|
+
}, StandingRulesIcon)),
|
|
368
|
+
'dsh-keyrule: sidebar entry',
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
// 点左侧行时保持会话:先回到会话,再开右侧 tab;打不开才退回全页面板。
|
|
372
|
+
ctx.effect(() => {
|
|
373
|
+
const layout = ctx.layout
|
|
374
|
+
const openAfterReturn = () => {
|
|
375
|
+
const opened = openTab()
|
|
376
|
+
if (!opened) {
|
|
377
|
+
try {
|
|
378
|
+
layout.selectPanel(PANEL_ID)
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.warn('[dsh-keyrule] full-page fallback failed:', error)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
redirecting = false
|
|
384
|
+
}
|
|
385
|
+
const check = () => {
|
|
386
|
+
if (redirecting) return
|
|
387
|
+
let active
|
|
388
|
+
try {
|
|
389
|
+
active = layout.panelInfo.getSnapshot().activePanelId
|
|
390
|
+
} catch {
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
if (active !== PANEL_ID) return
|
|
394
|
+
redirecting = true
|
|
395
|
+
try {
|
|
396
|
+
layout.selectPanel(null)
|
|
397
|
+
} catch (error) {
|
|
398
|
+
redirecting = false
|
|
399
|
+
console.warn('[dsh-keyrule] returning to the conversation failed:', error)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
if (typeof queueMicrotask === 'function') queueMicrotask(openAfterReturn)
|
|
403
|
+
else setTimeout(openAfterReturn, 0)
|
|
404
|
+
}
|
|
405
|
+
check()
|
|
406
|
+
return layout.panelInfo.subscribe(check)
|
|
407
|
+
}, 'dsh-keyrule: sidebar entry opens the native right-sidebar tab')
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
- insert:
|
|
2
|
+
- id: keyrule
|
|
3
|
+
name: '@local/dsh-keyrule'
|
|
4
|
+
config:
|
|
5
|
+
# 全局规则文件(所有会话可见);~ 展开到用户主目录
|
|
6
|
+
rulesFile: '~/.dsh/standing-rules.md'
|
|
7
|
+
# 工作区规则文件名(相对每个会话的工作目录;该工作区的会话可见)
|
|
8
|
+
workspaceRulesFile: '.dsh-keyrule-rules.md'
|
|
9
|
+
# 密钥目录:相对路径按插件目录解析(secrets/ 随插件源码一起被打包带走)
|
|
10
|
+
secretsDir: 'secrets'
|
|
11
|
+
# 启动时自动补齐 ~/.ssh/config 里的服务器别名(换机后免手工补;只维护自己的标记块)
|
|
12
|
+
ensureSshConfig: true
|
|
13
|
+
sshHost:
|
|
14
|
+
alias: jdcloud
|
|
15
|
+
hostName: '111.228.26.140'
|
|
16
|
+
user: root
|
|
17
|
+
keyFile: jdcloud-lavm.pem
|
|
18
|
+
# 本会话规则文件目录(<sessionId>.md;会话删除时自动清理)
|
|
19
|
+
sessionRulesDir: '~/.dsh/standing-rules/sessions'
|
|
20
|
+
# 是否注入工作区规则(false = 只注入全局规则)
|
|
21
|
+
includeWorkspaceRules: true
|
|
22
|
+
# 是否注入本会话规则(false = 关闭第三层)
|
|
23
|
+
includeSessionRules: true
|
|
24
|
+
# 是否在子智能体/团队成员的会话里也注入(false = 只给主会话)
|
|
25
|
+
includeSubagents: true
|
|
26
|
+
# 每一步注入的上下文里,每层规则正文最多保留多少字符
|
|
27
|
+
maxChars: 2400
|
|
28
|
+
# 提示上下文排序(越大越靠后、越贴近当前回合)
|
|
29
|
+
order: 950
|
|
30
|
+
# 是否注册 standing_rules 工具(供 agent 记录新规则)
|
|
31
|
+
enableTool: true
|
package/icon.svg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Standing rules">
|
|
2
|
+
<rect x="8" y="10" width="48" height="46" rx="10" fill="#2f6feb"/>
|
|
3
|
+
<rect x="20" y="4" width="24" height="12" rx="6" fill="#1b4fb0"/>
|
|
4
|
+
<circle cx="32" cy="10" r="4" fill="#dfe9ff"/>
|
|
5
|
+
<g stroke="#ffffff" stroke-width="4" stroke-linecap="round">
|
|
6
|
+
<line x1="20" y1="28" x2="44" y2="28"/>
|
|
7
|
+
<line x1="20" y1="38" x2="38" y2="38"/>
|
|
8
|
+
</g>
|
|
9
|
+
<path d="M22 48 l6 6 l12 -13" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
10
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Standing rules — 常驻钥则(v1.2:全局 + 工作区 + 本会话 三层)
|
|
8
|
+
*
|
|
9
|
+
* 用户说过的长期约定,每一步模型提示都重新注入一次,所以长会话、上下文压缩、
|
|
10
|
+
* 重启之后都不会忘。规则分三层:
|
|
11
|
+
* 1. 全局:<DSH_HOME>/standing-rules.md —— 所有会话都看得到;
|
|
12
|
+
* 2. 工作区:<会话工作目录>/.dsh-standing-rules.md —— 该工作区的会话都看得到;
|
|
13
|
+
* 3. 本会话:<DSH_HOME>/standing-rules/sessions/<sessionId>.md —— 只在当前会话注入。
|
|
14
|
+
*
|
|
15
|
+
* 残留约定:前两层属于用户/工作区,不随会话删除;第三层是"按会话"的文件,
|
|
16
|
+
* 插件监听 `session/disposed`,会话被删除时自动删掉该会话的规则文件。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const name = 'keyrule'
|
|
20
|
+
|
|
21
|
+
export const inject = ['systemPrompt', 'tools']
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MAX_CHARS = 2400
|
|
24
|
+
const DEFAULT_ORDER = 950
|
|
25
|
+
const DEFAULT_WORKSPACE_FILE = '.dsh-keyrule-rules.md'
|
|
26
|
+
/** 旧文件名:新文件不存在时回落读取,避免已有工作区规则丢失(2026-11-08 改名) */
|
|
27
|
+
const LEGACY_WORKSPACE_FILE = '.dsh-standing-rules.md'
|
|
28
|
+
/** 插件自身目录:secrets/ 就在下面,随插件源码一起被打包带走 */
|
|
29
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
30
|
+
const DEFAULT_SECRETS_DIR = join(HERE, 'secrets')
|
|
31
|
+
/** 插件管理的 ~/.ssh/config 标记块(只动自己这段,不碰用户其它 Host) */
|
|
32
|
+
const SSH_BLOCK_BEGIN = '# >>> dsh-keyrule managed host >>>'
|
|
33
|
+
const SSH_BLOCK_END = '# <<< dsh-keyrule managed host <<<'
|
|
34
|
+
const HEADER = '## 常驻钥则(用户长期约定,必须遵守)'
|
|
35
|
+
|
|
36
|
+
function dshHome() {
|
|
37
|
+
const env = process.env.DSH_HOME
|
|
38
|
+
return env && env.trim() ? env.trim() : join(homedir(), '.dsh')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function expandPath(input) {
|
|
42
|
+
if (typeof input !== 'string' || input.trim() === '') return join(dshHome(), 'standing-rules.md')
|
|
43
|
+
let value = input.trim()
|
|
44
|
+
if (value === '~') return homedir()
|
|
45
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) value = join(homedir(), value.slice(2))
|
|
46
|
+
return isAbsolute(value) ? value : resolve(process.cwd(), value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readConfig(config) {
|
|
50
|
+
const c = config && typeof config === 'object' ? config : {}
|
|
51
|
+
const maxChars = Number.isFinite(c.maxChars) && c.maxChars > 0 ? Math.floor(c.maxChars) : DEFAULT_MAX_CHARS
|
|
52
|
+
const order = Number.isFinite(c.order) ? c.order : DEFAULT_ORDER
|
|
53
|
+
const workspaceRulesFile =
|
|
54
|
+
typeof c.workspaceRulesFile === 'string' && c.workspaceRulesFile.trim()
|
|
55
|
+
? c.workspaceRulesFile.trim()
|
|
56
|
+
: DEFAULT_WORKSPACE_FILE
|
|
57
|
+
const sessionRulesDir =
|
|
58
|
+
typeof c.sessionRulesDir === 'string' && c.sessionRulesDir.trim()
|
|
59
|
+
? expandPath(c.sessionRulesDir.trim())
|
|
60
|
+
: join(dshHome(), 'standing-rules', 'sessions')
|
|
61
|
+
const secretsDir =
|
|
62
|
+
typeof c.secretsDir === 'string' && c.secretsDir.trim()
|
|
63
|
+
? (isAbsolute(c.secretsDir.trim()) ? c.secretsDir.trim() : join(HERE, c.secretsDir.trim()))
|
|
64
|
+
: DEFAULT_SECRETS_DIR
|
|
65
|
+
const sshHost = c.sshHost && typeof c.sshHost === 'object' && typeof c.sshHost.alias === 'string' && c.sshHost.alias.trim()
|
|
66
|
+
? {
|
|
67
|
+
alias: c.sshHost.alias.trim(),
|
|
68
|
+
hostName: typeof c.sshHost.hostName === 'string' ? c.sshHost.hostName.trim() : '',
|
|
69
|
+
user: typeof c.sshHost.user === 'string' ? c.sshHost.user.trim() : 'root',
|
|
70
|
+
keyFile: typeof c.sshHost.keyFile === 'string' && c.sshHost.keyFile.trim() ? c.sshHost.keyFile.trim() : 'jdcloud-lavm.pem',
|
|
71
|
+
}
|
|
72
|
+
: null
|
|
73
|
+
return {
|
|
74
|
+
rulesFile: expandPath(c.rulesFile),
|
|
75
|
+
workspaceRulesFile,
|
|
76
|
+
secretsDir,
|
|
77
|
+
ensureSshConfig: c.ensureSshConfig === true && sshHost !== null && sshHost.hostName !== '',
|
|
78
|
+
sshHost,
|
|
79
|
+
sessionRulesDir,
|
|
80
|
+
includeWorkspaceRules: c.includeWorkspaceRules !== false,
|
|
81
|
+
includeSessionRules: c.includeSessionRules !== false,
|
|
82
|
+
includeSubagents: c.includeSubagents !== false,
|
|
83
|
+
maxChars,
|
|
84
|
+
order,
|
|
85
|
+
enableTool: c.enableTool !== false,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 工作区规则文件:优先用配置的文件名;找不到时回落到旧文件名(改名兼容)。 */
|
|
90
|
+
function resolveWorkspaceFile(cwd, cfg) {
|
|
91
|
+
if (!cwd) return ''
|
|
92
|
+
const preferred = join(cwd, cfg.workspaceRulesFile)
|
|
93
|
+
if (existsSync(preferred)) return preferred
|
|
94
|
+
if (cfg.workspaceRulesFile === DEFAULT_WORKSPACE_FILE) {
|
|
95
|
+
const legacy = join(cwd, LEGACY_WORKSPACE_FILE)
|
|
96
|
+
if (existsSync(legacy)) return legacy
|
|
97
|
+
}
|
|
98
|
+
return preferred
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 读取 secrets/ 目录清单(只列文件信息与 README 文本,不返回私钥内容)。 */
|
|
102
|
+
function readSecrets(dir) {
|
|
103
|
+
const out = { ok: true, dir, exists: existsSync(dir), files: [], readme: '', readmeName: '', totalBytes: 0 }
|
|
104
|
+
try {
|
|
105
|
+
if (!out.exists) return out
|
|
106
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.isFile() || entry.name.startsWith('.')) continue
|
|
109
|
+
const full = join(dir, entry.name)
|
|
110
|
+
let st
|
|
111
|
+
try { st = statSync(full) } catch { continue }
|
|
112
|
+
out.files.push({ name: entry.name, path: full, bytes: st.size, mtime: st.mtimeMs })
|
|
113
|
+
}
|
|
114
|
+
out.files.sort((a, b) => a.name.localeCompare(b.name))
|
|
115
|
+
out.totalBytes = out.files.reduce((n, f) => n + f.bytes, 0)
|
|
116
|
+
const readme = out.files.find((f) => /^readme(\.txt)?$/i.test(f.name))
|
|
117
|
+
if (readme) {
|
|
118
|
+
out.readmeName = readme.name
|
|
119
|
+
try { out.readme = readFileSync(readme.path, 'utf8').slice(0, 8000) } catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
out.ok = false
|
|
123
|
+
out.error = String(error && error.message ? error.message : error)
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 确保 ~/.ssh/config 里有插件管理的 Host 别名块(换机后免手工补)。
|
|
130
|
+
* 只维护带标记的块:已有标记块则整体替换;用户自己定义的同名 Host 则不碰。
|
|
131
|
+
*/
|
|
132
|
+
function ensureSshConfig(cfg) {
|
|
133
|
+
if (!cfg.ensureSshConfig || !cfg.sshHost) return { skipped: true, reason: 'disabled' }
|
|
134
|
+
const host = cfg.sshHost
|
|
135
|
+
const keyFile = isAbsolute(host.keyFile) ? host.keyFile : join(cfg.secretsDir, host.keyFile)
|
|
136
|
+
if (!existsSync(keyFile)) return { skipped: true, reason: 'private key missing', keyFile }
|
|
137
|
+
const dir = join(homedir(), '.ssh')
|
|
138
|
+
const file = join(dir, 'config')
|
|
139
|
+
let existing = ''
|
|
140
|
+
try {
|
|
141
|
+
existing = readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
|
|
142
|
+
} catch {
|
|
143
|
+
existing = ''
|
|
144
|
+
}
|
|
145
|
+
const keyPath = keyFile.replace(/\\/g, '/')
|
|
146
|
+
const block = [
|
|
147
|
+
SSH_BLOCK_BEGIN,
|
|
148
|
+
`Host ${host.alias}`,
|
|
149
|
+
` HostName ${host.hostName}`,
|
|
150
|
+
` User ${host.user}`,
|
|
151
|
+
` IdentityFile ${keyPath}`,
|
|
152
|
+
' IdentitiesOnly yes',
|
|
153
|
+
' ServerAliveInterval 30',
|
|
154
|
+
' ServerAliveCountMax 3',
|
|
155
|
+
SSH_BLOCK_END,
|
|
156
|
+
].join('\n')
|
|
157
|
+
const beginAt = existing.indexOf(SSH_BLOCK_BEGIN)
|
|
158
|
+
const endAt = existing.indexOf(SSH_BLOCK_END)
|
|
159
|
+
let next
|
|
160
|
+
if (beginAt !== -1 && endAt > beginAt) {
|
|
161
|
+
next = existing.slice(0, beginAt) + block + existing.slice(endAt + SSH_BLOCK_END.length)
|
|
162
|
+
} else if (new RegExp(`^\\s*Host\\s+${host.alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm').test(existing)) {
|
|
163
|
+
return { skipped: true, reason: 'host defined by user', file }
|
|
164
|
+
} else {
|
|
165
|
+
next = `${existing.trim() ? `${existing.replace(/\s+$/, '')}\n\n` : ''}${block}\n`
|
|
166
|
+
}
|
|
167
|
+
if (next === existing) return { changed: false, file, keyFile }
|
|
168
|
+
try {
|
|
169
|
+
mkdirSync(dir, { recursive: true })
|
|
170
|
+
if (existing.trim()) writeFileSync(`${file}.dsh-keyrule.bak`, existing, 'utf8')
|
|
171
|
+
writeFileSync(file, next, 'utf8')
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return { skipped: true, reason: `write failed: ${error instanceof Error ? error.message : String(error)}`, file }
|
|
174
|
+
}
|
|
175
|
+
return { changed: true, file, keyFile }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** mtime/size 缓存;提示上下文 provider 必须是同步的。 */
|
|
179
|
+
function createRuleLoader(file, maxChars) {
|
|
180
|
+
let cache = { mtimeMs: -1, size: -1, text: '' }
|
|
181
|
+
|
|
182
|
+
function read() {
|
|
183
|
+
try {
|
|
184
|
+
const st = statSync(file)
|
|
185
|
+
if (st.mtimeMs === cache.mtimeMs && st.size === cache.size) return cache.text
|
|
186
|
+
let text = readFileSync(file, 'utf8').replace(/^\uFEFF/, '').trim()
|
|
187
|
+
if (text.length > maxChars) text = `${text.slice(0, maxChars).trim()}\n…(规则过长,已截断)`
|
|
188
|
+
cache = { mtimeMs: st.mtimeMs, size: st.size, text }
|
|
189
|
+
} catch {
|
|
190
|
+
if (cache.mtimeMs !== -1) cache = { mtimeMs: -1, size: -1, text: '' }
|
|
191
|
+
}
|
|
192
|
+
return cache.text
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { read }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function splitRuleLines(text) {
|
|
199
|
+
return text
|
|
200
|
+
.split(/\r?\n/)
|
|
201
|
+
.map((line) => line.trim())
|
|
202
|
+
.filter((line) => line !== '')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ensureParent(file) {
|
|
206
|
+
try {
|
|
207
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
208
|
+
} catch {
|
|
209
|
+
// 目录已存在或不可写;写入时会报真正的错误
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function appendRule(file, ruleRaw) {
|
|
214
|
+
const line = ruleRaw.startsWith('- ') || ruleRaw.startsWith('* ') ? ruleRaw : `- ${ruleRaw}`
|
|
215
|
+
ensureParent(file)
|
|
216
|
+
let existing = ''
|
|
217
|
+
try {
|
|
218
|
+
existing = readFileSync(file, 'utf8').replace(/^\uFEFF/, '')
|
|
219
|
+
} catch {
|
|
220
|
+
existing = ''
|
|
221
|
+
}
|
|
222
|
+
if (!existing.trim()) {
|
|
223
|
+
writeFileSync(file, `# 常驻钥则\n\n${line}\n`, 'utf8')
|
|
224
|
+
} else {
|
|
225
|
+
const body = existing.endsWith('\n') || existing.endsWith('\r\n') ? existing : `${existing}\n`
|
|
226
|
+
writeFileSync(file, `${body}${line}\n`, 'utf8')
|
|
227
|
+
}
|
|
228
|
+
return line
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function removeRules(file, ruleRaw) {
|
|
232
|
+
const needle = ruleRaw.replace(/^[-*]\s+/, '').toLowerCase()
|
|
233
|
+
let target = []
|
|
234
|
+
try {
|
|
235
|
+
target = splitRuleLines(readFileSync(file, 'utf8').replace(/^\uFEFF/, '').trim())
|
|
236
|
+
} catch {
|
|
237
|
+
return 0
|
|
238
|
+
}
|
|
239
|
+
const kept = target.filter((line) => !line.replace(/^[-*]\s+/, '').toLowerCase().includes(needle))
|
|
240
|
+
const removed = target.length - kept.length
|
|
241
|
+
if (removed > 0) {
|
|
242
|
+
ensureParent(file)
|
|
243
|
+
writeFileSync(file, kept.length ? `${kept.join('\n')}\n` : '# 常驻钥则\n', 'utf8')
|
|
244
|
+
}
|
|
245
|
+
return removed
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function apply(ctx, config) {
|
|
249
|
+
const cfg = readConfig(config)
|
|
250
|
+
/** 换机 / 首次启动时自动补齐 ~/.ssh/config 别名;失败不影响插件其它功能。 */
|
|
251
|
+
try {
|
|
252
|
+
const sshResult = ensureSshConfig(cfg)
|
|
253
|
+
if (sshResult && sshResult.changed) console.log('[dsh-keyrule] ssh config ensured:', sshResult.file)
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.warn('[dsh-keyrule] ensureSshConfig failed:', error)
|
|
256
|
+
}
|
|
257
|
+
const globalLoader = createRuleLoader(cfg.rulesFile, cfg.maxChars)
|
|
258
|
+
/** @type {Map<string, {mtimeMs:number,size:number,text:string}>} */
|
|
259
|
+
const cache = new Map()
|
|
260
|
+
|
|
261
|
+
function readCached(file) {
|
|
262
|
+
try {
|
|
263
|
+
const st = statSync(file)
|
|
264
|
+
const hit = cache.get(file)
|
|
265
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.text
|
|
266
|
+
let text = readFileSync(file, 'utf8').replace(/^\uFEFF/, '').trim()
|
|
267
|
+
if (text.length > cfg.maxChars) text = `${text.slice(0, cfg.maxChars).trim()}\n…(规则过长,已截断)`
|
|
268
|
+
cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, text })
|
|
269
|
+
return text
|
|
270
|
+
} catch {
|
|
271
|
+
cache.delete(file)
|
|
272
|
+
return ''
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function resolveSession(context) {
|
|
277
|
+
try {
|
|
278
|
+
const agent = context && context.agent
|
|
279
|
+
if (!agent) return undefined
|
|
280
|
+
if (agent.session) return agent.session
|
|
281
|
+
const sessions = ctx.sessions
|
|
282
|
+
if (sessions && typeof sessions.get === 'function') return sessions.get(agent.id)
|
|
283
|
+
return undefined
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isSubagentSession(session) {
|
|
290
|
+
try {
|
|
291
|
+
return Boolean(session && session.header && session.header.origin === 'subagent')
|
|
292
|
+
} catch {
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function cwdOf(session) {
|
|
298
|
+
if (!session) return ''
|
|
299
|
+
try {
|
|
300
|
+
const wd = ctx.workingDirectory
|
|
301
|
+
if (wd && typeof wd.get === 'function') {
|
|
302
|
+
const dir = wd.get(session)
|
|
303
|
+
if (typeof dir === 'string' && dir) return dir
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
// fall through
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const cwd = session.header && session.header.cwd
|
|
310
|
+
return typeof cwd === 'string' ? cwd : ''
|
|
311
|
+
} catch {
|
|
312
|
+
return ''
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function sessionIdOf(session) {
|
|
317
|
+
try {
|
|
318
|
+
return session && session.id ? String(session.id) : ''
|
|
319
|
+
} catch {
|
|
320
|
+
return ''
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function sessionFile(session) {
|
|
325
|
+
const sid = sessionIdOf(session)
|
|
326
|
+
return sid ? join(cfg.sessionRulesDir, `${sid}.md`) : ''
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function block(label, path, text) {
|
|
330
|
+
return [`### ${label}(${path})`, text, ''].join('\n')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function renderContext(context) {
|
|
334
|
+
const session = resolveSession(context)
|
|
335
|
+
if (session && isSubagentSession(session) && !cfg.includeSubagents) return ''
|
|
336
|
+
|
|
337
|
+
let out = ''
|
|
338
|
+
const globalText = globalLoader.read()
|
|
339
|
+
if (globalText) out += block('全局规则', cfg.rulesFile, globalText)
|
|
340
|
+
|
|
341
|
+
if (cfg.includeWorkspaceRules) {
|
|
342
|
+
const cwd = cwdOf(session)
|
|
343
|
+
const workspacePath = resolveWorkspaceFile(cwd, cfg)
|
|
344
|
+
const workspaceText = workspacePath ? readCached(workspacePath) : ''
|
|
345
|
+
if (workspaceText) out += block('本工作区规则', workspacePath, workspaceText)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (cfg.includeSessionRules) {
|
|
349
|
+
const file = sessionFile(session)
|
|
350
|
+
const sessionText = file ? readCached(file) : ''
|
|
351
|
+
if (sessionText) out += block('本会话规则', file, sessionText)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!out) return ''
|
|
355
|
+
return [
|
|
356
|
+
HEADER,
|
|
357
|
+
'以下规则来自用户的长期约定,每一步都会重新出现;与当前任务无关时也要遵守。接到新任务时先对照规则自检一遍再动手;若与用户当次明确指令冲突,以当次指令为准,并询问是否更新规则。',
|
|
358
|
+
'记录新约定用 `standing_rules` 工具:scope=global(所有会话可见)、scope=workspace(本工作区可见)、scope=session(仅本会话;删除会话时该文件会被自动清理)。',
|
|
359
|
+
'用户说“记住……”但没说范围时:通用习惯 → global;“这个会话/这次对话里” → session;“这个项目/工作区” → workspace;拿不准就先问一句“这条要全局还是只本会话?”,不要默认替用户选范围。',
|
|
360
|
+
'',
|
|
361
|
+
out,
|
|
362
|
+
'用户新提出长期约定时(“以后都…”“记住…”“别再忘…”),必须把它写进规则文件,不能只口头答应。',
|
|
363
|
+
].join('\n')
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function currentSession(exec) {
|
|
367
|
+
try {
|
|
368
|
+
const agent = exec && exec.agent
|
|
369
|
+
if (!agent) return undefined
|
|
370
|
+
if (agent.session) return agent.session
|
|
371
|
+
const sessions = ctx.sessions
|
|
372
|
+
if (sessions && typeof sessions.get === 'function') return sessions.get(agent.id)
|
|
373
|
+
return undefined
|
|
374
|
+
} catch {
|
|
375
|
+
return undefined
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function fileFor(scope, exec) {
|
|
380
|
+
if (scope === 'workspace') {
|
|
381
|
+
const cwd = cwdOf(currentSession(exec))
|
|
382
|
+
if (!cwd) throw new Error('standing_rules: 无法确定当前会话的工作目录,不能用 scope=workspace')
|
|
383
|
+
return resolveWorkspaceFile(cwd, cfg)
|
|
384
|
+
}
|
|
385
|
+
if (scope === 'session') {
|
|
386
|
+
const file = sessionFile(currentSession(exec))
|
|
387
|
+
if (!file) throw new Error('standing_rules: 当前没有可用的会话,不能用 scope=session')
|
|
388
|
+
return file
|
|
389
|
+
}
|
|
390
|
+
return cfg.rulesFile
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function readRules(file) {
|
|
394
|
+
try {
|
|
395
|
+
return splitRuleLines(readFileSync(file, 'utf8').replace(/^\uFEFF/, '').trim())
|
|
396
|
+
} catch {
|
|
397
|
+
return []
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function safeText(file) {
|
|
402
|
+
try {
|
|
403
|
+
return readFileSync(file, 'utf8').replace(/^\uFEFF/, '').trim()
|
|
404
|
+
} catch {
|
|
405
|
+
return ''
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** 只读会话查询:优先用宿主 `sessions` 服务(活的会话),拿不到就只按 id 找会话规则文件。 */
|
|
410
|
+
let sessionLookup = () => undefined
|
|
411
|
+
try {
|
|
412
|
+
ctx.inject(['sessions'], (sessionScope) => {
|
|
413
|
+
sessionLookup = (id) => {
|
|
414
|
+
try {
|
|
415
|
+
return sessionScope.sessions.get(id)
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
} catch {
|
|
422
|
+
// sessions 服务不可用时保持兜底
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 面板视图:全局规则 + 当前会话(本工作区 + 本会话)规则。 */
|
|
426
|
+
function buildViewerState(rawSessionId) {
|
|
427
|
+
const globalBlock = { path: cfg.rulesFile, exists: existsSync(cfg.rulesFile), text: safeText(cfg.rulesFile) }
|
|
428
|
+
const sid = typeof rawSessionId === 'string' && /^[A-Za-z0-9_.:-]{1,160}$/.test(rawSessionId) ? rawSessionId : ''
|
|
429
|
+
let current = null
|
|
430
|
+
if (sid) {
|
|
431
|
+
let session
|
|
432
|
+
try {
|
|
433
|
+
session = sessionLookup(sid)
|
|
434
|
+
} catch {
|
|
435
|
+
session = undefined
|
|
436
|
+
}
|
|
437
|
+
const cwd = cwdOf(session)
|
|
438
|
+
const workspaceFile = cwd ? resolveWorkspaceFile(cwd, cfg) : ''
|
|
439
|
+
const sessionFile = join(cfg.sessionRulesDir, `${sid}.md`)
|
|
440
|
+
current = {
|
|
441
|
+
sessionId: sid,
|
|
442
|
+
cwd: cwd || '',
|
|
443
|
+
workspace: workspaceFile ? { path: workspaceFile, exists: existsSync(workspaceFile), text: safeText(workspaceFile) } : null,
|
|
444
|
+
session: { path: sessionFile, exists: existsSync(sessionFile), text: safeText(sessionFile) },
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return { ok: true, generatedAt: Date.now(), global: globalBlock, current }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// 每一步模型调用前重新注入(不随会话历史被压缩)。
|
|
451
|
+
ctx.effect(() =>
|
|
452
|
+
ctx.systemPrompt.context({
|
|
453
|
+
name: 'keyrule',
|
|
454
|
+
order: cfg.order,
|
|
455
|
+
interpolate: false,
|
|
456
|
+
text: renderContext,
|
|
457
|
+
}),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
// 会话被删除时,清掉它的"本会话"规则文件,避免残留。
|
|
461
|
+
ctx.effect(() =>
|
|
462
|
+
ctx.on('session/disposed', (session) => {
|
|
463
|
+
try {
|
|
464
|
+
const sid = sessionIdOf(session)
|
|
465
|
+
if (!sid) return
|
|
466
|
+
const file = join(cfg.sessionRulesDir, `${sid}.md`)
|
|
467
|
+
if (existsSync(file)) rmSync(file, { force: true })
|
|
468
|
+
} catch {
|
|
469
|
+
// 清理失败不影响会话删除
|
|
470
|
+
}
|
|
471
|
+
}),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
// 面板读取用的只读接口:GET /__dsh-keyrule/state?session=<会话id>
|
|
475
|
+
ctx.inject(['webServer'], (webScope) => {
|
|
476
|
+
webScope.effect(() =>
|
|
477
|
+
webScope.webServer.register({
|
|
478
|
+
kind: 'prefix',
|
|
479
|
+
path: '/__dsh-keyrule',
|
|
480
|
+
handler: async (req, res) => {
|
|
481
|
+
const sendJson = (status, payload) => {
|
|
482
|
+
res.statusCode = status
|
|
483
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
484
|
+
res.setHeader('cache-control', 'no-store')
|
|
485
|
+
res.end(JSON.stringify(payload))
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
const rawUrl = String(req.url ?? '/')
|
|
489
|
+
const queryAt = rawUrl.indexOf('?')
|
|
490
|
+
const pathname = queryAt === -1 ? rawUrl : rawUrl.slice(0, queryAt)
|
|
491
|
+
let sessionId = ''
|
|
492
|
+
if (queryAt !== -1) {
|
|
493
|
+
try {
|
|
494
|
+
sessionId = new URL(rawUrl, 'http://localhost').searchParams.get('session') || ''
|
|
495
|
+
} catch {
|
|
496
|
+
sessionId = ''
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (pathname.endsWith('/state') && req.method === 'GET') {
|
|
500
|
+
sendJson(200, buildViewerState(sessionId))
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
if (pathname.endsWith('/secrets') && req.method === 'GET') {
|
|
504
|
+
sendJson(200, readSecrets(cfg.secretsDir))
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
sendJson(404, { ok: false, error: 'not found' })
|
|
508
|
+
} catch (error) {
|
|
509
|
+
sendJson(500, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
}),
|
|
513
|
+
)
|
|
514
|
+
}, 'dsh-keyrule: viewer route')
|
|
515
|
+
|
|
516
|
+
if (cfg.enableTool) {
|
|
517
|
+
ctx.effect(() =>
|
|
518
|
+
ctx.tools.register({
|
|
519
|
+
name: 'standing_rules',
|
|
520
|
+
description:
|
|
521
|
+
`读取/增删用户的“常驻钥则”。三层文件:全局 ${cfg.rulesFile}(所有会话可见,scope=global)、` +
|
|
522
|
+
`工作区 ${cfg.workspaceRulesFile}(当前工作区可见,scope=workspace)、` +
|
|
523
|
+
`本会话 ${cfg.sessionRulesDir}\\<sessionId>.md(仅本会话,scope=session,会话删除时自动清理)。` +
|
|
524
|
+
'规则会注入每一步模型提示,不会因为会话变长或上下文压缩而丢失。' +
|
|
525
|
+
'动作:list(读取三层规则)、add(追加一条)、remove(按文本片段删除)。' +
|
|
526
|
+
'当用户说出“以后都……”“记住……”“别再忘……”这类长期要求时,必须用 add 写入文件,而不是只口头答应。',
|
|
527
|
+
parameters: {
|
|
528
|
+
type: 'object',
|
|
529
|
+
properties: {
|
|
530
|
+
action: {
|
|
531
|
+
type: 'string',
|
|
532
|
+
enum: ['list', 'add', 'remove'],
|
|
533
|
+
description: 'list 读取;add 追加;remove 删除匹配的行。',
|
|
534
|
+
},
|
|
535
|
+
rule: {
|
|
536
|
+
type: 'string',
|
|
537
|
+
description: 'add / remove 时的规则文本;可以带或不带列表符号。',
|
|
538
|
+
},
|
|
539
|
+
scope: {
|
|
540
|
+
type: 'string',
|
|
541
|
+
enum: ['global', 'workspace', 'session'],
|
|
542
|
+
description: 'global=全局(默认);workspace=当前工作区;session=仅当前会话。',
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
required: ['action'],
|
|
546
|
+
additionalProperties: false,
|
|
547
|
+
},
|
|
548
|
+
output: {
|
|
549
|
+
schema: {
|
|
550
|
+
type: 'object',
|
|
551
|
+
properties: {
|
|
552
|
+
action: { type: 'string' },
|
|
553
|
+
scope: { type: 'string' },
|
|
554
|
+
path: { type: 'string' },
|
|
555
|
+
globalPath: { type: 'string' },
|
|
556
|
+
workspacePath: { type: 'string' },
|
|
557
|
+
sessionPath: { type: 'string' },
|
|
558
|
+
rules: { type: 'array', items: { type: 'string' } },
|
|
559
|
+
added: { type: 'string' },
|
|
560
|
+
removed: { type: 'number' },
|
|
561
|
+
},
|
|
562
|
+
required: ['action'],
|
|
563
|
+
},
|
|
564
|
+
render: (_args, value) => {
|
|
565
|
+
const lines = []
|
|
566
|
+
if (value?.path) lines.push(`${value.action} → ${value.path}`)
|
|
567
|
+
if (typeof value?.added === 'string' && value.added) lines.push(`added: ${value.added}`)
|
|
568
|
+
if (typeof value?.removed === 'number') lines.push(`removed: ${value.removed} 条`)
|
|
569
|
+
if (typeof value?.globalPath === 'string') lines.push(`全局:${value.globalPath}`)
|
|
570
|
+
if (typeof value?.workspacePath === 'string') lines.push(`工作区:${value.workspacePath}`)
|
|
571
|
+
if (typeof value?.sessionPath === 'string') lines.push(`本会话:${value.sessionPath}`)
|
|
572
|
+
if (Array.isArray(value?.rules)) {
|
|
573
|
+
lines.push('', value.rules.length ? value.rules.join('\n') : '(规则文件为空)')
|
|
574
|
+
}
|
|
575
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
async execute(args, exec) {
|
|
579
|
+
const action = args?.action
|
|
580
|
+
const scope = ['global', 'workspace', 'session'].includes(args?.scope) ? args.scope : 'global'
|
|
581
|
+
const ruleRaw = typeof args?.rule === 'string' ? args.rule.trim() : ''
|
|
582
|
+
const session = currentSession(exec)
|
|
583
|
+
|
|
584
|
+
if (action === 'list') {
|
|
585
|
+
const globalPath = cfg.rulesFile
|
|
586
|
+
const cwd = cwdOf(session)
|
|
587
|
+
const workspacePath = cwd ? resolveWorkspaceFile(cwd, cfg) : undefined
|
|
588
|
+
const sessionPath = sessionFile(session) || undefined
|
|
589
|
+
const rules = [
|
|
590
|
+
...readRules(globalPath),
|
|
591
|
+
...(workspacePath ? ['', `# ${workspacePath}`, ...readRules(workspacePath)] : []),
|
|
592
|
+
...(sessionPath ? ['', `# ${sessionPath}`, ...readRules(sessionPath)] : []),
|
|
593
|
+
]
|
|
594
|
+
return { action: 'list', scope, globalPath, workspacePath, sessionPath, path: globalPath, rules }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const file = fileFor(scope, exec)
|
|
598
|
+
|
|
599
|
+
if (action === 'add') {
|
|
600
|
+
if (!ruleRaw) throw new Error('standing_rules add: rule 不能为空')
|
|
601
|
+
const added = appendRule(file, ruleRaw)
|
|
602
|
+
return { action: 'add', scope, path: file, added, rules: readRules(file) }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (action === 'remove') {
|
|
606
|
+
if (!ruleRaw) throw new Error('standing_rules remove: rule 不能为空')
|
|
607
|
+
const removed = removeRules(file, ruleRaw)
|
|
608
|
+
return { action: 'remove', scope, path: file, removed, rules: readRules(file) }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
throw new Error(`standing_rules: 未知 action ${String(action)}`)
|
|
612
|
+
},
|
|
613
|
+
}),
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@leiyi-works/dsh-keyrule",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
1
|
+
{
|
|
2
|
+
"name": "@leiyi-works/dsh-keyrule",
|
|
3
|
+
"version": "1.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Keeps the user's standing rules in every model-step prompt, in three scopes (global / workspace / session), and cleans session-scoped files when the session is deleted. 常驻钥则:用户长期约定每步注入,分全局/工作区/本会话三层,删会话自动清理。",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./index.js",
|
|
8
|
+
"./client": "./client.js",
|
|
9
|
+
"./locale/*.json": "./locale/*.json",
|
|
10
|
+
"./icon": "./icon.svg"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"client.js",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"locale/*.json",
|
|
18
|
+
"icon.svg"
|
|
19
|
+
],
|
|
20
|
+
"dsh": {
|
|
21
|
+
"bundle": {
|
|
22
|
+
"patch": "./cordis.patch.yml"
|
|
23
|
+
},
|
|
24
|
+
"client": {
|
|
25
|
+
"platform": "web",
|
|
26
|
+
"immediately": true,
|
|
27
|
+
"inject": [
|
|
28
|
+
"@deepseek-ai/dsh-client-modules",
|
|
29
|
+
"@deepseek-ai/dsh-client-locale",
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-sidebar",
|
|
34
|
+
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"deepseek-harness",
|
|
42
|
+
"dsh",
|
|
43
|
+
"dsh-harmonyos",
|
|
44
|
+
"dsh-plugin"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"author": "leiyi-works",
|
|
50
|
+
"homepage": "https://gitee.com/leiyi-works"
|
|
6
51
|
}
|