@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/config.js
CHANGED
|
@@ -77,12 +77,16 @@ export const Config = z.object({
|
|
|
77
77
|
// maxInjectedItems 上限——只做**单向收缩**,绝不越过用户配置的上限;判据只看
|
|
78
78
|
// 查询本身,不做额外检索(先探针检索等于白付一次 fuseRecall)。
|
|
79
79
|
injectUncertaintyAdaptive: z.boolean().default(false),
|
|
80
|
-
// #249
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
80
|
+
// #249(第一批;第二批归位为注入子开关):能力说明——「怎么用记忆」的判断
|
|
81
|
+
// 指引。落两个零注入成本的位:①`memory_search` / `memory_save` 的工具描述补
|
|
82
|
+
// 一句判断指引(工具描述是常驻文本,不进每轮上下文);②一段 order 150 的
|
|
83
|
+
// 系统提示段(一次性、同会话内不随轮次变化,因此不作废前缀缓存),只讲总则
|
|
84
|
+
// (优先序、何时查、何时写、何时 no-op)。默认开=基础档:#249 §10 的判据是
|
|
85
|
+
// 「只修正既有位、不引入新注入时机/新表面/额外 LLM 调用」的子项随父开关
|
|
86
|
+
// 生效——「agent 不知道何时该查、何时该写」是已实测的缺口,把它默认关着是反的。
|
|
87
|
+
// 父开关 `autoInject` 关闭时它不生效(闸门见 injectChildEnabled);用户显式写进
|
|
88
|
+
// feature_flags 的值永远优先于这里的默认值。
|
|
89
|
+
injectGuidanceEnabled: z.boolean().default(true),
|
|
86
90
|
// #249(第一批):B1 pin 池预算——约束/偏好类注入条目的独立小上限。约束与
|
|
87
91
|
// 偏好被静默降级是本议题的立项核心(同类知识与情景日志同池同速率摘要,实测
|
|
88
92
|
// 一轮压缩后仅保 53%、五轮 10%),故这两类不进相关性竞争、不参与跨轮轮换、
|
|
@@ -208,6 +212,15 @@ export const Config = z.object({
|
|
|
208
212
|
// document 摘要行的注入预算(#230 拍板):次优先档内最多注入的 document
|
|
209
213
|
// 行数,超预算跳过由后续候选补位。只约束注入,不约束检索。
|
|
210
214
|
documentInjectBudget: z.natural().min(1).max(5).default(2),
|
|
215
|
+
// document 的 managed 落盘目录(#296 第二批):空串 = 跟随 memoryDir 的
|
|
216
|
+
// `<memoryDir>/documents/`。`~` / `~/` / `~\` 展开到 home,绝对路径原样用,相对
|
|
217
|
+
// 路径落在 memoryDir 下(这是本键自己的规则:memoryDir 的相对路径是原样留着的
|
|
218
|
+
// cwd 语义,两者刻意不同;`~user` 形式两边都不展开)。目录由 mneme 建,里面的
|
|
219
|
+
// `index.md`
|
|
220
|
+
// 整文件机器所有。这个目录**之外**的文件只登记指针行,正文一个字节都不碰
|
|
221
|
+
// (= #230 的「管线对正文零读零写」);与 memoryDir 一样是路径配置,不是
|
|
222
|
+
// 行为开关,故不进 settings.js 的 feature flags 白名单、也不上面板。
|
|
223
|
+
documentDir: z.string().default(""),
|
|
211
224
|
// 隐式 keep(v0.4.4):LLM 未提及的 snapshot 记忆自动补 {action:"keep"},
|
|
212
225
|
// 避免"未覆盖即全拒"白白浪费整轮 run。设为 false 时保留旧的严格校验
|
|
213
226
|
// (未覆盖即拒绝整单)。
|
|
@@ -607,6 +620,9 @@ const LIGHT_MODE_OFF = [
|
|
|
607
620
|
"rerankEnabled",
|
|
608
621
|
"autoReindexOnBoot",
|
|
609
622
|
"hybridInject",
|
|
623
|
+
// #249 第二批:能力说明在轻量档保持关闭——它是工具描述与提示段上的额外常驻
|
|
624
|
+
// 文本,轻量档(小模型 / 小上下文)不该因默认值翻转而多付这份提示成本。
|
|
625
|
+
"injectGuidanceEnabled",
|
|
610
626
|
"searchSemanticDedup",
|
|
611
627
|
"selectiveInjectEnabled",
|
|
612
628
|
"bm25SearchEnabled",
|
|
@@ -632,3 +648,35 @@ export function applyLightModePreset(cfg) {
|
|
|
632
648
|
for (const key of LIGHT_MODE_OFF) preset[key] = false;
|
|
633
649
|
return preset;
|
|
634
650
|
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* #249 第二批:注入形态的父/子开关。父 = `autoInject`(既有的挂载总闸,默认
|
|
654
|
+
* 开)。子项按 #249 §10 的判据分档——看它是否引入**新的注入时机/新的注入
|
|
655
|
+
* 表面/额外成本**:
|
|
656
|
+
*
|
|
657
|
+
* - 只修正既有每轮块内的位(能力说明)→ 随父开关生效,键的默认值给开;
|
|
658
|
+
* - 需要新时机或新表面(N2 回合结束提醒、N3 压缩边缘)→ 独立开关、默认关,
|
|
659
|
+
* 实现时挂到这张表下(今天还没有这两个键,故表里只有能力说明一项)。
|
|
660
|
+
*
|
|
661
|
+
* 语义:**父关 = 子项一律不生效,但子项的持久值原样保留**(父关是「不生效」,
|
|
662
|
+
* 不是「重置用户配置」)。用户显式写进 kv 的值也不因父关被改写。
|
|
663
|
+
*
|
|
664
|
+
* 闸门放在消费点而不是合并后的 cfg 上:压 cfg 会让 `/features` 的 effective
|
|
665
|
+
* 失去「子项自己勾着、父关时当前不生效」这个状态,面板就显示不出来。跨文件
|
|
666
|
+
* 契约见 test/inject-parent-gate.test.js(含面板侧关系的漂移检查)。
|
|
667
|
+
*
|
|
668
|
+
* 表里只放**顶层扁平键**:点号键(如 `memoryQualityFilter.enabled`)在合并后的
|
|
669
|
+
* cfg 里是嵌套对象,`cfg?.["a.b"]` 取不到,挂进来会恒判不生效且不报错。N2/N3
|
|
670
|
+
* 是扁平键,不受这条限制。
|
|
671
|
+
*/
|
|
672
|
+
export const INJECT_CHILD_FLAGS = Object.freeze({
|
|
673
|
+
autoInject: Object.freeze(["injectGuidanceEnabled"])
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
/** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */
|
|
677
|
+
export function injectChildEnabled(cfg, key) {
|
|
678
|
+
for (const [parent, children] of Object.entries(INJECT_CHILD_FLAGS)) {
|
|
679
|
+
if (children.includes(key) && cfg?.[parent] === false) return false;
|
|
680
|
+
}
|
|
681
|
+
return cfg?.[key] === true;
|
|
682
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// #254 写入准入的确定性计量锚:内容归一化哈希(exact duplicate 判定)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么用哈希而不是相似度:计量阶段要的是一个零成本、可复算、不留争议的判据。
|
|
4
|
+
// 相似度阈值两边都是误判——同一件事换个说法就掉到线下,不同的事共享话题词又顶到
|
|
5
|
+
// 线上,阈值往哪边挪都只是换一种错法(#254 拍板:计量信号用内容哈希)。哈希只认
|
|
6
|
+
// 「归一化后逐字节相同」,算不出来就只有一种解释。
|
|
7
|
+
//
|
|
8
|
+
// 但它不等于「原文逐字节相同」:归一化会折掉格式,也会折掉标点,于是 `版本 3.5` 与
|
|
9
|
+
// `版本 35`、`a-b` 与 `ab` 落同一个键。折叠范围(大小写 / 空白 / 标点)是口径本身
|
|
10
|
+
// 定的,#254 已拍板——误报面在计量阶段可接受(只标记、不拦截),但「命中即精确重复」
|
|
11
|
+
// 这句话别升级成「命中即内容相同」。要更窄的判据就得改归一化口径,而那要带存量重算。
|
|
12
|
+
//
|
|
13
|
+
// 归一化口径一次定死(NFKC → 小写 → 去标点 → 空白折叠 → trim),动机是让「只差
|
|
14
|
+
// 格式」的两次写入落在同一个键上:全角/半角、大小写、行内换行与缩进、句末标点都
|
|
15
|
+
// 不该让同一件事变成两条。反过来它不折叠同义词、不排序词序——那些只能靠相似度,
|
|
16
|
+
// 而相似度已判定不可用于本信号。
|
|
17
|
+
//
|
|
18
|
+
// 改口径必须带存量重算策略:memories.content_hash 是派生列,口径一变,库里已有的
|
|
19
|
+
// 值全部按旧口径算——补 NULL 修不好,只能整列重算(UPDATE 全表 + 重建索引)。
|
|
20
|
+
// 所以口径只能在这里改,且改要单独成批,不要混进别的改动里。
|
|
21
|
+
//
|
|
22
|
+
// 空内容(标题与正文归一后都为空)返回 null:这类写入没有可比内容,落 NULL 让它们
|
|
23
|
+
// 不互相匹配成「全是重复」。
|
|
24
|
+
//
|
|
25
|
+
// 另有一处形状相近、口径不同的哈希:service.js 里镜像人工编辑的 digest 校验(两处)
|
|
26
|
+
// 用 sha256(title\0content),那里**不**归一化(要逐字节保真)。两把哈希用途不同,别互换。
|
|
27
|
+
import { createHash } from "node:crypto";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 归一化一段文本用于内容比对(口径见文件头)。
|
|
31
|
+
* @param {unknown} value
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeForHash(value) {
|
|
35
|
+
return String(value ?? "")
|
|
36
|
+
.normalize("NFKC")
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/\p{P}/gu, "")
|
|
39
|
+
.replace(/\s+/g, " ")
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 一行记忆的内容锚:标题与正文分别归一后用 NUL 拼接再取 sha256(十六进制全串)。
|
|
45
|
+
* NUL 分隔是防拼接歧义——('ab','c') 与 ('a','bc') 不能落到同一个键上。
|
|
46
|
+
* @param {{title?: string, content?: string}|null|undefined} memory
|
|
47
|
+
* @returns {string|null} 归一后无内容时返回 null
|
|
48
|
+
*/
|
|
49
|
+
export function contentHashOf(memory) {
|
|
50
|
+
const title = normalizeForHash(memory?.title);
|
|
51
|
+
const content = normalizeForHash(memory?.content);
|
|
52
|
+
if (!title && !content) return null;
|
|
53
|
+
return createHash("sha256").update(`${title}\u0000${content}`).digest("hex");
|
|
54
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// dsh-mneme/src/document-index.js
|
|
2
|
+
// documentDir 的索引(issue #296 第二批)。`<documentDir>/index.md` 整文件机器所有,
|
|
3
|
+
// 可从库随时重建:内容里没有任何「生成时间」,所以删掉再同步一次得到逐字节相同的
|
|
4
|
+
// 文件(test/document-dir.test.js 钉住这一条)。
|
|
5
|
+
//
|
|
6
|
+
// 与镜像侧 documents.md 的分工(#296 第 2 节表格):这里是 document 子系统自己的
|
|
7
|
+
// 索引(id + 标题 + 定位 + managed 标记),那份是带 frontmatter 的镜像只读视图
|
|
8
|
+
// (多一个摘要首句)。两份都只列指针行,都不含正文——正文永远在 doc_path 指向的
|
|
9
|
+
// 文件里,那是 agent 的东西。
|
|
10
|
+
import { readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { STR } from "./lang.js";
|
|
13
|
+
import { isManagedDocumentPath } from "./document.js";
|
|
14
|
+
|
|
15
|
+
function oneLine(text) {
|
|
16
|
+
return String(text ?? "").replace(/\s+/g, " ").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} dir resolved documentDir (see resolveDocumentDir)
|
|
21
|
+
* @param {"zh"|"en"} language instance language, same as the mirror
|
|
22
|
+
*/
|
|
23
|
+
export function createDocumentIndex(dir, language = "zh") {
|
|
24
|
+
const filePath = join(dir, "index.md");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render the whole file from the given rows. Sorted updated_at DESC, id ASC:
|
|
28
|
+
* the same store state must always render the same bytes (rebuildable).
|
|
29
|
+
*/
|
|
30
|
+
function render(memories) {
|
|
31
|
+
const items = (memories ?? []).slice().sort((a, b) => {
|
|
32
|
+
if (a.updated_at !== b.updated_at) return a.updated_at < b.updated_at ? 1 : -1;
|
|
33
|
+
return a.id < b.id ? -1 : 1;
|
|
34
|
+
});
|
|
35
|
+
const lines = [STR.documentIndexHeader[language]()];
|
|
36
|
+
for (const m of items) {
|
|
37
|
+
const where = isManagedDocumentPath(dir, m.doc_path) ? "managed" : "external";
|
|
38
|
+
// doc_path 理论上必带(store.saveDocument 是唯一铸造口),缺了就只写定位
|
|
39
|
+
// 标记,不编造路径。
|
|
40
|
+
const path = m.doc_path ? ` · \`${oneLine(m.doc_path)}\`` : "";
|
|
41
|
+
lines.push(`- \`${m.id}\` **${oneLine(m.title)}** · ${where}${path}`);
|
|
42
|
+
}
|
|
43
|
+
return `${lines.join("\n")}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ensure the directory exists (recursive). Never throws: callers warn and move
|
|
48
|
+
* on — a managed directory that cannot be created must not fail plugin boot.
|
|
49
|
+
*/
|
|
50
|
+
function ensure() {
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
return { ok: true };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write the index if it changed. Never throws: a failed index write must not
|
|
61
|
+
* fail the business write that triggered it (same fail-safe shape as
|
|
62
|
+
* mirror.sync). Returns { ok, changed } or { ok: false, error }.
|
|
63
|
+
*/
|
|
64
|
+
function sync(memories) {
|
|
65
|
+
const text = render(memories);
|
|
66
|
+
let prev = null;
|
|
67
|
+
try { prev = readFileSync(filePath, "utf8"); } catch { /* first write */ }
|
|
68
|
+
if (prev === text) return { ok: true, changed: false };
|
|
69
|
+
const ready = ensure();
|
|
70
|
+
if (!ready.ok) return { ok: false, error: ready.error };
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(filePath, text, "utf8");
|
|
73
|
+
return { ok: true, changed: true };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Drop the index file (闸关时用)。闸关意味着 document 子系统整体退出,留一份
|
|
81
|
+
* 陈旧索引会列出已归档、已遗忘的指针行——正是 documents.md 在闸关时被镜像删掉
|
|
82
|
+
* 要避免的那种「看着还在、其实已关」的视图。删掉不从库取数,O(1),也不抛。
|
|
83
|
+
*/
|
|
84
|
+
function remove() {
|
|
85
|
+
try {
|
|
86
|
+
rmSync(filePath, { force: true });
|
|
87
|
+
return { ok: true };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { filePath, render, sync, ensure, remove };
|
|
94
|
+
}
|
package/lib/document.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { statSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { isAbsolute, resolve } from "node:path";
|
|
16
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
17
17
|
import { scopeKeyOf } from "./scope.js";
|
|
18
18
|
import { cosineSimilarity } from "./dream/clustering.js";
|
|
19
19
|
|
|
@@ -23,26 +23,71 @@ import { cosineSimilarity } from "./dream/clustering.js";
|
|
|
23
23
|
// 新版判成「无近重复」,留下两行活跃摘要漂移——supersede 探测必须全时段。
|
|
24
24
|
const DOCUMENT_MIN_SIM = 0.92;
|
|
25
25
|
const CANDIDATE_LIMIT = 200;
|
|
26
|
+
const DOCUMENT_DIR_NAME = "documents";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 解析 managed 文档目录(#296 第二批)。`configured` 为空 = 跟随 memoryDir 的
|
|
30
|
+
* `<memoryDir>/documents/`;`~` / `~/` / `~\` 展开到用户 home;相对路径落在
|
|
31
|
+
* memoryDir 下(本键自己的规则:memoryDir 的相对路径原样留着走 cwd 语义);
|
|
32
|
+
* 绝对路径归一化后原样用。纯函数,路径解析集中在 index.js 调用一次。
|
|
33
|
+
*/
|
|
34
|
+
export function resolveDocumentDir(memoryDir, configured = "") {
|
|
35
|
+
const raw = String(configured ?? "").trim();
|
|
36
|
+
if (!raw) return resolve(memoryDir, DOCUMENT_DIR_NAME);
|
|
37
|
+
if (raw === "~" || raw.startsWith("~/") || raw.startsWith("~\\")) {
|
|
38
|
+
return resolve(homedir(), raw.slice(2));
|
|
39
|
+
}
|
|
40
|
+
return isAbsolute(raw) ? resolve(raw) : resolve(memoryDir, raw);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 这个路径是不是落在 managed 目录里(`documentDir` 内 = mneme 管的树,`index.md`
|
|
45
|
+
* 与镜像 md 同权;之外 = 只登记指针行、正文一个字节都不碰)。比较前两边都归一化,
|
|
46
|
+
* Windows 上大小写不敏感(文件系统就是),否则 `C:\a\documents\x.md` 与
|
|
47
|
+
* `c:\A\Documents\x.md` 会被判成两类。
|
|
48
|
+
*/
|
|
49
|
+
export function isManagedDocumentPath(dir, path) {
|
|
50
|
+
if (!dir || !path) return false;
|
|
51
|
+
const norm = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p));
|
|
52
|
+
const base = norm(resolve(dir));
|
|
53
|
+
const target = norm(resolve(path));
|
|
54
|
+
const prefix = base.endsWith(sep) ? base : base + sep;
|
|
55
|
+
return target === base || target.startsWith(prefix);
|
|
56
|
+
}
|
|
26
57
|
|
|
27
58
|
// 追加到被取代行正文的指针注记。刻意不带语言分支:id 是跨语言稳定键(用户
|
|
28
59
|
// 对 tag/章节名建议用英文同理——指针性内容不翻译);可追溯链 = 注记里的新行
|
|
29
60
|
// id + 旧行 content_history 里的旧摘要(source=superseded)。
|
|
30
61
|
const supersededByPointer = (id) => `\n\n[superseded by ${id}]`;
|
|
31
62
|
|
|
63
|
+
// 升格吸收不碰的类型(#275 拍板 5 的边界):吸收的对象是「原子条」,而下面这两种
|
|
64
|
+
// 行各有自己的生命周期,被归档会各自留下第二行——
|
|
65
|
+
// document:本体是磁盘上的文件。归档它等于替用户撤下外档;而且 supersede 探测
|
|
66
|
+
// 只看活跃行(store.list 默认排除归档),同一 path 下次注册会在旧行还挂着
|
|
67
|
+
// 「新版本」语义时再铸一行,指针注记与 content_history 记账也走不到。
|
|
68
|
+
// summary:dream 总览与叙述按 source 身份去重、并按注入档位常驻。归档它会让下一
|
|
69
|
+
// 次做梦把它当不存在(候选集同样只取活跃行),于是库里多出一行同源总览。
|
|
70
|
+
// 口径是保守方向的:拿不准的行一律不吸收——少做一步没有代价,收错一步是内容事故。
|
|
71
|
+
const ABSORB_EXEMPT_TYPES = new Set(["document", "summary"]);
|
|
72
|
+
|
|
32
73
|
/**
|
|
33
74
|
* Build the document registrar. Injected deps are service.js closure members
|
|
34
75
|
* (store / embedQuery / transaction / finalize) so this module stays free of
|
|
35
76
|
* service-internal wiring; `finalize` is the single write epilogue (mirror
|
|
36
77
|
* sync + notify + re-embed) so callers never duplicate it.
|
|
37
78
|
*
|
|
38
|
-
*
|
|
79
|
+
* `pinnedTypes` 是注入的 #249 逐字保真池(service.js 的 PINNED_MEMORY_TYPES):
|
|
80
|
+
* 这里不 import 它在 service.js 里,是因为 service.js 反向 import 本模块——
|
|
81
|
+
* 方向反了会成环。
|
|
82
|
+
*
|
|
83
|
+
* @returns {(payload: object, opts?: object) => Promise<object>}
|
|
39
84
|
* `{ action: "created"|"superseded", memory, superseded?, evidence_kept,
|
|
40
|
-
* evidence_dropped, degraded }`
|
|
85
|
+
* evidence_dropped, evidence_archived, degraded }`
|
|
41
86
|
* @throws flag 关 / 路径非法或文件缺失 / title 或 summary 为空 / evidence 全部
|
|
42
87
|
* 捏造 / 仅 vector 近重复(疑似重复外档,交 agent 裁决而不是静默二选一)。
|
|
43
88
|
*/
|
|
44
|
-
export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize }) {
|
|
45
|
-
return async function registerDocument(payload, { hiddenEvidenceIds = [] } = {}) {
|
|
89
|
+
export function createDocumentRegistrar({ store, config, embedQuery, pushContentHistory, transaction, finalize, pinnedTypes }) {
|
|
90
|
+
return async function registerDocument(payload, { hiddenEvidenceIds = [], archiveEvidence = true } = {}) {
|
|
46
91
|
// opt-in 总闸(#230 对齐 #228 形态):关 = 注册入口整体不存在,错误信息
|
|
47
92
|
// 指回配置键,agent 能准确转告用户去开。
|
|
48
93
|
if (config?.documentMemoryEnabled !== true) {
|
|
@@ -90,15 +135,14 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
90
135
|
const hidden = new Set((Array.isArray(hiddenEvidenceIds) ? hiddenEvidenceIds : []).map((id) => String(id)));
|
|
91
136
|
const kept = [];
|
|
92
137
|
const dropped = [];
|
|
138
|
+
// 存在但已归档的引用另记一份:它可能是被上一版文档升格吸收走的行(见下方
|
|
139
|
+
// supersede 目标探测后的「认回」),不能一律按「不可用」处理。
|
|
140
|
+
const archivedRefs = new Set();
|
|
93
141
|
for (const id of wanted) {
|
|
94
142
|
const row = hidden.has(id) ? null : store.getById(id);
|
|
143
|
+
if (row?.archived) archivedRefs.add(id);
|
|
95
144
|
(row && !row.archived ? kept : dropped).push(id);
|
|
96
145
|
}
|
|
97
|
-
if (wanted.length > 0 && kept.length === 0) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
146
|
|
|
103
147
|
const title = String(payload?.title ?? "").trim();
|
|
104
148
|
if (!title) throw new Error("registerDocument: title is required");
|
|
@@ -131,6 +175,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
131
175
|
.filter((m) => scopeMatches(m));
|
|
132
176
|
const explicitTarget = scopeMatched.find((m) => samePath(m.doc_path, expanded))
|
|
133
177
|
?? scopeMatched.find((m) => m.title.trim() === title);
|
|
178
|
+
// 升格吸收过的引用认回(#275 拍板 5 的配套):evidence 行在文档注册成功后就翻了
|
|
179
|
+
// archived,于是「同一份文档出新版、evidence 照旧」这一最常规的路径会整批落在
|
|
180
|
+
// dropped——那会被下面的捏造判据误报成「证据都是编的」。口径:已归档行仍可为
|
|
181
|
+
// **吸收它的那份文档**背书(loser.evidence 就是它吸收走的名单),不为别的文档
|
|
182
|
+
// 背书;捏造与跨 scope 照旧拒绝。
|
|
183
|
+
if (explicitTarget) {
|
|
184
|
+
const own = new Set(explicitTarget.evidence ?? []);
|
|
185
|
+
for (const id of archivedRefs) {
|
|
186
|
+
if (!own.has(id)) continue;
|
|
187
|
+
const at = dropped.indexOf(id);
|
|
188
|
+
if (at >= 0) dropped.splice(at, 1);
|
|
189
|
+
kept.push(id);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (wanted.length > 0 && kept.length === 0) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`registerDocument: all ${wanted.length} evidence ids are unknown, archived or out of scope — fabricated evidence is rejected`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
134
197
|
if (!explicitTarget) {
|
|
135
198
|
// vector 档:embedder 不可用/向量缺失一律跳过——去重是增强不是写入依赖
|
|
136
199
|
// (findSessionDuplicate 同原则)。probe 用 title+summary,与行向量
|
|
@@ -206,7 +269,25 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
206
269
|
store.setArchived(loser.id, true);
|
|
207
270
|
superseded = store.getById(loser.id);
|
|
208
271
|
}
|
|
209
|
-
|
|
272
|
+
// #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
|
|
273
|
+
// 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
|
|
274
|
+
// 都成、要么都不成);②pinned(constraint / preference,即 #249 的逐字保真池)
|
|
275
|
+
// 永不自动归档——升格不能绕过它;③自有生命周期的类型(document / summary,见
|
|
276
|
+
// ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
|
|
277
|
+
// (可恢复),opt-out 走 archiveEvidence。
|
|
278
|
+
// pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
|
|
279
|
+
let evidenceArchived = 0;
|
|
280
|
+
if (archiveEvidence && pinnedTypes && typeof pinnedTypes.has === "function") {
|
|
281
|
+
for (const id of kept) {
|
|
282
|
+
// 事务内重读:期间被别的进程归档/遗忘的行不重复计数,也不误伤 pinned。
|
|
283
|
+
const row = store.getById(id);
|
|
284
|
+
if (!row || row.archived || row.forgotten) continue;
|
|
285
|
+
if (pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
|
|
286
|
+
store.setArchived(id, true);
|
|
287
|
+
evidenceArchived += 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { created, superseded, evidenceArchived };
|
|
210
291
|
});
|
|
211
292
|
finalize(result.superseded ? [result.created, result.superseded] : [result.created]);
|
|
212
293
|
return {
|
|
@@ -215,6 +296,7 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
215
296
|
...(result.superseded ? { superseded: result.superseded } : {}),
|
|
216
297
|
evidence_kept: kept.length,
|
|
217
298
|
evidence_dropped: dropped.length,
|
|
299
|
+
evidence_archived: result.evidenceArchived,
|
|
218
300
|
degraded: dropped.length > 0
|
|
219
301
|
};
|
|
220
302
|
};
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createStore } from "./store.js";
|
|
2
2
|
import { createMirror, TYPE_FILE } from "./mirror.js";
|
|
3
|
+
import { createDocumentIndex } from "./document-index.js";
|
|
4
|
+
import { resolveDocumentDir } from "./document.js";
|
|
3
5
|
import { createService } from "./service.js";
|
|
6
|
+
// #254 写入准入(第一阶段只计量,不拦截):见 src/write-admission.js 的文件头。
|
|
7
|
+
import { createWriteAdmission } from "./write-admission.js";
|
|
4
8
|
import { createTools } from "./tools.js";
|
|
5
9
|
import { createInjector } from "./inject.js";
|
|
6
10
|
import { createSummarizer } from "./summarize.js";
|
|
@@ -8,6 +12,8 @@ import { createDreamScheduler } from "./dream.js";
|
|
|
8
12
|
import { createSleepScheduler, runSleep } from "./dream/sleep.js";
|
|
9
13
|
import { createApi } from "./api.js";
|
|
10
14
|
import { createStandaloneApi } from "./api-standalone.js";
|
|
15
|
+
// #275 存储生命周期第一批:无损回收(手动入口,不挂启动路径)。
|
|
16
|
+
import { createMaintenance } from "./maintenance.js";
|
|
11
17
|
import { createSettings } from "./settings.js";
|
|
12
18
|
import { createCommandManager } from "./commands.js";
|
|
13
19
|
import { createEmbedder } from "./embedding.js";
|
|
@@ -193,15 +199,6 @@ export const apply = (ctx, config) => {
|
|
|
193
199
|
try {
|
|
194
200
|
store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString());
|
|
195
201
|
} catch { /* non-fatal */ }
|
|
196
|
-
// Bug8: enforce llm_audit_logs retention on boot (config.llmAudit.retentionDays,
|
|
197
|
-
// default 90). Best-effort like the failure prune — the audit trail is
|
|
198
|
-
// bookkeeping and a failed purge must never block plugin boot.
|
|
199
|
-
try {
|
|
200
|
-
if (rawCfg.llmAudit?.enabled !== false) {
|
|
201
|
-
const retentionMs = Number.isInteger(rawCfg.llmAudit?.retentionDays) ? rawCfg.llmAudit.retentionDays : 90;
|
|
202
|
-
store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
|
|
203
|
-
}
|
|
204
|
-
} catch { /* non-fatal */ }
|
|
205
202
|
|
|
206
203
|
// User-configurable settings (profile, rules, panel mode, standalone API
|
|
207
204
|
// token) share the same SQLite file in dedicated tables, isolated from
|
|
@@ -240,10 +237,46 @@ export const apply = (ctx, config) => {
|
|
|
240
237
|
cfg[objKey] = { ...(cfg[objKey] ?? {}), ...sub };
|
|
241
238
|
}
|
|
242
239
|
|
|
240
|
+
// Bug8 的启动期清理放在装配之后:面板把 llmAudit.* 写进 kv、经 nestedFlags 合进
|
|
241
|
+
// cfg,而写入侧(dream / summarize / 写入准入)读的都是装配后的 cfg——清理若读
|
|
242
|
+
// rawCfg,面板改了保留期它不认,更糟的是「开不开审计」与写入侧可能取到不同的值
|
|
243
|
+
// (raw 说关 → 不清理,cfg 说开 → 照写,审计表就无保留期地长)。保留期默认 90 天、
|
|
244
|
+
// 失败只 warn,与失败表清理同款:账本清理绝不许挡住插件启动。
|
|
245
|
+
try {
|
|
246
|
+
if (cfg.llmAudit?.enabled !== false) {
|
|
247
|
+
const retentionMs = Number.isInteger(cfg.llmAudit?.retentionDays) ? cfg.llmAudit.retentionDays : 90;
|
|
248
|
+
store.deleteOldLlmAudits(new Date(Date.now() - retentionMs * 86400000).toISOString());
|
|
249
|
+
}
|
|
250
|
+
} catch { /* non-fatal */ }
|
|
251
|
+
|
|
243
252
|
// 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
|
|
244
253
|
// sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
|
|
245
254
|
const mirror = createMirror(memoryDir, langOf(cfg));
|
|
246
|
-
|
|
255
|
+
|
|
256
|
+
// #296 第二批:managed 文档目录。解析与 memoryDir 同一套(空 = 跟随 memoryDir 的
|
|
257
|
+
// <memoryDir>/documents/,~ 展开,相对路径落在 memoryDir 下)。索引对象总是建:
|
|
258
|
+
// 闸关时它只承担「清掉陈旧 index.md」这一件事,与 documents.md 在闸关时被镜像删
|
|
259
|
+
// 掉同一口径。建目录与写索引失败都只 warn,不阻断插件加载(它们是机器产物)。
|
|
260
|
+
const documentDir = resolveDocumentDir(memoryDir, cfg.documentDir);
|
|
261
|
+
const documentIndex = createDocumentIndex(documentDir, langOf(cfg));
|
|
262
|
+
if (cfg.documentMemoryEnabled === true) {
|
|
263
|
+
const ensured = documentIndex.ensure();
|
|
264
|
+
if (!ensured.ok) {
|
|
265
|
+
ctx.logger?.warn?.(`[dsh-mneme] documentDir is not writable: ${documentDir}: ${ensured.error}`);
|
|
266
|
+
}
|
|
267
|
+
// 启动即渲染一次:索引不该等到第一次业务写才存在(删掉它之后重启也能自愈)。
|
|
268
|
+
const synced = documentIndex.sync(store.list({ type: "document", limit: null }));
|
|
269
|
+
if (!synced.ok) ctx.logger?.warn?.(`[dsh-mneme] document index sync failed: ${synced.error}`);
|
|
270
|
+
} else {
|
|
271
|
+
documentIndex.remove();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 写入准入实例:本批次只做计量(决策恒放行、写审计行),所以不需要新开关——它
|
|
275
|
+
// 不改变任何写入行为,也不新增拦截分支;既有的 llmAudit.enabled 关掉时它同样
|
|
276
|
+
// 不写(那个开关连审计行的启动期清理一起关掉)。第二阶段把拦截打开时才按仓库
|
|
277
|
+
// 惯例引入 opt-in 默认关的配置键,届时只改这一个实例的构造与 service 的调用点。
|
|
278
|
+
const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger });
|
|
279
|
+
const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
|
|
247
280
|
|
|
248
281
|
// F-NEW-03: if the mirror sync failed last run (persisted dirty state), retry
|
|
249
282
|
// a safe re-render at boot so a stale mirror converges without needing a
|
|
@@ -549,13 +582,19 @@ export const apply = (ctx, config) => {
|
|
|
549
582
|
disposers.push(api.dispose);
|
|
550
583
|
}
|
|
551
584
|
|
|
585
|
+
// #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
|
|
586
|
+
// 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、
|
|
587
|
+
// 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主
|
|
588
|
+
// 抢锁的那条路走不通)。
|
|
589
|
+
const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
|
|
590
|
+
|
|
552
591
|
// Standalone external API (v0.7.12): plain node:http server for ecosystem
|
|
553
592
|
// integrations outside the DSH host. Persisted external_api settings win
|
|
554
593
|
// over the bundle config (enabled/port); the Bearer token lives in the same
|
|
555
594
|
// kv and is auto-generated on first boot by createStandaloneApi. Binding a
|
|
556
595
|
// non-loopback host is the operator's documented responsibility.
|
|
557
596
|
if ((settings.getExternalApi?.()?.enabled ?? cfg.externalApiEnabled) === true) {
|
|
558
|
-
const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings });
|
|
597
|
+
const standalone = createStandaloneApi({ service, store, config: cfg, logger: ctx.logger, settings, maintenance });
|
|
559
598
|
disposers.push(() => standalone.server.close());
|
|
560
599
|
standalone.ready.catch((error) => {
|
|
561
600
|
ctx.logger?.warn?.(`[dsh-mneme] standalone API failed to start: ${String(error)}`);
|
package/lib/inject.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHotMemory } from "./hot-memory.js";
|
|
|
3
3
|
import { STR, langOf } from "./lang.js";
|
|
4
4
|
import { MEMORY_GUIDE_SECTION } from "./guide.js";
|
|
5
5
|
import { adaptiveInjectBudget } from "./search/adaptive.js";
|
|
6
|
+
import { injectChildEnabled } from "./config.js";
|
|
6
7
|
|
|
7
8
|
// Issue #179:注入预览的数据底座。systemPrompt 渲染是同步回调,面板只能事后
|
|
8
9
|
// 拉取,所以在这里旁路缓存「最近一帧组装」——快照就是本次渲染用过的同一份
|
|
@@ -283,12 +284,15 @@ export function createInjector(ctx, service, settings, config) {
|
|
|
283
284
|
return escapePromptVars(lines.join("\n"));
|
|
284
285
|
}
|
|
285
286
|
|
|
286
|
-
// #249
|
|
287
|
+
// #249 能力说明的系统提示段。order 150 = 插件指引段的既有惯例
|
|
287
288
|
// (ACP 的 ACP_SYSTEM_PROMPT 与 mnemon 的 routing 段都在 150)。文本是常量、
|
|
288
289
|
// 同会话内不随轮次变化,故不影响其后的前缀缓存,也不逐轮复读。宿主若不提供
|
|
289
290
|
// section seam 就静默跳过——#249 §3 的口径是「只用现成 seam,拿不到位的语义
|
|
290
291
|
// 降级处理」:能力说明仍落在工具描述上,不算失败。
|
|
291
|
-
|
|
292
|
+
// 走父/子闸门(#249 第二批):注入器本身只在 `autoInject` 开时挂载,这里是
|
|
293
|
+
// 第二道、也是同一个判据的显式落点——两处消费点共用 injectChildEnabled,
|
|
294
|
+
// 不在各自的地方重写「父关则子不生效」。
|
|
295
|
+
const guideSection = injectChildEnabled(config, "injectGuidanceEnabled") && typeof ctx.systemPrompt?.section === "function"
|
|
292
296
|
? ctx.systemPrompt.section({ name: "memory-guide", order: 150, text: MEMORY_GUIDE_SECTION })
|
|
293
297
|
: null;
|
|
294
298
|
|
package/lib/lang.js
CHANGED
|
@@ -196,12 +196,23 @@ export const STR = {
|
|
|
196
196
|
|
|
197
197
|
// --- mirror.js:镜像文件标签(渲染随实例语言;解析两种语言都认) ----------------
|
|
198
198
|
mirrorLabel: {
|
|
199
|
-
zh: { type: "类型", importance: "重要性", tags: "标签", updated: "更新时间", source: "来源" },
|
|
200
|
-
en: { type: "Type", importance: "Importance", tags: "Tags", updated: "Updated", source: "Source" }
|
|
199
|
+
zh: { type: "类型", importance: "重要性", tags: "标签", updated: "更新时间", source: "来源", scope: "作用域", sensitivity: "敏感度" },
|
|
200
|
+
en: { type: "Type", importance: "Importance", tags: "Tags", updated: "Updated", source: "Source", scope: "Scope", sensitivity: "Sensitivity" }
|
|
201
201
|
},
|
|
202
202
|
mirrorHeader: {
|
|
203
|
-
zh: (name) => `# ${name} — dsh-mneme 镜像\n\n<!--
|
|
204
|
-
en: (name) => `# ${name} — dsh-mneme mirror\n\n<!--
|
|
203
|
+
zh: (name) => `# ${name} — dsh-mneme 镜像\n\n<!-- 条目标题与正文可编辑,会被合并回记忆库(人工优先);文件头与条目元数据行由机器维护,改动会在下次同步时被覆盖。 -->\n\n`,
|
|
204
|
+
en: (name) => `# ${name} — dsh-mneme mirror\n\n<!-- Entry titles and bodies are editable and merged back into the memory store (human edits win); the file header and entry metadata lines are machine-owned and get overwritten on the next sync. -->\n\n`
|
|
205
|
+
},
|
|
206
|
+
// document 的镜像只有指针行(#296 第二批):没有可编辑的正文,手工改动一律被
|
|
207
|
+
// 下次同步覆盖——所以不能复用上面那句「可编辑、会被合并回记忆库」。
|
|
208
|
+
mirrorReadonlyHeader: {
|
|
209
|
+
zh: (name) => `# ${name} — dsh-mneme 只读视图\n\n<!-- 只含指针行:id + 标题 + 摘要首句 + 文件路径,不含正文。正文在路径指向的文件里,批注请写进记忆库;本文件由机器维护,手工改动会在下次同步时被覆盖。 -->\n\n`,
|
|
210
|
+
en: (name) => `# ${name} — dsh-mneme read-only view\n\n<!-- Pointer rows only: id + title + first sentence of the summary + file path, never the full text. The document itself lives at that path, annotations belong in the memory store, and this file is machine-owned: hand edits are overwritten on the next sync. -->\n\n`
|
|
211
|
+
},
|
|
212
|
+
// documentDir 的 index.md(#296 第二批):整文件机器所有、可从库重建。
|
|
213
|
+
documentIndexHeader: {
|
|
214
|
+
zh: () => "# document 索引 — dsh-mneme\n\n<!-- 整文件机器所有,可从记忆库随时重建(所以这里不写生成时间);要批注请写进记忆库(memory_save)。managed = 文件在 documentDir 内。 -->\n\n",
|
|
215
|
+
en: () => "# document index — dsh-mneme\n\n<!-- Machine-owned as a whole and rebuildable from the memory store at any time (which is why it carries no generation timestamp). Annotations belong in the memory store (memory_save). managed = the file sits inside documentDir. -->\n\n"
|
|
205
216
|
},
|
|
206
217
|
|
|
207
218
|
prompts: PROMPTS
|