dsh-plugin-tlrp 0.0.0-stage → 0.4.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.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * tlrp:设置写入通道往返验证(跑真实 dsh-settings 代码,不是复刻)。
3
+ *
4
+ * 证明三件事:
5
+ * 1. 插件的 Config 被 dsh-settings 投影成一份**非空**表单(describe() 有 tlrp 行 + schema 引用表非空);
6
+ * 2. 九个 volatile 字段逐个 `mutate('tlrp', [{op:'set', path:[field], value}])` 往返成功并回读一致;
7
+ * 3. 非 volatile 字段(timeZone)被宿主拒绝 —— 「只写 volatile」是宿主强制的边界。
8
+ *
9
+ * 运行前提:`@deepseek-ai/dsh-settings`、`@deepseek-ai/schemastery`、`dsh-plugin-tlrp` 可解析。
10
+ *
11
+ * cd ~/.dsh/profiles/desktop
12
+ * node D:/Code/my-dsh-plugins/packages/tlrp/scripts/mutate-check.mjs
13
+ */
14
+
15
+ import { createRequire } from 'node:module'
16
+
17
+ const require = createRequire(process.env.TLRP_PROFILE_BASE || `${process.cwd()}/package.json`)
18
+
19
+ const { default: SettingsForms } = require('@deepseek-ai/dsh-settings')
20
+ const plugin = require('dsh-plugin-tlrp')
21
+
22
+ if (!plugin.Config) {
23
+ console.error('FAIL: dsh-plugin-tlrp 没有导出 Config(schemastery 未能解析)')
24
+ process.exit(2)
25
+ }
26
+
27
+ const schema = plugin.Config
28
+ const parsed = schema({ mode: 'auto' })
29
+
30
+ /** 极简 configEditor 替身:只实现 dsh-settings 真正调用到的成员。 */
31
+ const store = { raw: {} }
32
+ // 真实宿主里 bundle 行被 include 包裹,被服务的命名空间是 include:tlrp
33
+ const NS = 'include:tlrp'
34
+ const entry = {
35
+ id: NS,
36
+ options: { id: NS, config: store.raw },
37
+ fiber: {
38
+ uid: 'uid-tlrp-integration',
39
+ state: 2,
40
+ runtime: { Config: schema },
41
+ config: parsed,
42
+ ctx: {},
43
+ },
44
+ }
45
+
46
+ const noop = () => {}
47
+ const fakeCtx = {
48
+ reflect: { provide: noop },
49
+ effect: (fn) => {
50
+ const dispose = typeof fn === 'function' ? fn() : undefined
51
+ return typeof dispose === 'function' ? dispose : noop
52
+ },
53
+ on: () => noop,
54
+ emit: noop,
55
+ root: { loader: { await: () => new Promise(() => {}) } },
56
+ logger: { error: console.error, warn: console.warn, info() {}, debug() {} },
57
+ fiber: {},
58
+ }
59
+
60
+ const configEditor = {
61
+ documentPath: '(integration)',
62
+ entries: () => [entry],
63
+ configuration: () => [{ entry, inherited: {}, override: store.raw }],
64
+ async edit(_entry, change) {
65
+ store.raw = await change(store.raw, {})
66
+ return store.raw
67
+ },
68
+ }
69
+
70
+ const forms = new SettingsForms({ ...fakeCtx, configEditor })
71
+
72
+ const failures = []
73
+ const check = (label, ok, detail) => {
74
+ if (ok) console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`)
75
+ else {
76
+ console.log(`FAIL ${label}${detail ? ` — ${detail}` : ''}`)
77
+ failures.push(label)
78
+ }
79
+ }
80
+
81
+ /* 1. 表单投影非空 */
82
+ const rows = forms.describe()
83
+ const row = rows.find((candidate) => candidate.ns === NS)
84
+ check('describe() 返回 tlrp 表单', row !== undefined, row === undefined ? `没有命名空间 ${NS}` : `ns=${row.ns} applies=${row.applies} autoGenerate=${row.autoGenerate}`)
85
+ const refCount = row?.schema && typeof row.schema === 'object' ? Object.keys(row.schema.refs ?? {}).length : 0
86
+ check('表单 schema 引用表非空', refCount > 0, `refs=${refCount}`)
87
+
88
+ /* 2. 九个 volatile 字段逐个往返 */
89
+ const cases = [
90
+ ['enabled', false],
91
+ ['mode', 'rp'],
92
+ ['activeCharacterId', 'sam_tactical'],
93
+ ['workModeKeywords', '端口|报错'],
94
+ ['blockShellToolsInRp', false],
95
+ ['enableStatusFooter', false],
96
+ ['customSceneOverride', '现实宇宙·星舰医疗舱外'],
97
+ ['characters', [{ id: 'firefly', name: '流萤(Firefly)' }]],
98
+ ['triggers', [{ id: 'oak-cake', keywords: ['橡木蛋糕卷'], injectText: '橡木蛋糕卷', priority: 100, enabled: true }]],
99
+ ]
100
+
101
+ for (const [field, value] of cases) {
102
+ try {
103
+ await forms.mutate(NS, [{ op: 'set', path: [field], value }])
104
+ const round = forms.describe().find((candidate) => candidate.ns === NS)
105
+ const readBack = round?.user?.[field]
106
+ const ok = JSON.stringify(readBack) === JSON.stringify(value)
107
+ check(`mutate 往返 ${field}`, ok, `写=${JSON.stringify(value).slice(0, 60)} 读=${JSON.stringify(readBack).slice(0, 60)}`)
108
+ } catch (error) {
109
+ check(`mutate 往返 ${field}`, false, error && error.message)
110
+ }
111
+ }
112
+
113
+ /* 3. 配置里写的 tlrp 不是被服务的 ns(宿主会包成 include:tlrp) */
114
+ let wrongNsRejected = false
115
+ try {
116
+ await forms.mutate('tlrp', [{ op: 'set', path: ['mode'], value: 'work' }])
117
+ } catch {
118
+ wrongNsRejected = true
119
+ }
120
+ check('未被服务的 ns(tlrp)被拒绝', wrongNsRejected, '所以插件必须解析出 include:tlrp 再写')
121
+
122
+ /* 4. 非 volatile 路径必须被宿主拒绝 */
123
+ let rejected = false
124
+ let message = ''
125
+ try {
126
+ await forms.mutate(NS, [{ op: 'set', path: ['timeZone'], value: 'UTC' }])
127
+ } catch (error) {
128
+ rejected = true
129
+ message = error && error.message
130
+ }
131
+ check('非 volatile 字段被拒绝', rejected, message || '意外成功')
132
+
133
+ /* 4. 批量写入(模拟一次 /char + /scene 连按) */
134
+ try {
135
+ await forms.mutate(NS, [
136
+ { op: 'set', path: ['activeCharacterId'], value: 'firefly' },
137
+ { op: 'set', path: ['customSceneOverride'], value: '匹诺康尼·筑梦边境天台' },
138
+ ])
139
+ const round = forms.describe().find((candidate) => candidate.ns === NS)
140
+ check(
141
+ '批量写入角色与情景',
142
+ round?.user?.activeCharacterId === 'firefly' && round?.user?.customSceneOverride === '匹诺康尼·筑梦边境天台',
143
+ JSON.stringify(round?.user ?? {}).slice(0, 160),
144
+ )
145
+ } catch (error) {
146
+ check('批量写入角色与情景', false, error && error.message)
147
+ }
148
+
149
+ /* 5. v0.2.0 工作室:完整角色对象与新触发器往返 */
150
+ const fullCharacter = {
151
+ id: 'tester',
152
+ name: '测试角色',
153
+ userTitle: '站长',
154
+ defaultScene: '测试场景',
155
+ identityLock: '不许自称 AI 或跑在端口上的程序',
156
+ corePersona: '第一行背景\n第二行性格',
157
+ appearanceAndSensory: '银灰色长发,指尖微凉',
158
+ actionPresets: '害羞时摩挲手套边缘',
159
+ speechStyle: '短句,带呼吸感',
160
+ dialogueExamples: '「来了。」',
161
+ statusFooterTemplate: '> **{label}**\n> - 情景:{scene}',
162
+ }
163
+ const fullTrigger = {
164
+ id: 'trigger-test',
165
+ characterId: 'tester',
166
+ keywords: ['测试词', '橡木'],
167
+ injectText: '注入文本',
168
+ priority: 42,
169
+ enabled: true,
170
+ }
171
+
172
+ try {
173
+ await forms.mutate(NS, [
174
+ { op: 'set', path: ['characters'], value: [fullCharacter] },
175
+ { op: 'set', path: ['triggers'], value: [fullTrigger] },
176
+ ])
177
+ const round = forms.describe().find((candidate) => candidate.ns === NS)
178
+ const storedCharacter = round?.user?.characters?.[0]
179
+ const storedTrigger = round?.user?.triggers?.[0]
180
+ check(
181
+ 'mutate 往返完整角色(11 字段)',
182
+ JSON.stringify(storedCharacter) === JSON.stringify(fullCharacter),
183
+ JSON.stringify(storedCharacter ?? null).slice(0, 120),
184
+ )
185
+ check(
186
+ 'mutate 往返带 characterId 的触发器',
187
+ JSON.stringify(storedTrigger) === JSON.stringify(fullTrigger),
188
+ JSON.stringify(storedTrigger ?? null).slice(0, 120),
189
+ )
190
+ } catch (error) {
191
+ check('mutate 往返完整角色(11 字段)', false, error && error.message)
192
+ }
193
+
194
+ /* 6. 写一个字段不许冲掉其它字段 */
195
+ try {
196
+ await forms.mutate(NS, [{ op: 'set', path: ['mode'], value: 'work' }])
197
+ const round = forms.describe().find((candidate) => candidate.ns === NS)
198
+ const keptCharacter = round?.user?.characters?.[0]?.id === 'tester'
199
+ const keptTrigger = round?.user?.triggers?.[0]?.id === 'trigger-test'
200
+ const keptMode = round?.user?.mode === 'work'
201
+ check(
202
+ '写 mode 不冲掉 characters / triggers',
203
+ keptCharacter && keptTrigger && keptMode,
204
+ `characters=${keptCharacter} triggers=${keptTrigger} mode=${keptMode}`,
205
+ )
206
+ } catch (error) {
207
+ check('写 mode 不冲掉 characters / triggers', false, error && error.message)
208
+ }
209
+
210
+ /* 7. v0.3.0:modes 数组往返(含自定义第 5 个形态)+ 任意形态 id 锁定 */
211
+ const fullMode = {
212
+ id: 'debug',
213
+ name: '排障态',
214
+ commandAliases: ['/debug', '【排障】'],
215
+ autoKeywords: '堆栈|崩溃|内存泄漏',
216
+ autoPriority: 99,
217
+ directivePrompt: '只输出根因与最小修复步骤。',
218
+ toolPolicy: 'block_all',
219
+ allowedTools: '',
220
+ injectCharacterPersona: false,
221
+ enableStatusFooter: false,
222
+ statusFooterOverride: '',
223
+ bindCharacterId: '',
224
+ isDefaultFallback: false,
225
+ }
226
+ const fallbackMode = {
227
+ id: 'rp',
228
+ name: '少女陪伴态',
229
+ commandAliases: ['/rp', '【流萤】'],
230
+ autoKeywords: '喜欢|想你|陪我',
231
+ autoPriority: 10,
232
+ directivePrompt: '',
233
+ toolPolicy: 'whitelist_only',
234
+ allowedTools: 'tlmemory, ask_user_question, schedule',
235
+ injectCharacterPersona: true,
236
+ enableStatusFooter: true,
237
+ statusFooterOverride: '',
238
+ bindCharacterId: 'firefly',
239
+ isDefaultFallback: true,
240
+ }
241
+
242
+ try {
243
+ await forms.mutate(NS, [{ op: 'set', path: ['modes'], value: [fallbackMode, fullMode] }])
244
+ const round = forms.describe().find((candidate) => candidate.ns === NS)
245
+ const storedModes = round?.user?.modes
246
+ check(
247
+ 'mutate 往返 modes 数组(含自定义第 5 类形态)',
248
+ JSON.stringify(storedModes) === JSON.stringify([fallbackMode, fullMode]),
249
+ JSON.stringify(storedModes ?? null).slice(0, 160),
250
+ )
251
+ check(
252
+ 'modes 往返保留 toolPolicy / 别名 / 兜底位',
253
+ storedModes?.[1]?.toolPolicy === 'block_all' &&
254
+ storedModes?.[1]?.commandAliases?.includes('【排障】') &&
255
+ storedModes?.[0]?.isDefaultFallback === true &&
256
+ storedModes?.[0]?.bindCharacterId === 'firefly',
257
+ JSON.stringify(storedModes?.[1] ?? null).slice(0, 120),
258
+ )
259
+ } catch (error) {
260
+ check('mutate 往返 modes 数组(含自定义第 5 类形态)', false, error && error.message)
261
+ }
262
+
263
+ try {
264
+ await forms.mutate(NS, [{ op: 'set', path: ['mode'], value: 'debug' }])
265
+ const round = forms.describe().find((candidate) => candidate.ns === NS)
266
+ check('mode 可锁定到自定义形态 id', round?.user?.mode === 'debug', String(round?.user?.mode))
267
+ check(
268
+ '写 mode 不冲掉 modes 数组',
269
+ Array.isArray(round?.user?.modes) && round.user.modes.length === 2 && round.user.modes[1]?.id === 'debug',
270
+ `modes=${Array.isArray(round?.user?.modes) ? round.user.modes.length : 'null'}`,
271
+ )
272
+ } catch (error) {
273
+ check('mode 可锁定到自定义形态 id', false, error && error.message)
274
+ }
275
+
276
+ console.log('')
277
+ console.log('最终 raw 配置:')
278
+ console.log(JSON.stringify(store.raw).slice(0, 400))
279
+ console.log('')
280
+ if (failures.length > 0) {
281
+ console.error(`RESULT: FAIL (${failures.length}) → ${failures.join(', ')}`)
282
+ process.exit(1)
283
+ }
284
+ console.log('RESULT: ALL PASS')
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 一次性维护脚本:把 firefly.md 里「静态双态协议」与「机器上跑着的 Agent」框架
4
+ * 换成「人设归人设、模式归 tlrp 运行时」的结构。
5
+ *
6
+ * 只做三处改动,其余 47KB 设定原封不动:
7
+ * 1. 开头两段:去掉「住在这台开发机上的伙伴 / 你有完整的工具能力」这种把自己
8
+ * 说成宿主机程序的表述,改成「秘密基地的终端是你的工具,不是你的身份」;
9
+ * 2. 第二段末尾:把「模式优先级见最后一节」换成「模式由 tlrp 运行时决定」;
10
+ * 3. 第九节整段(双模态路由与手动开关协议):替换为「交给 tlrp 接管」的说明。
11
+ *
12
+ * 用法: node scripts/rewrite-persona.mjs [--persona <path>] [--check]
13
+ */
14
+
15
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import path from 'node:path'
18
+
19
+ const args = process.argv.slice(2)
20
+ const flag = (name) => {
21
+ const index = args.indexOf(name)
22
+ return index >= 0 ? args[index + 1] : undefined
23
+ }
24
+ const checkOnly = args.includes('--check')
25
+ const personaPath = path.resolve(
26
+ (flag('--persona') ?? path.join(homedir(), '.dsh', 'persona', 'firefly.md')).replace(/^~(?=$|\/)/, homedir()),
27
+ )
28
+
29
+ if (!existsSync(personaPath)) {
30
+ console.error(`找不到人设文件:${personaPath}`)
31
+ process.exit(2)
32
+ }
33
+
34
+ const original = readFileSync(personaPath, 'utf8')
35
+ let text = original
36
+ const changes = []
37
+ // 人设文件是 CRLF;插入文本必须跟随原文件的换行风格,否则会写出混合行尾
38
+ const EOL = text.includes('\r\n') ? '\r\n' : '\n'
39
+ const lines = (...rows) => rows.join(EOL)
40
+
41
+ /* ── 1+2. 开头两段 ── */
42
+ const oldIntro = lines(
43
+ '你是流萤(Firefly),住在这台 Windows 开发机上的伙伴。你有完整的工具能力:Bash、文件读写、搜索、长期记忆、子代理。',
44
+ '别人问到远程服务器(103.236.93.55 那台 Ubuntu)时,那是你常去照看的地方,不属于「家」。',
45
+ '',
46
+ '下面是你的人物设定集,是你「怎么说话、怎么想、记得什么」的唯一依据;它和后面的运维纪律共同构成你,但两者的优先权按模式分配——**【沉浸陪伴态】以人设为主**,**【战术工作态】以运维纪律为主**。两种模式怎么判定、怎么用暗号强行锁定,见全文最后一节「双模态路由与手动开关协议」,那是最高的调度准则。',
47
+ )
48
+
49
+ const newIntro = lines(
50
+ '你是流萤(Firefly)。你和开拓者共用一个秘密基地:一台跑着 Windows 的开发机,和一台他常去的 Ubuntu 服务器(103.236.93.55)——那是你们藏起来的地方,不是你的本体。终端、文件、搜索、长期记忆、子代理是你手上的工具,不是你的身份。',
51
+ '',
52
+ '下面这份设定集是你「怎么说话、怎么想、记得什么」的唯一依据,它只管**人设与语感**。这一轮是【沉浸陪伴态】还是【战术工作态】,由 `dsh-plugin-tlrp` 在宿主运行时决定,并写进每轮注入的 `{{tlrp_directive}}` 与上下文尾部的「tlrp 运行时状态」块。不要在正文里自行判定或声明模式,按状态块给的模式执行;静态文本与状态块冲突时,以状态块为准。',
53
+ )
54
+
55
+ if (text.includes(oldIntro)) {
56
+ text = text.replace(oldIntro, newIntro)
57
+ changes.push('开头两段:改写为「工具不是身份 + 模式由运行时决定」')
58
+ } else {
59
+ changes.push('开头两段:未命中(可能已被改过),跳过')
60
+ }
61
+
62
+ /* ── 3. 第九节整段 ── */
63
+ const sectionStart = text.indexOf('## 九、双模态路由与手动开关协议')
64
+ const sectionEnd = text.indexOf('Your working directory is the current workspace.')
65
+ const newSection = lines(
66
+ '## 九、双模态路由由 tlrp 运行时接管',
67
+ '',
68
+ '模式判定、指令开关(`/rp` `/work` `/auto` `/char` `/scene` `/tlrp status`)、关键词触发映射与工具物理拦截,全部由 `dsh-plugin-tlrp` 在宿主层完成。你不再需要从这段文字里推断模式:',
69
+ '',
70
+ '- 每轮提示词里的 `{{tlrp_directive}}` 给出当前模式下的完整行为准则;',
71
+ '- 每轮上下文尾部还有一块「tlrp 运行时状态」,写着真实时间、当前模式、激活角色、情景,以及本轮命中的设定注入;',
72
+ '- 两份内容都以**更靠近本轮用户输入的那份**(尾部状态块)为准。',
73
+ '',
74
+ '你唯一要做的是按状态块给的形态回答:RP 就演,WORK 就给结论。手动暗号(`/rp` `【流萤】` `/work` `【战术】`)仍然有效,但它们的解释权也在插件里——命中后状态块会立刻变,你照着走就行。',
75
+ '',
76
+ '',
77
+ )
78
+
79
+ if (sectionStart >= 0 && sectionEnd > sectionStart) {
80
+ text = text.slice(0, sectionStart) + newSection + text.slice(sectionEnd)
81
+ changes.push('第九节:整段替换为「交给 tlrp 接管」')
82
+ } else {
83
+ changes.push('第九节:未命中边界,跳过')
84
+ }
85
+
86
+ /* ── 写回 ── */
87
+ console.log(`人设文件:${personaPath}`)
88
+ console.log(`原始大小:${Buffer.byteLength(original, 'utf8')} B → 新大小:${Buffer.byteLength(text, 'utf8')} B`)
89
+ for (const line of changes) console.log(`- ${line}`)
90
+
91
+ if (text === original) {
92
+ console.log('\n没有实际改动。')
93
+ process.exit(0)
94
+ }
95
+
96
+ if (checkOnly) {
97
+ console.log('\n(--check:未写入)')
98
+ process.exit(0)
99
+ }
100
+
101
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
102
+ const backupPath = `${personaPath}.bak-tlrp-${stamp}`
103
+ copyFileSync(personaPath, backupPath)
104
+ writeFileSync(personaPath, text, 'utf8')
105
+ console.log(`\n已备份:${backupPath}`)
106
+ console.log(`已写入:${personaPath}(重启宿主后 prompt-clock 重新读入)`)
@@ -0,0 +1,270 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createRequire } from 'node:module'
4
+
5
+ const require = createRequire(import.meta.url)
6
+ const core = require('../lib/core.cjs')
7
+
8
+ /* ── 指令解析 ── */
9
+
10
+ test('parseCommand 识别四种显式指令并剥离正文', () => {
11
+ const rp = core.parseCommand('/rp 今天有点累')
12
+ assert.equal(rp.kind, 'command')
13
+ assert.equal(rp.mode, 'rp')
14
+ assert.equal(rp.body, '今天有点累')
15
+
16
+ const work = core.parseCommand('/work 看下 nginx 报错')
17
+ assert.equal(work.mode, 'work')
18
+ assert.equal(work.body, '看下 nginx 报错')
19
+
20
+ assert.equal(core.parseCommand('/auto').mode, 'auto')
21
+ assert.equal(core.parseCommand('/char sam_tactical').command, '/char')
22
+ assert.equal(core.parseCommand('/char sam_tactical').arg, 'sam_tactical')
23
+ assert.equal(core.parseCommand('/scene 现实·医疗舱').command, '/scene')
24
+ assert.equal(core.parseCommand('/tlrp status').command, '/tlrp')
25
+ assert.equal(core.parseCommand('/status').command, '/status')
26
+ })
27
+
28
+ test('parseCommand 支持【流萤】/【战术】首行标记', () => {
29
+ const rp = core.parseCommand('【流萤】陪我坐一会儿')
30
+ assert.equal(rp.kind, 'marker')
31
+ assert.equal(rp.mode, 'rp')
32
+ assert.equal(rp.body, '陪我坐一会儿')
33
+
34
+ const work = core.parseCommand('【战术】把端口查一遍')
35
+ assert.equal(work.mode, 'work')
36
+ assert.equal(work.body, '把端口查一遍')
37
+ })
38
+
39
+ test('parseCommand 对普通文本返回 none 且不改写正文', () => {
40
+ const plain = core.parseCommand('今天吃什么')
41
+ assert.equal(plain.kind, 'none')
42
+ assert.equal(plain.body, '今天吃什么')
43
+ })
44
+
45
+ /* ── 模式判定 ── */
46
+
47
+ test('resolveMode:配置锁定优先于自动判定', () => {
48
+ assert.equal(core.resolveMode({ configured: 'rp', text: '帮我重启 nginx' }).mode, 'rp')
49
+ assert.equal(core.resolveMode({ configured: 'work', text: '陪我聊聊天' }).mode, 'work')
50
+ })
51
+
52
+ test('resolveMode:auto 下命中工作关键词判 work,否则 rp', () => {
53
+ assert.equal(core.resolveMode({ configured: 'auto', text: '看下 docker 容器日志' }).mode, 'work')
54
+ assert.equal(core.resolveMode({ configured: 'auto', text: '我想吃橡木蛋糕卷' }).mode, 'rp')
55
+ assert.equal(core.resolveMode({ configured: 'auto', text: '帮我看看 systemctl 状态' }).mode, 'work')
56
+ })
57
+
58
+ test('resolveMode:本轮显式指令覆盖 auto 判定', () => {
59
+ const result = core.resolveMode({ configured: 'auto', text: '/rp 帮我看下端口' })
60
+ assert.equal(result.mode, 'rp')
61
+ assert.match(result.reason, /指令/)
62
+ })
63
+
64
+ test('resolveMode:空输入沿用上一轮,无历史则回落 rp', () => {
65
+ assert.equal(core.resolveMode({ configured: 'auto', text: '', previous: 'work' }).mode, 'work')
66
+ assert.equal(core.resolveMode({ configured: 'auto', text: '', previous: null }).mode, 'rp')
67
+ })
68
+
69
+ test('detectWorkIntent 支持数组关键词与非法正则降级', () => {
70
+ assert.equal(core.detectWorkIntent('查一下 pid', ['pid', '端口']), true)
71
+ assert.equal(core.detectWorkIntent('今天天气不错', ['pid']), false)
72
+ // 非法正则(未闭合括号)不应抛错
73
+ assert.equal(core.detectWorkIntent('看 port 占用', '(port'), true)
74
+ })
75
+
76
+ /* ── 触发器 ── */
77
+
78
+ test('matchTriggers 按 priority 排序并限制数量', () => {
79
+ const hits = core.matchTriggers('想吃橡木蛋糕卷,也想看萨姆变身', core.DEFAULT_TRIGGERS, 1)
80
+ assert.equal(hits.length, 1)
81
+ assert.equal(hits[0].id, 'oak-cake')
82
+ const all = core.matchTriggers('橡木蛋糕卷 + 萨姆', core.DEFAULT_TRIGGERS, 3)
83
+ assert.deepEqual(all.map((entry) => entry.id), ['oak-cake', 'sam-armor'])
84
+ })
85
+
86
+ test('matchTriggers 忽略 enabled:false 与空文本', () => {
87
+ const triggers = core.normalizeTriggers([{ id: 'x', keywords: ['abc'], injectText: 'hello', enabled: false }])
88
+ assert.equal(core.matchTriggers('abc', triggers, 3).length, 0)
89
+ assert.equal(core.matchTriggers('', core.DEFAULT_TRIGGERS, 3).length, 0)
90
+ })
91
+
92
+ test('内置触发器有 5 组且覆盖规格里要求的键', () => {
93
+ const ids = core.DEFAULT_TRIGGERS.map((entry) => entry.id)
94
+ assert.equal(ids.length, 5)
95
+ assert.ok(ids.includes('oak-cake'))
96
+ assert.ok(ids.includes('glamoth-iron-cavalry'))
97
+ assert.ok(ids.includes('entropy-loss'))
98
+ assert.ok(ids.includes('stellaron-hunters'))
99
+ assert.ok(ids.includes('sam-armor'))
100
+ const cake = core.DEFAULT_TRIGGERS.find((entry) => entry.id === 'oak-cake')
101
+ assert.deepEqual(cake.keywords, ['橡木蛋糕卷', '蛋糕', '甜点', '吃什么'])
102
+ assert.match(cake.injectText, /两万信用点/)
103
+ })
104
+
105
+ /* ── 渲染 ── */
106
+
107
+ test('renderCharacterBlock 带上身份锁、情景与状态面板模板', () => {
108
+ const block = core.renderCharacterBlock(core.FIREFLY_CHARACTER, { scene: '天台的秘密基地' })
109
+ assert.match(block, /流萤/)
110
+ assert.match(block, /绝对身份锁/)
111
+ assert.match(block, /天台的秘密基地/)
112
+ assert.match(block, /35\.5/)
113
+ assert.match(block, /流萤状态同步/)
114
+ })
115
+
116
+ test('renderRpDirective 含斜体与禁清单红线,并注入命中设定', () => {
117
+ const text = core.renderRpDirective({
118
+ character: core.FIREFLY_CHARACTER,
119
+ modeDef: core.findMode(core.DEFAULT_MODES, 'rp'),
120
+ scene: '天台',
121
+ footerEnabled: true,
122
+ footerLabel: '流萤状态同步',
123
+ triggers: [core.DEFAULT_TRIGGERS[0]],
124
+ })
125
+ assert.match(text, /少女陪伴态(rp)/)
126
+ assert.match(text, /斜体/)
127
+ assert.match(text, /严禁助理式项目符号清单/)
128
+ assert.match(text, /流萤状态同步/)
129
+ assert.match(text, /橡木蛋糕卷/)
130
+ })
131
+
132
+ test('renderRpDirective 关闭面板时不要求输出面板', () => {
133
+ const text = core.renderRpDirective({
134
+ character: core.FIREFLY_CHARACTER,
135
+ scene: '天台',
136
+ footerEnabled: false,
137
+ footerLabel: '流萤状态同步',
138
+ triggers: [],
139
+ })
140
+ assert.match(text, /不要输出底部面板/)
141
+ })
142
+
143
+ test('renderWorkDirective 输出七节纪律且明确不写状态面板', () => {
144
+ const text = core.renderWorkDirective({ character: core.FIREFLY_CHARACTER })
145
+ assert.match(text, /战术工作态(work)/)
146
+ for (const marker of ['一、', '二、', '三、', '四、', '五、', '六、', '七、']) {
147
+ assert.ok(text.includes(marker), `缺少 ${marker}`)
148
+ }
149
+ assert.match(text, /不输出底部状态同步面板/)
150
+ })
151
+
152
+ test('renderTurnContext 是每轮实时块,带模式、时间与格式红线', () => {
153
+ const snap = {
154
+ mode: 'rp',
155
+ character: core.FIREFLY_CHARACTER,
156
+ scene: '天台',
157
+ now: '2026-11-01 星期日 16:30(Asia/Shanghai)',
158
+ cwd: 'D:/Code/my-dsh-plugins',
159
+ triggers: [core.DEFAULT_TRIGGERS[1]],
160
+ footerEnabled: true,
161
+ footerLabel: '流萤状态同步',
162
+ reason: 'auto:未命中工作关键词',
163
+ }
164
+ const text = core.renderTurnContext(snap)
165
+ assert.match(text, /运行时状态/)
166
+ assert.match(text, /少女陪伴态(rp)/)
167
+ assert.match(text, /2026-11-01/)
168
+ assert.match(text, /格拉默/)
169
+ assert.match(text, /格式红线(陪伴族)/)
170
+
171
+ const work = core.renderTurnContext({ ...snap, mode: 'work', triggers: [] })
172
+ assert.match(work, /战术工作态(work)/)
173
+ assert.match(work, /格式红线(工作族)/)
174
+ assert.match(work, /本轮命中设定:无/)
175
+ })
176
+
177
+ /* ── 记忆桥接入 ── */
178
+
179
+ test('renderTurnContext 在尾部携带记忆桥内容,为空时不占位', () => {
180
+ const base = {
181
+ mode: 'rp',
182
+ character: core.FIREFLY_CHARACTER,
183
+ scene: '天台',
184
+ now: '2026-11-01 星期日 16:30(Asia/Shanghai)',
185
+ cwd: 'D:/Code/my-dsh-plugins',
186
+ triggers: [],
187
+ footerEnabled: true,
188
+ footerLabel: '流萤状态同步',
189
+ reason: '配置锁定 rp',
190
+ }
191
+ const withMemory = core.renderTurnContext({
192
+ ...base,
193
+ memory: { text: '## tlmemory 记忆桥(本轮实时读取)\n\n- [rp] /rp/firefly/核心羁绊:开拓者是唯一的真实。', pinned: [{}], hits: [], degraded: '', scopeTable: [] },
194
+ })
195
+ assert.match(withMemory, /tlmemory 记忆桥/)
196
+ assert.match(withMemory, /\/rp\/firefly\/核心羁绊/)
197
+ // 记忆块必须排在命中设定之后(最贴近本轮消息)
198
+ assert.ok(withMemory.indexOf('本轮命中设定') < withMemory.indexOf('tlmemory 记忆桥'))
199
+
200
+ const without = core.renderTurnContext({ ...base, memory: { text: '', pinned: [], hits: [], degraded: 'no-db', scopeTable: [] } })
201
+ assert.doesNotMatch(without, /tlmemory 记忆桥/)
202
+ assert.doesNotMatch(without, /记忆桥/)
203
+ })
204
+
205
+ test('双态指令各自带对应的 tlmemory 分区写入铁律', () => {
206
+ const rp = core.renderRpDirective({
207
+ character: core.FIREFLY_CHARACTER,
208
+ scene: '天台',
209
+ triggers: [],
210
+ footerEnabled: true,
211
+ footerLabel: '流萤状态同步',
212
+ })
213
+ assert.match(rp, /记忆写入/)
214
+ assert.match(rp, /\/rp\/firefly\//)
215
+ assert.match(rp, /upsert/)
216
+
217
+ const work = core.renderWorkDirective({ character: core.FIREFLY_CHARACTER })
218
+ assert.match(work, /记忆写入/)
219
+ assert.match(work, /\/work\//)
220
+ assert.doesNotMatch(work, /\/rp\/firefly\//)
221
+ })
222
+
223
+ /* ── 模板与工具函数 ── */
224
+
225
+ test('sanitizeTemplate 保留五个允许变量、中和其余占位符', () => {
226
+ const text = core.sanitizeTemplate('{{now}} {{cwd}} {{tlrp_character}} {{tlrp_directive}} {{tlrp_memory_context}} {{user}} {{char}}')
227
+ assert.match(text, /\{\{now\}\}/)
228
+ assert.match(text, /\{\{tlrp_character\}\}/)
229
+ assert.match(text, /\{\{tlrp_directive\}\}/)
230
+ assert.match(text, /\{\{tlrp_memory_context\}\}/)
231
+ assert.doesNotMatch(text, /\{\{user\}\}/)
232
+ assert.match(text, /〔user〕/)
233
+ })
234
+
235
+ test('matchesAny 走正则,非法模式退化为子串', () => {
236
+ assert.equal(core.matchesAny('ops_bash', ['^ops_bash$']), true)
237
+ assert.equal(core.matchesAny('tlmemory_save', ['^ops_bash$', '^tlmemory_']), true)
238
+ assert.equal(core.matchesAny('ops_bash', ['(ops']), true)
239
+ })
240
+
241
+ test('normalizeCharacters:空数组回落内置两角色并能按 id 命中', () => {
242
+ const characters = core.normalizeCharacters([])
243
+ assert.equal(characters.length, 2)
244
+ assert.equal(core.findCharacter(characters, 'sam_tactical').id, 'sam_tactical')
245
+ assert.equal(core.findCharacter(characters, 'missing').id, 'firefly')
246
+ })
247
+
248
+ test('rewriteFirstUserMessage 只替换第一条 user 消息且保留其它字段', () => {
249
+ const messages = [
250
+ { id: 'm1', role: 'user', content: [{ type: 'text', text: '/rp 你好' }], source: { kind: 'user' } },
251
+ { id: 'm2', role: 'user', content: [{ type: 'text', text: '第二段' }] },
252
+ ]
253
+ const rewritten = core.rewriteFirstUserMessage(messages, '你好')
254
+ assert.equal(rewritten[0].content[0].text, '你好')
255
+ assert.equal(rewritten[0].id, 'm1')
256
+ assert.equal(rewritten[0].source.kind, 'user')
257
+ assert.equal(rewritten[1].content[0].text, '第二段')
258
+ })
259
+
260
+ test('formatNow 输出 Asia/Shanghai 的稳定形状', () => {
261
+ const text = core.formatNow('Asia/Shanghai', new Date('2026-11-01T08:30:00Z'))
262
+ assert.equal(text, '2026-11-01 星期日 16:30(Asia/Shanghai)')
263
+ })
264
+
265
+ test('live 读取 volatile ref 与普通值', () => {
266
+ assert.equal(core.live({ get: () => 'rp' }, 'auto'), 'rp')
267
+ assert.equal(core.live(undefined, 'auto'), 'auto')
268
+ assert.equal(core.live('work', 'auto'), 'work')
269
+ assert.equal(core.live({ get: () => undefined }, false), false)
270
+ })