@modusensus/dsh-mneme 0.8.8 → 0.8.9
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 +3 -4
- package/lib/api.js +1 -1
- package/lib/client.js +353 -163
- package/lib/continuity.js +1 -1
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +6 -6
- package/lib/entities/extractor.js +2 -2
- package/lib/peak-hours.js +117 -117
- package/lib/summarize.js +2 -2
- package/package.json +1 -1
- package/src/api.js +1 -1
- package/src/continuity.js +1 -1
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +6 -6
- package/src/entities/extractor.js +2 -2
- package/src/peak-hours.js +117 -117
- package/src/summarize.js +2 -2
- package/test/api.test.js +2 -1
- package/test/client.test.js +17 -6
- package/test/continuity.test.js +1 -1
- package/test/llm-message-source.test.js +32 -2
package/src/peak-hours.js
CHANGED
|
@@ -1,117 +1,117 @@
|
|
|
1
|
-
// 高峰时段(peak hours)解析与判定——纯函数模块,零依赖。
|
|
2
|
-
//
|
|
3
|
-
// 从 summarize.js 抽出(PR #320 review):#316 之后 summarize.js 反向依赖
|
|
4
|
-
// dream.js(withEffortFallback 复用),dream 镜像错峰时再 import summarize
|
|
5
|
-
// 会成真循环。时段调度本来就不是蒸馏的私有语义,独立成模块后两侧都从这里
|
|
6
|
-
// import,循环消失。re-export 兼容留在 summarize.js,调用方零改动。
|
|
7
|
-
//
|
|
8
|
-
// Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程
|
|
9
|
-
// 判断不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。
|
|
10
|
-
// spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天):
|
|
11
|
-
// "09:00-18:00" 每天 09:00-18:00
|
|
12
|
-
// "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun)
|
|
13
|
-
// "sat,sun 23:00-06:00" 周末跨零点段
|
|
14
|
-
// 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段,
|
|
15
|
-
// 绝不该因为写错格式把蒸馏/巩固停掉。
|
|
16
|
-
const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 };
|
|
17
|
-
|
|
18
|
-
function parseDayToken(token) {
|
|
19
|
-
const days = new Set();
|
|
20
|
-
const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null);
|
|
21
|
-
for (const piece of token.split(",")) {
|
|
22
|
-
const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase());
|
|
23
|
-
if (!matched) return null;
|
|
24
|
-
const from = normalize(matched[1]);
|
|
25
|
-
const to = matched[2] === undefined ? from : normalize(matched[2]);
|
|
26
|
-
if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null;
|
|
27
|
-
// 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。
|
|
28
|
-
for (let day = from; ; day = (day % 7) + 1) {
|
|
29
|
-
days.add(day);
|
|
30
|
-
if (day === to) break;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */
|
|
37
|
-
function isoDay(date) {
|
|
38
|
-
const day = date.getDay();
|
|
39
|
-
return day === 0 ? 7 : day;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function parsePeakSpec(spec) {
|
|
43
|
-
if (typeof spec !== "string" || spec.trim() === "") return null;
|
|
44
|
-
let rest = spec.trim();
|
|
45
|
-
let days = null;
|
|
46
|
-
// 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身,
|
|
47
|
-
// 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理,
|
|
48
|
-
// 不做猜测——宁可不省,也不能误停。
|
|
49
|
-
const sep = rest.search(/\s/);
|
|
50
|
-
if (sep > 0) {
|
|
51
|
-
const head = rest.slice(0, sep);
|
|
52
|
-
if (!head.includes(":")) {
|
|
53
|
-
days = parseDayToken(head);
|
|
54
|
-
if (days === null) return null;
|
|
55
|
-
rest = rest.slice(sep).trim();
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
const windows = [];
|
|
59
|
-
for (const part of rest.split(",")) {
|
|
60
|
-
const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part);
|
|
61
|
-
if (!matched) return null;
|
|
62
|
-
const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])];
|
|
63
|
-
if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null;
|
|
64
|
-
const start = sh * 60 + sm;
|
|
65
|
-
const end = eh * 60 + em;
|
|
66
|
-
if (start === end) return null;
|
|
67
|
-
windows.push({ start, end });
|
|
68
|
-
}
|
|
69
|
-
return windows.length > 0 ? { days, windows } : null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期:
|
|
74
|
-
* `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。
|
|
75
|
-
*/
|
|
76
|
-
export function isInPeakWindow(date, spec) {
|
|
77
|
-
const parsed = parsePeakSpec(spec);
|
|
78
|
-
if (!parsed) return false;
|
|
79
|
-
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
80
|
-
const today = isoDay(date);
|
|
81
|
-
const yesterday = today === 1 ? 7 : today - 1;
|
|
82
|
-
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
83
|
-
return parsed.windows.some(({ start, end }) => {
|
|
84
|
-
if (start < end) return minutes >= start && minutes < end && allowed(today);
|
|
85
|
-
return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday));
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。
|
|
91
|
-
* 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。
|
|
92
|
-
*/
|
|
93
|
-
export function nextOffPeakAt(date, spec) {
|
|
94
|
-
const parsed = parsePeakSpec(spec);
|
|
95
|
-
if (!parsed) return null;
|
|
96
|
-
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
97
|
-
const today = isoDay(date);
|
|
98
|
-
const yesterday = today === 1 ? 7 : today - 1;
|
|
99
|
-
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
100
|
-
let bestDelta = null;
|
|
101
|
-
const consider = (delta) => {
|
|
102
|
-
if (bestDelta === null || delta < bestDelta) bestDelta = delta;
|
|
103
|
-
};
|
|
104
|
-
for (const { start, end } of parsed.windows) {
|
|
105
|
-
if (start < end) {
|
|
106
|
-
if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes);
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (minutes >= start && allowed(today)) consider((1440 - minutes) + end);
|
|
110
|
-
else if (minutes < end && allowed(yesterday)) consider(end - minutes);
|
|
111
|
-
}
|
|
112
|
-
if (bestDelta === null) return null;
|
|
113
|
-
const at = new Date(date.getTime());
|
|
114
|
-
at.setSeconds(0, 0);
|
|
115
|
-
at.setMinutes(at.getMinutes() + bestDelta);
|
|
116
|
-
return at;
|
|
117
|
-
}
|
|
1
|
+
// 高峰时段(peak hours)解析与判定——纯函数模块,零依赖。
|
|
2
|
+
//
|
|
3
|
+
// 从 summarize.js 抽出(PR #320 review):#316 之后 summarize.js 反向依赖
|
|
4
|
+
// dream.js(withEffortFallback 复用),dream 镜像错峰时再 import summarize
|
|
5
|
+
// 会成真循环。时段调度本来就不是蒸馏的私有语义,独立成模块后两侧都从这里
|
|
6
|
+
// import,循环消失。re-export 兼容留在 summarize.js,调用方零改动。
|
|
7
|
+
//
|
|
8
|
+
// Issue #239(第 4 项,错峰队列):高峰时段解析与判定。纯函数、可单测——排程
|
|
9
|
+
// 判断不绑死真实时钟,测试才能确定性地覆盖跨零点、多段、星期过滤与非法写法。
|
|
10
|
+
// spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天):
|
|
11
|
+
// "09:00-18:00" 每天 09:00-18:00
|
|
12
|
+
// "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun)
|
|
13
|
+
// "sat,sun 23:00-06:00" 周末跨零点段
|
|
14
|
+
// 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段,
|
|
15
|
+
// 绝不该因为写错格式把蒸馏/巩固停掉。
|
|
16
|
+
const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 };
|
|
17
|
+
|
|
18
|
+
function parseDayToken(token) {
|
|
19
|
+
const days = new Set();
|
|
20
|
+
const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null);
|
|
21
|
+
for (const piece of token.split(",")) {
|
|
22
|
+
const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase());
|
|
23
|
+
if (!matched) return null;
|
|
24
|
+
const from = normalize(matched[1]);
|
|
25
|
+
const to = matched[2] === undefined ? from : normalize(matched[2]);
|
|
26
|
+
if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null;
|
|
27
|
+
// 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。
|
|
28
|
+
for (let day = from; ; day = (day % 7) + 1) {
|
|
29
|
+
days.add(day);
|
|
30
|
+
if (day === to) break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */
|
|
37
|
+
function isoDay(date) {
|
|
38
|
+
const day = date.getDay();
|
|
39
|
+
return day === 0 ? 7 : day;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parsePeakSpec(spec) {
|
|
43
|
+
if (typeof spec !== "string" || spec.trim() === "") return null;
|
|
44
|
+
let rest = spec.trim();
|
|
45
|
+
let days = null;
|
|
46
|
+
// 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身,
|
|
47
|
+
// 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理,
|
|
48
|
+
// 不做猜测——宁可不省,也不能误停。
|
|
49
|
+
const sep = rest.search(/\s/);
|
|
50
|
+
if (sep > 0) {
|
|
51
|
+
const head = rest.slice(0, sep);
|
|
52
|
+
if (!head.includes(":")) {
|
|
53
|
+
days = parseDayToken(head);
|
|
54
|
+
if (days === null) return null;
|
|
55
|
+
rest = rest.slice(sep).trim();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const windows = [];
|
|
59
|
+
for (const part of rest.split(",")) {
|
|
60
|
+
const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part);
|
|
61
|
+
if (!matched) return null;
|
|
62
|
+
const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])];
|
|
63
|
+
if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null;
|
|
64
|
+
const start = sh * 60 + sm;
|
|
65
|
+
const end = eh * 60 + em;
|
|
66
|
+
if (start === end) return null;
|
|
67
|
+
windows.push({ start, end });
|
|
68
|
+
}
|
|
69
|
+
return windows.length > 0 ? { days, windows } : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期:
|
|
74
|
+
* `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。
|
|
75
|
+
*/
|
|
76
|
+
export function isInPeakWindow(date, spec) {
|
|
77
|
+
const parsed = parsePeakSpec(spec);
|
|
78
|
+
if (!parsed) return false;
|
|
79
|
+
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
80
|
+
const today = isoDay(date);
|
|
81
|
+
const yesterday = today === 1 ? 7 : today - 1;
|
|
82
|
+
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
83
|
+
return parsed.windows.some(({ start, end }) => {
|
|
84
|
+
if (start < end) return minutes >= start && minutes < end && allowed(today);
|
|
85
|
+
return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。
|
|
91
|
+
* 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。
|
|
92
|
+
*/
|
|
93
|
+
export function nextOffPeakAt(date, spec) {
|
|
94
|
+
const parsed = parsePeakSpec(spec);
|
|
95
|
+
if (!parsed) return null;
|
|
96
|
+
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
97
|
+
const today = isoDay(date);
|
|
98
|
+
const yesterday = today === 1 ? 7 : today - 1;
|
|
99
|
+
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
100
|
+
let bestDelta = null;
|
|
101
|
+
const consider = (delta) => {
|
|
102
|
+
if (bestDelta === null || delta < bestDelta) bestDelta = delta;
|
|
103
|
+
};
|
|
104
|
+
for (const { start, end } of parsed.windows) {
|
|
105
|
+
if (start < end) {
|
|
106
|
+
if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (minutes >= start && allowed(today)) consider((1440 - minutes) + end);
|
|
110
|
+
else if (minutes < end && allowed(yesterday)) consider(end - minutes);
|
|
111
|
+
}
|
|
112
|
+
if (bestDelta === null) return null;
|
|
113
|
+
const at = new Date(date.getTime());
|
|
114
|
+
at.setSeconds(0, 0);
|
|
115
|
+
at.setMinutes(at.getMinutes() + bestDelta);
|
|
116
|
+
return at;
|
|
117
|
+
}
|
package/src/summarize.js
CHANGED
|
@@ -220,7 +220,7 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
220
220
|
}, undefined);
|
|
221
221
|
return {
|
|
222
222
|
messages: lines.length
|
|
223
|
-
? [createUserMessage({ content: [{ type: "text", text: trimTranscript(lines.join("\n"), maxChars) }], source: { kind: "plugin", plugin: "dsh-mneme" } })]
|
|
223
|
+
? [createUserMessage({ content: [{ type: "text", text: trimTranscript(lines.join("\n"), maxChars) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } })]
|
|
224
224
|
: [],
|
|
225
225
|
hasEvents: events.length > 0,
|
|
226
226
|
lastSeq
|
|
@@ -495,7 +495,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
495
495
|
model: route.model,
|
|
496
496
|
purpose: "summarization",
|
|
497
497
|
messages: [
|
|
498
|
-
{ role: "system", content: [{ type: "text", text: config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)] }], source: { kind: "plugin", plugin: "dsh-mneme" } },
|
|
498
|
+
{ role: "system", content: [{ type: "text", text: config.codingRetrospect ? STR.prompts.codingSummary[langOf(config)] : STR.prompts.summary[langOf(config)] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
499
499
|
...messages
|
|
500
500
|
],
|
|
501
501
|
signal: controller.signal
|
package/test/api.test.js
CHANGED
|
@@ -1427,7 +1427,8 @@ test("POST /api/dsh-mneme/test-model succeeds and reports reply + latency", asyn
|
|
|
1427
1427
|
// 0.1.6-alpha.1 起被官方 API 整单拒绝(「messages: at least one message
|
|
1428
1428
|
// is required」),探测按钮会 502。
|
|
1429
1429
|
assert.equal(MOCK_LLM.lastOptions.messages[0].role, "user");
|
|
1430
|
-
|
|
1430
|
+
// issue #326:kind 必须是生产者自有值,裸 "plugin" 被 DSH 0.1.7 的 V4 写入准入拒绝。
|
|
1431
|
+
assert.equal(MOCK_LLM.lastOptions.messages[0].source?.kind, "plugin:dsh-mneme");
|
|
1431
1432
|
assert.equal("reasoningEffort" in MOCK_LLM.lastOptions, false, "no effort configured -> field omitted");
|
|
1432
1433
|
});
|
|
1433
1434
|
|
package/test/client.test.js
CHANGED
|
@@ -234,9 +234,12 @@ test("entry tracks native class, inherits native sizing, and lifts the toolbar d
|
|
|
234
234
|
|
|
235
235
|
// Importance renders as Lucide star glyphs (the morphicons-paired data set;
|
|
236
236
|
// the runtime cannot require the ESM-only morphicons engine, so the path
|
|
237
|
-
// ships inline like the other stroke icons)
|
|
238
|
-
// drawer's edit-mode <option>
|
|
239
|
-
// inside <option
|
|
237
|
+
// ships inline like the other stroke icons). Raw text stars are fully
|
|
238
|
+
// retired: the drawer's edit-mode <option> select used to keep "★".repeat
|
|
239
|
+
// labels (SVG cannot render inside <option>) — it is now the SVG star
|
|
240
|
+
// button row, and the inline indicators (importance chips / entity meta /
|
|
241
|
+
// inject preview) share the same StarGlyph, so every star in the panel has
|
|
242
|
+
// one shape. (用户反馈:文本 ★ 过尖、与卡片 SVG 星不统一。)
|
|
240
243
|
test("importance renders as star glyphs, not raw text stars", () => {
|
|
241
244
|
assert.ok(
|
|
242
245
|
clientSource.includes("STAR_PATH_D"),
|
|
@@ -247,9 +250,9 @@ test("importance renders as star glyphs, not raw text stars", () => {
|
|
|
247
250
|
"the star-row component must exist"
|
|
248
251
|
);
|
|
249
252
|
assert.equal(
|
|
250
|
-
(clientSource.match(
|
|
251
|
-
|
|
252
|
-
"
|
|
253
|
+
(clientSource.match(/★/g) || []).length,
|
|
254
|
+
0,
|
|
255
|
+
"no raw text star may remain anywhere — every star renders through the shared SVG path"
|
|
253
256
|
);
|
|
254
257
|
assert.ok(
|
|
255
258
|
/h\(ImportanceStars, \{ className: "mneme-dmetaval"/.test(clientSource),
|
|
@@ -259,6 +262,14 @@ test("importance renders as star glyphs, not raw text stars", () => {
|
|
|
259
262
|
/h\(ImportanceStars, \{ value: m\.importance/.test(clientSource),
|
|
260
263
|
"the card foot must render the star row"
|
|
261
264
|
);
|
|
265
|
+
assert.ok(
|
|
266
|
+
/className: "mneme-staredit"/.test(clientSource),
|
|
267
|
+
"the drawer edit importance must be the SVG star button row, not a text-star <select>"
|
|
268
|
+
);
|
|
269
|
+
assert.ok(
|
|
270
|
+
/h\(StarGlyph, \{ size: 11 \}\)/.test(clientSource),
|
|
271
|
+
"inline star indicators must go through the shared StarGlyph"
|
|
272
|
+
);
|
|
262
273
|
});
|
|
263
274
|
|
|
264
275
|
// better-sidebar ecosystem integration is an optional capability (official
|
package/test/continuity.test.js
CHANGED
|
@@ -147,7 +147,7 @@ test("#249 N3:pre-step 双落点——压缩边缘落提案并往末尾追加
|
|
|
147
147
|
assert.equal(decision.messages.length, 1, "压缩边缘 → 追加一条");
|
|
148
148
|
const message = decision.messages[0];
|
|
149
149
|
assert.equal(message.source.plugin, "dsh-mneme");
|
|
150
|
-
assert.equal(message.source.kind, "plugin", "
|
|
150
|
+
assert.equal(message.source.kind, "plugin:dsh-mneme", "插件来源会被蒸馏管线跳过,注入物不会变成记忆(也不会自我蒸馏);裸 kind: \"plugin\" 被 V4 写入准入拒收(issue #326)");
|
|
151
151
|
assert.ok(message.content[0].text.startsWith(CONTINUITY_NOTICE_PREFIX));
|
|
152
152
|
assert.ok(message.content[0].text.includes("current_work: 把 N3 实现完"));
|
|
153
153
|
const proposal = store.getContinuityProposal("s1", KIND);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
3
6
|
import { createStore } from "../src/store.js";
|
|
4
7
|
import { createService } from "../src/service.js";
|
|
5
8
|
import { createDreamScheduler } from "../src/dream.js";
|
|
@@ -14,12 +17,15 @@ import { createVectorIndex } from "../src/vector-index.js";
|
|
|
14
17
|
// 阶段就抛 "Cannot read properties of undefined (reading 'kind')",四条 LLM 管线
|
|
15
18
|
// (dream / sleep / summarize / entity extraction)全军覆没、耗时 0ms。
|
|
16
19
|
// 这里用同等严格的桩驱动真实管线,任何新加的消息漏了 source 都会失败。
|
|
20
|
+
// issue #326:kind 值必须是生产者自有 kind "plugin:dsh-mneme"——DSH 0.1.7 起的
|
|
21
|
+
// V4 写入准入把裸 "plugin"(含缺失/空串)一并拒绝,注入/蒸馏会整条报
|
|
22
|
+
// "format v4 message requires a producer-owned source kind"。
|
|
17
23
|
function assertMessageSources(options) {
|
|
18
24
|
for (const message of options.messages ?? []) {
|
|
19
25
|
assert.equal(
|
|
20
26
|
message.source?.kind,
|
|
21
|
-
"plugin",
|
|
22
|
-
`LLM 消息缺 source
|
|
27
|
+
"plugin:dsh-mneme",
|
|
28
|
+
`LLM 消息缺 source 或 kind 不是生产者自有值(V4 会拒收): ${message.role} / ${String(message.content?.[0]?.text ?? "").slice(0, 40)}`
|
|
23
29
|
);
|
|
24
30
|
}
|
|
25
31
|
}
|
|
@@ -175,3 +181,27 @@ test("entity extraction: messages handed to the llm adapter carry source", async
|
|
|
175
181
|
assert.equal(calls.length, 1, "extraction called the LLM adapter");
|
|
176
182
|
store.close();
|
|
177
183
|
});
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------- shape lock
|
|
186
|
+
|
|
187
|
+
// issue #326 形状锁:任何新增的会话写入点再用裸 kind: "plugin"(V4 写入准入从
|
|
188
|
+
// DSH 0.1.7 起会整条拒绝)都直接红灯。静态 grep 锁而非运行时枚举,是因为写入点
|
|
189
|
+
// 散在 6 个文件 16 处,运行时桩只覆盖 LLM 管线、盖不住 api.js 的连通性探针。
|
|
190
|
+
test("no writer emits the rejected bare kind: \"plugin\" (issue #326)", () => {
|
|
191
|
+
const srcDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "src");
|
|
192
|
+
const offenders = [];
|
|
193
|
+
const walk = (dir) => {
|
|
194
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
195
|
+
const full = path.join(dir, entry.name);
|
|
196
|
+
if (entry.isDirectory()) walk(full);
|
|
197
|
+
else if (entry.name.endsWith(".js")) offenders.push(...scanSource(readFileSync(full, "utf8"), full));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const scanSource = (text, file) =>
|
|
201
|
+
text.split("\n").reduce(
|
|
202
|
+
(acc, line, i) => (/kind:\s*["'`]plugin["'`]/.test(line) ? [...acc, `${file}:${i + 1}`] : acc),
|
|
203
|
+
[]
|
|
204
|
+
);
|
|
205
|
+
walk(srcDir);
|
|
206
|
+
assert.deepEqual(offenders, [], `裸 kind: "plugin" 会被 V4 写入准入拒绝,改用生产者自有 kind(如 "plugin:dsh-mneme"): ${offenders.join(", ")}`);
|
|
207
|
+
});
|