dsh-prime-memory 0.20.0-beta.9 → 0.20.2
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 +183 -0
- package/ENGINEERING-NOTES.md +17 -0
- package/dist/client.js +1017 -447
- package/dist/config.d.ts +0 -120
- package/dist/config.js +1 -21
- package/dist/contract.d.ts +28 -57
- package/dist/hooks/recall.d.ts +4 -1
- package/dist/hooks/recall.js +7 -1
- package/dist/hooks/slot-recall.js +24 -1
- package/dist/index.d.ts +0 -78
- package/dist/index.js +9 -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/projection/slots.d.ts +23 -1
- package/dist/projection/slots.js +65 -21
- package/dist/room-review.d.ts +8 -1
- package/dist/room-review.js +10 -4
- package/dist/stats.d.ts +15 -33
- package/dist/stats.js +72 -204
- package/dist/store/l1.d.ts +2 -0
- package/dist/store/l1.js +4 -0
- package/dist/store/recall-usage.d.ts +43 -0
- package/dist/store/recall-usage.js +149 -0
- package/dist/store/rooms-registry.d.ts +11 -4
- package/dist/store/rooms-registry.js +33 -16
- package/dist/store/session-modes.d.ts +0 -8
- package/dist/store/session-modes.js +0 -52
- package/dist/store/slots.d.ts +3 -1
- package/dist/store/slots.js +2 -1
- package/dist/tools/index.js +21 -0
- 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
|
@@ -26,9 +26,8 @@ import { resolveConflictPair, listConflictPairs } from './conflict-service.js';
|
|
|
26
26
|
import { sourceAnchorLabels } from './pipeline/anchors.js';
|
|
27
27
|
import { readSupersedeMarker } from './store/supersede.js';
|
|
28
28
|
import { isSnapshotName } from './store/l1-snapshot.js';
|
|
29
|
-
import { closeTodoRefSlot } from './hooks/long-task.js';
|
|
30
29
|
import { WING_CATALOG, WING_FALLBACK } from './types.js';
|
|
31
|
-
import {
|
|
30
|
+
import { isRoomSlug } from './metadata-validators.js';
|
|
32
31
|
import { InProcMemoryBackend } from './store/memory-backend.js';
|
|
33
32
|
import { isWingCorner } from './store/session-modes.js';
|
|
34
33
|
import { startWingBackfill } from './wing-backfill.js';
|
|
@@ -52,8 +51,6 @@ export const MEMORY_ENDPOINTS = [
|
|
|
52
51
|
'dsh-memory/token-cost',
|
|
53
52
|
'dsh-memory/session-mode-get',
|
|
54
53
|
'dsh-memory/session-mode-set',
|
|
55
|
-
'dsh-memory/longtask-hint-get',
|
|
56
|
-
'dsh-memory/longtask-compress-tail',
|
|
57
54
|
'dsh-memory/wing-overview',
|
|
58
55
|
'dsh-memory/rooms-get',
|
|
59
56
|
'dsh-memory/rooms-export',
|
|
@@ -160,51 +157,6 @@ function readJsonBody(req) {
|
|
|
160
157
|
req.on('error', reject);
|
|
161
158
|
});
|
|
162
159
|
}
|
|
163
|
-
/** 压缩后去抖 ruminate:上次触发时刻(模块级;跨轮询/按压共享)。 */
|
|
164
|
-
let lastCompressRuminateAt = 0;
|
|
165
|
-
const COMPRESS_RUMINATE_DEBOUNCE_MS = 10 * 60_000;
|
|
166
|
-
/**
|
|
167
|
-
* 尾部压缩闭环(T05):压缩入队成功后,冲刷+L2(带 repo 感知)+relabel+recluster
|
|
168
|
-
* 消费一条龙由 ruminate 轻刷新接管——去抖 10 分钟,失败静默(压缩本身已成功,
|
|
169
|
-
* 重算由下一次任何触发兜底)。
|
|
170
|
-
*/
|
|
171
|
-
function debouncedCompressRuminate(ruminate, logger) {
|
|
172
|
-
const now = Date.now();
|
|
173
|
-
if (!ruminate || now - lastCompressRuminateAt < COMPRESS_RUMINATE_DEBOUNCE_MS)
|
|
174
|
-
return;
|
|
175
|
-
lastCompressRuminateAt = now;
|
|
176
|
-
void ruminate.start().catch((err) => logger.warn(`[memory] 压缩后反刍触发失败(压缩产物保留,重算由下次触发兜底): ${err instanceof Error ? err.message : String(err)}`));
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* 尾部 turn 压缩:把 (水位线, maxCapturedTurn] 内最近的最近 N 轮强制蒸馏入记忆
|
|
180
|
-
* (force 跳过阈值;L1 去重与水位线双重防重)。off 档会话保持完全隐身(不压缩)。
|
|
181
|
-
* 无锚点老数据只在首次压缩(无水位线)时纳入——宁少收不重蒸。
|
|
182
|
-
*/
|
|
183
|
-
export async function compressTailTurns(sessionId, turns, io) {
|
|
184
|
-
const toTurn = io.l0.maxCapturedTurn(sessionId);
|
|
185
|
-
if (toTurn === undefined)
|
|
186
|
-
return { enqueued: 0, fromTurn: null, toTurn: null };
|
|
187
|
-
const watermark = io.modes.getTailWatermark(sessionId) ?? 0;
|
|
188
|
-
const fromTurn = Math.max(watermark + 1, toTurn - turns + 1);
|
|
189
|
-
if (fromTurn > toTurn)
|
|
190
|
-
return { enqueued: 0, fromTurn, toTurn };
|
|
191
|
-
const mode = io.modes.get(sessionId);
|
|
192
|
-
if (mode === 'off')
|
|
193
|
-
return { enqueued: 0, fromTurn, toTurn };
|
|
194
|
-
const messages = await io.l0.recentBySession(sessionId, turns * 12 + 24);
|
|
195
|
-
const picked = messages.filter((m) => {
|
|
196
|
-
const t = m.anchor?.turn;
|
|
197
|
-
if (t === undefined)
|
|
198
|
-
return watermark === 0;
|
|
199
|
-
return t >= fromTurn && t <= toTurn;
|
|
200
|
-
});
|
|
201
|
-
if (picked.length === 0)
|
|
202
|
-
return { enqueued: 0, fromTurn, toTurn };
|
|
203
|
-
io.runner.enqueue(sessionId, picked, mode, { force: true });
|
|
204
|
-
io.modes.setTailWatermark(sessionId, toTurn);
|
|
205
|
-
io.logger.info(`[memory] 尾部压缩:turn ${fromTurn}..${toTurn} 已强制入队蒸馏(${picked.length} 条)`);
|
|
206
|
-
return { enqueued: picked.length, fromTurn, toTurn };
|
|
207
|
-
}
|
|
208
160
|
/**
|
|
209
161
|
* 组装端点 deps。抽成函数是为了让"哪个控制器落入哪个字段"成为可测接缝:
|
|
210
162
|
* 反刍端点读 deps.ruminate,若此处漏注入,端点会静默恒返 {supported:false}(面板整块不渲染)。
|
|
@@ -220,7 +172,6 @@ export function buildEndpointDeps(base, sources, controller) {
|
|
|
220
172
|
embedManager: sources.embedManager,
|
|
221
173
|
sessionInfo: sources.sessionInfo,
|
|
222
174
|
ruminate: controller,
|
|
223
|
-
longTask: sources.longTask,
|
|
224
175
|
roomRegistry: sources.roomRegistry,
|
|
225
176
|
};
|
|
226
177
|
return { ...base, ...injected };
|
|
@@ -228,8 +179,6 @@ export function buildEndpointDeps(base, sources, controller) {
|
|
|
228
179
|
export function registerMemoryRpc(ctx, cfg, stores, logger, status, live, modes, dataDir, rebuild, embedManager, sessionInfo,
|
|
229
180
|
/** 反刍控制器(存储降级时为 undefined):经 buildEndpointDeps 落入 deps.ruminate。 */
|
|
230
181
|
ruminate,
|
|
231
|
-
/** 长任务端点依赖(cfg.longTask.enabled 门控;缺省 = 端点恒 disabled)。 */
|
|
232
|
-
longTaskDeps,
|
|
233
182
|
/** Room 分类管理依赖(注册表;缺省 = rooms-get 不带 registry、rooms-export 恒 404 语义)。 */
|
|
234
183
|
roomRegistry) {
|
|
235
184
|
/** 当前是否持有一段有效注册(dispose 完成后清空,允许服务重上线时重注册)。 */
|
|
@@ -250,9 +199,9 @@ roomRegistry) {
|
|
|
250
199
|
const rpcHandler = async (endpoint, payload) => {
|
|
251
200
|
try {
|
|
252
201
|
const value = await handleEndpoint(endpoint, payload, buildEndpointDeps({ ctx, cfg, stores, logger },
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
{ status, live, modes, dataDir, rebuild, embedManager, sessionInfo,
|
|
202
|
+
// roomRegistry 必须随 sources 下发——漏带 = 面板侧 room-* 恒"未装配"
|
|
203
|
+
// (工具面直传不受影响;2026-10-02 beta.9 实测回归)
|
|
204
|
+
{ status, live, modes, dataDir, rebuild, embedManager, sessionInfo, roomRegistry }, ruminate));
|
|
256
205
|
return { ok: true, value };
|
|
257
206
|
}
|
|
258
207
|
catch (err) {
|
|
@@ -400,7 +349,7 @@ function sanitizeSettings(s) {
|
|
|
400
349
|
}
|
|
401
350
|
/** 端点分发表(导出供测试直调:可精确注入 rebuild/ruminate 等可选控制器,验证 deps 接线)。 */
|
|
402
351
|
export async function handleEndpoint(endpoint, payload, deps) {
|
|
403
|
-
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;
|
|
404
353
|
switch (endpoint) {
|
|
405
354
|
case 'dsh-memory/stats':
|
|
406
355
|
return buildStats(cfg, stores, status);
|
|
@@ -434,7 +383,6 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
434
383
|
halls: locked,
|
|
435
384
|
hallIncludeUnlabeled: bounds.includeUnlabeled,
|
|
436
385
|
hallIncludeGeneral: bounds.includeGeneral,
|
|
437
|
-
longTask: modes.getLongTask(sessionId),
|
|
438
386
|
};
|
|
439
387
|
return v;
|
|
440
388
|
}
|
|
@@ -444,9 +392,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
444
392
|
const p = (payload ?? {});
|
|
445
393
|
const sessionId = expectSessionId(p.sessionId);
|
|
446
394
|
const allowed = ['auto', 'chat', 'work', 'off'];
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
throw new Error('session-mode-set 需要 mode 或 longTask 之一');
|
|
395
|
+
if (p.mode === undefined) {
|
|
396
|
+
throw new Error('session-mode-set 需要 mode');
|
|
450
397
|
}
|
|
451
398
|
if (p.mode !== undefined && (typeof p.mode !== 'string' || !allowed.includes(p.mode))) {
|
|
452
399
|
throw new Error(`非法档位: ${String(p.mode)}(允许 ${allowed.join('/')})`);
|
|
@@ -468,17 +415,6 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
468
415
|
throw new Error(`非法 Wing 锁定: ${String(bad)}(允许 ${WING_CATALOG.map((h) => h.id).join('/')}/null)`);
|
|
469
416
|
}
|
|
470
417
|
}
|
|
471
|
-
// 长任务开关可选同车:布尔 = 切换;缺省 = 不动。关闭时撤下 todo 参考槽位
|
|
472
|
-
// (todo 参考只在长任务模式可用;撤下失败不影响开关本身,log 即可)。
|
|
473
|
-
if (p.longTask !== undefined) {
|
|
474
|
-
if (typeof p.longTask !== 'boolean') {
|
|
475
|
-
throw new Error(`非法长任务开关: ${String(p.longTask)}(允许 true/false)`);
|
|
476
|
-
}
|
|
477
|
-
modes.setLongTask(sessionId, p.longTask);
|
|
478
|
-
if (!p.longTask && longTaskDeps?.slots) {
|
|
479
|
-
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)}`));
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
418
|
if (p.mode !== undefined)
|
|
483
419
|
modes.set(sessionId, p.mode);
|
|
484
420
|
if (typeof p.recall === 'boolean') {
|
|
@@ -518,129 +454,6 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
518
454
|
halls: locked,
|
|
519
455
|
hallIncludeUnlabeled: bounds.includeUnlabeled,
|
|
520
456
|
hallIncludeGeneral: bounds.includeGeneral,
|
|
521
|
-
longTask: modes.getLongTask(sessionId),
|
|
522
|
-
};
|
|
523
|
-
return v;
|
|
524
|
-
}
|
|
525
|
-
case 'dsh-memory/longtask-hint-get': {
|
|
526
|
-
if (!modes)
|
|
527
|
-
throw new Error('档位存储未初始化');
|
|
528
|
-
const p = (payload ?? {});
|
|
529
|
-
const sessionId = expectSessionId(p.sessionId);
|
|
530
|
-
const enabled = cfg.longTask.enabled;
|
|
531
|
-
const longTask = modes.getLongTask(sessionId);
|
|
532
|
-
let contextPct = null;
|
|
533
|
-
if (enabled && longTask) {
|
|
534
|
-
// 分母:主对话模型官方窗口(3s race 封顶,同 session-stats 口径)
|
|
535
|
-
let window = null;
|
|
536
|
-
try {
|
|
537
|
-
const sel = deps.ctx.get('agentDefaultModel')?.currentSelection?.();
|
|
538
|
-
if (sel?.provider && sel?.model) {
|
|
539
|
-
window = await Promise.race([
|
|
540
|
-
resolveModelContextWindow(deps.ctx, sel.provider, sel.model),
|
|
541
|
-
new Promise((resolve) => setTimeout(() => resolve(null), 3_000)),
|
|
542
|
-
]);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
catch {
|
|
546
|
-
/* 可选服务缺失 = 分母未知 */
|
|
547
|
-
}
|
|
548
|
-
contextPct = longTaskDeps
|
|
549
|
-
? longTaskDeps.contextUsage.percentOfWindow(sessionId, window ?? undefined)
|
|
550
|
-
: null;
|
|
551
|
-
}
|
|
552
|
-
const drift = enabled ? longTaskDeps?.todoRef.latest(sessionId)?.drift ?? null : null;
|
|
553
|
-
const todoCount = enabled ? longTaskDeps?.todoRef.latest(sessionId)?.items.length ?? 0 : 0;
|
|
554
|
-
const reasons = [];
|
|
555
|
-
if (!longTask) {
|
|
556
|
-
if (contextPct !== null && contextPct >= cfg.longTask.contextThresholdPct) {
|
|
557
|
-
reasons.push('context-threshold');
|
|
558
|
-
}
|
|
559
|
-
if (drift !== null && drift >= cfg.longTask.driftThreshold) {
|
|
560
|
-
reasons.push('todo-drift');
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
const watermark = modes.getTailWatermark(sessionId);
|
|
564
|
-
let pendingTailTurns = null;
|
|
565
|
-
if (enabled && longTask && longTaskDeps) {
|
|
566
|
-
const captured = stores.l0.maxCapturedTurn(sessionId);
|
|
567
|
-
pendingTailTurns = captured === undefined ? 0 : Math.max(0, captured - (watermark ?? 0));
|
|
568
|
-
}
|
|
569
|
-
// 自动尾部压缩(长任务开启 + 显式 opt-in + 跨阈值):水位线只前进,
|
|
570
|
-
// 轮询重复触发天然幂等;失败只 warn,不打断建议状态返回。
|
|
571
|
-
if (enabled &&
|
|
572
|
-
longTask &&
|
|
573
|
-
longTaskDeps &&
|
|
574
|
-
cfg.longTask.autoCompress &&
|
|
575
|
-
pendingTailTurns !== null &&
|
|
576
|
-
pendingTailTurns > 0 &&
|
|
577
|
-
contextPct !== null &&
|
|
578
|
-
contextPct >= cfg.longTask.contextThresholdPct) {
|
|
579
|
-
try {
|
|
580
|
-
const ar = await compressTailTurns(sessionId, cfg.longTask.tailTurns, {
|
|
581
|
-
modes,
|
|
582
|
-
l0: stores.l0,
|
|
583
|
-
runner: longTaskDeps.runner,
|
|
584
|
-
logger: deps.logger,
|
|
585
|
-
});
|
|
586
|
-
if (ar.enqueued > 0)
|
|
587
|
-
debouncedCompressRuminate(ruminate, deps.logger);
|
|
588
|
-
}
|
|
589
|
-
catch (err) {
|
|
590
|
-
deps.logger.warn(`[memory] 自动尾部压缩失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
591
|
-
}
|
|
592
|
-
const captured = stores.l0.maxCapturedTurn(sessionId);
|
|
593
|
-
pendingTailTurns = captured === undefined ? 0 : Math.max(0, captured - (modes.getTailWatermark(sessionId) ?? 0));
|
|
594
|
-
}
|
|
595
|
-
const v = {
|
|
596
|
-
sessionId,
|
|
597
|
-
enabled,
|
|
598
|
-
longTask,
|
|
599
|
-
contextPct,
|
|
600
|
-
contextThresholdPct: cfg.longTask.contextThresholdPct,
|
|
601
|
-
drift,
|
|
602
|
-
driftThreshold: cfg.longTask.driftThreshold,
|
|
603
|
-
todoCount,
|
|
604
|
-
suggest: reasons.length > 0,
|
|
605
|
-
reasons,
|
|
606
|
-
tailTurns: cfg.longTask.tailTurns,
|
|
607
|
-
lastTailCompressedTurn: watermark ?? null,
|
|
608
|
-
pendingTailTurns,
|
|
609
|
-
};
|
|
610
|
-
return v;
|
|
611
|
-
}
|
|
612
|
-
case 'dsh-memory/longtask-compress-tail': {
|
|
613
|
-
if (!modes)
|
|
614
|
-
throw new Error('档位存储未初始化');
|
|
615
|
-
const p = (payload ?? {});
|
|
616
|
-
const sessionId = expectSessionId(p.sessionId);
|
|
617
|
-
const longTask = modes.getLongTask(sessionId);
|
|
618
|
-
const turns = p.turns === undefined
|
|
619
|
-
? cfg.longTask.tailTurns
|
|
620
|
-
: Math.max(1, Math.min(50, Math.round(p.turns)));
|
|
621
|
-
let enqueued = 0;
|
|
622
|
-
let fromTurn = null;
|
|
623
|
-
let toTurn = null;
|
|
624
|
-
if (longTask && longTaskDeps) {
|
|
625
|
-
// 只在长任务模式可用(部署开关在 compressTailTurns 内再守一道)
|
|
626
|
-
const r = await compressTailTurns(sessionId, turns, {
|
|
627
|
-
modes,
|
|
628
|
-
l0: stores.l0,
|
|
629
|
-
runner: longTaskDeps.runner,
|
|
630
|
-
logger: deps.logger,
|
|
631
|
-
});
|
|
632
|
-
enqueued = r.enqueued;
|
|
633
|
-
fromTurn = r.fromTurn;
|
|
634
|
-
toTurn = r.toTurn;
|
|
635
|
-
if (enqueued > 0)
|
|
636
|
-
debouncedCompressRuminate(ruminate, deps.logger);
|
|
637
|
-
}
|
|
638
|
-
const v = {
|
|
639
|
-
sessionId,
|
|
640
|
-
enqueued,
|
|
641
|
-
fromTurn,
|
|
642
|
-
toTurn,
|
|
643
|
-
longTask,
|
|
644
457
|
};
|
|
645
458
|
return v;
|
|
646
459
|
}
|
|
@@ -658,16 +471,17 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
658
471
|
case 'dsh-memory/rooms-get': {
|
|
659
472
|
const rooms = stores.l1.listRooms();
|
|
660
473
|
// 端点层合并注册表(Agent A 设计):注册表 active 条目 count=0 补齐并带
|
|
661
|
-
// source/label
|
|
474
|
+
// source/label(source 用条目真实来源——收编 grown ≠ 预注册,⭐角标靠它区分);
|
|
475
|
+
// 自生长条目标 source='grown'。不动 listRooms() 的缓存语义。
|
|
662
476
|
const merged = rooms.map((r) => ({ ...r, source: 'grown' }));
|
|
663
477
|
const registry = roomRegistry?.listActive() ?? [];
|
|
664
478
|
for (const e of registry) {
|
|
665
479
|
const idx = merged.findIndex((r) => r.room === e.slug);
|
|
666
480
|
if (idx === -1) {
|
|
667
|
-
merged.push({ room: e.slug, count: 0, source:
|
|
481
|
+
merged.push({ room: e.slug, count: 0, source: e.source, label: e.label });
|
|
668
482
|
}
|
|
669
483
|
else {
|
|
670
|
-
merged[idx] = { ...merged[idx], source:
|
|
484
|
+
merged[idx] = { ...merged[idx], source: e.source, label: e.label ?? merged[idx].label };
|
|
671
485
|
}
|
|
672
486
|
}
|
|
673
487
|
const v = {
|
|
@@ -714,18 +528,53 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
714
528
|
if (!live?.get().memoryMutate)
|
|
715
529
|
throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后,才能执行 Room 管理。');
|
|
716
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
|
+
}
|
|
717
546
|
if (p.action === 'retire') {
|
|
718
547
|
const slug = String(p.slug ?? '').trim();
|
|
719
548
|
if (!slug)
|
|
720
549
|
throw new Error('retire 需要 slug');
|
|
721
550
|
const active = p.active === true;
|
|
551
|
+
// 自生长未注册 slug 退场:自动补一条 grown 注册条目再置 retired——
|
|
552
|
+
// 否则词表无物可变,面板按钮无法切换为「恢复」(用户实测反馈)
|
|
553
|
+
if (!active && p.withRecords === true && !roomRegistry.bySlug(slug)) {
|
|
554
|
+
await roomRegistry.register({ slug, source: 'grown' });
|
|
555
|
+
}
|
|
722
556
|
const changed = await roomRegistry.setStatus(slug, active ? 'active' : 'retired');
|
|
557
|
+
// 带记录退场(软删,可恢复):该 room 下全部**活跃**记录分批 retire(每批 ≤200,与 records-delete 同原语)
|
|
558
|
+
let retiredRecords = 0;
|
|
559
|
+
if (p.withRecords === true && !active) {
|
|
560
|
+
for (let offset = 0;; offset += 200) {
|
|
561
|
+
const page = stores.l1.list({ tag: slug, retired: false, limit: 200, offset });
|
|
562
|
+
if (page.items.length === 0)
|
|
563
|
+
break;
|
|
564
|
+
const n = stores.l1.retire(page.items.map((r) => r.id), { at: new Date().toISOString(), reason: 'manual' });
|
|
565
|
+
retiredRecords += n;
|
|
566
|
+
if (page.items.length < 200)
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
deps.logger.info(`[memory] Room 退役带记录:${slug},退场 ${retiredRecords} 条(软删,可恢复)`);
|
|
570
|
+
}
|
|
723
571
|
const v = {
|
|
572
|
+
retiredRecords: retiredRecords > 0 ? retiredRecords : undefined,
|
|
724
573
|
notice: changed
|
|
725
574
|
? active
|
|
726
575
|
? `已恢复:${slug}`
|
|
727
|
-
: `已退役:${slug}(存量记录的 tags 不动,仅词表不再推荐)。`
|
|
728
|
-
: `未找到注册条目或状态无变化:${slug}
|
|
576
|
+
: `已退役:${slug}(存量记录的 tags 不动,仅词表不再推荐)。${retiredRecords > 0 ? `已连同退场 ${retiredRecords} 条记录(软删,可在「已退场」区恢复)。` : ''}`
|
|
577
|
+
: `未找到注册条目或状态无变化:${slug}。${retiredRecords > 0 ? `已退场 ${retiredRecords} 条记录(软删,可恢复)。` : ''}`,
|
|
729
578
|
};
|
|
730
579
|
return v;
|
|
731
580
|
}
|
|
@@ -1139,8 +988,8 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1139
988
|
const retiredSel = typeof p.retired === 'boolean' ? p.retired : undefined;
|
|
1140
989
|
// Room 过滤:tag 是**自生长**的 slug(无枚举),只做形状与长度校验(SQL 侧参数化)
|
|
1141
990
|
const tagSel = typeof p.tag === 'string' ? p.tag.trim().slice(0, 64) : '';
|
|
1142
|
-
if (tagSel && !
|
|
1143
|
-
throw new Error('tag 非法(
|
|
991
|
+
if (tagSel && !isRoomSlug(tagSel))
|
|
992
|
+
throw new Error('tag 非法(两级制:major 或 major/minor,各段小写字母数字连字符)');
|
|
1144
993
|
// 孤儿记忆筛选(与 tag 互斥,tag 优先):只认显式布尔,其余视为缺省。
|
|
1145
994
|
const untaggedSel = p.untagged === true && !tagSel;
|
|
1146
995
|
// R13 多值归一: halls 数组只留非空字符串(≤40 字符),去重,上限 8(角数);
|
|
@@ -1193,13 +1042,19 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1193
1042
|
return true;
|
|
1194
1043
|
});
|
|
1195
1044
|
}
|
|
1045
|
+
const pageIds = filtered.slice(offset, offset + limit).map((h) => h.id);
|
|
1046
|
+
const updatedById = new Map(stores.l1.getByIds(pageIds).map((r) => [r.id, r.updatedAt]));
|
|
1196
1047
|
const resp = {
|
|
1197
|
-
items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) }
|
|
1048
|
+
items: filtered.slice(offset, offset + limit).map((h) => hitToUiRecord({ ...h, metadata: metaById?.get(h.id) }, {
|
|
1049
|
+
agingWeight: agingWeightOf(updatedById.get(h.id), stores.l1.decayHalfLife),
|
|
1050
|
+
usage: stores.usage?.get(h.id) ?? null,
|
|
1051
|
+
})),
|
|
1198
1052
|
hasMore: filtered.length > offset + limit,
|
|
1199
1053
|
total: null,
|
|
1200
1054
|
truncated: wanted > SEARCH_CAP,
|
|
1201
1055
|
scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
|
|
1202
1056
|
wingCatalog,
|
|
1057
|
+
usageSummary: stores.usage?.summary() ?? null,
|
|
1203
1058
|
};
|
|
1204
1059
|
return resp;
|
|
1205
1060
|
}
|
|
@@ -1207,12 +1062,16 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1207
1062
|
// 接了只会让「仅退场+关键词」永远空结果,不如如实不筛。
|
|
1208
1063
|
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 });
|
|
1209
1064
|
const resp = {
|
|
1210
|
-
items: items.map(hitToUiRecord
|
|
1065
|
+
items: items.map((r) => hitToUiRecord(r, {
|
|
1066
|
+
agingWeight: agingWeightOf(r.updatedAt, stores.l1.decayHalfLife),
|
|
1067
|
+
usage: stores.usage?.get(r.id) ?? null,
|
|
1068
|
+
})),
|
|
1211
1069
|
hasMore: offset + items.length < total,
|
|
1212
1070
|
total,
|
|
1213
1071
|
truncated: false,
|
|
1214
1072
|
scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
|
|
1215
1073
|
wingCatalog,
|
|
1074
|
+
usageSummary: stores.usage?.summary() ?? null,
|
|
1216
1075
|
};
|
|
1217
1076
|
return resp;
|
|
1218
1077
|
}
|
|
@@ -1819,7 +1678,14 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1819
1678
|
}
|
|
1820
1679
|
}
|
|
1821
1680
|
/** 浏览器卡片字段(比 MemoryRecord 精简,去掉大 metadata;Hall 从 metadata 提取)。 */
|
|
1822
|
-
|
|
1681
|
+
/** 老化权重(与读路径 applyDecayWeight 同式):max(0.5, 0.5^(Δ天/半衰期));半衰期 ≤0 = 关 → null。 */
|
|
1682
|
+
export function agingWeightOf(updatedAtMs, halfLifeDays, now = Date.now()) {
|
|
1683
|
+
if (!(halfLifeDays > 0))
|
|
1684
|
+
return null;
|
|
1685
|
+
const ageDays = Math.max(0, (now - (updatedAtMs ?? 0)) / 86_400_000);
|
|
1686
|
+
return Math.max(0.5, Math.pow(0.5, ageDays / halfLifeDays));
|
|
1687
|
+
}
|
|
1688
|
+
export function hitToUiRecord(r, extra) {
|
|
1823
1689
|
const retired = r.validTo !== undefined;
|
|
1824
1690
|
const mark = retired ? readSupersedeMarker(r.metadata) : undefined;
|
|
1825
1691
|
return {
|
|
@@ -1845,6 +1711,8 @@ export function hitToUiRecord(r) {
|
|
|
1845
1711
|
// 但必须给出可见差异。
|
|
1846
1712
|
retired,
|
|
1847
1713
|
retiredReason: retired ? (mark?.reason ?? 'unknown') : null,
|
|
1714
|
+
agingWeight: extra?.agingWeight ?? null,
|
|
1715
|
+
usage: extra?.usage ?? null,
|
|
1848
1716
|
};
|
|
1849
1717
|
}
|
|
1850
1718
|
/**
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -70,6 +70,8 @@ export declare class L1Store {
|
|
|
70
70
|
decayHalfLifeDays?: number,
|
|
71
71
|
/** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
|
|
72
72
|
graphLane?: GraphLaneProvider);
|
|
73
|
+
/** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
|
|
74
|
+
get decayHalfLife(): number;
|
|
73
75
|
init(): Promise<void>;
|
|
74
76
|
/** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
|
|
75
77
|
private importLegacy;
|
package/dist/store/l1.js
CHANGED
|
@@ -56,6 +56,10 @@ export class L1Store {
|
|
|
56
56
|
this.decayHalfLifeDays = decayHalfLifeDays ?? 30;
|
|
57
57
|
this.graphLaneProvider = graphLane;
|
|
58
58
|
}
|
|
59
|
+
/** 读路径时效半衰期(天;0=关)。老化权重展示与召回衰减同一事实源。 */
|
|
60
|
+
get decayHalfLife() {
|
|
61
|
+
return this.decayHalfLifeDays;
|
|
62
|
+
}
|
|
59
63
|
async init() {
|
|
60
64
|
await ensureDir(this.recordsDir);
|
|
61
65
|
await this.importLegacy();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { MemoryLogger } from '../types.js';
|
|
2
|
+
export declare const RECALL_USAGE_FILE_VERSION: 1;
|
|
3
|
+
export interface RecallUsageEntry {
|
|
4
|
+
attempts: number;
|
|
5
|
+
used: number;
|
|
6
|
+
lastAttemptAt: number | null;
|
|
7
|
+
lastUsedAt: number | null;
|
|
8
|
+
}
|
|
9
|
+
export interface RecallUsageSummary {
|
|
10
|
+
/** 有统计的记忆条数。 */
|
|
11
|
+
tracked: number;
|
|
12
|
+
/** 尝试总次数(Σ attempts)。 */
|
|
13
|
+
attempted: number;
|
|
14
|
+
/** 使用总次数(Σ used)。 */
|
|
15
|
+
used: number;
|
|
16
|
+
/** 使用率(百分比整数;attempted=0 时 0)。 */
|
|
17
|
+
rate: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class RecallUsageStore {
|
|
20
|
+
private readonly logger?;
|
|
21
|
+
private readonly file;
|
|
22
|
+
private entries;
|
|
23
|
+
private loaded;
|
|
24
|
+
private degraded;
|
|
25
|
+
private degradedLogged;
|
|
26
|
+
private writeTimer;
|
|
27
|
+
private writeChain;
|
|
28
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
29
|
+
init(): Promise<void>;
|
|
30
|
+
private ensureLoaded;
|
|
31
|
+
/** 一轮召回:候选集 attempts++/lastAttemptAt=now;used 子集额外 used++/lastUsedAt=now
|
|
32
|
+
* (used 不在候选内 = 防御路径,同样算一次尝试——用了就是被尝试过)。 */
|
|
33
|
+
recordAttempt(candidateIds: readonly string[], usedIds: readonly string[]): void;
|
|
34
|
+
get(id: string): RecallUsageEntry | undefined;
|
|
35
|
+
summary(): RecallUsageSummary;
|
|
36
|
+
/** 立即落盘(去抖窗口内的兜底;测试/停机用)。 */
|
|
37
|
+
flush(): Promise<void>;
|
|
38
|
+
/** 同步等待在途写(测试用;无在途写时立即返回)。 */
|
|
39
|
+
flushSync(): void;
|
|
40
|
+
private prune;
|
|
41
|
+
private schedulePersist;
|
|
42
|
+
private persist;
|
|
43
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 召回使用统计 sidecar(老化权重效果统计的数据面)。
|
|
3
|
+
*
|
|
4
|
+
* 口径(用户裁定):**进 topN(被注入上下文)才算被使用**;参与尝试未进 = 只有
|
|
5
|
+
* attempts。每轮召回 recordAttempt(候选集, 注入集)——内存即时、去抖原子持久化
|
|
6
|
+
* (召回是热路径,统计只碰内存 Map)。
|
|
7
|
+
*
|
|
8
|
+
* 存储:`recall-usage.json` slots 式 sidecar,与 rooms-registry/session-modes 同族
|
|
9
|
+
* 纪律:缺失 = 合法空表;损坏/不可读 → 只读降级(内存态照常,停止回写),绝不阻塞
|
|
10
|
+
* 召回读路径(R2 红线)。
|
|
11
|
+
*/
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { readJsonStrict, atomicWriteText, ensureDir } from '../util/io.js';
|
|
14
|
+
export const RECALL_USAGE_FILE_VERSION = 1;
|
|
15
|
+
/** 条目上限(防已退场/被取代记忆的 id 无限累积):超限按 lastAttemptAt 淘汰最旧。 */
|
|
16
|
+
const MAX_ENTRIES = 50_000;
|
|
17
|
+
/** 去抖持久化间隔(召回热路径;窗口内断电最多丢最近统计,不影响记忆本体)。 */
|
|
18
|
+
const PERSIST_DEBOUNCE_MS = 5_000;
|
|
19
|
+
export class RecallUsageStore {
|
|
20
|
+
logger;
|
|
21
|
+
file;
|
|
22
|
+
entries = new Map();
|
|
23
|
+
loaded = false;
|
|
24
|
+
degraded;
|
|
25
|
+
degradedLogged = false;
|
|
26
|
+
writeTimer;
|
|
27
|
+
writeChain = Promise.resolve();
|
|
28
|
+
constructor(dataDir, logger) {
|
|
29
|
+
this.logger = logger;
|
|
30
|
+
this.file = path.join(dataDir, 'recall-usage.json');
|
|
31
|
+
}
|
|
32
|
+
async init() {
|
|
33
|
+
await ensureDir(path.dirname(this.file));
|
|
34
|
+
const read = await readJsonStrict(this.file);
|
|
35
|
+
if (!read.ok) {
|
|
36
|
+
if (read.reason !== 'missing') {
|
|
37
|
+
this.degraded = `${read.reason}${'detail' in read && read.detail ? `: ${read.detail}` : ''}`;
|
|
38
|
+
this.logger?.warn(`[memory] 召回使用统计只读降级: ${this.degraded}`);
|
|
39
|
+
}
|
|
40
|
+
this.loaded = true;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const raw = read.value.entries ?? {};
|
|
44
|
+
for (const [id, e] of Object.entries(raw)) {
|
|
45
|
+
if (!e || typeof e.attempts !== 'number')
|
|
46
|
+
continue;
|
|
47
|
+
this.entries.set(id, {
|
|
48
|
+
attempts: e.attempts,
|
|
49
|
+
used: typeof e.used === 'number' ? e.used : 0,
|
|
50
|
+
lastAttemptAt: typeof e.lastAttemptAt === 'number' ? e.lastAttemptAt : null,
|
|
51
|
+
lastUsedAt: typeof e.lastUsedAt === 'number' ? e.lastUsedAt : null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
this.loaded = true;
|
|
55
|
+
}
|
|
56
|
+
ensureLoaded() {
|
|
57
|
+
if (!this.loaded)
|
|
58
|
+
throw new Error('召回使用统计未初始化(先 await init())');
|
|
59
|
+
}
|
|
60
|
+
/** 一轮召回:候选集 attempts++/lastAttemptAt=now;used 子集额外 used++/lastUsedAt=now
|
|
61
|
+
* (used 不在候选内 = 防御路径,同样算一次尝试——用了就是被尝试过)。 */
|
|
62
|
+
recordAttempt(candidateIds, usedIds) {
|
|
63
|
+
this.ensureLoaded();
|
|
64
|
+
if (candidateIds.length === 0 && usedIds.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const cand = new Set(candidateIds);
|
|
68
|
+
for (const id of cand) {
|
|
69
|
+
const e = this.entries.get(id) ?? { attempts: 0, used: 0, lastAttemptAt: null, lastUsedAt: null };
|
|
70
|
+
e.attempts += 1;
|
|
71
|
+
e.lastAttemptAt = now;
|
|
72
|
+
this.entries.set(id, e);
|
|
73
|
+
}
|
|
74
|
+
for (const id of usedIds) {
|
|
75
|
+
const e = this.entries.get(id) ?? { attempts: 0, used: 0, lastAttemptAt: null, lastUsedAt: null };
|
|
76
|
+
e.used += 1;
|
|
77
|
+
if (!cand.has(id)) {
|
|
78
|
+
e.attempts += 1;
|
|
79
|
+
e.lastAttemptAt = now;
|
|
80
|
+
}
|
|
81
|
+
e.lastUsedAt = now;
|
|
82
|
+
this.entries.set(id, e);
|
|
83
|
+
}
|
|
84
|
+
this.prune();
|
|
85
|
+
this.schedulePersist();
|
|
86
|
+
}
|
|
87
|
+
get(id) {
|
|
88
|
+
this.ensureLoaded();
|
|
89
|
+
const e = this.entries.get(id);
|
|
90
|
+
return e ? { ...e } : undefined;
|
|
91
|
+
}
|
|
92
|
+
summary() {
|
|
93
|
+
this.ensureLoaded();
|
|
94
|
+
let attempted = 0;
|
|
95
|
+
let used = 0;
|
|
96
|
+
for (const e of this.entries.values()) {
|
|
97
|
+
attempted += e.attempts;
|
|
98
|
+
used += e.used;
|
|
99
|
+
}
|
|
100
|
+
return { tracked: this.entries.size, attempted, used, rate: attempted > 0 ? Math.round((used / attempted) * 100) : 0 };
|
|
101
|
+
}
|
|
102
|
+
/** 立即落盘(去抖窗口内的兜底;测试/停机用)。 */
|
|
103
|
+
flush() {
|
|
104
|
+
if (this.writeTimer) {
|
|
105
|
+
clearTimeout(this.writeTimer);
|
|
106
|
+
this.writeTimer = undefined;
|
|
107
|
+
}
|
|
108
|
+
return this.persist();
|
|
109
|
+
}
|
|
110
|
+
/** 同步等待在途写(测试用;无在途写时立即返回)。 */
|
|
111
|
+
flushSync() {
|
|
112
|
+
void this.flush();
|
|
113
|
+
}
|
|
114
|
+
prune() {
|
|
115
|
+
if (this.entries.size <= MAX_ENTRIES)
|
|
116
|
+
return;
|
|
117
|
+
const byOldest = [...this.entries.entries()].sort((a, b) => (a[1].lastAttemptAt ?? 0) - (b[1].lastAttemptAt ?? 0));
|
|
118
|
+
for (const [id] of byOldest.slice(0, this.entries.size - MAX_ENTRIES))
|
|
119
|
+
this.entries.delete(id);
|
|
120
|
+
}
|
|
121
|
+
schedulePersist() {
|
|
122
|
+
if (this.degraded || this.writeTimer)
|
|
123
|
+
return;
|
|
124
|
+
this.writeTimer = setTimeout(() => {
|
|
125
|
+
this.writeTimer = undefined;
|
|
126
|
+
void this.persist();
|
|
127
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
128
|
+
this.writeTimer.unref?.(); // 不为统计写hold进程(停机即丢窗口内统计,可接受)
|
|
129
|
+
}
|
|
130
|
+
async persist() {
|
|
131
|
+
if (this.degraded)
|
|
132
|
+
return;
|
|
133
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
134
|
+
try {
|
|
135
|
+
await ensureDir(path.dirname(this.file));
|
|
136
|
+
const data = { version: RECALL_USAGE_FILE_VERSION, entries: Object.fromEntries(this.entries) };
|
|
137
|
+
await atomicWriteText(this.file, JSON.stringify(data));
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
this.degraded = err instanceof Error ? err.message : String(err);
|
|
141
|
+
if (!this.degradedLogged) {
|
|
142
|
+
this.degradedLogged = true;
|
|
143
|
+
this.logger?.warn(`[memory] 召回使用统计写入失败,只读降级: ${this.degraded}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
await this.writeChain;
|
|
148
|
+
}
|
|
149
|
+
}
|