@modusensus/dsh-mneme 0.8.5 → 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 +45 -147
- 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 +78 -11
- package/lib/content-hash.js +54 -0
- package/lib/document-index.js +94 -0
- package/lib/document.js +93 -11
- package/lib/dream/sleep.js +122 -8
- package/lib/dream.js +1354 -1347
- package/lib/index.js +115 -14
- package/lib/inject.js +6 -2
- package/lib/lang.js +15 -4
- package/lib/local-embedder.js +44 -6
- 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 +6 -3
- package/lib/store.js +248 -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 +78 -11
- package/src/content-hash.js +54 -0
- package/src/document-index.js +94 -0
- package/src/document.js +93 -11
- package/src/dream/sleep.js +122 -8
- package/src/dream.js +1354 -1347
- package/src/index.js +115 -14
- package/src/inject.js +6 -2
- package/src/lang.js +15 -4
- package/src/local-embedder.js +44 -6
- 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 +6 -3
- package/src/store.js +248 -21
- package/src/tools.js +27 -9
- package/src/write-admission.js +259 -0
- package/test/api.test.js +15 -2
- 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/lastrunat-persist.test.js +101 -0
- package/test/llm-audit.test.js +136 -0
- package/test/local-embedder.test.js +45 -2
- 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/sleep.test.js +117 -0
- package/test/write-admission.test.js +345 -0
package/src/mirror.js
CHANGED
|
@@ -2,17 +2,37 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { STR, langOf } from "./lang.js";
|
|
5
|
+
import { PACKAGE_VERSION } from "./version-check.js";
|
|
5
6
|
|
|
6
7
|
export const TYPE_FILE = {
|
|
7
8
|
preference: "preferences.md",
|
|
8
9
|
project: "projects.md",
|
|
9
10
|
decision: "decisions.md",
|
|
10
11
|
history: "history.md",
|
|
11
|
-
summary: "summary.md"
|
|
12
|
+
summary: "summary.md",
|
|
13
|
+
pitfall: "pitfalls.md",
|
|
14
|
+
constraint: "constraints.md",
|
|
15
|
+
rejected_solution: "rejected-solutions.md",
|
|
16
|
+
pattern: "patterns.md",
|
|
17
|
+
document: "documents.md"
|
|
12
18
|
};
|
|
13
19
|
|
|
20
|
+
// 不落镜像的 type。现在是空集:document 由 #296 第二批收进来——第一版排除它的
|
|
21
|
+
// 理由是「镜像块渲染的字段里没有 doc_path,落一个 documents.md 会得到看着完整、
|
|
22
|
+
// 其实找不到文件的视图」,doc_path 进指针行以后这条理由不再成立。这个集合由
|
|
23
|
+
// test/mirror-types.test.js 钉死(TYPES \ TYPE_FILE 必须恰好等于本集合):将来
|
|
24
|
+
// 新增 type 时必须显式决定它落不落镜像,静默漏掉才是 #278 要修的那种盲区。
|
|
25
|
+
export const MIRROR_EXCLUDED_TYPES = new Set([]);
|
|
26
|
+
|
|
27
|
+
// 只读视图(#296):document 的镜像只有指针行(id + 标题 + 摘要首句 + doc_path),
|
|
28
|
+
// 没有可编辑的正文,所以它不参与 readHumanEdits 的人改合并,手工改动一律被下次
|
|
29
|
+
// 同步覆盖;导出/导入也不带它(那两个是 round-trip 通道,见 api.js)。
|
|
30
|
+
export const MIRROR_READONLY_TYPES = new Set(["document"]);
|
|
31
|
+
|
|
14
32
|
const ESCAPE = /([\\`*_[\]{}()#+.!|>~-])/g;
|
|
15
33
|
const UNESCAPE = new RegExp("\\\\" + ESCAPE.source, "g");
|
|
34
|
+
// 只读视图里摘要首句的字符上限(#296):够定位就行,长摘要不该把一行撑成一段。
|
|
35
|
+
const POINTER_MAX = 120;
|
|
16
36
|
|
|
17
37
|
function esc(text) {
|
|
18
38
|
return String(text).replace(ESCAPE, "\\$1");
|
|
@@ -22,6 +42,36 @@ function unescape(text) {
|
|
|
22
42
|
return String(text).replace(UNESCAPE, "$1");
|
|
23
43
|
}
|
|
24
44
|
|
|
45
|
+
// 元数据标签的唯一来源是 STR.mirrorLabel(lang.js):渲染按实例语言取,解析两套
|
|
46
|
+
// 都认。下面的正则从那张表现算——旧实现把手写正则与 lang.js 的两处文案分开维护,
|
|
47
|
+
// 加一个字段要改三处,漏改一处那一行就会随 /import 写进 entry 的 content。
|
|
48
|
+
const META_LABELS = [];
|
|
49
|
+
for (const language of Object.keys(STR.mirrorLabel)) META_LABELS.push(...Object.values(STR.mirrorLabel[language]));
|
|
50
|
+
|
|
51
|
+
function reEsc(text) {
|
|
52
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 元数据行的值必须单行:值里带换行会多出一行,轻则破坏「整段元数据行」的解析契约,
|
|
56
|
+
// 重则伪造出一个条目头(sensitivity 是调用方传入的任意字符串,属信任边界)。
|
|
57
|
+
function oneLine(text) {
|
|
58
|
+
return String(text).replace(/\s+/g, " ").trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 结构头锚:ID 行紧跟类型行才算条目头(正文里机器格式的 ID 行不会切开块)。
|
|
62
|
+
const ANCHOR_RE = new RegExp(
|
|
63
|
+
"^- \\*\\*ID\\*\\*: `([^`]+)`\\n- \\*\\*(?:" +
|
|
64
|
+
[STR.mirrorLabel.zh.type, STR.mirrorLabel.en.type].map(reEsc).join("|") +
|
|
65
|
+
")\\*\\*:", "gm"
|
|
66
|
+
);
|
|
67
|
+
// 渲染出的整段元数据行(加字段只需改 lang.js 的 mirrorLabel)。
|
|
68
|
+
const META_RUN_RE = new RegExp(`^(- \\*\\*(?:${META_LABELS.map(reEsc).join("|")})\\*\\*:.*\\n?)+`);
|
|
69
|
+
// 三方合并用的版本令牌(service.syncMirror 比对并发写)。
|
|
70
|
+
const UPDATED_RE = new RegExp(
|
|
71
|
+
"- \\*\\*(?:" + [STR.mirrorLabel.zh.updated, STR.mirrorLabel.en.updated].map(reEsc).join("|") +
|
|
72
|
+
")\\*\\*: ([^\\n]+)"
|
|
73
|
+
);
|
|
74
|
+
|
|
25
75
|
function renderMemory(m, language = "zh") {
|
|
26
76
|
// last-rendered digest baseline: sha256(title \x00 content). service.js
|
|
27
77
|
// compares the file hash against this to tell "untouched by a human" (machine
|
|
@@ -40,6 +90,12 @@ function renderMemory(m, language = "zh") {
|
|
|
40
90
|
lines.push(`- **${ML.tags}**: ${m.tags.map((t) => `\`${esc(t)}\``).join(" ")}`);
|
|
41
91
|
lines.push(`- **${ML.updated}**: ${m.updated_at}`);
|
|
42
92
|
if (m.source) lines.push(`- **${ML.source}**: ${esc(m.source)}`);
|
|
93
|
+
// scope / sensitivity 是只读展示字段(#278:sensitivity 的过滤语义另有归属,见
|
|
94
|
+
// 那条讨论;这里只把值摆出来)。只在真有值时渲染——本机 2843 条里只有 37 条带
|
|
95
|
+
// scope 标签,无条件渲染会给每条目多添两行空字段。
|
|
96
|
+
const scopeText = [m.agent_scope, m.workspace_scope].filter(Boolean).map(oneLine).join(" / ");
|
|
97
|
+
if (scopeText) lines.push(`- **${ML.scope}**: ${scopeText}`);
|
|
98
|
+
if (m.sensitivity) lines.push(`- **${ML.sensitivity}**: ${oneLine(m.sensitivity)}`);
|
|
43
99
|
lines.push("");
|
|
44
100
|
lines.push(`<!-- mirror-digest: ${digest} -->`);
|
|
45
101
|
lines.push(m.content);
|
|
@@ -49,22 +105,76 @@ function renderMemory(m, language = "zh") {
|
|
|
49
105
|
return lines.join("\n");
|
|
50
106
|
}
|
|
51
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 文件头 frontmatter(#278 第一批)。按 type 分文件时一个文件装 N 条,所以
|
|
110
|
+
* frontmatter 只能落在文件级;条目级元数据仍是块内的 `- **字段**:` 行(见
|
|
111
|
+
* renderMemory)。
|
|
112
|
+
*
|
|
113
|
+
* `covered` / `coverage` 是覆盖范围的自述,必须与这份文本**实际包含的行集**一致:
|
|
114
|
+
* 磁盘镜像写活跃集(active-only),/export 的文档写全表(all)。说一套写一套比
|
|
115
|
+
* 没有这个字段更糟——外部工具会照着它聚合条数与标签。
|
|
116
|
+
*
|
|
117
|
+
* 键名对齐 OKF v0.2:`type` 是 §4.1 唯一必填键,`tags` 是 §4.1 的推荐键,
|
|
118
|
+
* `generated.by` / `generated.at` 是 §5.2 的 trust 族。`generated.by` 按 §7 的
|
|
119
|
+
* actor 约定写 `<producer>/<version>`(所以带版本号);`generated.at` 按 §5.2 的
|
|
120
|
+
* 语义只记「内容上次真正变化」,sync 因此只在正文变化时才落盘。v0.2 已用
|
|
121
|
+
* `generated.at` 取代 v0.1 的 `timestamp`,没有 `updated` 这个顶层键。
|
|
122
|
+
* `covered` / `coverage` 是 §4.1 允许的 producer 自定义扩展。
|
|
123
|
+
*/
|
|
124
|
+
export function renderFileHeader(type, items, coverage = "active-only") {
|
|
125
|
+
const tags = [...new Set(items.flatMap((m) => (m.tags ?? []).map(String)))].sort();
|
|
126
|
+
return [
|
|
127
|
+
"---",
|
|
128
|
+
`type: ${type}`,
|
|
129
|
+
`generated.by: dsh-mneme/${PACKAGE_VERSION}`,
|
|
130
|
+
`generated.at: ${new Date().toISOString()}`,
|
|
131
|
+
`covered: ${items.length}`,
|
|
132
|
+
`coverage: ${coverage}`,
|
|
133
|
+
`tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
|
|
134
|
+
"---",
|
|
135
|
+
""
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* document 的只读视图一行(#296):一句话定位 + 文件指针,正文按 doc_path 去读。
|
|
141
|
+
* 摘要首句按句末标点切,切不出来就整段(超长再按字符数兜底),保证「一行一个
|
|
142
|
+
* 文档」的形态不被长摘要撑破。
|
|
143
|
+
*/
|
|
144
|
+
function renderPointer(m) {
|
|
145
|
+
const flat = String(m.content ?? "").replace(/\s+/g, " ").trim();
|
|
146
|
+
// 句末标点切首句:中文的。!? 后面通常不跟空格,不能像英文那样要求一个空白
|
|
147
|
+
// (要求了就一句都切不出来,整段摘要被当成「首句」塞进视图)。英文的 . 仍要求
|
|
148
|
+
// 后接空白或结尾,免得把 e.g. / v1.2 这类点号当成句末。
|
|
149
|
+
const first = flat.match(/^.*?(?:[。!?]|[!?]|[.](?=\s|$))|^.*$/s)?.[0] ?? flat;
|
|
150
|
+
// 按码点切,不按 UTF-16 码元:后者会在 emoji / 代理对中间落刀,落盘成 U+FFFD。
|
|
151
|
+
const chars = [...first];
|
|
152
|
+
const summary = chars.length > POINTER_MAX ? `${chars.slice(0, POINTER_MAX).join("")}…` : first;
|
|
153
|
+
const where = m.doc_path ? ` · \`${oneLine(m.doc_path)}\`` : "";
|
|
154
|
+
return `- \`${m.id}\` **${oneLine(m.title)}**:${oneLine(summary)}${where}\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
52
157
|
/**
|
|
53
158
|
* Render one type's memories into exactly the mirror-file text (header +
|
|
54
159
|
* per-memory blocks, updated_at DESC like sync). sync() writes this to disk;
|
|
55
|
-
* the /export endpoint
|
|
56
|
-
*
|
|
57
|
-
*
|
|
160
|
+
* the /export endpoint reuses the same entry blocks for its per-type sections,
|
|
161
|
+
* so an exported markdown round-trips back through parseHumanEdits →
|
|
162
|
+
* mergeHumanEdits. Unknown type → undefined.
|
|
163
|
+
*
|
|
164
|
+
* `fileHeader: false` 供 /export 用:一个文档只能有一个 frontmatter 块(在文档
|
|
165
|
+
* 最前,见 api.js 的 /export),所以分节不再各自带文件头。只读 type(#296 的
|
|
166
|
+
* document)只走磁盘镜像这一条:渲染指针行 + 只读文件头,导出侧不取它。
|
|
58
167
|
*/
|
|
59
|
-
export function renderMirrorText(type, memories, language = "zh") {
|
|
168
|
+
export function renderMirrorText(type, memories, language = "zh", { fileHeader = true } = {}) {
|
|
60
169
|
const name = TYPE_FILE[type];
|
|
61
170
|
if (!name) return undefined;
|
|
62
171
|
const items = (memories ?? [])
|
|
63
172
|
.slice()
|
|
64
173
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
174
|
+
const readonly = MIRROR_READONLY_TYPES.has(type);
|
|
175
|
+
const header = readonly ? STR.mirrorReadonlyHeader[language](name) : STR.mirrorHeader[language](name);
|
|
176
|
+
const body = items.map((m) => (readonly ? renderPointer(m) : renderMemory(m, language))).join("\n");
|
|
177
|
+
return (fileHeader ? renderFileHeader(type, items) : "") + header + body;
|
|
68
178
|
}
|
|
69
179
|
|
|
70
180
|
/**
|
|
@@ -91,7 +201,7 @@ export function parseHumanEdits(text) {
|
|
|
91
201
|
// A body line like "- **ID**: `x`" is not, so it never splits the block
|
|
92
202
|
// or produces a ghost entry.
|
|
93
203
|
// 解析同时接受两种语言的标签:切换语言前渲染的镜像文件仍能合并。
|
|
94
|
-
const anchors = [...normalized.matchAll(
|
|
204
|
+
const anchors = [...normalized.matchAll(ANCHOR_RE)];
|
|
95
205
|
let prevEnd = 0;
|
|
96
206
|
for (let i = 0; i < anchors.length; i++) {
|
|
97
207
|
const anchor = anchors[i];
|
|
@@ -109,7 +219,7 @@ export function parseHumanEdits(text) {
|
|
|
109
219
|
let body = normalized
|
|
110
220
|
.slice(blockStart, blockEnd)
|
|
111
221
|
.replace(/^- \*\*ID\*\*: `[^`]+`\n?/, "")
|
|
112
|
-
.replace(
|
|
222
|
+
.replace(META_RUN_RE, "")
|
|
113
223
|
.replace(/^<!-- mirror-digest: [a-f0-9]+ -->\n?/m, "");
|
|
114
224
|
const separators = [...body.matchAll(/^---\s*$/gm)];
|
|
115
225
|
const lastSep = separators[separators.length - 1];
|
|
@@ -120,7 +230,7 @@ export function parseHumanEdits(text) {
|
|
|
120
230
|
// render time — the version token for detecting a concurrent store write
|
|
121
231
|
// during a three-way merge of human edits (see service.syncMirror).
|
|
122
232
|
const block = normalized.slice(blockStart, blockEnd);
|
|
123
|
-
const updatedMatch = block.match(
|
|
233
|
+
const updatedMatch = block.match(UPDATED_RE);
|
|
124
234
|
const digestMatch = block.match(/<!-- mirror-digest: ([a-f0-9]+) -->/);
|
|
125
235
|
edits.push({
|
|
126
236
|
id: anchor[1],
|
|
@@ -136,6 +246,15 @@ export function parseHumanEdits(text) {
|
|
|
136
246
|
return edits;
|
|
137
247
|
}
|
|
138
248
|
|
|
249
|
+
/**
|
|
250
|
+
* 判断「这份文本的内容有没有变」时忽略 generated.at:它每次渲染都是新时间戳,
|
|
251
|
+
* 带上它就永远判为变化。其余字节必须逐字相同才算没变——镜像的幂等性(同样的库
|
|
252
|
+
* 渲染两次得到同样的文件)才是可断言的。
|
|
253
|
+
*/
|
|
254
|
+
function stripGeneratedAt(text) {
|
|
255
|
+
return String(text).replace(/^generated\.at: .*$/m, "");
|
|
256
|
+
}
|
|
257
|
+
|
|
139
258
|
export function createMirror(dir, language = "zh") {
|
|
140
259
|
mkdirSync(dir, { recursive: true });
|
|
141
260
|
|
|
@@ -150,7 +269,10 @@ export function createMirror(dir, language = "zh") {
|
|
|
150
269
|
* this wrapper only owns the "read file → text" side.
|
|
151
270
|
*/
|
|
152
271
|
function readHumanEdits(type = undefined) {
|
|
153
|
-
|
|
272
|
+
// 只读 type 不参与人改合并(#296):它们的文件里没有可编辑的正文,解析出来的
|
|
273
|
+
// 指针行文本若被当成「人改」写回库,会污染摘要。整类跳过,包括 type 指定的
|
|
274
|
+
// 那条调用路径。
|
|
275
|
+
const types = (type ? [type] : Object.keys(TYPE_FILE)).filter((t) => !MIRROR_READONLY_TYPES.has(t));
|
|
154
276
|
const edits = [];
|
|
155
277
|
for (const t of types) {
|
|
156
278
|
const file = filePath(t);
|
|
@@ -182,9 +304,19 @@ export function createMirror(dir, language = "zh") {
|
|
|
182
304
|
// memories do not "resurrect" via readHumanEdits
|
|
183
305
|
rmSync(file, { force: true });
|
|
184
306
|
} else {
|
|
185
|
-
// 渲染走 renderMirrorText(与 /export
|
|
186
|
-
|
|
187
|
-
|
|
307
|
+
// 渲染走 renderMirrorText(与 /export 共用同一条渲染路径)。
|
|
308
|
+
const text = renderMirrorText(type, items, language);
|
|
309
|
+
// 内容没变就不落盘。generated.at 每次渲染都新,无脑写会让编辑器、同步盘
|
|
310
|
+
// 与 git 在每次业务写后看到全部镜像文件「被外部修改」,而内容其实一字未
|
|
311
|
+
// 变;按 OKF 的语义它只该记内容变化,未变就保留旧值。这也是同步读 9 个
|
|
312
|
+
// 小文件的代价,比无谓写 9 次盘便宜。
|
|
313
|
+
let prev = null;
|
|
314
|
+
try {
|
|
315
|
+
prev = readFileSync(file, "utf8");
|
|
316
|
+
} catch { /* 首次写:读不到就是没有旧文件 */ }
|
|
317
|
+
if (prev === null || stripGeneratedAt(prev) !== stripGeneratedAt(text)) {
|
|
318
|
+
writeFileSync(file, text, "utf8");
|
|
319
|
+
}
|
|
188
320
|
}
|
|
189
321
|
results[type] = { ok: true };
|
|
190
322
|
} catch (error) {
|
package/src/recall-stats.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// 新内聚块从第一行就落在自己的文件里,service.js 保留 barrel 出口、调用方零改动。
|
|
6
6
|
// 纯读不写,绝不触发任何 write hook。
|
|
7
7
|
|
|
8
|
+
import { scopeKeyOf } from "./scope.js";
|
|
9
|
+
|
|
8
10
|
/**
|
|
9
11
|
* 口径(issue #217 评论 2026-09-18,锚 5bd2dab):
|
|
10
12
|
* - Top-N:窗口内 recall_runs.candidates(最终返回集)按 id 计数,join
|
|
@@ -20,6 +22,17 @@
|
|
|
20
22
|
* - coverage:recallRecordDefault 开启前的窗口算不到,earliestRunAt 为
|
|
21
23
|
* null(窗口内无回执)或早于窗口起点时,前端标注可信度;扫描行数有
|
|
22
24
|
* 上限,超出标 truncated(degraded 口径),不静默少算。
|
|
25
|
+
* - archive(#275 拍板的第五指标,与上面几项同位):
|
|
26
|
+
* · total = 归档区现有行数;addedInWindow / perDay = 窗口内进入归档区的行数
|
|
27
|
+
* 与其日均(净增速率)。归档时刻只能取 updated_at(setArchived 翻标志位时刷
|
|
28
|
+
* 它)——这是代理口径:对已归档行做一次 memory_update,或对同一行重复调
|
|
29
|
+
* setArchived(…, true),都会被算成「本窗口新进归档」,perDay 因此偏高。精确
|
|
30
|
+
* 口径要一个 archived_at 列(schema 变更,属第二批题材),本版不加;真正的
|
|
31
|
+
* 「净」增本来也要跨快照比 total(物理删除落地后,差值才会由负向变化体现)。
|
|
32
|
+
* · compressible = 可压掉行数:同 type、同 scope 三维且内容哈希完全相同的归档
|
|
33
|
+
* 行里,多出来的那些(每组留一行)。用内容哈希而不是向量近重复,是因为回收
|
|
34
|
+
* 动作本身会清掉归档行向量(clearArchivedEmbeddings)——指标不能建在它自己
|
|
35
|
+
* 的输入会被回收掉的数据上;精确重复与 #254 的计量口径同一把尺。
|
|
23
36
|
*
|
|
24
37
|
* @param {object} store - createStore 产物(只调用 listRecallRunsSince / all)
|
|
25
38
|
* @param {{ windowDays?: number, exemptDays?: number }} [options]
|
|
@@ -36,7 +49,8 @@ export function recallStats(store, options = {}) {
|
|
|
36
49
|
const rawSlots = Number(options.maxInjectSlots ?? 5);
|
|
37
50
|
const maxInjectSlots = Number.isInteger(rawSlots) && rawSlots >= 1 ? rawSlots : 5;
|
|
38
51
|
const now = Date.now();
|
|
39
|
-
const
|
|
52
|
+
const sinceMs = now - windowDays * 86400000;
|
|
53
|
+
const since = new Date(sinceMs).toISOString();
|
|
40
54
|
|
|
41
55
|
const { rows: runs, total } = store.listRecallRunsSince(since);
|
|
42
56
|
const hits = new Map(); // id -> { count, title, source }
|
|
@@ -67,8 +81,29 @@ export function recallStats(store, options = {}) {
|
|
|
67
81
|
let activeCount = 0;
|
|
68
82
|
let zombieCount = 0;
|
|
69
83
|
let exemptCount = 0;
|
|
84
|
+
// 第五指标(#275):归档区一侧单独累计,分组键与 #254 的去重候选集同构
|
|
85
|
+
// (type + 哈希 + scope 三维)——可压掉的必须是「本来就会被判成同一件事」的行。
|
|
86
|
+
let archivedTotal = 0;
|
|
87
|
+
let archivedInWindow = 0;
|
|
88
|
+
const hashGroups = new Map();
|
|
70
89
|
for (const m of memories) {
|
|
71
|
-
if (m.archived
|
|
90
|
+
if (m.archived) {
|
|
91
|
+
archivedTotal += 1;
|
|
92
|
+
const archivedMs = Date.parse(m.updated_at ?? "");
|
|
93
|
+
if (Number.isFinite(archivedMs) && archivedMs >= sinceMs) archivedInWindow += 1;
|
|
94
|
+
if (m.content_hash) {
|
|
95
|
+
const key = [
|
|
96
|
+
m.type ?? "",
|
|
97
|
+
m.content_hash,
|
|
98
|
+
scopeKeyOf(m.agent_scope) ?? "",
|
|
99
|
+
scopeKeyOf(m.workspace_scope) ?? "",
|
|
100
|
+
scopeKeyOf(m.sensitivity) ?? ""
|
|
101
|
+
].join("\u0000");
|
|
102
|
+
hashGroups.set(key, (hashGroups.get(key) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (m.forgotten) continue;
|
|
72
107
|
const createdMs = Date.parse(m.created_at ?? "");
|
|
73
108
|
// created_at 解析不了时无法证明已过机会期 → 归入豁免,宁漏勿误伤
|
|
74
109
|
if (!Number.isFinite(createdMs) || now - createdMs < EXEMPT_MS) {
|
|
@@ -98,6 +133,14 @@ export function recallStats(store, options = {}) {
|
|
|
98
133
|
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
|
|
99
134
|
.slice(0, 10);
|
|
100
135
|
|
|
136
|
+
let compressibleGroups = 0;
|
|
137
|
+
let compressibleRows = 0;
|
|
138
|
+
for (const n of hashGroups.values()) {
|
|
139
|
+
if (n < 2) continue;
|
|
140
|
+
compressibleGroups += 1;
|
|
141
|
+
compressibleRows += n - 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
101
144
|
return {
|
|
102
145
|
windowDays,
|
|
103
146
|
generatedAt: new Date(now).toISOString(),
|
|
@@ -113,6 +156,13 @@ export function recallStats(store, options = {}) {
|
|
|
113
156
|
slotFillRate: injectRuns > 0 ? injectedCount / (injectRuns * maxInjectSlots) : null
|
|
114
157
|
},
|
|
115
158
|
topRecalled,
|
|
159
|
+
// #275 第五指标:归档净增速率 + 可压掉行数(口径见文件头)
|
|
160
|
+
archive: {
|
|
161
|
+
total: archivedTotal,
|
|
162
|
+
addedInWindow: archivedInWindow,
|
|
163
|
+
perDay: Math.round((archivedInWindow / windowDays) * 100) / 100,
|
|
164
|
+
compressible: { rows: compressibleRows, groups: compressibleGroups }
|
|
165
|
+
},
|
|
116
166
|
zombie: {
|
|
117
167
|
activeCount,
|
|
118
168
|
zombieCount,
|
package/src/scope.js
CHANGED
|
@@ -34,12 +34,7 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
return function resolveWriteScope(exec) {
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
session = exec?.agent?.session ?? null;
|
|
40
|
-
} catch {
|
|
41
|
-
session = null;
|
|
42
|
-
}
|
|
37
|
+
const session = sessionOf(exec);
|
|
43
38
|
|
|
44
39
|
// 持久化 header(issue #17 的身份契约):session.header 是创建时冻结的
|
|
45
40
|
// SessionHeader(含 agentPreset/cwd)。⚠️ 不能先读 requestHeader()——那返回
|
|
@@ -83,6 +78,39 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
|
|
|
83
78
|
};
|
|
84
79
|
}
|
|
85
80
|
|
|
81
|
+
/**
|
|
82
|
+
* 工具 exec 上的会话对象。宿主 ctx 是 Proxy(cordis 4),裸读未 inject 的属性会
|
|
83
|
+
* 抛错——所以取值恒包在 try 里,取不到返回 null。scope 解析与写入准入的会话键
|
|
84
|
+
* 共用这一处取值口径。
|
|
85
|
+
* @param {object} exec
|
|
86
|
+
* @returns {object|null}
|
|
87
|
+
*/
|
|
88
|
+
export function sessionOf(exec) {
|
|
89
|
+
try {
|
|
90
|
+
return exec?.agent?.session ?? null;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 写入准入的会话键(#254 G1 会话写入预算的计数单位)。session_id 在这里只作
|
|
98
|
+
* 「同一段对话」的标识,不作为作用域键(见文件头:记忆挂在持久化身份标签上);
|
|
99
|
+
* 取不到会话身份(系统写入)返回 null = 不进预算。
|
|
100
|
+
* @param {object} exec
|
|
101
|
+
* @returns {string|null}
|
|
102
|
+
*/
|
|
103
|
+
export function sessionKeyOf(exec) {
|
|
104
|
+
// 整个取值(含 .id)都在 try 里:宿主 session 可能是会抛错的 accessor,而这个
|
|
105
|
+
// 函数在 memory_save 的保存之前被调用——抛出去就是一次工具调用直接失败。
|
|
106
|
+
try {
|
|
107
|
+
const id = sessionOf(exec)?.id;
|
|
108
|
+
return id == null ? null : String(id);
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
86
114
|
/**
|
|
87
115
|
* v0.8.1 底座(issue #170):显式 scope 参数(memory_save / memory_update /
|
|
88
116
|
* 面板编辑)的归一化。约定:
|
package/src/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/src/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:图召回轴——查询命中实体名时把挂联记忆并入检索融合池
|
|
@@ -172,8 +173,10 @@ const FEATURE_FLAG_ENUMS = {
|
|
|
172
173
|
summarizeDedupeMode: ["off", "title", "vector"],
|
|
173
174
|
// Issue #126:sleep 冲突阶段的动作集(conflict 默认 = 现状;full = 六分支)。
|
|
174
175
|
sleepActionSet: ["conflict", "full"],
|
|
175
|
-
|
|
176
|
-
|
|
176
|
+
// Issue #125:dream 候选集构造方式(window 默认 = 现状;hybrid 并入向量组)。
|
|
177
|
+
dreamCandidateMode: ["window", "hybrid"],
|
|
178
|
+
// 本地嵌入池化:auto = 按模型族判定(BGE → cls),可显式覆盖为 cls / mean。
|
|
179
|
+
localEmbedPooling: ["auto", "cls", "mean"]
|
|
177
180
|
};
|
|
178
181
|
const FEATURE_FLAG_STRING_MAX = 200;
|
|
179
182
|
|