@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/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
- const round = lastTurnNumber(deps.events);
758
- if (round === void 0) return null;
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 = 5;
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 不再升版:闭馆三问用 op_log JSON 详情承载(已有 audit 接口),不改表结构。 */
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) db.prepare("INSERT INTO meta (key, value) VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
2248
- else {
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
- VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?, ?)`);
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
- /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重)。 */
2325
- const ftsSearch = (match, scopes) => {
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
- return db.prepare(`SELECT * FROM nodes WHERE status = 'active' AND embedding IS NOT NULL AND scope IN (${placeholders})`).all(...scopes);
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
- sqlInsert.run(id, input.scope, input.kind, content, importance, confidence, at, at, sourceSessionId, input.sourceRound ?? null, input.sourceSeq ?? null, stored, imageryToJson(imagery));
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
- insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "write");
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
- insertRecord(id, {
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
- }, content, input.importance ?? old.importance, old.confidence, at, old.source_session_id, input.embedding ?? old.embedding, input.imagery ?? jsonToImagery(old.imagery_json), "update");
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 * FROM nodes WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, filter.limit, filter.offset).map(rowToRecord),
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
- insertRecord(id, input, content, input.importance ?? .5, input.confidence ?? .5, at, input.sourceSessionId ?? null, input.embedding ?? null, input.imagery, "distill");
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
- * 9 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
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
- * 构造 10 个工具定义(engram_save/search/timeline/update/forget/report/review/stats/export/distill)。
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 ?? []) parts.push(`${item.id}(kind=${item.kind}, importance=${item.importance})`);
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: "语义 + 关键词混合检索长期记忆。user 作用域存偏好与通用事实,project 作用域存项目约定与决策。结果行尾给出 id,供 engram_update/engram_forget 引用。",
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
- return `${index + 1}. [${hit.record.scope}/${hit.record.kind}] ${truncateItem(hit.record.content)}(id=${hit.record.id})${edge}`;
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
- required: true,
3622
- description: "使用效果"
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} 使用无效(confidence=${value.confidence})。该记忆后续召回排序将下降,持续无效会被衰减归档。`
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
- if (input.outcome !== "success" && input.outcome !== "failure") throw new Error("engram_report: outcome 必须是 success 或 failure");
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 record = await (await deps.openStore(scope)).reportOutcome(input.id, input.outcome);
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: input.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: "记忆库统计:各状态与种类数量、关系边数、信噪比、操作日志量。scope=all 时合并两库。",
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
- stats: await (await deps.openStore(scope)).stats()
3756
- })))).map(({ scope, stats }) => [
3757
- `[${scope}] 总数 ${stats.total}(active ${stats.active} / archived ${stats.archived} / forgotten ${stats.forgotten})`,
3758
- `种类分布: ${Object.entries(stats.byKind).map(([kind, count]) => `${kind}=${count}`).join(", ") || "空"}`,
3759
- `关系边 ${stats.edges} 条 · 信噪比 ${(stats.signalRatio * 100).toFixed(1)}% · 操作日志 ${stats.opLogCount} 条`
3760
- ].join("\n")).join("\n\n") };
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: "巡游路由:按「同类巩固 → 走廊相邻 → 反差补位」顺序组织 3-7 间房间,每站附入选理由 + 意象铭牌回声。适合在用户问起某主题时直接给出一条可走的导览路线,而不是无序结果集。",
4544
+ description: "巡游路由。mode=fixed:按固定巡游路线走全宫(桩位顺序恒定,骨架长期复用——宫殿的路线永远不变,靠顺序提取);mode=thematic(默认):按主题动态规划 3-7 站(同类巩固 → 走廊相邻 → 反差补位),适合用户问起某主题时给出一条可走的导览路线。",
3991
4545
  parameters: {
3992
4546
  query: {
3993
4547
  type: "string",
3994
- required: true,
3995
- description: "巡游主题(与 engram_search 同义)"
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, input.query);
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, input.query, maxStops);
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.0";
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 line = `- [${record.kind}] ${record.content}`;
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 textWithRationale = wrapWithRationale(text, top);
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 created = openEngramStore(scope === "user" ? join(resolved.dbDir, "user.db") : scope === "shared" ? join(resolved.dbDir, "shared.db") : join(resolved.dbDir, identity.dbName), rankBoost);
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 () => {