lume-dsh-plugin 0.7.1 → 0.7.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/README.md +1 -1
- package/lib/host/rpc-bridge.js +122 -0
- package/lib/index.js +70 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -307,7 +307,7 @@ Lume 因此选择「观察 + 重锚」:压缩发生时记录规模,在随后
|
|
|
307
307
|
|
|
308
308
|
## 宿主兼容性
|
|
309
309
|
|
|
310
|
-
- **RPC 通道必须注册在注入了 `webServer`
|
|
310
|
+
- **RPC 通道必须注册在注入了 `webServer` 的作用域里,且不能包 `effect`**:宿主的 `connection.rpc.handle` 内部以**调用方 ctx** 执行 `webServer.register(route)`(`register(owner, ...)` 里是 `owner.effect(() => owner.webServer.register(route))`)。而 cordis 的 `effect` 会另起一个 **fiber**,**注入授权不随子 fiber 继承**——所以 `webCtx.effect(() => webCtx.connection.rpc.handle(...))` 仍然越权抛错(0.7.1 就栽在这),必须像宿主自带的 `dsh-ppt` 那样**直接在 inject 回调里调用**。该注册还被挪到 `apply` 末尾并加独立 try/catch:它只服务客户端菜单,失败绝不该影响人设注入与工具。0.6.2 / 0.7.0 / 0.7.1 在 DSH Desktop 0.9.1 上的故障(DSH 起不来、或界面人设菜单空白)都源于此,请升级到 ≥0.7.2。
|
|
311
311
|
- **插件不会拖垮宿主**:`apply()` 外层有兜底 try/catch,插件内部的任何异常都降级为「部分功能不可用 + `logger.error`」,不再阻断 DSH 启动。
|
|
312
312
|
- **peer 声明只留宿主运行时保证提供的包**:`@deepseek-ai/dsh-client-ui-primitives` 这类前端包由宿主模块图在运行时提供,**不声明为 peer**——新版桌面安装器做严格 peer 闭包校验,声明它会连带检查它自己的 peer(`dsh-client-runtime`),导致安装/更新被拒绝(desktop 会写进 `.generations-deferred.json` 并冻结整个 profile 迁移)。它仍在 `devDependencies`(tsc 类型与 tsdown external 需要)。
|
|
313
313
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection RPC 的**桥接协议**实现(纯函数 + 一个路由工厂)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:宿主 `connection.rpc.handle(channel, handler)` 内部要求
|
|
5
|
+
* 「频道注册属于**调用方 fiber**」(见 dsh-client-connection 的 `register(owner, …)` →
|
|
6
|
+
* `owner.effect(() => owner.webServer.register(route))`),在 DSH Desktop 0.9.1 上我们的
|
|
7
|
+
* 入口 fiber 解析不到 `webServer` → 抛 `cannot get property "webServer" without inject`。
|
|
8
|
+
* 于是准备一条**不依赖 connection.rpc 的回退路径**:照宿主自己的写法把 HTTP 路由挂到
|
|
9
|
+
* `webServer` 上(宿主自带的 dsh-ppt 就是这么做的),报文与客户端 `conn.rpc.call` 完全一致:
|
|
10
|
+
*
|
|
11
|
+
* 请求 POST {channel}/{endpoint} content-type: application/json
|
|
12
|
+
* { type: "client-request", rpcId, method: endpoint, payload }
|
|
13
|
+
* 响应 200 { type: "server-response", rpcId, result: { ok, value|error } }
|
|
14
|
+
*
|
|
15
|
+
* 纯函数部分(解析/组装)可单测,路由工厂只做 Node req/res 的胶水。
|
|
16
|
+
*/
|
|
17
|
+
/** 端点段白名单,与宿主 ENDPOINT_SEGMENT_PATTERN 一致。 */
|
|
18
|
+
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
|
|
19
|
+
/** 组装一条回信(含 rpcId 与错误信封补全)。 */
|
|
20
|
+
export function connectionResponse(rpcId, result) {
|
|
21
|
+
return JSON.stringify({ type: "server-response", rpcId, result: withErrorDetails(result) });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 补全错误信封:客户端的 `parseConnectionResponse` 要求失败时
|
|
25
|
+
* `error.details` 是对象,缺失会直接抛 "invalid server-response failure"。
|
|
26
|
+
* (这是 0.7.2 之前一直存在的隐患:任何错误路径都会让客户端调用炸掉。)
|
|
27
|
+
*/
|
|
28
|
+
export function withErrorDetails(result) {
|
|
29
|
+
const envelope = result;
|
|
30
|
+
if (!envelope || typeof envelope !== "object" || envelope.ok !== false)
|
|
31
|
+
return result;
|
|
32
|
+
const error = envelope.error ?? {};
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: {
|
|
36
|
+
code: typeof error.code === "string" ? error.code : "bad-request",
|
|
37
|
+
message: typeof error.message === "string" ? error.message : "unknown error",
|
|
38
|
+
details: error.details && typeof error.details === "object" ? error.details : {},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** 解析客户端请求。与宿主的 rpcFetchHandler 语义对齐(404/415/400 + 信封校验)。 */
|
|
43
|
+
export function parseClientRequest(channel, request) {
|
|
44
|
+
if ((request.method ?? "GET").toUpperCase() !== "POST")
|
|
45
|
+
return { kind: "respond", status: 404, body: "not found", contentType: "text/plain" };
|
|
46
|
+
const pathname = (request.url ?? "/").split("?")[0] ?? "/";
|
|
47
|
+
if (!pathname.startsWith(`${channel}/`))
|
|
48
|
+
return { kind: "respond", status: 404, body: "not found", contentType: "text/plain" };
|
|
49
|
+
const endpoint = pathname.slice(channel.length + 1);
|
|
50
|
+
if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
|
51
|
+
return { kind: "respond", status: 404, body: "not found", contentType: "text/plain" };
|
|
52
|
+
}
|
|
53
|
+
const mime = (request.contentType ?? "").split(";")[0]?.trim().toLowerCase();
|
|
54
|
+
if (mime !== "application/json")
|
|
55
|
+
return { kind: "respond", status: 415, body: "content type must be application/json", contentType: "text/plain" };
|
|
56
|
+
let body;
|
|
57
|
+
try {
|
|
58
|
+
body = JSON.parse(request.rawBody);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { kind: "respond", status: 400, body: "body is not JSON", contentType: "text/plain" };
|
|
62
|
+
}
|
|
63
|
+
const envelope = body;
|
|
64
|
+
if (!envelope || typeof envelope !== "object" || envelope.type !== "client-request" || typeof envelope.rpcId !== "string" || typeof envelope.method !== "string") {
|
|
65
|
+
const rpcId = typeof envelope?.rpcId === "string" ? String(envelope.rpcId) : "invalid-request";
|
|
66
|
+
return { kind: "respond", status: 200, body: connectionResponse(rpcId, { ok: false, error: { code: "gateway/bad-request", message: "invalid client-request message" } }), contentType: "application/json" };
|
|
67
|
+
}
|
|
68
|
+
if (envelope.method !== endpoint) {
|
|
69
|
+
return { kind: "respond", status: 200, body: connectionResponse(envelope.rpcId, { ok: false, error: { code: "gateway/bad-request", message: `endpoint mismatch: ${envelope.method} vs ${endpoint}` } }), contentType: "application/json" };
|
|
70
|
+
}
|
|
71
|
+
return { kind: "dispatch", rpcId: envelope.rpcId, endpoint, payload: envelope.payload };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 组装可交给 `webServer.register()` 的路由(宿主自带 dsh-ppt 同形:
|
|
75
|
+
* `{ kind: "prefix", path: channel, handler(req, res) }`)。
|
|
76
|
+
*
|
|
77
|
+
* @param channel - RPC 频道(须匹配 /^\/[A-Za-z0-9._~-]+$/,且不得为 /api)
|
|
78
|
+
* @param dispatch - 业务分发(endpoint + payload → lume 信封)
|
|
79
|
+
* @param guard - 可选的浏览器信任闸(宿主 connection.requestRejection),返回状态码即拒绝
|
|
80
|
+
*/
|
|
81
|
+
export function makeRpcRoute(channel, dispatch, guard) {
|
|
82
|
+
return {
|
|
83
|
+
kind: "prefix",
|
|
84
|
+
path: channel,
|
|
85
|
+
handler: async (req, res) => {
|
|
86
|
+
const rejection = guard?.(req);
|
|
87
|
+
if (rejection !== undefined) {
|
|
88
|
+
res.writeHead(rejection);
|
|
89
|
+
res.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let rawBody = "";
|
|
93
|
+
try {
|
|
94
|
+
for await (const chunk of req)
|
|
95
|
+
rawBody += typeof chunk === "string" ? chunk : String(chunk);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* 客户端断开:下面按空 body 处理并回 400 */
|
|
99
|
+
}
|
|
100
|
+
const parsed = parseClientRequest(channel, {
|
|
101
|
+
method: req?.method,
|
|
102
|
+
url: req?.url,
|
|
103
|
+
contentType: req?.headers?.["content-type"] ?? null,
|
|
104
|
+
rawBody,
|
|
105
|
+
});
|
|
106
|
+
if (parsed.kind === "respond") {
|
|
107
|
+
res.writeHead(parsed.status, { "content-type": parsed.contentType });
|
|
108
|
+
res.end(parsed.body);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let result;
|
|
112
|
+
try {
|
|
113
|
+
result = await dispatch(parsed.endpoint, parsed.payload);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
result = { ok: false, error: { code: "internal", message: String(error?.message ?? error) } };
|
|
117
|
+
}
|
|
118
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
119
|
+
res.end(connectionResponse(parsed.rpcId, result));
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import z from "@deepseek-ai/schemastery";
|
|
|
21
21
|
import { buildPersonaContractSection, buildPersonaRuntimeSection } from "./host/injection.js";
|
|
22
22
|
import { loadPersonalities, NONE_PERSONA } from "./host/personalities.js";
|
|
23
23
|
import { createLumeRpcHandler } from "./host/rpc.js";
|
|
24
|
+
import { makeRpcRoute } from "./host/rpc-bridge.js";
|
|
24
25
|
import { FilePersonaStore, migrateLegacyState, PersonaStore } from "./host/store.js";
|
|
25
26
|
import { IdentityStore, LUME_IDENTITY_SPEC, zodLike } from "./host/identity.js";
|
|
26
27
|
import { PersonaRegistry } from "./host/registry.js";
|
|
@@ -1214,24 +1215,9 @@ function applyInner(ctx, config = {}) {
|
|
|
1214
1215
|
},
|
|
1215
1216
|
});
|
|
1216
1217
|
// ── RPC 通道 ──
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
// "cannot get property \"webServer\" without inject"。宿主自带的 dsh-ppt / dsh-api-gateway
|
|
1221
|
-
// 也都是 `ctx.inject(["webServer"], (webCtx) => webCtx.connection.rpc.handle(...))` 这个写法。
|
|
1222
|
-
// 用作用域注入而不是把 webServer 塞进顶层 inject:没有 web 载体的宿主(headless)里只
|
|
1223
|
-
// 失去 RPC 通道,插件其余功能照常工作,不会被 inject 卡住。
|
|
1224
|
-
ctx.inject(["webServer"], (webCtx) => {
|
|
1225
|
-
webCtx.effect(() => webCtx.connection.rpc.handle(LUME_CHANNEL, async (endpoint, payload) => {
|
|
1226
|
-
currentStore ??= await storesReady;
|
|
1227
|
-
identity ??= await identityReady;
|
|
1228
|
-
const result = await handleEndpoint(endpoint, payload);
|
|
1229
|
-
if (endpoint !== "list" && endpoint !== "getSessionPersona") {
|
|
1230
|
-
webCtx.logger?.warn?.(`lume: rpc ${endpoint} ${JSON.stringify(payload ?? {})} → ok=${result.ok}${result.ok ? "" : ` code=${result.error.code}`}`);
|
|
1231
|
-
}
|
|
1232
|
-
return result;
|
|
1233
|
-
}, { authority: "trusted-host" }), "lume: rpc channel");
|
|
1234
|
-
});
|
|
1218
|
+
// 挪到 apply 的最后注册:它只服务客户端菜单(人设列表/蒸馏/管理),
|
|
1219
|
+
// 绝不该挡住人设段、工具与易变段的注册(0.7.1 的教训:这里抛错 → 整段 apply 中断 → 界面看不到人设)。
|
|
1220
|
+
// 注册本身见下方 registerRpcChannel()。
|
|
1235
1221
|
// ── 系统提示词段落 ──
|
|
1236
1222
|
ctx.effect(() => ctx.systemPrompt.section({
|
|
1237
1223
|
name: LUME_PERSONA_SECTION,
|
|
@@ -1292,4 +1278,70 @@ function applyInner(ctx, config = {}) {
|
|
|
1292
1278
|
},
|
|
1293
1279
|
});
|
|
1294
1280
|
}, "lume.tool-notice-context()");
|
|
1281
|
+
registerRpcChannel(ctx);
|
|
1282
|
+
/**
|
|
1283
|
+
* 注册客户端 RPC 通道(`/lume`)。放在最后 + 独立 try/catch:
|
|
1284
|
+
* 它只服务客户端菜单,任何失败都不该影响人设注入、工具与易变段。
|
|
1285
|
+
*
|
|
1286
|
+
* 关键细节(0.7.1 踩过):必须**直接在注入作用域里调用** `connection.rpc.handle`,
|
|
1287
|
+
* 不能包 `effect`——新宿主的实现在 `register(owner, ...)` 里执行
|
|
1288
|
+
* `owner.effect(() => owner.webServer.register(route))`,而 cordis 的 `effect` 会另起
|
|
1289
|
+
* 一个 fiber,**注入授权不随子 fiber 继承**,于是 `owner.webServer` 再次越权并抛
|
|
1290
|
+
* "cannot get property \"webServer\" without inject"。宿主自带的 dsh-ppt 也正是直接在
|
|
1291
|
+
* inject 回调里调用(不包 effect)。没有 web 载体的宿主(headless)只是没有 RPC 通道。
|
|
1292
|
+
*/
|
|
1293
|
+
function registerRpcChannel(scope) {
|
|
1294
|
+
const dispatch = async (endpoint, payload) => {
|
|
1295
|
+
currentStore ??= await storesReady;
|
|
1296
|
+
identity ??= await identityReady;
|
|
1297
|
+
return handleEndpoint(endpoint, payload);
|
|
1298
|
+
};
|
|
1299
|
+
// 依赖组合与宿主自带 dsh-api-gateway 一致(["connection", "webServer"])。
|
|
1300
|
+
scope.inject(["connection", "webServer"], (webCtx) => {
|
|
1301
|
+
const notes = [];
|
|
1302
|
+
try {
|
|
1303
|
+
// ── 主路径:宿主公开 API。频道注册归属**调用方 fiber**,所以必须在注入作用域里调用。
|
|
1304
|
+
webCtx.connection.rpc.handle(LUME_CHANNEL, async (endpoint, payload) => {
|
|
1305
|
+
const result = await dispatch(endpoint, payload);
|
|
1306
|
+
if (endpoint !== "list" && endpoint !== "getSessionPersona") {
|
|
1307
|
+
webCtx.logger?.warn?.(`lume: rpc ${endpoint} → ok=${result.ok}${result.ok ? "" : ` code=${result.error.code}`}`);
|
|
1308
|
+
}
|
|
1309
|
+
return result;
|
|
1310
|
+
}, { authority: "trusted-host" });
|
|
1311
|
+
notes.push("connection.rpc.handle");
|
|
1312
|
+
}
|
|
1313
|
+
catch (error) {
|
|
1314
|
+
notes.push(`rpc.handle 失败(${describeError(error)})`);
|
|
1315
|
+
// ── 回退:自己往 webServer 注册 HTTP 路由(宿主自带 dsh-ppt 的写法)。
|
|
1316
|
+
// 报文与客户端 conn.rpc.call 完全一致,见 host/rpc-bridge.ts。
|
|
1317
|
+
try {
|
|
1318
|
+
const guard = typeof webCtx.connection?.requestRejection === "function"
|
|
1319
|
+
? (req) => webCtx.connection.requestRejection(req)
|
|
1320
|
+
: undefined;
|
|
1321
|
+
webCtx.webServer.register(makeRpcRoute(LUME_CHANNEL, dispatch, guard));
|
|
1322
|
+
notes.push("webServer.register(自注册路由)");
|
|
1323
|
+
}
|
|
1324
|
+
catch (fallbackError) {
|
|
1325
|
+
notes.push(`webServer.register 失败(${describeError(fallbackError)})`);
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
// 两条都失败时补上环境形状,便于下一轮定位(宿主 API 变更时这行就是证据)。
|
|
1329
|
+
if (!notes.some((note) => !note.includes("失败"))) {
|
|
1330
|
+
notes.push(`shapes: connection=${typeof webCtx.connection} rpc=${typeof webCtx.connection?.rpc} handle=${typeof webCtx.connection?.rpc?.handle} webServer=${typeof webCtx.webServer} get=${typeof webCtx.get}`);
|
|
1331
|
+
}
|
|
1332
|
+
webCtx.logger?.warn?.(`lume: RPC 通道 ${LUME_CHANNEL} = ${notes.join(" | ")}`);
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
/** 把任意抛出物变成可读的一行(宿主 logger 直接打对象会变成 `{}`,所以要自己转字符串)。 */
|
|
1336
|
+
function describeError(error) {
|
|
1337
|
+
if (error instanceof Error)
|
|
1338
|
+
return error.message;
|
|
1339
|
+
try {
|
|
1340
|
+
const text = JSON.stringify(error);
|
|
1341
|
+
return text && text !== "{}" ? text : String(error);
|
|
1342
|
+
}
|
|
1343
|
+
catch {
|
|
1344
|
+
return String(error);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1295
1347
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lume-dsh-plugin",
|
|
3
3
|
"description": "微光 (Lume) — DSH Desktop 增强插件:给会话装上工程纪律与真实关系。纪律层约束「如何正确完成任务」——意图路由、阶段门控、真实工具证据、交付前复核、文档能力感知;方法层把量化需求、改动台账、假设台账与项目知识变成可检查的产出,并用行为触发器在轨迹上纠偏(撒网不收敛 / 连写不验 / 死路重撞);人设层塑造「以何种风格表达」——从聊天记录、小说、剧本、设定文档蒸馏具名角色,长期记忆与风格随对话演进。约束按需注入,闲聊不额外付 token。",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|