dsh-prime-memory 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +401 -0
- package/CHANGELOG.ja.md +401 -0
- package/CHANGELOG.ko.md +401 -0
- package/CHANGELOG.md +100 -0
- package/dist/client.js +1180 -845
- package/dist/config.d.ts +128 -1341
- package/dist/config.js +80 -4
- package/dist/conflict-service.d.ts +4 -0
- package/dist/conflict-service.js +6 -1
- package/dist/contract.d.ts +196 -0
- package/dist/domain-gate.d.ts +11 -0
- package/dist/domain-gate.js +15 -0
- package/dist/hooks/obsolete-notice.d.ts +26 -0
- package/dist/hooks/obsolete-notice.js +64 -0
- package/dist/hooks/recall.d.ts +39 -0
- package/dist/hooks/recall.js +110 -30
- package/dist/index.d.ts +1 -1127
- package/dist/index.js +30 -2
- package/dist/memory-types.d.ts +69 -0
- package/dist/memory-types.js +165 -0
- package/dist/memport/format.d.ts +6 -1
- package/dist/memport/format.js +42 -2
- package/dist/pipeline/l1.d.ts +44 -2
- package/dist/pipeline/l1.js +163 -9
- package/dist/pipeline/md-sync.d.ts +65 -0
- package/dist/pipeline/md-sync.js +456 -0
- package/dist/pipeline/ruminate.d.ts +43 -1
- package/dist/pipeline/ruminate.js +100 -0
- package/dist/pipeline/runner.d.ts +10 -0
- package/dist/pipeline/runner.js +7 -1
- package/dist/pipeline/workspace-relink.d.ts +60 -0
- package/dist/pipeline/workspace-relink.js +73 -0
- package/dist/projection/recall.d.ts +51 -13
- package/dist/projection/recall.js +47 -12
- package/dist/prompts/examples.d.ts +73 -0
- package/dist/prompts/examples.js +178 -0
- package/dist/prompts/l1-dedup.d.ts +9 -3
- package/dist/prompts/l1-dedup.js +34 -13
- package/dist/prompts/l1-extraction.d.ts +5 -3
- package/dist/prompts/l1-extraction.js +49 -4
- package/dist/settings.d.ts +8 -0
- package/dist/settings.js +12 -1
- package/dist/stats.d.ts +12 -0
- package/dist/stats.js +327 -15
- package/dist/store/embedding-source.d.ts +8 -0
- package/dist/store/embedding-source.js +39 -2
- package/dist/store/file-versions.d.ts +2 -0
- package/dist/store/file-versions.js +2 -0
- package/dist/store/graph-store.d.ts +14 -0
- package/dist/store/graph-store.js +35 -0
- package/dist/store/l1-snapshot.d.ts +26 -1
- package/dist/store/l1-snapshot.js +53 -8
- package/dist/store/l1.d.ts +75 -3
- package/dist/store/l1.js +124 -3
- package/dist/store/memory-md-archive.d.ts +84 -0
- package/dist/store/memory-md-archive.js +155 -0
- package/dist/store/memory-md-disable.d.ts +23 -0
- package/dist/store/memory-md-disable.js +106 -0
- package/dist/store/memory-md-export.d.ts +50 -0
- package/dist/store/memory-md-export.js +200 -0
- package/dist/store/memory-md-ledger.d.ts +94 -0
- package/dist/store/memory-md-ledger.js +243 -0
- package/dist/store/memory-md-orphan.d.ts +27 -0
- package/dist/store/memory-md-orphan.js +153 -0
- package/dist/store/memory-md-parse.d.ts +35 -0
- package/dist/store/memory-md-parse.js +85 -0
- package/dist/store/memory-md-skeleton.d.ts +18 -0
- package/dist/store/memory-md-skeleton.js +61 -0
- package/dist/store/memory-md-types.d.ts +174 -0
- package/dist/store/memory-md-types.js +10 -0
- package/dist/store/memory-md.d.ts +108 -0
- package/dist/store/memory-md.js +219 -0
- package/dist/store/obsolete-notices.d.ts +39 -0
- package/dist/store/obsolete-notices.js +153 -0
- package/dist/store/recall-feedback.d.ts +75 -0
- package/dist/store/recall-feedback.js +213 -0
- package/dist/store/recall-usage.d.ts +11 -0
- package/dist/store/recall-usage.js +29 -0
- package/dist/store/session-recalled.d.ts +38 -3
- package/dist/store/session-recalled.js +156 -9
- package/dist/store/sqlite.d.ts +66 -0
- package/dist/store/sqlite.js +233 -15
- package/dist/store/supersede.d.ts +1 -1
- package/dist/store/supersede.js +1 -1
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.js +23 -6
- package/dist/types.d.ts +24 -7
- package/dist/types.js +10 -2
- package/dist/util/maturity.d.ts +20 -3
- package/dist/util/maturity.js +18 -1
- package/dist/util/md-name.d.ts +129 -0
- package/dist/util/md-name.js +366 -0
- package/dist/util/md-normalize.d.ts +23 -0
- package/dist/util/md-normalize.js +131 -0
- package/dist/workspace-route.d.ts +22 -0
- package/dist/workspace-route.js +66 -0
- package/dsh.plugin.json +1 -1
- package/package.json +6 -5
package/dist/stats.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createRequire } from 'node:module';
|
|
|
15
15
|
import { closeSync, openSync, readSync, statSync } from 'node:fs';
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import { EFFORT_CHOICES, resolveDataDir } from './config.js';
|
|
18
|
+
import { typeCatalogEntries } from './memory-types.js';
|
|
18
19
|
import { effectiveCfg } from './pipeline/runner.js';
|
|
19
20
|
import { emptyRecallStats } from './hooks/recall.js';
|
|
20
21
|
import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
|
|
@@ -35,6 +36,15 @@ import { startWingBackfill } from './wing-backfill.js';
|
|
|
35
36
|
import { errDetail } from './util/filelog.js';
|
|
36
37
|
import { snapshotTokenCost } from './token-cost.js';
|
|
37
38
|
import { computeMaturity } from './util/maturity.js';
|
|
39
|
+
import { isFeedbackScore } from './store/recall-feedback.js';
|
|
40
|
+
import { relinkWorkspaces } from './pipeline/workspace-relink.js';
|
|
41
|
+
import { exportRecords, reconcileLedger } from './store/memory-md-export.js';
|
|
42
|
+
import { disableProjectionFile } from './store/memory-md-disable.js';
|
|
43
|
+
import { MEMORY_TYPE_DIRS, UNSORTED_DIR, resolveMemoriesRoot, targetFor } from './store/memory-md.js';
|
|
44
|
+
import { ensureTypeSkeleton } from './store/memory-md-skeleton.js';
|
|
45
|
+
import { sweepOrphanFiles } from './store/memory-md-orphan.js';
|
|
46
|
+
import { collectProjectionFiles } from './store/memory-md-archive.js';
|
|
47
|
+
import { syncFromFiles } from './pipeline/md-sync.js';
|
|
38
48
|
const require = createRequire(import.meta.url);
|
|
39
49
|
export const PLUGIN_VERSION = require('../package.json').version;
|
|
40
50
|
/**
|
|
@@ -101,6 +111,12 @@ export const MEMORY_ENDPOINTS = [
|
|
|
101
111
|
'dsh-memory/memport-scan',
|
|
102
112
|
'dsh-memory/skills-list',
|
|
103
113
|
'dsh-memory/session-recalled',
|
|
114
|
+
'dsh-memory/recall-feedback',
|
|
115
|
+
'dsh-memory/workspace-relink',
|
|
116
|
+
'dsh-memory/mem-export',
|
|
117
|
+
'dsh-memory/mem-reconcile',
|
|
118
|
+
'dsh-memory/mem-sync',
|
|
119
|
+
'dsh-memory/mem-orphans',
|
|
104
120
|
'dsh-memory/skills-action',
|
|
105
121
|
];
|
|
106
122
|
/** HTTP 路由前缀(客户端 fetch `/dsh-memory/rpc/<短方法名>`)。 */
|
|
@@ -138,6 +154,23 @@ function writeJson(res, status, body) {
|
|
|
138
154
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
139
155
|
res.end(JSON.stringify(body));
|
|
140
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* 面板检索软超时(用户裁决:删除后检索面短暂变慢不得挂死面板):竞速到期即
|
|
159
|
+
* 返回业务错误(客户端显示可重试的提示,而不是永远「加载中」)。底层检索
|
|
160
|
+
* 稍后自然完成时其结果被 race 吸收,不产生未处理拒绝。
|
|
161
|
+
*/
|
|
162
|
+
function raceTimeout(p, ms, message) {
|
|
163
|
+
let timer;
|
|
164
|
+
const timeout = new Promise((_, reject) => {
|
|
165
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
166
|
+
});
|
|
167
|
+
return Promise.race([p, timeout]).finally(() => {
|
|
168
|
+
if (timer !== undefined)
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/** 面板关键词检索的软超时上限:正常检索是毫秒级,8s 足够宽裕。 */
|
|
173
|
+
const SEARCH_SOFT_TIMEOUT_MS = 8_000;
|
|
141
174
|
/** 读取 JSON 请求体(上限 8MB:面板最大载荷为 list-records 分页与图谱检索)。 */
|
|
142
175
|
function readJsonBody(req) {
|
|
143
176
|
return new Promise((resolve, reject) => {
|
|
@@ -402,19 +435,232 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
402
435
|
return v;
|
|
403
436
|
}
|
|
404
437
|
case 'dsh-memory/session-recalled': {
|
|
405
|
-
// G-live:返回本会话(按
|
|
438
|
+
// G-live:返回本会话(按 sessionKey,与投影 header.id / pill sessionId 同源)已注入、
|
|
439
|
+
// 尚未被压缩覆盖的召回记忆。
|
|
406
440
|
const p = (payload ?? {});
|
|
407
441
|
const sessionId = expectSessionId(p.sessionId);
|
|
408
442
|
const ids = stores.recalled?.list(sessionId) ?? [];
|
|
409
443
|
if (ids.length === 0)
|
|
410
|
-
return { sessionId, records: [] };
|
|
444
|
+
return { sessionId, records: [], typeCatalog: typeCatalogEntries() };
|
|
445
|
+
// 压缩水位之上的那批:正文已被摘要取代。与 retired 正交(真实实现见 session-recalled.ts)。
|
|
446
|
+
const superseded = stores.recalled?.supersededOf(sessionId);
|
|
411
447
|
const halfLife = effectiveCfg(cfg, live).recall?.decayHalfLifeDays ?? 0;
|
|
412
448
|
const byId = new Map(stores.l1.getByIds(ids).map((r) => [r.id, r]));
|
|
413
449
|
const records = ids
|
|
414
450
|
.map((id) => byId.get(id))
|
|
415
451
|
.filter((r) => r !== undefined)
|
|
416
|
-
.map((r) =>
|
|
417
|
-
|
|
452
|
+
.map((r) => {
|
|
453
|
+
const ui = hitToUiRecord(r, {
|
|
454
|
+
agingWeight: agingWeightOf(r.updatedAt, halfLife),
|
|
455
|
+
usage: stores.usage?.get(r.id) ?? null,
|
|
456
|
+
userRelevance: stores.feedback?.meanOf(r.id) ?? null,
|
|
457
|
+
userMark: stores.feedback?.lastOf(r.id) ?? null,
|
|
458
|
+
});
|
|
459
|
+
return superseded?.has(r.id) === true ? { ...ui, superseded: true } : ui;
|
|
460
|
+
});
|
|
461
|
+
return { sessionId, records, typeCatalog: typeCatalogEntries() };
|
|
462
|
+
}
|
|
463
|
+
case 'dsh-memory/recall-feedback': {
|
|
464
|
+
// 人在召回卡上给一条召回记忆打分:0..5 = 相关度(只聚合,不改写任何排序分);
|
|
465
|
+
// -1 = 声明过时,做两件事:退场该记录(软删可恢复)+ 排一条下一轮向模型的播报
|
|
466
|
+
//(取走即离队,不常驻)。条目**不**从本会话活召回集摘掉——它得留在列表里显示删除线。
|
|
467
|
+
if (cfg.recallFeedback?.enabled !== true)
|
|
468
|
+
throw new Error('召回反馈未开启(cfg.recallFeedback.enabled)');
|
|
469
|
+
const feedback = stores.feedback;
|
|
470
|
+
if (feedback === undefined)
|
|
471
|
+
throw new Error('召回反馈存储未装配');
|
|
472
|
+
const p = (payload ?? {});
|
|
473
|
+
const sessionId = expectSessionId(typeof p.sessionId === 'string' ? p.sessionId : undefined);
|
|
474
|
+
if (typeof p.id !== 'string' || p.id.length === 0)
|
|
475
|
+
throw new Error('id 缺失');
|
|
476
|
+
if (p.id.length > 512)
|
|
477
|
+
throw new Error('id 过长(≤512 字符)');
|
|
478
|
+
const id = p.id;
|
|
479
|
+
if (!isFeedbackScore(p.score))
|
|
480
|
+
throw new Error('score 必须是 -1 或 0..5');
|
|
481
|
+
// 结构性护栏(代替全局高权限模式):只能给**本会话真召回过、且还活着**的记录打分。
|
|
482
|
+
// 否则一个面对 web 的写端点就能退场任意 id;而这张卡上本来也只列得出这些。
|
|
483
|
+
const alive = stores.recalled?.list(sessionId) ?? [];
|
|
484
|
+
if (!alive.includes(id))
|
|
485
|
+
throw new Error('该记忆不在本会话的召回集里(可能已被压缩覆盖)');
|
|
486
|
+
const entry = feedback.rate(id, p.score);
|
|
487
|
+
let retired = false;
|
|
488
|
+
let announced = false;
|
|
489
|
+
// 退场**前**的记录快照(带 workspaceId/name)——失效联动要靠它算落点,
|
|
490
|
+
// 而 `retire` 只改 `valid_to`,不会动这两列,所以取在退场前后等价,取在前更直白。
|
|
491
|
+
let rec;
|
|
492
|
+
if (p.score === -1) {
|
|
493
|
+
rec = stores.l1.getByIds([id])[0];
|
|
494
|
+
const retiredAt = new Date().toISOString();
|
|
495
|
+
retired = stores.l1.retire([id], { at: retiredAt, reason: 'obsolete' }) > 0;
|
|
496
|
+
// 不再摘除:条目要留在列表里显示删除线。检索侧已由 detachL1FromRetrieval 阻断再注入。
|
|
497
|
+
// 并且标成**受保护**:这张列表不再被压缩清掉,但仍有上限淘汰——判过时的条目
|
|
498
|
+
// 是人的裁决凭证,不能因为“后面又召回了几条”而被挤出去。
|
|
499
|
+
stores.recalled?.protect(sessionId, [id]);
|
|
500
|
+
if (cfg.recallFeedback?.announce !== false && stores.notices !== undefined && rec !== undefined) {
|
|
501
|
+
const title = (rec.content.split('\n').find((l) => l.trim().length > 0) ?? rec.content).trim().slice(0, 120);
|
|
502
|
+
stores.notices.push(sessionId, { id, title, at: Date.now() });
|
|
503
|
+
announced = true;
|
|
504
|
+
}
|
|
505
|
+
deps.logger.info(`[memory] 用户声明记忆过时并退场(session=${sessionId}, ${retired ? '已退场' : '本已退场'}): ${id}`);
|
|
506
|
+
// 失效联动(N11,用户裁决改异步):退场成立后**后台**去动投射文件——
|
|
507
|
+
// 文件改名/建凭证不再阻塞本 RPC(面板只等库侧结果);它内部已收敛所有
|
|
508
|
+
// 失败为 'failed',这里再兜一层 catch 只落日志。响应不再带 fileDisabled
|
|
509
|
+
// (面板无消费方),磁盘凭证的对账走 mem-reconcile。
|
|
510
|
+
if (retired && rec !== undefined && cfg.memExport?.enabled === true && stores.mdLedger !== undefined) {
|
|
511
|
+
void disableProjectionFile({
|
|
512
|
+
// 传退场**后**的快照:渲染出的 frontmatter 才带 `retired: true`,
|
|
513
|
+
// 凭证自己就能说清它为什么存在。
|
|
514
|
+
record: { ...rec, validTo: Date.parse(retiredAt) || Date.now() },
|
|
515
|
+
cfgRoot: cfg.memExport.root,
|
|
516
|
+
dataDir: deps.dataDir,
|
|
517
|
+
ledger: stores.mdLedger,
|
|
518
|
+
logger: deps.logger,
|
|
519
|
+
})
|
|
520
|
+
.then((outcome) => deps.logger.info(`[memory] 投射文件失效联动(后台): ${id} → ${outcome}`))
|
|
521
|
+
.catch((err) => deps.logger.warn(`[memory] 投射文件失效联动(后台)异常: ${err instanceof Error ? err.message : String(err)}`));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
id,
|
|
526
|
+
score: p.score,
|
|
527
|
+
rating: { last: entry.last, rated: entry.rated, mean: entry.mean, obsolete: entry.obsolete },
|
|
528
|
+
retired,
|
|
529
|
+
announced,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
case 'dsh-memory/workspace-relink': {
|
|
533
|
+
// 存量记忆的 workspace 回填(内容路由)。**缺省干跑**:先看命中率与分布报告,再决定真写。
|
|
534
|
+
// 真写额外要求高权限门(它改的是库里的列,虽然只改 `workspace_id`)。
|
|
535
|
+
const wsSrc = effectiveCfg(cfg, live).workspaceSource;
|
|
536
|
+
if (wsSrc?.enabled !== true)
|
|
537
|
+
throw new Error('内容路由未开启(cfg.workspaceSource.enabled)');
|
|
538
|
+
const p = (payload ?? {});
|
|
539
|
+
const dryRun = p.dryRun !== false;
|
|
540
|
+
if (!dryRun && live?.get().memoryMutate !== true)
|
|
541
|
+
throw new Error('真写归属列需要高权限模式(memoryMutate)');
|
|
542
|
+
return relinkWorkspaces({
|
|
543
|
+
l1: {
|
|
544
|
+
list: (o) => stores.l1.list(o),
|
|
545
|
+
setWorkspaceIds: (items) => stores.l1.setWorkspaceIds(items),
|
|
546
|
+
},
|
|
547
|
+
routes: wsSrc.routes,
|
|
548
|
+
dryRun,
|
|
549
|
+
maxRecords: wsSrc.maxRecords,
|
|
550
|
+
timeBudgetMs: wsSrc.timeBudgetMs,
|
|
551
|
+
logger: deps.logger,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
case 'dsh-memory/mem-export': {
|
|
555
|
+
// 单向导出:库 → md 文件 + 账本。**不写检索面**(用例钉住:导出前后 hashRecords 相同)。
|
|
556
|
+
// 先查配置门再查装配门:“整面默认关”时该听到的是“没开”,不是“没装配”。
|
|
557
|
+
const exp = cfg.memExport;
|
|
558
|
+
if (exp?.enabled !== true)
|
|
559
|
+
throw new Error('md 投射未开启(cfg.memExport.enabled)');
|
|
560
|
+
const md = stores.mdLedger;
|
|
561
|
+
if (md === undefined)
|
|
562
|
+
throw new Error('md 投射账本未装配');
|
|
563
|
+
// 命名回填:没名字的记录先拿到全库唯一的 `name`,再算落点。
|
|
564
|
+
// 不回填就是真机那个形状:1166 条 name 全空、42 条挤进 17 个同名文件,
|
|
565
|
+
// 文件互相覆盖 + 导出每轮重刷 + 反向同步把一份内容回灌给多条。
|
|
566
|
+
// 它只写 `name` 列(不在快照哈希字段表里),所以 hashRecords 仍逐字不变。
|
|
567
|
+
const all = stores.l1.all();
|
|
568
|
+
const naming = stores.l1.assignMissingNames(all, { persist: true });
|
|
569
|
+
if (naming.failed > 0) {
|
|
570
|
+
deps.logger?.warn(`[memory] md 投射命名回填失败 ${naming.failed} 条(下轮导出会重试)`);
|
|
571
|
+
}
|
|
572
|
+
// 类型目录骨架(pm-name-distill Phase 4):37 类目录各放一个 `_index.md`(类名 + 库里条数)。
|
|
573
|
+
// 目录集合从词表真源派生(在 ensureTypeSkeleton 内),这里只喂 countsByType 与 roots。
|
|
574
|
+
// 单根必补;workspace 根**按需**——只给本轮 `targetFor` 算出真有落点(inWorkspace)的根补,
|
|
575
|
+
// 不建空 workspace 根(消失的 workspace 已由 targetFor 护栏退回单根,这里不绕过它造目录树)。
|
|
576
|
+
// 配置门在前(enabled!==true 已在上头抛错),故关着时该端点根本到不了这里 → 骨架零动作。
|
|
577
|
+
const countsByType = new Map();
|
|
578
|
+
const skeletonRoots = new Set([resolveMemoriesRoot(exp.root, deps.dataDir, deps.logger)]);
|
|
579
|
+
for (const r of all) {
|
|
580
|
+
const dir = MEMORY_TYPE_DIRS.has(r.type) ? r.type : UNSORTED_DIR;
|
|
581
|
+
countsByType.set(dir, (countsByType.get(dir) ?? 0) + 1);
|
|
582
|
+
const t = targetFor(r, exp.root, deps.dataDir, deps.logger);
|
|
583
|
+
if (t.inWorkspace)
|
|
584
|
+
skeletonRoots.add(t.root);
|
|
585
|
+
}
|
|
586
|
+
await ensureTypeSkeleton({ roots: [...skeletonRoots], countsByType, logger: deps.logger });
|
|
587
|
+
const rep = await exportRecords({
|
|
588
|
+
records: all,
|
|
589
|
+
cfgRoot: exp.root,
|
|
590
|
+
dataDir: deps.dataDir,
|
|
591
|
+
ledger: md,
|
|
592
|
+
maxRecords: exp.maxRecords,
|
|
593
|
+
timeBudgetMs: exp.timeBudgetMs,
|
|
594
|
+
logger: deps.logger,
|
|
595
|
+
});
|
|
596
|
+
return { ...rep, named: naming.named, nameFailed: naming.failed };
|
|
597
|
+
}
|
|
598
|
+
case 'dsh-memory/mem-reconcile': {
|
|
599
|
+
// 只读对账:不写库、不写文件,只按账本基线出四态报告(所以不需高权限门)。
|
|
600
|
+
const exp = cfg.memExport;
|
|
601
|
+
if (exp?.enabled !== true)
|
|
602
|
+
throw new Error('md 投射未开启(cfg.memExport.enabled)');
|
|
603
|
+
const md = stores.mdLedger;
|
|
604
|
+
if (md === undefined)
|
|
605
|
+
throw new Error('md 投射账本未装配');
|
|
606
|
+
return reconcileLedger({
|
|
607
|
+
records: stores.l1.all(),
|
|
608
|
+
cfgRoot: exp.root,
|
|
609
|
+
dataDir: deps.dataDir,
|
|
610
|
+
ledger: md,
|
|
611
|
+
maxRecords: exp.maxRecords,
|
|
612
|
+
timeBudgetMs: exp.timeBudgetMs,
|
|
613
|
+
logger: deps.logger,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
case 'dsh-memory/mem-sync': {
|
|
617
|
+
// P2 反向同步:文件 → 库。与导出的**风险不对称**——导出只写文件,本项会写库,
|
|
618
|
+
// 所以多两道门:要先开投射(没导出过 = 账本为空 = 判不出“谁变了”),要高权限。
|
|
619
|
+
const syn = cfg.mdSync;
|
|
620
|
+
if (syn?.enabled !== true)
|
|
621
|
+
throw new Error('md 反向同步未开启(cfg.mdSync.enabled)');
|
|
622
|
+
if (cfg.memExport?.enabled !== true)
|
|
623
|
+
throw new Error('md 反向同步需先开启 md 投射(cfg.memExport.enabled)');
|
|
624
|
+
if (!live?.get().memoryMutate)
|
|
625
|
+
throw new Error('记忆写删未开放:请在记忆库面板开启「高权限模式」后才能回灌文件侧修改。');
|
|
626
|
+
const md = stores.mdLedger;
|
|
627
|
+
if (md === undefined)
|
|
628
|
+
throw new Error('md 投射账本未装配');
|
|
629
|
+
return syncFromFiles({
|
|
630
|
+
store: stores.l1,
|
|
631
|
+
ledger: md,
|
|
632
|
+
cfgRoot: cfg.memExport.root,
|
|
633
|
+
dataDir: deps.dataDir,
|
|
634
|
+
maxFiles: syn.maxFiles,
|
|
635
|
+
timeBudgetMs: syn.timeBudgetMs,
|
|
636
|
+
logger: deps.logger,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
case 'dsh-memory/mem-orphans': {
|
|
640
|
+
// P4.4 孤儿归档:只搬“账本记着我们写过、但现在没记录认领它”的投射文件。
|
|
641
|
+
// 门序与同步侧一致:配置门 → 装配门 → 高权限门(干跑不需要)。**默认干跑**:
|
|
642
|
+
// 搬文件在用户眼里和删文件差不离,必须显式要。
|
|
643
|
+
const exp = cfg.memExport;
|
|
644
|
+
if (exp?.enabled !== true)
|
|
645
|
+
throw new Error('md 投射未开启(cfg.memExport.enabled)');
|
|
646
|
+
const md = stores.mdLedger;
|
|
647
|
+
if (md === undefined)
|
|
648
|
+
throw new Error('md 投射账本未装配');
|
|
649
|
+
const p = (payload ?? {});
|
|
650
|
+
const dryRun = p.dryRun !== false;
|
|
651
|
+
if (!dryRun && live?.get().memoryMutate !== true) {
|
|
652
|
+
throw new Error('孤儿文件归档需要高权限模式(memoryMutate)');
|
|
653
|
+
}
|
|
654
|
+
return sweepOrphanFiles({
|
|
655
|
+
records: stores.l1.all(),
|
|
656
|
+
cfgRoot: exp.root,
|
|
657
|
+
dataDir: deps.dataDir,
|
|
658
|
+
ledger: md,
|
|
659
|
+
dryRun,
|
|
660
|
+
maxFiles: exp.maxRecords,
|
|
661
|
+
timeBudgetMs: exp.timeBudgetMs,
|
|
662
|
+
logger: deps.logger,
|
|
663
|
+
});
|
|
418
664
|
}
|
|
419
665
|
case 'dsh-memory/session-mode-set': {
|
|
420
666
|
if (!modes)
|
|
@@ -1180,12 +1426,16 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1180
1426
|
const wingCatalog = offset === 0
|
|
1181
1427
|
? [...WING_CATALOG.map((h) => ({ id: h.id, label: h.label })), { id: WING_FALLBACK, label: '跨域' }]
|
|
1182
1428
|
: undefined;
|
|
1429
|
+
// 类型词表目录随首屏下发(task_1.5:单一真源在 `memory-types.ts`,client 不手抄)。
|
|
1430
|
+
const typeCatalog = offset === 0 ? typeCatalogEntries() : undefined;
|
|
1183
1431
|
// 关键词路径:复用检索唯一缝(与召回同源),取回后做场景/Wing 过滤 + 手工分页。
|
|
1184
1432
|
// 检索侧单次上限 200:分页窗口触达上限时显式标记 truncated(结果可能不完整)。
|
|
1185
1433
|
if (p.query && p.query.trim()) {
|
|
1186
1434
|
const SEARCH_CAP = 200;
|
|
1187
1435
|
const wanted = offset + limit + 1;
|
|
1188
|
-
|
|
1436
|
+
// 软超时护栏(用户裁决):删除/繁重后台动作后的短暂检索迟滞不得把面板
|
|
1437
|
+
// 永久钉在「加载中」。到期返回可重试的业务错误。
|
|
1438
|
+
const hits = await raceTimeout(stores.l1.search(p.query, Math.min(wanted, SEARCH_CAP), { type: p.type || undefined }), SEARCH_SOFT_TIMEOUT_MS, `检索超时(${Math.round(SEARCH_SOFT_TIMEOUT_MS / 1000)}s):检索面短暂繁忙,请重试或缩小关键词`);
|
|
1189
1439
|
let filtered = p.scene ? hits.filter((h) => h.scene_name === p.scene) : hits;
|
|
1190
1440
|
// Wing / Room 过滤(检索命中不含 metadata):按 id 批量取回元数据后过滤
|
|
1191
1441
|
let metaById = null;
|
|
@@ -1218,18 +1468,40 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1218
1468
|
return true;
|
|
1219
1469
|
});
|
|
1220
1470
|
}
|
|
1221
|
-
const
|
|
1222
|
-
const
|
|
1471
|
+
const page = filtered.slice(offset, offset + limit);
|
|
1472
|
+
const pageIds = page.map((h) => h.id);
|
|
1473
|
+
// 检索命中(`L1SearchHit`)只带 7 个字段,不含 createdAt/timestamps/version/
|
|
1474
|
+
// metadata/validTo——卡片上的「创建/活跃时间」与 R7 来源锚点因此恒为占位值。
|
|
1475
|
+
// 旧缺陷有两层:① 这些字段从未补过;② `metaById` 只在勾了 Wing/tag 过滤时才建,
|
|
1476
|
+
// 于是连 metadata 都不补——锚点在默认视图下永远显示 `-`。这里按 id 回查主表
|
|
1477
|
+
// 做完整水合,与浏览路径同口径;正文/得分仍以命中为准(FTS 可能给的是摘要)。
|
|
1478
|
+
const fullById = new Map(stores.l1.getByIds(pageIds).map((r) => [r.id, r]));
|
|
1223
1479
|
const resp = {
|
|
1224
|
-
items:
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1480
|
+
items: page.map((h) => {
|
|
1481
|
+
const full = fullById.get(h.id);
|
|
1482
|
+
return hitToUiRecord({
|
|
1483
|
+
...full,
|
|
1484
|
+
id: h.id,
|
|
1485
|
+
content: h.content,
|
|
1486
|
+
type: h.type,
|
|
1487
|
+
priority: h.priority,
|
|
1488
|
+
scene_name: h.scene_name,
|
|
1489
|
+
family: h.family,
|
|
1490
|
+
score: h.score,
|
|
1491
|
+
metadata: full?.metadata ?? metaById?.get(h.id),
|
|
1492
|
+
}, {
|
|
1493
|
+
agingWeight: agingWeightOf(full?.updatedAt, stores.l1.decayHalfLife),
|
|
1494
|
+
usage: stores.usage?.get(h.id) ?? null,
|
|
1495
|
+
userRelevance: stores.feedback?.meanOf(h.id) ?? null,
|
|
1496
|
+
userMark: stores.feedback?.lastOf(h.id) ?? null,
|
|
1497
|
+
});
|
|
1498
|
+
}),
|
|
1228
1499
|
hasMore: filtered.length > offset + limit,
|
|
1229
1500
|
total: null,
|
|
1230
1501
|
truncated: wanted > SEARCH_CAP,
|
|
1231
1502
|
scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
|
|
1232
1503
|
wingCatalog,
|
|
1504
|
+
typeCatalog,
|
|
1233
1505
|
usageSummary: stores.usage?.summary() ?? null,
|
|
1234
1506
|
};
|
|
1235
1507
|
return resp;
|
|
@@ -1241,12 +1513,15 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1241
1513
|
items: items.map((r) => hitToUiRecord(r, {
|
|
1242
1514
|
agingWeight: agingWeightOf(r.updatedAt, stores.l1.decayHalfLife),
|
|
1243
1515
|
usage: stores.usage?.get(r.id) ?? null,
|
|
1516
|
+
userRelevance: stores.feedback?.meanOf(r.id) ?? null,
|
|
1517
|
+
userMark: stores.feedback?.lastOf(r.id) ?? null,
|
|
1244
1518
|
})),
|
|
1245
1519
|
hasMore: offset + items.length < total,
|
|
1246
1520
|
total,
|
|
1247
1521
|
truncated: false,
|
|
1248
1522
|
scenes: offset === 0 ? stores.l1.distinctScenes() : undefined,
|
|
1249
1523
|
wingCatalog,
|
|
1524
|
+
typeCatalog,
|
|
1250
1525
|
usageSummary: stores.usage?.summary() ?? null,
|
|
1251
1526
|
};
|
|
1252
1527
|
return resp;
|
|
@@ -1282,7 +1557,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1282
1557
|
// (live.conflictFreeze 覆盖静态 cfg.conflictFreeze.enabled)。此前这里直接读
|
|
1283
1558
|
// cfg.conflictFreeze.enabled —— 面板开关写的是 live,而部署静态值恒 false,
|
|
1284
1559
|
// 于是开关已开、settings.yaml 已落 true,本页仍报"矛盾冻结未开启"。
|
|
1285
|
-
return listConflictPairs({ l1: stores.l1, conflictFreezeEnabled: effectiveCfg(cfg, live).conflictFreeze?.enabled === true, usage: stores.usage, decayHalfLifeDays: effectiveCfg(cfg, live).recall?.decayHalfLifeDays }, { limit: Number(p.limit) || undefined });
|
|
1560
|
+
return listConflictPairs({ l1: stores.l1, conflictFreezeEnabled: effectiveCfg(cfg, live).conflictFreeze?.enabled === true, usage: stores.usage, decayHalfLifeDays: effectiveCfg(cfg, live).recall?.decayHalfLifeDays, feedback: stores.feedback }, { limit: Number(p.limit) || undefined });
|
|
1286
1561
|
}
|
|
1287
1562
|
// ── §C 矛盾冻结裁决(task_25):与 memory_resolve_conflict 工具共用同一形状 ──
|
|
1288
1563
|
// 端点层同样不给"提示文案"出口的例外只有一条:**队列未开启**不是调用错误而是
|
|
@@ -1403,6 +1678,14 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1403
1678
|
}
|
|
1404
1679
|
}
|
|
1405
1680
|
if (dryRun) {
|
|
1681
|
+
// 待删清单要给人看:`targets` 只是计数,人无法从计数判断“这批是不是该删的那批”。
|
|
1682
|
+
// 取前 50 条预览(全量列表会把面板撑爆,而判据只需抽看)。
|
|
1683
|
+
const preview = stores.l1.getByIds(targets.slice(0, 50)).map((r) => ({
|
|
1684
|
+
id: r.id,
|
|
1685
|
+
name: r.name ?? '',
|
|
1686
|
+
type: r.type,
|
|
1687
|
+
retiredAt: r.validTo === undefined ? '' : new Date(r.validTo).toISOString(),
|
|
1688
|
+
}));
|
|
1406
1689
|
const resp = {
|
|
1407
1690
|
dryRun: true,
|
|
1408
1691
|
targets: targets.length,
|
|
@@ -1411,6 +1694,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1411
1694
|
dir: '',
|
|
1412
1695
|
name: '',
|
|
1413
1696
|
diffs: [],
|
|
1697
|
+
items: preview,
|
|
1414
1698
|
};
|
|
1415
1699
|
return resp;
|
|
1416
1700
|
}
|
|
@@ -1418,7 +1702,18 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1418
1702
|
const resp = { dryRun: false, targets: 0, purged: 0, aborted: false, dir: '', name: '', diffs: [] };
|
|
1419
1703
|
return resp;
|
|
1420
1704
|
}
|
|
1421
|
-
|
|
1705
|
+
// 删前先把该批记录抓在手里:删完再读就什么都读不到了(归档正文与预览清单都要用它们)。
|
|
1706
|
+
const purgeRecords = stores.l1.getByIds(targets);
|
|
1707
|
+
// N12:投射面开着时把该批的投射文件(含 `.disable`)一并收进快照目录。
|
|
1708
|
+
// 关着时 `files` 为 undefined ⇒ `exportThenPurge` 行为与改动前逐字一致。
|
|
1709
|
+
const files = cfg.memExport?.enabled === true
|
|
1710
|
+
? await collectProjectionFiles(purgeRecords, cfg.memExport.root, deps.dataDir, deps.logger)
|
|
1711
|
+
: undefined;
|
|
1712
|
+
const r = await stores.l1.purgeRetired(targets, 'cleanup-retired', { files });
|
|
1713
|
+
// F19:图投影的两张索引表全仓原本**只增不减**。记录行删了而投影行还在,
|
|
1714
|
+
// 从快照恢复回来的记录会被当成“已投影”而永不重投影(检索图静默缺一条,不报错)。
|
|
1715
|
+
// 只在真删成功时抹:干跑与中止都不动图——图是派生索引,不该因为一次预览被改。
|
|
1716
|
+
const graphForgotten = !r.aborted && r.purged > 0 ? (stores.graph?.forgetRecords(targets) ?? 0) : 0;
|
|
1422
1717
|
const resp = {
|
|
1423
1718
|
dryRun: false,
|
|
1424
1719
|
targets: targets.length,
|
|
@@ -1427,6 +1722,16 @@ export async function handleEndpoint(endpoint, payload, deps) {
|
|
|
1427
1722
|
dir: r.dir,
|
|
1428
1723
|
name: r.name,
|
|
1429
1724
|
diffs: r.diffs,
|
|
1725
|
+
otherChanged: r.otherChanged,
|
|
1726
|
+
archiveMd: r.archiveMd,
|
|
1727
|
+
files: r.files,
|
|
1728
|
+
graphForgotten,
|
|
1729
|
+
items: purgeRecords.slice(0, 50).map((x) => ({
|
|
1730
|
+
id: x.id,
|
|
1731
|
+
name: x.name ?? '',
|
|
1732
|
+
type: x.type,
|
|
1733
|
+
retiredAt: x.validTo === undefined ? '' : new Date(x.validTo).toISOString(),
|
|
1734
|
+
})),
|
|
1430
1735
|
};
|
|
1431
1736
|
return resp;
|
|
1432
1737
|
}
|
|
@@ -1889,10 +2194,17 @@ export function hitToUiRecord(r, extra) {
|
|
|
1889
2194
|
retiredReason: retired ? (mark?.reason ?? 'unknown') : null,
|
|
1890
2195
|
agingWeight: extra?.agingWeight ?? null,
|
|
1891
2196
|
usage: extra?.usage ?? null,
|
|
1892
|
-
// G3 成熟度:读时派生(priority + used +
|
|
1893
|
-
maturity: computeMaturity({
|
|
2197
|
+
// G3 成熟度:读时派生(priority + used + 老化 + 人工最近一笔),无额外端点/存储。
|
|
2198
|
+
maturity: computeMaturity({
|
|
2199
|
+
priority: r.priority ?? 60,
|
|
2200
|
+
usage: extra?.usage ?? null,
|
|
2201
|
+
agingWeight: extra?.agingWeight ?? null,
|
|
2202
|
+
mark: extra?.userMark ?? null,
|
|
2203
|
+
}),
|
|
1894
2204
|
// G1 使用场景:从命名空间保留键读出(功能关/无键 → undefined)。
|
|
1895
2205
|
usageScenario: Array.isArray(r.metadata?.dsh_usage_scenario) ? r.metadata?.dsh_usage_scenario : undefined,
|
|
2206
|
+
// 人给相关度均值:读时从 sidecar 派生(不进存储/快照);未打分 → 字段缺席。
|
|
2207
|
+
userRelevance: extra?.userRelevance ?? undefined,
|
|
1896
2208
|
};
|
|
1897
2209
|
}
|
|
1898
2210
|
/**
|
|
@@ -97,6 +97,14 @@ export declare class EmbeddingManager {
|
|
|
97
97
|
private applyBusy;
|
|
98
98
|
private reindex;
|
|
99
99
|
private reindexCancel;
|
|
100
|
+
/**
|
|
101
|
+
* 上一次重建的留痕(类型就地取自契约,不另开 import)。
|
|
102
|
+
*
|
|
103
|
+
* 为什么必须存:进度条只在 `running` 期间存在,而“待补 0 条”的重建会瞬间跑完,
|
|
104
|
+
* 界面上与“点了没反应”长得一模一样——真机反馈就是这么读的。留痕存**前后待补数**,
|
|
105
|
+
* 让“效果”是两个可比数字而不是一句“我转了一下”。
|
|
106
|
+
*/
|
|
107
|
+
private reindexLast;
|
|
100
108
|
/**
|
|
101
109
|
* 向量计数缓存(分级 TTL)。
|
|
102
110
|
*
|
|
@@ -187,6 +187,14 @@ export class EmbeddingManager {
|
|
|
187
187
|
cancelled: false,
|
|
188
188
|
};
|
|
189
189
|
reindexCancel = false;
|
|
190
|
+
/**
|
|
191
|
+
* 上一次重建的留痕(类型就地取自契约,不另开 import)。
|
|
192
|
+
*
|
|
193
|
+
* 为什么必须存:进度条只在 `running` 期间存在,而“待补 0 条”的重建会瞬间跑完,
|
|
194
|
+
* 界面上与“点了没反应”长得一模一样——真机反馈就是这么读的。留痕存**前后待补数**,
|
|
195
|
+
* 让“效果”是两个可比数字而不是一句“我转了一下”。
|
|
196
|
+
*/
|
|
197
|
+
reindexLast;
|
|
190
198
|
/**
|
|
191
199
|
* 向量计数缓存(分级 TTL)。
|
|
192
200
|
*
|
|
@@ -471,6 +479,27 @@ export class EmbeddingManager {
|
|
|
471
479
|
async reindexNow() {
|
|
472
480
|
this.reindexCancel = false;
|
|
473
481
|
this.reindex = { running: true, l1Done: 0, l1Total: 0, l0Done: 0, l0Total: 0, startedAt: Date.now(), cancelled: false };
|
|
482
|
+
// 重建**前**的待补数:先破缓存再取,否则拿到的可能是 TTL 里的旧值(那会让“前后差”假报 0)。
|
|
483
|
+
// 口径故意与界面用的同一个(相减法),不拿精确口径去对展示数字,否则两边永远对不上。
|
|
484
|
+
this.invalidateVecCache();
|
|
485
|
+
const v0 = this.vectorCounts();
|
|
486
|
+
const missingBefore = { l1: v0.l1.missing, l0: v0.l0.missing };
|
|
487
|
+
const finish = (cancelled, error) => {
|
|
488
|
+
this.invalidateVecCache();
|
|
489
|
+
const v1 = this.vectorCounts();
|
|
490
|
+
const at = Date.now();
|
|
491
|
+
this.reindexLast = {
|
|
492
|
+
finishedAt: at,
|
|
493
|
+
durationMs: Math.max(0, at - this.reindex.startedAt),
|
|
494
|
+
processed: this.reindex.l1Done + this.reindex.l0Done,
|
|
495
|
+
planned: this.reindex.l1Total + this.reindex.l0Total,
|
|
496
|
+
failed: failedTotal,
|
|
497
|
+
cancelled,
|
|
498
|
+
...(error === undefined ? {} : { error }),
|
|
499
|
+
missingBefore,
|
|
500
|
+
missingAfter: { l1: v1.l1.missing, l0: v1.l0.missing },
|
|
501
|
+
};
|
|
502
|
+
};
|
|
474
503
|
let failedTotal = 0;
|
|
475
504
|
try {
|
|
476
505
|
const r1 = await this.deps.l1.reindex({
|
|
@@ -493,6 +522,7 @@ export class EmbeddingManager {
|
|
|
493
522
|
this.reindex.cancelled = cancelled;
|
|
494
523
|
this.reindex.running = false;
|
|
495
524
|
this.invalidateVecCache(); // 收尾:计数必须立刻反映重建结果(TTL 可能还压着 1s 旧值)
|
|
525
|
+
finish(cancelled);
|
|
496
526
|
return { cancelled, failedTotal };
|
|
497
527
|
}
|
|
498
528
|
catch (err) {
|
|
@@ -500,6 +530,7 @@ export class EmbeddingManager {
|
|
|
500
530
|
this.reindex.running = false;
|
|
501
531
|
this.reindex.error = message;
|
|
502
532
|
this.invalidateVecCache();
|
|
533
|
+
finish(false, message);
|
|
503
534
|
return { cancelled: false, failedTotal, error: `重嵌入失败: ${message}` };
|
|
504
535
|
}
|
|
505
536
|
}
|
|
@@ -538,7 +569,7 @@ export class EmbeddingManager {
|
|
|
538
569
|
download: this.downloader.getProgress(),
|
|
539
570
|
apply: { phase: this.applyPhase, message: this.applyMessage, startedAt: this.applyStartedAt, busy: this.applyBusy },
|
|
540
571
|
local: this.localSvc ? { state: this.localSvc.getState(), error: this.localSvc.getLoadError() } : null,
|
|
541
|
-
reindex: { ...this.reindex },
|
|
572
|
+
reindex: { ...this.reindex, last: this.reindexLast },
|
|
542
573
|
vectors: this.vectorsCached(),
|
|
543
574
|
activeNote: this.activeNote,
|
|
544
575
|
};
|
|
@@ -562,7 +593,13 @@ export class EmbeddingManager {
|
|
|
562
593
|
const count = (kind) => {
|
|
563
594
|
const skip = this.deps.db.getVecSkipSet(kind);
|
|
564
595
|
const embedded = kind === 'l1' ? this.deps.db.countL1Vec() : this.deps.db.countL0Vec();
|
|
565
|
-
|
|
596
|
+
// 相减法还有第二条前提,之前漏了:**分母必须是活跃行**。退场行按设计就合法地没有
|
|
597
|
+
// 向量(撤检索面时 vec 行被 detach),把它们算进 total 会让 missing 恒等于退场数、
|
|
598
|
+
// 永不归零;而真正驱动补齐的 `countVecMissing`/`getL1ForReindex` 都排除退场行,于是
|
|
599
|
+
// 界面上同时出现“待补 114 条”与“重建索引无事可做”。本机实测:1280 总 / 114 退场 /
|
|
600
|
+
// 1166 活跃且全部已嵌,正是这个形状。`countL1Retired` 失败给 -1,按 0 处理退回旧行为。
|
|
601
|
+
const retired = kind === 'l1' ? Math.max(0, this.deps.db.countL1Retired()) : 0;
|
|
602
|
+
const total = (kind === 'l1' ? this.deps.db.countL1() : this.deps.db.countL0()) - retired;
|
|
566
603
|
// 缺失数走**相减**而非 LEFT JOIN:见下方 vectorsCached 上方的说明。
|
|
567
604
|
const missing = embedded < 0 || total < 0 ? -1 : Math.max(0, total - embedded - skip.size);
|
|
568
605
|
return { embedded, total, missing, skipped: skip.size };
|
|
@@ -27,3 +27,5 @@ export declare const SESSION_RECALLED_FILE_VERSION = 1;
|
|
|
27
27
|
export declare const PENDING_FILE_VERSION = 1;
|
|
28
28
|
/** `reconcile-state.json`。 */
|
|
29
29
|
export declare const RECONCILE_STATE_FILE_VERSION = 1;
|
|
30
|
+
/** `mem-md-ledger.json`(md 投射指纹账本,N9;派生态,删了可重建)。*/
|
|
31
|
+
export declare const MEM_MD_LEDGER_FILE_VERSION = 1;
|
|
@@ -27,3 +27,5 @@ export const SESSION_RECALLED_FILE_VERSION = 1;
|
|
|
27
27
|
export const PENDING_FILE_VERSION = 1;
|
|
28
28
|
/** `reconcile-state.json`。 */
|
|
29
29
|
export const RECONCILE_STATE_FILE_VERSION = 1;
|
|
30
|
+
/** `mem-md-ledger.json`(md 投射指纹账本,N9;派生态,删了可重建)。*/
|
|
31
|
+
export const MEM_MD_LEDGER_FILE_VERSION = 1;
|
|
@@ -106,6 +106,20 @@ export declare class GraphStore {
|
|
|
106
106
|
* 记录跳过。返回实际新建的 job 数。
|
|
107
107
|
*/
|
|
108
108
|
queueGraphProjection(recordIds: readonly string[], priority: number): number;
|
|
109
|
+
/**
|
|
110
|
+
* 记录被物理删除后,把两张**索引表**里指向它的行一并抹掉。
|
|
111
|
+
*
|
|
112
|
+
* 为什么必需(F19):`graph_projected_records` 之前全仓**只增不减**。记录行删了而投影行还在,
|
|
113
|
+
* 就留下两类静默问题:① 投影进度表里挂着一批查不到主人的 record_id;② 从快照**恢复**回来的
|
|
114
|
+
* 记录因为投影行已存在而被当成“已投影”,**永远不会重投影**(检索图里就这么缺一条,不报错)。
|
|
115
|
+
*
|
|
116
|
+
* 只删索引行,**不动图节点/边**:节点是跨记录聚合出来的事实,归图自己的生命周期管。
|
|
117
|
+
* 失败只 warn 并返回已删数——清理的既成事实不应被旁路索引拖挂。
|
|
118
|
+
*
|
|
119
|
+
* @param ids - 已被物理删除的记录 id。
|
|
120
|
+
* @returns 从投影表删掉的行数。
|
|
121
|
+
*/
|
|
122
|
+
forgetRecords(ids: readonly string[]): number;
|
|
109
123
|
/**
|
|
110
124
|
* 取下一个可执行 job 并置 running(attempts +1):只认当前 projectorVersion,
|
|
111
125
|
* attempts 封顶与退避窗口在 WHERE 里过滤;来源全缺失 → job 判 dead 返 null
|
|
@@ -514,6 +514,41 @@ export class GraphStore {
|
|
|
514
514
|
return 0;
|
|
515
515
|
}
|
|
516
516
|
}
|
|
517
|
+
/**
|
|
518
|
+
* 记录被物理删除后,把两张**索引表**里指向它的行一并抹掉。
|
|
519
|
+
*
|
|
520
|
+
* 为什么必需(F19):`graph_projected_records` 之前全仓**只增不减**。记录行删了而投影行还在,
|
|
521
|
+
* 就留下两类静默问题:① 投影进度表里挂着一批查不到主人的 record_id;② 从快照**恢复**回来的
|
|
522
|
+
* 记录因为投影行已存在而被当成“已投影”,**永远不会重投影**(检索图里就这么缺一条,不报错)。
|
|
523
|
+
*
|
|
524
|
+
* 只删索引行,**不动图节点/边**:节点是跨记录聚合出来的事实,归图自己的生命周期管。
|
|
525
|
+
* 失败只 warn 并返回已删数——清理的既成事实不应被旁路索引拖挂。
|
|
526
|
+
*
|
|
527
|
+
* @param ids - 已被物理删除的记录 id。
|
|
528
|
+
* @returns 从投影表删掉的行数。
|
|
529
|
+
*/
|
|
530
|
+
forgetRecords(ids) {
|
|
531
|
+
if (!this.db || ids.length === 0)
|
|
532
|
+
return 0;
|
|
533
|
+
const db = this.db;
|
|
534
|
+
let removed = 0;
|
|
535
|
+
try {
|
|
536
|
+
// 分批:IN 列表不能无限长(与入队/去重查询同一个口径)
|
|
537
|
+
for (let i = 0; i < ids.length; i += 500) {
|
|
538
|
+
const chunk = ids.slice(i, i + 500);
|
|
539
|
+
const ph = chunk.map(() => '?').join(',');
|
|
540
|
+
db.prepare(`DELETE FROM graph_projected_records WHERE record_id IN (${ph})`).run(...chunk);
|
|
541
|
+
const jobInfo = db.prepare(`DELETE FROM graph_job_records WHERE record_id IN (${ph})`).run(...chunk);
|
|
542
|
+
removed += chunk.length;
|
|
543
|
+
void jobInfo;
|
|
544
|
+
}
|
|
545
|
+
return removed;
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
this.logger?.warn(`${TAG} 图索引遗录清理失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
549
|
+
return removed;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
517
552
|
/**
|
|
518
553
|
* 取下一个可执行 job 并置 running(attempts +1):只认当前 projectorVersion,
|
|
519
554
|
* attempts 封顶与退避窗口在 WHERE 里过滤;来源全缺失 → job 判 dead 返 null
|