@modusensus/dsh-mneme 0.8.6 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -148
- package/bin/cli.mjs +689 -603
- package/lib/api-standalone.js +57 -1
- package/lib/api.js +34 -11
- package/lib/client.js +289 -59
- package/lib/config.js +54 -6
- package/lib/content-hash.js +54 -0
- package/lib/document-index.js +94 -0
- package/lib/document.js +93 -11
- package/lib/index.js +50 -11
- package/lib/inject.js +6 -2
- package/lib/lang.js +15 -4
- package/lib/maintenance.js +142 -0
- package/lib/mirror.js +147 -15
- package/lib/recall-stats.js +52 -2
- package/lib/scope.js +34 -6
- package/lib/service.js +86 -7
- package/lib/settings.js +2 -1
- package/lib/store.js +231 -21
- package/lib/tools.js +27 -9
- package/lib/write-admission.js +259 -0
- package/package.json +4 -4
- package/src/api-standalone.js +57 -1
- package/src/api.js +34 -11
- package/src/config.js +54 -6
- package/src/content-hash.js +54 -0
- package/src/document-index.js +94 -0
- package/src/document.js +93 -11
- package/src/index.js +50 -11
- package/src/inject.js +6 -2
- package/src/lang.js +15 -4
- package/src/maintenance.js +142 -0
- package/src/mirror.js +147 -15
- package/src/recall-stats.js +52 -2
- package/src/scope.js +34 -6
- package/src/service.js +86 -7
- package/src/settings.js +2 -1
- package/src/store.js +231 -21
- package/src/tools.js +27 -9
- package/src/write-admission.js +259 -0
- package/test/api.test.js +12 -0
- package/test/client.test.js +90 -5
- package/test/content-hash.test.js +204 -0
- package/test/document-dir.test.js +233 -0
- package/test/document.test.js +151 -3
- package/test/inject-parent-gate.test.js +106 -0
- package/test/inject-pools.test.js +8 -3
- package/test/llm-audit.test.js +42 -0
- package/test/maintenance.test.js +391 -0
- package/test/mirror-types.test.js +208 -0
- package/test/recall-evals.test.js +18 -0
- package/test/recall-stats.test.js +66 -0
- package/test/write-admission.test.js +345 -0
package/lib/service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { TYPE_FILE } from "./mirror.js";
|
|
2
|
+
import { TYPE_FILE, MIRROR_READONLY_TYPES } from "./mirror.js";
|
|
3
3
|
import { updatedAtBounds } from "./store.js";
|
|
4
4
|
import { normalizeExplicitScope, scopeKeyOf } from "./scope.js";
|
|
5
5
|
import { STR, langOf } from "./lang.js";
|
|
@@ -29,7 +29,9 @@ const CODING_MEMORY_TYPES = new Set(["rejected_solution", "pitfall", "constraint
|
|
|
29
29
|
// 注意 constraint 同时属于 CODING_MEMORY_TYPES,非编码任务里已被 codingGate
|
|
30
30
|
// 滤掉,pin 池同样拿不到它;是否豁免该门控是待维护者拍板的口径问题,本批次
|
|
31
31
|
// 不动既有门控(不放大行为面)。
|
|
32
|
-
|
|
32
|
+
// 导出给写入准入(#254 的穿透口)与注入侧共用一份定义:两处各写一份 list 迟早
|
|
33
|
+
// 漂移成「pin 池豁免了、预算没豁免」这类两套口径。
|
|
34
|
+
export const PINNED_MEMORY_TYPES = new Set(["constraint", "preference"]);
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* 判断一段文本是否编码类任务(关键词匹配,codingRetrospect 读取侧门控)。
|
|
@@ -167,7 +169,7 @@ export function computeRetrievalMetrics(actualIds, expectedIds) {
|
|
|
167
169
|
};
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
export function createService({ store, mirror, config, onWrite, logger }) {
|
|
172
|
+
export function createService({ store, mirror, config, onWrite, logger, documentIndex, writeAdmission }) {
|
|
171
173
|
const language = langOf(config);
|
|
172
174
|
// Optional dream scheduler hook, installed via setDreamHook after creation
|
|
173
175
|
// (the scheduler holds a reference back to the service, so it cannot be
|
|
@@ -876,12 +878,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
876
878
|
mode,
|
|
877
879
|
topK: lim,
|
|
878
880
|
threshold: threshold ?? null,
|
|
881
|
+
// Per-source 信号随回执落盘(fuseRecall 本就无条件计算,此前只服务
|
|
882
|
+
// signalTransparency 展示):复用侧查询画像(query 型 → 各路权重的
|
|
883
|
+
// 离线聚合,AssoMem arXiv 2510.10397 消融所示收益大头)需要这份原料。
|
|
884
|
+
// 纯加字段——recall_runs 无 schema 变更,旧行 candidates 缺该键照常读。
|
|
879
885
|
candidates: result.map((m) => ({
|
|
880
886
|
id: m.id,
|
|
881
887
|
title: m.title,
|
|
882
888
|
content: m.content,
|
|
883
889
|
score: m.score ?? null,
|
|
884
|
-
source: m.source ?? "keyword"
|
|
890
|
+
source: m.source ?? "keyword",
|
|
891
|
+
signals: signals.get(m.id) ?? {}
|
|
885
892
|
})),
|
|
886
893
|
createdAt: new Date().toISOString()
|
|
887
894
|
});
|
|
@@ -1243,6 +1250,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1243
1250
|
scheduleEmbed(result);
|
|
1244
1251
|
return { action: "merged", memory: result };
|
|
1245
1252
|
}
|
|
1253
|
+
// #254 写入准入(第一阶段只计量):决策恒 allow、不拦写入,只把两个闸门的
|
|
1254
|
+
// 测量点算出来。放在 store.save 之前——第二阶段要在这里拦下写入,接缝先摆好。
|
|
1255
|
+
// 计量是旁路:任何一环失败只 warn,绝不反噬写入(与质量打分同款容错)。
|
|
1256
|
+
let admission = null;
|
|
1257
|
+
if (writeAdmission) {
|
|
1258
|
+
try {
|
|
1259
|
+
admission = writeAdmission.evaluate({ memory, sessionKey: memory._sessionKey });
|
|
1260
|
+
} catch (e) {
|
|
1261
|
+
try { logger?.warn?.(`[dsh-mneme] write admission evaluate failed: ${String(e)}`); } catch { /* 不反噬 */ }
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1246
1264
|
const created = store.save({
|
|
1247
1265
|
type: memory.type,
|
|
1248
1266
|
title: memory.title,
|
|
@@ -1263,6 +1281,17 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1263
1281
|
...(quality ? { quality_score: quality.score } : {})
|
|
1264
1282
|
});
|
|
1265
1283
|
const result = applyQualityDisposition(created, quality, qf);
|
|
1284
|
+
if (admission) {
|
|
1285
|
+
try {
|
|
1286
|
+
writeAdmission.record({
|
|
1287
|
+
sessionKey: memory._sessionKey,
|
|
1288
|
+
verdict: admission,
|
|
1289
|
+
memoryId: created.id
|
|
1290
|
+
});
|
|
1291
|
+
} catch (e) {
|
|
1292
|
+
try { logger?.warn?.(`[dsh-mneme] write admission record failed: ${String(e)}`); } catch { /* 不反噬 */ }
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1266
1295
|
afterSync("write");
|
|
1267
1296
|
notifyWrite();
|
|
1268
1297
|
scheduleEmbed(result);
|
|
@@ -1560,6 +1589,10 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1560
1589
|
* Only content/title are taken; structure fields stay machine-owned.
|
|
1561
1590
|
*/
|
|
1562
1591
|
function mergeHumanEdits(type, edits) {
|
|
1592
|
+
// #296:只读 type 的结构性守卫。调用点(启动合并 / reconcileHumanEdits)都已
|
|
1593
|
+
// 跳过它们,但这条不变量属于「人改回填」本身——将来多一个调用点不该重开这个口
|
|
1594
|
+
// (指针行文本被当正文写回 document 行会污染摘要)。
|
|
1595
|
+
if (MIRROR_READONLY_TYPES.has(type)) return 0;
|
|
1563
1596
|
let applied = 0;
|
|
1564
1597
|
for (const edit of edits) {
|
|
1565
1598
|
if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
|
|
@@ -1787,6 +1820,26 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1787
1820
|
* non-forgotten memories are mirrored: forgotten entries must not reach the
|
|
1788
1821
|
* human-editable file (a human "edit" could otherwise resurrect them).
|
|
1789
1822
|
*/
|
|
1823
|
+
// #296 第二批:documentDir/index.md 的写后语。与注册入口共用同一个
|
|
1824
|
+
// documentMemoryEnabled 闸(默认关):闸开时只列活跃指针行(正文永远不进这个
|
|
1825
|
+
// 文件),闸关时把索引删掉——document 子系统整体退出,留一份陈旧索引会列出已
|
|
1826
|
+
// 归档的行,正是镜像侧 documents.md 在闸关时被删掉要避免的那种「看着还在、其实
|
|
1827
|
+
// 已关」的视图。两步都只 warn:索引是机器产物,不能让触发它的业务写失败。
|
|
1828
|
+
function syncDocumentIndex() {
|
|
1829
|
+
if (!documentIndex) return;
|
|
1830
|
+
if (config?.documentMemoryEnabled !== true) {
|
|
1831
|
+
const removed = documentIndex.remove();
|
|
1832
|
+
if (!removed?.ok) logger?.warn?.("document index remove failed:", removed?.error);
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
try {
|
|
1836
|
+
const result = documentIndex.sync(store.list({ type: "document", limit: null }));
|
|
1837
|
+
if (!result?.ok) logger?.warn?.("document index sync failed:", result?.error);
|
|
1838
|
+
} catch (error) {
|
|
1839
|
+
logger?.warn?.("document index sync failed:", error);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1790
1843
|
// syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
|
|
1791
1844
|
// v0.3.6(audit peer 4 阻断):
|
|
1792
1845
|
// - 开始时 incrementGeneration 绑定本次期望轮次 gen;成功用
|
|
@@ -1796,7 +1849,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1796
1849
|
// - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
|
|
1797
1850
|
// - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
|
|
1798
1851
|
function syncMirror() {
|
|
1799
|
-
if (txDepth > 0
|
|
1852
|
+
if (txDepth > 0) return { success: true, deferred: true }; // deferred to the transaction's commit
|
|
1853
|
+
// #296 第二批:documentDir/index.md 走同一条写后语——它与镜像一样是「从库渲染
|
|
1854
|
+
// 的机器产物」,同样只在内容变化时落盘、同样自己吞掉失败。放在 !mirror 短路
|
|
1855
|
+
// 之前:索引在不在,不该取决于镜像是否装配(无镜像的宿主与测试同样要有它)。
|
|
1856
|
+
syncDocumentIndex();
|
|
1857
|
+
if (!mirror) return { success: true, deferred: true };
|
|
1800
1858
|
const now = new Date().toISOString();
|
|
1801
1859
|
let gen;
|
|
1802
1860
|
try {
|
|
@@ -1812,8 +1870,20 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
1812
1870
|
// 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
|
|
1813
1871
|
const coveredTypes = new Set();
|
|
1814
1872
|
try {
|
|
1815
|
-
//
|
|
1816
|
-
|
|
1873
|
+
// 预先取本次要覆盖的全部活跃行(只读一次)。这里曾经是
|
|
1874
|
+
// store.list({ limit: 500 })——活跃集超过 500 时镜像会静默少掉尾部记忆,
|
|
1875
|
+
// 而文件本身没有任何提示(#278 第一批)。store.list 的 limit 默认值只有
|
|
1876
|
+
// 50,比原来显式传的 500 更小,所以改用 all() 并在这里做同一套过滤
|
|
1877
|
+
// (forgotten/archived 都不进镜像,与 includeForgotten:false + 默认
|
|
1878
|
+
// includeArchived:false 等价)。
|
|
1879
|
+
// 代价是全表读,且落在每次业务写后的最热路径上(#202 自记 all() 5k 行
|
|
1880
|
+
// 231ms → ~135ms)。要压这一层得换按 type 分页取,属另一批的事;
|
|
1881
|
+
// 在这里退回任何截断都不行——「宣称覆盖活跃集」与静默截断不能共存。
|
|
1882
|
+
const list = store.all().filter((m) => !m.forgotten && !m.archived
|
|
1883
|
+
// #296 第二批:镜像里的 documents.md 与 index.md 共用 documentMemoryEnabled
|
|
1884
|
+
// 闸。关掉时把 document 行也从渲染集里去掉——sync 对「空 type」的既有处理
|
|
1885
|
+
// 会把陈旧的 documents.md 删掉,不留一个「看着还在、其实已关」的视图。
|
|
1886
|
+
&& (m.type !== "document" || config?.documentMemoryEnabled === true));
|
|
1817
1887
|
for (const memory of list) {
|
|
1818
1888
|
if (memory?.type && TYPE_FILE[memory.type]) {
|
|
1819
1889
|
coveredTypes.add(memory.type);
|
|
@@ -2092,6 +2162,12 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
2092
2162
|
function archiveMemory(id, f) {
|
|
2093
2163
|
const updated = store.setArchived(id, f);
|
|
2094
2164
|
afterSync("write");
|
|
2165
|
+
// #275 B 项:归档行的向量会被手动回收清掉(检索恒带 archived = 0,按定义不可达),
|
|
2166
|
+
// 还原回活跃面时补一次嵌入——否则那行只剩关键词可检索,回收就成了单程票。
|
|
2167
|
+
// 判据用真值(`!f`)而不是 `f === false`:store.setArchived 自己就是按真值归一
|
|
2168
|
+
// (`archived ? 1 : 0`),API/工具传 0 或空串同样会把行放回活跃面,口径必须同一把尺。
|
|
2169
|
+
// 排队语义与写入路径同一处(txDepth / 未就绪由 scheduleEmbed 自己挡)。
|
|
2170
|
+
if (!f) scheduleEmbed(updated);
|
|
2095
2171
|
return updated;
|
|
2096
2172
|
}
|
|
2097
2173
|
|
|
@@ -2105,6 +2181,9 @@ export function createService({ store, mirror, config, onWrite, logger }) {
|
|
|
2105
2181
|
embedQuery,
|
|
2106
2182
|
pushContentHistory,
|
|
2107
2183
|
transaction,
|
|
2184
|
+
// #275 拍板 5:升格吸收的 evidence 行随之归档,但 pinned 池(#249)永不自动归档
|
|
2185
|
+
// ——注册器不 import 这个集合,方向反了会成环,所以在这里注入。
|
|
2186
|
+
pinnedTypes: PINNED_MEMORY_TYPES,
|
|
2108
2187
|
finalize: (rows) => {
|
|
2109
2188
|
for (const row of rows) scheduleEmbed(row);
|
|
2110
2189
|
}
|
package/lib/settings.js
CHANGED
|
@@ -45,7 +45,8 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
45
45
|
"hotMemoryEnabled",
|
|
46
46
|
// Issue #239 第 5 项:注入条数的查询自适应(确定性强则收缩注入条数,默认关)。
|
|
47
47
|
"injectUncertaintyAdaptive",
|
|
48
|
-
// Issue #249
|
|
48
|
+
// Issue #249:能力说明(工具描述判断指引 + order 150 总则段)。第二批起它是
|
|
49
|
+
// 注入父开关 autoInject 的子项、默认开(父关时不生效,闸门见 config.js)。
|
|
49
50
|
"injectGuidanceEnabled",
|
|
50
51
|
"entityExtractionEnabled",
|
|
51
52
|
// Issue #219:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
|
package/lib/store.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { statSync } from "node:fs";
|
|
4
|
+
import { contentHashOf } from "./content-hash.js";
|
|
3
5
|
|
|
4
6
|
const SCHEMA = `
|
|
5
7
|
CREATE TABLE IF NOT EXISTS memories (
|
|
@@ -13,6 +15,7 @@ CREATE TABLE IF NOT EXISTS memories (
|
|
|
13
15
|
archived INTEGER NOT NULL DEFAULT 0,
|
|
14
16
|
source TEXT,
|
|
15
17
|
content_history TEXT,
|
|
18
|
+
content_hash TEXT,
|
|
16
19
|
embedding TEXT,
|
|
17
20
|
epistemic_status TEXT NOT NULL DEFAULT 'subjective',
|
|
18
21
|
last_accessed_at TEXT,
|
|
@@ -192,7 +195,8 @@ CREATE TABLE IF NOT EXISTS llm_audit_logs (
|
|
|
192
195
|
status TEXT NOT NULL, -- success | error | skipped
|
|
193
196
|
error_message TEXT,
|
|
194
197
|
related_memory_ids TEXT, -- JSON: ids the call operated on
|
|
195
|
-
metadata TEXT
|
|
198
|
+
metadata TEXT, -- JSON: free-form extras
|
|
199
|
+
session_key TEXT -- #254 写入准入的会话键(LLM 调用行恒 NULL)
|
|
196
200
|
);
|
|
197
201
|
CREATE INDEX IF NOT EXISTS idx_llm_audit_timestamp ON llm_audit_logs(timestamp);
|
|
198
202
|
CREATE INDEX IF NOT EXISTS idx_llm_audit_source ON llm_audit_logs(trigger_source);
|
|
@@ -405,6 +409,7 @@ function toRow(row) {
|
|
|
405
409
|
type: row.type,
|
|
406
410
|
title: row.title,
|
|
407
411
|
content: row.content,
|
|
412
|
+
content_hash: row.content_hash ?? undefined,
|
|
408
413
|
tags: parseTags(row.tags),
|
|
409
414
|
importance: row.importance,
|
|
410
415
|
forgotten: row.forgotten === 1,
|
|
@@ -607,7 +612,9 @@ function toLlmAudit(row) {
|
|
|
607
612
|
status: row.status,
|
|
608
613
|
error_message: row.error_message ?? undefined,
|
|
609
614
|
related_memory_ids: parseJsonArray(row.related_memory_ids),
|
|
610
|
-
metadata
|
|
615
|
+
metadata,
|
|
616
|
+
// #254 写入准入的会话键;LLM 调用行恒 NULL。
|
|
617
|
+
session_key: row.session_key ?? undefined
|
|
611
618
|
};
|
|
612
619
|
}
|
|
613
620
|
|
|
@@ -711,6 +718,34 @@ export function createStore(path) {
|
|
|
711
718
|
addColumn("memories", "workspace_scope_source", "ALTER TABLE memories ADD COLUMN workspace_scope_source TEXT");
|
|
712
719
|
addColumn("memories", "scope_decided_at", "ALTER TABLE memories ADD COLUMN scope_decided_at TEXT");
|
|
713
720
|
|
|
721
|
+
// #254 计量信号:内容归一化哈希(口径与动机写在 content-hash.js 的文件头)。派生
|
|
722
|
+
// 列,不参与任何判定——只给「同一内容又被写了一次」当等值锚。索引建在加列之后:
|
|
723
|
+
// 老库打开时 SCHEMA 的 CREATE TABLE 对既有表不生效,列还不存在,把索引写进 SCHEMA
|
|
724
|
+
// 会直接报 no such column(与下方 llm_audit_logs.session_key 同理)。
|
|
725
|
+
addColumn("memories", "content_hash", "ALTER TABLE memories ADD COLUMN content_hash TEXT");
|
|
726
|
+
// 列序是 content_hash 打头:哈希几乎唯一,等值 seek 就已收窄到候选行,type 只当同
|
|
727
|
+
// 一次 seek 里的第二列过滤;反过来(type 打头)下面的存量回填就得全表扫——每次打开
|
|
728
|
+
// 都要把全部正文读一遍。
|
|
729
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memories_content_hash ON memories(content_hash, type)");
|
|
730
|
+
// 存量回填:老库(含归档区)的行都没有值,而「归档行纳入去重候选集」这条口径正是
|
|
731
|
+
// 要靠既有归档行出数据。只算 NULL 行、幂等;改口径不是补 NULL 能修好的,要整列重算
|
|
732
|
+
// (见 content-hash.js 的文件头)。空标题空正文的行算不出锚,跳过不写——否则每次
|
|
733
|
+
// 打开都要为这些行再跑一遍 UPDATE。
|
|
734
|
+
{
|
|
735
|
+
const updates = db
|
|
736
|
+
.prepare("SELECT id, title, content FROM memories WHERE content_hash IS NULL")
|
|
737
|
+
.all()
|
|
738
|
+
.map((row) => [contentHashOf(row), row.id])
|
|
739
|
+
.filter(([hash]) => hash);
|
|
740
|
+
if (updates.length) {
|
|
741
|
+
const stmt = db.prepare("UPDATE memories SET content_hash = ? WHERE id = ? AND content_hash IS NULL");
|
|
742
|
+
// 一个事务包住整批:逐条自动提交是每行一次 WAL 提交,几千行的存量库上纯属浪费。
|
|
743
|
+
runAtomically(() => {
|
|
744
|
+
for (const [hash, id] of updates) stmt.run(hash, id);
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
714
749
|
// Legacy dream_runs without policy_epoch → backfill with the default epoch.
|
|
715
750
|
addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
|
|
716
751
|
addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
|
|
@@ -724,6 +759,13 @@ export function createStore(path) {
|
|
|
724
759
|
addColumn("mirror_state", "applied_generation", "ALTER TABLE mirror_state ADD COLUMN applied_generation INTEGER NOT NULL DEFAULT 0");
|
|
725
760
|
addColumn("mirror_state", "type_status", "ALTER TABLE mirror_state ADD COLUMN type_status TEXT");
|
|
726
761
|
|
|
762
|
+
// #254 写入准入(第一阶段只计量):准入决策行按会话聚合——「会话内新建了几行」
|
|
763
|
+
// 与「同话题重复间隔」都靠它算出来,所以会话键必须是可等值查询的列,而不是塞在
|
|
764
|
+
// metadata 里做 JSON 匹配。列可空且不带 DEFAULT(存量行零重写)。索引建在加列
|
|
765
|
+
// 之后:老库打开时列还不存在(SCHEMA 的 CREATE TABLE 只对新建库生效)。
|
|
766
|
+
addColumn("llm_audit_logs", "session_key", "ALTER TABLE llm_audit_logs ADD COLUMN session_key TEXT");
|
|
767
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_llm_audit_session ON llm_audit_logs(session_key);");
|
|
768
|
+
|
|
727
769
|
// Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
|
|
728
770
|
// (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
|
|
729
771
|
// enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
|
|
@@ -942,6 +984,8 @@ export function createStore(path) {
|
|
|
942
984
|
const importance = Number.isInteger(memory.importance) ? memory.importance : 3;
|
|
943
985
|
const evidence = Array.isArray(memory.evidence) ? JSON.stringify(memory.evidence) : null;
|
|
944
986
|
const docPath = typeof memory.doc_path === "string" && memory.doc_path.trim() ? memory.doc_path : null;
|
|
987
|
+
// #254 计量锚:由本行的 title/content 派生,调用方传什么都以这里算出的为准。
|
|
988
|
+
const contentHash = contentHashOf(memory);
|
|
945
989
|
const embedding = Array.isArray(memory.embedding) && memory.embedding.length
|
|
946
990
|
? JSON.stringify(memory.embedding)
|
|
947
991
|
: null;
|
|
@@ -952,8 +996,8 @@ export function createStore(path) {
|
|
|
952
996
|
: inferEpistemicStatus(memory);
|
|
953
997
|
runAtomically(() => {
|
|
954
998
|
db.prepare(
|
|
955
|
-
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, created_at, updated_at)
|
|
956
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
999
|
+
`INSERT INTO memories (id, type, title, content, tags, importance, forgotten, archived, source, content_history, quality_score, embedding, epistemic_status, agent_scope, workspace_scope, agent_scope_source, workspace_scope_source, scope_decided_at, sensitivity, occurred_at, evidence, doc_path, content_hash, created_at, updated_at)
|
|
1000
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
957
1001
|
).run(
|
|
958
1002
|
id,
|
|
959
1003
|
type,
|
|
@@ -976,6 +1020,7 @@ export function createStore(path) {
|
|
|
976
1020
|
normalizeOccurredAt(memory.occurred_at),
|
|
977
1021
|
evidence,
|
|
978
1022
|
docPath,
|
|
1023
|
+
contentHash,
|
|
979
1024
|
now,
|
|
980
1025
|
now
|
|
981
1026
|
);
|
|
@@ -988,6 +1033,41 @@ export function createStore(path) {
|
|
|
988
1033
|
return getById(id);
|
|
989
1034
|
}
|
|
990
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* #254 计量:与本次写入「归一化内容哈希」相同的既有行(去重候选集)。
|
|
1038
|
+
* 结构化锚先收窄成本:按哈希等值 seek(走 idx_memories_content_hash,哈希几乎唯
|
|
1039
|
+
* 一,落在候选集上的行只有几条),再按 type 与 scope 三维过滤——不是全表扫、也不
|
|
1040
|
+
* 逐行比哈希。scope 用 IS 比(NULL 安全):未标注行互相匹配、与已标注行不匹配,
|
|
1041
|
+
* 与 service.js 去重键(scopeKeyOf)同口径;入参先按 store 自己的 normalizeScopeText
|
|
1042
|
+
* 归一,免得调用方传 " foo " 就漏配。
|
|
1043
|
+
* 归档行与已遗忘行都在集内:#275 的分界是「出口止体积、不止重复」——被质量闸归档
|
|
1044
|
+
* 的同一个事实必须仍能判成重复,否则同样的内容再写一次又是一条新行(#254 拍板:
|
|
1045
|
+
* 归档行纳入去重候选集)。
|
|
1046
|
+
* 排序即取舍:活跃行排在归档/遗忘行之前,再按 updated_at 倒序。LIMIT 先于调用方的
|
|
1047
|
+
* 「活区优先」判断执行,而归档动作本身会顶 updated_at——纯按时间倒序时,同键命中一
|
|
1048
|
+
* 旦超过窗口宽度,活跃行就被归档行挤出候选集,调用方只能看到归档命中,把「活跃重复」
|
|
1049
|
+
* 误报成「归档重复」,恰好把这条信号要分流的两类弄反。
|
|
1050
|
+
* @returns {Array<{id: string, archived: boolean, forgotten: boolean}>} 活跃行优先,其后按最近写入
|
|
1051
|
+
*/
|
|
1052
|
+
function findContentHashMatches({ type, hash, agent_scope: agentScope, workspace_scope: workspaceScope, sensitivity, limit = 10 } = {}) {
|
|
1053
|
+
if (!hash || !type) return [];
|
|
1054
|
+
const lim = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 50) : 10;
|
|
1055
|
+
const rows = db.prepare(
|
|
1056
|
+
`SELECT id, archived, forgotten FROM memories
|
|
1057
|
+
WHERE type = ? AND content_hash = ?
|
|
1058
|
+
AND agent_scope IS ? AND workspace_scope IS ? AND sensitivity IS ?
|
|
1059
|
+
ORDER BY archived ASC, forgotten ASC, updated_at DESC, id LIMIT ?`
|
|
1060
|
+
).all(
|
|
1061
|
+
type,
|
|
1062
|
+
hash,
|
|
1063
|
+
normalizeScopeText(agentScope),
|
|
1064
|
+
normalizeScopeText(workspaceScope),
|
|
1065
|
+
normalizeScopeText(sensitivity),
|
|
1066
|
+
lim
|
|
1067
|
+
);
|
|
1068
|
+
return rows.map((row) => ({ id: row.id, archived: row.archived === 1, forgotten: row.forgotten === 1 }));
|
|
1069
|
+
}
|
|
1070
|
+
|
|
991
1071
|
function update(id, patch) {
|
|
992
1072
|
const existing = getById(id);
|
|
993
1073
|
if (!existing) throw new Error(`memory not found: ${id}`);
|
|
@@ -1034,13 +1114,18 @@ export function createStore(path) {
|
|
|
1034
1114
|
const nextDecidedAt = patch.scope_decided_at !== undefined
|
|
1035
1115
|
? normalizeOccurredAt(patch.scope_decided_at)
|
|
1036
1116
|
: (existing.scope_decided_at ?? null);
|
|
1117
|
+
// #254:标题或正文变了,锚跟着重算——它是当前内容的派生物,留旧值等于让「这行
|
|
1118
|
+
// 现在装的什么」和标记对不上。
|
|
1119
|
+
const nextTitle = patch.title ?? existing.title;
|
|
1120
|
+
const nextContent = patch.content ?? existing.content;
|
|
1121
|
+
const contentHash = contentHashOf({ title: nextTitle, content: nextContent });
|
|
1037
1122
|
runAtomically(() => {
|
|
1038
1123
|
db.prepare(
|
|
1039
|
-
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, agent_scope=?, workspace_scope=?, agent_scope_source=?, workspace_scope_source=?, scope_decided_at=?, evidence=?, updated_at=? WHERE id=?`
|
|
1124
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, agent_scope=?, workspace_scope=?, agent_scope_source=?, workspace_scope_source=?, scope_decided_at=?, evidence=?, content_hash=?, updated_at=? WHERE id=?`
|
|
1040
1125
|
).run(
|
|
1041
1126
|
type,
|
|
1042
|
-
|
|
1043
|
-
|
|
1127
|
+
nextTitle,
|
|
1128
|
+
nextContent,
|
|
1044
1129
|
JSON.stringify(patch.tags ?? existing.tags),
|
|
1045
1130
|
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
1046
1131
|
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
@@ -1054,6 +1139,7 @@ export function createStore(path) {
|
|
|
1054
1139
|
nextWorkspaceSource,
|
|
1055
1140
|
nextDecidedAt,
|
|
1056
1141
|
evidence,
|
|
1142
|
+
contentHash,
|
|
1057
1143
|
now,
|
|
1058
1144
|
id
|
|
1059
1145
|
);
|
|
@@ -1115,15 +1201,19 @@ export function createStore(path) {
|
|
|
1115
1201
|
// dirty == false — recoverMirror sees no debt and the mirror stays stale.
|
|
1116
1202
|
// Wrapping both in one transaction means a CAS miss rolls back cleanly too
|
|
1117
1203
|
// (no write, no generation bump).
|
|
1204
|
+
// 同 update:#254 的锚随标题/正文重算(CAS 路径也改这两列)。
|
|
1205
|
+
const nextTitle = patch.title ?? existing.title;
|
|
1206
|
+
const nextContent = patch.content ?? existing.content;
|
|
1207
|
+
const contentHash = contentHashOf({ title: nextTitle, content: nextContent });
|
|
1118
1208
|
let applied = false;
|
|
1119
1209
|
runAtomically(() => {
|
|
1120
1210
|
const result = db.prepare(
|
|
1121
|
-
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, updated_at=?
|
|
1211
|
+
`UPDATE memories SET type=?, title=?, content=?, tags=?, importance=?, source=?, content_history=?, quality_score=?, embedding=?, epistemic_status=?, content_hash=?, updated_at=?
|
|
1122
1212
|
WHERE id=? AND updated_at=?`
|
|
1123
1213
|
).run(
|
|
1124
1214
|
type,
|
|
1125
|
-
|
|
1126
|
-
|
|
1215
|
+
nextTitle,
|
|
1216
|
+
nextContent,
|
|
1127
1217
|
JSON.stringify(patch.tags ?? existing.tags),
|
|
1128
1218
|
Number.isInteger(patch.importance) ? patch.importance : existing.importance,
|
|
1129
1219
|
patch.source !== undefined ? patch.source : (existing.source ?? null),
|
|
@@ -1131,6 +1221,7 @@ export function createStore(path) {
|
|
|
1131
1221
|
qualityScore,
|
|
1132
1222
|
embedding,
|
|
1133
1223
|
epistemicStatus,
|
|
1224
|
+
contentHash,
|
|
1134
1225
|
now,
|
|
1135
1226
|
id,
|
|
1136
1227
|
expectedUpdatedAt
|
|
@@ -1182,15 +1273,15 @@ export function createStore(path) {
|
|
|
1182
1273
|
function demoteToSummary(id, summary, { minRefTimeMs } = {}) {
|
|
1183
1274
|
let changed = false;
|
|
1184
1275
|
runAtomically(() => {
|
|
1185
|
-
const row = db.prepare("SELECT last_accessed_at, content, _full_content FROM memories WHERE id = ?").get(id);
|
|
1276
|
+
const row = db.prepare("SELECT title, last_accessed_at, content, _full_content FROM memories WHERE id = ?").get(id);
|
|
1186
1277
|
if (!row || row._full_content) return;
|
|
1187
1278
|
if (minRefTimeMs !== undefined && row.last_accessed_at) {
|
|
1188
1279
|
const lastMs = Date.parse(row.last_accessed_at);
|
|
1189
1280
|
if (lastMs >= minRefTimeMs) return; // touched after snapshot — still hot
|
|
1190
1281
|
}
|
|
1191
1282
|
db.prepare(
|
|
1192
|
-
"UPDATE memories SET content = ?, _full_content = ?, updated_at = ? WHERE id = ?"
|
|
1193
|
-
).run(summary, row.content, nowIso(), id);
|
|
1283
|
+
"UPDATE memories SET content = ?, _full_content = ?, content_hash = ?, updated_at = ? WHERE id = ?"
|
|
1284
|
+
).run(summary, row.content, contentHashOf({ title: row.title, content: summary }), nowIso(), id);
|
|
1194
1285
|
incrementGeneration();
|
|
1195
1286
|
changed = true;
|
|
1196
1287
|
});
|
|
@@ -1201,11 +1292,11 @@ export function createStore(path) {
|
|
|
1201
1292
|
function restoreContent(id) {
|
|
1202
1293
|
let changed = false;
|
|
1203
1294
|
runAtomically(() => {
|
|
1204
|
-
const row = db.prepare("SELECT content, _full_content FROM memories WHERE id = ?").get(id);
|
|
1295
|
+
const row = db.prepare("SELECT title, content, _full_content FROM memories WHERE id = ?").get(id);
|
|
1205
1296
|
if (!row || !row._full_content) return;
|
|
1206
1297
|
db.prepare(
|
|
1207
|
-
"UPDATE memories SET content = ?, _full_content = NULL, updated_at = ? WHERE id = ?"
|
|
1208
|
-
).run(row._full_content, nowIso(), id);
|
|
1298
|
+
"UPDATE memories SET content = ?, _full_content = NULL, content_hash = ?, updated_at = ? WHERE id = ?"
|
|
1299
|
+
).run(row._full_content, contentHashOf({ title: row.title, content: row._full_content }), nowIso(), id);
|
|
1209
1300
|
incrementGeneration();
|
|
1210
1301
|
changed = true;
|
|
1211
1302
|
});
|
|
@@ -1766,8 +1857,8 @@ export function createStore(path) {
|
|
|
1766
1857
|
db.prepare(
|
|
1767
1858
|
`INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id,
|
|
1768
1859
|
input_tokens, output_tokens, total_tokens, cost_usd, duration_ms, status,
|
|
1769
|
-
error_message, related_memory_ids, metadata)
|
|
1770
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1860
|
+
error_message, related_memory_ids, metadata, session_key)
|
|
1861
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1771
1862
|
).run(
|
|
1772
1863
|
entry.timestamp ?? now,
|
|
1773
1864
|
entry.trigger_source,
|
|
@@ -1783,12 +1874,13 @@ export function createStore(path) {
|
|
|
1783
1874
|
JSON.stringify(entry.related_memory_ids ?? []),
|
|
1784
1875
|
entry.metadata !== undefined
|
|
1785
1876
|
? (typeof entry.metadata === "string" ? entry.metadata : JSON.stringify(entry.metadata))
|
|
1786
|
-
: null
|
|
1877
|
+
: null,
|
|
1878
|
+
entry.session_key ?? null
|
|
1787
1879
|
);
|
|
1788
1880
|
return toLlmAudit(db.prepare("SELECT * FROM llm_audit_logs ORDER BY id DESC LIMIT 1").get());
|
|
1789
1881
|
}
|
|
1790
1882
|
|
|
1791
|
-
function listLlmAudits({ limit = 50, offset = 0, source } = {}) {
|
|
1883
|
+
function listLlmAudits({ limit = 50, offset = 0, source, sessionKey } = {}) {
|
|
1792
1884
|
const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
|
|
1793
1885
|
const clauses = [];
|
|
1794
1886
|
const params = [];
|
|
@@ -1796,6 +1888,11 @@ export function createStore(path) {
|
|
|
1796
1888
|
clauses.push("trigger_source = ?");
|
|
1797
1889
|
params.push(source);
|
|
1798
1890
|
}
|
|
1891
|
+
// #254 写入准入:会话内的话题回填只需要该会话的行(走 idx_llm_audit_session)。
|
|
1892
|
+
if (sessionKey) {
|
|
1893
|
+
clauses.push("session_key = ?");
|
|
1894
|
+
params.push(sessionKey);
|
|
1895
|
+
}
|
|
1799
1896
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1800
1897
|
const rows = db.prepare(
|
|
1801
1898
|
`SELECT * FROM llm_audit_logs ${where} ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?`
|
|
@@ -1803,13 +1900,17 @@ export function createStore(path) {
|
|
|
1803
1900
|
return rows.map(toLlmAudit);
|
|
1804
1901
|
}
|
|
1805
1902
|
|
|
1806
|
-
function countLlmAudits({ source } = {}) {
|
|
1903
|
+
function countLlmAudits({ source, sessionKey } = {}) {
|
|
1807
1904
|
const clauses = [];
|
|
1808
1905
|
const params = [];
|
|
1809
1906
|
if (source) {
|
|
1810
1907
|
clauses.push("trigger_source = ?");
|
|
1811
1908
|
params.push(source);
|
|
1812
1909
|
}
|
|
1910
|
+
if (sessionKey) {
|
|
1911
|
+
clauses.push("session_key = ?");
|
|
1912
|
+
params.push(sessionKey);
|
|
1913
|
+
}
|
|
1813
1914
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
1814
1915
|
return db.prepare(`SELECT count(*) AS c FROM llm_audit_logs ${where}`).get(...params).c;
|
|
1815
1916
|
}
|
|
@@ -1858,6 +1959,106 @@ export function createStore(path) {
|
|
|
1858
1959
|
return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes;
|
|
1859
1960
|
}
|
|
1860
1961
|
|
|
1962
|
+
// --- 存储生命周期:无损回收(#275 第一批)--------------------------------
|
|
1963
|
+
//
|
|
1964
|
+
// 两项都零价值判断、零条数变化:行数不变,只把不可重建/不可达的内容丢掉。
|
|
1965
|
+
// 删行不在本模块里(维护者拍板 3:裁列同意、删行不同意)——dream_runs 的骨架、
|
|
1966
|
+
// LLM 决策原文与 receipt 永不删。
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* 历史 run 的输入快照统计(#275 A 项,dry-run 用)。
|
|
1970
|
+
*
|
|
1971
|
+
* `input` 这一列的含义**按 run_type 分叉**:auto / sleep 的 run 存的是当时的记忆库
|
|
1972
|
+
* 快照(可由记忆库重建),organize 的 run 存的是 apply 的重放载荷——`organize.js`
|
|
1973
|
+
* 的 apply 直接读它(`snapshot = Array.isArray(report.input) ? report.input : []`),
|
|
1974
|
+
* 置空会让 apply 找不到候选、静默什么都不做却照写回执并盖上 applied_at,那份报告
|
|
1975
|
+
* 从此永远重放不了。所以 organize 行不在可清范围里(同一列,两种语义,只能按 type 分)。
|
|
1976
|
+
*
|
|
1977
|
+
* `bytes` 是列文本大小,只作上界(真实释放看 VACUUM 前后)。
|
|
1978
|
+
* @returns {{runs: number, bytes: number}}
|
|
1979
|
+
*/
|
|
1980
|
+
function dreamRunInputStats(before) {
|
|
1981
|
+
const row = db.prepare(
|
|
1982
|
+
`SELECT count(*) AS c, COALESCE(SUM(LENGTH(input)), 0) AS b
|
|
1983
|
+
FROM dream_runs
|
|
1984
|
+
WHERE input IS NOT NULL AND created_at < ? AND run_type != 'organize'`
|
|
1985
|
+
).get(before);
|
|
1986
|
+
return { runs: row.c, bytes: row.b };
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/** 置空历史 run 的输入快照(organize 的重放载荷除外)。返回实际改动的行数。 */
|
|
1990
|
+
function clearDreamRunInputs(before) {
|
|
1991
|
+
return db.prepare(
|
|
1992
|
+
`UPDATE dream_runs SET input = NULL
|
|
1993
|
+
WHERE input IS NOT NULL AND created_at < ? AND run_type != 'organize'`
|
|
1994
|
+
).run(before).changes;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* 归档行仍带的向量统计(#275 B 项)。检索 SQL 恒带 `archived = 0`,所以这部分
|
|
1999
|
+
* 向量按定义不可达,清掉不改变任何检索结果。
|
|
2000
|
+
* @returns {{rows: number, bytes: number}}
|
|
2001
|
+
*/
|
|
2002
|
+
function archivedEmbeddingStats() {
|
|
2003
|
+
const row = db.prepare(
|
|
2004
|
+
`SELECT count(*) AS c, COALESCE(SUM(LENGTH(embedding)), 0) AS b
|
|
2005
|
+
FROM memories WHERE archived = 1 AND embedding IS NOT NULL`
|
|
2006
|
+
).get();
|
|
2007
|
+
return { rows: row.c, bytes: row.b };
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/** 清掉归档行的向量。返回实际改动行数。 */
|
|
2011
|
+
function clearArchivedEmbeddings() {
|
|
2012
|
+
const changed = db.prepare(
|
|
2013
|
+
"UPDATE memories SET embedding = NULL WHERE archived = 1 AND embedding IS NOT NULL"
|
|
2014
|
+
).run().changes;
|
|
2015
|
+
// Issue #202 的单条失效点在 setEmbedding 里,批量清走不到它——整表清缓存最省事,
|
|
2016
|
+
// 代价只是活跃行下次检索多解析一次(FIFO 上限 4000,自愈)。
|
|
2017
|
+
if (changed > 0) embeddingCache.clear();
|
|
2018
|
+
return changed;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* 库文件与页统计(#275 报告口径):回收收益按 VACUUM 前后体积量,不按列字节估——
|
|
2023
|
+
* 实际释放来自溢出页与索引页的回收,列文本大小只是上界。
|
|
2024
|
+
*
|
|
2025
|
+
* 体积是**磁盘足迹**:WAL 模式下主文件之外还有 `-wal` / `-shm`,只量主文件会把
|
|
2026
|
+
* 「刚清完还没落盘」的那部分算漏(实测过:VACUUM 后主文件可能一动不动,要等一次
|
|
2027
|
+
* checkpoint)。所以三份一起量;`:memory:` 库没有文件。
|
|
2028
|
+
*/
|
|
2029
|
+
function storageStats() {
|
|
2030
|
+
const pageSize = db.prepare("PRAGMA page_size").get().page_size;
|
|
2031
|
+
const pageCount = db.prepare("PRAGMA page_count").get().page_count;
|
|
2032
|
+
const freelistCount = db.prepare("PRAGMA freelist_count").get().freelist_count;
|
|
2033
|
+
const sizeOf = (p) => {
|
|
2034
|
+
try {
|
|
2035
|
+
return statSync(p).size;
|
|
2036
|
+
} catch {
|
|
2037
|
+
return 0;
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
2040
|
+
let fileBytes = null;
|
|
2041
|
+
if (typeof path === "string" && path !== ":memory:") {
|
|
2042
|
+
fileBytes = sizeOf(path) + sizeOf(`${path}-wal`) + sizeOf(`${path}-shm`);
|
|
2043
|
+
}
|
|
2044
|
+
return { path: typeof path === "string" ? path : null, pageSize, pageCount, freelistCount, fileBytes };
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* 整库 VACUUM(#275 的手动步骤):代价 O(库大小) 且需要排他写锁,所以只在显式
|
|
2049
|
+
* 入口里跑,绝不挂启动路径、也不开 auto_vacuum。
|
|
2050
|
+
*
|
|
2051
|
+
* 收尾补一次 `wal_checkpoint(TRUNCATE)`:WAL 模式下 VACUUM 重排的是主文件里的页,
|
|
2052
|
+
* 已回收的空间可能还挂在 WAL 里,不 checkpoint 就量不到体积下降——报告口径要的是
|
|
2053
|
+
* 真实释放量,量不到等于没做(评审实测:VACUUM 后主文件长度不变,checkpoint 后才降)。
|
|
2054
|
+
*/
|
|
2055
|
+
function vacuum() {
|
|
2056
|
+
const started = Date.now();
|
|
2057
|
+
db.exec("VACUUM");
|
|
2058
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
2059
|
+
return { duration_ms: Date.now() - started };
|
|
2060
|
+
}
|
|
2061
|
+
|
|
1861
2062
|
// --- failure memories ----------------------------------------------------
|
|
1862
2063
|
|
|
1863
2064
|
/**
|
|
@@ -2485,6 +2686,8 @@ export function createStore(path) {
|
|
|
2485
2686
|
saveDocument,
|
|
2486
2687
|
update,
|
|
2487
2688
|
compareAndUpdate,
|
|
2689
|
+
// #254:写入准入的内容哈希候选集(只读)。
|
|
2690
|
+
findContentHashMatches,
|
|
2488
2691
|
remove,
|
|
2489
2692
|
setForget,
|
|
2490
2693
|
setArchived,
|
|
@@ -2523,6 +2726,13 @@ export function createStore(path) {
|
|
|
2523
2726
|
countLlmAudits,
|
|
2524
2727
|
getLlmAuditStats,
|
|
2525
2728
|
deleteOldLlmAudits,
|
|
2729
|
+
// 存储生命周期(#275 第一批):无损回收的统计、清理与体积口径。
|
|
2730
|
+
dreamRunInputStats,
|
|
2731
|
+
clearDreamRunInputs,
|
|
2732
|
+
archivedEmbeddingStats,
|
|
2733
|
+
clearArchivedEmbeddings,
|
|
2734
|
+
storageStats,
|
|
2735
|
+
vacuum,
|
|
2526
2736
|
saveFailure,
|
|
2527
2737
|
listFailures,
|
|
2528
2738
|
getFailureStats,
|