@nightpainters/dsh-ling 1.5.1 → 1.5.3
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/ACCESS-DESIGN.md +10 -7
- package/CHANGELOG.md +140 -3
- package/DESIGN.md +162 -18
- package/README.md +66 -32
- package/lib/audit/access-log.js +181 -25
- package/lib/audit/index.js +45 -3
- package/lib/audit/patch-spec.js +1 -1
- package/lib/client.js +1183 -88
- package/lib/host/api.js +716 -83
- package/lib/host/backfill.js +8 -2
- package/lib/host/feedback.js +112 -2
- package/lib/host/freeze.js +52 -1
- package/lib/host/genesis.js +27 -5
- package/lib/host/guard.js +39 -2
- package/lib/host/habit-gen.js +12 -4
- package/lib/host/import-file.js +62 -6
- package/lib/host/import-log.js +80 -2
- package/lib/host/inject.js +49 -12
- package/lib/host/l1.js +11 -4
- package/lib/host/lifecycle.js +13 -2
- package/lib/host/memory.js +425 -33
- package/lib/host/mode-service.js +85 -0
- package/lib/host/mode.js +29 -9
- package/lib/host/persona.js +38 -7
- package/lib/host/retitle.js +6 -0
- package/lib/host/session-line.js +688 -0
- package/lib/host/snapshot.js +48 -7
- package/lib/host/summarizer.js +159 -3
- package/lib/host/surgery.js +600 -0
- package/lib/host/tools.js +12 -1
- package/lib/host/tree-ops.js +1 -1
- package/lib/index.js +22 -29
- package/package.json +51 -4
- package/tools/apply-access-log-patch.mjs +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// dsh-ling host — 模式服务(P3-A,2026-09-29):把「给某个会话设模式」做成跨插件能力。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:器灵的模式记录是**私有**的(memory.db 的 `session_meta.mode`),
|
|
4
|
+
// 而 memory / gate / settings 三个依赖只活在器灵自己的 apply 闭包里 —— 别的插件
|
|
5
|
+
// (如飞书桥 dsh-feishu-host)拿不到,于是「来自飞书的会话默认走生活挡位」一直做不到。
|
|
6
|
+
// 本模块把这个动作注册成 cordis 服务 `dsh-ling/mode`,调用方按**软依赖**取用:
|
|
7
|
+
//
|
|
8
|
+
// const ling = ctx.get('dsh-ling/mode'); // 拿不到就是 undefined,不是错误
|
|
9
|
+
// await ling?.applyMode(sessionId, 'life', { keepModel: { provider, model } });
|
|
10
|
+
//
|
|
11
|
+
// 契约(语义与 mode.js 一致,差别只在默认值):
|
|
12
|
+
// applyMode(sessionId, modeKey, opts) -> Promise<Result>
|
|
13
|
+
// · **不改全局** `settings.mode.lastMode`
|
|
14
|
+
// —— GUI 的 `applyModeToSession` 才改,那是「同时设为新会话默认」的语义;
|
|
15
|
+
// 跨插件调用若照做,会把飞书的挡位泄漏成桌面端的默认值。
|
|
16
|
+
// · opts.keepModel 调用方自带的模型。**跨插件调用必须传** —— 不传就退回
|
|
17
|
+
// 「读全局当前选择」,会把 GUI 正选着的模型覆盖到对方会话上。
|
|
18
|
+
// · opts.syncEffort 默认 true(模式的实质就是推理等级);false = 只记模式。
|
|
19
|
+
// · **失败不抛**:以 `{ok:false, reason}` 返回。调用方不该因为器灵缺席/出错
|
|
20
|
+
// 而中断自己的流程。
|
|
21
|
+
//
|
|
22
|
+
// Result = { ok, mode, reasoningEffort, warning?, queued?, lastMode?, reason? }
|
|
23
|
+
import { KNOWN_MODES, applyModeToSession, currentMode } from './mode.js';
|
|
24
|
+
|
|
25
|
+
export const MODE_SERVICE_NAME = 'dsh-ling/mode';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 把模式能力注册成服务。
|
|
29
|
+
*
|
|
30
|
+
* 用 `ctx.provide()` 而不是 `class extends Service`:本插件的 apply 是函数形态,
|
|
31
|
+
* 且 dsh-ling 以 `file:` + Junction 加载、包内无 node_modules,`import '@deepseek-ai/cordis'`
|
|
32
|
+
* 会走 realpath 回到**本包根目录**再向上找 —— 找不到。`ctx.provide` 是同一套
|
|
33
|
+
* reflect 机制的上层入口,零 import、同样由 fiber 生命周期托管(返回 disposer)。
|
|
34
|
+
*
|
|
35
|
+
* @param {object} ctx cordis 上下文(插件自己的)。
|
|
36
|
+
* @param {object} deps `{ gate, memory, settings }`,与 mode.js 同源。
|
|
37
|
+
* @returns {() => void} disposer(拆服务)。
|
|
38
|
+
*/
|
|
39
|
+
export function registerModeService(ctx, { gate, memory, settings }) {
|
|
40
|
+
const api = {
|
|
41
|
+
name: MODE_SERVICE_NAME,
|
|
42
|
+
knownModes: KNOWN_MODES,
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 给某个会话设模式。
|
|
46
|
+
*
|
|
47
|
+
* @param {string} sessionId 目标会话(飞书侧形如 `feishu-<uuid>`)。
|
|
48
|
+
* @param {'work'|'life'} modeKey
|
|
49
|
+
* @param {{keepModel?: {provider: string, model: string}, syncEffort?: boolean}} [opts]
|
|
50
|
+
*/
|
|
51
|
+
async applyMode(sessionId, modeKey, opts = {}) {
|
|
52
|
+
if (!sessionId) return { ok: false, reason: 'no-session-id' };
|
|
53
|
+
if (!KNOWN_MODES.includes(modeKey)) return { ok: false, reason: `unknown-mode:${modeKey}` };
|
|
54
|
+
try {
|
|
55
|
+
return await applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey, {
|
|
56
|
+
setDefault: false, // 跨插件:只改这条会话,不动全局默认
|
|
57
|
+
keepModel: opts.keepModel ?? null,
|
|
58
|
+
syncEffort: opts.syncEffort !== false,
|
|
59
|
+
});
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { ok: false, reason: 'throw:' + String(e?.message ?? e) };
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/** 读某个会话当前生效的模式(session_meta → lastMode → 'life')。 */
|
|
66
|
+
currentMode(sessionId) {
|
|
67
|
+
try {
|
|
68
|
+
return currentMode(memory, settings, sessionId);
|
|
69
|
+
} catch {
|
|
70
|
+
return 'life';
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const dispose = ctx.provide(MODE_SERVICE_NAME, api);
|
|
77
|
+
console.info('[dsh-ling] mode service provided: %s', MODE_SERVICE_NAME);
|
|
78
|
+
return typeof dispose === 'function' ? dispose : () => {};
|
|
79
|
+
} catch (e) {
|
|
80
|
+
// 同名服务已被别的 fiber 占用(例如器灵被加载了两遍):只记日志,不抛 ——
|
|
81
|
+
// 器灵其余功能照常,不能因为一个可选能力注册失败就整插件起不来。
|
|
82
|
+
console.debug('[dsh-ling] mode service provide failed', e);
|
|
83
|
+
return () => {};
|
|
84
|
+
}
|
|
85
|
+
}
|
package/lib/host/mode.js
CHANGED
|
@@ -45,13 +45,26 @@ export function currentModelSelection(ctx) {
|
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} [opts] P3-A(2026-09-29) 加的调用方选项,缺省 = 原行为(GUI 路径)。
|
|
50
|
+
* @param {boolean} [opts.setDefault] 是否把全局 `mode.lastMode` 一起改掉(=「设为新会话默认」)。
|
|
51
|
+
* GUI 的 applyModeToSession 走 true;跨插件(器灵模式服务)走 false —— 否则飞书的挡位
|
|
52
|
+
* 会泄漏成桌面端的默认值。
|
|
53
|
+
* @param {?{provider:string,model:string}} [opts.keepModel] 调用方自带的「当前模型」。
|
|
54
|
+
* 不传才退回读**全局**当前选择;跨插件调用必须传,否则会把别人的模型写回本会话。
|
|
55
|
+
* @param {boolean} [opts.syncEffort] 是否同步推理等级,默认 true(模式的实质就是它)。
|
|
56
|
+
*/
|
|
57
|
+
async function doApply(ctx, memory, settings, sessionId, modeKey, opts = {}) {
|
|
49
58
|
const reasoningEffort = resolveModeEffort(settings.get(), modeKey);
|
|
50
59
|
const controller = svc(ctx, 'sessionController');
|
|
51
60
|
let warning;
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
if (opts.syncEffort === false) {
|
|
62
|
+
warning = 'effort-sync-skipped';
|
|
63
|
+
} else if (controller && typeof controller.selectModel === 'function') {
|
|
64
|
+
const keep = (opts.keepModel && opts.keepModel.provider && opts.keepModel.model)
|
|
65
|
+
? opts.keepModel
|
|
66
|
+
: currentModelSelection(ctx); // 当前模型:原样带回,不改
|
|
67
|
+
if (!keep || !keep.provider || !keep.model) {
|
|
55
68
|
warning = 'unknown-current-model; 推理等级未同步(拒绝替用户指定模型)';
|
|
56
69
|
console.debug('[dsh-ling]', warning);
|
|
57
70
|
} else {
|
|
@@ -73,23 +86,30 @@ async function doApply(ctx, memory, settings, sessionId, modeKey) {
|
|
|
73
86
|
warning = 'no-sessionController; mode recorded only';
|
|
74
87
|
}
|
|
75
88
|
memory.setSessionMode(sessionId, modeKey);
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
let lastMode = settings.get().mode?.lastMode;
|
|
90
|
+
if (opts.setDefault === true) {
|
|
91
|
+
const s = await settings.update({ mode: { lastMode: modeKey } }); // D2 跟随
|
|
92
|
+
lastMode = s.mode.lastMode;
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, warning, sessionId, mode: modeKey, reasoningEffort, lastMode };
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
/**
|
|
81
98
|
* Apply mode to a session (D4): running → queue (returns queued), idle → apply now.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} [opts] 见 doApply。缺省 `{setDefault:true}` = GUI 语义(改本会话 + 设为默认)。
|
|
82
101
|
*/
|
|
83
|
-
export async function applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey) {
|
|
102
|
+
export async function applyModeToSession(ctx, gate, memory, settings, sessionId, modeKey, opts = {}) {
|
|
84
103
|
if (!sessionId) return { ok: false, reason: 'no-session-id' };
|
|
85
104
|
if (!KNOWN_MODES.includes(modeKey)) return { ok: false, reason: `unknown-mode:${modeKey}` };
|
|
105
|
+
const merged = { setDefault: true, ...opts };
|
|
86
106
|
const queued = gate.enqueueIfRunning(sessionId, () => {
|
|
87
|
-
doApply(ctx, memory, settings, sessionId, modeKey).catch((e) =>
|
|
107
|
+
doApply(ctx, memory, settings, sessionId, modeKey, merged).catch((e) =>
|
|
88
108
|
console.debug('[dsh-ling] queued mode apply failed', e),
|
|
89
109
|
);
|
|
90
110
|
});
|
|
91
111
|
if (queued) return { ok: true, queued: true, running: true, sessionId, mode: modeKey };
|
|
92
|
-
return await doApply(ctx, memory, settings, sessionId, modeKey);
|
|
112
|
+
return await doApply(ctx, memory, settings, sessionId, modeKey, merged);
|
|
93
113
|
}
|
|
94
114
|
|
|
95
115
|
/** Current mode for a session: session_meta → settings.lastMode. */
|
package/lib/host/persona.js
CHANGED
|
@@ -42,6 +42,10 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
42
42
|
toneWork: '', // 工作模式语气('' = 跟随 tone)
|
|
43
43
|
toneLife: '', // 生活模式语气('' = 跟随 tone)
|
|
44
44
|
extraLore: '', // 自由扩展设定
|
|
45
|
+
duty: '', // 职业/职责/责任(1.5.2 I-5):改变率极低的身份级设定。**只收陈述句**
|
|
46
|
+
// (「我承担/我擅长/我能给出」),**禁授权式**(「我应当/我必须/我的任务/我的责任是」)——
|
|
47
|
+
// 授权式写法把人写成"被授权者",是语义劫持最好用的钩子(同 [规矩] 段头那次的教训)。
|
|
48
|
+
// 默认只能是 ''(约束 B):机制不设非空默认、也不从历史/模式推断预填。
|
|
45
49
|
pronoun: '她', // 第三人称代词(她/他/TA/它,或自定义):用于「给模型看的提示词」;界面文案第二步跟随
|
|
46
50
|
},
|
|
47
51
|
styles: {
|
|
@@ -75,6 +79,11 @@ export const DEFAULT_SETTINGS = Object.freeze({
|
|
|
75
79
|
trackWorkspaces: ['*'],
|
|
76
80
|
idleRefresh: true, // B:长会话在空闲边界按需刷新 L1(运行中永不刷新)
|
|
77
81
|
idleRefreshMinIntervalMin: 10, // 同一个会话两次空闲刷新的最小间隔(分钟;0 = 不限制)
|
|
82
|
+
// E0(1.5.2)「那一行跟着会话走」:每轮用户在 `agent/pre-step` 追加一条带序号的活行
|
|
83
|
+
// (标题 + 最新进展,只追不替,见 lib/host/session-line.js)。
|
|
84
|
+
// 关掉即回到原样:已写入的历史行留着(平台无删除),但不再追加。
|
|
85
|
+
// ⚠️ 这是**内存态**设置(`settings-file.js` 的 get() 每轮读),面板 update() 立即改 ⇒ 不用重启宿主。
|
|
86
|
+
sessionProgressLine: true,
|
|
78
87
|
},
|
|
79
88
|
updates: { applyWhileRunning: false }, // D4 语义固化:默认冻结
|
|
80
89
|
guard: { // /api 守卫(2026-09-16 加固 ①+②;2026-09-17 加固 ③)
|
|
@@ -160,12 +169,16 @@ export function assemblePersona(settings, modeKey) {
|
|
|
160
169
|
// 为什么不把每条 push 都用 '\n\n' 连接:那样条目之间会变成双倍空行(段内反而更糊)。
|
|
161
170
|
const segs = [];
|
|
162
171
|
const self = [];
|
|
163
|
-
|
|
172
|
+
// 身份段正文过 escSegBrackets:与底线/规矩/习惯/承担/设定同规格,禁止正文自造段边界。
|
|
173
|
+
// **只包正文**:段头 [身份·X] 的方括号是字面量,保持半角(段边界判据是"行首是不是 [")。
|
|
174
|
+
// 但段头里嵌着 aiName —— 名字本身是用户文本,所以**逐字段**转义:名字/称呼/自述各过一遍,
|
|
175
|
+
// 有意的引号(")、分号(;)、逗号(,)一律不动(它们不是段边界判据的一部分)。
|
|
176
|
+
const who = p.aiName ? `你是"${escSegBrackets(p.aiName)}"` : '你是 DeepSeek 助手';
|
|
164
177
|
const callName = userTitleForMode(p.userTitle, mode); // 工作=正式名(首段),生活=昵称(尾段)
|
|
165
|
-
const call = callName ? `;你称用户为"${callName}"` : '';
|
|
178
|
+
const call = callName ? `;你称用户为"${escSegBrackets(callName)}"` : '';
|
|
166
179
|
// 定位自述允许多行书写,注入时压缩为一行(避免破坏身份句段落)
|
|
167
180
|
const titleOneLine = p.aiTitle ? p.aiTitle.replace(/\s+/g, ' ').trim() : '';
|
|
168
|
-
const title = titleOneLine ? `,${titleOneLine}` : '';
|
|
181
|
+
const title = titleOneLine ? `,${escSegBrackets(titleOneLine)}` : '';
|
|
169
182
|
// 结尾标点归一:title 自带句尾标点时不重复补(否则会出现 "拜上。。")
|
|
170
183
|
const headSelf = `${who}${call}${title}`;
|
|
171
184
|
self.push(headSelf.replace(/([。.!!??])\s*$/, '') + '。');
|
|
@@ -174,7 +187,10 @@ export function assemblePersona(settings, modeKey) {
|
|
|
174
187
|
const tPick = mode === 'work' ? p.toneWork : p.toneLife;
|
|
175
188
|
const tone = (tPick && TONES[tPick]) ? tPick : ((p.tone && TONES[p.tone]) ? p.tone : '');
|
|
176
189
|
if (tone) self.push(`语气:${TONES[tone]}`);
|
|
177
|
-
|
|
190
|
+
// 段头里的名字(aiName)与正文各字段一样是用户文本 ⇒ 那一处也必须转义(最容易漏的一处);
|
|
191
|
+
// 而 [身份· 与 ] 两个字面量**不转** —— 跟着一起转掉等于整段从注入面凭空消失。
|
|
192
|
+
// 正文再逐行兜一次(幂等):语气文案/语言文案将来若带上 [, 不必回来改这里。
|
|
193
|
+
segs.push([`[身份·${escSegBrackets(coreNameOf(p.aiName))}]`, ...self.map((s) => escSegBrackets(s))].join('\n'));
|
|
178
194
|
if (Array.isArray(p.bottomLines) && p.bottomLines.length) {
|
|
179
195
|
const seg = ['[底线]'];
|
|
180
196
|
for (const r of p.bottomLines) if (typeof r === 'string' && r.trim()) seg.push(`- ${escSegBrackets(r.trim())}`);
|
|
@@ -199,16 +215,31 @@ export function assemblePersona(settings, modeKey) {
|
|
|
199
215
|
(h) => h && h.byUser === true && h.amendedBy !== 'ling' && typeof h.text === 'string' && h.text.trim(),
|
|
200
216
|
);
|
|
201
217
|
if (awaiting.length) {
|
|
202
|
-
|
|
218
|
+
// 正文(h.text)过 escSegBrackets:与底线/规矩/习惯/承担/设定同规格,禁止正文自造段边界。
|
|
219
|
+
// **只包正文**:段头 [待我回应] 保持半角;包在正文外的「」引号与 、 分隔符是有意的结构,不动它们
|
|
220
|
+
// (转义只落在引号**里面**的文本上,「」配对关系逐字不变)。
|
|
221
|
+
// 为什么这一段特别要兜:此处**直接读 p.habitsPending**(没走 habitsPendingOf() 的 norm()),
|
|
222
|
+
// 而 norm() 会把换行压成空格、trim() 不会 —— 所以手改 settings.json 往某条候选的 text 里
|
|
223
|
+
// 塞「换行 + 半角 [」是通的,注入面就会真多出一个行首段头。出口转义是这条路上唯一一道闸。
|
|
224
|
+
segs.push(`[待我回应] 用户提的习惯候选:${awaiting.map((h) => `「${escSegBrackets(h.text.trim())}」`).join('、')}`
|
|
203
225
|
+ '——用 habit_resolve 表态(accept / amend 改说法 / decline)');
|
|
204
226
|
}
|
|
227
|
+
// 职责(1.5.2 I-5):身份级、改变率极低。空值**不渲染整段**(不留空段头 —— 空段头会被模型当成"有内容但被藏起来")。
|
|
228
|
+
// 正文过 escSegBrackets:与底线/规矩/习惯同规格,禁止正文自造段边界。
|
|
229
|
+
if (typeof p.duty === 'string' && p.duty.trim()) {
|
|
230
|
+
segs.push(`[承担]\n${escSegBrackets(p.duty.trim())}`);
|
|
231
|
+
}
|
|
232
|
+
// 正文过 escSegBrackets:与底线/规矩/习惯/承担同规格,禁止正文自造段边界。
|
|
233
|
+
// **只包正文**:段头 [设定] 必须保持半角 —— 段边界判据是"行首是不是 [",段头被转全角等于整段凭空消失。
|
|
205
234
|
if (typeof p.extraLore === 'string' && p.extraLore.trim()) {
|
|
206
|
-
segs.push(`[设定]\n${p.extraLore.trim()}`);
|
|
235
|
+
segs.push(`[设定]\n${escSegBrackets(p.extraLore.trim())}`);
|
|
207
236
|
}
|
|
208
237
|
const style = settings?.styles?.[mode];
|
|
209
238
|
if (typeof style === 'string' && style.trim()) {
|
|
210
239
|
// 段头与内容合并为一行(2026-09-16 瘦身:原来两行且重复"本会话为X模式")
|
|
211
|
-
|
|
240
|
+
// 正文过 escSegBrackets:与底线/规矩/习惯/承担同规格,禁止正文自造段边界(style 里的换行 + [ 也能伪造段头)。
|
|
241
|
+
// **只包正文**:段头 [工作模式]/[生活模式] 保持半角,否则段边界判据失效。
|
|
242
|
+
segs.push(`[${mode === 'work' ? '工作' : '生活'}模式]${escSegBrackets(style.trim())}`);
|
|
212
243
|
}
|
|
213
244
|
return segs.join('\n\n');
|
|
214
245
|
}
|
package/lib/host/retitle.js
CHANGED
|
@@ -14,6 +14,12 @@ import { summarizeWithRetry } from './dsweb-summary.js';
|
|
|
14
14
|
import { llmOnce } from './deepsummary.js';
|
|
15
15
|
import { rawSessionCandidates } from './memory.js';
|
|
16
16
|
|
|
17
|
+
/** 标题**生成上限**(`heuristicTitle` 切句读/硬截的落点:它决定"机器造出来的标题最长多少字")。
|
|
18
|
+
* ⚠️ 与 `session-line.js` 的 `TITLE_MAX_CHARS`(= 60)**同名但不同语义**(E1 判定,2026-10-01):
|
|
19
|
+
* 那个是"那一行的**显示兜底**夹取",作用于**任何来源**的标题(含源库自带、主人手改 ≤120)。
|
|
20
|
+
* 两处的读者与方向都不同 ⇒ **不要把两处"统一"成一个数**:统一到 58 会让手改/源库标题多切一刀,
|
|
21
|
+
* 统一到 60 会改变下面 `heuristicTitle` 在 59-60 字输入上的行为(两个方向都是行为变更)。
|
|
22
|
+
* 唯一的不变式是**显示兜底 ≥ 本值**(否则机器刚造出来的标题会在那一行里被再切一刀)。 */
|
|
17
23
|
export const TITLE_MAX_CHARS = 58; // 46 → 58:46 会把一句完整的话切成半句
|
|
18
24
|
export const LEGACY_TITLE_MAX = 46; // 1.2.2 之前的硬截上限 —— 长度"达到"它就说明这条标题曾被切过
|
|
19
25
|
export const MACHINE_TITLE_MAX = 40; // 机器生成标题的上限(手改上限在 memory.renameTitle:120)
|