dsh-plugin-tlrp 0.0.0-stage → 0.3.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 +21 -0
- package/README.md +195 -2
- package/client.js +1785 -0
- package/cordis.patch.yml +18 -0
- package/icon.svg +18 -0
- package/index.cjs +37 -0
- package/index.js +34 -0
- package/lib/core.cjs +2551 -0
- package/lib/memory-bridge.cjs +265 -0
- package/lib/resolve.cjs +74 -0
- package/lib/schema.cjs +188 -0
- package/locale/en.json +19 -0
- package/locale/zh.json +19 -0
- package/package.json +72 -3
- package/scripts/apply-check.mjs +357 -0
- package/scripts/client-check.mjs +445 -0
- package/scripts/install-into-profile.mjs +167 -0
- package/scripts/mutate-check.mjs +284 -0
- package/scripts/rewrite-persona.mjs +106 -0
- package/test/core.test.mjs +270 -0
- package/test/memory-bridge.test.mjs +77 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// packages/tlrp/lib/memory-bridge.cjs
|
|
2
|
+
//
|
|
3
|
+
// tlrp ← → tlmemory 的记忆桥。
|
|
4
|
+
//
|
|
5
|
+
// 为什么是「只读直连 SQLite」而不是调用 tlmemory 的 ctx 服务:
|
|
6
|
+
// 1. tlrp 必须零依赖、CJS、单文件可挂载:它要在新老 DSH 上都能装,不能因为
|
|
7
|
+
// tlmemory 没装/版本不匹配就整个插件起不来;
|
|
8
|
+
// 2. 只读语义足够 —— tlrp 只负责「把该在场的信息搬到台面上」,写入永远由模型
|
|
9
|
+
// 通过 tlmemory_save 发起(谁拥有数据谁负责写,避免两个插件同时写同一行);
|
|
10
|
+
// 3. 驱动走 Node 内置 node:sqlite(与仓库约定一致,绝不引入 better-sqlite3 这类
|
|
11
|
+
// 按 NODE_MODULE_VERSION 编译的驱动)。宿主 Node 过旧时 require 会抛错,
|
|
12
|
+
// 这里全部吞掉并降级为「无记忆上下文」,绝不让记忆桥拖垮对话。
|
|
13
|
+
//
|
|
14
|
+
// 降级矩阵(任一不满足都只是少一段注入,不报错、不阻塞):
|
|
15
|
+
// no-driver 宿主 Node 没有 node:sqlite
|
|
16
|
+
// no-db 库文件不存在(tlmemory 从未运行过)
|
|
17
|
+
// no-schema tlmemory 还是 v1(没有 scope / char_id / tags 列)
|
|
18
|
+
// no-fts memory_fts 不可用,退化为 LIKE 检索
|
|
19
|
+
|
|
20
|
+
const os = require('node:os')
|
|
21
|
+
const path = require('node:path')
|
|
22
|
+
const fs = require('node:fs')
|
|
23
|
+
|
|
24
|
+
const EXCLUDED_STATUS = "n.status NOT IN ('pending', 'merged', 'superseded')"
|
|
25
|
+
|
|
26
|
+
let driver
|
|
27
|
+
let driverProbed = false
|
|
28
|
+
|
|
29
|
+
// 一轮里 snapshot() 会被变量 / context / guard 多次调用,每次开库太浪费。
|
|
30
|
+
// 用「参数 + 文本」做键、3 秒 TTL 的短缓存:同一轮命中缓存,跨轮自然失效。
|
|
31
|
+
const CACHE_TTL_MS = 3000
|
|
32
|
+
let cacheKey = ''
|
|
33
|
+
let cacheAt = 0
|
|
34
|
+
let cacheValue = null
|
|
35
|
+
|
|
36
|
+
/** 惰性探测 node:sqlite(Electron 宿主上可能不存在,绝不能放在模块顶部 require)。 */
|
|
37
|
+
function loadDriver() {
|
|
38
|
+
if (driverProbed) return driver
|
|
39
|
+
driverProbed = true
|
|
40
|
+
try {
|
|
41
|
+
// eslint-disable-next-line node/no-unsupported-features/node-builtins
|
|
42
|
+
driver = require('node:sqlite')
|
|
43
|
+
} catch {
|
|
44
|
+
driver = null
|
|
45
|
+
}
|
|
46
|
+
return driver
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveDbPath(explicit) {
|
|
50
|
+
const value = String(explicit || process.env.TLMEMORY_DB || '').trim()
|
|
51
|
+
if (value) return value
|
|
52
|
+
return path.join(os.homedir(), '.dsh', 'tlmemory.db')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 打开只读库;失败返回 null(调用方按降级处理)。 */
|
|
56
|
+
function openReadOnly(dbPath) {
|
|
57
|
+
const mod = loadDriver()
|
|
58
|
+
if (!mod || typeof mod.DatabaseSync !== 'function') return { handle: null, reason: 'no-driver' }
|
|
59
|
+
if (!fs.existsSync(dbPath)) return { handle: null, reason: 'no-db' }
|
|
60
|
+
try {
|
|
61
|
+
const handle = new mod.DatabaseSync(dbPath, { readOnly: true })
|
|
62
|
+
return { handle, reason: '' }
|
|
63
|
+
} catch {
|
|
64
|
+
// 只读打开失败(例如文件被独占)时再试一次常规打开:宁可短暂持写锁也不要整段失效
|
|
65
|
+
try {
|
|
66
|
+
return { handle: new mod.DatabaseSync(dbPath), reason: '' }
|
|
67
|
+
} catch {
|
|
68
|
+
return { handle: null, reason: 'no-db' }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 列探测:v1 库没有 scope/char_id/tags,必须降级而不是抛错。 */
|
|
74
|
+
function hasPartitionSchema(handle) {
|
|
75
|
+
try {
|
|
76
|
+
const rows = handle.prepare('PRAGMA table_info(nodes)').all()
|
|
77
|
+
const names = new Set(rows.map((row) => String(row.name || '')))
|
|
78
|
+
return names.has('scope') && names.has('char_id') && names.has('tags')
|
|
79
|
+
} catch {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasFts(handle) {
|
|
85
|
+
try {
|
|
86
|
+
handle.prepare('SELECT rowid FROM memory_fts LIMIT 1').all()
|
|
87
|
+
return true
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 从用户本轮发言里抽 3~4 字的检索锚点(与 tlmemory 的 trigram 拆分同一口径)。 */
|
|
94
|
+
function extractTerms(text, limit = 4) {
|
|
95
|
+
const out = []
|
|
96
|
+
const seen = new Set()
|
|
97
|
+
const push = (term) => {
|
|
98
|
+
const clean = String(term || '').trim()
|
|
99
|
+
if (!clean || seen.has(clean)) return
|
|
100
|
+
seen.add(clean)
|
|
101
|
+
out.push(clean)
|
|
102
|
+
}
|
|
103
|
+
for (const seg of String(text || '').split(/[^\p{L}\p{N}_]+/u)) {
|
|
104
|
+
if (!seg) continue
|
|
105
|
+
const chars = Array.from(seg)
|
|
106
|
+
if (chars.length < 3) continue // trigram 对 <3 字符不可见,短词交给模型自己表述
|
|
107
|
+
if (chars.length <= 6) {
|
|
108
|
+
push(seg)
|
|
109
|
+
} else {
|
|
110
|
+
for (let i = 0; i + 4 <= chars.length && out.length < limit; i += 3) {
|
|
111
|
+
push(chars.slice(i, i + 4).join(''))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (out.length >= limit) break
|
|
115
|
+
}
|
|
116
|
+
return out.slice(0, limit)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rowToItem(row) {
|
|
120
|
+
const dirPath = String(row.path || '/')
|
|
121
|
+
const name = String(row.name || '')
|
|
122
|
+
return {
|
|
123
|
+
scope: String(row.scope || ''),
|
|
124
|
+
charId: String(row.char_id || ''),
|
|
125
|
+
path: `${dirPath}${name}`,
|
|
126
|
+
content: String(row.content || '').slice(0, 240),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderItem(item) {
|
|
131
|
+
return `- [${item.scope || '?'}] ${item.path}:${item.content}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 取本轮记忆上下文。
|
|
136
|
+
*
|
|
137
|
+
* @param {object} input
|
|
138
|
+
* @param {string} input.characterId 当前激活角色
|
|
139
|
+
* @param {'rp'|'work'|'off'} input.mode 本轮模式
|
|
140
|
+
* @param {string} [input.text] 用户本轮发言(用于关键词静默检索)
|
|
141
|
+
* @param {string} [input.dbPath] 覆盖库路径(测试/多 profile 用)
|
|
142
|
+
* @param {number} [input.pinnedLimit]
|
|
143
|
+
* @param {number} [input.hitLimit]
|
|
144
|
+
* @returns {{text:string, pinned:Array, hits:Array, degraded:string, scopeTable:string[]}}
|
|
145
|
+
*/
|
|
146
|
+
function readMemoryContext(input = {}) {
|
|
147
|
+
const mode = input.mode === 'work' ? 'work' : 'rp'
|
|
148
|
+
const charId = String(input.characterId || '').trim()
|
|
149
|
+
const partitions = mode === 'rp' ? ['rp', 'lore', 'core'] : ['work', 'core']
|
|
150
|
+
const dbPath = resolveDbPath(input.dbPath)
|
|
151
|
+
const empty = { text: '', pinned: [], hits: [], degraded: '', scopeTable: partitions }
|
|
152
|
+
|
|
153
|
+
const key = `${mode}|${charId}|${dbPath}|${String(input.text || '')}`
|
|
154
|
+
const at = Date.now()
|
|
155
|
+
if (cacheValue && cacheKey === key && at - cacheAt < CACHE_TTL_MS) return cacheValue
|
|
156
|
+
const remember = (value) => {
|
|
157
|
+
cacheKey = key
|
|
158
|
+
cacheAt = Date.now()
|
|
159
|
+
cacheValue = value
|
|
160
|
+
return value
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { handle, reason } = openReadOnly(dbPath)
|
|
164
|
+
if (!handle) return remember({ ...empty, degraded: reason })
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
if (!hasPartitionSchema(handle)) return remember({ ...empty, degraded: 'no-schema' })
|
|
168
|
+
|
|
169
|
+
const partitionSql = `n.scope IN (${partitions.map(() => '?').join(', ')})`
|
|
170
|
+
// 角色隔离但保留共享层:char_id='' 的行(core 画像、历史迁移记忆)对所有角色可见
|
|
171
|
+
const charSql = charId ? "(n.char_id = ? OR n.char_id = '')" : '1 = 1'
|
|
172
|
+
const baseParams = charId ? [...partitions, charId] : [...partitions]
|
|
173
|
+
|
|
174
|
+
const pinnedLimit = Number.isFinite(input.pinnedLimit) ? input.pinnedLimit : 3
|
|
175
|
+
const pinnedRows = handle
|
|
176
|
+
.prepare(
|
|
177
|
+
`SELECT n.scope, n.char_id, n.path, n.name, n.content
|
|
178
|
+
FROM nodes n
|
|
179
|
+
WHERE n.is_pinned = 1 AND n.is_leaf = 1 AND ${partitionSql} AND ${charSql} AND ${EXCLUDED_STATUS}
|
|
180
|
+
ORDER BY n.updated_at DESC
|
|
181
|
+
LIMIT ${pinnedLimit}`,
|
|
182
|
+
)
|
|
183
|
+
.all(...baseParams)
|
|
184
|
+
|
|
185
|
+
const pinned = pinnedRows.map(rowToItem)
|
|
186
|
+
|
|
187
|
+
const terms = extractTerms(input.text)
|
|
188
|
+
let hits = []
|
|
189
|
+
if (terms.length > 0) {
|
|
190
|
+
const hitLimit = Number.isFinite(input.hitLimit) ? input.hitLimit : 3
|
|
191
|
+
const pinnedPaths = new Set(pinned.map((item) => item.path))
|
|
192
|
+
if (hasFts(handle)) {
|
|
193
|
+
const match = terms.map((term) => `"${term.replace(/"/g, '""')}"`).join(' OR ')
|
|
194
|
+
const rows = handle
|
|
195
|
+
.prepare(
|
|
196
|
+
`SELECT n.scope, n.char_id, n.path, n.name, n.content
|
|
197
|
+
FROM memory_fts
|
|
198
|
+
JOIN nodes n ON n.id = memory_fts.rowid
|
|
199
|
+
WHERE memory_fts MATCH ? AND ${partitionSql} AND ${charSql} AND ${EXCLUDED_STATUS}
|
|
200
|
+
ORDER BY n.is_pinned DESC, bm25(memory_fts, 0.2, 2.0, 1.5, 1.0, 1.5, 3.0)
|
|
201
|
+
LIMIT ${hitLimit + pinned.length}`,
|
|
202
|
+
)
|
|
203
|
+
.all(match, ...baseParams)
|
|
204
|
+
hits = rows.map(rowToItem).filter((item) => !pinnedPaths.has(item.path)).slice(0, hitLimit)
|
|
205
|
+
} else {
|
|
206
|
+
const like = terms.map(() => 'n.content LIKE ?').join(' OR ')
|
|
207
|
+
const rows = handle
|
|
208
|
+
.prepare(
|
|
209
|
+
`SELECT n.scope, n.char_id, n.path, n.name, n.content
|
|
210
|
+
FROM nodes n
|
|
211
|
+
WHERE (${like}) AND n.is_leaf = 1 AND ${partitionSql} AND ${charSql} AND ${EXCLUDED_STATUS}
|
|
212
|
+
ORDER BY n.is_pinned DESC, n.updated_at DESC
|
|
213
|
+
LIMIT ${hitLimit + pinned.length}`,
|
|
214
|
+
)
|
|
215
|
+
.all(...terms.map((term) => `%${term}%`), ...baseParams)
|
|
216
|
+
hits = rows.map(rowToItem).filter((item) => !pinnedPaths.has(item.path)).slice(0, hitLimit)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const text = renderMemoryBlock({ pinned, hits, mode, charId })
|
|
221
|
+
return remember({ text, pinned, hits, degraded: '', scopeTable: partitions })
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return remember({ ...empty, degraded: `error:${String(error && error.message ? error.message : error).slice(0, 120)}` })
|
|
224
|
+
} finally {
|
|
225
|
+
try {
|
|
226
|
+
handle.close()
|
|
227
|
+
} catch {
|
|
228
|
+
/* 关闭失败不影响本轮结果 */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 渲染成可直接塞进提示词的一段文本(空记忆时返回空串,不占上下文)。 */
|
|
234
|
+
function renderMemoryBlock({ pinned, hits, mode, charId }) {
|
|
235
|
+
if (pinned.length === 0 && hits.length === 0) return ''
|
|
236
|
+
const lines = ['## tlmemory 记忆桥(本轮实时读取)']
|
|
237
|
+
if (pinned.length > 0) {
|
|
238
|
+
lines.push('', '### 常驻记忆(pinned,每轮必带,不需要检索就能看到)')
|
|
239
|
+
for (const item of pinned) lines.push(renderItem(item))
|
|
240
|
+
}
|
|
241
|
+
if (hits.length > 0) {
|
|
242
|
+
lines.push('', '### 本轮命中的相关记忆(关键词静默检索)')
|
|
243
|
+
for (const item of hits) lines.push(renderItem(item))
|
|
244
|
+
}
|
|
245
|
+
lines.push(
|
|
246
|
+
'',
|
|
247
|
+
'使用规则:以上是**已经落库的事实**,可以自然引用、不要当成待验证的猜测;与库中记录冲突的临时说法以库为准。',
|
|
248
|
+
)
|
|
249
|
+
if (mode === 'rp') {
|
|
250
|
+
lines.push(
|
|
251
|
+
`- 本轮新出现的重要互动、约定、情绪变化:用 tlmemory_save 写入 /rp/${charId || '<char>'}/... 分区(tags 给 3~5 个 ≥3 字词)。`,
|
|
252
|
+
)
|
|
253
|
+
} else {
|
|
254
|
+
lines.push('- 本轮产生的技术结论:用 tlmemory_save 写入 /work/... 分区;同一主题复用同一 path 让 upsert 自动收敛。')
|
|
255
|
+
}
|
|
256
|
+
return lines.join('\n')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
readMemoryContext,
|
|
261
|
+
renderMemoryBlock,
|
|
262
|
+
extractTerms,
|
|
263
|
+
resolveDbPath,
|
|
264
|
+
loadDriver,
|
|
265
|
+
}
|
package/lib/resolve.cjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 在不引入任何依赖的前提下解析宿主的 `@deepseek-ai/schemastery`。
|
|
5
|
+
*
|
|
6
|
+
* 为什么需要它:插件可能以 `file:` / `link:`(软链)方式装进 profile,
|
|
7
|
+
* 也可能被 pnpm 实体拷贝到 `<profile>/node_modules/dsh-plugin-tlrp`。前者按
|
|
8
|
+
* realpath 解析,裸 import 找不到宿主依赖;后者可以。这里按「自身 → 当前
|
|
9
|
+
* profile → 已知 profile → profile 级 node_modules」的顺序逐个尝试,
|
|
10
|
+
* 全部失败时返回 undefined,插件退化为「无 Config schema」仍可运行。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { createRequire } = require('node:module')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
const os = require('node:os')
|
|
16
|
+
|
|
17
|
+
const SPECIFIER = '@deepseek-ai/schemastery'
|
|
18
|
+
|
|
19
|
+
/** 可能的解析基点(package.json 或任意文件路径)。 */
|
|
20
|
+
function candidateBases() {
|
|
21
|
+
const bases = []
|
|
22
|
+
const profileDir = process.env.DSH_PROFILE_DIR
|
|
23
|
+
if (typeof profileDir === 'string' && profileDir.trim()) bases.push(path.join(profileDir, 'package.json'))
|
|
24
|
+
const home = os.homedir()
|
|
25
|
+
const profile = process.env.DSH_PROFILE
|
|
26
|
+
if (typeof profile === 'string' && profile.trim()) bases.push(path.join(home, '.dsh', 'profiles', profile, 'package.json'))
|
|
27
|
+
for (const known of ['desktop', 'web', 'default']) {
|
|
28
|
+
bases.push(path.join(home, '.dsh', 'profiles', known, 'package.json'))
|
|
29
|
+
}
|
|
30
|
+
bases.push(path.join(home, '.dsh', 'profiles', 'node_modules', 'noop.js'))
|
|
31
|
+
return bases
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 尝试解析 schemastery。
|
|
36
|
+
* @param {NodeRequire} [fallbackRequire] 调用方自带的 require(CJS 入口直接传 require)
|
|
37
|
+
* @returns {{ z: any, attempts: string[], source: string }}
|
|
38
|
+
*/
|
|
39
|
+
function loadSchemastery(fallbackRequire) {
|
|
40
|
+
const attempts = []
|
|
41
|
+
|
|
42
|
+
const attempt = (loader, label) => {
|
|
43
|
+
try {
|
|
44
|
+
const mod = loader(SPECIFIER)
|
|
45
|
+
const z = mod && (mod.default ?? mod)
|
|
46
|
+
if (z && typeof z.object === 'function') return z
|
|
47
|
+
attempts.push(`${label}: 导出里没有 object()`)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
attempts.push(`${label}: ${(error && (error.code || error.message)) || error}`)
|
|
50
|
+
}
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof fallbackRequire === 'function') {
|
|
55
|
+
const direct = attempt(fallbackRequire, 'self')
|
|
56
|
+
if (direct) return { z: direct, attempts, source: 'self' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const base of candidateBases()) {
|
|
60
|
+
let req
|
|
61
|
+
try {
|
|
62
|
+
req = createRequire(base)
|
|
63
|
+
} catch (error) {
|
|
64
|
+
attempts.push(`${base}: ${(error && error.message) || error}`)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const found = attempt(req, base)
|
|
68
|
+
if (found) return { z: found, attempts, source: base }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { z: undefined, attempts, source: '(none)' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { loadSchemastery, candidateBases, SPECIFIER }
|
package/lib/schema.cjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* tlrp 的 Cordis Config(Schemastery)。
|
|
5
|
+
*
|
|
6
|
+
* 这个 schema 同时就是「设置界面」:`@deepseek-ai/dsh-settings` 只把
|
|
7
|
+
* `.volatile()` 字段投影成表单,也只允许写 volatile 路径,所以所有需要
|
|
8
|
+
* 在设置面板里改、或者需要被 `/rp` `/work` `/char` `/scene` 指令改的字段
|
|
9
|
+
* 一律标 `.volatile()`。
|
|
10
|
+
*
|
|
11
|
+
* `.volatile()` 之后字段的解析结果是 live ref(`{ get() }`),运行期读数
|
|
12
|
+
* 必须走 `core.live()`,否则热更新看到的是启动时的旧快照。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const core = require('./core.cjs')
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 构建 Config schema。
|
|
19
|
+
* @param {any} z `@deepseek-ai/schemastery` 的导出(默认导出)
|
|
20
|
+
* @returns {any} Schemastery schema
|
|
21
|
+
*/
|
|
22
|
+
function buildConfig(z) {
|
|
23
|
+
const characterShape = z.object({
|
|
24
|
+
id: z.string().default(''),
|
|
25
|
+
name: z.string().default(''),
|
|
26
|
+
userTitle: z.string().default(''),
|
|
27
|
+
identityLock: z.string().default(''),
|
|
28
|
+
corePersona: z.string().default(''),
|
|
29
|
+
defaultScene: z.string().default(''),
|
|
30
|
+
appearanceAndSensory: z.string().default(''),
|
|
31
|
+
actionPresets: z.string().default(''),
|
|
32
|
+
speechStyle: z.string().default(''),
|
|
33
|
+
dialogueExamples: z.string().default(''),
|
|
34
|
+
statusFooterTemplate: z.string().default(''),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const triggerShape = z.object({
|
|
38
|
+
id: z.string().default(''),
|
|
39
|
+
characterId: z.string().default(''),
|
|
40
|
+
keywords: z.array(z.string()).default([]),
|
|
41
|
+
injectText: z.string().default(''),
|
|
42
|
+
priority: z.number().default(0),
|
|
43
|
+
enabled: z.boolean().default(true),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const modeShape = z.object({
|
|
47
|
+
id: z.string().default(''),
|
|
48
|
+
name: z.string().default(''),
|
|
49
|
+
commandAliases: z.array(z.string()).default([]),
|
|
50
|
+
autoKeywords: z.string().default(''),
|
|
51
|
+
autoPriority: z.number().default(50),
|
|
52
|
+
directivePrompt: z.string().default(''),
|
|
53
|
+
toolPolicy: z.union(['allow_all', 'whitelist_only', 'block_all']).default('allow_all'),
|
|
54
|
+
allowedTools: z.string().default(''),
|
|
55
|
+
injectCharacterPersona: z.boolean().default(true),
|
|
56
|
+
enableStatusFooter: z.boolean().default(true),
|
|
57
|
+
statusFooterOverride: z.string().default(''),
|
|
58
|
+
bindCharacterId: z.string().default(''),
|
|
59
|
+
isDefaultFallback: z.boolean().default(false),
|
|
60
|
+
styleLayer: z
|
|
61
|
+
.union(['inherit', 'on', 'off'])
|
|
62
|
+
.default('inherit')
|
|
63
|
+
.description('风格层开关(只带语言风格,不演动作):on 强制开、off 强制关、inherit 跟随全局 styleLayerDefault。'),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return z.object({
|
|
67
|
+
/* ── 设置面板字段(全部 volatile) ── */
|
|
68
|
+
enabled: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.default(true)
|
|
71
|
+
.description('插件总开关。关闭后不注入任何角色、形态约束与工具拦截。')
|
|
72
|
+
.volatile(),
|
|
73
|
+
mode: z
|
|
74
|
+
.string()
|
|
75
|
+
.default('auto')
|
|
76
|
+
.description('路由锁定:auto 按各形态的 autoKeywords 自动分流;填任意已注册形态 id 则锁死在该形态。聊天里用 /auto、/mode <id> 或形态别名切换。')
|
|
77
|
+
.volatile(),
|
|
78
|
+
modes: z
|
|
79
|
+
.array(modeShape)
|
|
80
|
+
.default(core.DEFAULT_MODES.map((entry) => ({ ...entry, commandAliases: [...entry.commandAliases] })))
|
|
81
|
+
.description('运行形态矩阵:每个形态含 id / name / commandAliases / autoKeywords / autoPriority / directivePrompt / toolPolicy / allowedTools / injectCharacterPersona / enableStatusFooter / statusFooterOverride / bindCharacterId / isDefaultFallback。增删改后即时生效。')
|
|
82
|
+
.volatile(),
|
|
83
|
+
activeCharacterId: z
|
|
84
|
+
.string()
|
|
85
|
+
.default('firefly')
|
|
86
|
+
.description('当前激活的预设角色 ID(见 characters)。聊天里用 /char <id> 切换。')
|
|
87
|
+
.volatile(),
|
|
88
|
+
workModeKeywords: z
|
|
89
|
+
.union([z.string(), z.array(z.string())])
|
|
90
|
+
.default(core.DEFAULT_WORK_KEYWORDS)
|
|
91
|
+
.description('(0.2.0 兼容字段)行为族为 work 的形态若没写自己的 autoKeywords,则用这里的关键词做自动分流。新形态请直接在形态自己的 autoKeywords 里配置。')
|
|
92
|
+
.volatile(),
|
|
93
|
+
blockShellToolsInRp: z
|
|
94
|
+
.boolean()
|
|
95
|
+
.default(true)
|
|
96
|
+
.description('(0.2.0 兼容字段)行为族为 rp 的形态在 whitelist_only 策略下是否物理拦截工具;0.3.0 起建议改用形态自己的 toolPolicy。')
|
|
97
|
+
.volatile(),
|
|
98
|
+
enableStatusFooter: z
|
|
99
|
+
.boolean()
|
|
100
|
+
.default(true)
|
|
101
|
+
.description('状态面板总开关;与形态自己的 enableStatusFooter 取「与」。')
|
|
102
|
+
.volatile(),
|
|
103
|
+
customSceneOverride: z
|
|
104
|
+
.string()
|
|
105
|
+
.default('')
|
|
106
|
+
.description('当前临时覆盖的剧情情景;留空则使用角色的 defaultScene。聊天里用 /scene <描述> 覆盖。')
|
|
107
|
+
.volatile(),
|
|
108
|
+
characters: z
|
|
109
|
+
.array(characterShape)
|
|
110
|
+
.default(core.DEFAULT_CHARACTERS.map((entry) => ({ ...entry })))
|
|
111
|
+
.description('多角色预设库。每个角色含 id / name / userTitle / identityLock / defaultScene / appearanceAndSensory / actionPresets / speechStyle / statusFooterTemplate。')
|
|
112
|
+
.volatile(),
|
|
113
|
+
triggers: z
|
|
114
|
+
.array(triggerShape)
|
|
115
|
+
.default(core.DEFAULT_TRIGGERS.map((entry) => ({ ...entry, keywords: [...entry.keywords] })))
|
|
116
|
+
.description('提示词触发映射表(动态世界书):命中 keywords 即把 injectText 注入本轮上下文。')
|
|
117
|
+
.volatile(),
|
|
118
|
+
|
|
119
|
+
/* ── 风格层:只带语言风格 + 表情 + 本地表情包/图库 ── */
|
|
120
|
+
styleLayerDefault: z
|
|
121
|
+
.union(['on', 'off'])
|
|
122
|
+
.default('off')
|
|
123
|
+
.description('风格层全局默认开关:形态自己的 styleLayer 没写 on/off 时按这里走。聊天里用 /style on|off|default 临时改本会话。')
|
|
124
|
+
.volatile(),
|
|
125
|
+
styleEmojiHint: z
|
|
126
|
+
.string()
|
|
127
|
+
.default('允许适度使用 emoji:一屏最多 2 个,放在句尾,不堆在句首,也不用 emoji 替代文字。')
|
|
128
|
+
.description('风格层的表情规范;留空则完全不出现 emoji。')
|
|
129
|
+
.volatile(),
|
|
130
|
+
stickerDir: z
|
|
131
|
+
.string()
|
|
132
|
+
.default('')
|
|
133
|
+
.description('本地表情包库目录;留空为 ~/.dsh/stickers。子目录名即角色 id,图片文件名即贴纸标签(如 开心.png → 标签「开心」)。')
|
|
134
|
+
.volatile(),
|
|
135
|
+
galleryDir: z
|
|
136
|
+
.string()
|
|
137
|
+
.default('')
|
|
138
|
+
.description('本地图库目录;留空为 ~/.dsh/gallery。图片文件名即图片标签,供「给我看图」这类请求使用。')
|
|
139
|
+
.volatile(),
|
|
140
|
+
maxStickersPerTurn: z
|
|
141
|
+
.number()
|
|
142
|
+
.default(2)
|
|
143
|
+
.description('单轮最多贴几张表情包或图片(0 = 只准用文字描述,不许贴图)。')
|
|
144
|
+
.volatile(),
|
|
145
|
+
|
|
146
|
+
/* ── 以下为普通配置(通过 profile 的 cordis.patch.yml 调整) ── */
|
|
147
|
+
timeZone: z.string().default('Asia/Shanghai').description('{{now}} 与状态块使用的时区。'), statusFooterLabel: z.string().default('流萤状态同步').description('底部状态面板的标题标签。'),
|
|
148
|
+
registerNowVariable: z
|
|
149
|
+
.union(['auto', 'off'])
|
|
150
|
+
.default('auto')
|
|
151
|
+
.description('是否尝试注册 {{now}} / {{cwd}}:auto 表示已被 prompt-clock 占用时自动让位,off 表示完全不注册。'),
|
|
152
|
+
enableMemoryBridge: z
|
|
153
|
+
.boolean()
|
|
154
|
+
.default(true)
|
|
155
|
+
.description('是否每轮从 tlmemory 的 SQLite 只读读取常驻羁绊(pinned)与关键词命中;驱动缺失或库不存在时自动降级为空。'),
|
|
156
|
+
memoryDbPath: z
|
|
157
|
+
.string()
|
|
158
|
+
.default('')
|
|
159
|
+
.description('tlmemory 数据库路径;留空则依次取环境变量 TLMEMORY_DB、~/.dsh/tlmemory.db。'),
|
|
160
|
+
coreSectionEnabled: z.boolean().default(true).description('是否注册 {{tlrp_character}} / {{tlrp_directive}} 所在的核心 prompt section。'),
|
|
161
|
+
coreSectionOrder: z.number().default(10150).description('核心 section 的排序位置(默认在 first-party 指引之后、persona suffix 之前)。'),
|
|
162
|
+
coreSectionText: z
|
|
163
|
+
.string()
|
|
164
|
+
.default('## tlrp 角色与形态装配\n当前时间:{{now}}\n工作目录:{{cwd}}\n\n{{tlrp_character}}\n\n{{tlrp_directive}}')
|
|
165
|
+
.description('核心 section 的模板;只允许 {{now}} / {{cwd}} / {{tlrp_character}} / {{tlrp_directive}} / {{tlrp_memory_context}} 五个占位符,其余会被中和。'),
|
|
166
|
+
contextOrder: z.number().default(300).description('每轮尾部实时状态块的 context 排序位置。'),
|
|
167
|
+
maxTriggerInjections: z.number().default(3).description('单轮最多注入多少组触发器(上限 8)。'),
|
|
168
|
+
blockedToolPatterns: z
|
|
169
|
+
.array(z.string())
|
|
170
|
+
.default([...core.DEFAULT_BLOCKED_TOOL_PATTERNS])
|
|
171
|
+
.description('RP 态下要拦截的工具名正则(deny 语义)。'),
|
|
172
|
+
allowedToolPatterns: z
|
|
173
|
+
.array(z.string())
|
|
174
|
+
.default([...core.DEFAULT_ALLOWED_TOOL_PATTERNS])
|
|
175
|
+
.description('无论什么模式都放行的工具名正则(allow 语义,优先于 deny)。'),
|
|
176
|
+
activeProfileEntryId: z
|
|
177
|
+
.string()
|
|
178
|
+
.default('tlrp')
|
|
179
|
+
.description('settings.mutate 使用的 profile 条目 id;改 bundle 行 id 时必须同步。'),
|
|
180
|
+
rpAllowedTools: z
|
|
181
|
+
.string()
|
|
182
|
+
.default(core.DEFAULT_RP_ALLOWED_TOOLS)
|
|
183
|
+
.description('RP 态放行的工具白名单(逗号分隔的工具名或前缀,默认 tlmemory / ask_user_question / schedule)。命中即放行,优先于拦截规则。')
|
|
184
|
+
.volatile(),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { buildConfig }
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "TLRP · Multi-Mode Matrix Engine",
|
|
4
|
+
"description": "Registers the tlrp settings namespace, an editable matrix of runtime modes, /mode and per-mode alias commands, live {{tlrp_character}} / {{tlrp_directive}} / {{now}} prompt variables, a per-turn tail status block, and physical tool gating driven by each mode's toolPolicy."
|
|
5
|
+
},
|
|
6
|
+
"command": {
|
|
7
|
+
"mode": "Switch to a runtime mode (/mode <mode-id>; auto restores routing)",
|
|
8
|
+
"rp": "Switch to the companion mode",
|
|
9
|
+
"work": "Switch to tactical work mode",
|
|
10
|
+
"sam": "Switch to SAM assault mode",
|
|
11
|
+
"sleep": "Switch to the medbay mode",
|
|
12
|
+
"auto": "Restore automatic semantic routing",
|
|
13
|
+
"char": "Switch the active preset character",
|
|
14
|
+
"scene": "Override the current scene",
|
|
15
|
+
"title": "Rename how the active character addresses you",
|
|
16
|
+
"trigger": "Add a keyword trigger mapping",
|
|
17
|
+
"tlrp": "Show tlrp status: current mode, routing chain, character, scene, enabled triggers"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "TLRP · 多形态矩阵引擎",
|
|
4
|
+
"description": "注册 tlrp 设置命名空间、可自由增删的 modes 形态矩阵、/mode 与形态别名指令、{{tlrp_character}} / {{tlrp_directive}} / {{now}} 动态提示变量、每轮尾部实时状态块,并按各形态的 toolPolicy 物理拦截工具。"
|
|
5
|
+
},
|
|
6
|
+
"command": {
|
|
7
|
+
"mode": "切换到指定运行形态(/mode <形态ID>;auto 恢复自动路由)",
|
|
8
|
+
"rp": "切换到少女陪伴态",
|
|
9
|
+
"work": "切换到战术工作态",
|
|
10
|
+
"sam": "切换到萨姆强袭态",
|
|
11
|
+
"sleep": "切换到医疗舱微凉态",
|
|
12
|
+
"auto": "恢复自动语义分流",
|
|
13
|
+
"char": "切换当前激活的预设角色",
|
|
14
|
+
"scene": "覆盖当前所处情景",
|
|
15
|
+
"title": "修改当前角色对对方的专属称呼",
|
|
16
|
+
"trigger": "新增一组关键词触发映射",
|
|
17
|
+
"tlrp": "查看 tlrp 状态:当前形态、路由链路、角色、情景、已启用触发器"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-tlrp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "DSH 核心角色扮演与多形态矩阵引擎:注册 tlrp 设置命名空间、可自由增删的 modes 形态矩阵、/mode 与形态别名指令拦截、{{tlrp_character}}/{{tlrp_directive}}/{{tlrp_memory_context}}/{{now}} 动态变量、每轮尾部实时状态与 tlmemory 记忆桥注入,并按形态的 toolPolicy 物理拦截工具。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"registry": "https://registry.npmjs.org/"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./index.cjs",
|
|
11
|
+
"module": "./index.js",
|
|
12
|
+
"icon": "./icon.svg",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./index.js",
|
|
16
|
+
"require": "./index.cjs"
|
|
17
|
+
},
|
|
18
|
+
"./client": {
|
|
19
|
+
"default": "./client.js"
|
|
20
|
+
},
|
|
21
|
+
"./locale/*.json": "./locale/*.json",
|
|
22
|
+
"./icon": "./icon.svg",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"index.js",
|
|
27
|
+
"index.cjs",
|
|
28
|
+
"client.js",
|
|
29
|
+
"lib/core.cjs",
|
|
30
|
+
"lib/schema.cjs",
|
|
31
|
+
"lib/resolve.cjs",
|
|
32
|
+
"lib/memory-bridge.cjs",
|
|
33
|
+
"cordis.patch.yml",
|
|
34
|
+
"locale/*.json",
|
|
35
|
+
"icon.svg",
|
|
36
|
+
"README.md",
|
|
37
|
+
"scripts/install-into-profile.mjs",
|
|
38
|
+
"scripts/apply-check.mjs",
|
|
39
|
+
"scripts/mutate-check.mjs",
|
|
40
|
+
"scripts/rewrite-persona.mjs",
|
|
41
|
+
"scripts/client-check.mjs",
|
|
42
|
+
"test/core.test.mjs",
|
|
43
|
+
"test/memory-bridge.test.mjs"
|
|
44
|
+
],
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"dsh-plugin",
|
|
50
|
+
"dsh-bundle",
|
|
51
|
+
"roleplay",
|
|
52
|
+
"system-prompt",
|
|
53
|
+
"tlrp"
|
|
54
|
+
],
|
|
55
|
+
"dsh": {
|
|
56
|
+
"bundle": {
|
|
57
|
+
"patch": "./cordis.patch.yml"
|
|
58
|
+
},
|
|
59
|
+
"client": {
|
|
60
|
+
"platform": "web",
|
|
61
|
+
"inject": [
|
|
62
|
+
"@deepseek-ai/dsh-client-locale",
|
|
63
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"scripts": {
|
|
69
|
+
"test": "node --test test/core.test.mjs test/memory-bridge.test.mjs",
|
|
70
|
+
"check:apply": "node scripts/apply-check.mjs",
|
|
71
|
+
"check:mutate": "node scripts/mutate-check.mjs",
|
|
72
|
+
"check:client": "node scripts/client-check.mjs",
|
|
73
|
+
"install:profile": "node scripts/install-into-profile.mjs"
|
|
74
|
+
}
|
|
6
75
|
}
|