lume-dsh-plugin 0.4.1
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 +238 -0
- package/assets/personalities/butler-corpus.jsonl +30 -0
- package/assets/personalities/butler.txt +12 -0
- package/assets/personalities/loli-corpus.jsonl +30 -0
- package/assets/personalities/loli.txt +12 -0
- package/assets/personalities/none-corpus.jsonl +0 -0
- package/assets/personalities/none.txt +0 -0
- package/assets/personalities/senpai-corpus.jsonl +30 -0
- package/assets/personalities/senpai.txt +12 -0
- package/assets/personalities/tsundere-corpus.jsonl +30 -0
- package/assets/personalities/tsundere.txt +12 -0
- package/assets/personalities.json +47 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2395 -0
- package/lib/core/card.js +101 -0
- package/lib/core/dialogue-mining.js +409 -0
- package/lib/core/leak-detector.js +41 -0
- package/lib/core/manifest.js +60 -0
- package/lib/core/persona-text.js +30 -0
- package/lib/core/retrieval.js +100 -0
- package/lib/core/sampling.js +46 -0
- package/lib/core/text.js +17 -0
- package/lib/host/boundary.js +32 -0
- package/lib/host/distill.js +520 -0
- package/lib/host/extraction.js +150 -0
- package/lib/host/identity.js +217 -0
- package/lib/host/injection.js +100 -0
- package/lib/host/personalities.js +49 -0
- package/lib/host/reflection.js +150 -0
- package/lib/host/registry.js +62 -0
- package/lib/host/rpc.js +284 -0
- package/lib/host/session-runtime.js +48 -0
- package/lib/host/store.js +123 -0
- package/lib/index.js +737 -0
- package/package.json +99 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export class PersonaRegistry {
|
|
2
|
+
#builtins;
|
|
3
|
+
#identity;
|
|
4
|
+
constructor(builtins, identity) {
|
|
5
|
+
this.#builtins = builtins;
|
|
6
|
+
this.#identity = identity;
|
|
7
|
+
}
|
|
8
|
+
/** 统一解析;自定义人设包装成 Persona(无语料,声音来自 promptText)。 */
|
|
9
|
+
resolve(name) {
|
|
10
|
+
if (!name)
|
|
11
|
+
return undefined;
|
|
12
|
+
const builtin = this.#builtins[name];
|
|
13
|
+
if (builtin)
|
|
14
|
+
return builtin;
|
|
15
|
+
const custom = this.#identity()?.getCustomPersona(name);
|
|
16
|
+
if (!custom)
|
|
17
|
+
return undefined;
|
|
18
|
+
return {
|
|
19
|
+
name,
|
|
20
|
+
displayName: custom.displayName,
|
|
21
|
+
description: custom.description,
|
|
22
|
+
promptText: custom.promptText,
|
|
23
|
+
corpus: custom.corpus ?? [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 生效身份名:存储档案(用户改名)优先,回退出厂名(manifest defaultName)。
|
|
28
|
+
* 自定义人设无出厂名,返回 null(展示层用 displayName)。
|
|
29
|
+
*/
|
|
30
|
+
profileNameOf(name) {
|
|
31
|
+
const identityName = this.#identity()?.getProfileName(name) ?? null;
|
|
32
|
+
if (identityName)
|
|
33
|
+
return identityName;
|
|
34
|
+
return this.#builtins[name]?.defaultName ?? null;
|
|
35
|
+
}
|
|
36
|
+
/** 下拉列表:内置 + 自定义,附生效身份名。 */
|
|
37
|
+
list() {
|
|
38
|
+
const identity = this.#identity();
|
|
39
|
+
const out = Object.values(this.#builtins).map((p) => ({
|
|
40
|
+
name: p.name,
|
|
41
|
+
displayName: p.displayName,
|
|
42
|
+
description: p.description,
|
|
43
|
+
profileName: identity?.getProfileName(p.name) ?? p.defaultName ?? null,
|
|
44
|
+
custom: false,
|
|
45
|
+
}));
|
|
46
|
+
const customs = identity?.listCustomPersonas() ?? {};
|
|
47
|
+
for (const [name, custom] of Object.entries(customs)) {
|
|
48
|
+
out.push({
|
|
49
|
+
name,
|
|
50
|
+
displayName: custom.displayName,
|
|
51
|
+
description: custom.description,
|
|
52
|
+
profileName: identity?.getProfileName(name) ?? null,
|
|
53
|
+
custom: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** 自定义人设记录 → Persona 形状的转换(供工具/RPC 校验复用)。 */
|
|
60
|
+
export function customToRecord(name, custom) {
|
|
61
|
+
return { name, displayName: custom.displayName, description: custom.description, promptText: custom.promptText, corpus: [] };
|
|
62
|
+
}
|
package/lib/host/rpc.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { settleMemoryText, STORY_MEMORY_CAP } from "./distill.js";
|
|
2
|
+
import { normalizeCard, parseCard } from "../core/card.js";
|
|
3
|
+
import { isCoreMemory } from "./injection.js";
|
|
4
|
+
function requireString(payload, field) {
|
|
5
|
+
const value = payload?.[field];
|
|
6
|
+
return typeof value === "string" && value ? value : null;
|
|
7
|
+
}
|
|
8
|
+
export function createLumeRpcHandler(deps) {
|
|
9
|
+
// store/identity/registry 必须每次调用时经 deps 取(getter)—— 宿主侧存储在
|
|
10
|
+
// 插件启动后才就绪,启动时解构会把尚未就绪的值捕获住。
|
|
11
|
+
return async (endpoint, payload) => {
|
|
12
|
+
if (!deps.store) {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
error: { code: "storage-unavailable", message: "lume storage is not ready" },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const { store, registry, identity, distill } = deps;
|
|
19
|
+
switch (endpoint) {
|
|
20
|
+
case "list": {
|
|
21
|
+
return { ok: true, value: registry.list() };
|
|
22
|
+
}
|
|
23
|
+
case "select": {
|
|
24
|
+
const sessionId = requireString(payload, "sessionId");
|
|
25
|
+
const personaName = requireString(payload, "personaName");
|
|
26
|
+
if (!sessionId)
|
|
27
|
+
return { ok: false, error: { code: "bad-request", message: "sessionId is required" } };
|
|
28
|
+
if (!personaName || !registry.resolve(personaName)) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
error: { code: "unknown-persona", message: `未知人设: ${String(personaName)}` },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
await store.select(sessionId, personaName);
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
case "getSessionPersona": {
|
|
38
|
+
const sessionId = requireString(payload, "sessionId");
|
|
39
|
+
if (!sessionId)
|
|
40
|
+
return { ok: false, error: { code: "bad-request", message: "sessionId is required" } };
|
|
41
|
+
return { ok: true, value: store.get(sessionId) };
|
|
42
|
+
}
|
|
43
|
+
case "getProfile": {
|
|
44
|
+
const personaName = requireString(payload, "personaName");
|
|
45
|
+
if (!personaName || !registry.resolve(personaName)) {
|
|
46
|
+
return { ok: false, error: { code: "unknown-persona", message: `未知人设: ${String(personaName)}` } };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, value: { name: identity?.getProfileName(personaName) ?? null } };
|
|
49
|
+
}
|
|
50
|
+
case "setProfile": {
|
|
51
|
+
const personaName = requireString(payload, "personaName");
|
|
52
|
+
const profileName = requireString(payload, "name");
|
|
53
|
+
if (!personaName || !registry.resolve(personaName)) {
|
|
54
|
+
return { ok: false, error: { code: "unknown-persona", message: `未知人设: ${String(personaName)}` } };
|
|
55
|
+
}
|
|
56
|
+
if (!identity)
|
|
57
|
+
return { ok: false, error: { code: "storage-unavailable", message: "identity store unavailable" } };
|
|
58
|
+
if (!profileName)
|
|
59
|
+
return { ok: false, error: { code: "bad-request", message: "name is required" } };
|
|
60
|
+
await identity.setProfileName(personaName, profileName);
|
|
61
|
+
return { ok: true };
|
|
62
|
+
}
|
|
63
|
+
case "deleteCustomPersona": {
|
|
64
|
+
const personaName = requireString(payload, "personaName");
|
|
65
|
+
if (!personaName)
|
|
66
|
+
return { ok: false, error: { code: "bad-request", message: "personaName is required" } };
|
|
67
|
+
if (!identity)
|
|
68
|
+
return { ok: false, error: { code: "storage-unavailable", message: "identity store unavailable" } };
|
|
69
|
+
try {
|
|
70
|
+
await identity.deleteCustomPersona(personaName);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return { ok: false, error: { code: "forbidden", message: String(error?.message ?? error) } };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true };
|
|
76
|
+
}
|
|
77
|
+
case "distillStart": {
|
|
78
|
+
if (!distill)
|
|
79
|
+
return { ok: false, error: { code: "storage-unavailable", message: "distill runner unavailable" } };
|
|
80
|
+
const text = requireString(payload, "text");
|
|
81
|
+
if (!text)
|
|
82
|
+
return { ok: false, error: { code: "bad-request", message: "text is required" } };
|
|
83
|
+
const rawHint = payload?.hint;
|
|
84
|
+
const hint = typeof rawHint === "string" && rawHint.trim() ? rawHint.trim() : undefined;
|
|
85
|
+
try {
|
|
86
|
+
return { ok: true, value: { jobId: distill.start({ text, hint }) } };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
return { ok: false, error: { code: "bad-request", message: String(error?.message ?? error) } };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
case "distillStatus": {
|
|
93
|
+
if (!distill)
|
|
94
|
+
return { ok: false, error: { code: "storage-unavailable", message: "distill runner unavailable" } };
|
|
95
|
+
const jobId = requireString(payload, "jobId");
|
|
96
|
+
if (!jobId)
|
|
97
|
+
return { ok: false, error: { code: "bad-request", message: "jobId is required" } };
|
|
98
|
+
return { ok: true, value: distill.status(jobId) };
|
|
99
|
+
}
|
|
100
|
+
case "distillCancel": {
|
|
101
|
+
if (!distill)
|
|
102
|
+
return { ok: false, error: { code: "storage-unavailable", message: "distill runner unavailable" } };
|
|
103
|
+
const jobId = requireString(payload, "jobId");
|
|
104
|
+
if (!jobId)
|
|
105
|
+
return { ok: false, error: { code: "bad-request", message: "jobId is required" } };
|
|
106
|
+
return { ok: true, value: { cancelled: distill.cancel(jobId) } };
|
|
107
|
+
}
|
|
108
|
+
case "saveCustomPersona": {
|
|
109
|
+
if (!identity)
|
|
110
|
+
return { ok: false, error: { code: "storage-unavailable", message: "identity store unavailable" } };
|
|
111
|
+
const name = requireString(payload, "name");
|
|
112
|
+
const displayName = requireString(payload, "displayName");
|
|
113
|
+
const promptText = requireString(payload, "promptText");
|
|
114
|
+
if (!name || !displayName || !promptText) {
|
|
115
|
+
return { ok: false, error: { code: "bad-request", message: "name, displayName and promptText are required" } };
|
|
116
|
+
}
|
|
117
|
+
const description = payload.description;
|
|
118
|
+
const corpus = payload.corpus;
|
|
119
|
+
const memory = payload.memory;
|
|
120
|
+
const distillVersion = payload.distillVersion;
|
|
121
|
+
const distillSource = payload.distillSource;
|
|
122
|
+
const distillHint = payload.distillHint;
|
|
123
|
+
const rawCreatedAt = payload.createdAt;
|
|
124
|
+
try {
|
|
125
|
+
await identity.setCustomPersona(name, {
|
|
126
|
+
displayName,
|
|
127
|
+
description: typeof description === "string" ? description : "",
|
|
128
|
+
promptText,
|
|
129
|
+
// 编辑保存时带原 createdAt;新建(蒸馏/对话创建)落当前时间
|
|
130
|
+
createdAt: typeof rawCreatedAt === "number" ? rawCreatedAt : Date.now(),
|
|
131
|
+
corpus: Array.isArray(corpus) ? corpus : undefined,
|
|
132
|
+
distillVersion: typeof distillVersion === "number" ? distillVersion : undefined,
|
|
133
|
+
distillSource: typeof distillSource === "string" ? distillSource : undefined,
|
|
134
|
+
distillHint: typeof distillHint === "string" ? distillHint : undefined,
|
|
135
|
+
});
|
|
136
|
+
// 真实记忆点:蒸馏产出的事件条写入身份域(人设即人——她记得你们的事)
|
|
137
|
+
if (Array.isArray(memory)) {
|
|
138
|
+
const facts = identity.getMemory(name);
|
|
139
|
+
for (const item of memory) {
|
|
140
|
+
const text = typeof item?.text === "string" ? item.text : "";
|
|
141
|
+
if (!text.trim())
|
|
142
|
+
continue;
|
|
143
|
+
// 蒸馏层已按句末标点收尾;这里只做长度兜底(故事 80/事件 40 已在宿主层约束),
|
|
144
|
+
// 禁止 40 字硬切——会把完整句子拦腰截断
|
|
145
|
+
const settled = settleMemoryText(text, STORY_MEMORY_CAP);
|
|
146
|
+
if (!settled)
|
|
147
|
+
continue;
|
|
148
|
+
if (facts.some((f) => f.text.includes(settled) || settled.includes(f.text)))
|
|
149
|
+
continue;
|
|
150
|
+
await identity.addMemory(name, settled, (candidate, all) => all.some((f) => f.text.includes(candidate) || candidate.includes(f.text)));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
return { ok: false, error: { code: "forbidden", message: String(error?.message ?? error) } };
|
|
156
|
+
}
|
|
157
|
+
return { ok: true };
|
|
158
|
+
}
|
|
159
|
+
case "getCustomPersona": {
|
|
160
|
+
if (!identity)
|
|
161
|
+
return { ok: false, error: { code: "storage-unavailable", message: "identity store unavailable" } };
|
|
162
|
+
const personaName = requireString(payload, "personaName");
|
|
163
|
+
if (!personaName)
|
|
164
|
+
return { ok: false, error: { code: "bad-request", message: "personaName is required" } };
|
|
165
|
+
const record = identity.getCustomPersona(personaName);
|
|
166
|
+
if (!record)
|
|
167
|
+
return { ok: false, error: { code: "unknown-persona", message: `非自定义人设或不存在: ${personaName}` } };
|
|
168
|
+
return { ok: true, value: record };
|
|
169
|
+
}
|
|
170
|
+
case "exportPersona": {
|
|
171
|
+
const personaName = requireString(payload, "personaName");
|
|
172
|
+
if (!personaName)
|
|
173
|
+
return { ok: false, error: { code: "bad-request", message: "personaName is required" } };
|
|
174
|
+
const persona = registry.resolve(personaName);
|
|
175
|
+
if (!persona)
|
|
176
|
+
return { ok: false, error: { code: "unknown-persona", message: `未知人设: ${personaName}` } };
|
|
177
|
+
const includeMemory = payload.includeMemory === true;
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
value: {
|
|
181
|
+
format: "lume-persona-card",
|
|
182
|
+
version: 1,
|
|
183
|
+
persona: {
|
|
184
|
+
name: persona.name,
|
|
185
|
+
displayName: persona.displayName,
|
|
186
|
+
description: persona.description,
|
|
187
|
+
promptText: persona.promptText,
|
|
188
|
+
corpus: persona.corpus ?? [],
|
|
189
|
+
profileName: registry.profileNameOf(personaName),
|
|
190
|
+
styleRules: identity?.getStyleRules(personaName) ?? [],
|
|
191
|
+
...(includeMemory ? { memory: identity?.getMemory(personaName) ?? [] } : {}),
|
|
192
|
+
...(persona.signatureWords?.length ? { signatureWords: persona.signatureWords } : {}),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
case "getMemory": {
|
|
198
|
+
if (!identity)
|
|
199
|
+
return { ok: false, error: { code: "storage-unavailable", message: "identity store unavailable" } };
|
|
200
|
+
const personaName = requireString(payload, "personaName");
|
|
201
|
+
if (!personaName)
|
|
202
|
+
return { ok: false, error: { code: "bad-request", message: "personaName is required" } };
|
|
203
|
+
return { ok: true, value: identity.getMemory(personaName).map((f) => ({ text: f.text, at: f.at, core: isCoreMemory(f.text) })) };
|
|
204
|
+
}
|
|
205
|
+
case "deleteMemory": {
|
|
206
|
+
if (!identity)
|
|
207
|
+
return { ok: false, error: { code: "storage-unavailable", message: "identity store unavailable" } };
|
|
208
|
+
const personaName = requireString(payload, "personaName");
|
|
209
|
+
if (!personaName)
|
|
210
|
+
return { ok: false, error: { code: "bad-request", message: "personaName is required" } };
|
|
211
|
+
const idx = payload.index;
|
|
212
|
+
if (typeof idx !== "number" || !Number.isFinite(idx) || idx < 0) {
|
|
213
|
+
return { ok: false, error: { code: "bad-request", message: "index must be a non-negative integer" } };
|
|
214
|
+
}
|
|
215
|
+
const facts = identity.getMemory(personaName);
|
|
216
|
+
if (idx >= facts.length) {
|
|
217
|
+
return { ok: false, error: { code: "bad-request", message: `index ${idx} out of range (${facts.length} items)` } };
|
|
218
|
+
}
|
|
219
|
+
facts.splice(idx, 1);
|
|
220
|
+
await identity.replaceMemory(personaName, facts);
|
|
221
|
+
return { ok: true };
|
|
222
|
+
}
|
|
223
|
+
case "updateMemory": {
|
|
224
|
+
if (!identity)
|
|
225
|
+
return { ok: false, error: { code: "storage-unavailable", message: "identity store unavailable" } };
|
|
226
|
+
const personaName = requireString(payload, "personaName");
|
|
227
|
+
const text = requireString(payload, "text");
|
|
228
|
+
if (!personaName)
|
|
229
|
+
return { ok: false, error: { code: "bad-request", message: "personaName is required" } };
|
|
230
|
+
if (!text)
|
|
231
|
+
return { ok: false, error: { code: "bad-request", message: "text is required" } };
|
|
232
|
+
const idx = payload.index;
|
|
233
|
+
if (typeof idx !== "number" || !Number.isFinite(idx) || idx < 0) {
|
|
234
|
+
return { ok: false, error: { code: "bad-request", message: "index must be a non-negative integer" } };
|
|
235
|
+
}
|
|
236
|
+
const facts = identity.getMemory(personaName);
|
|
237
|
+
if (idx >= facts.length) {
|
|
238
|
+
return { ok: false, error: { code: "bad-request", message: `index ${idx} out of range (${facts.length} items)` } };
|
|
239
|
+
}
|
|
240
|
+
facts[idx] = { text, at: facts[idx].at };
|
|
241
|
+
await identity.replaceMemory(personaName, facts);
|
|
242
|
+
return { ok: true };
|
|
243
|
+
}
|
|
244
|
+
case "importPersona": {
|
|
245
|
+
if (!identity)
|
|
246
|
+
return { ok: false, error: { code: "storage-unavailable", message: "identity store unavailable" } };
|
|
247
|
+
const raw = payload.payload;
|
|
248
|
+
if (typeof raw !== "string")
|
|
249
|
+
return { ok: false, error: { code: "bad-request", message: "payload (JSON string) is required" } };
|
|
250
|
+
const parsed = parseCard(raw);
|
|
251
|
+
if (!parsed.ok)
|
|
252
|
+
return { ok: false, error: { code: "bad-card", message: parsed.error } };
|
|
253
|
+
const normalized = normalizeCard(parsed.value.persona);
|
|
254
|
+
if (!normalized.ok)
|
|
255
|
+
return { ok: false, error: { code: "forbidden", message: normalized.error } };
|
|
256
|
+
const card = normalized.value;
|
|
257
|
+
try {
|
|
258
|
+
await identity.setCustomPersona(card.name, {
|
|
259
|
+
displayName: card.displayName,
|
|
260
|
+
description: card.description,
|
|
261
|
+
promptText: card.promptText,
|
|
262
|
+
createdAt: Date.now(),
|
|
263
|
+
corpus: card.corpus,
|
|
264
|
+
});
|
|
265
|
+
if (card.profileName)
|
|
266
|
+
await identity.setProfileName(card.name, card.profileName);
|
|
267
|
+
if (card.styleRules.length > 0)
|
|
268
|
+
await identity.replaceStyleRules(card.name, card.styleRules);
|
|
269
|
+
if (card.memory && card.memory.length > 0)
|
|
270
|
+
await identity.replaceMemory(card.name, card.memory);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
return { ok: false, error: { code: "forbidden", message: String(error?.message ?? error) } };
|
|
274
|
+
}
|
|
275
|
+
return { ok: true, value: { name: card.name, displayName: card.displayName } };
|
|
276
|
+
}
|
|
277
|
+
default:
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
error: { code: "bad-request", message: `unknown lume endpoint ${JSON.stringify(endpoint)}` },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** 运行时状态上限:与 PersonaStore 的 maxSessions 对齐,超限淘汰最旧。 */
|
|
2
|
+
const MAX_RUNTIME_SESSIONS = 200;
|
|
3
|
+
function defaultRuntime() {
|
|
4
|
+
return {
|
|
5
|
+
userText: "",
|
|
6
|
+
assistantText: "",
|
|
7
|
+
lastQuery: null,
|
|
8
|
+
turnIndex: 0,
|
|
9
|
+
lastInjected: undefined,
|
|
10
|
+
switchTurn: null,
|
|
11
|
+
prevPersona: undefined,
|
|
12
|
+
switchGreetingPending: false,
|
|
13
|
+
prevSignatures: [],
|
|
14
|
+
leakEscalated: false,
|
|
15
|
+
activeBoundary: null,
|
|
16
|
+
extracting: null,
|
|
17
|
+
lastExtractionAt: undefined,
|
|
18
|
+
lastExchange: null,
|
|
19
|
+
recentTurns: [],
|
|
20
|
+
protocolCorrection: null,
|
|
21
|
+
lastFailureQuery: null,
|
|
22
|
+
failureStreak: 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export class SessionRuntimeStore {
|
|
26
|
+
#map = new Map();
|
|
27
|
+
/** 取或建会话运行时;新建时触发 LRU 淘汰。 */
|
|
28
|
+
get(sid) {
|
|
29
|
+
let st = this.#map.get(sid);
|
|
30
|
+
if (!st) {
|
|
31
|
+
st = defaultRuntime();
|
|
32
|
+
this.#map.set(sid, st);
|
|
33
|
+
this.#evictOldest();
|
|
34
|
+
}
|
|
35
|
+
return st;
|
|
36
|
+
}
|
|
37
|
+
delete(sid) {
|
|
38
|
+
return this.#map.delete(sid);
|
|
39
|
+
}
|
|
40
|
+
#evictOldest() {
|
|
41
|
+
while (this.#map.size > MAX_RUNTIME_SESSIONS) {
|
|
42
|
+
const oldest = this.#map.keys().next().value;
|
|
43
|
+
if (oldest === undefined)
|
|
44
|
+
break;
|
|
45
|
+
this.#map.delete(oldest);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话人设存储:storageDomain 表之上的真 LRU 语义 + 旧状态文件迁移。
|
|
3
|
+
*
|
|
4
|
+
* dsh-storage-json 的记录按插入序持久化,对已存在键 put 不会移动位置,
|
|
5
|
+
* 所以「重选」必须 delete + put 才能刷新 LRU 新旧。读取不落盘
|
|
6
|
+
* (prompt 构建每次都读,不能每次都写文件)。
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
9
|
+
/** 显式人设选择的存取(不含默认值语义 —— 那是宿主 apply 的职责)。 */
|
|
10
|
+
export class PersonaStore {
|
|
11
|
+
#table;
|
|
12
|
+
#maxSessions;
|
|
13
|
+
constructor(table, options = {}) {
|
|
14
|
+
this.#table = table;
|
|
15
|
+
this.#maxSessions = options.maxSessions ?? 200;
|
|
16
|
+
}
|
|
17
|
+
/** 显式选择;未选择过返回 null(区别于「选了 none」)。 */
|
|
18
|
+
get(sessionId) {
|
|
19
|
+
const value = this.#table.get(String(sessionId));
|
|
20
|
+
return typeof value === "string" ? value : null;
|
|
21
|
+
}
|
|
22
|
+
/** 写入显式选择,刷新 LRU 新旧并按上限淘汰最旧会话。 */
|
|
23
|
+
async select(sessionId, personaName) {
|
|
24
|
+
const key = String(sessionId);
|
|
25
|
+
try {
|
|
26
|
+
await this.#table.delete(key);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// 键不存在或后端瞬时故障不阻断写入
|
|
30
|
+
}
|
|
31
|
+
await this.#table.put(key, personaName);
|
|
32
|
+
await this.#evictOldest();
|
|
33
|
+
}
|
|
34
|
+
async #evictOldest() {
|
|
35
|
+
while (this.#table.size > this.#maxSessions) {
|
|
36
|
+
const oldest = this.#table.keys().next().value;
|
|
37
|
+
if (oldest === undefined)
|
|
38
|
+
break;
|
|
39
|
+
await this.#table.delete(oldest);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 一次性迁移:把 v0.1.0 写在 assets/persona-state.json 的旧记忆
|
|
45
|
+
* 导入 storageDomain,成功后把旧文件改名 .migrated 留档。
|
|
46
|
+
* 任何失败都不抛出 —— 迁移是尽力而为,主路径不受影响。
|
|
47
|
+
*/
|
|
48
|
+
export async function migrateLegacyState(store, legacyPath) {
|
|
49
|
+
if (!existsSync(legacyPath))
|
|
50
|
+
return false;
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
const raw = JSON.parse(readFileSync(legacyPath, "utf8"));
|
|
54
|
+
entries = Object.entries(raw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
let imported = 0;
|
|
60
|
+
for (const [sessionId, personaName] of entries) {
|
|
61
|
+
if (typeof personaName !== "string" || !sessionId)
|
|
62
|
+
continue;
|
|
63
|
+
if (store.get(sessionId) !== null)
|
|
64
|
+
continue; // 已有显式选择,不覆盖
|
|
65
|
+
await store.select(sessionId, personaName);
|
|
66
|
+
imported++;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
renameSync(legacyPath, `${legacyPath}.migrated`);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// 改名失败则下次启动会重复导入,但 store.get 判重保证幂等
|
|
73
|
+
}
|
|
74
|
+
return imported > 0 || entries.length === 0;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 降级存储:storageDomain 不可用时退回 v0.1.0 的 assets JSON 文件落盘
|
|
78
|
+
* (安装目录内,升级会丢 —— 仅作可用性兜底,不再是对外承诺的存储位置)。
|
|
79
|
+
* 接口与 PersonaStore 完全一致,宿主侧无感切换。
|
|
80
|
+
*/
|
|
81
|
+
export class FilePersonaStore {
|
|
82
|
+
#path;
|
|
83
|
+
#maxSessions;
|
|
84
|
+
#map;
|
|
85
|
+
constructor(path, options = {}) {
|
|
86
|
+
this.#path = path;
|
|
87
|
+
this.#maxSessions = options.maxSessions ?? 200;
|
|
88
|
+
this.#map = new Map();
|
|
89
|
+
try {
|
|
90
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
91
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
92
|
+
if (typeof value === "string")
|
|
93
|
+
this.#map.set(key, value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// 无历史文件或损坏 —— 从空表开始
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
get(sessionId) {
|
|
101
|
+
return this.#map.get(String(sessionId)) ?? null;
|
|
102
|
+
}
|
|
103
|
+
async select(sessionId, personaName) {
|
|
104
|
+
const key = String(sessionId);
|
|
105
|
+
this.#map.delete(key);
|
|
106
|
+
this.#map.set(key, personaName);
|
|
107
|
+
while (this.#map.size > this.#maxSessions) {
|
|
108
|
+
const oldest = this.#map.keys().next().value;
|
|
109
|
+
if (oldest === undefined)
|
|
110
|
+
break;
|
|
111
|
+
this.#map.delete(oldest);
|
|
112
|
+
}
|
|
113
|
+
this.#persist();
|
|
114
|
+
}
|
|
115
|
+
#persist() {
|
|
116
|
+
try {
|
|
117
|
+
writeFileSync(this.#path, JSON.stringify(Object.fromEntries(this.#map), null, 2));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// 写失败不影响会话(与 v0.1.0 行为一致)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|