weaver-e9-assistant-cli 1.1.4 → 1.1.11

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/dist/memory.js ADDED
@@ -0,0 +1,968 @@
1
+ /**
2
+ * 用户记忆模块:千人千面的流程快捷方式 + 字段默认值 + 三层记忆。
3
+ *
4
+ * 设计原则:
5
+ * - 按环境 + 用户双层隔离:`~/.e9/user_memory_<envkey>_<selfId>.json`
6
+ * - 换环境/workflowId 不同 → 隔离;换人/习惯不同 → 隔离
7
+ * - 写入 fail-soft:写失败不阻断业务
8
+ * - 三层记忆模型(借鉴 E10):
9
+ * user — 用户画像(展示/沟通偏好)
10
+ * shortcuts — 全局别名→工作流映射(跨流程通用,所属 memory.md 层)
11
+ * fieldDefaults / formCache — 工作流维度(所属 workflow:{id} 层)
12
+ * - 冲突规则:记忆与当前系统解析结果冲突时以当前系统为准
13
+ *
14
+ * 文件结构(v2,向后兼容 v1):
15
+ * {
16
+ * selfId, selfName, updatedAt,
17
+ * available: true, // 记忆是否可用
18
+ * user: { preferences }, // 用户画像(三层之 user)
19
+ * shortcuts: { "<关键词>": { ... } }, // 全局别名映射(三层之 memory.md)
20
+ * fieldDefaults: { "<wfId>": { ... } }, // 字段默认值(三层之 workflow:{id})
21
+ * autoSubmit: ["<关键词>"],
22
+ * formCache: { "<wfId>": { ... } } // 表单结构缓存
23
+ * }
24
+ */
25
+
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import os from 'node:os';
29
+ import { profileNameFromBaseUrl } from './config.js';
30
+ import { getCurrentUser } from './wfuser.js';
31
+
32
+ /** 缓存目录(测试可用 E9_MEMORY_CACHE_DIR 覆盖)。 */
33
+ export function memoryCacheDir() {
34
+ return process.env.E9_MEMORY_CACHE_DIR || path.join(os.homedir(), '.e9');
35
+ }
36
+
37
+ /** 缓存文件路径:~/.e9/user_memory_<envkey>_<selfId>.json */
38
+ export function memoryCachePath(cfg, selfId) {
39
+ const envkey = profileNameFromBaseUrl(cfg.baseUrl);
40
+ return path.join(memoryCacheDir(), `user_memory_${envkey}_${selfId}.json`);
41
+ }
42
+
43
+ /** 解析 cookie 里的 userId(兜底)。 */
44
+ function parseUserIdFromCookie(cookie) {
45
+ if (!cookie) return '';
46
+ const m = cookie.match(/(?:^|;\s*)loginidweaver=([^;]+)/);
47
+ if (!m) return '';
48
+ return m[1] || '';
49
+ }
50
+
51
+ // ─── 读取 ──────────────────────────────────────
52
+
53
+ /**
54
+ * 读记忆文件;文件不存在/损坏/selfId 不匹配 → 返回空记忆。
55
+ * v2 格式新字段(user/formCache)不存在时自动补默认值,保证向后兼容。
56
+ */
57
+ export function readMemory(cfg, selfId) {
58
+ try {
59
+ const raw = JSON.parse(fs.readFileSync(memoryCachePath(cfg, selfId), 'utf8'));
60
+ if (!raw || raw.selfId !== selfId) return emptyMemory(selfId, '');
61
+ return {
62
+ selfId: raw.selfId || selfId,
63
+ selfName: raw.selfName || '',
64
+ updatedAt: raw.updatedAt || '',
65
+ lastCacheCheck: raw.lastCacheCheck || null,
66
+ available: raw.available !== false, // 默认 true(兼容 v1 文件无此字段)
67
+ user: raw.user || {},
68
+ shortcuts: raw.shortcuts || {},
69
+ fieldDefaults: raw.fieldDefaults || {},
70
+ autoSubmit: raw.autoSubmit || [],
71
+ formCache: raw.formCache || {},
72
+ workflowListCache: raw.workflowListCache || {},
73
+ };
74
+ } catch {
75
+ return emptyMemory(selfId, '');
76
+ }
77
+ }
78
+
79
+ /** 写记忆文件(失败不抛,不阻断业务)。 */
80
+ export function writeMemory(cfg, mem) {
81
+ const p = memoryCachePath(cfg, mem.selfId);
82
+ // ★ 原子写:先写临时文件再 rename 替换(同卷 rename 原子),并发读方永远不会看到半写文件,
83
+ // 避免 JSON 撕裂/损坏导致 readMemory 拿空记忆。(跨进程 last-writer-wins 仍在,但不再产生损坏缓存。)
84
+ const tmp = `${p}.${process.pid}.tmp`;
85
+ try {
86
+ fs.mkdirSync(memoryCacheDir(), { recursive: true });
87
+ fs.writeFileSync(tmp, JSON.stringify({
88
+ selfId: mem.selfId,
89
+ selfName: mem.selfName,
90
+ updatedAt: new Date().toISOString(),
91
+ lastCacheCheck: mem.lastCacheCheck || null,
92
+ available: mem.available !== false,
93
+ user: mem.user || {},
94
+ shortcuts: mem.shortcuts || {},
95
+ fieldDefaults: mem.fieldDefaults || {},
96
+ autoSubmit: mem.autoSubmit || [],
97
+ formCache: mem.formCache || {},
98
+ workflowListCache: mem.workflowListCache || {},
99
+ }, null, 2) + '\n', 'utf8');
100
+ fs.renameSync(tmp, p);
101
+ } catch {
102
+ // 写失败不阻断业务;顺带清理可能残留的临时文件
103
+ try { fs.rmSync(tmp, { force: true }); } catch { /* 清理失败忽略 */ }
104
+ }
105
+ }
106
+
107
+ /** 空记忆模板。 */
108
+ function emptyMemory(selfId, selfName) {
109
+ return {
110
+ selfId, selfName, updatedAt: '', lastCacheCheck: null,
111
+ available: true,
112
+ user: {},
113
+ shortcuts: {}, fieldDefaults: {}, autoSubmit: [],
114
+ formCache: {},
115
+ workflowListCache: {},
116
+ };
117
+ }
118
+
119
+ /**
120
+ * 加载当前用户的记忆(带 selfId 解析)。
121
+ * 返回 { mem, selfId, selfName }。
122
+ */
123
+ export async function loadMemory(cfg) {
124
+ let selfId = '';
125
+ let selfName = '';
126
+ try {
127
+ const u = await getCurrentUser(cfg);
128
+ selfId = String(u?.userid ?? '');
129
+ selfName = String(u?.username ?? '');
130
+ } catch { /* 走 cookie 兜底 */ }
131
+ if (!selfId) selfId = parseUserIdFromCookie(cfg?.cookie);
132
+ if (!selfId) {
133
+ return { mem: emptyMemory('', ''), selfId: '', selfName: '' };
134
+ }
135
+ const mem = readMemory(cfg, selfId);
136
+ mem.selfName = selfName || mem.selfName;
137
+
138
+ // ★ 后台缓存巡检:24h 内未检查过则异步触发(fire-and-forget,不阻塞当前命令)
139
+ const now = Date.now();
140
+ const lastCheck = mem.lastCacheCheck ? new Date(mem.lastCacheCheck).getTime() : 0;
141
+ if (now - lastCheck >= CACHE_CHECK_INTERVAL_MS) {
142
+ const shortcuts = Object.keys(mem.shortcuts || {});
143
+ if (shortcuts.length > 0) {
144
+ // 不 await——后台静默跑完,下次命令就能用新鲜缓存
145
+ refreshStaleFormCaches(cfg, selfId).catch(() => {});
146
+ }
147
+ }
148
+
149
+ return { mem, selfId, selfName };
150
+ }
151
+
152
+ // ─── 快捷方式(别名→工作流映射)────────────────
153
+
154
+ /**
155
+ * 保存一个快捷方式。
156
+ */
157
+ export function saveShortcut(cfg, selfId, selfName, keyword, workflowId, workflowName) {
158
+ const mem = readMemory(cfg, selfId);
159
+ const existing = mem.shortcuts[keyword];
160
+ mem.shortcuts[keyword] = {
161
+ workflowId: String(workflowId),
162
+ workflowName: workflowName || existing?.workflowName || '',
163
+ hitCount: (existing?.hitCount || 0) + 1,
164
+ lastUsed: new Date().toISOString(),
165
+ createdAt: existing?.createdAt || new Date().toISOString(),
166
+ };
167
+ mem.selfName = selfName || mem.selfName;
168
+ writeMemory(cfg, mem);
169
+ return mem.shortcuts[keyword];
170
+ }
171
+
172
+ /**
173
+ * 查找快捷方式(精确匹配关键词)。
174
+ */
175
+ export function findShortcut(cfg, selfId, keyword) {
176
+ const mem = readMemory(cfg, selfId);
177
+ return mem.shortcuts[keyword] || null;
178
+ }
179
+
180
+ // ─── 模糊匹配 ──────────────────────────────────
181
+
182
+ /**
183
+ * 编辑距离(Levenshtein)。
184
+ * 阈值:≤1 认为可匹配("请假"↔"休假" 距离=1,"请假"↔"请个假" 距离=1)。
185
+ * >1 的不认("打卡"↔"请假" 距离=2 不匹配)。
186
+ */
187
+ function levenshtein(a, b) {
188
+ const m = a.length, n = b.length;
189
+ if (m === 0) return n;
190
+ if (n === 0) return m;
191
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
192
+ let curr = new Array(n + 1);
193
+ for (let i = 1; i <= m; i++) {
194
+ curr[0] = i;
195
+ for (let j = 1; j <= n; j++) {
196
+ curr[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j], curr[j - 1], prev[j - 1]);
197
+ }
198
+ [prev, curr] = [curr, prev];
199
+ }
200
+ return prev[n];
201
+ }
202
+
203
+ /** 去除常见后缀,提高匹配召回率。 */
204
+ const COMMON_SUFFIXES = ['流程', '申请', '单', '审批', '表'];
205
+
206
+ function stripCommonSuffixes(s) {
207
+ let result = s;
208
+ for (const suffix of COMMON_SUFFIXES) {
209
+ if (result.endsWith(suffix) && result.length > suffix.length + 1) {
210
+ result = result.slice(0, -suffix.length);
211
+ }
212
+ }
213
+ return result;
214
+ }
215
+
216
+ /**
217
+ * 规范化关键词(小写、去空格、去常见后缀)。
218
+ */
219
+ function normalizeKeyword(kw) {
220
+ return stripCommonSuffixes(kw.trim().toLowerCase()).trim();
221
+ }
222
+
223
+ /**
224
+ * 模糊查找快捷方式。
225
+ *
226
+ * 匹配策略(按优先级):
227
+ * 1. 精确匹配 → 直接返回
228
+ * 2. 规范化精确匹配(去空格去后缀)→ 返回
229
+ * 3. 规范化后互相包含("请个假" contains "请假")→ 返回
230
+ * 4. 编辑距离 ≤2 → 返回
231
+ * 5. 都不命中 → 返回 null
232
+ *
233
+ * 多候选时按 hitCount 降序、lastUsed 降序取最佳。
234
+ *
235
+ * @returns {{ shortcut, matchedBy: 'exact'|'normalized'|'contains'|'fuzzy', keyword: string } | null}
236
+ */
237
+ export function findShortcutFuzzy(cfg, selfId, keyword) {
238
+ const mem = readMemory(cfg, selfId);
239
+ const keys = Object.keys(mem.shortcuts);
240
+ if (keys.length === 0) return null;
241
+
242
+ const input = (keyword || '').trim();
243
+ if (!input) return null;
244
+
245
+ // 1. 精确匹配
246
+ const exact = mem.shortcuts[input];
247
+ if (exact) return { shortcut: exact, matchedBy: 'exact', keyword: input };
248
+
249
+ const inputNorm = normalizeKeyword(input);
250
+
251
+ // 2. 规范化精确匹配
252
+ for (const key of keys) {
253
+ if (normalizeKeyword(key) === inputNorm) {
254
+ return { shortcut: mem.shortcuts[key], matchedBy: 'normalized', keyword: key };
255
+ }
256
+ }
257
+
258
+ // 3. 规范化后互相包含
259
+ const containsMatches = [];
260
+ for (const key of keys) {
261
+ const keyNorm = normalizeKeyword(key);
262
+ if (keyNorm && inputNorm && (inputNorm.includes(keyNorm) || keyNorm.includes(inputNorm))) {
263
+ containsMatches.push({ key, s: mem.shortcuts[key] });
264
+ }
265
+ }
266
+ if (containsMatches.length === 1) {
267
+ return { shortcut: containsMatches[0].s, matchedBy: 'contains', keyword: containsMatches[0].key };
268
+ }
269
+ if (containsMatches.length > 1) {
270
+ containsMatches.sort((a, b) => {
271
+ const hc = (b.s.hitCount || 0) - (a.s.hitCount || 0);
272
+ if (hc !== 0) return hc;
273
+ return new Date(b.s.lastUsed || 0).getTime() - new Date(a.s.lastUsed || 0).getTime();
274
+ });
275
+ return { shortcut: containsMatches[0].s, matchedBy: 'contains', keyword: containsMatches[0].key };
276
+ }
277
+
278
+ // 4. 编辑距离 ≤1
279
+ const fuzzyMatches = [];
280
+ for (const key of keys) {
281
+ const keyNorm = normalizeKeyword(key);
282
+ if (keyNorm && inputNorm) {
283
+ const dist = levenshtein(inputNorm, keyNorm);
284
+ if (dist <= 1) fuzzyMatches.push({ key, s: mem.shortcuts[key], dist });
285
+ }
286
+ }
287
+ if (fuzzyMatches.length > 0) {
288
+ fuzzyMatches.sort((a, b) => a.dist - b.dist || (b.s.hitCount || 0) - (a.s.hitCount || 0));
289
+ return { shortcut: fuzzyMatches[0].s, matchedBy: 'fuzzy', keyword: fuzzyMatches[0].key };
290
+ }
291
+
292
+ return null;
293
+ }
294
+
295
+ /**
296
+ * 记录快捷方式命中(更新 hitCount + lastUsed)。
297
+ * keyword 必须是用 findShortcutFuzzy 返回的 keyword(存储的实际 key),
298
+ * 否则模糊匹配的命中可能写入错误的键。
299
+ */
300
+ export function hitShortcut(cfg, selfId, keyword) {
301
+ const mem = readMemory(cfg, selfId);
302
+ const s = mem.shortcuts[keyword];
303
+ if (!s) return;
304
+ s.hitCount = (s.hitCount || 0) + 1;
305
+ s.lastUsed = new Date().toISOString();
306
+ mem.updatedAt = new Date().toISOString();
307
+ writeMemory(cfg, mem);
308
+ }
309
+
310
+ /** 快捷方式清理默认阈值:90 天未使用。 */
311
+ export const SHORTCUT_EXPIRY_DAYS = 90;
312
+
313
+ /**
314
+ * 清理过期快捷方式(超过 maxAgeDays 天未使用)。
315
+ * 返回清理掉的快捷方式列表(供展示)。
316
+ */
317
+ export function cleanupShortcuts(cfg, selfId, maxAgeDays = SHORTCUT_EXPIRY_DAYS) {
318
+ const mem = readMemory(cfg, selfId);
319
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
320
+ const removed = [];
321
+ for (const [keyword, s] of Object.entries(mem.shortcuts)) {
322
+ const lastUsed = new Date(s.lastUsed || s.createdAt || 0).getTime();
323
+ if (lastUsed < cutoff) {
324
+ removed.push({ keyword, workflowId: s.workflowId, workflowName: s.workflowName, lastUsed: s.lastUsed });
325
+ delete mem.shortcuts[keyword];
326
+ }
327
+ }
328
+ if (removed.length > 0) {
329
+ writeMemory(cfg, mem);
330
+ }
331
+ return removed;
332
+ }
333
+
334
+ /**
335
+ * 查找过期快捷方式(不删除,仅列出)。
336
+ */
337
+ export function findExpiredShortcuts(cfg, selfId, maxAgeDays = SHORTCUT_EXPIRY_DAYS) {
338
+ const mem = readMemory(cfg, selfId);
339
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
340
+ const expired = [];
341
+ for (const [keyword, s] of Object.entries(mem.shortcuts)) {
342
+ const lastUsed = new Date(s.lastUsed || s.createdAt || 0).getTime();
343
+ if (lastUsed < cutoff) {
344
+ expired.push({ keyword, workflowId: s.workflowId, workflowName: s.workflowName, lastUsed: s.lastUsed, hitCount: s.hitCount });
345
+ }
346
+ }
347
+ return expired.sort((a, b) => new Date(a.lastUsed || 0).getTime() - new Date(b.lastUsed || 0).getTime());
348
+ }
349
+
350
+ /**
351
+ * 删除快捷方式。
352
+ */
353
+ export function forgetShortcut(cfg, selfId, keyword) {
354
+ const mem = readMemory(cfg, selfId);
355
+ if (!mem.shortcuts[keyword]) return false;
356
+ delete mem.shortcuts[keyword];
357
+ writeMemory(cfg, mem);
358
+ return true;
359
+ }
360
+
361
+ // ─── 字段默认值(工作流维度)───────────────
362
+
363
+ /** 值历史最大保留条数。 */
364
+ const MAX_VALUE_HISTORY = 10;
365
+
366
+ /**
367
+ * 保存字段默认值(同时更新值频率历史)。
368
+ * history 按使用频次降序排列,最多保留 MAX_VALUE_HISTORY 条。
369
+ */
370
+ export function saveFieldDefault(cfg, selfId, selfName, workflowId, fieldId, label, value) {
371
+ const mem = readMemory(cfg, selfId);
372
+ if (!mem.fieldDefaults[workflowId]) mem.fieldDefaults[workflowId] = {};
373
+ const entry = mem.fieldDefaults[workflowId][fieldId] || {};
374
+ const now = new Date().toISOString();
375
+
376
+ // 更新频率历史
377
+ const history = Array.isArray(entry.history) ? [...entry.history] : [];
378
+ const existing = history.find(h => h.value === value);
379
+ if (existing) {
380
+ existing.count += 1;
381
+ existing.lastUsed = now;
382
+ } else {
383
+ history.push({ value, count: 1, firstUsed: now, lastUsed: now });
384
+ }
385
+ // 按使用频次降序,截断
386
+ history.sort((a, b) => b.count - a.count || new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime());
387
+ if (history.length > MAX_VALUE_HISTORY) history.length = MAX_VALUE_HISTORY;
388
+
389
+ mem.fieldDefaults[workflowId][fieldId] = {
390
+ label: label || entry.label || '',
391
+ default: value,
392
+ savedAt: now,
393
+ history,
394
+ };
395
+ mem.selfName = selfName || mem.selfName;
396
+ writeMemory(cfg, mem);
397
+ }
398
+
399
+ /**
400
+ * 获取某流程的字段默认值(向后兼容:返回 {label, default, savedAt, history?})。
401
+ */
402
+ export function getFieldDefaults(cfg, selfId, workflowId) {
403
+ const mem = readMemory(cfg, selfId);
404
+ return mem.fieldDefaults[workflowId] || {};
405
+ }
406
+
407
+ /**
408
+ * 获取某字段的值频率历史(按使用次数降序)。
409
+ * 返回 [{value, count, firstUsed, lastUsed}] 或 [](无历史时回退到单个 default 值)。
410
+ */
411
+ export function getFieldValueHistory(cfg, selfId, workflowId, fieldId) {
412
+ const defaults = getFieldDefaults(cfg, selfId, workflowId);
413
+ const entry = defaults[fieldId];
414
+ if (!entry) return [];
415
+ if (Array.isArray(entry.history) && entry.history.length > 0) {
416
+ return entry.history;
417
+ }
418
+ // v1 兼容:无 history 时用 default 值构造单条历史
419
+ if (entry.default !== undefined && entry.default !== '') {
420
+ return [{ value: String(entry.default), count: 1, lastUsed: entry.savedAt }];
421
+ }
422
+ return [];
423
+ }
424
+
425
+ // ─── 自动提交 ──────────────────────────────────
426
+
427
+ export function setAutoSubmit(cfg, selfId, keyword, enabled) {
428
+ const mem = readMemory(cfg, selfId);
429
+ if (enabled && !mem.autoSubmit.includes(keyword)) {
430
+ mem.autoSubmit.push(keyword);
431
+ } else if (!enabled) {
432
+ mem.autoSubmit = mem.autoSubmit.filter(k => k !== keyword);
433
+ }
434
+ writeMemory(cfg, mem);
435
+ }
436
+
437
+ // ─── 清空 ──────────────────────────────────────
438
+
439
+ export function clearMemory(cfg, selfId) {
440
+ try {
441
+ const p = memoryCachePath(cfg, selfId);
442
+ if (fs.existsSync(p)) fs.unlinkSync(p);
443
+ return true;
444
+ } catch {
445
+ return false;
446
+ }
447
+ }
448
+
449
+ // ─── 表单结构缓存 ──────────────────────────────
450
+
451
+ /** 表单缓存默认 TTL:30 天(毫秒)。 */
452
+ export const FORM_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
453
+ /**
454
+ * 表单缓存结构版本。递增后旧缓存一律失效重拉:
455
+ * v2 起缓存 linkageCfg(公式/联动),旧缓存可能缺少后来新增的公式配置。
456
+ */
457
+ export const FORM_CACHE_SCHEMA_VERSION = 2;
458
+
459
+ /**
460
+ * 计算表单字段级签名(供后台巡检对比)。
461
+ *
462
+ * 只比 totalFields 数量检测不到「字段数不变但属性变」(改 label/htmltype/isedit/isview/viewattr)。
463
+ * 签名覆盖每个字段的可编辑性与类型属性,字段数量或任一属性变化即产生不同签名。
464
+ * 字段顺序无关(loadForm 返回顺序可能变化),FNV-1a 32 位哈希十六进制。
465
+ */
466
+ export function computeFormSignature(tables) {
467
+ if (!Array.isArray(tables)) return '';
468
+ const parts = [];
469
+ for (const t of tables) {
470
+ for (const fld of (t?.fields ?? [])) {
471
+ parts.push([
472
+ String(fld?.id ?? ''),
473
+ String(fld?.htmltype ?? ''),
474
+ String(fld?.isonlyshow ?? ''),
475
+ String(fld?.isedit ?? ''),
476
+ String(fld?.isview ?? ''),
477
+ String(fld?.viewattr ?? ''),
478
+ String(fld?.name ?? ''),
479
+ ].join('|'));
480
+ }
481
+ }
482
+ parts.sort();
483
+ const s = parts.join('~');
484
+ let h = 0;
485
+ for (let i = 0; i < s.length; i++) {
486
+ h ^= s.charCodeAt(i);
487
+ h = (h * 0x01000193) >>> 0;
488
+ }
489
+ return h.toString(36);
490
+ }
491
+
492
+ export function cacheFormFields(cfg, selfId, workflowId, formData) {
493
+ try {
494
+ const mem = readMemory(cfg, selfId);
495
+ if (!mem.formCache) mem.formCache = {};
496
+ const now = new Date().toISOString();
497
+ mem.formCache[String(workflowId)] = {
498
+ schemaVersion: FORM_CACHE_SCHEMA_VERSION,
499
+ cachedAt: now,
500
+ lastValidatedAt: now,
501
+ workflowName: formData.workflowName,
502
+ tables: formData.tables,
503
+ totalFields: formData.totalFields,
504
+ verifyRight: formData.verifyRight,
505
+ submitParams: formData.submitParams,
506
+ params: formData.params,
507
+ linkageCfg: formData.raw?.linkageCfg,
508
+ formSignature: computeFormSignature(formData.tables),
509
+ };
510
+ writeMemory(cfg, mem);
511
+ } catch { /* 写失败不阻断业务 */ }
512
+ }
513
+
514
+ export function getCachedFormFields(cfg, selfId, workflowId, maxAgeMs = FORM_CACHE_TTL_MS) {
515
+ try {
516
+ const mem = readMemory(cfg, selfId);
517
+ const entry = mem.formCache?.[String(workflowId)];
518
+ if (!entry) return null;
519
+ if (Number(entry.schemaVersion) !== FORM_CACHE_SCHEMA_VERSION) return null;
520
+ if (Date.now() - new Date(entry.cachedAt).getTime() > maxAgeMs) return null;
521
+ return entry;
522
+ } catch {
523
+ return null;
524
+ }
525
+ }
526
+
527
+ export function clearFormCache(cfg, selfId, workflowId) {
528
+ const mem = readMemory(cfg, selfId);
529
+ if (!mem.formCache?.[String(workflowId)]) return false;
530
+ delete mem.formCache[String(workflowId)];
531
+ writeMemory(cfg, mem);
532
+ return true;
533
+ }
534
+
535
+ // ─── 字段映射缓存(formCache 便捷访问)─────────
536
+
537
+ /**
538
+ * 从缓存的表单结构中构建字段查找映射。
539
+ *
540
+ * 内部调 getCachedFormFields,复用其 TTL;过期/缺失/无 tables 时返回 null。
541
+ *
542
+ * @returns {{ byName: Map<string, object>, byId: Map<string, object> } | null}
543
+ */
544
+ export function getCachedFieldMap(cfg, selfId, workflowId, maxAgeMs = FORM_CACHE_TTL_MS) {
545
+ try {
546
+ const entry = getCachedFormFields(cfg, selfId, workflowId, maxAgeMs);
547
+ if (!entry || !Array.isArray(entry.tables) || entry.tables.length === 0) return null;
548
+ const byName = new Map();
549
+ const byId = new Map();
550
+ for (const table of entry.tables) {
551
+ if (!Array.isArray(table.fields)) continue;
552
+ for (const f of table.fields) {
553
+ if (f.id) byId.set(f.id, f);
554
+ if (f.name) byName.set(f.name, f);
555
+ }
556
+ }
557
+ return { byName, byId };
558
+ } catch {
559
+ return null;
560
+ }
561
+ }
562
+
563
+ // ─── 浏览框选项缓存(formCache 子模块)─────────
564
+
565
+ /**
566
+ * 浏览框选项缓存 TTL:24 小时。
567
+ * 选项(组织架构/人员/成本中心等)变动频率远高于表单结构,不能用 30 天——长时间 TTL 会拿到
568
+ * 已离职人员、已撤销部门的选项。与表单结构(FORM_CACHE_TTL_MS=30天)分离。
569
+ */
570
+ export const BROWSE_OPTIONS_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
571
+
572
+ /**
573
+ * 缓存某流程某字段的浏览框选项(id+name 对)。
574
+ * 浏览框选项极少变化,与表单结构共享 TTL。
575
+ */
576
+ export function cacheBrowseOptions(cfg, selfId, workflowId, fieldId, options) {
577
+ try {
578
+ const mem = readMemory(cfg, selfId);
579
+ if (!mem.formCache) mem.formCache = {};
580
+ const wf = mem.formCache[String(workflowId)] || (mem.formCache[String(workflowId)] = { cachedAt: new Date().toISOString() });
581
+ if (!wf.browseOptions) wf.browseOptions = {};
582
+ wf.browseOptions[String(fieldId)] = {
583
+ cachedAt: new Date().toISOString(),
584
+ options: options.map(o => ({ id: o.id, name: o.name })),
585
+ };
586
+ writeMemory(cfg, mem);
587
+ } catch { /* 写失败不阻断业务 */ }
588
+ }
589
+
590
+ /**
591
+ * 取缓存的浏览框选项。返回 [{id, name}] 或 null(未缓存/过期)。
592
+ * 默认 24 小时 TTL(BROWSE_OPTIONS_CACHE_TTL_MS)——比表单结构短,选项变动更频繁。
593
+ */
594
+ export function getCachedBrowseOptions(cfg, selfId, workflowId, fieldId, maxAgeMs = BROWSE_OPTIONS_CACHE_TTL_MS) {
595
+ try {
596
+ const mem = readMemory(cfg, selfId);
597
+ const entry = mem.formCache?.[String(workflowId)]?.browseOptions?.[String(fieldId)];
598
+ if (!entry) return null;
599
+ if (Date.now() - new Date(entry.cachedAt).getTime() > maxAgeMs) return null;
600
+ return entry.options;
601
+ } catch {
602
+ return null;
603
+ }
604
+ }
605
+
606
+ // ─── 用户画像(三层之 user)───────────────────
607
+
608
+ /**
609
+ * 设置记忆可用性(enable/disable)。
610
+ */
611
+ export function setMemoryAvailable(cfg, selfId, enabled) {
612
+ const mem = readMemory(cfg, selfId);
613
+ mem.available = !!enabled;
614
+ writeMemory(cfg, mem);
615
+ }
616
+
617
+ /**
618
+ * 保存用户偏好。
619
+ * @param {string} key - 偏好键(如 communicationStyle / showRequestId)
620
+ * @param {*} value - 偏好值
621
+ */
622
+ export function saveUserPreference(cfg, selfId, key, value) {
623
+ const mem = readMemory(cfg, selfId);
624
+ if (!mem.user) mem.user = {};
625
+ if (!mem.user.preferences) mem.user.preferences = {};
626
+ mem.user.preferences[key] = value;
627
+ writeMemory(cfg, mem);
628
+ }
629
+
630
+ /**
631
+ * 获取用户偏好。
632
+ */
633
+ export function getUserPreferences(cfg, selfId) {
634
+ const mem = readMemory(cfg, selfId);
635
+ return mem.user?.preferences || {};
636
+ }
637
+
638
+ // ─── 列出 ─────────────────────────────────────
639
+
640
+ /**
641
+ * 列出全部快捷方式(按 lastUsed 降序)。
642
+ */
643
+ export function listShortcuts(cfg, selfId) {
644
+ const mem = readMemory(cfg, selfId);
645
+ return Object.entries(mem.shortcuts)
646
+ .map(([keyword, s]) => ({ keyword, ...s }))
647
+ .sort((a, b) => new Date(b.lastUsed || 0).getTime() - new Date(a.lastUsed || 0).getTime());
648
+ }
649
+
650
+ // ─── 工作流列表缓存 ────────────────────────────
651
+
652
+ /** 工作流列表缓存 TTL:1 天(比表单结构短,新增/权限变更需较快生效)。 */
653
+ export const WORKFLOW_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
654
+
655
+ /**
656
+ * 缓存新建流程列表(按 tab 分键)。
657
+ * categories: [{id, name, workflows: [{id, name, typeId, ...}]}]
658
+ */
659
+ export function cacheWorkflowList(cfg, selfId, tab, categories) {
660
+ try {
661
+ const mem = readMemory(cfg, selfId);
662
+ if (!mem.workflowListCache) mem.workflowListCache = {};
663
+ mem.workflowListCache[String(tab)] = {
664
+ cachedAt: new Date().toISOString(),
665
+ categories,
666
+ };
667
+ writeMemory(cfg, mem);
668
+ } catch { /* 写失败不阻断 */ }
669
+ }
670
+
671
+ /**
672
+ * 取缓存的工作流列表。返回 categories 或 null。
673
+ */
674
+ export function getCachedWorkflowList(cfg, selfId, tab, maxAgeMs = WORKFLOW_LIST_CACHE_TTL_MS) {
675
+ try {
676
+ const mem = readMemory(cfg, selfId);
677
+ const entry = mem.workflowListCache?.[String(tab)];
678
+ if (!entry) return null;
679
+ if (Date.now() - new Date(entry.cachedAt).getTime() > maxAgeMs) return null;
680
+ return entry.categories;
681
+ } catch {
682
+ return null;
683
+ }
684
+ }
685
+
686
+ /**
687
+ * 按名在缓存的工作流列表中查找(客户端过滤,不走 API)。
688
+ * 返回 [{id, name, ...}] 或 null(缓存未命中)。
689
+ */
690
+ export function findWorkflowByNameInCache(categories, name) {
691
+ if (!categories || !name) return null;
692
+ const kw = name.trim().toLowerCase();
693
+ const hits = [];
694
+ for (const c of categories) {
695
+ for (const w of c.workflows || []) {
696
+ if ((w.name || '').toLowerCase().includes(kw)) {
697
+ hits.push(w);
698
+ }
699
+ }
700
+ }
701
+ return hits.length > 0 ? hits : null;
702
+ }
703
+
704
+ /**
705
+ * 清除工作流列表缓存。
706
+ */
707
+ export function clearWorkflowListCache(cfg, selfId, tab) {
708
+ const mem = readMemory(cfg, selfId);
709
+ if (!mem.workflowListCache) return false;
710
+ if (tab) {
711
+ if (!mem.workflowListCache[String(tab)]) return false;
712
+ delete mem.workflowListCache[String(tab)];
713
+ } else {
714
+ mem.workflowListCache = {};
715
+ }
716
+ writeMemory(cfg, mem);
717
+ return true;
718
+ }
719
+
720
+ // ─── 导出/导入(跨环境迁移)─────────────────────
721
+
722
+ /**
723
+ * 导出当前用户完整记忆(JSON 字符串,可直接写文件或 stdout)。
724
+ * 包含:shortcuts、fieldDefaults、autoSubmit、formCache、workflowListCache、user.preferences。
725
+ */
726
+ export function exportMemory(cfg, selfId) {
727
+ const mem = readMemory(cfg, selfId);
728
+ const exportData = {
729
+ exportedAt: new Date().toISOString(),
730
+ selfId: mem.selfId,
731
+ selfName: mem.selfName,
732
+ shortcuts: mem.shortcuts || {},
733
+ fieldDefaults: mem.fieldDefaults || {},
734
+ autoSubmit: mem.autoSubmit || [],
735
+ formCache: mem.formCache || {},
736
+ workflowListCache: mem.workflowListCache || {},
737
+ user: mem.user || {},
738
+ };
739
+ return JSON.stringify(exportData, null, 2);
740
+ }
741
+
742
+ /**
743
+ * 导入记忆(合并模式:已有保留,新增加入)。返回 { addedShortcuts, addedFields, skippedShortcuts }。
744
+ * input 为 JSON 字符串(exportMemory 的输出格式)。
745
+ */
746
+ export function importMemory(cfg, selfId, selfName, input) {
747
+ let importData;
748
+ try {
749
+ importData = JSON.parse(input);
750
+ } catch (e) {
751
+ throw new Error(`导入文件 JSON 解析失败: ${e.message}`);
752
+ }
753
+ if (!importData || typeof importData !== 'object') {
754
+ throw new Error('导入文件格式不正确');
755
+ }
756
+
757
+ const mem = readMemory(cfg, selfId);
758
+ const addedShortcuts = [];
759
+ const skippedShortcuts = [];
760
+ let addedFields = 0;
761
+
762
+ // 1. 合并快捷方式:已有保留,只加新的
763
+ for (const [keyword, s] of Object.entries(importData.shortcuts || {})) {
764
+ if (mem.shortcuts[keyword]) {
765
+ skippedShortcuts.push(keyword);
766
+ } else {
767
+ mem.shortcuts[keyword] = {
768
+ ...s,
769
+ createdAt: s.createdAt || importData.exportedAt || new Date().toISOString(),
770
+ lastUsed: null, // 导入的不算使用过
771
+ hitCount: 0,
772
+ };
773
+ addedShortcuts.push(keyword);
774
+ }
775
+ }
776
+
777
+ // 2. 合并字段默认值:逐字段合并,已有保留
778
+ for (const [wfId, fields] of Object.entries(importData.fieldDefaults || {})) {
779
+ if (!mem.fieldDefaults[wfId]) mem.fieldDefaults[wfId] = {};
780
+ for (const [fieldId, f] of Object.entries(fields)) {
781
+ if (!mem.fieldDefaults[wfId][fieldId]) {
782
+ mem.fieldDefaults[wfId][fieldId] = f;
783
+ addedFields++;
784
+ }
785
+ }
786
+ }
787
+
788
+ // 3. 合并自动提交:已有关键字保留,新关键字加入
789
+ const existingAuto = new Set(mem.autoSubmit || []);
790
+ if (Array.isArray(importData.autoSubmit)) {
791
+ for (const k of importData.autoSubmit) {
792
+ if (!existingAuto.has(k)) {
793
+ (mem.autoSubmit || (mem.autoSubmit = [])).push(k);
794
+ existingAuto.add(k);
795
+ }
796
+ }
797
+ }
798
+
799
+ // 4. 合并用户偏好:已有保留,新 key 加入
800
+ const existingPrefs = mem.user?.preferences || {};
801
+ const importPrefs = importData.user?.preferences || {};
802
+ for (const [k, v] of Object.entries(importPrefs)) {
803
+ if (!(k in existingPrefs)) {
804
+ if (!mem.user) mem.user = {};
805
+ if (!mem.user.preferences) mem.user.preferences = {};
806
+ mem.user.preferences[k] = v;
807
+ }
808
+ }
809
+
810
+ // 5. formCache / workflowListCache 不合并——环境不同表单结构可能不同,保留现有
811
+
812
+ mem.updatedAt = new Date().toISOString();
813
+ writeMemory(cfg, mem);
814
+ return { addedShortcuts, addedFields, skippedShortcuts };
815
+ }
816
+
817
+ // ─── 联动字段结果缓存 ──────────────────────────
818
+
819
+ /** 联动结果缓存 TTL:1 小时(毫秒)。联动数据(组织架构/成本中心等)有变化可能性。 */
820
+ export const LINKAGE_CACHE_TTL_MS = 60 * 60 * 1000;
821
+
822
+ /** 单流程联动缓存最大条目数。key 含字段值,组合几乎每次不同,无上限会无限膨胀。 */
823
+ export const MAX_LINKAGE_CACHE_ENTRIES = 200;
824
+
825
+ /**
826
+ * 缓存联动解析结果。
827
+ * key 由 workflowId + configId + triggerFieldId + triggerValue 组成,确保相同输入直接命中。
828
+ *
829
+ * ★ 淘汰:写入前先清 TTL 已过期的条目,再超上限删最旧——防止缓存文件随字段值组合无限增长
830
+ * (旧实现只按 TTL 读不删,过期条目永久残留,整文件每次 stringify/parse 越来越慢)。
831
+ */
832
+ export function cacheLinkageResult(cfg, selfId, workflowId, configId, triggerFieldId, triggerValue, changeValue) {
833
+ try {
834
+ const mem = readMemory(cfg, selfId);
835
+ if (!mem.formCache) mem.formCache = {};
836
+ if (!mem.formCache[String(workflowId)]) mem.formCache[String(workflowId)] = {};
837
+ if (!mem.formCache[String(workflowId)].linkageResults) {
838
+ mem.formCache[String(workflowId)].linkageResults = {};
839
+ }
840
+ const lr = mem.formCache[String(workflowId)].linkageResults;
841
+ // 1. 清过期(TTL 内的才可能被复用)
842
+ const now = Date.now();
843
+ for (const [k, entry] of Object.entries(lr)) {
844
+ if (now - new Date(entry.cachedAt).getTime() > LINKAGE_CACHE_TTL_MS) delete lr[k];
845
+ }
846
+ // 2. 仍超上限则删最旧(留出一个空位给新条目)
847
+ const entries = Object.entries(lr);
848
+ if (entries.length >= MAX_LINKAGE_CACHE_ENTRIES) {
849
+ entries.sort((a, b) => new Date(a[1].cachedAt).getTime() - new Date(b[1].cachedAt).getTime());
850
+ const toRemove = entries.length - MAX_LINKAGE_CACHE_ENTRIES + 1;
851
+ for (let i = 0; i < toRemove; i++) delete lr[entries[i][0]];
852
+ }
853
+ const key = `${configId}:${triggerFieldId}:${triggerValue}`;
854
+ lr[key] = {
855
+ cachedAt: new Date().toISOString(),
856
+ changeValue,
857
+ };
858
+ writeMemory(cfg, mem);
859
+ } catch { /* 写失败不阻断 */ }
860
+ }
861
+
862
+ /**
863
+ * 取缓存的联动结果。返回 changeValue 或 null。
864
+ */
865
+ export function getCachedLinkageResult(cfg, selfId, workflowId, configId, triggerFieldId, triggerValue, maxAgeMs = LINKAGE_CACHE_TTL_MS) {
866
+ try {
867
+ const mem = readMemory(cfg, selfId);
868
+ const key = `${configId}:${triggerFieldId}:${triggerValue}`;
869
+ const entry = mem.formCache?.[String(workflowId)]?.linkageResults?.[key];
870
+ if (!entry) return null;
871
+ if (Date.now() - new Date(entry.cachedAt).getTime() > maxAgeMs) return null;
872
+ return entry.changeValue;
873
+ } catch {
874
+ return null;
875
+ }
876
+ }
877
+
878
+ // ─── 后台缓存刷新(自动巡检)────────────────────
879
+
880
+ /** 两次全量缓存检查的最小间隔(24 小时)。 */
881
+ export const CACHE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
882
+
883
+ /**
884
+ * 后台巡检所有 shortcut 对应流程的表单缓存,有变化自动更新。
885
+ *
886
+ * - 距上次全量检查 > CACHE_CHECK_INTERVAL_MS 才执行
887
+ * - 逐个流程调 describeFormFields 对比 totalFields,不同则更新缓存
888
+ * - 无变化则只更新 lastValidatedAt
889
+ * - 异步执行、静默完成,调用方不应 await(fire-and-forget)
890
+ *
891
+ * 返回 { checked, updated, errors, details } 供 CLI manual refresh 展示。
892
+ */
893
+ export async function refreshStaleFormCaches(cfg, selfId) {
894
+ const result = { checked: 0, updated: 0, errors: 0, details: [] };
895
+ try {
896
+ const mem = readMemory(cfg, selfId);
897
+ const now = Date.now();
898
+
899
+ // 节流:24h 内已经检查过则跳过
900
+ if (mem.lastCacheCheck) {
901
+ const lastCheck = new Date(mem.lastCacheCheck).getTime();
902
+ if (now - lastCheck < CACHE_CHECK_INTERVAL_MS) {
903
+ return result;
904
+ }
905
+ }
906
+
907
+ // 收集所有 shortcut 对应的 workflowId(去重)
908
+ const wfIds = [...new Set(Object.values(mem.shortcuts || {}).map(s => s.workflowId).filter(Boolean))];
909
+ if (wfIds.length === 0) {
910
+ mem.lastCacheCheck = new Date().toISOString();
911
+ writeMemory(cfg, mem);
912
+ return result;
913
+ }
914
+
915
+ // 动态导入避免循环依赖
916
+ const { describeFormFields } = await import('./wfform.js');
917
+
918
+ for (const wfId of wfIds) {
919
+ const cached = mem.formCache?.[String(wfId)];
920
+ if (!cached) continue;
921
+ const lastValidated = new Date(cached.lastValidatedAt || cached.cachedAt || 0).getTime();
922
+ if (now - lastValidated < CACHE_CHECK_INTERVAL_MS) continue;
923
+
924
+ result.checked++;
925
+ try {
926
+ const fresh = await describeFormFields(cfg, wfId);
927
+ if (fresh.verifyRight === false) {
928
+ result.details.push({ workflowId: wfId, name: cached.workflowName, status: 'no-right' });
929
+ continue;
930
+ }
931
+ // ★ 对比字段级签名(不只 totalFields):字段数不变但改 label/htmltype/isedit/isview/viewattr 也能检出。
932
+ // 老缓存无签名时用自身 tables 现算,保证旧文件也能被升级。
933
+ const freshSig = computeFormSignature(fresh.tables);
934
+ const cachedSig = cached.formSignature || computeFormSignature(cached.tables);
935
+ const changed = fresh.totalFields !== cached.totalFields
936
+ || (freshSig && cachedSig && freshSig !== cachedSig);
937
+ if (changed) {
938
+ mem.formCache[String(wfId)] = {
939
+ ...cached,
940
+ cachedAt: new Date().toISOString(),
941
+ lastValidatedAt: new Date().toISOString(),
942
+ tables: fresh.tables,
943
+ totalFields: fresh.totalFields,
944
+ verifyRight: fresh.verifyRight,
945
+ submitParams: fresh.submitParams,
946
+ params: fresh.params,
947
+ linkageCfg: fresh.raw?.linkageCfg,
948
+ formSignature: freshSig,
949
+ };
950
+ result.updated++;
951
+ result.details.push({ workflowId: wfId, name: cached.workflowName, status: 'updated',
952
+ oldCount: cached.totalFields, newCount: fresh.totalFields });
953
+ } else {
954
+ mem.formCache[String(wfId)].lastValidatedAt = new Date().toISOString();
955
+ mem.formCache[String(wfId)].formSignature = cachedSig; // 升级老缓存签名
956
+ result.details.push({ workflowId: wfId, name: cached.workflowName, status: 'ok' });
957
+ }
958
+ } catch (e) {
959
+ result.errors++;
960
+ result.details.push({ workflowId: wfId, name: cached?.workflowName || wfId, status: 'error', error: e.message });
961
+ }
962
+ }
963
+
964
+ mem.lastCacheCheck = new Date().toISOString();
965
+ writeMemory(cfg, mem);
966
+ } catch { /* 巡检失败不阻断任何业务 */ }
967
+ return result;
968
+ }