dsh-prime-memory 0.25.0 → 0.26.1
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 +37 -0
- package/CHANGELOG.ja.md +38 -0
- package/CHANGELOG.ko.md +38 -0
- package/CHANGELOG.md +39 -0
- package/dist/client.js +991 -665
- package/dist/config.d.ts +44 -2
- package/dist/config.js +32 -2
- package/dist/contract.d.ts +108 -2
- package/dist/index.js +3 -3
- package/dist/keyword-backfill.d.ts +52 -7
- package/dist/keyword-backfill.js +204 -62
- package/dist/llm.d.ts +9 -0
- package/dist/llm.js +7 -4
- package/dist/pipeline/relabel.d.ts +11 -12
- package/dist/pipeline/relabel.js +54 -66
- package/dist/pipeline/runner.js +24 -1
- package/dist/projection/recall.d.ts +3 -2
- package/dist/projection/recall.js +26 -5
- package/dist/projection/slots.d.ts +11 -0
- package/dist/projection/slots.js +13 -3
- package/dist/redistill-backfill.d.ts +148 -0
- package/dist/redistill-backfill.js +420 -0
- package/dist/room-review.d.ts +12 -13
- package/dist/room-review.js +39 -29
- package/dist/settings.js +15 -0
- package/dist/stats.js +138 -21
- package/dist/store/l1.d.ts +12 -0
- package/dist/store/l1.js +9 -0
- package/dist/store/memory-md.d.ts +8 -0
- package/dist/store/memory-md.js +13 -2
- package/dist/store/sqlite.d.ts +17 -0
- package/dist/store/sqlite.js +33 -0
- package/dist/tools/index.d.ts +10 -1
- package/dist/tools/index.js +7 -1
- package/dist/tools/skills.d.ts +7 -4
- package/dist/tools/skills.js +9 -11
- package/dist/wing-backfill.d.ts +25 -14
- package/dist/wing-backfill.js +133 -96
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { callLLM, parseJsonLogged } from './llm.js';
|
|
2
|
+
import { normKeywords, normUsageScenario, KEYWORDS_METADATA_KEY } from './metadata-validators.js';
|
|
3
|
+
import { scoreSkeleton } from './prompts/examples.js';
|
|
4
|
+
import { normalizeContentMd } from './util/md-normalize.js';
|
|
5
|
+
import { yieldLoop } from './util/yield.js';
|
|
6
|
+
// 一次 LLM 调用只处理**一条**记忆(旧形状是 20 条/次,已改)。
|
|
7
|
+
// 为什么不批量:本任务的输出是「整条正文重写」,20 条一批意味着单请求要生 5000+ token——
|
|
8
|
+
// 真机实测因此把单请求拖到 200s+ 并在超时线上被掐死,而且主路由(小模型)一次空转
|
|
9
|
+
// 就带走整批 20 条。实测「每条一个请求」在 35B 端点上只要 ~2.6s(4 路并发 3.2s),
|
|
10
|
+
// 251 条 ≈ 3 分钟——批量在这里不省任何时间,只堆风险。
|
|
11
|
+
/** 扫描页大小。 */
|
|
12
|
+
const PAGE = 200;
|
|
13
|
+
/** 单次任务默认处理上限(cfg.backfill.maxRecords 优先)。 */
|
|
14
|
+
const DEFAULT_MAX_RECORDS = 40;
|
|
15
|
+
/** 总墙钟默认预算(cfg.backfill.timeBudgetMs 优先)。 */
|
|
16
|
+
const DEFAULT_TIME_BUDGET_MS = 1_800_000;
|
|
17
|
+
/**
|
|
18
|
+
* 默认并行度(cfg.backfill.concurrency 优先):1 = 串行逐条(用户定的形状)。
|
|
19
|
+
* 想临时提速就拖概览页滑块;本端点的实测标定是 conc=4 最优(再高因排队反而变慢)。
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_CONCURRENCY = 1;
|
|
22
|
+
/**
|
|
23
|
+
* 一次调用(含整条回退链)的默认超时(cfg.backfill.callTimeoutMs 优先)。
|
|
24
|
+
*
|
|
25
|
+
* 单条实测 ~2.6s,给 240s 不是为常规速度,而是为回退链留位:主路由(小模型)
|
|
26
|
+
* 可能先空转一轮再降级,预算得按「路由数 × 单路由耗时」算,而不是按成功路径算。
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_CALL_TIMEOUT_MS = 240_000;
|
|
29
|
+
/** 单条整形的输出预算(一条 ≤300 字正文 + 分节标题 + 5 关键词,实测 165-175 token)。 */
|
|
30
|
+
const RESHAPE_MAX_TOKENS = 1024;
|
|
31
|
+
/**
|
|
32
|
+
* 整形**版本戳**:只在**成功替换**时写入,值为当前整形规则版本(`RESHAPE_SCHEMA_VERSION`)。
|
|
33
|
+
* 扫描阶段据此排除「已按当前规则整过形」的记录——见 `isReshapedAtCurrentVersion`。
|
|
34
|
+
*/
|
|
35
|
+
export const RESHAPE_VER_KEY = 'dsh_reshape_ver';
|
|
36
|
+
/**
|
|
37
|
+
* 当前整形规则版本。**改动整形骨架/提示词/输出契约时必须 +1**,否则旧产物不会重跑。
|
|
38
|
+
* v1 = 行内标签式(`规则:/触发时机:`,已废);v2 = `## 节名` 分节 + 空心标题闸。
|
|
39
|
+
*/
|
|
40
|
+
export const RESHAPE_SCHEMA_VERSION = 2;
|
|
41
|
+
/**
|
|
42
|
+
* 旧版遗留的布尔「试过」标记(**不再新写**):旧逻辑给「写回」与「留原文」都打上它,
|
|
43
|
+
* 于是「没改成的」也被永久排除。现仅作存量兼容读取(见 `isReshapedAtCurrentVersion`)。
|
|
44
|
+
*/
|
|
45
|
+
const RESHAPE_TRIED_KEY = 'dsh_reshape_tried';
|
|
46
|
+
/** 分节标题判定(与 `hasHollowHeading` 同口径:`#`/`##` 后必须有非空标题字)。 */
|
|
47
|
+
const HEADING_RE = /^\s*#{1,6}\s+\S/;
|
|
48
|
+
const state = {
|
|
49
|
+
running: false, updated: 0, skipped: 0, failed: 0,
|
|
50
|
+
chunk: 1, candidates: 0, processed: 0,
|
|
51
|
+
scanning: false, batchesDone: 0, batchTotal: 0, concurrency: DEFAULT_CONCURRENCY, deferred: 0,
|
|
52
|
+
cancelled: false, scanned: 0, scanTotal: 0,
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* 人工中止信号。置位后:①池子不再派新活;②已发出的请求先拿**宽限**(cancelGraceMs)
|
|
56
|
+
* 自然收工(token 不白费、已写回的全入账);③**宽限到期强杀**在飞请求——
|
|
57
|
+
* 没有③的话「中止」最坏要等满 callTimeoutMs(240s) 才生效,等于没中止。
|
|
58
|
+
*/
|
|
59
|
+
let abortRequested = false;
|
|
60
|
+
/** 本轮在飞请求的强杀器(runOnce 入口新建)。 */
|
|
61
|
+
let runAbort = null;
|
|
62
|
+
/** 中止宽限计时器(一次性)。 */
|
|
63
|
+
let graceTimer = null;
|
|
64
|
+
/** 本轮生效的中止宽限(启动时从 cfg 读定,已钳位)。 */
|
|
65
|
+
let graceMs = 15_000;
|
|
66
|
+
/**
|
|
67
|
+
* 中止宽限钳位。**硬界 30s**(用户定:中止必须在 30s 内强停)——无论 YAML 写了多少,
|
|
68
|
+
* 都不会超过这个值;未配置/非法取默认 15s。
|
|
69
|
+
*/
|
|
70
|
+
function clampGraceMs(raw) {
|
|
71
|
+
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : 15_000;
|
|
72
|
+
return Math.min(30_000, Math.max(0, n));
|
|
73
|
+
}
|
|
74
|
+
/** 请求中止本轮(空闲时无副作用);返回后中止是**有界**的:最多等 graceMs(≤60s)。 */
|
|
75
|
+
export function cancelRedistillBackfill() {
|
|
76
|
+
if (!state.running)
|
|
77
|
+
return { cancelled: false, running: false };
|
|
78
|
+
abortRequested = true;
|
|
79
|
+
if (graceTimer === null) {
|
|
80
|
+
graceTimer = setTimeout(() => {
|
|
81
|
+
graceTimer = null;
|
|
82
|
+
if (state.running)
|
|
83
|
+
runAbort?.abort();
|
|
84
|
+
}, Math.max(0, graceMs));
|
|
85
|
+
}
|
|
86
|
+
return { cancelled: true, running: true };
|
|
87
|
+
}
|
|
88
|
+
export function redistillBackfillState() {
|
|
89
|
+
return { ...state };
|
|
90
|
+
}
|
|
91
|
+
// ── SYSTEM 提示词(定稿版,由 tools/redistill.prompt.md 的 ```text 块迁移) ──
|
|
92
|
+
const SYSTEM_PROMPT = [
|
|
93
|
+
'你是记忆库的存量记忆整形器。给定**一条**已有记忆(含类型、标题、当前正文),为它做两件事:',
|
|
94
|
+
'1) 把正文按该记忆的类型重排成**带 markdown 小标题的分节陈述**;2) 提炼 keywords 与 usage_scenario。',
|
|
95
|
+
'',
|
|
96
|
+
'【分节格式硬要求】每个要素写成一个 `## 节名` 小节,节名**逐字**用在下面列出的类型要素名(如 `## 规则`、`## 触发时机`),节名后换行写正文:',
|
|
97
|
+
'- 判定器按这些要素词认骨架,自拟同义词会被判缺。',
|
|
98
|
+
'- **绝不允许空节**:`## 节名` 下面必须有实质正文;信息不足以填满某节时**整节省略该标题**(连标题一起不写),绝不编造。',
|
|
99
|
+
'',
|
|
100
|
+
'【内容骨架】按类型写成对应要素链(让记忆自带因果链而非零散一句):',
|
|
101
|
+
'- work_method:目标 → 步骤(每步带为什么) → 前置/注意',
|
|
102
|
+
'- work_fact:结论/事实 → 依据(谁在何时确认) → 适用范围/失效条件',
|
|
103
|
+
'- work_task:任务 → 当前状态 → 下一步/阻塞(负责人·截止)',
|
|
104
|
+
'- work_artifact:交付物 → 位置(路径/链接) → 用途/结论',
|
|
105
|
+
'- instruction:规则 → 触发时机 → 反例/边界',
|
|
106
|
+
'- episodic:事件 → 时间/参与者 → 结果',
|
|
107
|
+
'- persona:偏好/习惯 → 表现/例子 → 强度',
|
|
108
|
+
'正文整形硬约束:',
|
|
109
|
+
'- 只重排、不新增事实、不删既有事实——原文里的具体标识(文件名/函数/配置项/命令/错误码/版本号/数字阈值/日期/路径)必须原样保留。',
|
|
110
|
+
'- 信息不足以填满某节时省略该节,绝不编造;保持精炼、不堆砌、不写空话套话。',
|
|
111
|
+
'- 用与原文相同的语言;技术标识符、API 名、错误码原样保留。',
|
|
112
|
+
'- 若原文已具备该类型骨架、无需整形,就把 content 原样返回(不要为改而改)。',
|
|
113
|
+
'',
|
|
114
|
+
'【keywords】唯一判据:用户未来会输入什么词把这条记忆捞出来。优先具体名词性标识(项目/模块/文件/函数/配置项/命令/错误码/版本号/数字阈值/工具名/专有实体名)。禁止把正文里的动宾短语压缩成抽象黑话当关键词。2–5 个(宁少勿滥);每个 2–6 词或 1 个复合术语;不得与标题字面重复、不得互相近义堆叠。',
|
|
115
|
+
'【usage_scenario】1–3 条;每条 5–10 词的短场景描述(不是完整命令句);彼此必须可区分,相似的合并成更泛化的一条。',
|
|
116
|
+
'',
|
|
117
|
+
'输出:只输出**一个 JSON 对象**(不是数组),不要多余文字、不要解释。形如:',
|
|
118
|
+
'{"content":"<整形后正文>","keywords":["…"],"usage_scenario":["…"]}',
|
|
119
|
+
].join('\n');
|
|
120
|
+
/**
|
|
121
|
+
* 空心标题检测:某个 `## 节名` 到下一标题之间没有实质文字。
|
|
122
|
+
*
|
|
123
|
+
* 为什么需要它:判定器(`scoreSkeleton`)按子串命中认骨架,而标题词本身就是那个子串——
|
|
124
|
+
* 只写 `## 触发时机` 不写内容就能白得一个命中。不堵这个闸,“加分才写回”会退化成
|
|
125
|
+
* “加空标题就写回”,正文反而被掏空了。
|
|
126
|
+
*/
|
|
127
|
+
export function hasHollowHeading(content) {
|
|
128
|
+
const lines = content.split('\n');
|
|
129
|
+
const isHeading = (s) => /^\s*#{1,6}\s+\S/.test(s);
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
if (!isHeading(lines[i]))
|
|
132
|
+
continue;
|
|
133
|
+
let body = '';
|
|
134
|
+
for (let j = i + 1; j < lines.length && !isHeading(lines[j]); j++)
|
|
135
|
+
body += lines[j];
|
|
136
|
+
// 只剩空白/标点/项目符的节 = 空节
|
|
137
|
+
if (body.replace(/[\s\-–—*·::、.,;]/g, '') === '')
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
/** 质量闸:整形后骨架命中数严格上升(或旧不齐备且新齐备)且正文非空、无空心标题 → 判可写。 */
|
|
143
|
+
export function reshapePassesGate(type, oldContent, newContent) {
|
|
144
|
+
const body = typeof newContent === 'string' ? newContent.trim() : '';
|
|
145
|
+
if (body.length === 0)
|
|
146
|
+
return false;
|
|
147
|
+
if (hasHollowHeading(body))
|
|
148
|
+
return false;
|
|
149
|
+
const o = scoreSkeleton(type, oldContent || '');
|
|
150
|
+
const n = scoreSkeleton(type, body);
|
|
151
|
+
return (n.齐备 && !o.齐备) || n.命中数 > o.命中数;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* 是否已按**当前版本**整理过 → 扫描阶段排除(用户定:「成功替换的」才不再进入)。
|
|
155
|
+
*
|
|
156
|
+
* 判据:
|
|
157
|
+
* - 带当前版本戳(`dsh_reshape_ver === RESHAPE_SCHEMA_VERSION`)⇒ 已成功替换,排除;
|
|
158
|
+
* - 旧存量兼容:只有布尔 `dsh_reshape_tried`(成功与留原文都打过,无法区分)+ 正文确实是
|
|
159
|
+
* `## 分节`式 ⇒ 视为已整形排除;**不带标题的则重新入列**(它们是「试过但没改成」的,
|
|
160
|
+
* 或首轮行内标签式产物,都该重跑)。
|
|
161
|
+
*/
|
|
162
|
+
export function isReshapedAtCurrentVersion(meta, content) {
|
|
163
|
+
if (meta?.[RESHAPE_VER_KEY] === RESHAPE_SCHEMA_VERSION)
|
|
164
|
+
return true;
|
|
165
|
+
if (meta?.[RESHAPE_TRIED_KEY] === true)
|
|
166
|
+
return content.split('\n').some((l) => HEADING_RE.test(l));
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
/** 并发度钳位(与 schema 的 min1/max8 同口径,但解析路径上不能假设 schema 跑过)。 */
|
|
170
|
+
function clampConcurrency(raw) {
|
|
171
|
+
const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.round(raw) : DEFAULT_CONCURRENCY;
|
|
172
|
+
return Math.min(8, Math.max(1, n));
|
|
173
|
+
}
|
|
174
|
+
/** 启动后台重蒸馏回填;已在运行返回 false(单飞)。 */
|
|
175
|
+
export function startRedistillBackfill(deps) {
|
|
176
|
+
if (state.running)
|
|
177
|
+
return { started: false, running: true };
|
|
178
|
+
state.running = true;
|
|
179
|
+
state.updated = 0;
|
|
180
|
+
state.skipped = 0;
|
|
181
|
+
state.failed = 0;
|
|
182
|
+
state.candidates = 0;
|
|
183
|
+
state.processed = 0;
|
|
184
|
+
state.scanning = true;
|
|
185
|
+
state.batchesDone = 0;
|
|
186
|
+
state.batchTotal = 0;
|
|
187
|
+
state.deferred = 0;
|
|
188
|
+
state.cancelled = false;
|
|
189
|
+
abortRequested = false;
|
|
190
|
+
state.concurrency = clampConcurrency(deps.cfg.backfill?.concurrency);
|
|
191
|
+
void runRedistillBackfillOnce(deps).finally(() => {
|
|
192
|
+
state.running = false;
|
|
193
|
+
state.scanning = false;
|
|
194
|
+
deps.logger.info(`[memory] redistill 回填结束:写回 ${state.updated} 条,留原文 ${state.skipped} 条,失败 ${state.failed} 条`
|
|
195
|
+
+ (state.deferred > 0 ? `,留待下次 ${state.deferred} 条` : ''));
|
|
196
|
+
});
|
|
197
|
+
return { started: true, running: true };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* 回填主体(导出供单测 await 到完成;生产走 `startRedistillBackfill` 的单飞后台包装)。
|
|
201
|
+
* 门控关或向量未就绪 ⇒ 入口即返回,不碰任何记录、FTS、投射。
|
|
202
|
+
*
|
|
203
|
+
* 阶段划分对齐反刍重标定(`pipeline/relabel.ts`):**机械扫描期把筛子下推到底**,
|
|
204
|
+
* LLM 期只吃真目标、逐条推进、**每条即时提交**、条间受墙钟预算约束。
|
|
205
|
+
* 形状约定(用户定):**按单元、串行、不合并**——`concurrency` 默认 1(跑完一条再发下一条),
|
|
206
|
+
* 拖高概览页滑块只是临时提速。旧形状(20 条一批)真机把单请求拖到 200s+ 并在超时线上被掐。
|
|
207
|
+
*/
|
|
208
|
+
export async function runRedistillBackfillOnce(deps) {
|
|
209
|
+
const { cfg, l1, logger } = deps;
|
|
210
|
+
// 新一轮入口清中止信号:abortRequested 是模块级,直调 runOnce 的路径(单测/未来调用方)
|
|
211
|
+
// 不经过 startX 的复位,上一轮的中止信号会粘住这一轮。
|
|
212
|
+
abortRequested = false;
|
|
213
|
+
state.cancelled = false;
|
|
214
|
+
if (graceTimer !== null) {
|
|
215
|
+
clearTimeout(graceTimer);
|
|
216
|
+
graceTimer = null;
|
|
217
|
+
}
|
|
218
|
+
runAbort = new AbortController();
|
|
219
|
+
if (!cfg.redistill?.enabled) {
|
|
220
|
+
logger.info('[memory] redistill 回填跳过:redistill.enabled = false(功能关,不整形)');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (!l1.vectorsReady()) {
|
|
224
|
+
logger.info('[memory] redistill 回填跳过:向量未就绪(改正文会静默丢向量,等嵌入就绪后再触发)');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const MAX_RECORDS = cfg.backfill?.maxRecords ?? DEFAULT_MAX_RECORDS;
|
|
228
|
+
const TIME_BUDGET_MS = cfg.backfill?.timeBudgetMs ?? DEFAULT_TIME_BUDGET_MS;
|
|
229
|
+
graceMs = clampGraceMs(cfg.backfill?.cancelGraceMs);
|
|
230
|
+
const LIMIT = clampConcurrency(cfg.backfill?.concurrency);
|
|
231
|
+
state.concurrency = LIMIT;
|
|
232
|
+
const startedAt = Date.now();
|
|
233
|
+
// ── 扫描阶段(分页 + 每页让位,**不设墙钟**:轻量 SQL 扫描不是 LLM 调用的对手)──
|
|
234
|
+
// 退场判据在 SQL 里(`retiredL1Sql`),骨架判据在这里——两关都过才进真目标集。
|
|
235
|
+
// 旧实现只按“未打 tried 标记”收候选(真机 1221 条),到 hydrate 后才筛,结果
|
|
236
|
+
// 每批 20 条里只有 ~4 条是真目标——现在反过来:只送要整形的。
|
|
237
|
+
const targets = [];
|
|
238
|
+
let scanned = 0;
|
|
239
|
+
state.scanning = true;
|
|
240
|
+
for (let offset = 0;; offset += PAGE) {
|
|
241
|
+
if (abortRequested)
|
|
242
|
+
break; // 中止:扫描段也当场退出(不把最后一页白扫完)
|
|
243
|
+
const page = l1.activeForSkeleton(PAGE, offset);
|
|
244
|
+
if (page.length === 0)
|
|
245
|
+
break;
|
|
246
|
+
scanned += page.length;
|
|
247
|
+
for (const r of page) {
|
|
248
|
+
if (isReshapedAtCurrentVersion(r.metadata, r.content))
|
|
249
|
+
continue;
|
|
250
|
+
if (r.content.trim() === '')
|
|
251
|
+
continue;
|
|
252
|
+
if (scoreSkeleton(r.type, r.content).齐备)
|
|
253
|
+
continue;
|
|
254
|
+
targets.push(r.id);
|
|
255
|
+
}
|
|
256
|
+
state.candidates = targets.length;
|
|
257
|
+
state.scanned = scanned;
|
|
258
|
+
await yieldLoop();
|
|
259
|
+
}
|
|
260
|
+
state.scanning = false;
|
|
261
|
+
logger.info(`[memory] redistill 回填启动:活跃 ${scanned} 条中真目标 ${targets.length} 条,本次上限 ${MAX_RECORDS} 条,并行度 ${LIMIT}`);
|
|
262
|
+
const batch = targets.slice(0, MAX_RECORDS);
|
|
263
|
+
state.candidates = batch.length;
|
|
264
|
+
state.batchTotal = batch.length;
|
|
265
|
+
state.deferred = Math.max(0, targets.length - batch.length);
|
|
266
|
+
const callTimeoutMs = cfg.backfill?.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
267
|
+
/** 一条一个请求:取一条 → 送整形 → 质量闸 → **当场提交**(不攒批、不合并)。 */
|
|
268
|
+
const reshapeAndCommit = async (id) => {
|
|
269
|
+
const [rec] = l1.getByIds([id]);
|
|
270
|
+
if (!rec || !rec.content || rec.content.trim() === '') {
|
|
271
|
+
state.processed++;
|
|
272
|
+
state.batchesDone++;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// 扫描与执行之间可能已被反刍/手动路径改好,再判一次齐备就跳过(不白烧 token)。
|
|
276
|
+
if (scoreSkeleton(rec.type, rec.content).齐备) {
|
|
277
|
+
state.processed++;
|
|
278
|
+
state.batchesDone++;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const item = await reshapeOne(deps.ctx, cfg, logger, rec, callTimeoutMs, runAbort?.signal);
|
|
282
|
+
if (!item) {
|
|
283
|
+
// 中止强杀导致的失败不计入 failed(不是模型/网络的错,是中止信号)
|
|
284
|
+
if (!abortRequested)
|
|
285
|
+
state.failed++;
|
|
286
|
+
}
|
|
287
|
+
else if (reshapePassesGate(rec.type, rec.content, item.content)) {
|
|
288
|
+
const meta = { ...(rec.metadata ?? {}) };
|
|
289
|
+
if (item.keywords.length > 0)
|
|
290
|
+
meta[KEYWORDS_METADATA_KEY] = item.keywords;
|
|
291
|
+
if (item.usageScenario.length > 0)
|
|
292
|
+
meta.dsh_usage_scenario = item.usageScenario;
|
|
293
|
+
// 成功替换 ⇒ 盖当前版本戳(下轮不再入列);不再写旧的 tried 布尔。
|
|
294
|
+
meta[RESHAPE_VER_KEY] = RESHAPE_SCHEMA_VERSION;
|
|
295
|
+
const updated = {
|
|
296
|
+
...rec,
|
|
297
|
+
content: normalizeContentMd(item.content),
|
|
298
|
+
metadata: meta,
|
|
299
|
+
updatedAt: Date.now(),
|
|
300
|
+
version: (rec.version ?? 0) + 1,
|
|
301
|
+
};
|
|
302
|
+
try {
|
|
303
|
+
await l1.appendNew([updated]); // JSONL 追加 + upsertL1(重嵌+FTS) + fireFileSync 重导出
|
|
304
|
+
state.updated++;
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
state.failed++;
|
|
308
|
+
logger.warn(`[memory] redistill 写回失败(原记录未改): id=${rec.id} - ${err instanceof Error ? err.message : String(err)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
// 质量闸不过:留原文,**不动任何标记**(「试过就排除」已废——用户定:只有成功替换的才不再进入)。
|
|
313
|
+
// 于是下轮它仍会入列、可再试;重试次数上界由 maxRecords 与人工触发控制,不会自转。
|
|
314
|
+
state.skipped++;
|
|
315
|
+
}
|
|
316
|
+
state.processed++;
|
|
317
|
+
state.batchesDone++;
|
|
318
|
+
await yieldLoop();
|
|
319
|
+
};
|
|
320
|
+
/**
|
|
321
|
+
* 弹性工作池:每完成一条就重读一次**生效并发**(拖概览页滑块即时生效,不必重开一轮)。
|
|
322
|
+
* - 拖高:外层循环立刻补 worker(不必等当前条跑完);
|
|
323
|
+
* - 拖低:多出的 worker 各自干完手头那条后自然收工(不挖断已烧 token 的请求)。
|
|
324
|
+
* 缺省 getter 时等同启动值(`cfg.backfill.concurrency`,默认 1 = 串行)。
|
|
325
|
+
* 预算只在**条前**检查:中途挖断会让已烧 token 的那条无成果,不如让它提交。
|
|
326
|
+
*/
|
|
327
|
+
const readLimit = () => {
|
|
328
|
+
const raw = deps.concurrency ? deps.concurrency() : undefined;
|
|
329
|
+
return clampConcurrency(raw ?? cfg.backfill?.concurrency);
|
|
330
|
+
};
|
|
331
|
+
let cursor = 0;
|
|
332
|
+
let budgetHit = false;
|
|
333
|
+
const work = async () => {
|
|
334
|
+
for (;;) {
|
|
335
|
+
if (abortRequested)
|
|
336
|
+
return; // 中止:不再取新活(在飞的那条自然跑完)
|
|
337
|
+
if (budgetHit || Date.now() - startedAt > TIME_BUDGET_MS) {
|
|
338
|
+
budgetHit = true;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const i = cursor++;
|
|
342
|
+
if (i >= batch.length)
|
|
343
|
+
return;
|
|
344
|
+
await reshapeAndCommit(batch[i]);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const inFlight = new Set();
|
|
348
|
+
const spawn = () => {
|
|
349
|
+
const p = work().finally(() => inFlight.delete(p));
|
|
350
|
+
inFlight.add(p);
|
|
351
|
+
};
|
|
352
|
+
for (;;) {
|
|
353
|
+
const target = Math.max(1, Math.min(readLimit(), batch.length));
|
|
354
|
+
state.concurrency = target; // 面板「并行 N」跟着实时值走,不再停在启动值上骗人
|
|
355
|
+
while (!budgetHit && !abortRequested && inFlight.size < target && cursor < batch.length)
|
|
356
|
+
spawn();
|
|
357
|
+
if (inFlight.size === 0)
|
|
358
|
+
break;
|
|
359
|
+
await Promise.race(inFlight);
|
|
360
|
+
}
|
|
361
|
+
if (abortRequested) {
|
|
362
|
+
state.cancelled = true;
|
|
363
|
+
state.deferred += Math.max(0, batch.length - state.processed);
|
|
364
|
+
logger.info(`[memory] redistill 回填:已按请求中止(在飞请求跑完即收工),本轮完成 ${state.processed} 条,剩 ${state.deferred} 条留待再触发`);
|
|
365
|
+
}
|
|
366
|
+
if (budgetHit) {
|
|
367
|
+
state.deferred += Math.max(0, batch.length - state.processed);
|
|
368
|
+
logger.info(`[memory] redistill 回填:墙钟预算(${Math.round(TIME_BUDGET_MS / 1000)}s)用尽,累计留待再触发 ${state.deferred} 条`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* 整形**一条**记忆(一个请求只装一条)。失败/输出非法返回 null(原记录不动)。
|
|
373
|
+
*
|
|
374
|
+
* 导出不带 ctx/cfg 以外的参数,供单测注入桩 LLM。旧名 `reshapeChunk`(多条一次)已废:
|
|
375
|
+
* 合并请求把爆炸半径从 1 条放大到 20 条,而实测逐条只花 2.6s,合并毫无收益。
|
|
376
|
+
*/
|
|
377
|
+
export async function reshapeOne(ctx, cfg, logger, rec, callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS,
|
|
378
|
+
/** 本轮中止信号(可选):与单次超时**取或**,宽限到期后由它当场掐断请求。 */
|
|
379
|
+
externalSignal) {
|
|
380
|
+
const user = `记忆:\n类型:${rec.type}\n标题:${rec.name ?? ''}\n当前正文:${rec.content.slice(0, 1200)}`;
|
|
381
|
+
// 每条都记耗时:本轮排查的就是“单条到底要多久”,没这个数就只能猜并发/超时该怎么配。
|
|
382
|
+
const t0 = Date.now();
|
|
383
|
+
const secs = () => Math.round((Date.now() - t0) / 1000);
|
|
384
|
+
try {
|
|
385
|
+
const raw = await callLLM(ctx, cfg, {
|
|
386
|
+
system: SYSTEM_PROMPT,
|
|
387
|
+
user,
|
|
388
|
+
maxTokens: RESHAPE_MAX_TOKENS,
|
|
389
|
+
layer: 'l1-extract',
|
|
390
|
+
// 整形是「重排 + 拎标识」的直答任务:显式关思考,否则思考模型会把预算烧在
|
|
391
|
+
// reasoning 上吐 0 字符(真机 156/162 次 `llm empty output` 皆为此)。
|
|
392
|
+
reasoningEffort: 'off',
|
|
393
|
+
logger,
|
|
394
|
+
// 自己给超时信号:沿用 cfg.llm.timeoutMs(120s) 会在回退链上被掐死;
|
|
395
|
+
// 再与「本轮中止」取或——宽限到期后由它当场掐断。
|
|
396
|
+
signal: (() => {
|
|
397
|
+
const t = AbortSignal.timeout(callTimeoutMs > 0 ? callTimeoutMs : DEFAULT_CALL_TIMEOUT_MS);
|
|
398
|
+
return externalSignal ? AbortSignal.any([t, externalSignal]) : t;
|
|
399
|
+
})(),
|
|
400
|
+
});
|
|
401
|
+
logger.info(`[memory] redistill 单条耗时 ${secs()}s(id=${rec.id})`);
|
|
402
|
+
const parsed = parseJsonLogged(raw, 'redistill 回填', logger);
|
|
403
|
+
// 通吃两种形状:模型偶尔无视“只输出一个对象”而回一个单元素数组。
|
|
404
|
+
const obj = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
405
|
+
if (!obj || typeof obj !== 'object')
|
|
406
|
+
return null;
|
|
407
|
+
const content = typeof obj.content === 'string' ? obj.content : '';
|
|
408
|
+
if (content.trim() === '')
|
|
409
|
+
return null;
|
|
410
|
+
return {
|
|
411
|
+
content,
|
|
412
|
+
keywords: normKeywords(obj.keywords, 5),
|
|
413
|
+
usageScenario: normUsageScenario(obj.usage_scenario, 3),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
logger.warn(`[memory] redistill 单条失败(id=${rec.id},已等 ${secs()}s,原记录未改): ${err instanceof Error ? err.message : String(err)}`);
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
}
|
package/dist/room-review.d.ts
CHANGED
|
@@ -83,19 +83,20 @@ export declare function parseRoomCandidateItems(parsed: ReadonlyArray<{
|
|
|
83
83
|
room: string;
|
|
84
84
|
count: number;
|
|
85
85
|
}>): RoomCandidate[];
|
|
86
|
-
/** 候选标注系统 prompt:喂现有 Room 词表(带计数)
|
|
86
|
+
/** 候选标注系统 prompt:喂现有 Room 词表(带计数),优先挂靠、少量新建兜底(逐条)。 */
|
|
87
87
|
export declare function roomCandidateSystemPrompt(roomCatalog: ReadonlyArray<{
|
|
88
88
|
room: string;
|
|
89
89
|
count: number;
|
|
90
90
|
}>): string;
|
|
91
|
-
/**
|
|
92
|
-
|
|
91
|
+
/**
|
|
92
|
+
* 候选标注器:一条孤儿记录 → 候选 Room(优先现有词表;失败返回 null,调用方零改动)。
|
|
93
|
+
*
|
|
94
|
+
* **一个请求只装一条**(旧名 `roomCandidateChunk`,多条一次,已废)。
|
|
95
|
+
*/
|
|
96
|
+
export declare function roomCandidateOne(ctx: Context, cfg: MemoryConfig, logger: MemoryLogger, rec: MemoryRecord, roomCatalog: ReadonlyArray<{
|
|
93
97
|
room: string;
|
|
94
98
|
count: number;
|
|
95
|
-
}>): Promise<
|
|
96
|
-
id: string;
|
|
97
|
-
rooms: string[];
|
|
98
|
-
}>>;
|
|
99
|
+
}>): Promise<string[] | null>;
|
|
99
100
|
export interface OrphanAnnotateIO {
|
|
100
101
|
l1: RoomReviewStore;
|
|
101
102
|
logger: MemoryLogger;
|
|
@@ -113,16 +114,14 @@ export interface OrphanAnnotateIO {
|
|
|
113
114
|
* 写前重读合并(与 relabel 同纪律);失败单条跳过、原记录零改动。
|
|
114
115
|
*/
|
|
115
116
|
export interface OrphanAnnotateOverrides {
|
|
116
|
-
/** 测试注入口:替换 LLM 候选标注器(默认走真实 callLLM 路径);第二参为合并词表。
|
|
117
|
-
|
|
117
|
+
/** 测试注入口:替换 LLM 候选标注器(默认走真实 callLLM 路径);第二参为合并词表。
|
|
118
|
+
* **逐条**形状:返回该条的候选 slug(空数组 = 无法判断),失败返回 null。 */
|
|
119
|
+
annotator?: (rec: MemoryRecord, catalog: ReadonlyArray<{
|
|
118
120
|
room: string;
|
|
119
121
|
count: number;
|
|
120
122
|
label?: string;
|
|
121
123
|
description?: string;
|
|
122
|
-
}>) => Promise<
|
|
123
|
-
id: string;
|
|
124
|
-
rooms: string[];
|
|
125
|
-
}>>;
|
|
124
|
+
}>) => Promise<string[] | null>;
|
|
126
125
|
}
|
|
127
126
|
export declare function annotateOrphanCandidates(ctx: Context, cfg: MemoryConfig, io: OrphanAnnotateIO, limit?: number, overrides?: OrphanAnnotateOverrides): Promise<{
|
|
128
127
|
selected: number;
|
package/dist/room-review.js
CHANGED
|
@@ -24,7 +24,7 @@ export function parseRoomCandidateItems(parsed, ids, roomCatalog) {
|
|
|
24
24
|
}
|
|
25
25
|
return [...byId.entries()].map(([id, rooms]) => ({ id, rooms }));
|
|
26
26
|
}
|
|
27
|
-
/** 候选标注系统 prompt:喂现有 Room 词表(带计数)
|
|
27
|
+
/** 候选标注系统 prompt:喂现有 Room 词表(带计数),优先挂靠、少量新建兜底(逐条)。 */
|
|
28
28
|
export function roomCandidateSystemPrompt(roomCatalog) {
|
|
29
29
|
const catalog = roomCatalog.length > 0
|
|
30
30
|
? roomCatalog
|
|
@@ -32,39 +32,46 @@ export function roomCandidateSystemPrompt(roomCatalog) {
|
|
|
32
32
|
.map((r) => `- ${r.room}(现有 ${r.count} 条)`)
|
|
33
33
|
.join('\n')
|
|
34
34
|
: '(暂无现有 Room)';
|
|
35
|
-
return ('你是记忆库的 Room
|
|
36
|
-
'
|
|
35
|
+
return ('你是记忆库的 Room 归类器。给你一条记忆与现有 Room 词表(带计数),' +
|
|
36
|
+
'为这条记忆选出它最可能归属的 1-3 个 Room slug。规则:\n' +
|
|
37
37
|
'1. **优先从现有 Room 词表里选**(保持分类收敛,避免碎片化);\n' +
|
|
38
38
|
'2. 词表确实没有合适主题时,才提议最多 2 个新 slug(小写字母/数字/连字符);\n' +
|
|
39
39
|
'3. 完全无法判断时返回空数组(宁缺毋滥,不硬猜)。\n' +
|
|
40
40
|
`现有 Room 词表:\n${catalog}\n\n` +
|
|
41
|
-
'输出 JSON
|
|
41
|
+
'输出 JSON 对象:{"rooms":["slug1","slug2"]}。只输出 JSON,不要多余文字。');
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
/**
|
|
44
|
+
* 候选标注器:一条孤儿记录 → 候选 Room(优先现有词表;失败返回 null,调用方零改动)。
|
|
45
|
+
*
|
|
46
|
+
* **一个请求只装一条**(旧名 `roomCandidateChunk`,多条一次,已废)。
|
|
47
|
+
*/
|
|
48
|
+
export async function roomCandidateOne(ctx, cfg, logger, rec, roomCatalog) {
|
|
49
|
+
const user = `记忆:\n${rec.content.slice(0, 900)}`;
|
|
46
50
|
try {
|
|
47
51
|
const raw = await callLLM(ctx, cfg, {
|
|
48
52
|
system: roomCandidateSystemPrompt(roomCatalog),
|
|
49
|
-
user
|
|
50
|
-
maxTokens:
|
|
53
|
+
user,
|
|
54
|
+
maxTokens: 1024,
|
|
51
55
|
layer: 'l1-extract',
|
|
56
|
+
// 归类是直答任务:显式关思考(不然思考模型把预算烧在 reasoning 上吐 0 字符)。
|
|
57
|
+
reasoningEffort: 'off',
|
|
52
58
|
logger,
|
|
53
59
|
});
|
|
54
60
|
const parsed = parseJsonLogged(raw, 'Room 候选标注', logger);
|
|
55
|
-
|
|
61
|
+
// 通吃两种形状:模型偶尔回单元素数组(或带 id 的旧形状)。
|
|
62
|
+
const obj = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
63
|
+
if (!obj || typeof obj !== 'object')
|
|
56
64
|
return [];
|
|
57
|
-
const
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
return parseRoomCandidateItems(shape, chunk.map((r) => r.id), roomCatalog);
|
|
65
|
+
const roomsRaw = Array.isArray(obj.rooms)
|
|
66
|
+
? obj.rooms
|
|
67
|
+
: [];
|
|
68
|
+
// 复用既有清洗(目录内优先排前、去重、截断 3):用单元素数组喂它(忽略模型回的自报 id)。
|
|
69
|
+
const cleaned = parseRoomCandidateItems([{ id: rec.id, rooms: roomsRaw }], [rec.id], roomCatalog);
|
|
70
|
+
return cleaned[0]?.rooms ?? [];
|
|
64
71
|
}
|
|
65
72
|
catch (err) {
|
|
66
|
-
logger.warn(`[memory] Room
|
|
67
|
-
return
|
|
73
|
+
logger.warn(`[memory] Room 候选标注单条失败(id=${rec.id},原记录未改): ${err instanceof Error ? err.message : String(err)}`);
|
|
74
|
+
return null;
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
/** 活跃孤儿的选择器:untagged + 未退场,跳过已 skipped 的。 */
|
|
@@ -101,24 +108,27 @@ export async function annotateOrphanCandidates(ctx, cfg, io, limit = 40, overrid
|
|
|
101
108
|
...regActive.map((e) => ({ room: e.slug, count: 0, label: e.label, description: e.description })),
|
|
102
109
|
...grown.filter((r) => !regSet.has(r.room)).map((r) => ({ room: r.room, count: r.count })),
|
|
103
110
|
].slice(0, 120);
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
: await roomCandidateChunk(ctx, cfg, io.logger, selected, merged);
|
|
111
|
+
const annotate = overrides.annotator ??
|
|
112
|
+
((rec) => roomCandidateOne(ctx, cfg, io.logger, rec, merged));
|
|
107
113
|
let written = 0;
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
let candidates = 0;
|
|
115
|
+
// 逐条串行:取一条 → 产候选 → 当场写 pending。不攒批、不合并。
|
|
116
|
+
for (const rec of selected) {
|
|
117
|
+
const rooms = await annotate(rec, merged);
|
|
118
|
+
if (!rooms || rooms.length === 0)
|
|
110
119
|
continue;
|
|
111
|
-
|
|
120
|
+
candidates++;
|
|
121
|
+
const fresh = io.l1.getByIds([rec.id])[0];
|
|
112
122
|
if (!fresh)
|
|
113
123
|
continue;
|
|
114
124
|
const meta = { ...(fresh.metadata ?? {}) };
|
|
115
|
-
meta[ROOM_CANDIDATES_KEY] =
|
|
125
|
+
meta[ROOM_CANDIDATES_KEY] = rooms;
|
|
116
126
|
meta[ROOM_REVIEW_KEY] = 'pending';
|
|
117
|
-
if (io.l1.patchMetadata(id, meta))
|
|
127
|
+
if (io.l1.patchMetadata(rec.id, meta))
|
|
118
128
|
written++;
|
|
119
129
|
}
|
|
120
|
-
io.logger.info(`[memory] 孤儿候选预标记:选中 ${selected.length},产候选 ${
|
|
121
|
-
return { selected: selected.length, candidates
|
|
130
|
+
io.logger.info(`[memory] 孤儿候选预标记:选中 ${selected.length},产候选 ${candidates},写 pending ${written} 条(待 agent 复查)`);
|
|
131
|
+
return { selected: selected.length, candidates, written };
|
|
122
132
|
}
|
|
123
133
|
/** 待复查队列:pending 且有候选的活跃孤儿,按更新时间升序(最旧的先复查)。 */
|
|
124
134
|
function pendingQueue(l1) {
|
package/dist/settings.js
CHANGED
|
@@ -81,13 +81,20 @@ const ALWAYS_ON = {
|
|
|
81
81
|
memoryMutate: false,
|
|
82
82
|
conflictFreeze: false,
|
|
83
83
|
skillsEnabled: false,
|
|
84
|
+
keywordEnabled: false,
|
|
85
|
+
redistillEnabled: false,
|
|
86
|
+
mdSyncEnabled: false,
|
|
87
|
+
memportEnabled: false,
|
|
84
88
|
// md 投射护栏运行时覆盖:0 = 跟随部署 cfg.memExport.maxRecords(面板上可改)
|
|
85
89
|
mdExportMaxRecords: 0,
|
|
90
|
+
// 回填并行度运行时覆盖:0 = 跟随部署 cfg.backfill.concurrency(概览页滑块可改)
|
|
91
|
+
backfillConcurrency: 0,
|
|
86
92
|
toolRoom: true,
|
|
87
93
|
toolGraph: true,
|
|
88
94
|
toolRuminate: true,
|
|
89
95
|
toolConflict: true,
|
|
90
96
|
toolMutate: true,
|
|
97
|
+
toolSkills: true,
|
|
91
98
|
};
|
|
92
99
|
/** 自带自定义设置页(settings.section 顶层「记忆」分节,client 半挂载):
|
|
93
100
|
* 关掉宿主按 volatile 字段自动生成的表单页,避免同一个插件出现两份设置入口。
|
|
@@ -317,13 +324,21 @@ function resolveSettings(value) {
|
|
|
317
324
|
conflictFreeze: v.conflictFreeze === true,
|
|
318
325
|
// 轨2 技能结晶:严格 === true(默认关,新功能默认关)
|
|
319
326
|
skillsEnabled: v.skillsEnabled === true,
|
|
327
|
+
// 存量回填/进阶功能启用开关:严格 === true(默认关)
|
|
328
|
+
keywordEnabled: v.keywordEnabled === true,
|
|
329
|
+
redistillEnabled: v.redistillEnabled === true,
|
|
330
|
+
mdSyncEnabled: v.mdSyncEnabled === true,
|
|
331
|
+
memportEnabled: v.memportEnabled === true,
|
|
320
332
|
// md 投射护栏钳 0~10000(与 live schema 同上限);0 = 跟随部署默认
|
|
321
333
|
mdExportMaxRecords: Math.min(10_000, Math.max(0, num(v.mdExportMaxRecords))),
|
|
334
|
+
// 回填并行度钳 0~8(与 live schema 同上限);0 = 跟随部署 cfg.backfill.concurrency
|
|
335
|
+
backfillConcurrency: Math.min(8, Math.max(0, Math.floor(num(v.backfillConcurrency)))),
|
|
322
336
|
// 工具分组封印:严格 !== false(缺省/异常值 = 未封印,工具照常注册)
|
|
323
337
|
toolRoom: v.toolRoom !== false,
|
|
324
338
|
toolGraph: v.toolGraph !== false,
|
|
325
339
|
toolRuminate: v.toolRuminate !== false,
|
|
326
340
|
toolConflict: v.toolConflict !== false,
|
|
327
341
|
toolMutate: v.toolMutate !== false,
|
|
342
|
+
toolSkills: v.toolSkills !== false,
|
|
328
343
|
};
|
|
329
344
|
}
|