dsh-prime-memory 0.20.0-beta.2 → 0.20.0-beta.21
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/CHANGELOG.md +192 -3
- package/ENGINEERING-NOTES.md +17 -0
- package/dist/client.js +1158 -405
- package/dist/config.d.ts +0 -120
- package/dist/config.js +1 -21
- package/dist/contract.d.ts +111 -56
- package/dist/hooks/recall.d.ts +4 -1
- package/dist/hooks/recall.js +7 -1
- package/dist/index.d.ts +0 -78
- package/dist/index.js +14 -21
- package/dist/metadata-validators.d.ts +5 -0
- package/dist/metadata-validators.js +11 -1
- package/dist/pipeline/recluster.js +1 -1
- package/dist/pipeline/ruminate.d.ts +11 -1
- package/dist/pipeline/ruminate.js +18 -1
- package/dist/room-review.d.ts +196 -0
- package/dist/room-review.js +281 -0
- package/dist/stats.d.ts +23 -34
- package/dist/stats.js +268 -201
- package/dist/store/l1.d.ts +34 -0
- package/dist/store/l1.js +68 -1
- package/dist/store/recall-usage.d.ts +43 -0
- package/dist/store/recall-usage.js +149 -0
- package/dist/store/rooms-registry.d.ts +66 -0
- package/dist/store/rooms-registry.js +250 -0
- package/dist/store/session-modes.d.ts +0 -8
- package/dist/store/session-modes.js +0 -52
- package/dist/store/sqlite.d.ts +4 -0
- package/dist/store/sqlite.js +20 -0
- package/dist/tools/index.d.ts +4 -1
- package/dist/tools/index.js +232 -1
- package/dist/types.d.ts +4 -0
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/dist/hooks/long-task.d.ts +0 -31
- package/dist/hooks/long-task.js +0 -93
- package/dist/store/context-usage.d.ts +0 -28
- package/dist/store/context-usage.js +0 -47
- package/dist/store/todo-ref.d.ts +0 -41
- package/dist/store/todo-ref.js +0 -65
package/dist/stats.js
CHANGED
|
@@ -18,6 +18,7 @@ import { EFFORT_CHOICES, resolveDataDir } from './config.js';
|
|
|
18
18
|
import { effectiveCfg } from './pipeline/runner.js';
|
|
19
19
|
import { emptyRecallStats } from './hooks/recall.js';
|
|
20
20
|
import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
|
|
21
|
+
import { mergeRoom, renameRoom } from './room-review.js';
|
|
21
22
|
import { projectDistillChain, validateDistillChain } from './settings.js';
|
|
22
23
|
import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from './store/receipts.js';
|
|
23
24
|
import { getTraceStore } from './store/trace.js';
|
|
@@ -25,9 +26,8 @@ import { resolveConflictPair, listConflictPairs } from './conflict-service.js';
|
|
|
25
26
|
import { sourceAnchorLabels } from './pipeline/anchors.js';
|
|
26
27
|
import { readSupersedeMarker } from './store/supersede.js';
|
|
27
28
|
import { isSnapshotName } from './store/l1-snapshot.js';
|
|
28
|
-
import { closeTodoRefSlot } from './hooks/long-task.js';
|
|
29
29
|
import { WING_CATALOG, WING_FALLBACK } from './types.js';
|
|
30
|
-
import {
|
|
30
|
+
import { isRoomSlug } from './metadata-validators.js';
|
|
31
31
|
import { InProcMemoryBackend } from './store/memory-backend.js';
|
|
32
32
|
import { isWingCorner } from './store/session-modes.js';
|
|
33
33
|
import { startWingBackfill } from './wing-backfill.js';
|
|
@@ -36,7 +36,7 @@ import { snapshotTokenCost } from './token-cost.js';
|
|
|
36
36
|
const require = createRequire(import.meta.url);
|
|
37
37
|
export const PLUGIN_VERSION = require('../package.json').version;
|
|
38
38
|
/**
|
|
39
|
-
* 端点全集运行时清单(
|
|
39
|
+
* 端点全集运行时清单(与 tests/contract-keys.test.ts 的 ENDPOINTS 及
|
|
40
40
|
* contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
|
|
41
41
|
* 注意:本清单同时是 HTTP 前缀路由 `/dsh-memory/rpc/<短名>` 的**放行白名单**
|
|
42
42
|
* (见下方 SHORT_ENDPOINTS),漏一条 = 该端点在面板里静默消失(404 被客户端
|
|
@@ -51,10 +51,11 @@ export const MEMORY_ENDPOINTS = [
|
|
|
51
51
|
'dsh-memory/token-cost',
|
|
52
52
|
'dsh-memory/session-mode-get',
|
|
53
53
|
'dsh-memory/session-mode-set',
|
|
54
|
-
'dsh-memory/longtask-hint-get',
|
|
55
|
-
'dsh-memory/longtask-compress-tail',
|
|
56
54
|
'dsh-memory/wing-overview',
|
|
57
55
|
'dsh-memory/rooms-get',
|
|
56
|
+
'dsh-memory/rooms-export',
|
|
57
|
+
'dsh-memory/room-register',
|
|
58
|
+
'dsh-memory/room-admin',
|
|
58
59
|
'dsh-memory/wing-backfill',
|
|
59
60
|
'dsh-memory/session-stats',
|
|
60
61
|
'dsh-memory/settings-get',
|
|
@@ -156,51 +157,6 @@ function readJsonBody(req) {
|
|
|
156
157
|
req.on('error', reject);
|
|
157
158
|
});
|
|
158
159
|
}
|
|
159
|
-
/** 压缩后去抖 ruminate:上次触发时刻(模块级;跨轮询/按压共享)。 */
|
|
160
|
-
let lastCompressRuminateAt = 0;
|
|
161
|
-
const COMPRESS_RUMINATE_DEBOUNCE_MS = 10 * 60_000;
|
|
162
|
-
/**
|
|
163
|
-
* 尾部压缩闭环(T05):压缩入队成功后,冲刷+L2(带 repo 感知)+relabel+recluster
|
|
164
|
-
* 消费一条龙由 ruminate 轻刷新接管——去抖 10 分钟,失败静默(压缩本身已成功,
|
|
165
|
-
* 重算由下一次任何触发兜底)。
|
|
166
|
-
*/
|
|
167
|
-
function debouncedCompressRuminate(ruminate, logger) {
|
|
168
|
-
const now = Date.now();
|
|
169
|
-
if (!ruminate || now - lastCompressRuminateAt < COMPRESS_RUMINATE_DEBOUNCE_MS)
|
|
170
|
-
return;
|
|
171
|
-
lastCompressRuminateAt = now;
|
|
172
|
-
void ruminate.start().catch((err) => logger.warn(`[memory] 压缩后反刍触发失败(压缩产物保留,重算由下次触发兜底): ${err instanceof Error ? err.message : String(err)}`));
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* 尾部 turn 压缩:把 (水位线, maxCapturedTurn] 内最近的最近 N 轮强制蒸馏入记忆
|
|
176
|
-
* (force 跳过阈值;L1 去重与水位线双重防重)。off 档会话保持完全隐身(不压缩)。
|
|
177
|
-
* 无锚点老数据只在首次压缩(无水位线)时纳入——宁少收不重蒸。
|
|
178
|
-
*/
|
|
179
|
-
export async function compressTailTurns(sessionId, turns, io) {
|
|
180
|
-
const toTurn = io.l0.maxCapturedTurn(sessionId);
|
|
181
|
-
if (toTurn === undefined)
|
|
182
|
-
return { enqueued: 0, fromTurn: null, toTurn: null };
|
|
183
|
-
const watermark = io.modes.getTailWatermark(sessionId) ?? 0;
|
|
184
|
-
const fromTurn = Math.max(watermark + 1, toTurn - turns + 1);
|
|
185
|
-
if (fromTurn > toTurn)
|
|
186
|
-
return { enqueued: 0, fromTurn, toTurn };
|
|
187
|
-
const mode = io.modes.get(sessionId);
|
|
188
|
-
if (mode === 'off')
|
|
189
|
-
return { enqueued: 0, fromTurn, toTurn };
|
|
190
|
-
const messages = await io.l0.recentBySession(sessionId, turns * 12 + 24);
|
|
191
|
-
const picked = messages.filter((m) => {
|
|
192
|
-
const t = m.anchor?.turn;
|
|
193
|
-
if (t === undefined)
|
|
194
|
-
return watermark === 0;
|
|
195
|
-
return t >= fromTurn && t <= toTurn;
|
|
196
|
-
});
|
|
197
|
-
if (picked.length === 0)
|
|
198
|
-
return { enqueued: 0, fromTurn, toTurn };
|
|
199
|
-
io.runner.enqueue(sessionId, picked, mode, { force: true });
|
|
200
|
-
io.modes.setTailWatermark(sessionId, toTurn);
|
|
201
|
-
io.logger.info(`[memory] 尾部压缩:turn ${fromTurn}..${toTurn} 已强制入队蒸馏(${picked.length} 条)`);
|
|
202
|
-
return { enqueued: picked.length, fromTurn, toTurn };
|
|
203
|
-
}
|
|
204
160
|
/**
|
|
205
161
|
* 组装端点 deps。抽成函数是为了让"哪个控制器落入哪个字段"成为可测接缝:
|
|
206
162
|
* 反刍端点读 deps.ruminate,若此处漏注入,端点会静默恒返 {supported:false}(面板整块不渲染)。
|
|
@@ -216,15 +172,15 @@ export function buildEndpointDeps(base, sources, controller) {
|
|
|
216
172
|
embedManager: sources.embedManager,
|
|
217
173
|
sessionInfo: sources.sessionInfo,
|
|
218
174
|
ruminate: controller,
|
|
219
|
-
|
|
175
|
+
roomRegistry: sources.roomRegistry,
|
|
220
176
|
};
|
|
221
177
|
return { ...base, ...injected };
|
|
222
178
|
}
|
|
223
179
|
export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir, rebuild, embedManager, sessionInfo,
|
|
224
180
|
/** 反刍控制器(存储降级时为 undefined):经 buildEndpointDeps 落入 deps.ruminate。 */
|
|
225
181
|
ruminate,
|
|
226
|
-
/**
|
|
227
|
-
|
|
182
|
+
/** Room 分类管理依赖(注册表;缺省 = rooms-get 不带 registry、rooms-export 恒 404 语义)。 */
|
|
183
|
+
roomRegistry) {
|
|
228
184
|
/** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
|
|
229
185
|
let holding = false;
|
|
230
186
|
/** 当前 handle 绑定的 connection 实例(internal/service 第二参;用于识别实例替换)。 */
|
|
@@ -242,7 +198,10 @@ longTaskDeps) {
|
|
|
242
198
|
// 端点处理器:HTTP 层与旧 connection.rpc 的 handler 共用同一分发。
|
|
243
199
|
const rpcHandler = async (endpoint, payload) => {
|
|
244
200
|
try {
|
|
245
|
-
const value = await handleEndpoint(endpoint, payload, buildEndpointDeps({ ctx, cfg, stores, logger },
|
|
201
|
+
const value = await handleEndpoint(endpoint, payload, buildEndpointDeps({ ctx, cfg, stores, logger },
|
|
202
|
+
// roomRegistry 必须随 sources 下发——漏带 = 面板侧 room-* 恒"未装配"
|
|
203
|
+
// (工具面直传不受影响;2026-10-02 beta.9 实测回归)
|
|
204
|
+
{ status, live, modes, dataDir, rebuild, embedManager, sessionInfo, roomRegistry }, ruminate));
|
|
246
205
|
return { ok: true, value };
|
|
247
206
|
}
|
|
248
207
|
catch (err) {
|
|
@@ -390,7 +349,7 @@ function sanitizeSettings(s) {
|
|
|
390
349
|
}
|
|
391
350
|
/** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
|
|
392
351
|
export async function handleEndpoint(endpoint, payload, deps) {
|
|
393
|
-
const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo,
|
|
352
|
+
const { cfg, stores, status, live, modes, dataDir, rebuild, ruminate, embedManager, sessionInfo, roomRegistry } = deps;
|
|
394
353
|
switch (endpoint) {
|
|
395
354
|
case 'dsh-memory/stats':
|
|
396
355
|
return buildStats(cfg, stores, status);
|
|
@@ -424,7 +383,6 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
424
383
|
halls: locked,
|
|
425
384
|
hallIncludeUnlabeled: bounds.includeUnlabeled,
|
|
426
385
|
hallIncludeGeneral: bounds.includeGeneral,
|
|
427
|
-
longTask: modes.getLongTask(sessionId),
|
|
428
386
|
};
|
|
429
387
|
return v;
|
|
430
388
|
}
|
|
@@ -434,9 +392,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
434
392
|
const p = (payload ?? {});
|
|
435
393
|
const sessionId = expectSessionId(p.sessionId);
|
|
436
394
|
const allowed = ['auto', 'chat', 'work', 'off'];
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
throw new Error('session-mode-set 需要 mode 或 longTask 之一');
|
|
395
|
+
if (p.mode === undefined) {
|
|
396
|
+
throw new Error('session-mode-set 需要 mode');
|
|
440
397
|
}
|
|
441
398
|
if (p.mode !== undefined && (typeof p.mode !== 'string' || !allowed.includes(p.mode))) {
|
|
442
399
|
throw new Error(`非法档位: ${String(p.mode)}(允许 ${allowed.join('/')})`);
|
|
@@ -458,17 +415,6 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
458
415
|
throw new Error(`非法 Wing 锁定: ${String(bad)}(允许 ${WING_CATALOG.map((h) => h.id).join('/')}/null)`);
|
|
459
416
|
}
|
|
460
417
|
}
|
|
461
|
-
// 长任务开关可选同车:布尔 = 切换;缺省 = 不动。关闭时撤下 todo 参考槽位
|
|
462
|
-
// (todo 参考只在长任务模式可用;撤下失败不影响开关本身,log 即可)。
|
|
463
|
-
if (p.longTask !== undefined) {
|
|
464
|
-
if (typeof p.longTask !== 'boolean') {
|
|
465
|
-
throw new Error(`非法长任务开关: ${String(p.longTask)}(允许 true/false)`);
|
|
466
|
-
}
|
|
467
|
-
modes.setLongTask(sessionId, p.longTask);
|
|
468
|
-
if (!p.longTask && longTaskDeps?.slots) {
|
|
469
|
-
closeTodoRefSlot({ cfg, modes, slots: longTaskDeps.slots, contextUsage: longTaskDeps.contextUsage, todoRef: longTaskDeps.todoRef, logger: deps.logger }).catch((err) => deps.logger.warn(`[memory] todo 参考槽位撤下失败: ${err instanceof Error ? err.message : String(err)}`));
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
418
|
if (p.mode !== undefined)
|
|
473
419
|
modes.set(sessionId, p.mode);
|
|
474
420
|
if (typeof p.recall === 'boolean') {
|
|
@@ -508,129 +454,6 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
508
454
|
halls: locked,
|
|
509
455
|
hallIncludeUnlabeled: bounds.includeUnlabeled,
|
|
510
456
|
hallIncludeGeneral: bounds.includeGeneral,
|
|
511
|
-
longTask: modes.getLongTask(sessionId),
|
|
512
|
-
};
|
|
513
|
-
return v;
|
|
514
|
-
}
|
|
515
|
-
case 'dsh-memory/longtask-hint-get': {
|
|
516
|
-
if (!modes)
|
|
517
|
-
throw new Error('档位存储未初始化');
|
|
518
|
-
const p = (payload ?? {});
|
|
519
|
-
const sessionId = expectSessionId(p.sessionId);
|
|
520
|
-
const enabled = cfg.longTask.enabled;
|
|
521
|
-
const longTask = modes.getLongTask(sessionId);
|
|
522
|
-
let contextPct = null;
|
|
523
|
-
if (enabled && longTask) {
|
|
524
|
-
// 分母:主对话模型官方窗口(3s race 封顶,同 session-stats 口径)
|
|
525
|
-
let window = null;
|
|
526
|
-
try {
|
|
527
|
-
const sel = deps.ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
528
|
-
if (sel?.provider && sel?.model) {
|
|
529
|
-
window = await Promise.race([
|
|
530
|
-
resolveModelContextWindow(deps.ctx, sel.provider, sel.model),
|
|
531
|
-
new Promise((resolve) => setTimeout(() => resolve(null), 3_000)),
|
|
532
|
-
]);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
catch {
|
|
536
|
-
/* 可选服务缺失 = 分母未知 */
|
|
537
|
-
}
|
|
538
|
-
contextPct = longTaskDeps
|
|
539
|
-
? longTaskDeps.contextUsage.percentOfWindow(sessionId, window ?? undefined)
|
|
540
|
-
: null;
|
|
541
|
-
}
|
|
542
|
-
const drift = enabled ? longTaskDeps?.todoRef.latest(sessionId)?.drift ?? null : null;
|
|
543
|
-
const todoCount = enabled ? longTaskDeps?.todoRef.latest(sessionId)?.items.length ?? 0 : 0;
|
|
544
|
-
const reasons = [];
|
|
545
|
-
if (!longTask) {
|
|
546
|
-
if (contextPct !== null && contextPct >= cfg.longTask.contextThresholdPct) {
|
|
547
|
-
reasons.push('context-threshold');
|
|
548
|
-
}
|
|
549
|
-
if (drift !== null && drift >= cfg.longTask.driftThreshold) {
|
|
550
|
-
reasons.push('todo-drift');
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
const watermark = modes.getTailWatermark(sessionId);
|
|
554
|
-
let pendingTailTurns = null;
|
|
555
|
-
if (enabled && longTask && longTaskDeps) {
|
|
556
|
-
const captured = stores.l0.maxCapturedTurn(sessionId);
|
|
557
|
-
pendingTailTurns = captured === undefined ? 0 : Math.max(0, captured - (watermark ?? 0));
|
|
558
|
-
}
|
|
559
|
-
// 自动尾部压缩(长任务开启 + 显式 opt-in + 跨阈值):水位线只前进,
|
|
560
|
-
// 轮询重复触发天然幂等;失败只 warn,不打断建议状态返回。
|
|
561
|
-
if (enabled &&
|
|
562
|
-
longTask &&
|
|
563
|
-
longTaskDeps &&
|
|
564
|
-
cfg.longTask.autoCompress &&
|
|
565
|
-
pendingTailTurns !== null &&
|
|
566
|
-
pendingTailTurns > 0 &&
|
|
567
|
-
contextPct !== null &&
|
|
568
|
-
contextPct >= cfg.longTask.contextThresholdPct) {
|
|
569
|
-
try {
|
|
570
|
-
const ar = await compressTailTurns(sessionId, cfg.longTask.tailTurns, {
|
|
571
|
-
modes,
|
|
572
|
-
l0: stores.l0,
|
|
573
|
-
runner: longTaskDeps.runner,
|
|
574
|
-
logger: deps.logger,
|
|
575
|
-
});
|
|
576
|
-
if (ar.enqueued > 0)
|
|
577
|
-
debouncedCompressRuminate(ruminate, deps.logger);
|
|
578
|
-
}
|
|
579
|
-
catch (err) {
|
|
580
|
-
deps.logger.warn(`[memory] 自动尾部压缩失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
581
|
-
}
|
|
582
|
-
const captured = stores.l0.maxCapturedTurn(sessionId);
|
|
583
|
-
pendingTailTurns = captured === undefined ? 0 : Math.max(0, captured - (modes.getTailWatermark(sessionId) ?? 0));
|
|
584
|
-
}
|
|
585
|
-
const v = {
|
|
586
|
-
sessionId,
|
|
587
|
-
enabled,
|
|
588
|
-
longTask,
|
|
589
|
-
contextPct,
|
|
590
|
-
contextThresholdPct: cfg.longTask.contextThresholdPct,
|
|
591
|
-
drift,
|
|
592
|
-
driftThreshold: cfg.longTask.driftThreshold,
|
|
593
|
-
todoCount,
|
|
594
|
-
suggest: reasons.length > 0,
|
|
595
|
-
reasons,
|
|
596
|
-
tailTurns: cfg.longTask.tailTurns,
|
|
597
|
-
lastTailCompressedTurn: watermark ?? null,
|
|
598
|
-
pendingTailTurns,
|
|
599
|
-
};
|
|
600
|
-
return v;
|
|
601
|
-
}
|
|
602
|
-
case 'dsh-memory/longtask-compress-tail': {
|
|
603
|
-
if (!modes)
|
|
604
|
-
throw new Error('档位存储未初始化');
|
|
605
|
-
const p = (payload ?? {});
|
|
606
|
-
const sessionId = expectSessionId(p.sessionId);
|
|
607
|
-
const longTask = modes.getLongTask(sessionId);
|
|
608
|
-
const turns = p.turns === undefined
|
|
609
|
-
? cfg.longTask.tailTurns
|
|
610
|
-
: Math.max(1, Math.min(50, Math.round(p.turns)));
|
|
611
|
-
let enqueued = 0;
|
|
612
|
-
let fromTurn = null;
|
|
613
|
-
let toTurn = null;
|
|
614
|
-
if (longTask && longTaskDeps) {
|
|
615
|
-
// 只在长任务模式可用(部署开关在 compressTailTurns 内再守一道)
|
|
616
|
-
const r = await compressTailTurns(sessionId, turns, {
|
|
617
|
-
modes,
|
|
618
|
-
l0: stores.l0,
|
|
619
|
-
runner: longTaskDeps.runner,
|
|
620
|
-
logger: deps.logger,
|
|
621
|
-
});
|
|
622
|
-
enqueued = r.enqueued;
|
|
623
|
-
fromTurn = r.fromTurn;
|
|
624
|
-
toTurn = r.toTurn;
|
|
625
|
-
if (enqueued > 0)
|
|
626
|
-
debouncedCompressRuminate(ruminate, deps.logger);
|
|
627
|
-
}
|
|
628
|
-
const v = {
|
|
629
|
-
sessionId,
|
|
630
|
-
enqueued,
|
|
631
|
-
fromTurn,
|
|
632
|
-
toTurn,
|
|
633
|
-
longTask,
|
|
634
457
|
};
|
|
635
458
|
return v;
|
|
636
459
|
}
|
|
@@ -647,10 +470,226 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
647
470
|
// ── Room 分类计数(标签自生长分类;展开 metadata.tags 聚合,零 schema) ──
|
|
648
471
|
case 'dsh-memory/rooms-get': {
|
|
649
472
|
const rooms = stores.l1.listRooms();
|
|
650
|
-
|
|
473
|
+
// 端点层合并注册表(Agent A 设计):注册表 active 条目 count=0 补齐并带
|
|
474
|
+
// source/label(source 用条目真实来源——收编 grown ≠ 预注册,⭐角标靠它区分);
|
|
475
|
+
// 自生长条目标 source='grown'。不动 listRooms() 的缓存语义。
|
|
476
|
+
const merged = rooms.map((r) => ({ ...r, source: 'grown' }));
|
|
477
|
+
const registry = roomRegistry?.listActive() ?? [];
|
|
478
|
+
for (const e of registry) {
|
|
479
|
+
const idx = merged.findIndex((r) => r.room === e.slug);
|
|
480
|
+
if (idx === -1) {
|
|
481
|
+
merged.push({ room: e.slug, count: 0, source: e.source, label: e.label });
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
merged[idx] = { ...merged[idx], source: e.source, label: e.label ?? merged[idx].label };
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const v = {
|
|
488
|
+
rooms: merged,
|
|
489
|
+
total: merged.length,
|
|
490
|
+
orphanCount: stores.l1.untaggedL1Count(),
|
|
491
|
+
registry: roomRegistry?.list().map((e) => ({
|
|
492
|
+
slug: e.slug,
|
|
493
|
+
label: e.label,
|
|
494
|
+
description: e.description,
|
|
495
|
+
source: e.source,
|
|
496
|
+
aliases: e.aliases,
|
|
497
|
+
status: e.status,
|
|
498
|
+
})),
|
|
499
|
+
};
|
|
500
|
+
return v;
|
|
501
|
+
}
|
|
502
|
+
case 'dsh-memory/room-register': {
|
|
503
|
+
if (!roomRegistry)
|
|
504
|
+
throw new Error('Room 注册表未装配(需要插件重载以初始化)。');
|
|
505
|
+
if (!live?.get().memoryMutate)
|
|
506
|
+
throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后,模型才能预注册 Room。');
|
|
507
|
+
const p = (payload ?? {});
|
|
508
|
+
const slug = String(p.slug ?? '').trim();
|
|
509
|
+
if (!slug)
|
|
510
|
+
throw new Error('slug 不能为空');
|
|
511
|
+
const r = await roomRegistry.register({
|
|
512
|
+
slug,
|
|
513
|
+
label: typeof p.label === 'string' ? p.label : undefined,
|
|
514
|
+
description: typeof p.description === 'string' ? p.description : undefined,
|
|
515
|
+
source: p.source === 'grown' ? 'grown' : undefined,
|
|
516
|
+
});
|
|
517
|
+
const v = {
|
|
518
|
+
slug: r.entry.slug,
|
|
519
|
+
created: r.created,
|
|
520
|
+
notice: r.created ? '已预注册。' : '已存在(只补全了缺失的 label/description)。',
|
|
521
|
+
};
|
|
522
|
+
return v;
|
|
523
|
+
}
|
|
524
|
+
// ── Room 破坏性面管理(merge/rename/retire;与 memory_room_admin 工具同编排) ──
|
|
525
|
+
case 'dsh-memory/room-admin': {
|
|
526
|
+
if (!roomRegistry)
|
|
527
|
+
throw new Error('Room 注册表未装配(需要插件重载以初始化)。');
|
|
528
|
+
if (!live?.get().memoryMutate)
|
|
529
|
+
throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后,才能执行 Room 管理。');
|
|
530
|
+
const p = (payload ?? {});
|
|
531
|
+
if (p.action === 'update') {
|
|
532
|
+
const slug = String(p.slug ?? '').trim();
|
|
533
|
+
if (!slug)
|
|
534
|
+
throw new Error('update 需要 slug');
|
|
535
|
+
const changed = await roomRegistry.update(slug, {
|
|
536
|
+
label: typeof p.label === 'string' ? p.label : undefined,
|
|
537
|
+
description: typeof p.description === 'string' ? p.description : undefined,
|
|
538
|
+
});
|
|
539
|
+
const v = {
|
|
540
|
+
notice: changed
|
|
541
|
+
? `已更新:${slug}(显示名/归类说明,标注器下次即用新词)`
|
|
542
|
+
: `无变化:${slug} 未找到或未提供任何非空字段`,
|
|
543
|
+
};
|
|
544
|
+
return v;
|
|
545
|
+
}
|
|
546
|
+
if (p.action === 'retire') {
|
|
547
|
+
const slug = String(p.slug ?? '').trim();
|
|
548
|
+
if (!slug)
|
|
549
|
+
throw new Error('retire 需要 slug');
|
|
550
|
+
const active = p.active === true;
|
|
551
|
+
const changed = await roomRegistry.setStatus(slug, active ? 'active' : 'retired');
|
|
552
|
+
// 带记录退场(软删,可恢复):该 room 下全部**活跃**记录分批 retire(每批 ≤200,与 records-delete 同原语)
|
|
553
|
+
let retiredRecords = 0;
|
|
554
|
+
if (p.withRecords === true && !active) {
|
|
555
|
+
for (let offset = 0;; offset += 200) {
|
|
556
|
+
const page = stores.l1.list({ tag: slug, retired: false, limit: 200, offset });
|
|
557
|
+
if (page.items.length === 0)
|
|
558
|
+
break;
|
|
559
|
+
const n = stores.l1.retire(page.items.map((r) => r.id), { at: new Date().toISOString(), reason: 'manual' });
|
|
560
|
+
retiredRecords += n;
|
|
561
|
+
if (page.items.length < 200)
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
deps.logger.info(`[memory] Room 退役带记录:${slug},退场 ${retiredRecords} 条(软删,可恢复)`);
|
|
565
|
+
}
|
|
566
|
+
const v = {
|
|
567
|
+
retiredRecords: retiredRecords > 0 ? retiredRecords : undefined,
|
|
568
|
+
notice: changed
|
|
569
|
+
? active
|
|
570
|
+
? `已恢复:${slug}`
|
|
571
|
+
: `已退役:${slug}(存量记录的 tags 不动,仅词表不再推荐)。${retiredRecords > 0 ? `已连同退场 ${retiredRecords} 条记录(软删,可在「已退场」区恢复)。` : ''}`
|
|
572
|
+
: `未找到注册条目或状态无变化:${slug}。${retiredRecords > 0 ? `已退场 ${retiredRecords} 条记录(软删,可恢复)。` : ''}`,
|
|
573
|
+
};
|
|
574
|
+
return v;
|
|
575
|
+
}
|
|
576
|
+
if (p.action === 'merge' || p.action === 'rename') {
|
|
577
|
+
const from = String(p.from ?? '').trim();
|
|
578
|
+
const to = String(p.to ?? '').trim();
|
|
579
|
+
if (!from || !to)
|
|
580
|
+
throw new Error('merge/rename 需要 from 与 to 两个 slug');
|
|
581
|
+
const dryRun = p.dryRun !== false;
|
|
582
|
+
const r = await (p.action === 'merge' ? mergeRoom : renameRoom)({ l1: stores.l1, registry: roomRegistry, logger: deps.logger }, from, to, { dryRun });
|
|
583
|
+
if (r.dryRun) {
|
|
584
|
+
const v = {
|
|
585
|
+
notice: `[dryRun 预览] ${from} → ${to}:影响 ${r.affected} 条${r.hasMore ? '(超过单次上限,需续跑)' : ''}。` +
|
|
586
|
+
`样例:${r.preview.join(', ') || '无'}。确认无误后以 dryRun=false 执行(执行前自动备份,完成后自动入队场景重算)。`,
|
|
587
|
+
dryRun: true,
|
|
588
|
+
affected: r.affected,
|
|
589
|
+
preview: r.preview,
|
|
590
|
+
hasMore: r.hasMore,
|
|
591
|
+
};
|
|
592
|
+
return v;
|
|
593
|
+
}
|
|
594
|
+
// 实跑:受影响场景入队重算(source='room-merge';与工具同口径)
|
|
595
|
+
let requeued = 0;
|
|
596
|
+
for (const family of r.families ?? []) {
|
|
597
|
+
for (const scene of r.scenes ?? []) {
|
|
598
|
+
stores.l1.enqueueSceneRecluster(family, [scene], `room-merge:${Date.now()}`, 'room-merge');
|
|
599
|
+
requeued++;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const v = {
|
|
603
|
+
notice: `${p.action === 'merge' ? '合并' : '改名'}完成:${from} → ${to},重写 ${r.applied} 条` +
|
|
604
|
+
`(备份:${r.backupFile ?? '无'})${requeued > 0 ? `;已入队 ${requeued} 个场景重算` : ''}。`,
|
|
605
|
+
dryRun: false,
|
|
606
|
+
affected: r.applied,
|
|
607
|
+
backupFile: r.backupFile,
|
|
608
|
+
};
|
|
609
|
+
return v;
|
|
610
|
+
}
|
|
611
|
+
throw new Error(`非法 action:${String(p.action)}(允许 merge/rename/retire)`);
|
|
612
|
+
}
|
|
613
|
+
case 'dsh-memory/rooms-export': {
|
|
614
|
+
const p = (payload ?? {});
|
|
615
|
+
const kind = p.kind === 'orphans' ? 'orphans' : p.kind === 'rooms' ? 'rooms' : p.kind === 'records' ? 'records' : '';
|
|
616
|
+
if (!kind)
|
|
617
|
+
throw new Error(`非法 kind: ${String(p.kind)}(允许 rooms/orphans/records)`);
|
|
618
|
+
const limit = Math.min(Math.max(Number(p.limit) || 2000, 1), 10_000);
|
|
619
|
+
const esc = (v) => {
|
|
620
|
+
const t = String(v ?? '');
|
|
621
|
+
return /[,\r\n"]/.test(t) ? '"' + t.replace(/"/g, '""') + '"' : t;
|
|
622
|
+
};
|
|
623
|
+
if (kind === 'rooms') {
|
|
624
|
+
const registry = roomRegistry?.list() ?? [];
|
|
625
|
+
const counts = new Map(stores.l1.listRooms().map((r) => [r.room, r.count]));
|
|
626
|
+
const lines = ['slug,count,source,label,description,status,aliases'];
|
|
627
|
+
let n = 0;
|
|
628
|
+
const seen = new Set();
|
|
629
|
+
for (const e of registry) {
|
|
630
|
+
lines.push([e.slug, String(counts.get(e.slug) ?? 0), e.source, e.label ?? '', e.description ?? '', e.status, (e.aliases ?? []).join('|')].map(esc).join(','));
|
|
631
|
+
seen.add(e.slug);
|
|
632
|
+
n++;
|
|
633
|
+
}
|
|
634
|
+
for (const r of stores.l1.listRooms()) {
|
|
635
|
+
if (seen.has(r.room))
|
|
636
|
+
continue;
|
|
637
|
+
lines.push([r.room, String(r.count), 'grown', '', '', 'active', ''].map(esc).join(','));
|
|
638
|
+
n++;
|
|
639
|
+
}
|
|
640
|
+
const v = { kind, csv: lines.join('\r\n'), total: n, truncated: false };
|
|
641
|
+
return v;
|
|
642
|
+
}
|
|
643
|
+
if (kind === 'records') {
|
|
644
|
+
const tag = String(p.tag ?? '').trim();
|
|
645
|
+
if (!tag)
|
|
646
|
+
throw new Error('kind=records 需要 tag(目标 Room slug)');
|
|
647
|
+
const header = 'id,type,scene_name,updated_at,tags,content';
|
|
648
|
+
const lines = [header];
|
|
649
|
+
let n = 0;
|
|
650
|
+
let truncated2 = false;
|
|
651
|
+
for (let offset = 0; offset < limit; offset += 200) {
|
|
652
|
+
const page = stores.l1.list({ tag, retired: undefined, limit: 200, offset });
|
|
653
|
+
for (const r of page.items) {
|
|
654
|
+
if (n >= limit) {
|
|
655
|
+
truncated2 = true;
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
const meta = (r.metadata ?? {});
|
|
659
|
+
const tags = Array.isArray(meta.tags) ? meta.tags.join('|') : '';
|
|
660
|
+
lines.push([r.id, r.type, r.scene_name, new Date(r.updatedAt).toISOString(), tags, r.content].map(esc).join(','));
|
|
661
|
+
n++;
|
|
662
|
+
}
|
|
663
|
+
if (truncated2 || page.items.length < 200)
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
const v = { kind, csv: lines.join('\r\n'), total: n, truncated: truncated2 };
|
|
667
|
+
return v;
|
|
668
|
+
}
|
|
669
|
+
// orphans:无 Room 绑定的活跃记录清单(id/type/scene/updated/content/候选/状态)
|
|
670
|
+
const header = 'id,type,scene_name,updated_at,content,room_candidates,room_review';
|
|
671
|
+
const lines = [header];
|
|
672
|
+
let n = 0;
|
|
673
|
+
let truncated = false;
|
|
674
|
+
for (let offset = 0; offset < limit; offset += 200) {
|
|
675
|
+
const page = stores.l1.list({ untagged: true, retired: false, limit: 200, offset });
|
|
676
|
+
for (const r of page.items) {
|
|
677
|
+
if (n >= limit) {
|
|
678
|
+
truncated = true;
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
const meta = (r.metadata ?? {});
|
|
682
|
+
lines.push([r.id, r.type, r.scene_name, new Date(r.updatedAt).toISOString(), r.content,
|
|
683
|
+
Array.isArray(meta.roomCandidates) ? meta.roomCandidates.join('|') : '',
|
|
684
|
+
String(meta.roomReview ?? '')].map(esc).join(','));
|
|
685
|
+
n++;
|
|
686
|
+
}
|
|
687
|
+
if (truncated || page.items.length < 200)
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
const v = { kind, csv: lines.join('\r\n'), total: n, truncated };
|
|
651
691
|
return v;
|
|
652
692
|
}
|
|
653
|
-
// ── 一键回填(task_15):后台任务,单飞;端点立即返回,进度看 wing-overview ──
|
|
654
693
|
case 'dsh-memory/wing-backfill': {
|
|
655
694
|
const r = startWingBackfill({
|
|
656
695
|
ctx: deps.ctx,
|
|
@@ -944,8 +983,10 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
944
983
|
const retiredSel = typeof p.retired === 'boolean' ? p.retired : undefined;
|
|
945
984
|
// Room 过滤:tag 是**自生长**的 slug(无枚举),只做形状与长度校验(SQL 侧参数化)
|
|
946
985
|
const tagSel = typeof p.tag === 'string' ? p.tag.trim().slice(0, 64) : '';
|
|
947
|
-
if (tagSel && !
|
|
948
|
-
throw new Error('tag 非法(
|
|
986
|
+
if (tagSel && !isRoomSlug(tagSel))
|
|
987
|
+
throw new Error('tag 非法(两级制:major 或 major/minor,各段小写字母数字连字符)');
|
|
988
|
+
// 孤儿记忆筛选(与 tag 互斥,tag 优先):只认显式布尔,其余视为缺省。
|
|
989
|
+
const untaggedSel = p.untagged === true && !tagSel;
|
|
949
990
|
// R13 多值归一: halls 数组只留非空字符串(≤40 字符),去重,上限 8(角数);
|
|
950
991
|
// wing 单值保留兼容,归一后与 halls 合并
|
|
951
992
|
const wingSel = Array.from(new Set([
|
|
@@ -986,29 +1027,46 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
986
1027
|
if (!Array.isArray(tags) || !tags.some((t) => t === tagSel))
|
|
987
1028
|
return false;
|
|
988
1029
|
}
|
|
1030
|
+
if (untaggedSel) {
|
|
1031
|
+
// 孤儿:tags 缺失/非数组/全部空串均算无 Room
|
|
1032
|
+
const tags = m?.tags;
|
|
1033
|
+
const hasRoom = Array.isArray(tags) && tags.some((t) => typeof t === 'string' && t !== '');
|
|
1034
|
+
if (hasRoom)
|
|
1035
|
+
return false;
|
|
1036
|
+
}
|
|
989
1037
|
return true;
|
|
990
1038
|
});
|
|
991
1039
|
}
|
|
1040
|
+
const pageIds = filtered.slice(offset, offset + limit).map((h) => h.id);
|
|
1041
|
+
const updatedById = new Map(stores.l1.getByIds(pageIds).map((r) => [r.id, r.updatedAt]));
|
|
992
1042
|
const resp = {
|
|
993
|
-
items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) }
|
|
1043
|
+
items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) }, {
|
|
1044
|
+
agingWeight: agingWeightOf(updatedById.get(h.id), stores.l1.decayHalfLife),
|
|
1045
|
+
usage: stores.usage?.get(h.id) ?? null,
|
|
1046
|
+
})),
|
|
994
1047
|
hasMore: filtered.length > offset + limit,
|
|
995
1048
|
total: null,
|
|
996
1049
|
truncated: wanted > SEARCH_CAP,
|
|
997
1050
|
scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
|
|
998
1051
|
wingCatalog,
|
|
1052
|
+
usageSummary: stores.usage?.summary() ?? null,
|
|
999
1053
|
};
|
|
1000
1054
|
return resp;
|
|
1001
1055
|
}
|
|
1002
1056
|
// 退场筛查仅浏览路径透传;检索路径不接——FTS/向量里本就没有已退场行,
|
|
1003
1057
|
// 接了只会让「仅退场+关键词」永远空结果,不如如实不筛。
|
|
1004
|
-
const { items, total } = stores.l1.list({ type: p.type || undefined, scene: p.scene || undefined, hall: p.hall || undefined, halls: wingSel.length > 0 ? wingSel : undefined, tag: tagSel || undefined, retired: retiredSel, limit, offset });
|
|
1058
|
+
const { items, total } = stores.l1.list({ type: p.type || undefined, scene: p.scene || undefined, hall: p.hall || undefined, halls: wingSel.length > 0 ? wingSel : undefined, tag: tagSel || undefined, untagged: untaggedSel || undefined, retired: retiredSel, limit, offset });
|
|
1005
1059
|
const resp = {
|
|
1006
|
-
items: items.map(hitToUiRecord
|
|
1060
|
+
items: items.map((r) => hitToUiRecord(r, {
|
|
1061
|
+
agingWeight: agingWeightOf(r.updatedAt, stores.l1.decayHalfLife),
|
|
1062
|
+
usage: stores.usage?.get(r.id) ?? null,
|
|
1063
|
+
})),
|
|
1007
1064
|
hasMore: offset + items.length < total,
|
|
1008
1065
|
total,
|
|
1009
1066
|
truncated: false,
|
|
1010
1067
|
scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
|
|
1011
1068
|
wingCatalog,
|
|
1069
|
+
usageSummary: stores.usage?.summary() ?? null,
|
|
1012
1070
|
};
|
|
1013
1071
|
return resp;
|
|
1014
1072
|
}
|
|
@@ -1615,7 +1673,14 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1615
1673
|
}
|
|
1616
1674
|
}
|
|
1617
1675
|
/** 浏览器卡片字段(比 MemoryRecord 精简,去掉大 metadata;Hall 从 metadata 提取)。 */
|
|
1618
|
-
|
|
1676
|
+
/** 老化权重(与读路径 applyDecayWeight 同式):max(0.5, 0.5^(Δ天/半衰期));半衰期 ≤0 = 关 → null。 */
|
|
1677
|
+
export function agingWeightOf(updatedAtMs, halfLifeDays, now = Date.now()) {
|
|
1678
|
+
if (!(halfLifeDays > 0))
|
|
1679
|
+
return null;
|
|
1680
|
+
const ageDays = Math.max(0, (now - (updatedAtMs ?? 0)) / 86_400_000);
|
|
1681
|
+
return Math.max(0.5, Math.pow(0.5, ageDays / halfLifeDays));
|
|
1682
|
+
}
|
|
1683
|
+
export function hitToUiRecord(r, extra) {
|
|
1619
1684
|
const retired = r.validTo !== undefined;
|
|
1620
1685
|
const mark = retired ? readSupersedeMarker(r.metadata) : undefined;
|
|
1621
1686
|
return {
|
|
@@ -1641,6 +1706,8 @@ export function hitToUiRecord(r) {
|
|
|
1641
1706
|
// 但必须给出可见差异。
|
|
1642
1707
|
retired,
|
|
1643
1708
|
retiredReason: retired ? (mark?.reason ?? 'unknown') : null,
|
|
1709
|
+
agingWeight: extra?.agingWeight ?? null,
|
|
1710
|
+
usage: extra?.usage ?? null,
|
|
1644
1711
|
};
|
|
1645
1712
|
}
|
|
1646
1713
|
/**
|