@kenz1117/dsh-engram 0.7.1 → 0.7.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/README.en.md +58 -11
- package/README.md +52 -11
- package/lib/client.js +1 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +682 -77
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -28,7 +28,9 @@ const CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
|
28
28
|
"injectTokenBudget",
|
|
29
29
|
"rankRecencyWeight",
|
|
30
30
|
"rankProofWeight",
|
|
31
|
-
"queryRewrite"
|
|
31
|
+
"queryRewrite",
|
|
32
|
+
"autoSlot",
|
|
33
|
+
"reviewScheduling"
|
|
32
34
|
]);
|
|
33
35
|
const INGEST_MODES = /* @__PURE__ */ new Set([
|
|
34
36
|
"off",
|
|
@@ -50,7 +52,9 @@ const Config = z.object({
|
|
|
50
52
|
injectTokenBudget: z.number().step(1).min(128).max(8192),
|
|
51
53
|
rankRecencyWeight: z.number().min(0).max(2),
|
|
52
54
|
rankProofWeight: z.number().min(0).max(2),
|
|
53
|
-
queryRewrite: z.boolean()
|
|
55
|
+
queryRewrite: z.boolean(),
|
|
56
|
+
autoSlot: z.boolean(),
|
|
57
|
+
reviewScheduling: z.boolean()
|
|
54
58
|
});
|
|
55
59
|
/**
|
|
56
60
|
* 显式 resolve 步骤:默认值只在唯一的此处落地,非法值 loud 失败。
|
|
@@ -87,7 +91,9 @@ function resolveConfig(config = {}) {
|
|
|
87
91
|
injectTokenBudget: config.injectTokenBudget ?? 1024,
|
|
88
92
|
rankRecencyWeight: config.rankRecencyWeight ?? .2,
|
|
89
93
|
rankProofWeight: config.rankProofWeight ?? .1,
|
|
90
|
-
queryRewrite: config.queryRewrite ?? true
|
|
94
|
+
queryRewrite: config.queryRewrite ?? true,
|
|
95
|
+
autoSlot: config.autoSlot ?? true,
|
|
96
|
+
reviewScheduling: config.reviewScheduling ?? true
|
|
91
97
|
};
|
|
92
98
|
}
|
|
93
99
|
//#endregion
|
|
@@ -575,8 +581,11 @@ function throttleDecision(events) {
|
|
|
575
581
|
if (forbidsCapture(joined)) return "capture-forbidden";
|
|
576
582
|
return null;
|
|
577
583
|
}
|
|
578
|
-
/** 各 turn/start 事件的下标与轮次号(缺 data.turn 时轮次为 undefined)。
|
|
584
|
+
/** 各 turn/start 事件的下标与轮次号(缺 data.turn 时轮次为 undefined)。
|
|
585
|
+
* events 允许 undefined:会话 dispose 后事件源已 detach,宿主可能给不出日志,
|
|
586
|
+
* 此时按空日志处理而不是抛 TypeError(调用方在会话生命周期之外,异常会变成未处理 rejection)。 */
|
|
579
587
|
function turnStarts(events) {
|
|
588
|
+
if (events === void 0) return [];
|
|
580
589
|
const starts = [];
|
|
581
590
|
for (let i = 0; i < events.length; i++) {
|
|
582
591
|
if (events[i]?.type !== "turn/start") continue;
|
|
@@ -751,18 +760,22 @@ async function markPendingIngest(store, sessionId, turn) {
|
|
|
751
760
|
* 会话结束时的末轮摄取:切片为最后一个 turn/start 到日志末尾,复用提炼管线。
|
|
752
761
|
* 失败/超时只告警并把 (sessionId, turn) pending 键写入 op_log(下次会话首次
|
|
753
762
|
* pre-step 重放补做),绝不影响对话。
|
|
763
|
+
* 本函数不 reject:从取轮次到摄取全程在 try 内,异常一律降级为告警 + pending 键
|
|
764
|
+
* (dispose 观察器是 fire-and-forget,逃逸的 rejection 会被宿主的 fail-loud 当作致命错误)。
|
|
754
765
|
* @returns 摄取结果;无末轮或失败(已落 pending)时返回 null。
|
|
755
766
|
*/
|
|
756
767
|
async function ingestFinalTurn(deps) {
|
|
757
|
-
|
|
758
|
-
|
|
768
|
+
/** 末轮轮次号;取不到(无 turn/start 或事件源不可用)时不落 pending——键需要轮次。 */
|
|
769
|
+
let round;
|
|
759
770
|
try {
|
|
771
|
+
round = lastTurnNumber(deps.events);
|
|
772
|
+
if (round === void 0) return null;
|
|
760
773
|
return await ingestPreviousTurn({
|
|
761
774
|
...deps,
|
|
762
775
|
slice: "last"
|
|
763
776
|
});
|
|
764
777
|
} catch (error) {
|
|
765
|
-
try {
|
|
778
|
+
if (round !== void 0) try {
|
|
766
779
|
await markPendingIngest(await deps.openStore(), deps.sessionId, round);
|
|
767
780
|
} catch {}
|
|
768
781
|
console.warn("[dsh-engram] 会话结束的末轮摄取失败(已记入待补做队列,不影响对话):", error);
|
|
@@ -1515,6 +1528,19 @@ function gatherRefurbSuggestions(records, options = DEFAULT_REFURB_OPTIONS) {
|
|
|
1515
1528
|
reason: `内容长度 ${record.content.length} > 400,建议拆为多条独立房间。`,
|
|
1516
1529
|
confidence: .6
|
|
1517
1530
|
});
|
|
1531
|
+
for (const record of active) {
|
|
1532
|
+
const score = record.imageryScore;
|
|
1533
|
+
if (score !== void 0 && score >= .5) continue;
|
|
1534
|
+
const slot = record.slot === void 0 ? "" : `${record.slot.room}#${record.slot.index} `;
|
|
1535
|
+
suggestions.push({
|
|
1536
|
+
action: "review",
|
|
1537
|
+
primaryId: record.id,
|
|
1538
|
+
candidates: [],
|
|
1539
|
+
scope: record.scope,
|
|
1540
|
+
reason: score === void 0 ? `${slot}未挂门牌:宫殿纪律要求每个标记唯一、差异化、带日期,建议用 engram_update 的 placard 参数补挂。` : `${slot}门牌得分 ${score.toFixed(2)} < 0.5(不合唯一/差异化/带日期纪律),建议重写铭牌。`,
|
|
1541
|
+
confidence: .4
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1518
1544
|
return suggestions;
|
|
1519
1545
|
}
|
|
1520
1546
|
//#endregion
|
|
@@ -1625,6 +1651,7 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
1625
1651
|
...kind !== null && kind !== "" && kind !== "all" ? { kind } : {},
|
|
1626
1652
|
...q !== null && q !== "" ? { q } : {},
|
|
1627
1653
|
...redacted === "true" || redacted === "false" ? { redacted: redacted === "true" } : {},
|
|
1654
|
+
...url.searchParams.get("sort") === "tour" ? { sort: "tour" } : {},
|
|
1628
1655
|
limit,
|
|
1629
1656
|
offset
|
|
1630
1657
|
};
|
|
@@ -1679,6 +1706,48 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
1679
1706
|
}))).flat().sort((a, b) => b.at - a.at).slice(0, limit) });
|
|
1680
1707
|
return;
|
|
1681
1708
|
}
|
|
1709
|
+
if (req.method === "GET" && route === "review-due") {
|
|
1710
|
+
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
1711
|
+
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
|
|
1712
|
+
const now = Date.now();
|
|
1713
|
+
json(res, 200, {
|
|
1714
|
+
scope,
|
|
1715
|
+
items: (await (await deps.openStore(scope)).dueReviews(now, limit)).map((record) => ({
|
|
1716
|
+
id: record.id,
|
|
1717
|
+
kind: record.kind,
|
|
1718
|
+
...record.slot === void 0 ? {} : { slot: record.slot },
|
|
1719
|
+
caption: record.imagery?.caption ?? null,
|
|
1720
|
+
nextReviewAt: record.review?.nextReviewAt ?? null,
|
|
1721
|
+
overdueDays: record.review?.nextReviewAt === null || record.review?.nextReviewAt === void 0 ? 0 : Math.max(0, Math.floor((now - record.review.nextReviewAt) / 864e5)),
|
|
1722
|
+
reps: record.review?.reps ?? 0
|
|
1723
|
+
}))
|
|
1724
|
+
});
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
if (req.method === "POST" && route === "review-answer") {
|
|
1728
|
+
if (!guardWrite(req, res)) return;
|
|
1729
|
+
const body = await readJsonBody(req);
|
|
1730
|
+
if (body === null || typeof body.id !== "string" || body.id === "") {
|
|
1731
|
+
json(res, 400, { error: "id required" });
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
const grade = typeof body.grade === "number" && Number.isInteger(body.grade) && body.grade >= 0 && body.grade <= 5 ? body.grade : null;
|
|
1735
|
+
if (grade === null) {
|
|
1736
|
+
json(res, 400, { error: "grade must be an integer 0-5" });
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
|
|
1740
|
+
const record = await (await deps.openStore(scope)).scheduleReview(body.id, grade);
|
|
1741
|
+
if (record === void 0) {
|
|
1742
|
+
json(res, 404, { error: `未找到条目 ${body.id}` });
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
json(res, 200, {
|
|
1746
|
+
id: record.id,
|
|
1747
|
+
review: record.review ?? null
|
|
1748
|
+
});
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1682
1751
|
if (req.method === "GET" && route === "review") {
|
|
1683
1752
|
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
1684
1753
|
const id = url.searchParams.get("id");
|
|
@@ -1694,6 +1763,53 @@ function registerEngramRoutes(ctx, deps) {
|
|
|
1694
1763
|
json(res, 200, view);
|
|
1695
1764
|
return;
|
|
1696
1765
|
}
|
|
1766
|
+
if (req.method === "GET" && route === "review-due") {
|
|
1767
|
+
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
1768
|
+
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit") ?? 20) || 20));
|
|
1769
|
+
const now = Date.now();
|
|
1770
|
+
const due = await (await deps.openStore(scope)).dueReviews(now, limit);
|
|
1771
|
+
json(res, 200, {
|
|
1772
|
+
scope,
|
|
1773
|
+
count: due.length,
|
|
1774
|
+
now,
|
|
1775
|
+
items: due.map((record) => ({
|
|
1776
|
+
id: record.id,
|
|
1777
|
+
kind: record.kind,
|
|
1778
|
+
scope: record.scope,
|
|
1779
|
+
importance: record.importance,
|
|
1780
|
+
confidence: record.confidence,
|
|
1781
|
+
...record.slot === void 0 ? {} : { slot: record.slot },
|
|
1782
|
+
...typeof record.imagery?.caption === "string" ? { caption: record.imagery.caption } : {},
|
|
1783
|
+
...record.review?.nextReviewAt === null || record.review?.nextReviewAt === void 0 ? {} : { overdueDays: Math.max(0, Math.floor((now - record.review.nextReviewAt) / 864e5)) },
|
|
1784
|
+
...record.review === void 0 ? {} : {
|
|
1785
|
+
reps: record.review.reps,
|
|
1786
|
+
intervalDays: record.review.intervalDays
|
|
1787
|
+
}
|
|
1788
|
+
}))
|
|
1789
|
+
});
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
if (req.method === "POST" && route === "review-answer") {
|
|
1793
|
+
if (!guardWrite(req, res)) return;
|
|
1794
|
+
const body = await readJsonBody(req);
|
|
1795
|
+
if (body === null || typeof body.id !== "string" || body.id === "") {
|
|
1796
|
+
json(res, 400, { error: "id required" });
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
const grade = Number(body.grade);
|
|
1800
|
+
if (!Number.isInteger(grade) || grade < 0 || grade > 5) {
|
|
1801
|
+
json(res, 400, { error: "grade must be an integer 0-5" });
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
const scope = scopeOf$1(typeof body.scope === "string" ? body.scope : null, "user");
|
|
1805
|
+
const record = await (await deps.openStore(scope)).scheduleReview(body.id, grade);
|
|
1806
|
+
if (record === void 0) {
|
|
1807
|
+
json(res, 404, { error: `未找到条目 ${body.id}` });
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
json(res, 200, { record });
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1697
1813
|
if (req.method === "GET" && route === "export") {
|
|
1698
1814
|
const scope = scopeOf$1(url.searchParams.get("scope"), "user");
|
|
1699
1815
|
const format = url.searchParams.get("format") === "json" ? "json" : "markdown";
|
|
@@ -2064,6 +2180,106 @@ function migrateProjectDb(dbDir, identity) {
|
|
|
2064
2180
|
}
|
|
2065
2181
|
return "none";
|
|
2066
2182
|
}
|
|
2183
|
+
/** kind → 默认房间名。固定映射,保证同主题记忆总是聚在同一间(同类巩固)。 */
|
|
2184
|
+
const KIND_ROOMS = {
|
|
2185
|
+
fact: "事实厅",
|
|
2186
|
+
preference: "偏好阁",
|
|
2187
|
+
decision: "决策堂",
|
|
2188
|
+
episode: "往事廊",
|
|
2189
|
+
skill: "技法坊"
|
|
2190
|
+
};
|
|
2191
|
+
/**
|
|
2192
|
+
* 为新条目分配桩位。
|
|
2193
|
+
* @param kind - 记忆种类(决定默认房间)。
|
|
2194
|
+
* @param occupancy - 各房间占用状态(store.slotCountsByRoom() 的快照)。
|
|
2195
|
+
* @returns 分配的桩位与是否开了新房(开新房时调用方应记 op_log 提醒人工命名/拆分)。
|
|
2196
|
+
*/
|
|
2197
|
+
function assignSlot(kind, occupancy) {
|
|
2198
|
+
const base = KIND_ROOMS[kind];
|
|
2199
|
+
for (let n = 1;; n += 1) {
|
|
2200
|
+
const room = n === 1 ? base : `${base}-${n}`;
|
|
2201
|
+
const state = occupancy[room];
|
|
2202
|
+
if (state === void 0) return {
|
|
2203
|
+
slot: {
|
|
2204
|
+
room,
|
|
2205
|
+
index: 1
|
|
2206
|
+
},
|
|
2207
|
+
openedNewRoom: n > 1
|
|
2208
|
+
};
|
|
2209
|
+
if (state.count < 9) return {
|
|
2210
|
+
slot: {
|
|
2211
|
+
room,
|
|
2212
|
+
index: state.maxIndex + 1
|
|
2213
|
+
},
|
|
2214
|
+
openedNewRoom: false
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
//#endregion
|
|
2219
|
+
//#region src/review/sm2.ts
|
|
2220
|
+
/** ease 系数下限(SM-2 标准值):低于此值记忆会陷入过密复习。 */
|
|
2221
|
+
const MIN_EASE = 1.3;
|
|
2222
|
+
const DAY_MS = 864e5;
|
|
2223
|
+
/**
|
|
2224
|
+
* 按回忆质量推进调度。
|
|
2225
|
+
* @param grade - 回忆质量 0-5(0/1 完全遗忘,2 模糊错误,3 勉强,4 正确有迟疑,5 完美)。
|
|
2226
|
+
* @param state - 当前调度状态。
|
|
2227
|
+
* @param now - 答题时刻(epoch 毫秒)。
|
|
2228
|
+
* @returns 新调度状态(不修改入参)。
|
|
2229
|
+
*/
|
|
2230
|
+
function nextSchedule(grade, state, now) {
|
|
2231
|
+
if (grade < 3) {
|
|
2232
|
+
const ease = Math.max(MIN_EASE, state.easeFactor + (.1 - (5 - grade) * (.08 + (5 - grade) * .02)));
|
|
2233
|
+
return {
|
|
2234
|
+
nextReviewAt: now + 1 * DAY_MS,
|
|
2235
|
+
easeFactor: Math.round(ease * 100) / 100,
|
|
2236
|
+
intervalDays: 1,
|
|
2237
|
+
reps: 0
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
const reps = state.reps + 1;
|
|
2241
|
+
const intervalDays = reps === 1 ? 1 : reps === 2 ? 6 : Math.max(1, Math.round(state.intervalDays * state.easeFactor));
|
|
2242
|
+
const ease = Math.max(MIN_EASE, state.easeFactor + (.1 - (5 - grade) * (.08 + (5 - grade) * .02)));
|
|
2243
|
+
return {
|
|
2244
|
+
nextReviewAt: now + intervalDays * DAY_MS,
|
|
2245
|
+
easeFactor: Math.round(ease * 100) / 100,
|
|
2246
|
+
intervalDays,
|
|
2247
|
+
reps
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
/** 日期/时间锚点:ISO 日期、中文年月、相对时间词。 */
|
|
2251
|
+
const DATE_ANCHOR = /\d{4}[-/年.]\s?\d{1,2}|[今昨]天|本周|上周|周[一二三四五六日天]|\d{1,2}月\d{1,2}[日号]/;
|
|
2252
|
+
/** 差异化比较的前缀长度:前 6 字相同即视为「近似到无法区分」。 */
|
|
2253
|
+
const DIFF_PREFIX_LEN = 6;
|
|
2254
|
+
/**
|
|
2255
|
+
* 计算门牌质量分(0-1,保留两位小数)。
|
|
2256
|
+
* 构成:caption 有效(4-30 字)且全库唯一 +0.4;带日期/时间锚点 +0.3;
|
|
2257
|
+
* 与同房既有门牌差异化(前 6 字不重复)+0.3。无 caption 记 0 分。
|
|
2258
|
+
* @param caption - 门牌文字(ImageryLabel.caption);null/undefined 表示未挂牌。
|
|
2259
|
+
* @param context - 既有门牌快照。
|
|
2260
|
+
* @returns 0-1 的质量分。
|
|
2261
|
+
*/
|
|
2262
|
+
function scorePlacard(caption, context) {
|
|
2263
|
+
if (caption === null || caption === void 0) return 0;
|
|
2264
|
+
const text = caption.trim();
|
|
2265
|
+
let score = 0;
|
|
2266
|
+
const valid = text.length >= 4 && text.length <= 30;
|
|
2267
|
+
if (valid && !context.existingCaptions.includes(text)) score += .4;
|
|
2268
|
+
if (DATE_ANCHOR.test(text)) score += .3;
|
|
2269
|
+
const prefix = text.slice(0, DIFF_PREFIX_LEN);
|
|
2270
|
+
const clashes = context.roomCaptions.some((other) => other.slice(0, DIFF_PREFIX_LEN) === prefix);
|
|
2271
|
+
if (valid && !clashes) score += .3;
|
|
2272
|
+
return Math.round(Math.min(1, score) * 100) / 100;
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* 低分门牌的增强建议(附在 engram_save 输出末尾,中文一行)。
|
|
2276
|
+
* @param score - scorePlacard 的得分。
|
|
2277
|
+
* @returns 建议文本;非低分返回 null。
|
|
2278
|
+
*/
|
|
2279
|
+
function placardImprovementHint(score) {
|
|
2280
|
+
if (score >= .5) return null;
|
|
2281
|
+
return "门牌不合规(宫殿纪律:唯一 · 差异化 · 带日期):建议用 engram_update 的 imagery 参数挂一个 4-30 字、含日期锚点、与同房其他门牌前 6 字不重复的铭牌。";
|
|
2282
|
+
}
|
|
2067
2283
|
//#endregion
|
|
2068
2284
|
//#region src/store/sqlite.ts
|
|
2069
2285
|
/**
|
|
@@ -2074,18 +2290,31 @@ function migrateProjectDb(dbDir, identity) {
|
|
|
2074
2290
|
* @module @kenz1117/dsh-engram/store/sqlite
|
|
2075
2291
|
*/
|
|
2076
2292
|
/** 当前 schema 版本;结构性变更必须 +1。可空列与伴随表走增量迁移(见 openEngramStore 的迁移段)。 */
|
|
2077
|
-
const SCHEMA_VERSION =
|
|
2293
|
+
const SCHEMA_VERSION = 6;
|
|
2078
2294
|
/** 增量迁移表:key 为起始版本,value 为升到下一版本的 SQL(可多语句)。
|
|
2079
2295
|
* v2 → v3:nodes 补可空列 outcome(使用效果回报)。
|
|
2080
2296
|
* v3 → v4:新增 nodes_revisions 修订表(update 归档旧条目时的内容快照)。
|
|
2081
2297
|
* v4 → v5:nodes 补 imagery_json 列(意象铭牌:caption + sensoryTags + emotionalValence + provisional)。
|
|
2082
|
-
* v5
|
|
2298
|
+
* v5 → v6:桩位(slot_room/slot_index)、意象质量分(imagery_score)、SM-2 调度
|
|
2299
|
+
* (next_review_at/ease_factor/interval_days/review_reps)+ 固定巡游路线表 tour_routes。
|
|
2300
|
+
* 全部可空或带默认值,存量条目零搬运;排桩由 backfillSlots 幂等补齐。 */
|
|
2083
2301
|
const MIGRATIONS = {
|
|
2084
2302
|
"2": "ALTER TABLE nodes ADD COLUMN outcome TEXT",
|
|
2085
2303
|
"3": `CREATE TABLE IF NOT EXISTS nodes_revisions (
|
|
2086
2304
|
node_id TEXT NOT NULL, content TEXT NOT NULL, kind TEXT NOT NULL,
|
|
2087
2305
|
importance REAL NOT NULL, superseded_at INTEGER NOT NULL);`,
|
|
2088
|
-
"4": "ALTER TABLE nodes ADD COLUMN imagery_json TEXT"
|
|
2306
|
+
"4": "ALTER TABLE nodes ADD COLUMN imagery_json TEXT",
|
|
2307
|
+
"5": `ALTER TABLE nodes ADD COLUMN slot_room TEXT;
|
|
2308
|
+
ALTER TABLE nodes ADD COLUMN slot_index INTEGER;
|
|
2309
|
+
ALTER TABLE nodes ADD COLUMN imagery_score REAL;
|
|
2310
|
+
ALTER TABLE nodes ADD COLUMN next_review_at INTEGER;
|
|
2311
|
+
ALTER TABLE nodes ADD COLUMN ease_factor REAL;
|
|
2312
|
+
ALTER TABLE nodes ADD COLUMN interval_days REAL;
|
|
2313
|
+
ALTER TABLE nodes ADD COLUMN review_reps INTEGER DEFAULT 0;
|
|
2314
|
+
CREATE TABLE IF NOT EXISTS tour_routes (
|
|
2315
|
+
position INTEGER PRIMARY KEY, node_id TEXT NOT NULL);
|
|
2316
|
+
CREATE INDEX IF NOT EXISTS nodes_slot ON nodes (slot_room, slot_index);
|
|
2317
|
+
CREATE INDEX IF NOT EXISTS nodes_review_due ON nodes (next_review_at);`
|
|
2089
2318
|
};
|
|
2090
2319
|
/** RRF 融合常数:score = Σ 1/(K + rank)。 */
|
|
2091
2320
|
const RRF_K = 60;
|
|
@@ -2130,6 +2359,7 @@ function jsonToImagery(raw) {
|
|
|
2130
2359
|
}
|
|
2131
2360
|
function rowToRecord(row) {
|
|
2132
2361
|
const imagery = jsonToImagery(row.imagery_json);
|
|
2362
|
+
const hasReviewState = row.next_review_at !== null || row.ease_factor !== null || row.interval_days !== null;
|
|
2133
2363
|
return {
|
|
2134
2364
|
id: asMemoryId(row.id),
|
|
2135
2365
|
scope: row.scope,
|
|
@@ -2145,7 +2375,18 @@ function rowToRecord(row) {
|
|
|
2145
2375
|
sourceSessionId: row.source_session_id,
|
|
2146
2376
|
sourceRound: row.source_round,
|
|
2147
2377
|
sourceSeq: row.source_seq,
|
|
2148
|
-
...imagery === void 0 ? {} : { imagery }
|
|
2378
|
+
...imagery === void 0 ? {} : { imagery },
|
|
2379
|
+
...row.slot_room === null || row.slot_index === null ? {} : { slot: {
|
|
2380
|
+
room: row.slot_room,
|
|
2381
|
+
index: row.slot_index
|
|
2382
|
+
} },
|
|
2383
|
+
...row.imagery_score === null ? {} : { imageryScore: row.imagery_score },
|
|
2384
|
+
...hasReviewState ? { review: {
|
|
2385
|
+
nextReviewAt: row.next_review_at,
|
|
2386
|
+
easeFactor: row.ease_factor ?? 2.5,
|
|
2387
|
+
intervalDays: row.interval_days ?? 0,
|
|
2388
|
+
reps: row.review_reps ?? 0
|
|
2389
|
+
} } : {}
|
|
2149
2390
|
};
|
|
2150
2391
|
}
|
|
2151
2392
|
function blobToVec(blob) {
|
|
@@ -2198,14 +2439,20 @@ const NO_BOOST = {
|
|
|
2198
2439
|
proofWeight: 0,
|
|
2199
2440
|
decayAfterDays: 30
|
|
2200
2441
|
};
|
|
2442
|
+
/** 缺省全开(config 的默认值也在此处对齐)。 */
|
|
2443
|
+
const DEFAULT_AUTOMATION = {
|
|
2444
|
+
autoSlot: true,
|
|
2445
|
+
reviewScheduling: true
|
|
2446
|
+
};
|
|
2201
2447
|
/**
|
|
2202
2448
|
* 打开(必要时创建)一个 scope 分库。
|
|
2203
2449
|
* @param path - SQLite 文件路径;目录不存在会自动创建(0o700)。
|
|
2204
2450
|
* @param rankBoost - 排序 boost 参数;缺省不乘任何因子。
|
|
2451
|
+
* @param automation - 写入期自动化开关(自动排桩/初始排期);缺省全开。
|
|
2205
2452
|
* @returns 就绪的 EngramStore。
|
|
2206
2453
|
* @throws EngramError(code=SCHEMA_INCOMPATIBLE) 当库的 schema 版本高于当前实现。
|
|
2207
2454
|
*/
|
|
2208
|
-
async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
2455
|
+
async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_AUTOMATION) {
|
|
2209
2456
|
await mkdir(dirname(path), {
|
|
2210
2457
|
recursive: true,
|
|
2211
2458
|
mode: 448
|
|
@@ -2230,7 +2477,10 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2230
2477
|
importance REAL NOT NULL, confidence REAL NOT NULL, status TEXT NOT NULL,
|
|
2231
2478
|
created_at INTEGER NOT NULL, last_accessed_at INTEGER NOT NULL, access_count INTEGER NOT NULL,
|
|
2232
2479
|
source_session_id TEXT, source_round INTEGER, source_seq INTEGER, embedding BLOB, outcome TEXT,
|
|
2233
|
-
imagery_json TEXT
|
|
2480
|
+
imagery_json TEXT,
|
|
2481
|
+
slot_room TEXT, slot_index INTEGER, imagery_score REAL,
|
|
2482
|
+
next_review_at INTEGER, ease_factor REAL, interval_days REAL,
|
|
2483
|
+
review_reps INTEGER DEFAULT 0);
|
|
2234
2484
|
CREATE TABLE IF NOT EXISTS edges (
|
|
2235
2485
|
from_id TEXT NOT NULL, to_id TEXT NOT NULL, type TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
2236
2486
|
PRIMARY KEY (from_id, to_id, type));
|
|
@@ -2240,12 +2490,17 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2240
2490
|
CREATE TABLE IF NOT EXISTS nodes_revisions (
|
|
2241
2491
|
node_id TEXT NOT NULL, content TEXT NOT NULL, kind TEXT NOT NULL,
|
|
2242
2492
|
importance REAL NOT NULL, superseded_at INTEGER NOT NULL);
|
|
2493
|
+
CREATE TABLE IF NOT EXISTS tour_routes (
|
|
2494
|
+
position INTEGER PRIMARY KEY, node_id TEXT NOT NULL);
|
|
2243
2495
|
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(node_id UNINDEXED, content, tokenize='unicode61');
|
|
2244
2496
|
CREATE INDEX IF NOT EXISTS nodes_scope_status ON nodes (scope, status);
|
|
2245
2497
|
`);
|
|
2246
2498
|
const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
2247
|
-
if (versionRow === void 0)
|
|
2248
|
-
|
|
2499
|
+
if (versionRow === void 0) {
|
|
2500
|
+
db.exec(`CREATE INDEX IF NOT EXISTS nodes_slot ON nodes (slot_room, slot_index);
|
|
2501
|
+
CREATE INDEX IF NOT EXISTS nodes_review_due ON nodes (next_review_at);`);
|
|
2502
|
+
db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
|
|
2503
|
+
} else {
|
|
2249
2504
|
let version = Number(versionRow.value);
|
|
2250
2505
|
if (!Number.isInteger(version) || version > SCHEMA_VERSION) {
|
|
2251
2506
|
db.close();
|
|
@@ -2274,8 +2529,9 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2274
2529
|
const sqlGet = db.prepare("SELECT * FROM nodes WHERE id = ?");
|
|
2275
2530
|
const sqlInsert = db.prepare(`INSERT INTO nodes
|
|
2276
2531
|
(id, scope, kind, content, importance, confidence, status, created_at, last_accessed_at, access_count,
|
|
2277
|
-
source_session_id, source_round, source_seq, embedding, imagery_json
|
|
2278
|
-
|
|
2532
|
+
source_session_id, source_round, source_seq, embedding, imagery_json,
|
|
2533
|
+
slot_room, slot_index, imagery_score, next_review_at, ease_factor, interval_days)
|
|
2534
|
+
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
2279
2535
|
const sqlFtsInsert = db.prepare("INSERT INTO nodes_fts (node_id, content) VALUES (?, ?)");
|
|
2280
2536
|
const sqlSetStatus = db.prepare("UPDATE nodes SET status = ?, last_accessed_at = ? WHERE id = ?");
|
|
2281
2537
|
const sqlSetOutcome = db.prepare(`UPDATE nodes
|
|
@@ -2316,22 +2572,44 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2316
2572
|
const sqlAllNodes = db.prepare("SELECT * FROM nodes ORDER BY created_at");
|
|
2317
2573
|
const sqlAllEdges = db.prepare("SELECT * FROM edges");
|
|
2318
2574
|
const sqlDecay = db.prepare(`UPDATE nodes SET status = 'archived'
|
|
2319
|
-
WHERE status = 'active' AND importance < ? AND last_accessed_at <
|
|
2575
|
+
WHERE status = 'active' AND importance < ? AND last_accessed_at < ? AND next_review_at IS NULL`);
|
|
2576
|
+
const sqlScheduleReview = db.prepare(`UPDATE nodes
|
|
2577
|
+
SET next_review_at = ?, ease_factor = ?, interval_days = ?, review_reps = ?, last_accessed_at = ?
|
|
2578
|
+
WHERE id = ?`);
|
|
2579
|
+
const sqlDueReviews = db.prepare(`SELECT * FROM nodes
|
|
2580
|
+
WHERE status = 'active' AND next_review_at IS NOT NULL AND next_review_at <= ?
|
|
2581
|
+
ORDER BY next_review_at ASC LIMIT ?`);
|
|
2582
|
+
const sqlSlotCounts = db.prepare(`SELECT slot_room AS room, MAX(slot_index) AS maxIndex, COUNT(*) AS n
|
|
2583
|
+
FROM nodes WHERE slot_room IS NOT NULL AND status != 'forgotten' GROUP BY slot_room`);
|
|
2584
|
+
const sqlSetSlot = db.prepare("UPDATE nodes SET slot_room = ?, slot_index = ? WHERE id = ?");
|
|
2585
|
+
const sqlUnslotted = db.prepare(`SELECT * FROM nodes WHERE slot_room IS NULL AND status = 'active'
|
|
2586
|
+
ORDER BY kind, created_at`);
|
|
2587
|
+
const sqlRouteAppend = db.prepare(`INSERT INTO tour_routes (position, node_id)
|
|
2588
|
+
VALUES ((SELECT COALESCE(MAX(position), -1) + 1 FROM tour_routes), ?)`);
|
|
2589
|
+
const sqlRouteList = db.prepare("SELECT position, node_id FROM tour_routes ORDER BY position");
|
|
2590
|
+
const sqlRouteHas = db.prepare("SELECT 1 AS x FROM tour_routes WHERE node_id = ? LIMIT 1");
|
|
2591
|
+
const sqlSlotNeighbors = db.prepare(`SELECT id FROM nodes
|
|
2592
|
+
WHERE slot_room = ? AND slot_index IN (?, ?) AND status = 'active' AND id != ?`);
|
|
2593
|
+
const sqlListPlacards = db.prepare(`SELECT slot_room AS room, json_extract(imagery_json, '$.caption') AS caption
|
|
2594
|
+
FROM nodes WHERE imagery_json IS NOT NULL AND status = 'active'`);
|
|
2320
2595
|
const sqlPurgeNodes = db.prepare("DELETE FROM nodes");
|
|
2321
2596
|
const sqlPurgeEdges = db.prepare("DELETE FROM edges");
|
|
2322
2597
|
const sqlPurgeFts = db.prepare("DELETE FROM nodes_fts");
|
|
2323
2598
|
const sqlPurgeLog = db.prepare("DELETE FROM op_log");
|
|
2324
|
-
|
|
2325
|
-
|
|
2599
|
+
const sqlPurgeRoutes = db.prepare("DELETE FROM tour_routes");
|
|
2600
|
+
/** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重);rooms 非空时只查指定房间。 */
|
|
2601
|
+
const ftsSearch = (match, scopes, rooms) => {
|
|
2326
2602
|
const placeholders = scopes.map(() => "?").join(",");
|
|
2603
|
+
const roomCond = rooms === void 0 || rooms.length === 0 ? "" : ` AND n.slot_room IN (${rooms.map(() => "?").join(",")})`;
|
|
2327
2604
|
return db.prepare(`SELECT n.* FROM nodes_fts f JOIN nodes n ON n.id = f.node_id
|
|
2328
|
-
WHERE nodes_fts MATCH ? AND n.status = 'active' AND n.scope IN (${placeholders})
|
|
2329
|
-
ORDER BY bm25(nodes_fts) LIMIT ${RANK_POOL}`).all(match, ...scopes);
|
|
2605
|
+
WHERE nodes_fts MATCH ? AND n.status = 'active' AND n.scope IN (${placeholders})${roomCond}
|
|
2606
|
+
ORDER BY bm25(nodes_fts) LIMIT ${RANK_POOL}`).all(match, ...scopes, ...rooms ?? []);
|
|
2330
2607
|
};
|
|
2331
|
-
/** 向量候选池:active 且带向量的条目,按 scope
|
|
2332
|
-
const vectorPool = (scopes) => {
|
|
2608
|
+
/** 向量候选池:active 且带向量的条目,按 scope 集合过滤(占位符动态生成);rooms 非空时只查指定房间。 */
|
|
2609
|
+
const vectorPool = (scopes, rooms) => {
|
|
2333
2610
|
const placeholders = scopes.map(() => "?").join(",");
|
|
2334
|
-
|
|
2611
|
+
const roomCond = rooms === void 0 || rooms.length === 0 ? "" : ` AND slot_room IN (${rooms.map(() => "?").join(",")})`;
|
|
2612
|
+
return db.prepare(`SELECT * FROM nodes WHERE status = 'active' AND embedding IS NOT NULL AND scope IN (${placeholders})${roomCond}`).all(...scopes, ...rooms ?? []);
|
|
2335
2613
|
};
|
|
2336
2614
|
const getRow = (id) => sqlGet.get(id);
|
|
2337
2615
|
/**
|
|
@@ -2340,7 +2618,8 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2340
2618
|
*/
|
|
2341
2619
|
const insertRecord = (id, input, content, importance, confidence, at, sourceSessionId, embedding, imagery, op) => {
|
|
2342
2620
|
const stored = embedding === null ? null : embedding instanceof Float32Array ? vecToBlob(embedding) : embedding;
|
|
2343
|
-
|
|
2621
|
+
const initialReview = input.initialReviewAt ?? null;
|
|
2622
|
+
sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored, imageryToJson(imagery), input.slot?.room ?? null, input.slot?.index ?? null, input.imageryScore ?? null, initialReview, initialReview === null ? null : 2.5, initialReview === null ? null : 0);
|
|
2344
2623
|
sqlFtsInsert.run(id, tokenizeForFts(content));
|
|
2345
2624
|
sqlLog.run(at, op, id, JSON.stringify({
|
|
2346
2625
|
kind: input.kind,
|
|
@@ -2366,6 +2645,45 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2366
2645
|
related: related.map(asMemoryId)
|
|
2367
2646
|
};
|
|
2368
2647
|
};
|
|
2648
|
+
/**
|
|
2649
|
+
* 写入期自动化(save/批量/摄取/update/蒸馏全部写入路径统一在此落地;调用方须已持事务):
|
|
2650
|
+
* 1) 排桩——显式 slot 优先,否则按 kind 分房自动分配(满员开新房并记 op_log);
|
|
2651
|
+
* 2) 门牌评分——有铭牌时按「唯一/差异化/带日期」启发式落库;
|
|
2652
|
+
* 3) 初始排期——reviewScheduling 开启且未显式指定时,1 天后首次到期;
|
|
2653
|
+
* 4) 巡游路线——有桩位的条目登记到固定路线末尾。
|
|
2654
|
+
* @returns 合入 WriteInput 的自动化字段。
|
|
2655
|
+
*/
|
|
2656
|
+
const applyWriteAutomation = (input, id, at, imagery) => {
|
|
2657
|
+
let slot = input.slot;
|
|
2658
|
+
if (slot === void 0 && automation.autoSlot) {
|
|
2659
|
+
const occupancy = {};
|
|
2660
|
+
for (const row of sqlSlotCounts.all()) occupancy[row.room] = {
|
|
2661
|
+
count: row.n,
|
|
2662
|
+
maxIndex: row.maxIndex
|
|
2663
|
+
};
|
|
2664
|
+
const assigned = assignSlot(input.kind, occupancy);
|
|
2665
|
+
slot = assigned.slot;
|
|
2666
|
+
if (assigned.openedNewRoom) sqlLog.run(at, "room-open", "BATCH", JSON.stringify({
|
|
2667
|
+
room: slot.room,
|
|
2668
|
+
kind: input.kind
|
|
2669
|
+
}));
|
|
2670
|
+
}
|
|
2671
|
+
let imageryScore = input.imageryScore;
|
|
2672
|
+
if (imageryScore === void 0 && imagery !== void 0) {
|
|
2673
|
+
const placards = sqlListPlacards.all().filter((row) => typeof row.caption === "string" && row.caption !== "");
|
|
2674
|
+
imageryScore = scorePlacard(imagery.caption, {
|
|
2675
|
+
existingCaptions: placards.map((row) => row.caption),
|
|
2676
|
+
roomCaptions: slot === void 0 ? [] : placards.filter((row) => row.room === slot.room).map((row) => row.caption)
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
const initialReviewAt = input.initialReviewAt ?? (automation.reviewScheduling ? at + 864e5 : void 0);
|
|
2680
|
+
if (slot !== void 0 && sqlRouteHas.get(id) === void 0) sqlRouteAppend.run(id);
|
|
2681
|
+
return {
|
|
2682
|
+
...slot === void 0 ? {} : { slot },
|
|
2683
|
+
...imageryScore === void 0 ? {} : { imageryScore },
|
|
2684
|
+
...initialReviewAt === void 0 ? {} : { initialReviewAt }
|
|
2685
|
+
};
|
|
2686
|
+
};
|
|
2369
2687
|
return {
|
|
2370
2688
|
async write(input) {
|
|
2371
2689
|
const content = input.content.trim();
|
|
@@ -2373,7 +2691,11 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2373
2691
|
const id = asMemoryId(randomUUID());
|
|
2374
2692
|
const at = Date.now();
|
|
2375
2693
|
withTransaction(() => {
|
|
2376
|
-
|
|
2694
|
+
const automationFields = applyWriteAutomation(input, id, at, input.imagery);
|
|
2695
|
+
insertRecord(id, {
|
|
2696
|
+
...input,
|
|
2697
|
+
...automationFields
|
|
2698
|
+
}, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "write");
|
|
2377
2699
|
});
|
|
2378
2700
|
return rowToRecord(sqlGet.get(id));
|
|
2379
2701
|
},
|
|
@@ -2410,7 +2732,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2410
2732
|
const limit = query.limit ?? 8;
|
|
2411
2733
|
const scores = /* @__PURE__ */ new Map();
|
|
2412
2734
|
const match = ftsMatchExpression(query.text);
|
|
2413
|
-
if (match !== void 0) ftsSearch(match, query.scopes).forEach((row, index) => {
|
|
2735
|
+
if (match !== void 0) ftsSearch(match, query.scopes, query.rooms).forEach((row, index) => {
|
|
2414
2736
|
scores.set(row.id, {
|
|
2415
2737
|
score: 1 / (RRF_K + index + 1),
|
|
2416
2738
|
via: "fts"
|
|
@@ -2419,7 +2741,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2419
2741
|
let degraded = true;
|
|
2420
2742
|
if (queryVector !== void 0) {
|
|
2421
2743
|
degraded = false;
|
|
2422
|
-
vectorPool(query.scopes).map((row) => ({
|
|
2744
|
+
vectorPool(query.scopes, query.rooms).map((row) => ({
|
|
2423
2745
|
row,
|
|
2424
2746
|
sim: cosine(queryVector, blobToVec(row.embedding))
|
|
2425
2747
|
})).filter((entry) => entry.sim >= MIN_COSINE).sort((a, b) => b.sim - a.sim).slice(0, RANK_POOL).forEach((entry, index) => {
|
|
@@ -2472,11 +2794,13 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2472
2794
|
const row = getRow(id);
|
|
2473
2795
|
if (row === void 0) continue;
|
|
2474
2796
|
const viaEdge = viaEdgeOf.get(id);
|
|
2797
|
+
const cueNeighbors = hits.length < 5 && row.slot_room !== null && row.slot_index !== null ? sqlSlotNeighbors.all(row.slot_room, row.slot_index - 1, row.slot_index + 1, id).map((neighbor) => asMemoryId(neighbor.id)) : [];
|
|
2475
2798
|
hits.push({
|
|
2476
2799
|
record: rowToRecord(row),
|
|
2477
2800
|
score: info.score,
|
|
2478
2801
|
via: info.via,
|
|
2479
|
-
...viaEdge === void 0 ? {} : { viaEdge }
|
|
2802
|
+
...viaEdge === void 0 ? {} : { viaEdge },
|
|
2803
|
+
...cueNeighbors.length === 0 ? {} : { cues: { neighbors: cueNeighbors } }
|
|
2480
2804
|
});
|
|
2481
2805
|
sqlTouch.run(Date.now(), id);
|
|
2482
2806
|
}
|
|
@@ -2504,11 +2828,17 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2504
2828
|
sqlRevisionInsert.run(input.id, old.content, old.kind, old.importance, at);
|
|
2505
2829
|
sqlSetStatus.run("archived", at, input.id);
|
|
2506
2830
|
sqlLog.run(at, "superseded", input.id, JSON.stringify({ supersededBy: id }));
|
|
2507
|
-
|
|
2831
|
+
const imagery = input.imagery ?? jsonToImagery(old.imagery_json);
|
|
2832
|
+
const updateInput = {
|
|
2508
2833
|
scope: input.scope,
|
|
2509
2834
|
kind: input.kind,
|
|
2510
2835
|
content
|
|
2511
|
-
}
|
|
2836
|
+
};
|
|
2837
|
+
const automationFields = applyWriteAutomation(updateInput, id, at, imagery);
|
|
2838
|
+
insertRecord(id, {
|
|
2839
|
+
...updateInput,
|
|
2840
|
+
...automationFields
|
|
2841
|
+
}, content, input.importance ?? old.importance, old.confidence, at, old.source_session_id, input.embedding ?? old.embedding, imagery, "update");
|
|
2512
2842
|
sqlEdgeUpsert.run(id, input.id, "supersedes", at);
|
|
2513
2843
|
});
|
|
2514
2844
|
return rowToRecord(sqlGet.get(id));
|
|
@@ -2569,6 +2899,82 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2569
2899
|
sqlLog.run(Date.now(), "outcome-report", id, outcome);
|
|
2570
2900
|
return rowToRecord(sqlGet.get(id));
|
|
2571
2901
|
},
|
|
2902
|
+
async scheduleReview(id, grade) {
|
|
2903
|
+
const row = getRow(id);
|
|
2904
|
+
if (row === void 0) return void 0;
|
|
2905
|
+
const now = Date.now();
|
|
2906
|
+
const next = nextSchedule(grade, rowToRecord(row).review ?? {
|
|
2907
|
+
nextReviewAt: null,
|
|
2908
|
+
easeFactor: 2.5,
|
|
2909
|
+
intervalDays: 0,
|
|
2910
|
+
reps: row.review_reps ?? 0
|
|
2911
|
+
}, now);
|
|
2912
|
+
sqlScheduleReview.run(next.nextReviewAt, next.easeFactor, next.intervalDays, next.reps, now, id);
|
|
2913
|
+
sqlLog.run(now, "review-answer", id, JSON.stringify({
|
|
2914
|
+
grade,
|
|
2915
|
+
nextIntervalDays: next.intervalDays
|
|
2916
|
+
}));
|
|
2917
|
+
return rowToRecord(sqlGet.get(id));
|
|
2918
|
+
},
|
|
2919
|
+
async dueReviews(now, limit) {
|
|
2920
|
+
return sqlDueReviews.all(now, Math.max(1, limit)).map(rowToRecord);
|
|
2921
|
+
},
|
|
2922
|
+
async slotCountsByRoom() {
|
|
2923
|
+
const rows = sqlSlotCounts.all();
|
|
2924
|
+
const result = {};
|
|
2925
|
+
for (const row of rows) result[row.room] = {
|
|
2926
|
+
count: row.n,
|
|
2927
|
+
maxIndex: row.maxIndex
|
|
2928
|
+
};
|
|
2929
|
+
return result;
|
|
2930
|
+
},
|
|
2931
|
+
async assignSlot(id, slot) {
|
|
2932
|
+
sqlSetSlot.run(slot.room, slot.index, id);
|
|
2933
|
+
sqlLog.run(Date.now(), "slot-assign", id, JSON.stringify(slot));
|
|
2934
|
+
},
|
|
2935
|
+
async backfillSlots(capacityNote) {
|
|
2936
|
+
const rows = sqlUnslotted.all();
|
|
2937
|
+
if (rows.length === 0) return 0;
|
|
2938
|
+
const now = Date.now();
|
|
2939
|
+
withTransaction(() => {
|
|
2940
|
+
const occupancy = {};
|
|
2941
|
+
for (const row of sqlSlotCounts.all()) occupancy[row.room] = {
|
|
2942
|
+
count: row.n,
|
|
2943
|
+
maxIndex: row.maxIndex
|
|
2944
|
+
};
|
|
2945
|
+
for (const row of rows) {
|
|
2946
|
+
const { slot, openedNewRoom } = assignSlot(row.kind, occupancy);
|
|
2947
|
+
sqlSetSlot.run(slot.room, slot.index, row.id);
|
|
2948
|
+
if (sqlRouteHas.get(row.id) === void 0) sqlRouteAppend.run(row.id);
|
|
2949
|
+
const state = occupancy[slot.room] ?? {
|
|
2950
|
+
count: 0,
|
|
2951
|
+
maxIndex: 0
|
|
2952
|
+
};
|
|
2953
|
+
occupancy[slot.room] = {
|
|
2954
|
+
count: state.count + 1,
|
|
2955
|
+
maxIndex: Math.max(state.maxIndex, slot.index)
|
|
2956
|
+
};
|
|
2957
|
+
if (openedNewRoom) capacityNote(slot.room);
|
|
2958
|
+
}
|
|
2959
|
+
sqlLog.run(now, "slot-backfill", "BATCH", JSON.stringify({ assigned: rows.length }));
|
|
2960
|
+
});
|
|
2961
|
+
return rows.length;
|
|
2962
|
+
},
|
|
2963
|
+
async routeAppend(id) {
|
|
2964
|
+
sqlRouteAppend.run(id);
|
|
2965
|
+
},
|
|
2966
|
+
async routeHas(id) {
|
|
2967
|
+
return sqlRouteHas.get(id) !== void 0;
|
|
2968
|
+
},
|
|
2969
|
+
async routeList() {
|
|
2970
|
+
return sqlRouteList.all().map((row) => ({
|
|
2971
|
+
position: row.position,
|
|
2972
|
+
id: asMemoryId(row.node_id)
|
|
2973
|
+
}));
|
|
2974
|
+
},
|
|
2975
|
+
async listPlacards() {
|
|
2976
|
+
return sqlListPlacards.all().filter((row) => typeof row.caption === "string" && row.caption !== "");
|
|
2977
|
+
},
|
|
2572
2978
|
async restore(id) {
|
|
2573
2979
|
if (getRow(id) === void 0) throw new EngramError("NOT_FOUND", `条目 ${id} 不存在`);
|
|
2574
2980
|
sqlSetStatus.run("active", Date.now(), id);
|
|
@@ -2597,7 +3003,8 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2597
3003
|
const where = conds.join(" AND ");
|
|
2598
3004
|
const total = db.prepare(`SELECT COUNT(*) AS n FROM nodes WHERE ${where}`).get(...params).n;
|
|
2599
3005
|
return {
|
|
2600
|
-
records: db.prepare(`SELECT
|
|
3006
|
+
records: (filter.sort === "tour" ? db.prepare(`SELECT nodes.* FROM nodes LEFT JOIN tour_routes ON tour_routes.node_id = nodes.id
|
|
3007
|
+
WHERE ${where} ORDER BY tour_routes.position IS NULL, tour_routes.position ASC, created_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset) : db.prepare(`SELECT * FROM nodes WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset)).map(rowToRecord),
|
|
2601
3008
|
total
|
|
2602
3009
|
};
|
|
2603
3010
|
},
|
|
@@ -2696,7 +3103,11 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2696
3103
|
const id = asMemoryId(randomUUID());
|
|
2697
3104
|
const at = Date.now();
|
|
2698
3105
|
withTransaction(() => {
|
|
2699
|
-
|
|
3106
|
+
const automationFields = applyWriteAutomation(input, id, at, input.imagery);
|
|
3107
|
+
insertRecord(id, {
|
|
3108
|
+
...input,
|
|
3109
|
+
...automationFields
|
|
3110
|
+
}, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "distill");
|
|
2700
3111
|
for (const oldId of oldIds) {
|
|
2701
3112
|
sqlSetStatus.run("archived", at, oldId);
|
|
2702
3113
|
sqlEdgeUpsert.run(id, oldId, "supersedes", at);
|
|
@@ -2722,6 +3133,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST) {
|
|
|
2722
3133
|
sqlPurgeEdges.run();
|
|
2723
3134
|
sqlPurgeFts.run();
|
|
2724
3135
|
sqlPurgeLog.run();
|
|
3136
|
+
sqlPurgeRoutes.run();
|
|
2725
3137
|
});
|
|
2726
3138
|
},
|
|
2727
3139
|
async close() {
|
|
@@ -2931,7 +3343,7 @@ function enforceBudget(lines, totalBudget = RECALL_TOTAL_CHARS) {
|
|
|
2931
3343
|
//#endregion
|
|
2932
3344
|
//#region src/tools/create.ts
|
|
2933
3345
|
/**
|
|
2934
|
-
*
|
|
3346
|
+
* 15 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
|
|
2935
3347
|
* scope 决定读写哪个分库;嵌入缺失时检索结果显式标记降级。
|
|
2936
3348
|
* @module @kenz1117/dsh-engram/tools/create
|
|
2937
3349
|
*/
|
|
@@ -3011,7 +3423,8 @@ async function rewriteQueries(deps, exec, query) {
|
|
|
3011
3423
|
}
|
|
3012
3424
|
}
|
|
3013
3425
|
/**
|
|
3014
|
-
* 构造
|
|
3426
|
+
* 构造 15 个工具定义(engram_save/search/timeline/update/forget/report/review/review_queue/
|
|
3427
|
+
* stats/export/distill/examine/neighbors/audit_forgotten/tour)。
|
|
3015
3428
|
* @param deps - 分库打开器、嵌入器、辅助 LLM、导出目录。
|
|
3016
3429
|
* @returns 可直接 register 的工具定义数组。
|
|
3017
3430
|
*/
|
|
@@ -3022,7 +3435,10 @@ function createEngramTools(deps) {
|
|
|
3022
3435
|
function renderSaveResultText(value) {
|
|
3023
3436
|
if (value.count !== void 0) {
|
|
3024
3437
|
const parts = [`已批量保存 ${value.count} 条记忆`];
|
|
3025
|
-
for (const item of value.items ?? [])
|
|
3438
|
+
for (const item of value.items ?? []) {
|
|
3439
|
+
const slot = item.slot === void 0 ? "" : `, ${item.slot.room}#${item.slot.index}`;
|
|
3440
|
+
parts.push(`${item.id}(kind=${item.kind}, importance=${item.importance}${slot})`);
|
|
3441
|
+
}
|
|
3026
3442
|
const failures = value.failed ?? [];
|
|
3027
3443
|
if (failures.length > 0) parts.push(`${failures.length} 条失败:${failures.map((entry) => `#${entry.index + 1} ${entry.reason}`).join(";")}`);
|
|
3028
3444
|
parts.push("后续会话可用 engram_search 召回。");
|
|
@@ -3040,7 +3456,8 @@ function createEngramTools(deps) {
|
|
|
3040
3456
|
content: item.content,
|
|
3041
3457
|
...item.importance === void 0 ? {} : { importance: item.importance },
|
|
3042
3458
|
sourceSessionId: item.sourceSessionId,
|
|
3043
|
-
...embedding === void 0 ? {} : { embedding }
|
|
3459
|
+
...embedding === void 0 ? {} : { embedding },
|
|
3460
|
+
...item.imagery === void 0 ? {} : { imagery: item.imagery }
|
|
3044
3461
|
});
|
|
3045
3462
|
const candidates = embedding === void 0 ? [] : await store.findContradictions(embedding);
|
|
3046
3463
|
for (const candidate of candidates) await store.linkEdge(record.id, candidate.id, "contradicts");
|
|
@@ -3049,6 +3466,18 @@ function createEngramTools(deps) {
|
|
|
3049
3466
|
candidates
|
|
3050
3467
|
};
|
|
3051
3468
|
}
|
|
3469
|
+
/** 门牌参数收敛:非空字符串转 ImageryLabel(感官/情绪维度留空——AI 不需要人脑补丁),非法返回 undefined。 */
|
|
3470
|
+
function placardOf(raw) {
|
|
3471
|
+
if (typeof raw !== "string") return void 0;
|
|
3472
|
+
const caption = raw.trim();
|
|
3473
|
+
if (caption === "") return void 0;
|
|
3474
|
+
return {
|
|
3475
|
+
caption,
|
|
3476
|
+
sensoryTags: [],
|
|
3477
|
+
emotionalValence: 0,
|
|
3478
|
+
provisional: false
|
|
3479
|
+
};
|
|
3480
|
+
}
|
|
3052
3481
|
/** 批量保存:统一清洗/校验/批量内去重,一次批量嵌入,逐条写入;单条失败不阻塞其余。 */
|
|
3053
3482
|
async function saveBatch(sourceSessionId, items, rawScope) {
|
|
3054
3483
|
if (items.length > MAX_SAVE_BATCH) throw new Error(`engram_save: 单次最多保存 ${MAX_SAVE_BATCH} 条`);
|
|
@@ -3120,7 +3549,8 @@ function createEngramTools(deps) {
|
|
|
3120
3549
|
saved.push({
|
|
3121
3550
|
id: record.id,
|
|
3122
3551
|
kind: record.kind,
|
|
3123
|
-
importance: record.importance
|
|
3552
|
+
importance: record.importance,
|
|
3553
|
+
...record.slot === void 0 ? {} : { slot: record.slot }
|
|
3124
3554
|
});
|
|
3125
3555
|
} catch (error) {
|
|
3126
3556
|
failed.push({
|
|
@@ -3184,6 +3614,10 @@ function createEngramTools(deps) {
|
|
|
3184
3614
|
importance: {
|
|
3185
3615
|
type: "number",
|
|
3186
3616
|
description: "重要性 0-1,默认 0.5(仅单条模式)"
|
|
3617
|
+
},
|
|
3618
|
+
placard: {
|
|
3619
|
+
type: "string",
|
|
3620
|
+
description: "门牌(可选,仅单条模式):4-30 字铭牌。宫殿纪律:唯一 · 差异化 · 带日期锚点(如「2026-09 向量检索选型」),禁止与既有门牌近似到无法区分"
|
|
3187
3621
|
}
|
|
3188
3622
|
},
|
|
3189
3623
|
output: {
|
|
@@ -3213,6 +3647,20 @@ function createEngramTools(deps) {
|
|
|
3213
3647
|
importance: {
|
|
3214
3648
|
type: "number",
|
|
3215
3649
|
required: true
|
|
3650
|
+
},
|
|
3651
|
+
slot: {
|
|
3652
|
+
type: "object",
|
|
3653
|
+
additionalProperties: false,
|
|
3654
|
+
properties: {
|
|
3655
|
+
room: {
|
|
3656
|
+
type: "string",
|
|
3657
|
+
required: true
|
|
3658
|
+
},
|
|
3659
|
+
index: {
|
|
3660
|
+
type: "number",
|
|
3661
|
+
required: true
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3216
3664
|
}
|
|
3217
3665
|
}
|
|
3218
3666
|
}
|
|
@@ -3256,33 +3704,38 @@ function createEngramTools(deps) {
|
|
|
3256
3704
|
const store = await deps.openStore(scope);
|
|
3257
3705
|
const embedder = await deps.embedder;
|
|
3258
3706
|
const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
|
|
3707
|
+
const imagery = placardOf(input.placard);
|
|
3259
3708
|
const { record, candidates } = await writeWithContradictions(store, {
|
|
3260
3709
|
scope,
|
|
3261
3710
|
kind: input.kind,
|
|
3262
3711
|
content,
|
|
3263
3712
|
...typeof input.importance === "number" ? { importance: input.importance } : {},
|
|
3264
3713
|
sourceSessionId,
|
|
3265
|
-
...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] }
|
|
3714
|
+
...embeddings?.[0] === void 0 ? {} : { embedding: embeddings[0] },
|
|
3715
|
+
...imagery === void 0 ? {} : { imagery }
|
|
3266
3716
|
});
|
|
3717
|
+
const placardHint = imagery === void 0 || record.imageryScore === void 0 ? "" : `\n${placardImprovementHint(record.imageryScore) ?? ""}`;
|
|
3267
3718
|
if (candidates.length > 0) {
|
|
3268
3719
|
const listed = candidates.map((candidate) => `「${candidate.content}」(id=${candidate.id})`).join(";");
|
|
3269
3720
|
return {
|
|
3270
3721
|
id: record.id,
|
|
3271
3722
|
kind: record.kind,
|
|
3272
3723
|
importance: record.importance,
|
|
3273
|
-
text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget
|
|
3724
|
+
text: `已保存 ${record.id}。注意:与现有记忆高度相似——${listed}。若这是修正而非新事实,请用 engram_update 归并,或 engram_forget 去重。${placardHint}`
|
|
3274
3725
|
};
|
|
3275
3726
|
}
|
|
3727
|
+
const base = `已保存记忆 ${record.id}(kind=${record.kind}, importance=${record.importance}${record.slot === void 0 ? "" : `, ${record.slot.room}#${record.slot.index}`})。后续会话可用 engram_search 召回。`;
|
|
3276
3728
|
return {
|
|
3277
3729
|
id: record.id,
|
|
3278
3730
|
kind: record.kind,
|
|
3279
|
-
importance: record.importance
|
|
3731
|
+
importance: record.importance,
|
|
3732
|
+
text: `${base}${placardHint}`
|
|
3280
3733
|
};
|
|
3281
3734
|
}
|
|
3282
3735
|
});
|
|
3283
3736
|
const search = defineTool({
|
|
3284
3737
|
name: "engram_search",
|
|
3285
|
-
description: "语义 +
|
|
3738
|
+
description: "语义 + 关键词混合检索长期记忆。宫殿纪律:先想进哪个房间——事实厅(fact)/偏好阁(preference)/决策堂(decision)/往事廊(episode)/技法坊(skill),带上 room 参数只查该房间,更快更准;不确定房间时缺省全库检索。user 作用域存偏好与通用事实,project 作用域存项目约定与决策。结果行尾给出 id,供 engram_update/engram_forget 引用。",
|
|
3286
3739
|
parameters: {
|
|
3287
3740
|
query: {
|
|
3288
3741
|
type: "string",
|
|
@@ -3299,6 +3752,10 @@ function createEngramTools(deps) {
|
|
|
3299
3752
|
],
|
|
3300
3753
|
description: "作用域,默认 all"
|
|
3301
3754
|
},
|
|
3755
|
+
room: {
|
|
3756
|
+
type: "string",
|
|
3757
|
+
description: "房间路由:只在指定房间内检索(如「决策堂」)。房间目录见 engram_stats 输出"
|
|
3758
|
+
},
|
|
3302
3759
|
limit: {
|
|
3303
3760
|
type: "number",
|
|
3304
3761
|
description: "返回条数上限,默认 8"
|
|
@@ -3327,6 +3784,7 @@ function createEngramTools(deps) {
|
|
|
3327
3784
|
async execute(args, exec) {
|
|
3328
3785
|
const input = args;
|
|
3329
3786
|
const scopes = scopesOf(input.scope);
|
|
3787
|
+
const rooms = typeof input.room === "string" && input.room.trim() !== "" ? [input.room.trim()] : void 0;
|
|
3330
3788
|
const limit = input.limit ?? 8;
|
|
3331
3789
|
const rewrite = await rewriteQueries(deps, exec, input.query);
|
|
3332
3790
|
const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
|
|
@@ -3335,7 +3793,8 @@ function createEngramTools(deps) {
|
|
|
3335
3793
|
return (await deps.openStore(scope)).search({
|
|
3336
3794
|
text: queryText,
|
|
3337
3795
|
scopes: [scope],
|
|
3338
|
-
limit
|
|
3796
|
+
limit,
|
|
3797
|
+
...rooms === void 0 ? {} : { rooms }
|
|
3339
3798
|
}, vector);
|
|
3340
3799
|
}));
|
|
3341
3800
|
return {
|
|
@@ -3346,11 +3805,14 @@ function createEngramTools(deps) {
|
|
|
3346
3805
|
const degraded = retrievals.some((retrieval) => retrieval.degraded);
|
|
3347
3806
|
const lines = enforceBudget(mergeQueryResults(retrievals, limit, 60, Math.floor(Math.max(0, limit) / Math.max(1, rewrite.queries.length))).map((hit, index) => {
|
|
3348
3807
|
const edge = hit.viaEdge === void 0 ? "" : `(经 ${hit.viaEdge.type} 关联自 ${hit.viaEdge.from})`;
|
|
3349
|
-
|
|
3808
|
+
const slot = hit.record.slot === void 0 ? "" : ` ${hit.record.slot.room}#${hit.record.slot.index}`;
|
|
3809
|
+
const date = ` 刻于 ${new Date(hit.record.createdAt).toISOString().slice(0, 10)}`;
|
|
3810
|
+
const cues = hit.cues === void 0 ? "" : ` 相邻桩位: ${hit.cues.neighbors.join(", ")}`;
|
|
3811
|
+
return `${index + 1}. [${hit.record.scope}/${hit.record.kind}]${slot}${date} ${truncateItem(hit.record.content)}(id=${hit.record.id})${edge}${cues}`;
|
|
3350
3812
|
}));
|
|
3351
3813
|
return {
|
|
3352
3814
|
degraded,
|
|
3353
|
-
text: renderMemoryPacket(`${degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${lines.join("\n") || "无命中"}`, "tool_search", input.query)
|
|
3815
|
+
text: renderMemoryPacket(`${degraded && lines.length > 0 ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${rooms === void 0 ? "" : `(房间路由:${rooms.join("、")})\n`}${lines.join("\n") || "无命中"}`, "tool_search", input.query)
|
|
3354
3816
|
};
|
|
3355
3817
|
}
|
|
3356
3818
|
});
|
|
@@ -3444,6 +3906,10 @@ function createEngramTools(deps) {
|
|
|
3444
3906
|
type: "string",
|
|
3445
3907
|
enum: [...KINDS],
|
|
3446
3908
|
description: "种类,默认继承旧条目"
|
|
3909
|
+
},
|
|
3910
|
+
placard: {
|
|
3911
|
+
type: "string",
|
|
3912
|
+
description: "门牌(可选):4-30 字铭牌,替换旧条目门牌。宫殿纪律:唯一 · 差异化 · 带日期锚点"
|
|
3447
3913
|
}
|
|
3448
3914
|
},
|
|
3449
3915
|
output: {
|
|
@@ -3476,13 +3942,15 @@ function createEngramTools(deps) {
|
|
|
3476
3942
|
if (old === void 0) throw new Error(`engram_update: 条目 ${input.id} 不存在于 ${scope} 库(用 engram_search 确认 id 与 scope)`);
|
|
3477
3943
|
const embedder = await deps.embedder;
|
|
3478
3944
|
const embeddings = embedder === void 0 ? void 0 : await embedder.embed([content.trim()]);
|
|
3945
|
+
const imagery = placardOf(input.placard);
|
|
3479
3946
|
return {
|
|
3480
3947
|
id: (await store.update({
|
|
3481
3948
|
id: input.id,
|
|
3482
3949
|
scope,
|
|
3483
3950
|
kind: input.kind ?? old.kind,
|
|
3484
3951
|
content,
|
|
3485
|
-
...embeddings === void 0 ? {} : { embedding: embeddings[0] }
|
|
3952
|
+
...embeddings === void 0 ? {} : { embedding: embeddings[0] },
|
|
3953
|
+
...imagery === void 0 ? {} : { imagery }
|
|
3486
3954
|
})).id,
|
|
3487
3955
|
superseded: input.id
|
|
3488
3956
|
};
|
|
@@ -3600,6 +4068,61 @@ function createEngramTools(deps) {
|
|
|
3600
4068
|
return { text: `闭馆考古(共 ${sliced.length} 条):\n${lines.join("\n")}` };
|
|
3601
4069
|
}
|
|
3602
4070
|
});
|
|
4071
|
+
const reviewQueue = defineTool({
|
|
4072
|
+
name: "engram_review_queue",
|
|
4073
|
+
description: "今日待回忆队列:列出已到期间隔重复的记忆,每条只给宫殿坐标与门牌线索(不给正文)。用法:对每条先尝试回忆内容,然后 engram_review 揭示核对,再 engram_report 传 grade(0-5)自评——主动回忆比重复阅读的记忆强化效果强得多。会话开始注入会提示今日是否有待回忆。",
|
|
4074
|
+
parameters: {
|
|
4075
|
+
scope: {
|
|
4076
|
+
type: "string",
|
|
4077
|
+
enum: [
|
|
4078
|
+
"user",
|
|
4079
|
+
"project",
|
|
4080
|
+
"shared",
|
|
4081
|
+
"all"
|
|
4082
|
+
],
|
|
4083
|
+
description: "作用域,默认 all"
|
|
4084
|
+
},
|
|
4085
|
+
limit: {
|
|
4086
|
+
type: "integer",
|
|
4087
|
+
description: "返回条数上限,默认 10(最逾期在前)"
|
|
4088
|
+
}
|
|
4089
|
+
},
|
|
4090
|
+
output: {
|
|
4091
|
+
schema: {
|
|
4092
|
+
type: "object",
|
|
4093
|
+
additionalProperties: false,
|
|
4094
|
+
properties: { text: {
|
|
4095
|
+
type: "string",
|
|
4096
|
+
required: true
|
|
4097
|
+
} }
|
|
4098
|
+
},
|
|
4099
|
+
render: (_args, value) => [{
|
|
4100
|
+
type: "text",
|
|
4101
|
+
text: value.text
|
|
4102
|
+
}]
|
|
4103
|
+
},
|
|
4104
|
+
async execute(args) {
|
|
4105
|
+
const input = args;
|
|
4106
|
+
const scopes = scopesOf(input.scope);
|
|
4107
|
+
const limit = Number.isInteger(input.limit) ? Math.min(Math.max(1, input.limit), 50) : 10;
|
|
4108
|
+
const now = Date.now();
|
|
4109
|
+
const groups = await Promise.all(scopes.map(async (scope) => ({
|
|
4110
|
+
scope,
|
|
4111
|
+
due: await (await deps.openStore(scope)).dueReviews(now, limit)
|
|
4112
|
+
})));
|
|
4113
|
+
const total = groups.reduce((sum, group) => sum + group.due.length, 0);
|
|
4114
|
+
if (total === 0) return { text: "今日无待回忆条目(队列空)。新记忆保存后次日首次到期。" };
|
|
4115
|
+
const lines = [`今日待回忆 ${total} 段(最逾期在前)。对每段:先回忆 → engram_review 核对 → engram_report 传 grade 自评。`];
|
|
4116
|
+
for (const { scope, due } of groups) for (const [index, record] of due.entries()) {
|
|
4117
|
+
const slot = record.slot === void 0 ? "(未排桩)" : `${record.slot.room} #${record.slot.index}`;
|
|
4118
|
+
const placard = record.imagery?.caption ?? "(无门牌)";
|
|
4119
|
+
const overdueDays = record.review?.nextReviewAt === null || record.review?.nextReviewAt === void 0 ? 0 : Math.max(0, Math.floor((now - record.review.nextReviewAt) / 864e5));
|
|
4120
|
+
const overdue = overdueDays === 0 ? "今日到期" : `逾期 ${overdueDays} 天`;
|
|
4121
|
+
lines.push(`${index + 1}. [${scope}] ${slot} · 门牌「${placard}」 · ${overdue} · id=${record.id}`);
|
|
4122
|
+
}
|
|
4123
|
+
return { text: lines.join("\n") };
|
|
4124
|
+
}
|
|
4125
|
+
});
|
|
3603
4126
|
return [
|
|
3604
4127
|
save,
|
|
3605
4128
|
search,
|
|
@@ -3608,7 +4131,7 @@ function createEngramTools(deps) {
|
|
|
3608
4131
|
forget,
|
|
3609
4132
|
defineTool({
|
|
3610
4133
|
name: "engram_report",
|
|
3611
|
-
description: "回报一条记忆(尤其 skill 类)使用后的实际效果:success(有效,提权)或 failure(无效,降权)。id 与 scope 来自 engram_search
|
|
4134
|
+
description: "回报一条记忆(尤其 skill 类)使用后的实际效果:success(有效,提权)或 failure(无效,降权)。id 与 scope 来自 engram_search 结果。也可作为复习自评入口:传 grade(0 完全遗忘 … 5 完美回忆)显式报告回忆质量。效果影响后续召回排序与复习排期,长期无效的记忆会被衰减归档。",
|
|
3612
4135
|
parameters: {
|
|
3613
4136
|
id: {
|
|
3614
4137
|
type: "string",
|
|
@@ -3618,8 +4141,11 @@ function createEngramTools(deps) {
|
|
|
3618
4141
|
outcome: {
|
|
3619
4142
|
type: "string",
|
|
3620
4143
|
enum: ["success", "failure"],
|
|
3621
|
-
|
|
3622
|
-
|
|
4144
|
+
description: "使用效果(与 grade 二选一;同传时 grade 优先)"
|
|
4145
|
+
},
|
|
4146
|
+
grade: {
|
|
4147
|
+
type: "integer",
|
|
4148
|
+
description: "回忆质量自评 0-5(复习答题用;0/1 完全遗忘,3 勉强,5 完美)"
|
|
3623
4149
|
},
|
|
3624
4150
|
scope: {
|
|
3625
4151
|
type: "string",
|
|
@@ -3647,27 +4173,36 @@ function createEngramTools(deps) {
|
|
|
3647
4173
|
confidence: {
|
|
3648
4174
|
type: "number",
|
|
3649
4175
|
required: true
|
|
3650
|
-
}
|
|
4176
|
+
},
|
|
4177
|
+
nextReviewAt: { type: "number" }
|
|
3651
4178
|
}
|
|
3652
4179
|
},
|
|
3653
4180
|
render: (_args, value) => [{
|
|
3654
4181
|
type: "text",
|
|
3655
|
-
text: value.outcome === "success" ? `已记录:记忆 ${value.id} 使用有效(confidence=${value.confidence})。该记忆后续召回排序将提升。` : `已记录:记忆 ${value.id}
|
|
4182
|
+
text: (value.outcome === "success" ? `已记录:记忆 ${value.id} 使用有效(confidence=${value.confidence})。该记忆后续召回排序将提升。` : `已记录:记忆 ${value.id} 标记为无效/遗忘(confidence=${value.confidence})。排序将下降,持续无效会被衰减归档。`) + (value.nextReviewAt === void 0 ? "" : ` 下次复习:${new Date(value.nextReviewAt).toISOString().slice(0, 10)}。`)
|
|
3656
4183
|
}]
|
|
3657
4184
|
},
|
|
3658
4185
|
async execute(args) {
|
|
3659
4186
|
const input = args;
|
|
3660
|
-
|
|
4187
|
+
const hasGrade = Number.isInteger(input.grade) && input.grade >= 0 && input.grade <= 5;
|
|
4188
|
+
if (input.grade !== void 0 && !hasGrade) throw new Error("engram_report: grade 必须是 0-5 的整数");
|
|
4189
|
+
if (input.outcome !== "success" && input.outcome !== "failure" && !hasGrade) throw new Error("engram_report: 需要 outcome(success/failure)或 grade(0-5)参数");
|
|
4190
|
+
const outcome = input.outcome === "success" || input.outcome === "failure" ? input.outcome : input.grade >= 3 ? "success" : "failure";
|
|
4191
|
+
const grade = hasGrade ? input.grade : outcome === "success" ? 5 : 1;
|
|
3661
4192
|
const scope = scopeOf(input.scope, "project");
|
|
3662
|
-
const
|
|
4193
|
+
const store = await deps.openStore(scope);
|
|
4194
|
+
const record = await store.reportOutcome(input.id, outcome);
|
|
3663
4195
|
if (record === void 0) throw new Error(`engram_report: 条目 ${input.id} 不存在(scope=${scope})`);
|
|
4196
|
+
const scheduled = await store.scheduleReview(input.id, grade);
|
|
3664
4197
|
return {
|
|
3665
4198
|
id: record.id,
|
|
3666
|
-
outcome
|
|
3667
|
-
confidence: record.confidence
|
|
4199
|
+
outcome,
|
|
4200
|
+
confidence: record.confidence,
|
|
4201
|
+
...scheduled?.review?.nextReviewAt === null || scheduled?.review?.nextReviewAt === void 0 ? {} : { nextReviewAt: scheduled.review.nextReviewAt }
|
|
3668
4202
|
};
|
|
3669
4203
|
}
|
|
3670
4204
|
}),
|
|
4205
|
+
reviewQueue,
|
|
3671
4206
|
defineTool({
|
|
3672
4207
|
name: "engram_review",
|
|
3673
4208
|
description: "审计一条记忆:查看内容、来源(会话/轮次/事件)、取代链、矛盾与关联,以及最近操作日志。",
|
|
@@ -3723,7 +4258,7 @@ function createEngramTools(deps) {
|
|
|
3723
4258
|
}),
|
|
3724
4259
|
defineTool({
|
|
3725
4260
|
name: "engram_stats",
|
|
3726
|
-
description: "
|
|
4261
|
+
description: "记忆库统计:各状态与种类数量、关系边数、信噪比、操作日志量,以及房间目录(房名/桩位数/最新门牌——检索前先看目录决定进哪个房间,配 engram_search 的 room 参数)。scope=all 时合并两库。",
|
|
3727
4262
|
parameters: { scope: {
|
|
3728
4263
|
type: "string",
|
|
3729
4264
|
enum: [
|
|
@@ -3750,14 +4285,33 @@ function createEngramTools(deps) {
|
|
|
3750
4285
|
},
|
|
3751
4286
|
async execute(args) {
|
|
3752
4287
|
const scopes = scopesOf(args.scope);
|
|
3753
|
-
return { text: (await Promise.all(scopes.map(async (scope) =>
|
|
3754
|
-
scope
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
4288
|
+
return { text: (await Promise.all(scopes.map(async (scope) => {
|
|
4289
|
+
const store = await deps.openStore(scope);
|
|
4290
|
+
const [storeStats, rooms, placards] = await Promise.all([
|
|
4291
|
+
store.stats(),
|
|
4292
|
+
store.slotCountsByRoom(),
|
|
4293
|
+
store.listPlacards()
|
|
4294
|
+
]);
|
|
4295
|
+
return {
|
|
4296
|
+
scope,
|
|
4297
|
+
stats: storeStats,
|
|
4298
|
+
rooms,
|
|
4299
|
+
placards
|
|
4300
|
+
};
|
|
4301
|
+
}))).map(({ scope, stats, rooms, placards }) => {
|
|
4302
|
+
const latestPlacardByRoom = /* @__PURE__ */ new Map();
|
|
4303
|
+
for (const row of placards) if (row.room !== null) latestPlacardByRoom.set(row.room, row.caption);
|
|
4304
|
+
const roomLines = Object.entries(rooms).sort(([a], [b]) => a.localeCompare(b, "zh-Hans-CN")).map(([room, state]) => {
|
|
4305
|
+
const placard = latestPlacardByRoom.get(room);
|
|
4306
|
+
return ` ${room}: ${state.count}/${state.maxIndex} 桩${placard === void 0 ? "" : ` · 最新门牌「${placard}」`}`;
|
|
4307
|
+
});
|
|
4308
|
+
return [
|
|
4309
|
+
`[${scope}] 总数 ${stats.total}(active ${stats.active} / archived ${stats.archived} / forgotten ${stats.forgotten})`,
|
|
4310
|
+
`种类分布: ${Object.entries(stats.byKind).map(([kind, count]) => `${kind}=${count}`).join(", ") || "空"}`,
|
|
4311
|
+
`关系边 ${stats.edges} 条 · 信噪比 ${(stats.signalRatio * 100).toFixed(1)}% · 操作日志 ${stats.opLogCount} 条`,
|
|
4312
|
+
roomLines.length === 0 ? "房间目录: (尚未排桩)" : `房间目录(engram_search 用 room 参数直进):\n${roomLines.join("\n")}`
|
|
4313
|
+
].join("\n");
|
|
4314
|
+
}).join("\n\n") };
|
|
3761
4315
|
}
|
|
3762
4316
|
}),
|
|
3763
4317
|
defineTool({
|
|
@@ -3987,12 +4541,16 @@ function createEngramTools(deps) {
|
|
|
3987
4541
|
}),
|
|
3988
4542
|
defineTool({
|
|
3989
4543
|
name: "engram_tour",
|
|
3990
|
-
description: "
|
|
4544
|
+
description: "巡游路由。mode=fixed:按固定巡游路线走全宫(桩位顺序恒定,骨架长期复用——宫殿的路线永远不变,靠顺序提取);mode=thematic(默认):按主题动态规划 3-7 站(同类巩固 → 走廊相邻 → 反差补位),适合用户问起某主题时给出一条可走的导览路线。",
|
|
3991
4545
|
parameters: {
|
|
3992
4546
|
query: {
|
|
3993
4547
|
type: "string",
|
|
3994
|
-
|
|
3995
|
-
|
|
4548
|
+
description: "巡游主题(thematic 模式必填,与 engram_search 同义)"
|
|
4549
|
+
},
|
|
4550
|
+
mode: {
|
|
4551
|
+
type: "string",
|
|
4552
|
+
enum: ["fixed", "thematic"],
|
|
4553
|
+
description: "fixed 固定路线全宫巡游 / thematic 主题动态路线(默认 thematic)"
|
|
3996
4554
|
},
|
|
3997
4555
|
scope: {
|
|
3998
4556
|
type: "string",
|
|
@@ -4006,7 +4564,7 @@ function createEngramTools(deps) {
|
|
|
4006
4564
|
},
|
|
4007
4565
|
maxStops: {
|
|
4008
4566
|
type: "integer",
|
|
4009
|
-
description: "最多站数(默认 6,3-7
|
|
4567
|
+
description: "最多站数(默认 6,3-7 之间;fixed 模式默认 20)"
|
|
4010
4568
|
}
|
|
4011
4569
|
},
|
|
4012
4570
|
output: {
|
|
@@ -4026,9 +4584,41 @@ function createEngramTools(deps) {
|
|
|
4026
4584
|
async execute(args, exec) {
|
|
4027
4585
|
const input = args;
|
|
4028
4586
|
const scopes = scopesOf(input.scope);
|
|
4587
|
+
if ((input.mode === "fixed" ? "fixed" : "thematic") === "fixed") {
|
|
4588
|
+
const maxStops = Number.isInteger(input.maxStops) ? Math.max(1, input.maxStops) : 20;
|
|
4589
|
+
const sections = [];
|
|
4590
|
+
let shown = 0;
|
|
4591
|
+
let skipped = 0;
|
|
4592
|
+
for (const scope of scopes) {
|
|
4593
|
+
const store = await deps.openStore(scope);
|
|
4594
|
+
const route = await store.routeList();
|
|
4595
|
+
if (route.length === 0) continue;
|
|
4596
|
+
const records = await store.getMany(route.map((stop) => stop.id));
|
|
4597
|
+
const byId = new Map(records.map((record) => [String(record.id), record]));
|
|
4598
|
+
const lines = [];
|
|
4599
|
+
for (const stop of route) {
|
|
4600
|
+
if (shown >= maxStops) break;
|
|
4601
|
+
const record = byId.get(String(stop.id));
|
|
4602
|
+
if (record === void 0 || record.status !== "active") {
|
|
4603
|
+
skipped += 1;
|
|
4604
|
+
continue;
|
|
4605
|
+
}
|
|
4606
|
+
shown += 1;
|
|
4607
|
+
const slot = record.slot === void 0 ? "" : `${record.slot.room} #${record.slot.index} · `;
|
|
4608
|
+
const placard = record.imagery?.caption;
|
|
4609
|
+
lines.push(`第 ${stop.position + 1} 站 · ${slot}[${record.kind}] ${truncateItem(record.content)}(id=${record.id})${placard === null || placard === void 0 ? "" : ` · 门牌「${placard}」`}`);
|
|
4610
|
+
}
|
|
4611
|
+
if (lines.length > 0) sections.push(`【${scope} 宫殿 · 固定巡游】\n${lines.join("\n")}`);
|
|
4612
|
+
}
|
|
4613
|
+
if (shown === 0) return { text: "巡游路线为空:尚无排桩记忆(保存记忆后自动登记路线)。" };
|
|
4614
|
+
const tail = skipped > 0 ? `\n(另有 ${skipped} 个空桩:原记忆已闭馆或归档,桩位保留不回收)` : "";
|
|
4615
|
+
return { text: `${sections.join("\n\n")}${tail}` };
|
|
4616
|
+
}
|
|
4617
|
+
if (typeof input.query !== "string" || input.query.trim() === "") throw new Error("engram_tour: thematic 模式需要 query 参数(巡游主题)");
|
|
4618
|
+
const tourQuery = input.query;
|
|
4029
4619
|
const limit = 12;
|
|
4030
4620
|
const maxStops = Number.isInteger(input.maxStops) ? Math.min(Math.max(3, input.maxStops), 7) : 6;
|
|
4031
|
-
const rewrite = await rewriteQueries(deps, exec,
|
|
4621
|
+
const rewrite = await rewriteQueries(deps, exec, tourQuery);
|
|
4032
4622
|
const retrievals = await Promise.all(rewrite.queries.map(async (queryText) => {
|
|
4033
4623
|
const vector = await queryVectorOf(deps, queryText);
|
|
4034
4624
|
const results = await Promise.all(scopes.map(async (scope) => {
|
|
@@ -4048,7 +4638,7 @@ function createEngramTools(deps) {
|
|
|
4048
4638
|
const userStore = await deps.openStore("user");
|
|
4049
4639
|
const projectStore = await deps.openStore("project");
|
|
4050
4640
|
const poolLookup = async (id) => await userStore.get(id) ?? await projectStore.get(id);
|
|
4051
|
-
const route = await planTour(merged, poolLookup,
|
|
4641
|
+
const route = await planTour(merged, poolLookup, tourQuery, maxStops);
|
|
4052
4642
|
return { text: `${degraded ? "(语义嵌入不可用,仅关键词检索)\n" : ""}${route.narrative}` };
|
|
4053
4643
|
}
|
|
4054
4644
|
}),
|
|
@@ -4193,7 +4783,7 @@ function reasonsLabel(reasons) {
|
|
|
4193
4783
|
/** Cordis 插件名(loader 诊断与注入 source 使用)。 */
|
|
4194
4784
|
const name = "dsh-engram";
|
|
4195
4785
|
/** 插件版本(与 package.json 同步,写进备份 _meta.json)。 */
|
|
4196
|
-
const VERSION = "0.7.
|
|
4786
|
+
const VERSION = "0.7.2";
|
|
4197
4787
|
/** 必需服务:工具注册表与 LLM 流式端点(摄取/蒸馏的辅助调用)。 */
|
|
4198
4788
|
const inject = ["tools", "llm"];
|
|
4199
4789
|
/**
|
|
@@ -4206,13 +4796,14 @@ const inject = ["tools", "llm"];
|
|
|
4206
4796
|
*/
|
|
4207
4797
|
function renderProfileDetailed(records, tokenBudget) {
|
|
4208
4798
|
const estimate = (text) => Math.ceil(text.length / 4);
|
|
4209
|
-
const header = "User memory profile (dsh-engram, cross-session):";
|
|
4210
|
-
const footer = "Use engram_search to recall details; use engram_save to persist new facts.";
|
|
4799
|
+
const header = "User memory profile (dsh-engram, cross-session) — Grand Hall (always present):";
|
|
4800
|
+
const footer = "Use engram_search to recall details (pass room to search inside one room); use engram_save to persist new facts.";
|
|
4211
4801
|
let remaining = Math.max(0, tokenBudget - estimate(header) - estimate(footer));
|
|
4212
4802
|
const lines = [];
|
|
4213
4803
|
const overflow = [];
|
|
4214
4804
|
for (const record of records) {
|
|
4215
|
-
const
|
|
4805
|
+
const slot = record.slot === void 0 ? "" : ` ${record.slot.room}#${record.slot.index}`;
|
|
4806
|
+
const line = `- [${record.kind}]${slot} ${record.content}`;
|
|
4216
4807
|
const cost = estimate(line);
|
|
4217
4808
|
if (cost <= remaining) {
|
|
4218
4809
|
lines.push(line);
|
|
@@ -4344,6 +4935,7 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
4344
4935
|
if (step !== 1) return decision;
|
|
4345
4936
|
const top = await (await openStore("user")).topActive("user", resolved.profileTopN);
|
|
4346
4937
|
if (top.length === 0) return decision;
|
|
4938
|
+
const dueTotal = resolved.reviewScheduling ? (await Promise.all(["user", "project"].map(async (scope) => (await openStore(scope)).dueReviews(Date.now(), 50)))).reduce((sum, rows) => sum + rows.length, 0) : 0;
|
|
4347
4939
|
const detailed = renderProfileDetailed(top, resolved.injectTokenBudget);
|
|
4348
4940
|
let text = detailed.text;
|
|
4349
4941
|
if (detailed.overflow.length > 0) {
|
|
@@ -4359,7 +4951,8 @@ async function preStep(ctx, openStore, resolved, embedder, state, logRequest, {
|
|
|
4359
4951
|
}), resolved.injectTokenBudget).text;
|
|
4360
4952
|
}
|
|
4361
4953
|
}
|
|
4362
|
-
const
|
|
4954
|
+
const dueLine = dueTotal === 0 ? "" : `\nPalace review due today: ${dueTotal}${dueTotal >= 50 ? "+" : ""} memories. Use engram_review_queue for active recall (recall beats re-reading).`;
|
|
4955
|
+
const textWithRationale = wrapWithRationale(text, top) + dueLine;
|
|
4363
4956
|
const hash = createHash("sha256").update(textWithRationale).digest("hex");
|
|
4364
4957
|
if (state.lastProfileAgent === String(agent.id) && hash === state.lastProfileHash) return decision;
|
|
4365
4958
|
state.lastProfileAgent = String(agent.id);
|
|
@@ -4425,7 +5018,17 @@ function apply(ctx, config = {}) {
|
|
|
4425
5018
|
const openStore = (scope) => {
|
|
4426
5019
|
const existing = stores.get(scope);
|
|
4427
5020
|
if (existing !== void 0) return existing;
|
|
4428
|
-
const
|
|
5021
|
+
const path = scope === "user" ? join(resolved.dbDir, "user.db") : scope === "shared" ? join(resolved.dbDir, "shared.db") : join(resolved.dbDir, identity.dbName);
|
|
5022
|
+
const created = openEngramStore(path, rankBoost, {
|
|
5023
|
+
autoSlot: resolved.autoSlot,
|
|
5024
|
+
reviewScheduling: resolved.reviewScheduling
|
|
5025
|
+
}).then(async (store) => {
|
|
5026
|
+
const assigned = await store.backfillSlots((room) => {
|
|
5027
|
+
console.warn(`[dsh-engram] 房间已满,自动开新房「${room}」(可在管理面板翻新清单中人工拆分/命名)`);
|
|
5028
|
+
});
|
|
5029
|
+
if (assigned > 0) console.warn(`[dsh-engram] 存量记忆排桩完成:${assigned} 条已钉入宫殿(${path})`);
|
|
5030
|
+
return store;
|
|
5031
|
+
});
|
|
4429
5032
|
stores.set(scope, created);
|
|
4430
5033
|
return created;
|
|
4431
5034
|
};
|
|
@@ -4482,6 +5085,8 @@ function apply(ctx, config = {}) {
|
|
|
4482
5085
|
}),
|
|
4483
5086
|
logRequest: logIngestRequest,
|
|
4484
5087
|
signal: AbortSignal.timeout(FINAL_INGEST_TIMEOUT_MS)
|
|
5088
|
+
}).catch((error) => {
|
|
5089
|
+
console.warn("[dsh-engram] 会话结束的末轮摄取异常(不影响对话):", error);
|
|
4485
5090
|
});
|
|
4486
5091
|
});
|
|
4487
5092
|
const runDecay = async () => {
|