feiyu-memory 0.0.0-stage → 4.0.0

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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * 配置层 —— 一份规格,两个出口。
3
+ *
4
+ * 为什么不用「静态 import schemastery」:
5
+ * 那会让 `@deepseek-ai/schemastery` 成为运行时硬依赖。一旦它在目标机器上
6
+ * 解析不到,`import` 直接抛错 → **整个插件加载失败**。
7
+ * 对于一个"装上就该忘掉它"的插件,这个失败模式不能接受。
8
+ *
9
+ * 所以:
10
+ * - `CONFIG_SPEC` 是唯一真相源,纯 JS,零依赖。
11
+ * - `resolveConfig()` 自己补默认值、做类型转换和范围钳制 —— 保证行为**一定确定**。
12
+ * - `buildSchema(z)` 在 schemastery 可用时才调用,额外产出给设置表单用的 schema。
13
+ * 拿不到就没有表单,但**所有选项照样能在 `cordis.patch.yml` 的 `config:` 里写**。
14
+ */
15
+
16
+ /**
17
+ * @typedef {'boolean'|'number'|'string'} ConfigType
18
+ * @typedef {object} ConfigField
19
+ * @property {string} key
20
+ * @property {ConfigType} type
21
+ * @property {*} default
22
+ * @property {number} [min]
23
+ * @property {number} [max]
24
+ * @property {number} [step]
25
+ * @property {string} label
26
+ */
27
+
28
+ /** @type {ConfigField[]} */
29
+ export const CONFIG_SPEC = [
30
+ {
31
+ key: 'dataDir',
32
+ type: 'string',
33
+ default: '',
34
+ label: '档案目录(留空 = ~/.dsh/feiyu-diary)',
35
+ },
36
+ {
37
+ key: 'enabled',
38
+ type: 'boolean',
39
+ default: true,
40
+ label: '总开关',
41
+ },
42
+ {
43
+ key: 'includePersona',
44
+ type: 'boolean',
45
+ default: true,
46
+ label: '把人格内核一起注入(装了别的人设插件就关掉)',
47
+ },
48
+ {
49
+ key: 'budgetTokens',
50
+ type: 'number',
51
+ // 2000 → 3000。量过的:常驻段本身要 2680 token(「最近的事」八条占 1422),
52
+ // 2000 根本装不下,每次都裁 —— 「最近的事」只剩 2 条。3000 能给到 6 条。
53
+ // 代价约每轮 +1000 token ≈ 每句话多 0.06 分。
54
+ default: 3000,
55
+ min: 200,
56
+ max: 8000,
57
+ step: 1,
58
+ label: '注入预算(token)',
59
+ },
60
+ {
61
+ key: 'model',
62
+ type: 'string',
63
+ default: '',
64
+ label: '固化用模型(留空 = 跟随当前默认模型)',
65
+ },
66
+ {
67
+ key: 'reasoningEffort',
68
+ type: 'string',
69
+ default: 'off',
70
+ label: '固化的思考档位(off/low/high/max;保持 off)',
71
+ },
72
+ {
73
+ key: 'maxTokens',
74
+ type: 'number',
75
+ default: 900,
76
+ min: 200,
77
+ max: 4000,
78
+ step: 1,
79
+ label: '固化最大输出 token',
80
+ },
81
+ {
82
+ key: 'thirdPartyRedaction',
83
+ type: 'boolean',
84
+ default: true,
85
+ label: '第三方隐私泛化',
86
+ },
87
+ {
88
+ key: 'amountThreshold',
89
+ type: 'number',
90
+ default: 100,
91
+ min: 0,
92
+ step: 1,
93
+ label: '金额泛化门槛(低于此值保留)',
94
+ },
95
+ {
96
+ key: 'minSalience',
97
+ type: 'number',
98
+ default: 0.15,
99
+ min: 0,
100
+ max: 1,
101
+ step: 0.05,
102
+ label: '日记重要度门槛',
103
+ },
104
+ {
105
+ key: 'highSim',
106
+ type: 'number',
107
+ default: 0.5,
108
+ min: 0,
109
+ max: 1,
110
+ step: 0.05,
111
+ label: '必触发的相似度阈值',
112
+ },
113
+ {
114
+ key: 'recentEntries',
115
+ type: 'number',
116
+ default: 8,
117
+ min: 0,
118
+ max: 30,
119
+ step: 1,
120
+ label: '「最近的事」最多列几条(不靠相似度,永远在场)',
121
+ },
122
+ {
123
+ key: 'maxShowPerTurn',
124
+ type: 'number',
125
+ default: 4,
126
+ min: 0,
127
+ max: 12,
128
+ step: 1,
129
+ label: '「突然想起来」最多几条(按相似度召回)',
130
+ },
131
+ {
132
+ key: 'wanderChance',
133
+ type: 'number',
134
+ default: 0.08,
135
+ min: 0,
136
+ max: 0.5,
137
+ step: 0.01,
138
+ label: '走神想起旧事的概率',
139
+ },
140
+ {
141
+ // ── 续写窗口 ──
142
+ //
143
+ // 同一个会话里,隔几轮之内还写到同一件事,就**并进上一条**,不新增。
144
+ //
145
+ // 为什么要有这一项:实测 8 小时写了 25 条,其中 15 条来自同一个会话,
146
+ // 间隔有 1 / 1 / 3 / 3 / 4 / 4 / 5 分钟的 —— **五分钟里写三条**。
147
+ // 那不是"记得多",是同一件事被拆成三次写。
148
+ //
149
+ // 2026-10-27 之前它硬编码在 host 里(4 轮),配置里没有这一项,
150
+ // 所以谁也调不了。现在暴露出来,默认也调宽了。
151
+ //
152
+ // ⚠️ **没做成"每天上限"**。上限会把"那天真的很沉"一起裁掉 ——
153
+ // 而那种日子恰恰是最不该裁的。限制合并不是限制记忆。
154
+ key: 'appendWindowTurns',
155
+ type: 'number',
156
+ default: 20,
157
+ min: 0,
158
+ max: 200,
159
+ step: 1,
160
+ label: '续写窗口(同会话里隔几轮内写到同一件事就并进上一条,0 = 关掉)',
161
+ },
162
+ {
163
+ key: 'offPeakHours',
164
+ type: 'string',
165
+ default: '00:30-08:30',
166
+ label: '低峰时段(HH:MM-HH:MM)',
167
+ },
168
+ {
169
+ key: 'deferToOffPeak',
170
+ type: 'boolean',
171
+ default: false,
172
+ label: '把固化推迟到低峰时段',
173
+ },
174
+ {
175
+ key: 'debugLog',
176
+ type: 'boolean',
177
+ default: false,
178
+ label: '开发期调试日志',
179
+ },
180
+ ];
181
+
182
+ /** 全部默认值(冻结) */
183
+ export const CONFIG_DEFAULTS = Object.freeze(
184
+ Object.fromEntries(CONFIG_SPEC.map((f) => [f.key, f.default])),
185
+ );
186
+
187
+ /**
188
+ * DSH 0.2.0-rc.2 起,loader 会把 `config:` 里的每个值包成 cosmokit 的
189
+ * **volatile 引用**(为了 `patchReload: live` 热重载):
190
+ *
191
+ * { get: () => current, [Symbol.for("cosmokit.volatile.write")]: (v) => {...} }
192
+ *
193
+ * 它是 Object.freeze 过的普通对象,get 是函数值 —— 于是:
194
+ * JSON.stringify(ref) → "{}"
195
+ * Object.keys(ref) → ["get"]
196
+ * String(ref) → **"[object Object]"**
197
+ *
198
+ * 我们原来把它当值用,后果比"路径难看"严重得多:
199
+ *
200
+ * · **所有 string 字段**变成 "[object Object]":
201
+ * dataDir → 变成相对路径,跟着进程 cwd 跑。换个目录启动就"搬家",
202
+ * 表现像失忆,而且全程无报错。
203
+ * model → 不存在的模型名,**固化那次调用根本发不出去**,
204
+ * 只剩 feiyu_note 能写进去。
205
+ * reasoningEffort / offPeakHours → 同样失效。
206
+ * · **number / boolean** 静默回落默认值(Number(ref) 是 NaN、布尔比不上),
207
+ * 用户改不动 budgetTokens / minSalience,也没有任何提示。
208
+ *
209
+ * 正确读法是 `.get()`(DSH 自家插件都这么写,比如 dsh-bash-local 的 workdir)。
210
+ *
211
+ * 用 Symbol.for 探测、不 import cosmokit —— 保住「零运行时依赖」。
212
+ *
213
+ * 报这个 bug 的朋友把机制定位到了 cosmokit 的那一行,还贴了只读探针的输出。
214
+ * 我原来那个"只认绝对路径"的补丁只是治标:它拦住了 [object Object] 当路径,
215
+ * 但 model / offPeakHours 照样坏,数字布尔照样静默回落。
216
+ */
217
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write');
218
+
219
+ function unref(value) {
220
+ if (typeof value !== 'object' || value === null) return value;
221
+ // volatile 的判据:写着 write symbol,而且 get 是函数
222
+ if (VOLATILE_WRITE in value && typeof value.get === 'function') {
223
+ try {
224
+ return value.get();
225
+ } catch {
226
+ return undefined;
227
+ }
228
+ }
229
+ return value;
230
+ }
231
+
232
+ function coerce(field, value) {
233
+ value = unref(value);
234
+ if (value === undefined || value === null) return field.default;
235
+
236
+ if (field.type === 'boolean') {
237
+ if (typeof value === 'boolean') return value;
238
+ if (value === 'true' || value === 1 || value === '1') return true;
239
+ if (value === 'false' || value === 0 || value === '0') return false;
240
+ return field.default;
241
+ }
242
+
243
+ if (field.type === 'number') {
244
+ const n = Number(value);
245
+ if (!Number.isFinite(n)) return field.default;
246
+ let out = n;
247
+ if (Number.isFinite(field.min)) out = Math.max(field.min, out);
248
+ if (Number.isFinite(field.max)) out = Math.min(field.max, out);
249
+ return out;
250
+ }
251
+
252
+ // ⚠️ 别对非字符串做 String(value) —— 那正是把对象变成 "[object Object]" 的那一步。
253
+ // 配置项不是字符串,说明上层给错了;回默认比"变成一串垃圾"好排查得多。
254
+ return typeof value === 'string' ? value : field.default;
255
+ }
256
+
257
+ /**
258
+ * 把原始配置(来自 `cordis.patch.yml` 的 `config:`)规整成一定合法的配置对象。
259
+ * **永不抛错** —— 坏配置退回默认值。
260
+ *
261
+ * @param {unknown} raw
262
+ * @returns {Record<string, any>}
263
+ */
264
+ export function resolveConfig(raw) {
265
+ const src = raw && typeof raw === 'object' ? raw : {};
266
+ /** @type {Record<string, any>} */
267
+ const out = {};
268
+ for (const field of CONFIG_SPEC) out[field.key] = coerce(field, src[field.key]);
269
+ return out;
270
+ }
271
+
272
+ /**
273
+ * 用 schemastery 构造 Config。**只在能 import 到 schemastery 时调用。**
274
+ *
275
+ * 注意 `volatile` 是**链式方法**(`z.string().volatile()`),
276
+ * 而且必须落在固定对象路径上 —— 不能嵌在数组、union、dict 里。
277
+ *
278
+ * @param {any} z schemastery 默认导出
279
+ * @returns {any}
280
+ */
281
+ export function buildSchema(z) {
282
+ /** @type {Record<string, any>} */
283
+ const shape = {};
284
+ for (const f of CONFIG_SPEC) {
285
+ let s;
286
+ if (f.type === 'boolean') s = z.boolean();
287
+ else if (f.type === 'number') {
288
+ s = z.number();
289
+ if (Number.isFinite(f.step)) s = s.step(f.step);
290
+ if (Number.isFinite(f.min)) s = s.min(f.min);
291
+ if (Number.isFinite(f.max)) s = s.max(f.max);
292
+ } else s = z.string();
293
+ shape[f.key] = s.default(f.default).volatile();
294
+ }
295
+ return z.object(shape);
296
+ }
297
+
298
+ /**
299
+ * 判断"现在是不是低峰时段"。配置成 `HH:MM-HH:MM`;跨午夜也支持。
300
+ *
301
+ * @param {string} spec
302
+ * @param {Date} [now]
303
+ * @returns {boolean} 配置不合法时返回 true(即"随时可以跑",不让坏配置卡死功能)
304
+ */
305
+ export function isOffPeak(spec, now = new Date()) {
306
+ if (typeof spec !== 'string') return true;
307
+ const m = spec.match(/^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/);
308
+ if (m === null) return true;
309
+
310
+ const [, sh, sm, eh, em] = m;
311
+ const start = Number(sh) * 60 + Number(sm);
312
+ const end = Number(eh) * 60 + Number(em);
313
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return true;
314
+
315
+ const cur = now.getHours() * 60 + now.getMinutes();
316
+ return start <= end ? cur >= start && cur < end : cur >= start || cur < end;
317
+ }
318
+
319
+ /**
320
+ * 距离下一个低峰时段开始还有多少毫秒。已经在低峰里就返回 0。
321
+ *
322
+ * @param {string} spec
323
+ * @param {Date} [now]
324
+ * @returns {number}
325
+ */
326
+ export function msUntilOffPeak(spec, now = new Date()) {
327
+ if (isOffPeak(spec, now)) return 0;
328
+ const m = typeof spec === 'string' ? spec.match(/^\s*(\d{1,2}):(\d{2})/) : null;
329
+ if (m === null) return 0;
330
+ const start = Number(m[1]) * 60 + Number(m[2]);
331
+ const cur = now.getHours() * 60 + now.getMinutes();
332
+ let delta = start - cur;
333
+ if (delta <= 0) delta += 24 * 60;
334
+ return delta * 60000;
335
+ }