@kenz1117/dsh-engram 0.7.12 → 0.7.14

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
@@ -23,6 +23,7 @@ const CONFIG_KEYS = /* @__PURE__ */ new Set([
23
23
  "modelCacheDir",
24
24
  "hfEndpoint",
25
25
  "ingest",
26
+ "reflect",
26
27
  "provider",
27
28
  "model",
28
29
  "decayAfterDays",
@@ -50,6 +51,11 @@ const INGEST_MODES = /* @__PURE__ */ new Set([
50
51
  "light",
51
52
  "eager"
52
53
  ]);
54
+ const REFLECT_MODES = /* @__PURE__ */ new Set([
55
+ "off",
56
+ "suggest",
57
+ "auto"
58
+ ]);
53
59
  /** Schemastery 校验面(cordis.yml 读取时校验)。 */
54
60
  const Config = z.object({
55
61
  dbDir: z.string(),
@@ -59,6 +65,7 @@ const Config = z.object({
59
65
  modelCacheDir: z.string(),
60
66
  hfEndpoint: z.string(),
61
67
  ingest: z.string(),
68
+ reflect: z.string(),
62
69
  provider: z.string(),
63
70
  model: z.string(),
64
71
  decayAfterDays: z.number().step(1).min(1).max(3650),
@@ -100,6 +107,7 @@ function resolveConfig(config = {}) {
100
107
  for (const key of Object.keys(config)) if (!CONFIG_KEYS.has(key)) throw new Error(`dsh-engram: unknown config key "${key}"`);
101
108
  if (config.legacyMigration !== void 0 && config.legacyMigration !== "eager" && config.legacyMigration !== "conservative") throw new Error("dsh-engram: legacyMigration must be one of eager|conservative");
102
109
  if (config.ingest !== void 0 && !INGEST_MODES.has(config.ingest)) throw new Error(`dsh-engram: ingest must be one of off|light|eager, got "${String(config.ingest)}"`);
110
+ if (config.reflect !== void 0 && !REFLECT_MODES.has(config.reflect)) throw new Error(`dsh-engram: reflect must be one of off|suggest|auto, got "${String(config.reflect)}"`);
103
111
  if (config.profileTopN !== void 0 && (!Number.isInteger(config.profileTopN) || config.profileTopN < 1 || config.profileTopN > 64)) throw new Error("dsh-engram: profileTopN must be an integer in [1, 64]");
104
112
  const hasProvider = config.provider !== void 0;
105
113
  const hasModel = config.model !== void 0;
@@ -136,6 +144,7 @@ function resolveConfig(config = {}) {
136
144
  modelCacheDir: config.modelCacheDir ?? join(dbDir, "models"),
137
145
  hfEndpoint: config.hfEndpoint,
138
146
  ingest: config.ingest ?? "off",
147
+ reflect: config.reflect ?? "off",
139
148
  routeOverride: hasProvider && hasModel ? {
140
149
  provider: config.provider,
141
150
  model: config.model
@@ -186,6 +195,10 @@ function asEntityId(raw) {
186
195
  function asFactId(raw) {
187
196
  return raw;
188
197
  }
198
+ /** 从任意字符串铸造品牌化信念 id(存储层入库前调用)。 */
199
+ function asObservationId(raw) {
200
+ return raw;
201
+ }
189
202
  /** 归一化比较键:trim + 合并连续空白 + 小写(消解第一版只做精确归一匹配,不做相似合并)。 */
190
203
  function normalizeEntityName(raw) {
191
204
  return raw.trim().replace(/\s+/g, " ").toLowerCase();
@@ -314,17 +327,13 @@ function finishError(finish) {
314
327
  * @returns 模型输出全文。
315
328
  */
316
329
  async function streamText(ctx, params) {
317
- const messages = [{
318
- role: "user",
330
+ const messages = [createUserMessage({
319
331
  content: [{
320
332
  type: "text",
321
333
  text: params.userText
322
334
  }],
323
- source: {
324
- kind: "plugin",
325
- plugin: "dsh-engram"
326
- }
327
- }];
335
+ source: { kind: "plugin:dsh-engram" }
336
+ })];
328
337
  const options = {
329
338
  provider: params.route.provider,
330
339
  model: params.route.model,
@@ -823,7 +832,7 @@ function collectTexts(events, includeAssistant) {
823
832
  let minSeq = null;
824
833
  for (const event of events) if (event.type === "user/message") {
825
834
  const data = event.data;
826
- if (data?.source?.kind === "plugin") continue;
835
+ if (typeof data?.source?.kind === "string" && data.source.kind.startsWith("plugin")) continue;
827
836
  const segments = (data?.content ?? []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text);
828
837
  if (segments.length > 0) {
829
838
  texts.push(...segments);
@@ -871,7 +880,7 @@ function turnSignals(events) {
871
880
  const toolNames = /* @__PURE__ */ new Set();
872
881
  for (const event of events) if (event.type === "user/message") {
873
882
  const data = event.data;
874
- if (data?.source?.kind === "plugin") continue;
883
+ if (typeof data?.source?.kind === "string" && data.source.kind.startsWith("plugin")) continue;
875
884
  for (const block of data?.content ?? []) if (block?.type === "text" && typeof block.text === "string") userChars += block.text.length;
876
885
  } else if (event.type === "assistant/message") {
877
886
  if ((event.data?.content ?? []).some((block) => block?.type === "text" && typeof block.text === "string" && block.text !== "")) completedTurns = 1;
@@ -1182,7 +1191,7 @@ function collectSessionText(events) {
1182
1191
  const texts = [];
1183
1192
  for (const event of events) if (event.type === "user/message") {
1184
1193
  const data = event.data;
1185
- if (data?.source?.kind === "plugin") continue;
1194
+ if (typeof data?.source?.kind === "string" && data.source.kind.startsWith("plugin")) continue;
1186
1195
  const segments = (data?.content ?? []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text);
1187
1196
  if (segments.length > 0) texts.push(...segments);
1188
1197
  } else if (event.type === "assistant/message") {
@@ -1713,6 +1722,241 @@ function cosine$1(a, b) {
1713
1722
  return denom === 0 ? 0 : dot / denom;
1714
1723
  }
1715
1724
  //#endregion
1725
+ //#region src/consolidation/reflect.ts
1726
+ /**
1727
+ * 信念巩固(Reflect):把尚未成为任何信念证据的原始记忆交辅助 LLM 巩固为一句话信念
1728
+ * (observation),并对被新证据波及的 stale 信念做新鲜度复核。
1729
+ *
1730
+ * 与 flywheel/distill 的区别:蒸馏是「合并归档」(旧记忆进 supersedes 链后消失于 active),
1731
+ * 巩固是「抽象叠加」(原始记忆全部保留,信念是带证据引用的额外一层;同一主题经向量近邻
1732
+ * 归并持续细化同一条,而非反复新增)。
1733
+ *
1734
+ * 四个动作:
1735
+ * - form :从新证据形成信念;向量近邻达到阈值时并入既有信念(refine),否则新建
1736
+ * - refine :重写 stale 信念的表述并并入新证据(status 回 active)
1737
+ * - still :stale 信念经证据复核仍成立(仅刷新复核时间)
1738
+ * - refute :stale 信念被证据否定(status 置 refuted,保留证据链供审计)
1739
+ * @module @kenz1117/dsh-engram/consolidation/reflect
1740
+ */
1741
+ /** 取材池大小:从 active 记忆按 importance/confidence 倒序扫描的上限。 */
1742
+ const REFLECT_POOL = 300;
1743
+ /** 单次巩固喂给 LLM 的新证据条数上限(协议内常量:单次辅助调用的可控上下文)。 */
1744
+ const REFLECT_MAX_INPUT = 30;
1745
+ /** 单次喂给 LLM 的待复核 stale 信念上限。 */
1746
+ const REFLECT_MAX_STALE = 5;
1747
+ /** 单条 stale 信念附带的证据原文条数上限。 */
1748
+ const REFLECT_STALE_EVIDENCE_LIMIT = 6;
1749
+ /** stale 证据原文截断字符数(控制总上下文)。 */
1750
+ const REFLECT_EVIDENCE_SLICE = 120;
1751
+ /** 巩固输出 token 上限。 */
1752
+ const REFLECT_MAX_TOKENS = 900;
1753
+ /** 信念向量近邻归并阈值:新信念与既有信念余弦达到该值则并入同一条(持续细化)。 */
1754
+ const REFLECT_MERGE_THRESHOLD = .9;
1755
+ /** 新鲜度阈值:新证据与既有信念余弦达到该值(低于归并阈值,语义更宽)则把信念标 stale 待复核。 */
1756
+ const REFLECT_STALE_THRESHOLD = .86;
1757
+ const REFLECT_SYSTEM = [
1758
+ "下面给出两部分材料:【待巩固记忆】是新近写入的原始记忆;【待复核信念】是此前巩固出、但可能受新证据影响的信念(附原始证据)。",
1759
+ "请对材料做巩固与复核,只输出一个 JSON 数组,每项为以下四种动作之一:",
1760
+ "1. {\"action\":\"form\",\"belief\":\"巩固出的一句话信念(结论/规律/稳定偏好)\",\"sourceIds\":[\"证据记忆id\"]} —— sourceIds 至少 1 个且必须全部来自【待巩固记忆】的 id;多条记忆支撑同一信念时合并成一条,不要为单条琐碎记忆立信念。",
1761
+ "2. {\"action\":\"refine\",\"observationId\":\"待复核信念id\",\"belief\":\"结合新证据后的新表述\",\"sourceIds\":[\"新增证据记忆id\"]} —— 新证据补充或限定了该信念时使用;sourceIds 必须来自【待巩固记忆】,可为空数组(仅表述更准确)。",
1762
+ "3. {\"action\":\"still\",\"observationId\":\"待复核信念id\"} —— 新证据不影响该信念,原表述仍成立。",
1763
+ "4. {\"action\":\"refute\",\"observationId\":\"待复核信念id\",\"reason\":\"被否定的原因(引用证据)\"} —— 新证据与信念矛盾且原信念不再成立。",
1764
+ "observationId 必须取自【待复核信念】给出的 id。材料中没有值得巩固或复核的内容时输出 []。不要输出 JSON 以外的任何内容。"
1765
+ ].join("\n");
1766
+ /**
1767
+ * 执行一次信念巩固。纯异步:LLM/解析失败抛错(调用层决定是否落 pending),
1768
+ * 单条动作的写入失败计入 skipped 不中断整批。
1769
+ */
1770
+ async function reflectObservations(deps) {
1771
+ const outcome = {
1772
+ pool: 0,
1773
+ candidates: 0,
1774
+ staleReviewed: 0,
1775
+ formed: 0,
1776
+ refined: 0,
1777
+ confirmed: 0,
1778
+ refuted: 0,
1779
+ skipped: 0
1780
+ };
1781
+ const pool = await deps.store.topActive(deps.scope, REFLECT_POOL);
1782
+ outcome.pool = pool.length;
1783
+ const known = await deps.store.listObservations({ limit: 1e3 });
1784
+ const evidenced = /* @__PURE__ */ new Set();
1785
+ for (const observation of known) {
1786
+ if (observation.status === "refuted") continue;
1787
+ for (const sourceId of observation.sourceIds) evidenced.add(String(sourceId));
1788
+ }
1789
+ const candidates = pool.filter((record) => !evidenced.has(String(record.id))).slice(0, REFLECT_MAX_INPUT);
1790
+ if (deps.embedder !== void 0 && candidates.length > 0) {
1791
+ const vectors = await deps.embedder.embed(candidates.map((record) => record.content));
1792
+ const staleMarks = /* @__PURE__ */ new Set();
1793
+ for (const vector of vectors) {
1794
+ if (vector === void 0) continue;
1795
+ const nearest = await deps.store.nearestObservation(vector);
1796
+ if (nearest !== void 0 && nearest.record.status === "active" && nearest.similarity >= REFLECT_STALE_THRESHOLD) staleMarks.add(String(nearest.record.id));
1797
+ }
1798
+ for (const id of staleMarks) try {
1799
+ await deps.store.markObservationStale(asObservationId(id));
1800
+ } catch {}
1801
+ }
1802
+ const stale = await deps.store.listObservations({
1803
+ status: "stale",
1804
+ limit: REFLECT_MAX_STALE
1805
+ });
1806
+ outcome.candidates = candidates.length;
1807
+ outcome.staleReviewed = stale.length;
1808
+ if (candidates.length === 0 && stale.length === 0) return outcome;
1809
+ const candidateIds = new Set(candidates.map((record) => String(record.id)));
1810
+ const staleIds = new Set(stale.map((observation) => String(observation.id)));
1811
+ const sections = [];
1812
+ if (candidates.length > 0) {
1813
+ const lines = candidates.map((record) => `[${String(record.id)}] (${record.kind}, importance ${record.importance.toFixed(2)}) ${record.content}`);
1814
+ sections.push(`【待巩固记忆】\n${lines.join("\n")}`);
1815
+ }
1816
+ if (stale.length > 0) {
1817
+ const blocks = [];
1818
+ for (const observation of stale) {
1819
+ const evidence = (await deps.store.getMany(observation.sourceIds.slice(0, REFLECT_STALE_EVIDENCE_LIMIT))).map((record) => ` - ${record.content.slice(0, REFLECT_EVIDENCE_SLICE)}`).join("\n");
1820
+ blocks.push(`[${String(observation.id)}] 证据 ${String(observation.proofCount)} 条:${observation.belief}\n${evidence}`);
1821
+ }
1822
+ sections.push(`【待复核信念】\n${blocks.join("\n")}`);
1823
+ }
1824
+ const userText = sections.join("\n\n");
1825
+ deps.logRequest({
1826
+ route: deps.route,
1827
+ scope: deps.scope,
1828
+ candidateCount: candidates.length,
1829
+ staleCount: stale.length,
1830
+ maxTokens: REFLECT_MAX_TOKENS
1831
+ });
1832
+ const parsed = parseJsonArray(await deps.call({
1833
+ route: deps.route,
1834
+ system: REFLECT_SYSTEM,
1835
+ userText,
1836
+ maxTokens: REFLECT_MAX_TOKENS,
1837
+ purpose: "engram-reflect",
1838
+ signal: deps.signal
1839
+ }));
1840
+ if (parsed === void 0 || parsed.length === 0) return outcome;
1841
+ for (const item of parsed) {
1842
+ const action = item;
1843
+ try {
1844
+ if (action.action === "form") {
1845
+ if (await applyForm(deps, action, candidateIds) === "refined") outcome.refined += 1;
1846
+ else outcome.formed += 1;
1847
+ } else if (action.action === "refine") {
1848
+ const observationId = requireObservationId(action.observationId, staleIds);
1849
+ if (observationId === void 0) {
1850
+ outcome.skipped += 1;
1851
+ continue;
1852
+ }
1853
+ if (typeof action.belief !== "string" || action.belief.trim() === "") {
1854
+ outcome.skipped += 1;
1855
+ continue;
1856
+ }
1857
+ const sourceIds = requireCandidateIds(action.sourceIds, candidateIds);
1858
+ if (sourceIds === void 0) {
1859
+ outcome.skipped += 1;
1860
+ continue;
1861
+ }
1862
+ await refineWithEmbedding(deps, observationId, action.belief.trim(), sourceIds);
1863
+ outcome.refined += 1;
1864
+ } else if (action.action === "still") {
1865
+ const observationId = requireObservationId(action.observationId, staleIds);
1866
+ if (observationId === void 0) {
1867
+ outcome.skipped += 1;
1868
+ continue;
1869
+ }
1870
+ await deps.store.confirmObservation(observationId);
1871
+ outcome.confirmed += 1;
1872
+ } else if (action.action === "refute") {
1873
+ const observationId = requireObservationId(action.observationId, staleIds);
1874
+ if (observationId === void 0) {
1875
+ outcome.skipped += 1;
1876
+ continue;
1877
+ }
1878
+ await deps.store.refuteObservation(observationId);
1879
+ outcome.refuted += 1;
1880
+ } else outcome.skipped += 1;
1881
+ } catch {
1882
+ outcome.skipped += 1;
1883
+ }
1884
+ }
1885
+ try {
1886
+ await deps.store.audit("reflect", "AUX", JSON.stringify({
1887
+ scope: deps.scope,
1888
+ pool: outcome.pool,
1889
+ candidates: outcome.candidates,
1890
+ staleReviewed: outcome.staleReviewed,
1891
+ formed: outcome.formed,
1892
+ refined: outcome.refined,
1893
+ confirmed: outcome.confirmed,
1894
+ refuted: outcome.refuted,
1895
+ skipped: outcome.skipped
1896
+ }));
1897
+ } catch {}
1898
+ return outcome;
1899
+ }
1900
+ /** 处理 form:校验信念与证据,算向量并决定近邻归并或新建。@returns 落库形态。 */
1901
+ async function applyForm(deps, action, candidateIds) {
1902
+ if (typeof action.belief !== "string" || action.belief.trim() === "") throw new Error("form: belief 缺失");
1903
+ const sourceIds = requireCandidateIds(action.sourceIds, candidateIds);
1904
+ if (sourceIds === void 0 || sourceIds.length === 0) throw new Error("form: sourceIds 越界或为空");
1905
+ const belief = action.belief.trim();
1906
+ if (deps.embedder !== void 0) {
1907
+ const vector = (await deps.embedder.embed([belief]))[0];
1908
+ if (vector !== void 0) {
1909
+ const nearest = await deps.store.nearestObservation(vector);
1910
+ if (nearest !== void 0 && nearest.similarity >= REFLECT_MERGE_THRESHOLD) {
1911
+ await deps.store.refineObservation(nearest.record.id, belief, sourceIds, vector);
1912
+ return "refined";
1913
+ }
1914
+ await deps.store.createObservation({
1915
+ scope: deps.scope,
1916
+ belief,
1917
+ sourceIds,
1918
+ embedding: vector
1919
+ });
1920
+ return "formed";
1921
+ }
1922
+ }
1923
+ await deps.store.createObservation({
1924
+ scope: deps.scope,
1925
+ belief,
1926
+ sourceIds
1927
+ });
1928
+ return "formed";
1929
+ }
1930
+ /** refine 时同样尝试为新表述算向量(失败则只改文本与证据,保留旧向量)。 */
1931
+ async function refineWithEmbedding(deps, observationId, belief, sourceIds) {
1932
+ if (deps.embedder !== void 0) {
1933
+ const vector = (await deps.embedder.embed([belief]))[0];
1934
+ if (vector !== void 0) {
1935
+ await deps.store.refineObservation(observationId, belief, sourceIds, vector);
1936
+ return;
1937
+ }
1938
+ }
1939
+ await deps.store.refineObservation(observationId, belief, sourceIds);
1940
+ }
1941
+ /** 校验 observationId 是本次喂入的 stale 信念;非法返回 undefined(跳过该动作)。 */
1942
+ function requireObservationId(raw, staleIds) {
1943
+ if (typeof raw !== "string" || !staleIds.has(raw)) return void 0;
1944
+ return asObservationId(raw);
1945
+ }
1946
+ /**
1947
+ * 校验 sourceIds:必须为数组、每个 id 都来自本次候选集;返回品牌化 id 列表。
1948
+ * 非数组或含越界 id 返回 undefined(动作跳过);空数组合法(refine 仅改表述时使用)。
1949
+ */
1950
+ function requireCandidateIds(raw, candidateIds) {
1951
+ if (!Array.isArray(raw)) return void 0;
1952
+ const ids = [];
1953
+ for (const item of raw) {
1954
+ if (typeof item !== "string" || !candidateIds.has(item)) return void 0;
1955
+ ids.push(asMemoryId(item));
1956
+ }
1957
+ return ids;
1958
+ }
1959
+ //#endregion
1716
1960
  //#region src/jev/client.ts
1717
1961
  /**
1718
1962
  * 调用 Jev /v1/systemone 端点并行回答 Noul 问题。
@@ -3646,7 +3890,7 @@ function placardImprovementHint(score) {
3646
3890
  * @module @kenz1117/dsh-engram/store/sqlite
3647
3891
  */
3648
3892
  /** 当前 schema 版本;结构性变更必须 +1。可空列与伴随表走增量迁移(见 openEngramStore 的迁移段)。 */
3649
- const SCHEMA_VERSION = 11;
3893
+ const SCHEMA_VERSION = 12;
3650
3894
  /** 增量迁移表:key 为起始版本,value 为升到下一版本的 SQL(可多语句)。
3651
3895
  * v2 → v3:nodes 补可空列 outcome(使用效果回报)。
3652
3896
  * v3 → v4:新增 nodes_revisions 修订表(update 归档旧条目时的内容快照)。
@@ -3666,7 +3910,11 @@ const SCHEMA_VERSION = 11;
3666
3910
  * v10 → v11:facts 事实表——摄取/工具期抽取的一句话事实,挂在实体上(entity_id
3667
3911
  * 引用同库 entities.id,无外键)。valid_at/invalid_at 时间窗 + replaced_by 软失效
3668
3912
  * 链:新事实可声明取代旧事实(旧事实置 invalid_at 并回指 replaced_by),历史链
3669
- * 保留可审计;asOf 查询按时间窗过滤(见 factsOfEntity)。 */
3913
+ * 保留可审计;asOf 查询按时间窗过滤(见 factsOfEntity)。
3914
+ * v11 → v12:observations 巩固信念表——辅助 LLM 从多条原始记忆巩固出的一句话信念,
3915
+ * sources_json 证据 id 列表 + proof_count 证据数(去重并集持续累积),status 三态
3916
+ * active/stale/refuted 支持新鲜度复核;embedding 支持近邻归并(同主题并入同一条而非
3917
+ * 重复新增)。与 nodes 无外键(源记忆可被归档/遗忘,证据链只保留 id 引用)。 */
3670
3918
  const MIGRATIONS = {
3671
3919
  "2": "ALTER TABLE nodes ADD COLUMN outcome TEXT",
3672
3920
  "3": `CREATE TABLE IF NOT EXISTS nodes_revisions (
@@ -3707,7 +3955,12 @@ const MIGRATIONS = {
3707
3955
  id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, content TEXT NOT NULL,
3708
3956
  valid_at INTEGER NOT NULL, invalid_at INTEGER, replaced_by TEXT,
3709
3957
  source_node_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
3710
- CREATE INDEX IF NOT EXISTS facts_entity ON facts (entity_id);`
3958
+ CREATE INDEX IF NOT EXISTS facts_entity ON facts (entity_id);`,
3959
+ "11": `CREATE TABLE IF NOT EXISTS observations (
3960
+ id TEXT PRIMARY KEY, scope TEXT NOT NULL, belief TEXT NOT NULL, status TEXT NOT NULL,
3961
+ sources_json TEXT NOT NULL, proof_count INTEGER NOT NULL, embedding BLOB,
3962
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_validated_at INTEGER NOT NULL);
3963
+ CREATE INDEX IF NOT EXISTS observations_status ON observations (status, proof_count, updated_at);`
3711
3964
  };
3712
3965
  /** RRF 融合常数:score = Σ 1/(K + rank)。 */
3713
3966
  const RRF_K = 60;
@@ -3816,6 +4069,27 @@ function factRowToRecord(row) {
3816
4069
  function vecToBlob(vector) {
3817
4070
  return new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
3818
4071
  }
4072
+ /** 行 → ObservationRecord;sources_json 由本插件自写,解析失败按空证据兜底(不炸读侧)。 */
4073
+ function observationRowToRecord(row) {
4074
+ let sourceIds = [];
4075
+ try {
4076
+ const parsed = JSON.parse(row.sources_json);
4077
+ if (Array.isArray(parsed)) sourceIds = parsed.filter((item) => typeof item === "string").map(asMemoryId);
4078
+ } catch {
4079
+ sourceIds = [];
4080
+ }
4081
+ return {
4082
+ id: asObservationId(row.id),
4083
+ scope: row.scope,
4084
+ belief: row.belief,
4085
+ status: row.status,
4086
+ sourceIds,
4087
+ proofCount: row.proof_count,
4088
+ createdAt: row.created_at,
4089
+ updatedAt: row.updated_at,
4090
+ lastValidatedAt: row.last_validated_at
4091
+ };
4092
+ }
3819
4093
  /** 余弦相似度;任一向量零范数时返回 0。 */
3820
4094
  function cosine(a, b) {
3821
4095
  let dot = 0;
@@ -3932,6 +4206,10 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3932
4206
  id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, content TEXT NOT NULL,
3933
4207
  valid_at INTEGER NOT NULL, invalid_at INTEGER, replaced_by TEXT,
3934
4208
  source_node_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
4209
+ CREATE TABLE IF NOT EXISTS observations (
4210
+ id TEXT PRIMARY KEY, scope TEXT NOT NULL, belief TEXT NOT NULL, status TEXT NOT NULL,
4211
+ sources_json TEXT NOT NULL, proof_count INTEGER NOT NULL, embedding BLOB,
4212
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_validated_at INTEGER NOT NULL);
3935
4213
  CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(node_id UNINDEXED, content, tokenize='unicode61');
3936
4214
  CREATE INDEX IF NOT EXISTS nodes_scope_status ON nodes (scope, status);
3937
4215
  CREATE INDEX IF NOT EXISTS nodes_kind_created ON nodes (kind, created_at);
@@ -3939,6 +4217,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3939
4217
  CREATE INDEX IF NOT EXISTS entities_name ON entities (name);
3940
4218
  CREATE INDEX IF NOT EXISTS node_entities_entity ON node_entities (entity_id);
3941
4219
  CREATE INDEX IF NOT EXISTS facts_entity ON facts (entity_id);
4220
+ CREATE INDEX IF NOT EXISTS observations_status ON observations (status, proof_count, updated_at);
3942
4221
  `);
3943
4222
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
3944
4223
  if (versionRow === void 0) {
@@ -4074,6 +4353,18 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4074
4353
  const sqlFactInvalidate = db.prepare("UPDATE facts SET invalid_at = ?, replaced_by = ?, updated_at = ? WHERE id = ?");
4075
4354
  const sqlFactGet = db.prepare("SELECT * FROM facts WHERE id = ?");
4076
4355
  const sqlPurgeFacts = db.prepare("DELETE FROM facts");
4356
+ const sqlObservationInsert = db.prepare(`INSERT INTO observations
4357
+ (id, scope, belief, status, sources_json, proof_count, embedding, created_at, updated_at, last_validated_at)
4358
+ VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`);
4359
+ const sqlObservationGet = db.prepare("SELECT * FROM observations WHERE id = ?");
4360
+ const sqlObservationUpdate = db.prepare(`UPDATE observations
4361
+ SET belief = ?, status = ?, sources_json = ?, proof_count = ?,
4362
+ embedding = COALESCE(?, embedding), updated_at = ?, last_validated_at = ? WHERE id = ?`);
4363
+ const sqlObservationSetStatus = db.prepare("UPDATE observations SET status = ?, updated_at = ?, last_validated_at = ? WHERE id = ?");
4364
+ const sqlObservationMarkStale = db.prepare("UPDATE observations SET status = 'stale' WHERE id = ?");
4365
+ const sqlObservationRefute = db.prepare("UPDATE observations SET status = 'refuted', updated_at = ? WHERE id = ?");
4366
+ const sqlObservationActiveVectors = db.prepare("SELECT * FROM observations WHERE status != 'refuted' AND embedding IS NOT NULL");
4367
+ const sqlPurgeObservations = db.prepare("DELETE FROM observations");
4077
4368
  /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重);rooms 非空时只查指定房间。 */
4078
4369
  const ftsSearch = (match, scopes, rooms) => {
4079
4370
  const placeholders = scopes.map(() => "?").join(",");
@@ -4878,6 +5169,79 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4878
5169
  total
4879
5170
  };
4880
5171
  },
5172
+ async createObservation(input) {
5173
+ const id = asObservationId(randomUUID());
5174
+ const now = Date.now();
5175
+ const sourceIds = [...new Set(input.sourceIds.map(String))].map(asMemoryId);
5176
+ if (sourceIds.length === 0) throw new EngramError("BAD_INPUT", "createObservation 要求至少 1 条证据记忆");
5177
+ const stored = input.embedding === void 0 ? null : vecToBlob(input.embedding);
5178
+ withTransaction(() => {
5179
+ sqlObservationInsert.run(id, input.scope, input.belief, JSON.stringify(sourceIds.map(String)), sourceIds.length, stored, now, now, now);
5180
+ });
5181
+ return {
5182
+ id,
5183
+ scope: input.scope,
5184
+ belief: input.belief,
5185
+ status: "active",
5186
+ sourceIds,
5187
+ proofCount: sourceIds.length,
5188
+ createdAt: now,
5189
+ updatedAt: now,
5190
+ lastValidatedAt: now
5191
+ };
5192
+ },
5193
+ async refineObservation(id, belief, addSourceIds, embedding) {
5194
+ const existing = sqlObservationGet.get(String(id));
5195
+ if (existing === void 0) return void 0;
5196
+ const merged = [.../* @__PURE__ */ new Set([...observationRowToRecord(existing).sourceIds.map(String), ...addSourceIds.map(String)])].map(asMemoryId);
5197
+ const now = Date.now();
5198
+ const stored = embedding === void 0 ? null : vecToBlob(embedding);
5199
+ withTransaction(() => {
5200
+ sqlObservationUpdate.run(belief, "active", JSON.stringify(merged.map(String)), merged.length, stored, now, now, String(id));
5201
+ });
5202
+ return observationRowToRecord(sqlObservationGet.get(String(id)));
5203
+ },
5204
+ async markObservationStale(id) {
5205
+ if (sqlObservationGet.get(String(id)) === void 0) return void 0;
5206
+ sqlObservationMarkStale.run(String(id));
5207
+ return observationRowToRecord(sqlObservationGet.get(String(id)));
5208
+ },
5209
+ async confirmObservation(id) {
5210
+ if (sqlObservationGet.get(String(id)) === void 0) return void 0;
5211
+ const now = Date.now();
5212
+ sqlObservationSetStatus.run("active", now, now, String(id));
5213
+ return observationRowToRecord(sqlObservationGet.get(String(id)));
5214
+ },
5215
+ async refuteObservation(id) {
5216
+ if (sqlObservationGet.get(String(id)) === void 0) return void 0;
5217
+ const now = Date.now();
5218
+ withTransaction(() => {
5219
+ sqlObservationRefute.run(now, String(id));
5220
+ });
5221
+ return observationRowToRecord(sqlObservationGet.get(String(id)));
5222
+ },
5223
+ async listObservations(filter) {
5224
+ const offset = Math.max(0, filter.offset ?? 0);
5225
+ if (filter.status === void 0) return db.prepare("SELECT * FROM observations ORDER BY proof_count DESC, updated_at DESC LIMIT ? OFFSET ?").all(filter.limit, offset).map(observationRowToRecord);
5226
+ return db.prepare("SELECT * FROM observations WHERE status = ? ORDER BY proof_count DESC, updated_at DESC LIMIT ? OFFSET ?").all(filter.status, filter.limit, offset).map(observationRowToRecord);
5227
+ },
5228
+ async nearestObservation(embedding) {
5229
+ const rows = sqlObservationActiveVectors.all();
5230
+ let best;
5231
+ let bestSim = 0;
5232
+ for (const row of rows) {
5233
+ if (row.embedding === null) continue;
5234
+ const sim = cosine(embedding, blobToVec(row.embedding));
5235
+ if (sim > bestSim) {
5236
+ bestSim = sim;
5237
+ best = row;
5238
+ }
5239
+ }
5240
+ return best === void 0 ? void 0 : {
5241
+ record: observationRowToRecord(best),
5242
+ similarity: bestSim
5243
+ };
5244
+ },
4881
5245
  async audit(op, targetId, detail) {
4882
5246
  sqlLog.run(Date.now(), op, targetId, detail);
4883
5247
  },
@@ -4903,6 +5267,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4903
5267
  sqlPurgeNodeEntities.run();
4904
5268
  sqlPurgeEntities.run();
4905
5269
  sqlPurgeFacts.run();
5270
+ sqlPurgeObservations.run();
4906
5271
  });
4907
5272
  },
4908
5273
  async close() {
@@ -5281,7 +5646,7 @@ function assessEvidence(batch, request) {
5281
5646
  //#endregion
5282
5647
  //#region src/tools/create.ts
5283
5648
  /**
5284
- * 20 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
5649
+ * 21 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
5285
5650
  * scope 决定读写哪个分库;嵌入缺失时检索结果显式标记降级。
5286
5651
  * @module @kenz1117/dsh-engram/tools/create
5287
5652
  */
@@ -7211,6 +7576,67 @@ function createEngramTools(baseDeps) {
7211
7576
  return { text: `蒸馏完成:取材 ${outcome.input} 条,产出 ${outcome.distilled} 条高层规律,归档 ${outcome.superseded} 条旧记忆(supersedes 链已建立,可 engram_review 审计)。` };
7212
7577
  }
7213
7578
  }),
7579
+ defineTool({
7580
+ name: "engram_reflect",
7581
+ description: "信念巩固:把多条原始记忆经辅助 LLM 巩固为带证据数的高层信念(form 新建/refine 细化/still 维持/refute 否定),同主题自动归并;原始记忆保留不归档。反复出现的模式或新增一批记忆后调用。",
7582
+ parameters: { scope: {
7583
+ type: "string",
7584
+ enum: [
7585
+ "user",
7586
+ "project",
7587
+ "shared"
7588
+ ],
7589
+ description: "作用域,默认 user"
7590
+ } },
7591
+ output: {
7592
+ schema: {
7593
+ type: "object",
7594
+ additionalProperties: false,
7595
+ properties: { text: {
7596
+ type: "string",
7597
+ required: true
7598
+ } }
7599
+ },
7600
+ render: (_args, value) => [{
7601
+ type: "text",
7602
+ text: value.text
7603
+ }]
7604
+ },
7605
+ async execute(args, exec) {
7606
+ const scope = scopeOf(args.scope, "user");
7607
+ if (deps.call === void 0) throw new Error("engram_reflect: 辅助 LLM 不可用(宿主未提供 llm 服务),无法巩固");
7608
+ const call = deps.call;
7609
+ const events = exec.agent?.session?.snapshotEvents() ?? [];
7610
+ const route = deps.routeOverride ?? routeFromEvents(events);
7611
+ if (route === void 0) throw new Error("engram_reflect: 无法确定模型路由(会话尚无模型请求),请在 cordis.yml 配置 provider/model");
7612
+ const store = await deps.openStore(scope);
7613
+ const outcome = await reflectObservations({
7614
+ store,
7615
+ embedder: await deps.embedder,
7616
+ scope,
7617
+ call: (params) => call({
7618
+ ...params,
7619
+ sessionId: exec.agent === void 0 ? void 0 : String(exec.agent.session.id)
7620
+ }),
7621
+ logRequest: (data) => {
7622
+ store.audit("reflect-request", "AUX", JSON.stringify(data)).catch(() => {});
7623
+ },
7624
+ route,
7625
+ signal: exec.signal
7626
+ });
7627
+ const pending = await store.listAuditDetails("reflect-pending");
7628
+ let consumed = 0;
7629
+ for (const detail of pending) try {
7630
+ if (JSON.parse(detail).scope === scope) {
7631
+ await store.clearAudit("reflect-pending", detail);
7632
+ consumed += 1;
7633
+ }
7634
+ } catch {}
7635
+ const lines = [`信念巩固完成:取材池 ${String(outcome.pool)} 条,新证据 ${String(outcome.candidates)} 条、待复核信念 ${String(outcome.staleReviewed)} 条;新建 ${String(outcome.formed)},细化 ${String(outcome.refined)},维持 ${String(outcome.confirmed)},否定 ${String(outcome.refuted)},跳过 ${String(outcome.skipped)}。`, "信念是叠加在原始记忆之上的抽象层(证据 id 与证据数已留存,原始记忆未归档)。"];
7636
+ if (consumed > 0) lines.push(`同时消费了 ${String(consumed)} 条会话结束时登记的待巩固任务。`);
7637
+ return { text: lines.join("\n") };
7638
+ }
7639
+ }),
7214
7640
  defineTool({
7215
7641
  name: "engram_examine",
7216
7642
  description: "渐进式披露:按 id 批量拉取记忆完整铭牌(content + 房间 + 状态 + 边关系)。仅在已通过 engram_search/timeline/neighbors 拿到候选 id 后调用,避免一次性吞全文。建议 ≤16 个 id,超出会按入参顺序保留前 N 条。",
@@ -7768,6 +8194,12 @@ function renderProfileWithCurated(curated, records, tokenBudget, itemBudget = DE
7768
8194
  const COMPRESS_MAX_TOKENS = 800;
7769
8195
  /** 压缩辅助调用超时:压缩在 pre-step 关键路径上,必须限时防阻塞首轮请求。 */
7770
8196
  const COMPRESS_TIMEOUT_MS = 8e3;
8197
+ /** turn_start 注入的巩固信念条数上限(按证据数、新近度倒序)。 */
8198
+ const OBSERVATION_INJECT_LIMIT = 3;
8199
+ /** 单条信念注入截断字符数。 */
8200
+ const OBSERVATION_INJECT_ITEM_CHARS = 120;
8201
+ /** 信念区块总字符上限(独立于画像 token 预算,避免与画像互相挤压)。 */
8202
+ const OBSERVATION_INJECT_BLOCK_CHARS = 480;
7771
8203
  const COMPRESS_SYSTEM = [
7772
8204
  "把记忆条目压缩为更短的一句话表述(每条不超过 40 个字符),保留可跨会话复用的关键信息(事实、偏好、决策、方法)。",
7773
8205
  "只输出一个 JSON 数组,每项形如 {\"id\": \"原样返回的id\", \"content\": \"压缩后表述\"},条目数量与 id 必须与输入一一对应。",
@@ -7882,8 +8314,7 @@ async function preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedde
7882
8314
  text: reminder
7883
8315
  }],
7884
8316
  source: {
7885
- kind: "plugin",
7886
- plugin: name,
8317
+ kind: `plugin:${name}`,
7887
8318
  form: "snapshot",
7888
8319
  sections: [{
7889
8320
  name,
@@ -7923,6 +8354,14 @@ async function preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedde
7923
8354
  }).text;
7924
8355
  }
7925
8356
  }
8357
+ const observations = await store.listObservations({
8358
+ status: "active",
8359
+ limit: OBSERVATION_INJECT_LIMIT
8360
+ });
8361
+ if (observations.length > 0) {
8362
+ const block = ["Consolidated beliefs (distilled from multiple stored memories; verify against sources before high-stakes use):", ...observations.map((observation) => `- ${observation.belief.slice(0, OBSERVATION_INJECT_ITEM_CHARS)} (${String(observation.proofCount)} sources)`)].join("\n").slice(0, OBSERVATION_INJECT_BLOCK_CHARS);
8363
+ text = `${text}\n\n${block}`;
8364
+ }
7926
8365
  const dueLine = dueTotal === 0 ? "" : `\nPalace review due today: ${dueTotal}${dueTotal >= 50 ? "+" : ""} memories. Use engram_review_queue for active recall (recall beats re-reading).`;
7927
8366
  const textWithRationale = wrapWithRationale(text, top) + dueLine;
7928
8367
  const hash = createHash("sha256").update(textWithRationale).digest("hex");
@@ -7938,8 +8377,7 @@ async function preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedde
7938
8377
  text: packet
7939
8378
  }],
7940
8379
  source: {
7941
- kind: "plugin",
7942
- plugin: name,
8380
+ kind: `plugin:${name}`,
7943
8381
  form: "snapshot",
7944
8382
  sections: [{
7945
8383
  name,
@@ -8369,6 +8807,51 @@ function apply(ctx, config = {}) {
8369
8807
  console.warn("[dsh-engram] 会话结束的收尾处理异常(不影响对话):", error);
8370
8808
  });
8371
8809
  });
8810
+ if (resolved.reflect !== "off") ctx.on("session/disposed", (session) => {
8811
+ const events = session.snapshotEvents();
8812
+ const sessionId = String(session.id);
8813
+ const cwd = sessionCwd(session);
8814
+ const route = resolved.routeOverride ?? routeFromEvents(events);
8815
+ const targets = [{
8816
+ scope: "user",
8817
+ open: () => openStore("user")
8818
+ }];
8819
+ if (cwd !== void 0 && existsSync(join(resolved.dbDir, dbNameForCwd(cwd)))) targets.push({
8820
+ scope: "project",
8821
+ open: () => openStoreForProjectCwd(cwd)
8822
+ });
8823
+ (async () => {
8824
+ for (const target of targets) try {
8825
+ const store = await target.open();
8826
+ if (resolved.reflect === "suggest") {
8827
+ const detail = JSON.stringify({
8828
+ scope: target.scope,
8829
+ sessionId
8830
+ });
8831
+ if (!await store.hasAudit("reflect-pending", detail)) await store.audit("reflect-pending", "AUX", detail);
8832
+ continue;
8833
+ }
8834
+ if (route === void 0) continue;
8835
+ const outcome = await reflectObservations({
8836
+ store,
8837
+ embedder: await embedder,
8838
+ scope: target.scope,
8839
+ call: (params) => streamText(ctx, {
8840
+ ...params,
8841
+ sessionId: session.id
8842
+ }),
8843
+ logRequest: (data) => {
8844
+ store.audit("reflect-request", "AUX", JSON.stringify(data)).catch(() => {});
8845
+ },
8846
+ route,
8847
+ signal: AbortSignal.timeout(FINAL_INGEST_TIMEOUT_MS)
8848
+ });
8849
+ if (outcome.formed + outcome.refined + outcome.confirmed + outcome.refuted > 0) console.log(`[dsh-engram] 信念巩固完成(${target.scope}):新建 ${String(outcome.formed)},细化 ${String(outcome.refined)},维持 ${String(outcome.confirmed)},否定 ${String(outcome.refuted)}`);
8850
+ } catch (error) {
8851
+ console.warn(`[dsh-engram] 会话结束的信念巩固异常(${target.scope},不影响对话):`, error);
8852
+ }
8853
+ })();
8854
+ });
8372
8855
  const runDecay = async () => {
8373
8856
  const targets = [{
8374
8857
  label: "user",