@kenz1117/dsh-engram 0.7.13 → 0.7.15

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();
@@ -1709,6 +1722,241 @@ function cosine$1(a, b) {
1709
1722
  return denom === 0 ? 0 : dot / denom;
1710
1723
  }
1711
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
1712
1960
  //#region src/jev/client.ts
1713
1961
  /**
1714
1962
  * 调用 Jev /v1/systemone 端点并行回答 Noul 问题。
@@ -2715,6 +2963,34 @@ function registerEngramRoutes(ctx, deps) {
2715
2963
  });
2716
2964
  return;
2717
2965
  }
2966
+ if (req.method === "GET" && route === "observations") {
2967
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2968
+ const status = url.searchParams.get("status");
2969
+ const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 100) || 100));
2970
+ const items = await (await storeFor(scope)).listObservations({
2971
+ ...status !== null && status !== "" && status !== "all" ? { status } : {},
2972
+ limit
2973
+ });
2974
+ json(res, 200, {
2975
+ scope,
2976
+ items,
2977
+ total: items.length
2978
+ });
2979
+ return;
2980
+ }
2981
+ if (req.method === "GET" && route === "observation-evidence") {
2982
+ const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2983
+ const ids = (url.searchParams.get("ids") ?? "").split(",").map((part) => part.trim()).filter((part) => part !== "").slice(0, 50);
2984
+ if (ids.length === 0) {
2985
+ json(res, 400, { error: "ids required" });
2986
+ return;
2987
+ }
2988
+ json(res, 200, {
2989
+ scope,
2990
+ items: await (await storeFor(scope)).getMany(ids)
2991
+ });
2992
+ return;
2993
+ }
2718
2994
  if (req.method === "GET" && route === "entities") {
2719
2995
  const scope = scopeOf$1(url.searchParams.get("scope"), "user");
2720
2996
  const kind = url.searchParams.get("kind");
@@ -3642,7 +3918,7 @@ function placardImprovementHint(score) {
3642
3918
  * @module @kenz1117/dsh-engram/store/sqlite
3643
3919
  */
3644
3920
  /** 当前 schema 版本;结构性变更必须 +1。可空列与伴随表走增量迁移(见 openEngramStore 的迁移段)。 */
3645
- const SCHEMA_VERSION = 11;
3921
+ const SCHEMA_VERSION = 12;
3646
3922
  /** 增量迁移表:key 为起始版本,value 为升到下一版本的 SQL(可多语句)。
3647
3923
  * v2 → v3:nodes 补可空列 outcome(使用效果回报)。
3648
3924
  * v3 → v4:新增 nodes_revisions 修订表(update 归档旧条目时的内容快照)。
@@ -3662,7 +3938,11 @@ const SCHEMA_VERSION = 11;
3662
3938
  * v10 → v11:facts 事实表——摄取/工具期抽取的一句话事实,挂在实体上(entity_id
3663
3939
  * 引用同库 entities.id,无外键)。valid_at/invalid_at 时间窗 + replaced_by 软失效
3664
3940
  * 链:新事实可声明取代旧事实(旧事实置 invalid_at 并回指 replaced_by),历史链
3665
- * 保留可审计;asOf 查询按时间窗过滤(见 factsOfEntity)。 */
3941
+ * 保留可审计;asOf 查询按时间窗过滤(见 factsOfEntity)。
3942
+ * v11 → v12:observations 巩固信念表——辅助 LLM 从多条原始记忆巩固出的一句话信念,
3943
+ * sources_json 证据 id 列表 + proof_count 证据数(去重并集持续累积),status 三态
3944
+ * active/stale/refuted 支持新鲜度复核;embedding 支持近邻归并(同主题并入同一条而非
3945
+ * 重复新增)。与 nodes 无外键(源记忆可被归档/遗忘,证据链只保留 id 引用)。 */
3666
3946
  const MIGRATIONS = {
3667
3947
  "2": "ALTER TABLE nodes ADD COLUMN outcome TEXT",
3668
3948
  "3": `CREATE TABLE IF NOT EXISTS nodes_revisions (
@@ -3703,7 +3983,12 @@ const MIGRATIONS = {
3703
3983
  id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, content TEXT NOT NULL,
3704
3984
  valid_at INTEGER NOT NULL, invalid_at INTEGER, replaced_by TEXT,
3705
3985
  source_node_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
3706
- CREATE INDEX IF NOT EXISTS facts_entity ON facts (entity_id);`
3986
+ CREATE INDEX IF NOT EXISTS facts_entity ON facts (entity_id);`,
3987
+ "11": `CREATE TABLE IF NOT EXISTS observations (
3988
+ id TEXT PRIMARY KEY, scope TEXT NOT NULL, belief TEXT NOT NULL, status TEXT NOT NULL,
3989
+ sources_json TEXT NOT NULL, proof_count INTEGER NOT NULL, embedding BLOB,
3990
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_validated_at INTEGER NOT NULL);
3991
+ CREATE INDEX IF NOT EXISTS observations_status ON observations (status, proof_count, updated_at);`
3707
3992
  };
3708
3993
  /** RRF 融合常数:score = Σ 1/(K + rank)。 */
3709
3994
  const RRF_K = 60;
@@ -3812,6 +4097,27 @@ function factRowToRecord(row) {
3812
4097
  function vecToBlob(vector) {
3813
4098
  return new Uint8Array(vector.buffer, vector.byteOffset, vector.byteLength);
3814
4099
  }
4100
+ /** 行 → ObservationRecord;sources_json 由本插件自写,解析失败按空证据兜底(不炸读侧)。 */
4101
+ function observationRowToRecord(row) {
4102
+ let sourceIds = [];
4103
+ try {
4104
+ const parsed = JSON.parse(row.sources_json);
4105
+ if (Array.isArray(parsed)) sourceIds = parsed.filter((item) => typeof item === "string").map(asMemoryId);
4106
+ } catch {
4107
+ sourceIds = [];
4108
+ }
4109
+ return {
4110
+ id: asObservationId(row.id),
4111
+ scope: row.scope,
4112
+ belief: row.belief,
4113
+ status: row.status,
4114
+ sourceIds,
4115
+ proofCount: row.proof_count,
4116
+ createdAt: row.created_at,
4117
+ updatedAt: row.updated_at,
4118
+ lastValidatedAt: row.last_validated_at
4119
+ };
4120
+ }
3815
4121
  /** 余弦相似度;任一向量零范数时返回 0。 */
3816
4122
  function cosine(a, b) {
3817
4123
  let dot = 0;
@@ -3928,6 +4234,10 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3928
4234
  id TEXT PRIMARY KEY, entity_id TEXT NOT NULL, content TEXT NOT NULL,
3929
4235
  valid_at INTEGER NOT NULL, invalid_at INTEGER, replaced_by TEXT,
3930
4236
  source_node_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
4237
+ CREATE TABLE IF NOT EXISTS observations (
4238
+ id TEXT PRIMARY KEY, scope TEXT NOT NULL, belief TEXT NOT NULL, status TEXT NOT NULL,
4239
+ sources_json TEXT NOT NULL, proof_count INTEGER NOT NULL, embedding BLOB,
4240
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_validated_at INTEGER NOT NULL);
3931
4241
  CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(node_id UNINDEXED, content, tokenize='unicode61');
3932
4242
  CREATE INDEX IF NOT EXISTS nodes_scope_status ON nodes (scope, status);
3933
4243
  CREATE INDEX IF NOT EXISTS nodes_kind_created ON nodes (kind, created_at);
@@ -3935,6 +4245,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
3935
4245
  CREATE INDEX IF NOT EXISTS entities_name ON entities (name);
3936
4246
  CREATE INDEX IF NOT EXISTS node_entities_entity ON node_entities (entity_id);
3937
4247
  CREATE INDEX IF NOT EXISTS facts_entity ON facts (entity_id);
4248
+ CREATE INDEX IF NOT EXISTS observations_status ON observations (status, proof_count, updated_at);
3938
4249
  `);
3939
4250
  const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
3940
4251
  if (versionRow === void 0) {
@@ -4070,6 +4381,18 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4070
4381
  const sqlFactInvalidate = db.prepare("UPDATE facts SET invalid_at = ?, replaced_by = ?, updated_at = ? WHERE id = ?");
4071
4382
  const sqlFactGet = db.prepare("SELECT * FROM facts WHERE id = ?");
4072
4383
  const sqlPurgeFacts = db.prepare("DELETE FROM facts");
4384
+ const sqlObservationInsert = db.prepare(`INSERT INTO observations
4385
+ (id, scope, belief, status, sources_json, proof_count, embedding, created_at, updated_at, last_validated_at)
4386
+ VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`);
4387
+ const sqlObservationGet = db.prepare("SELECT * FROM observations WHERE id = ?");
4388
+ const sqlObservationUpdate = db.prepare(`UPDATE observations
4389
+ SET belief = ?, status = ?, sources_json = ?, proof_count = ?,
4390
+ embedding = COALESCE(?, embedding), updated_at = ?, last_validated_at = ? WHERE id = ?`);
4391
+ const sqlObservationSetStatus = db.prepare("UPDATE observations SET status = ?, updated_at = ?, last_validated_at = ? WHERE id = ?");
4392
+ const sqlObservationMarkStale = db.prepare("UPDATE observations SET status = 'stale' WHERE id = ?");
4393
+ const sqlObservationRefute = db.prepare("UPDATE observations SET status = 'refuted', updated_at = ? WHERE id = ?");
4394
+ const sqlObservationActiveVectors = db.prepare("SELECT * FROM observations WHERE status != 'refuted' AND embedding IS NOT NULL");
4395
+ const sqlPurgeObservations = db.prepare("DELETE FROM observations");
4073
4396
  /** FTS 道:按 scope 集合检索(占位符动态生成,scope 集合由调用方去重);rooms 非空时只查指定房间。 */
4074
4397
  const ftsSearch = (match, scopes, rooms) => {
4075
4398
  const placeholders = scopes.map(() => "?").join(",");
@@ -4874,6 +5197,79 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4874
5197
  total
4875
5198
  };
4876
5199
  },
5200
+ async createObservation(input) {
5201
+ const id = asObservationId(randomUUID());
5202
+ const now = Date.now();
5203
+ const sourceIds = [...new Set(input.sourceIds.map(String))].map(asMemoryId);
5204
+ if (sourceIds.length === 0) throw new EngramError("BAD_INPUT", "createObservation 要求至少 1 条证据记忆");
5205
+ const stored = input.embedding === void 0 ? null : vecToBlob(input.embedding);
5206
+ withTransaction(() => {
5207
+ sqlObservationInsert.run(id, input.scope, input.belief, JSON.stringify(sourceIds.map(String)), sourceIds.length, stored, now, now, now);
5208
+ });
5209
+ return {
5210
+ id,
5211
+ scope: input.scope,
5212
+ belief: input.belief,
5213
+ status: "active",
5214
+ sourceIds,
5215
+ proofCount: sourceIds.length,
5216
+ createdAt: now,
5217
+ updatedAt: now,
5218
+ lastValidatedAt: now
5219
+ };
5220
+ },
5221
+ async refineObservation(id, belief, addSourceIds, embedding) {
5222
+ const existing = sqlObservationGet.get(String(id));
5223
+ if (existing === void 0) return void 0;
5224
+ const merged = [.../* @__PURE__ */ new Set([...observationRowToRecord(existing).sourceIds.map(String), ...addSourceIds.map(String)])].map(asMemoryId);
5225
+ const now = Date.now();
5226
+ const stored = embedding === void 0 ? null : vecToBlob(embedding);
5227
+ withTransaction(() => {
5228
+ sqlObservationUpdate.run(belief, "active", JSON.stringify(merged.map(String)), merged.length, stored, now, now, String(id));
5229
+ });
5230
+ return observationRowToRecord(sqlObservationGet.get(String(id)));
5231
+ },
5232
+ async markObservationStale(id) {
5233
+ if (sqlObservationGet.get(String(id)) === void 0) return void 0;
5234
+ sqlObservationMarkStale.run(String(id));
5235
+ return observationRowToRecord(sqlObservationGet.get(String(id)));
5236
+ },
5237
+ async confirmObservation(id) {
5238
+ if (sqlObservationGet.get(String(id)) === void 0) return void 0;
5239
+ const now = Date.now();
5240
+ sqlObservationSetStatus.run("active", now, now, String(id));
5241
+ return observationRowToRecord(sqlObservationGet.get(String(id)));
5242
+ },
5243
+ async refuteObservation(id) {
5244
+ if (sqlObservationGet.get(String(id)) === void 0) return void 0;
5245
+ const now = Date.now();
5246
+ withTransaction(() => {
5247
+ sqlObservationRefute.run(now, String(id));
5248
+ });
5249
+ return observationRowToRecord(sqlObservationGet.get(String(id)));
5250
+ },
5251
+ async listObservations(filter) {
5252
+ const offset = Math.max(0, filter.offset ?? 0);
5253
+ 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);
5254
+ 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);
5255
+ },
5256
+ async nearestObservation(embedding) {
5257
+ const rows = sqlObservationActiveVectors.all();
5258
+ let best;
5259
+ let bestSim = 0;
5260
+ for (const row of rows) {
5261
+ if (row.embedding === null) continue;
5262
+ const sim = cosine(embedding, blobToVec(row.embedding));
5263
+ if (sim > bestSim) {
5264
+ bestSim = sim;
5265
+ best = row;
5266
+ }
5267
+ }
5268
+ return best === void 0 ? void 0 : {
5269
+ record: observationRowToRecord(best),
5270
+ similarity: bestSim
5271
+ };
5272
+ },
4877
5273
  async audit(op, targetId, detail) {
4878
5274
  sqlLog.run(Date.now(), op, targetId, detail);
4879
5275
  },
@@ -4899,6 +5295,7 @@ async function openEngramStore(path, rankBoost = NO_BOOST, automation = DEFAULT_
4899
5295
  sqlPurgeNodeEntities.run();
4900
5296
  sqlPurgeEntities.run();
4901
5297
  sqlPurgeFacts.run();
5298
+ sqlPurgeObservations.run();
4902
5299
  });
4903
5300
  },
4904
5301
  async close() {
@@ -5277,7 +5674,7 @@ function assessEvidence(batch, request) {
5277
5674
  //#endregion
5278
5675
  //#region src/tools/create.ts
5279
5676
  /**
5280
- * 20 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
5677
+ * 21 个 engram_ 工具的定义与执行器。工具 schema 保持窄参数;
5281
5678
  * scope 决定读写哪个分库;嵌入缺失时检索结果显式标记降级。
5282
5679
  * @module @kenz1117/dsh-engram/tools/create
5283
5680
  */
@@ -7207,6 +7604,67 @@ function createEngramTools(baseDeps) {
7207
7604
  return { text: `蒸馏完成:取材 ${outcome.input} 条,产出 ${outcome.distilled} 条高层规律,归档 ${outcome.superseded} 条旧记忆(supersedes 链已建立,可 engram_review 审计)。` };
7208
7605
  }
7209
7606
  }),
7607
+ defineTool({
7608
+ name: "engram_reflect",
7609
+ description: "信念巩固:把多条原始记忆经辅助 LLM 巩固为带证据数的高层信念(form 新建/refine 细化/still 维持/refute 否定),同主题自动归并;原始记忆保留不归档。反复出现的模式或新增一批记忆后调用。",
7610
+ parameters: { scope: {
7611
+ type: "string",
7612
+ enum: [
7613
+ "user",
7614
+ "project",
7615
+ "shared"
7616
+ ],
7617
+ description: "作用域,默认 user"
7618
+ } },
7619
+ output: {
7620
+ schema: {
7621
+ type: "object",
7622
+ additionalProperties: false,
7623
+ properties: { text: {
7624
+ type: "string",
7625
+ required: true
7626
+ } }
7627
+ },
7628
+ render: (_args, value) => [{
7629
+ type: "text",
7630
+ text: value.text
7631
+ }]
7632
+ },
7633
+ async execute(args, exec) {
7634
+ const scope = scopeOf(args.scope, "user");
7635
+ if (deps.call === void 0) throw new Error("engram_reflect: 辅助 LLM 不可用(宿主未提供 llm 服务),无法巩固");
7636
+ const call = deps.call;
7637
+ const events = exec.agent?.session?.snapshotEvents() ?? [];
7638
+ const route = deps.routeOverride ?? routeFromEvents(events);
7639
+ if (route === void 0) throw new Error("engram_reflect: 无法确定模型路由(会话尚无模型请求),请在 cordis.yml 配置 provider/model");
7640
+ const store = await deps.openStore(scope);
7641
+ const outcome = await reflectObservations({
7642
+ store,
7643
+ embedder: await deps.embedder,
7644
+ scope,
7645
+ call: (params) => call({
7646
+ ...params,
7647
+ sessionId: exec.agent === void 0 ? void 0 : String(exec.agent.session.id)
7648
+ }),
7649
+ logRequest: (data) => {
7650
+ store.audit("reflect-request", "AUX", JSON.stringify(data)).catch(() => {});
7651
+ },
7652
+ route,
7653
+ signal: exec.signal
7654
+ });
7655
+ const pending = await store.listAuditDetails("reflect-pending");
7656
+ let consumed = 0;
7657
+ for (const detail of pending) try {
7658
+ if (JSON.parse(detail).scope === scope) {
7659
+ await store.clearAudit("reflect-pending", detail);
7660
+ consumed += 1;
7661
+ }
7662
+ } catch {}
7663
+ 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 与证据数已留存,原始记忆未归档)。"];
7664
+ if (consumed > 0) lines.push(`同时消费了 ${String(consumed)} 条会话结束时登记的待巩固任务。`);
7665
+ return { text: lines.join("\n") };
7666
+ }
7667
+ }),
7210
7668
  defineTool({
7211
7669
  name: "engram_examine",
7212
7670
  description: "渐进式披露:按 id 批量拉取记忆完整铭牌(content + 房间 + 状态 + 边关系)。仅在已通过 engram_search/timeline/neighbors 拿到候选 id 后调用,避免一次性吞全文。建议 ≤16 个 id,超出会按入参顺序保留前 N 条。",
@@ -7764,6 +8222,12 @@ function renderProfileWithCurated(curated, records, tokenBudget, itemBudget = DE
7764
8222
  const COMPRESS_MAX_TOKENS = 800;
7765
8223
  /** 压缩辅助调用超时:压缩在 pre-step 关键路径上,必须限时防阻塞首轮请求。 */
7766
8224
  const COMPRESS_TIMEOUT_MS = 8e3;
8225
+ /** turn_start 注入的巩固信念条数上限(按证据数、新近度倒序)。 */
8226
+ const OBSERVATION_INJECT_LIMIT = 3;
8227
+ /** 单条信念注入截断字符数。 */
8228
+ const OBSERVATION_INJECT_ITEM_CHARS = 120;
8229
+ /** 信念区块总字符上限(独立于画像 token 预算,避免与画像互相挤压)。 */
8230
+ const OBSERVATION_INJECT_BLOCK_CHARS = 480;
7767
8231
  const COMPRESS_SYSTEM = [
7768
8232
  "把记忆条目压缩为更短的一句话表述(每条不超过 40 个字符),保留可跨会话复用的关键信息(事实、偏好、决策、方法)。",
7769
8233
  "只输出一个 JSON 数组,每项形如 {\"id\": \"原样返回的id\", \"content\": \"压缩后表述\"},条目数量与 id 必须与输入一一对应。",
@@ -7918,6 +8382,14 @@ async function preStep(ctx, openStore, openStoreForProjectCwd, resolved, embedde
7918
8382
  }).text;
7919
8383
  }
7920
8384
  }
8385
+ const observations = await store.listObservations({
8386
+ status: "active",
8387
+ limit: OBSERVATION_INJECT_LIMIT
8388
+ });
8389
+ if (observations.length > 0) {
8390
+ 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);
8391
+ text = `${text}\n\n${block}`;
8392
+ }
7921
8393
  const dueLine = dueTotal === 0 ? "" : `\nPalace review due today: ${dueTotal}${dueTotal >= 50 ? "+" : ""} memories. Use engram_review_queue for active recall (recall beats re-reading).`;
7922
8394
  const textWithRationale = wrapWithRationale(text, top) + dueLine;
7923
8395
  const hash = createHash("sha256").update(textWithRationale).digest("hex");
@@ -8363,6 +8835,51 @@ function apply(ctx, config = {}) {
8363
8835
  console.warn("[dsh-engram] 会话结束的收尾处理异常(不影响对话):", error);
8364
8836
  });
8365
8837
  });
8838
+ if (resolved.reflect !== "off") ctx.on("session/disposed", (session) => {
8839
+ const events = session.snapshotEvents();
8840
+ const sessionId = String(session.id);
8841
+ const cwd = sessionCwd(session);
8842
+ const route = resolved.routeOverride ?? routeFromEvents(events);
8843
+ const targets = [{
8844
+ scope: "user",
8845
+ open: () => openStore("user")
8846
+ }];
8847
+ if (cwd !== void 0 && existsSync(join(resolved.dbDir, dbNameForCwd(cwd)))) targets.push({
8848
+ scope: "project",
8849
+ open: () => openStoreForProjectCwd(cwd)
8850
+ });
8851
+ (async () => {
8852
+ for (const target of targets) try {
8853
+ const store = await target.open();
8854
+ if (resolved.reflect === "suggest") {
8855
+ const detail = JSON.stringify({
8856
+ scope: target.scope,
8857
+ sessionId
8858
+ });
8859
+ if (!await store.hasAudit("reflect-pending", detail)) await store.audit("reflect-pending", "AUX", detail);
8860
+ continue;
8861
+ }
8862
+ if (route === void 0) continue;
8863
+ const outcome = await reflectObservations({
8864
+ store,
8865
+ embedder: await embedder,
8866
+ scope: target.scope,
8867
+ call: (params) => streamText(ctx, {
8868
+ ...params,
8869
+ sessionId: session.id
8870
+ }),
8871
+ logRequest: (data) => {
8872
+ store.audit("reflect-request", "AUX", JSON.stringify(data)).catch(() => {});
8873
+ },
8874
+ route,
8875
+ signal: AbortSignal.timeout(FINAL_INGEST_TIMEOUT_MS)
8876
+ });
8877
+ 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)}`);
8878
+ } catch (error) {
8879
+ console.warn(`[dsh-engram] 会话结束的信念巩固异常(${target.scope},不影响对话):`, error);
8880
+ }
8881
+ })();
8882
+ });
8366
8883
  const runDecay = async () => {
8367
8884
  const targets = [{
8368
8885
  label: "user",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kenz1117/dsh-engram",
3
3
  "description": "Cross-session long-term memory for DeepSeek Harness (memory palace · AGI Architecture Exploration on dsh-market): dual-scope SQLite memory graph, hybrid retrieval, provenance audit, and a knowledge flywheel.",
4
- "version": "0.7.13",
4
+ "version": "0.7.15",
5
5
  "icon": "icon.svg",
6
6
  "logo": "icon-256.svg",
7
7
  "publishConfig": {