@modusensus/dsh-mneme 0.7.11 → 0.7.13
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.en.md +69 -0
- package/README.md +227 -21
- package/bin/cli.mjs +603 -0
- package/lib/api-standalone.js +264 -0
- package/lib/api.js +91 -2
- package/lib/client.js +243 -1
- package/lib/config.js +80 -0
- package/lib/index.js +43 -12
- package/lib/quality-filter.js +4 -1
- package/lib/service.js +38 -5
- package/lib/settings.js +40 -0
- package/lib/store.js +4 -1
- package/lib/summarize.js +197 -59
- package/lib/tools.js +3 -3
- package/package.json +7 -1
- package/src/api-standalone.js +264 -0
- package/src/api.js +91 -2
- package/src/config.js +80 -0
- package/src/index.js +43 -12
- package/src/quality-filter.js +4 -1
- package/src/service.js +38 -5
- package/src/settings.js +40 -0
- package/src/store.js +4 -1
- package/src/summarize.js +197 -59
- package/src/tools.js +3 -3
- package/test/api.test.js +44 -0
- package/test/helpers/peer-worker.mjs +10 -0
- package/test/peer-blockers.test.js +4 -9
- package/test/settings.test.js +24 -0
- package/test/standalone-api.test.js +326 -0
- package/test/summarize.test.js +116 -0
package/src/summarize.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
2
|
|
|
3
|
-
const SUMMARY_PROMPT =
|
|
4
|
-
|
|
3
|
+
const SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容,提炼值得跨会话记住的原子记忆。
|
|
4
|
+
原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、名字、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。
|
|
5
|
+
只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history","title":"简短标题","content":"保留原始细节的一句话","importance":1-5}。
|
|
5
6
|
不要输出任何其他文字。`;
|
|
6
7
|
|
|
8
|
+
// 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
|
|
9
|
+
// 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
|
|
10
|
+
// title/content 单列结构(store 无结构化字段),信息浓缩进 content。
|
|
11
|
+
const CODING_SUMMARY_PROMPT = `你是记忆库提炼助手。根据下面的会话内容(含用户输入、助手思考/回答、工具调用与结果),提炼值得跨会话记住的原子记忆。
|
|
12
|
+
原子记忆原则:每条记忆只装一个独立事实/偏好/决策,短小、自带完整上下文(把数字、报错信息、命令、路径、结论等原始细节保留在 content 里,不要抽象概括);宁可拆成多条也绝不合并丢细节。信息量一般提 2-4 条,信息密集的对话可提 4-8 条。
|
|
13
|
+
只输出 JSON 数组,每项形如 {"type":"preference|project|decision|history|rejected_solution|pitfall|constraint","title":"简短标题","content":"保留原始细节的一句话","importance":1-5}。
|
|
14
|
+
若对话涉及编码/调试,可额外提取编码类记忆:
|
|
15
|
+
- rejected_solution:被否决/废弃的实现方案(content 含方案简述 + 被否决原因 + 最终采用方案)
|
|
16
|
+
- pitfall:调试踩坑记录(content 含现象/报错 + 根因 + 解决/规避方法)
|
|
17
|
+
- constraint:项目工程约束(content 含约束描述 + 来源)
|
|
18
|
+
普通闲聊、临时无关对话一律不提取编码类记忆。不要输出任何其他文字。`;
|
|
19
|
+
|
|
7
20
|
/** Extract a JSON array from LLM output that may contain prose around it. */
|
|
8
21
|
export function parseSummaryJson(raw) {
|
|
9
22
|
const text = String(raw ?? "");
|
|
@@ -17,7 +30,7 @@ export function parseSummaryJson(raw) {
|
|
|
17
30
|
return [];
|
|
18
31
|
}
|
|
19
32
|
if (!Array.isArray(arr)) return [];
|
|
20
|
-
const VALID = new Set(["preference", "project", "decision", "history"]);
|
|
33
|
+
const VALID = new Set(["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"]);
|
|
21
34
|
return arr.filter(
|
|
22
35
|
(item) =>
|
|
23
36
|
item &&
|
|
@@ -76,22 +89,108 @@ function toProtocolChunk(chunk) {
|
|
|
76
89
|
// events must not leak into the memory store. Events without a data payload
|
|
77
90
|
// (minimal test doubles) pass the kind check and are handled by the content
|
|
78
91
|
// check below.
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
//
|
|
93
|
+
// codingRetrospect: the distill context is the FULL turn transcript —
|
|
94
|
+
// user prompts plus assistant thinking/replies, tool calls + results and code
|
|
95
|
+
// dispatch output — so the summarizer can see tool errors and extract pitfall
|
|
96
|
+
// root causes, not just what the user typed. The same filtering stays: only
|
|
97
|
+
// source.kind === "user" prompts enter (plugin/machine content is excluded).
|
|
98
|
+
// The result is a single text transcript passed to the LLM as one user message
|
|
99
|
+
// (SUMMARY_PROMPT already says "根据下面的会话内容").
|
|
100
|
+
function collectMessages(session, maxChars = 8000) {
|
|
101
|
+
// DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events
|
|
102
|
+
const events = session.snapshotEvents?.() ?? session.events ?? [];
|
|
103
|
+
const lines = [];
|
|
104
|
+
// 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
|
|
105
|
+
// 直接传字符串数组)。text 之外按需抽 thinking/reasoning 块。
|
|
106
|
+
const textOf = (content) => {
|
|
107
|
+
if (typeof content === "string") return content;
|
|
108
|
+
if (!Array.isArray(content)) return "";
|
|
109
|
+
return content
|
|
110
|
+
.map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
|
|
111
|
+
.filter((s) => s)
|
|
112
|
+
.join("\n");
|
|
113
|
+
};
|
|
114
|
+
const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
|
|
115
|
+
for (const event of events) {
|
|
116
|
+
const data = event?.data ?? {};
|
|
117
|
+
const kind = data?.source?.kind;
|
|
118
|
+
switch (event.type) {
|
|
119
|
+
case "user/message": {
|
|
120
|
+
if (kind !== undefined && kind !== "user") break;
|
|
121
|
+
const text = textOf(data?.content);
|
|
122
|
+
if (text.trim()) lines.push(`用户:${text}`);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "assistant/message": {
|
|
126
|
+
const msg = data?.message;
|
|
127
|
+
const blocks = Array.isArray(msg?.content) ? msg.content : [];
|
|
128
|
+
const text = textOf(blocks);
|
|
129
|
+
if (text.trim()) lines.push(`助手:${text}`);
|
|
130
|
+
const thinking = blocks
|
|
131
|
+
.map((b) => (typeof b === "string" ? "" : (b && b.type === "reasoning" && typeof b.text === "string" ? b.text : "")))
|
|
132
|
+
.filter((s) => s)
|
|
133
|
+
.join("\n");
|
|
134
|
+
if (thinking.trim()) lines.push(`助手思考:${thinking}`);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case "tool/call": {
|
|
138
|
+
const args = typeof data.arguments === "string"
|
|
139
|
+
? data.arguments
|
|
140
|
+
: data.arguments ? JSON.stringify(data.arguments) : "";
|
|
141
|
+
lines.push(`工具调用:${data.name ?? "?"}(${trim(args, 300)})`);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "tool/result": {
|
|
145
|
+
const out = typeof data.output === "string" ? data.output : data.output ? JSON.stringify(data.output) : "";
|
|
146
|
+
const status = data.ok === false ? "失败" : "成功";
|
|
147
|
+
lines.push(`工具结果(${status}):${trim(out, 500)}`);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case "tool/code-dispatch": {
|
|
151
|
+
const out = typeof data.output === "string" ? data.output : data.output ? JSON.stringify(data.output) : "";
|
|
152
|
+
const status = data.ok === false ? "失败" : "成功";
|
|
153
|
+
lines.push(`代码执行(${status}):${trim(out, 500)}`);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
default:
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return lines.length ? [createUserMessage({ content: [{ type: "text", text: trim(lines.join("\n"), maxChars) }] })] : [];
|
|
83
161
|
}
|
|
84
162
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
163
|
+
// ── 智能调速器(429 保护)─────────────────────────────────────────────
|
|
164
|
+
// 对话一多时 turn/end 会批量触发蒸馏,多个 LLM 请求"一拥而上"正是 429 的
|
|
165
|
+
// 来源。这里借鉴机场安检的思路:所有蒸馏调用进同一个全局串行队列,按间隔
|
|
166
|
+
// distillRateLimitIntervalMs 分批放行;命中 429 时按 distillRateLimitBaseDelayMs
|
|
167
|
+
// 指数退避(1s→2s→4s…)自动重试 distillRateLimitRetries 次,全程对用户透明,
|
|
168
|
+
// 不把 429 错误码直接抛出去。
|
|
169
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
170
|
+
|
|
171
|
+
function isRateLimited(error) {
|
|
172
|
+
const status = error?.status ?? error?.statusCode ?? error?.response?.status;
|
|
173
|
+
if (status === 429) return true;
|
|
174
|
+
const msg = String(error?.message ?? error ?? "");
|
|
175
|
+
return /429|rate.?limit|too many requests|请求过于频繁/i.test(msg);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 全局串行链:每个蒸馏任务在前一个结束后才开始,单次失败不阻塞后续。
|
|
179
|
+
// 相邻请求间隔按"距上一个结束不足 intervalMs 就补齐等待"实现——单次蒸馏
|
|
180
|
+
// 零延迟(上次结束距今已超过间隔,直接放行),只有连续批量蒸馏才触发限速。
|
|
181
|
+
let distillQueue = Promise.resolve();
|
|
182
|
+
let lastDistillEnd = 0;
|
|
183
|
+
function enqueueDistill(task, intervalMs = 0) {
|
|
184
|
+
const run = distillQueue.then(async () => {
|
|
185
|
+
if (intervalMs > 0) {
|
|
186
|
+
const wait = Math.max(0, intervalMs - (Date.now() - lastDistillEnd));
|
|
187
|
+
if (wait > 0) await sleep(wait);
|
|
188
|
+
}
|
|
189
|
+
lastDistillEnd = Date.now();
|
|
190
|
+
return task();
|
|
191
|
+
});
|
|
192
|
+
distillQueue = run.catch(() => {});
|
|
193
|
+
return run;
|
|
95
194
|
}
|
|
96
195
|
|
|
97
196
|
export function createSummarizer(ctx, service, config) {
|
|
@@ -104,7 +203,7 @@ export function createSummarizer(ctx, service, config) {
|
|
|
104
203
|
if (disposed || inFlight.has(session.id)) return;
|
|
105
204
|
const controller = new AbortController();
|
|
106
205
|
inFlight.set(session.id, controller);
|
|
107
|
-
//
|
|
206
|
+
// audit state for the compression call. null = no audit for this run
|
|
108
207
|
// (disabled, or no LLM call was actually made). The audit row is written in
|
|
109
208
|
// the finally below — once, regardless of which exit path the call took —
|
|
110
209
|
// so a failed/aborted stream still leaves a status='error' trail without
|
|
@@ -119,7 +218,9 @@ export function createSummarizer(ctx, service, config) {
|
|
|
119
218
|
? { provider: header.provider, model: header.model }
|
|
120
219
|
: undefined;
|
|
121
220
|
if (!route) return;
|
|
122
|
-
|
|
221
|
+
// 完整转录(Codex 式):蒸馏把整轮对话交给 LLM 提炼原子记忆,不再硬裁
|
|
222
|
+
// 8000 字截断语义;上限由 distillMaxChars 控制(默认 24000,可调大)。
|
|
223
|
+
const messages = collectMessages(session, config.distillMaxChars ?? 24000);
|
|
123
224
|
if (!messages.length) return;
|
|
124
225
|
|
|
125
226
|
if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
|
|
@@ -134,61 +235,98 @@ export function createSummarizer(ctx, service, config) {
|
|
|
134
235
|
};
|
|
135
236
|
}
|
|
136
237
|
|
|
137
|
-
const assembler = new BlockAssembler();
|
|
138
|
-
let text = "";
|
|
139
238
|
const options = {
|
|
140
239
|
provider: route.provider,
|
|
141
240
|
model: route.model,
|
|
142
241
|
purpose: "summarization",
|
|
143
242
|
messages: [
|
|
144
|
-
{ role: "system", content: [{ type: "text", text: SUMMARY_PROMPT }] },
|
|
243
|
+
{ role: "system", content: [{ type: "text", text: config.codingRetrospect ? CODING_SUMMARY_PROMPT : SUMMARY_PROMPT }] },
|
|
145
244
|
...messages
|
|
146
245
|
],
|
|
147
246
|
signal: controller.signal
|
|
148
247
|
};
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
248
|
+
// 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
|
|
249
|
+
// 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
|
|
250
|
+
const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
|
|
251
|
+
const { text, assembledText, aborted } = await enqueueDistill(async () => {
|
|
252
|
+
const retries = config.distillRateLimitRetries ?? 3;
|
|
253
|
+
const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
|
|
254
|
+
for (let attempt = 0; ; attempt++) {
|
|
255
|
+
const assembler = new BlockAssembler();
|
|
256
|
+
let text = "";
|
|
257
|
+
let aborted = false;
|
|
258
|
+
try {
|
|
259
|
+
for await (const chunk of ctx.llm.stream(options)) {
|
|
260
|
+
if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
|
|
261
|
+
if (chunk.type === "text-delta") {
|
|
262
|
+
text += chunk.text ?? chunk.delta ?? "";
|
|
263
|
+
}
|
|
264
|
+
if (chunk.type === "usage" && audit) {
|
|
265
|
+
const i = chunk.input_tokens ?? chunk.inputTokens ?? chunk.prompt_tokens ?? chunk.promptTokens;
|
|
266
|
+
const o = chunk.output_tokens ?? chunk.outputTokens ?? chunk.completion_tokens ?? chunk.completionTokens;
|
|
267
|
+
if (Number.isFinite(i)) audit.inputTokens = i;
|
|
268
|
+
if (Number.isFinite(o)) audit.outputTokens = o;
|
|
167
269
|
}
|
|
168
|
-
|
|
270
|
+
if (chunk.type === "finish") {
|
|
271
|
+
const reasonKind = chunk.reason?.kind ?? chunk.kind;
|
|
272
|
+
if (reasonKind === "error" || reasonKind === "aborted") {
|
|
273
|
+
// 429 也可能以 finish reason error 携带 rate-limit 信息,统一
|
|
274
|
+
// 转抛错走指数退避重试。
|
|
275
|
+
if (isRateLimited(chunk.reason ?? chunk)) {
|
|
276
|
+
throw Object.assign(new Error("rate limited"), { status: 429 });
|
|
277
|
+
}
|
|
278
|
+
if (audit) {
|
|
279
|
+
audit.status = "error";
|
|
280
|
+
audit.errorMessage = `llm stream ${reasonKind}`;
|
|
281
|
+
}
|
|
282
|
+
aborted = true;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Direct delta accumulation is the primary extraction path (it works
|
|
288
|
+
// for real protocol chunks {index,text} and looser {delta} shapes
|
|
289
|
+
// alike); the assembler blocks are a fallback for streams that only
|
|
290
|
+
// deliver text inside block-end. This dsh-llm exposes no public
|
|
291
|
+
// no-arg assemble() — blocks() is the message-level API.
|
|
292
|
+
const blocks = assembler.blocks();
|
|
293
|
+
const assembledText = blocks
|
|
294
|
+
.filter((b) => b.type === "text")
|
|
295
|
+
.map((b) => b.text ?? "")
|
|
296
|
+
.join("");
|
|
297
|
+
return { text, assembledText, aborted };
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error?.name === "AbortError" || controller.signal.aborted) throw error; // dispose 中止直接放行
|
|
300
|
+
if (isRateLimited(error) && attempt < retries) {
|
|
301
|
+
const delay = baseDelayMs * 2 ** attempt;
|
|
302
|
+
ctx.logger?.warn?.(
|
|
303
|
+
`dsh-mneme: 蒸馏请求过于频繁(429),为避免限流等待 ${delay}ms 后自动重试(第 ${attempt + 1}/${retries} 次)`
|
|
304
|
+
);
|
|
305
|
+
await sleep(delay);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// 429 重试耗尽或非 429 错误:记 audit 后抛出,保持原失败路径。
|
|
309
|
+
if (audit) {
|
|
310
|
+
audit.status = "error";
|
|
311
|
+
audit.errorMessage = String(error?.message ?? error);
|
|
169
312
|
}
|
|
313
|
+
throw error;
|
|
170
314
|
}
|
|
171
315
|
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
audit.status = "error";
|
|
175
|
-
audit.errorMessage = String(error?.message ?? error);
|
|
176
|
-
}
|
|
177
|
-
throw error; // caller's catch handles the failure; audit already staged
|
|
178
|
-
}
|
|
179
|
-
// Direct delta accumulation is the primary extraction path (it works
|
|
180
|
-
// for real protocol chunks {index,text} and looser {delta} shapes
|
|
181
|
-
// alike); the assembler blocks are a fallback for streams that only
|
|
182
|
-
// deliver text inside block-end. This dsh-llm exposes no public
|
|
183
|
-
// no-arg assemble() — blocks() is the message-level API.
|
|
184
|
-
const blocks = assembler.blocks();
|
|
185
|
-
const assembledText = blocks
|
|
186
|
-
.filter((b) => b.type === "text")
|
|
187
|
-
.map((b) => b.text ?? "")
|
|
188
|
-
.join("");
|
|
316
|
+
}, intervalMs);
|
|
317
|
+
if (aborted) return;
|
|
189
318
|
const entries = parseSummaryJson(text || assembledText);
|
|
190
319
|
for (const entry of entries) {
|
|
191
|
-
|
|
320
|
+
// Provenance: the summarizer runs on a real session (turn/end hook), so
|
|
321
|
+
// session.id is always available here — it rides the human-readable
|
|
322
|
+
// source label.
|
|
323
|
+
// 编码记忆类型(codingRetrospect)不带 tag:读取侧门控/加权靠 m.type
|
|
324
|
+
// (rejected_solution/pitfall/constraint)区分即可,tag 体系
|
|
325
|
+
// sanitizeTags 不认 `type:` 前缀反而会清空 tags 列(额外一次 UPDATE)。
|
|
326
|
+
service.saveWithDedupe({
|
|
327
|
+
...entry,
|
|
328
|
+
source: `session:${session.id}`
|
|
329
|
+
});
|
|
192
330
|
}
|
|
193
331
|
} finally {
|
|
194
332
|
if (audit) {
|
package/src/tools.js
CHANGED
|
@@ -30,7 +30,7 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
30
30
|
"Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
|
|
31
31
|
"Merges into an existing entry of the same type when the title matches.",
|
|
32
32
|
parameters: {
|
|
33
|
-
type: { type: "string", required: true, enum: ["preference", "project", "decision", "history"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary" },
|
|
33
|
+
type: { type: "string", required: true, enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary; rejected_solution=rejected/abandoned implementation approach; pitfall=debugging lesson (symptom+root cause+fix); constraint=engineering constraint" },
|
|
34
34
|
title: { type: "string", required: true, description: "Short unique title" },
|
|
35
35
|
content: { type: "string", required: true, description: "Memory body" },
|
|
36
36
|
tags: { type: "array", items: { type: "string" }, description: "Optional tags" },
|
|
@@ -100,7 +100,7 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
100
100
|
name: "memory_list",
|
|
101
101
|
description: "List memory entries by type, high-importance first, then newest, paginated. Set include_archived=true to also list archived (hidden) entries so they can be located and restored.",
|
|
102
102
|
parameters: {
|
|
103
|
-
type: { type: "string", enum: ["preference", "project", "decision", "history"], description: "Filter by type; omit for all" },
|
|
103
|
+
type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "Filter by type; omit for all" },
|
|
104
104
|
limit: { type: "integer", description: "Page size (default 50)" },
|
|
105
105
|
offset: { type: "integer", description: "Page offset (default 0)" },
|
|
106
106
|
include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" }
|
|
@@ -138,7 +138,7 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
138
138
|
id: { type: "string", required: true, description: "Memory id" },
|
|
139
139
|
title: { type: "string" },
|
|
140
140
|
content: { type: "string" },
|
|
141
|
-
type: { type: "string", enum: ["preference", "project", "decision", "history"] },
|
|
141
|
+
type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"] },
|
|
142
142
|
tags: { type: "array", items: { type: "string" } },
|
|
143
143
|
importance: { type: "integer", description: "1-5" },
|
|
144
144
|
reason: { type: "string", description: "Optional context for the correction (what the user actually said/wanted), recorded for reflection" }
|
package/test/api.test.js
CHANGED
|
@@ -191,6 +191,50 @@ test("PUT /api/dsh-mneme/rules saves rules", async () => {
|
|
|
191
191
|
assert.deepEqual(settings.getRules(), ["a", "b"]);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
// --- panel mode (light/standard) ---
|
|
195
|
+
|
|
196
|
+
test("GET /api/dsh-mneme/mode defaults to standard", async () => {
|
|
197
|
+
const { routes } = setup();
|
|
198
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
199
|
+
const res = new FakeRes();
|
|
200
|
+
await route.handler(req("/api/dsh-mneme/mode"), res);
|
|
201
|
+
assert.equal(res.statusCode, 200);
|
|
202
|
+
assert.deepEqual(JSON.parse(res.body), { mode: "standard" });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("PUT /api/dsh-mneme/mode validates the enum and persists", async () => {
|
|
206
|
+
const { routes, settings } = setup();
|
|
207
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
208
|
+
|
|
209
|
+
const put = new FakeRes();
|
|
210
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), put);
|
|
211
|
+
assert.equal(put.statusCode, 200);
|
|
212
|
+
assert.deepEqual(JSON.parse(put.body), { mode: "light" });
|
|
213
|
+
assert.equal(settings.getPanelMode(), "light");
|
|
214
|
+
|
|
215
|
+
const back = new FakeRes();
|
|
216
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "standard" }), back);
|
|
217
|
+
assert.deepEqual(JSON.parse(back.body), { mode: "standard" });
|
|
218
|
+
|
|
219
|
+
const bad = new FakeRes();
|
|
220
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "turbo" }), bad);
|
|
221
|
+
assert.equal(bad.statusCode, 400);
|
|
222
|
+
assert.equal(settings.getPanelMode(), "standard", "invalid value not persisted");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("PUT /api/dsh-mneme/mode is token-gated like other settings writes", async () => {
|
|
226
|
+
const { routes } = setup(undefined, "secret-token");
|
|
227
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/mode");
|
|
228
|
+
const res = new FakeRes();
|
|
229
|
+
await route.handler(req("/api/dsh-mneme/mode", "PUT", { mode: "light" }), res);
|
|
230
|
+
assert.equal(res.statusCode, 401);
|
|
231
|
+
const ok = new FakeRes();
|
|
232
|
+
const authed = req("/api/dsh-mneme/mode", "PUT", { mode: "light" });
|
|
233
|
+
authed.headers = { authorization: "Bearer secret-token" };
|
|
234
|
+
await route.handler(authed, ok);
|
|
235
|
+
assert.equal(ok.statusCode, 200);
|
|
236
|
+
});
|
|
237
|
+
|
|
194
238
|
test("GET /api/dsh-mneme/commands lists commands", async () => {
|
|
195
239
|
const { routes, settings } = setup();
|
|
196
240
|
settings.addCommand({ name: "agenda", instruction: "x" });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Multi-process concurrency worker for test/peer-blockers.test.js:
|
|
2
|
+
// opens the SQLite store passed via argv and increments the mirror
|
|
3
|
+
// generation N times, so several real OS processes contend on one DB file.
|
|
4
|
+
// Usage: node test/helpers/peer-worker.mjs <dbPath> <iterations>
|
|
5
|
+
import { createStore } from "../../src/store.js";
|
|
6
|
+
|
|
7
|
+
const [, , dbPath, iterations] = process.argv;
|
|
8
|
+
const store = createStore(dbPath);
|
|
9
|
+
for (let i = 0; i < Number(iterations); i++) store.incrementGeneration();
|
|
10
|
+
store.close();
|
|
@@ -11,7 +11,6 @@ import { createMirror } from "../src/mirror.js";
|
|
|
11
11
|
import { createService } from "../src/service.js";
|
|
12
12
|
|
|
13
13
|
const execFileP = promisify(execFile);
|
|
14
|
-
const STORE_PATH = fileURLToPath(new URL("../src/store.js", import.meta.url));
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* v0.3.8 回归测试(audit peer 6 项运行时阻断 → INSTALLATION_NOT_APPROVED)。
|
|
@@ -98,16 +97,12 @@ test("peer-C: 多进程并发原子递增——8 进程×10 次 incrementGenerat
|
|
|
98
97
|
const N = 8;
|
|
99
98
|
const M = 10;
|
|
100
99
|
try {
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const store = createStore(process.argv[2]);
|
|
105
|
-
for (let i = 0; i < ${M}; i++) { store.incrementGeneration(); }
|
|
106
|
-
store.close();
|
|
107
|
-
`;
|
|
100
|
+
// 子进程:静态 worker 脚本(test/helpers/peer-worker.mjs,可审查、无动态
|
|
101
|
+
// 生成代码),execFile 参数数组、无 shell;DB 路径与迭代数经 argv 传入。
|
|
102
|
+
const workerPath = fileURLToPath(new URL("./helpers/peer-worker.mjs", import.meta.url));
|
|
108
103
|
await Promise.all(
|
|
109
104
|
Array.from({ length: N }, () =>
|
|
110
|
-
execFileP(process.execPath, [
|
|
105
|
+
execFileP(process.execPath, [workerPath, dbPath, String(M)], { timeout: 30000 })
|
|
111
106
|
)
|
|
112
107
|
);
|
|
113
108
|
const store = createStore(dbPath);
|
package/test/settings.test.js
CHANGED
|
@@ -99,3 +99,27 @@ test("vector config disabled value is stored as false", () => {
|
|
|
99
99
|
assert.equal(settings.getVectorConfig().enabled, false);
|
|
100
100
|
store.close();
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test("panel mode defaults to standard and round-trips", () => {
|
|
104
|
+
const { store, settings } = setup();
|
|
105
|
+
assert.equal(settings.getPanelMode(), "standard");
|
|
106
|
+
settings.setPanelMode("light");
|
|
107
|
+
assert.equal(settings.getPanelMode(), "light");
|
|
108
|
+
settings.setPanelMode("standard");
|
|
109
|
+
assert.equal(settings.getPanelMode(), "standard");
|
|
110
|
+
store.close();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("external api settings persist token and merge partial writes", () => {
|
|
114
|
+
const { store, settings } = setup();
|
|
115
|
+
assert.equal(settings.getExternalApi(), undefined);
|
|
116
|
+
settings.setExternalApi({ enabled: true, port: 9000, token: "tok-1" });
|
|
117
|
+
assert.deepEqual(settings.getExternalApi(), { enabled: true, port: 9000, host: "127.0.0.1", token: "tok-1" });
|
|
118
|
+
// Token-only write preserves the other keys.
|
|
119
|
+
settings.setExternalApi({ token: "tok-2" });
|
|
120
|
+
assert.deepEqual(settings.getExternalApi(), { enabled: true, port: 9000, host: "127.0.0.1", token: "tok-2" });
|
|
121
|
+
// Host write persists and survives the next partial merge.
|
|
122
|
+
settings.setExternalApi({ host: "0.0.0.0" });
|
|
123
|
+
assert.deepEqual(settings.getExternalApi(), { enabled: true, port: 9000, host: "0.0.0.0", token: "tok-2" });
|
|
124
|
+
store.close();
|
|
125
|
+
});
|