@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/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
- assert.equal(MOCK_LLM.lastOptions.messages[0].source?.kind, "plugin");
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
 
@@ -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), not raw ★ text. Only the
238
- // drawer's edit-mode <option> labels keep the text form — SVG cannot render
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(/"★"\.repeat/g) || []).length,
251
- 1,
252
- "only the drawer edit <option> labels may keep the ★ text form"
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
@@ -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(provider 会抛 TypeError): ${message.role} / ${String(message.content?.[0]?.text ?? "").slice(0, 40)}`
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
+ });