@modusensus/dsh-mneme 0.12.0 → 0.13.0
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 +48 -19
- package/docs/CONFIGURATION.md +43 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +581 -29
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3537 -1205
- package/lib/config.js +139 -1
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +92 -2
- package/lib/inject.js +189 -18
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +87 -4
- package/lib/peak-hours.js +57 -2
- package/lib/semantic.js +347 -60
- package/lib/service.js +193 -66
- package/lib/settings.js +112 -14
- package/lib/store.js +347 -21
- package/lib/summarize.js +38 -11
- package/lib/tools.js +35 -4
- package/lib/user-settings.js +128 -32
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +116 -2
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/src/api-standalone.js +4 -1
- package/src/api.js +581 -29
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +139 -1
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +92 -2
- package/src/inject.js +189 -18
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +87 -4
- package/src/peak-hours.js +57 -2
- package/src/semantic.js +347 -60
- package/src/service.js +193 -66
- package/src/settings.js +112 -14
- package/src/store.js +347 -21
- package/src/summarize.js +38 -11
- package/src/tools.js +35 -4
- package/src/user-settings.js +128 -32
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +116 -2
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +375 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +779 -154
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +7 -7
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +355 -0
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +7 -0
- package/test/wiki-t1-jobs.test.js +125 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日记库:把 `<memoryDir>/diaries/` 底下的所有分桶读成「谁写了多少篇」的清单。
|
|
3
|
+
*
|
|
4
|
+
* 和 `dir.js` 的分工:那边管**一个桶在哪**(给定 projectRoot 算键与路径),
|
|
5
|
+
* 这边管**有哪些桶**——左侧面板要的是后者,它拿不到「当前会话的项目」,
|
|
6
|
+
* 因为 `main` 座位不注入 sessionId。
|
|
7
|
+
*
|
|
8
|
+
* 「谁写的」只记在正文 frontmatter 里(`persona: settings (amarre)`),库里
|
|
9
|
+
* 没有第二份。所以这里读最新一篇的 frontmatter 来认人:读不到就退回桶名,
|
|
10
|
+
* 绝不猜——猜错会把 A 的日记挂到 B 名下,而那在界面上看不出来。
|
|
11
|
+
*/
|
|
12
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { DIARY_FILE_RE, diaryRoot } from "./dir.js";
|
|
15
|
+
|
|
16
|
+
/** frontmatter 块:文件必须以 `---` 起头,第二道横线收尾。 */
|
|
17
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 桶键 → 项目根与写日记的 agent。来源是 diaries/state.json(compose 自己写的账本)。
|
|
21
|
+
*
|
|
22
|
+
* 为什么面板要这个:一个桶就是一份 `projectKey(projectRoot)` 的产物,桶名里只有
|
|
23
|
+
* 目录名和 8 位哈希,**反推不回绝对路径**。而「现在再写一篇」必须给 `project`。
|
|
24
|
+
* 读不到就返回空表——那是「这个桶是别人手工放的」或「账本丢了」,面板那时候
|
|
25
|
+
* 就不该长出一个点了必然 400 的按钮。
|
|
26
|
+
*/
|
|
27
|
+
function readBucketState(root) {
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = JSON.parse(readFileSync(join(root, "state.json"), "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 桶名合法性:**不按字符集白名单**,按「它是个单层目录名」判。
|
|
39
|
+
*
|
|
40
|
+
* 一开始写的是 `^[A-Za-z0-9._-]+$`,理由是路径成分越严越好——但现场就有一个
|
|
41
|
+
* 保留中文的桶(`QQ对话-4f061790`,用户那天让另一个会话手工写的)。白名单会把
|
|
42
|
+
* 那一本从导航栏里抹掉,等于把用户写过的日记藏起来,而界面上看不出来。
|
|
43
|
+
*
|
|
44
|
+
* 所以要挡的是**路径语义**,不是字符:分隔符、上跳、控制字符、首尾空白、
|
|
45
|
+
* 以及 `.` / `..` 这两个特殊目录名。挡完这些,桶名就只剩「单层目录名」这一种
|
|
46
|
+
* 可能,下游可以直接拼路径,不需要再比一次路径前缀(见 `bucketDir` 的注释)。
|
|
47
|
+
*/
|
|
48
|
+
export function isBucketName(name) {
|
|
49
|
+
const s = String(name ?? "");
|
|
50
|
+
if (!s || s !== s.trim()) return false;
|
|
51
|
+
if (s === "." || s === "..") return false;
|
|
52
|
+
if (/[/\\]/.test(s)) return false;
|
|
53
|
+
// eslint-disable-next-line no-control-regex
|
|
54
|
+
if (/[\u0000-\u001f\u007f]/.test(s)) return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 桶名 → 目录绝对路径。名字不合法就是 `null`。
|
|
60
|
+
*
|
|
61
|
+
* 这里**没有**第二道「解析后比对根前缀」的校验:`isBucketName` 已经挡住了
|
|
62
|
+
* 分隔符与上跳,剩下的只可能是单层名字,前缀比对永远走不到。试过写它,
|
|
63
|
+
* 变异测试(把比对删掉)一跑八条用例全绿,正说明它是死代码——那就别留,
|
|
64
|
+
* 留着的防御没人能验证,反而让人以为这里有一层额外的兜底。
|
|
65
|
+
*/
|
|
66
|
+
function bucketDir(root, bucket) {
|
|
67
|
+
if (!isBucketName(bucket)) return null;
|
|
68
|
+
return join(root, bucket);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readFrontmatter(path) {
|
|
72
|
+
let text;
|
|
73
|
+
try {
|
|
74
|
+
text = readFileSync(path, "utf8");
|
|
75
|
+
} catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
const hit = FRONTMATTER_RE.exec(text);
|
|
79
|
+
if (!hit) return {};
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const line of hit[1].split(/\r?\n/)) {
|
|
82
|
+
const at = line.indexOf(":");
|
|
83
|
+
if (at <= 0) continue;
|
|
84
|
+
const key = line.slice(0, at).trim();
|
|
85
|
+
const value = line.slice(at + 1).trim();
|
|
86
|
+
if (key) out[key] = value;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 列一个桶里的日记文件。与 `export.js` 的 `listDiaries` 同一口径
|
|
93
|
+
* (只认 `YYYY-MM-DD.md`、跳过空文件、新的在前),这里不重复实现。
|
|
94
|
+
*/
|
|
95
|
+
function diaryFiles(dir) {
|
|
96
|
+
let names;
|
|
97
|
+
try {
|
|
98
|
+
names = readdirSync(dir);
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const rows = [];
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
if (!DIARY_FILE_RE.test(name)) continue;
|
|
105
|
+
const path = join(dir, name);
|
|
106
|
+
let st;
|
|
107
|
+
try {
|
|
108
|
+
st = statSync(path);
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!st.isFile() || st.size <= 0) continue;
|
|
113
|
+
rows.push({ date: name.slice(0, 10), path, bytes: st.size, mtime: st.mtimeMs });
|
|
114
|
+
}
|
|
115
|
+
return rows.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 全部桶。读不到根目录返回空清单而不是抛——「还没写过第一篇」与「路径坏了」
|
|
120
|
+
* 在面板上是两种不同的话,但这一层分不出来,交给调用方去看 `root`。
|
|
121
|
+
*/
|
|
122
|
+
export function listDiaryLibrary({ memoryDir, configured = "" } = {}) {
|
|
123
|
+
const root = diaryRoot({ memoryDir, configured });
|
|
124
|
+
let names;
|
|
125
|
+
try {
|
|
126
|
+
names = readdirSync(root);
|
|
127
|
+
} catch {
|
|
128
|
+
return { root, buckets: [] };
|
|
129
|
+
}
|
|
130
|
+
const buckets = [];
|
|
131
|
+
const state = readBucketState(root);
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
// `.deleted` 一类是回收站,不是日记。
|
|
134
|
+
if (name.startsWith(".")) continue;
|
|
135
|
+
if (!isBucketName(name)) continue;
|
|
136
|
+
const dir = join(root, name);
|
|
137
|
+
let st;
|
|
138
|
+
try {
|
|
139
|
+
st = statSync(dir);
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!st.isDirectory()) continue;
|
|
144
|
+
const files = diaryFiles(dir);
|
|
145
|
+
// 空桶(文件被删光、只剩 runs.jsonl)不列:导航栏里挂一个点开是空的项
|
|
146
|
+
// 比不列更糟。
|
|
147
|
+
if (!files.length) continue;
|
|
148
|
+
let persona = "";
|
|
149
|
+
let project = "";
|
|
150
|
+
for (const file of files) {
|
|
151
|
+
const fm = readFrontmatter(file.path);
|
|
152
|
+
if (!project && fm.project) project = fm.project;
|
|
153
|
+
// persona 只认第一篇带它的:同一桶里可能先后住过两个人格(#613 要拆的那种),
|
|
154
|
+
// 面板先按最新那篇记的人显示,不混。
|
|
155
|
+
if (fm.persona) {
|
|
156
|
+
persona = fm.persona;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const meta = state[name] && typeof state[name] === "object" ? state[name] : {};
|
|
161
|
+
buckets.push({
|
|
162
|
+
key: name,
|
|
163
|
+
project,
|
|
164
|
+
// 「现在再写一篇」需要的两样:绝对路径与写作者身份。账本里没有就留空,
|
|
165
|
+
// 面板据此把按钮禁掉——不猜路径,猜错会写在别的项目里。
|
|
166
|
+
projectRoot: typeof meta.projectRoot === "string" ? meta.projectRoot : "",
|
|
167
|
+
agentId: typeof meta.agentId === "string" ? meta.agentId : "",
|
|
168
|
+
persona,
|
|
169
|
+
latest: files[0].date,
|
|
170
|
+
count: files.length,
|
|
171
|
+
entries: files.map((file) => ({ date: file.date, bytes: file.bytes, mtime: file.mtime }))
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
buckets.sort((a, b) => (a.latest < b.latest ? 1 : a.latest > b.latest ? -1 : 0));
|
|
175
|
+
return { root, buckets };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 读一篇的正文。桶名与日期都要过校验:这条路径直接拼文件系统。 */
|
|
179
|
+
export function readLibraryEntry({ memoryDir, configured = "", bucket, date } = {}) {
|
|
180
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date ?? ""))) return null;
|
|
181
|
+
const root = diaryRoot({ memoryDir, configured });
|
|
182
|
+
const dir = bucketDir(root, bucket);
|
|
183
|
+
if (dir === null) return null;
|
|
184
|
+
try {
|
|
185
|
+
return readFileSync(join(dir, `${date}.md`), "utf8");
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 素材来源之一:QQ 聊天收件箱(只读)。
|
|
3
|
+
*
|
|
4
|
+
* 这个模块**不 import `dsh-onebot-qq` 的任何东西**——那个包是 npm 装进来的普通目录,
|
|
5
|
+
* 每次升级都会被覆盖,边界靠这个 provider 的入参/返回值切开,而不是靠 require 路径。
|
|
6
|
+
*
|
|
7
|
+
* 素材口径(实测 `D:\\DSH\\.dsh\\media\\onebot\\qq-inbox.jsonl` 的 decision 分布定下来的):
|
|
8
|
+
* 只取 `decision.stage === "dispatch" && decision.ok === true` 且消息里真有 text 段的行。
|
|
9
|
+
* 同一份样本里 70 行中,白名单挡掉的群聊 20 行、投递成功但无有效文本 5 行——不滤掉,
|
|
10
|
+
* 日记就会变成「今天什么也没干」的噪音,还占掉 document 注入预算。
|
|
11
|
+
*
|
|
12
|
+
* 已知缺口(照实写在这里,别当它不存在):inbox 只有**入站**消息,机器人自己的回复
|
|
13
|
+
* 不在其中(样本里 message_sent 命中 0)。所以日记能写「今天她说了什么」,
|
|
14
|
+
* 写不了「我怎么回的」——需要时得另找来源,不能靠这一份编。
|
|
15
|
+
*/
|
|
16
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { localDateString } from "./rollover.js";
|
|
19
|
+
import { memoryBase } from "./dir.js";
|
|
20
|
+
|
|
21
|
+
/** `qq-inbox.jsonl` = 当前代(0),`qq-inbox.N.jsonl` = 越旧 N 越大。 */
|
|
22
|
+
export const INBOX_FILE_RE = /^qq-inbox(?:\.(\d+))?\.jsonl$/;
|
|
23
|
+
|
|
24
|
+
function generationOf(name) {
|
|
25
|
+
const m = INBOX_FILE_RE.exec(name);
|
|
26
|
+
return m && m[1] ? Number(m[1]) : 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 收件箱文件,**新的在前**。
|
|
31
|
+
*
|
|
32
|
+
* 目录读不到返回 null(= 没有这个来源),与「目录在但没有消息」严格区分:
|
|
33
|
+
* 前者是 `no-inbox-dir`,后者是 `no-material`,两者的处置完全不同。
|
|
34
|
+
*/
|
|
35
|
+
export function inboxFiles(dir) {
|
|
36
|
+
if (!dir) return null;
|
|
37
|
+
let names;
|
|
38
|
+
try {
|
|
39
|
+
names = readdirSync(dir);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return names
|
|
44
|
+
.filter((n) => INBOX_FILE_RE.test(n))
|
|
45
|
+
.sort((a, b) => generationOf(a) - generationOf(b));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 只打码长度 ≥6 的数字串(与 onebot `lib/record.js:47-49` 的 redactDigits 同口径)。 */
|
|
49
|
+
export function redactDigits(text) {
|
|
50
|
+
return String(text ?? "").replace(/\d{6,}/g, (m) => m.slice(0, 3) + "****");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 一条消息里所有 text 段的拼接。图片/表情/语音段没有可读正文,直接跳过。 */
|
|
54
|
+
export function textOf(frame) {
|
|
55
|
+
const msg = frame && frame.message;
|
|
56
|
+
if (!Array.isArray(msg)) return "";
|
|
57
|
+
return msg
|
|
58
|
+
.filter((s) => s && s.type === "text" && s.data && typeof s.data.text === "string")
|
|
59
|
+
.map((s) => s.data.text)
|
|
60
|
+
.join("")
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 有效投递:`dispatch` 且 `ok === true`。其余(whitelist / command / 失败)都不算素材。 */
|
|
65
|
+
export function isEffective(frame) {
|
|
66
|
+
const d = frame && frame.decision;
|
|
67
|
+
return !!d && d.stage === "dispatch" && d.ok === true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 采一段窗口内的素材。
|
|
72
|
+
*
|
|
73
|
+
* 返回三选一:
|
|
74
|
+
* - `{items, scanned, ignored}` 拿到东西(items 可以为空 = 当天确实没聊)
|
|
75
|
+
* - `{error: "no-inbox-dir"}` 没有这个来源(目录不在)
|
|
76
|
+
* - `{error: "inbox-unreadable", detail}` 目录在但一个文件都读不出来
|
|
77
|
+
*/
|
|
78
|
+
export function collectQqInbox({ dir, dateLocal, maxItems = 400 } = {}) {
|
|
79
|
+
if (!dir) return { error: "no-inbox-dir" };
|
|
80
|
+
const files = inboxFiles(dir);
|
|
81
|
+
if (files === null) return { error: "no-inbox-dir", detail: String(dir) };
|
|
82
|
+
if (files.length === 0) return { error: "inbox-unreadable", detail: String(dir) };
|
|
83
|
+
const items = [];
|
|
84
|
+
let scanned = 0;
|
|
85
|
+
let ignored = 0;
|
|
86
|
+
let readable = 0;
|
|
87
|
+
for (const name of files) {
|
|
88
|
+
let text;
|
|
89
|
+
try {
|
|
90
|
+
text = readFileSync(join(dir, name), "utf8");
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
readable += 1;
|
|
95
|
+
for (const line of text.split("\n")) {
|
|
96
|
+
if (!line.trim()) continue;
|
|
97
|
+
scanned += 1;
|
|
98
|
+
let row;
|
|
99
|
+
try {
|
|
100
|
+
row = JSON.parse(line);
|
|
101
|
+
} catch {
|
|
102
|
+
// 坏行跳过但计数(与 loadObservations 同一条纪律)。
|
|
103
|
+
ignored += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (!row || row.kind !== "message" || !isEffective(row.frame)) {
|
|
107
|
+
ignored += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const ts = Number(row.ts);
|
|
111
|
+
if (!Number.isFinite(ts)) {
|
|
112
|
+
ignored += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// 不在当天窗口内的行不算「忽略」,它属于别的日子。
|
|
116
|
+
if (localDateString(new Date(ts)) !== dateLocal) continue;
|
|
117
|
+
const body = textOf(row.frame);
|
|
118
|
+
if (!body) {
|
|
119
|
+
ignored += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
items.push({ ts, role: "user", text: redactDigits(body).slice(0, 2000) });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (readable === 0) return { error: "inbox-unreadable", detail: String(dir) };
|
|
126
|
+
items.sort((a, b) => a.ts - b.ts);
|
|
127
|
+
return { items: items.slice(-maxItems), scanned, ignored };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 把 `<DSH_HOME>/media/onebot` 推出来:memoryDir 默认就是 `<DSH_HOME>/memory`。
|
|
132
|
+
*
|
|
133
|
+
* 推不出来(memoryDir 被指到别处、或给了显式配置)就用配置值;两者都没有时这个来源
|
|
134
|
+
* 直接报 `no-inbox-dir`——宁可这一路没有素材,也不去猜别的盘。
|
|
135
|
+
*/
|
|
136
|
+
export function resolveInboxDir({ memoryDir, configured = "" } = {}) {
|
|
137
|
+
const custom = String(configured ?? "").trim();
|
|
138
|
+
if (custom) return custom;
|
|
139
|
+
// 必须走 memoryBase 而不是直接用配置串:默认值就是带 `~` 的字面量,
|
|
140
|
+
// 不展开的话 join 出来的路径永远落空,旁挂会静默读不到(CodeRabbit #458 F2)。
|
|
141
|
+
return join(memoryBase(memoryDir), "..", "media", "onebot");
|
|
142
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨天判定:本机没有日历调度原语(cordis.patch.yml 没有 schedules 服务,
|
|
3
|
+
* node_modules 里唯一的 scheduler 是 React 那个),所以「每天 0 点写一篇」写不出来。
|
|
4
|
+
*
|
|
5
|
+
* 可实现的等价形态是**跨天惰性判定**:在 turn/end 与插件启动两个既有挂载点上问
|
|
6
|
+
* 「上一次写的日期是不是今天」,不是才写。差别只在于「今天一句话没说」那天没有日记
|
|
7
|
+
* ——而那恰好是对的:没有素材就不该编。
|
|
8
|
+
*
|
|
9
|
+
* 全部是纯函数(时间由调用方注入),所以「跨 5 天只补 1 天」这类边界能被真的测出来。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 宿主本地时区的 `YYYY-MM-DD`。刻意不用 UTC:日记的「今天」是用户感知的今天。 */
|
|
13
|
+
export function localDateString(date) {
|
|
14
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
15
|
+
const y = d.getFullYear();
|
|
16
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
17
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
18
|
+
return `${y}-${m}-${day}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toUtcMs(dateLocal) {
|
|
22
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(dateLocal ?? ""));
|
|
23
|
+
if (!m) return null;
|
|
24
|
+
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `to - from` 的整数天数;任一参数形状不对返回 null(调用方据此走「当作首次」)。 */
|
|
28
|
+
export function dayGap(fromDate, toDate) {
|
|
29
|
+
const a = toUtcMs(fromDate);
|
|
30
|
+
const b = toUtcMs(toDate);
|
|
31
|
+
if (a === null || b === null) return null;
|
|
32
|
+
return Math.round((b - a) / 86400000);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 一次最多回补几天。默认 1:睡得久一点就补一篇,不该醒来一次补十篇把模型配额烧光。 */
|
|
36
|
+
export const MAX_CATCH_UP_DAYS = 1;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 今天该不该写。
|
|
40
|
+
*
|
|
41
|
+
* 返回 `{roll, gapDays, gap}`:
|
|
42
|
+
* - `roll` 要不要写。`lastDate` 为空(从没写过)→ 写。
|
|
43
|
+
* - `gapDays` 距上次写的天数(null = 没写过或日期形状不对)。
|
|
44
|
+
* - `gap` 中间有没有「永久丢掉」的日子(超过回补上限)——必须如实写进日记头部,
|
|
45
|
+
* 不能假装连续。这是本模块唯一允许对读者撒谎的地方,所以它必须是个返回值,
|
|
46
|
+
* 不是一个可选参数。
|
|
47
|
+
*
|
|
48
|
+
* 时钟往回调(`lastDate` 比今天还新)时不写:那不是「跨天」,那是同一份时间被
|
|
49
|
+
* 重算了一次,再写一次只会覆盖掉今天已经写好的那篇。
|
|
50
|
+
*/
|
|
51
|
+
export function shouldRollDay(lastDate, today, { maxCatchUpDays = MAX_CATCH_UP_DAYS } = {}) {
|
|
52
|
+
const now = String(today ?? "");
|
|
53
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(now)) return { roll: false, gapDays: null, gap: false };
|
|
54
|
+
const last = String(lastDate ?? "");
|
|
55
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(last)) return { roll: true, gapDays: null, gap: false };
|
|
56
|
+
if (last === now) return { roll: false, gapDays: 0, gap: false };
|
|
57
|
+
const gapDays = dayGap(last, now);
|
|
58
|
+
if (gapDays === null || gapDays < 0) return { roll: false, gapDays, gap: false };
|
|
59
|
+
const limit = Number.isFinite(maxCatchUpDays) && maxCatchUpDays >= 0 ? maxCatchUpDays : MAX_CATCH_UP_DAYS;
|
|
60
|
+
return { roll: true, gapDays, gap: gapDays > limit };
|
|
61
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 素材来源之二:当前会话自己的转录(**主来源**)。
|
|
3
|
+
*
|
|
4
|
+
* 需求原话是「与人格注入绑定,当前的拥有人格的 agent 写日记」——那就意味着日记的
|
|
5
|
+
* 素材首先得是**这个 agent 自己这一天经历过的对话**,而不是 QQ 收件箱里别人的消息。
|
|
6
|
+
* QQ inbox 只是没有会话上下文时(启动补写)的兜底来源。
|
|
7
|
+
*
|
|
8
|
+
* 口径与 summarize.js 的蒸馏转录**刻意同源**(那里已把隐私边界定完,不重写第二份):
|
|
9
|
+
* - 只认 `user/message` 与 `assistant/message`;
|
|
10
|
+
* - 机器注入内容(AGENTS.md、skill 正文、文件变更通知)按 `source.kind` 挡掉;
|
|
11
|
+
* - assistant 的 reasoning 私有思考块不采集(textOf 只认 `type:"text"`);
|
|
12
|
+
* - 工具调用/结果不进日记(那是过程,不是这一天发生的事)。
|
|
13
|
+
*
|
|
14
|
+
* 这个模块是纯的:会话对象由调用方给,时间由事件带,不读盘、不联网、不碰 ctx。
|
|
15
|
+
*/
|
|
16
|
+
import { localDateString } from "./rollover.js";
|
|
17
|
+
import { redactDigits } from "./qq-inbox.js";
|
|
18
|
+
|
|
19
|
+
/** 单条消息进 prompt 的上限:一条超长粘贴不该把整篇日记的预算吃掉。 */
|
|
20
|
+
export const MAX_ITEM_CHARS = 1200;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 会话身份(决定用哪张人格卡)。与 `src/inject.js:311-336` 同一口径。
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ 不能先读 `requestHeader()`:那返回的是请求级模型路由头,会把 `agentPreset`
|
|
26
|
+
* 遮蔽成空(scope.js 里记着本地 web 实测踩中过)。身份只在 `session.header` 里。
|
|
27
|
+
*/
|
|
28
|
+
export function sessionAgentId(session) {
|
|
29
|
+
try {
|
|
30
|
+
const header = session?.header ?? null;
|
|
31
|
+
const preset = header?.agentPreset;
|
|
32
|
+
return typeof preset === "string" && preset.trim() ? preset.trim() : null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 会话事件里可选的时间字段。三个都试是因为宿主各版本用过不同的名字。 */
|
|
39
|
+
function eventTimeMs(event) {
|
|
40
|
+
for (const key of ["at", "time", "ts"]) {
|
|
41
|
+
const v = event?.[key];
|
|
42
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
43
|
+
if (typeof v === "string" && v.trim()) {
|
|
44
|
+
const parsed = Date.parse(v);
|
|
45
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function textOf(content) {
|
|
52
|
+
if (typeof content === "string") return content;
|
|
53
|
+
if (!Array.isArray(content)) return "";
|
|
54
|
+
return content
|
|
55
|
+
.map((block) => (typeof block === "string" ? block : block && block.type === "text" && typeof block.text === "string" ? block.text : ""))
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 事件里有没有**可公开的会话正文**。与 summarize.js 的 publicTranscriptParts 同一套判据。 */
|
|
61
|
+
function bodyOf(event) {
|
|
62
|
+
const data = event?.data ?? {};
|
|
63
|
+
const kind = data?.source?.kind;
|
|
64
|
+
if (event?.type === "user/message") {
|
|
65
|
+
// 机器注入内容(kind 非 user)不进素材:那不是「你们今天聊的」。
|
|
66
|
+
if (kind !== undefined && kind !== "user") return null;
|
|
67
|
+
const text = textOf(data?.content).trim();
|
|
68
|
+
return text ? { role: "user", text } : null;
|
|
69
|
+
}
|
|
70
|
+
if (event?.type === "assistant/message") {
|
|
71
|
+
const text = textOf(data?.message?.content).trim();
|
|
72
|
+
return text ? { role: "assistant", text } : null;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 采当前会话的转录。
|
|
79
|
+
*
|
|
80
|
+
* `dateLocal` 为 null 时取**全部**事件(手动按钮的语义:「就把现在这段写下来」,
|
|
81
|
+
* 不按自然日切)。给日期时只留那一天的事件——事件没有时间戳的一律丢弃:无法证明
|
|
82
|
+
* 它属于今天,就不能把它写进今天。
|
|
83
|
+
*
|
|
84
|
+
* 返回 `{items, scanned, ignored}`(items 可以为空)或 `{error}`。
|
|
85
|
+
*/
|
|
86
|
+
export function collectSessionTranscript({ session, dateLocal = null, maxItems = 400, maxChars = MAX_ITEM_CHARS } = {}) {
|
|
87
|
+
if (!session) return { error: "no-session" };
|
|
88
|
+
let events = null;
|
|
89
|
+
try {
|
|
90
|
+
events = typeof session.snapshotEvents === "function" ? session.snapshotEvents() : session.events;
|
|
91
|
+
} catch {
|
|
92
|
+
events = session?.events ?? null;
|
|
93
|
+
}
|
|
94
|
+
if (!Array.isArray(events)) return { error: "session-unreadable" };
|
|
95
|
+
|
|
96
|
+
const items = [];
|
|
97
|
+
let scanned = 0;
|
|
98
|
+
let ignored = 0;
|
|
99
|
+
for (const event of events) {
|
|
100
|
+
const body = bodyOf(event);
|
|
101
|
+
if (!body) {
|
|
102
|
+
// 工具调用/系统事件占了绝对多数,数进 ignored 会让「忽略」这个词失真。
|
|
103
|
+
// 只把它们跳过,不计入 ignored。
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
scanned += 1;
|
|
107
|
+
const ts = eventTimeMs(event);
|
|
108
|
+
if (dateLocal !== null && dateLocal !== undefined) {
|
|
109
|
+
if (ts === null || localDateString(new Date(ts)) !== dateLocal) {
|
|
110
|
+
// 别的日子不算「忽略」,无时间戳才算读不懂。
|
|
111
|
+
if (ts === null) ignored += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
items.push({
|
|
116
|
+
ts,
|
|
117
|
+
role: body.role,
|
|
118
|
+
text: redactDigits(body.text).slice(0, maxChars)
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return { items: items.slice(-maxItems), scanned, ignored };
|
|
122
|
+
}
|
package/src/dream/decisions.js
CHANGED
|
@@ -119,6 +119,16 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
119
119
|
// 不够的——ACTIONS 是两个模块共用的,模型自发出 supersede / differentiate 时校验器
|
|
120
120
|
// 照样放行,"opt-in 零行为变化"就成了空话。省略 / null = 不限制(沿用全局 ACTIONS)。
|
|
121
121
|
const allowedActions = Array.isArray(options.allowedActions) ? new Set(options.allowedActions) : null;
|
|
122
|
+
// reflectionUpdateEnabled=false:update 在本次调用里不算合法动作。走与
|
|
123
|
+
// allowedActions 同一条单条跳过的通路(不是整单拒绝)——用户关掉的是「改写已有
|
|
124
|
+
// 记忆」这一种动作,模型多输出了几条 update 不该连带丢掉同批的 merge/archive。
|
|
125
|
+
// 与 prompt 侧后缀双闸:prompt 让模型别写,这里兜住模型仍然写了的情况。
|
|
126
|
+
const allowUpdate = options.allowUpdate !== false;
|
|
127
|
+
// 被用户开关关掉的动作单独计数:它们进 skipped(要留痕),但不该触发下面的
|
|
128
|
+
// 「全部跳过 = 模型坏了」整单拒绝——那是防幻觉 id / 截断输出的护栏,而这里是
|
|
129
|
+
// 用户自己关掉的动作。混在一起会让「只输出了 update」的一轮被判 failed、调度器
|
|
130
|
+
// 拿同一批输入重跑同一条必被拒的输出,无限循环。
|
|
131
|
+
let policySkipped = 0;
|
|
122
132
|
if (!Array.isArray(decisions)) {
|
|
123
133
|
return { ok: false, errors: ["decision list must be an array"], resolvedShortIds: 0 };
|
|
124
134
|
}
|
|
@@ -168,6 +178,7 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
168
178
|
// 单条校验错误先进 local:skipInvalid 模式下整条跳过,严格模式下才并入
|
|
169
179
|
// 全局 errors(沿用 v0.6.9 的双轨结构)。
|
|
170
180
|
const local = [];
|
|
181
|
+
let policyDisabled = false;
|
|
171
182
|
// Issue #126:supersede 与 conflict 一样用 winner/loser 定位双方(沿用同一套
|
|
172
183
|
// 字段,校验与 receipt 逻辑因此可以直接复用)。
|
|
173
184
|
const ids = d && (d.action === "conflict" || d.action === "supersede") ? [d.winner, d.loser] : (d?.ids ?? []);
|
|
@@ -176,6 +187,12 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
176
187
|
} else if (allowedActions && !allowedActions.has(d.action)) {
|
|
177
188
|
// 动作本身合法,但不在当前档位允许的集合里(例如默认档收到 supersede)。
|
|
178
189
|
local.push(`${at}: action ${JSON.stringify(d.action)} not allowed in this mode`);
|
|
190
|
+
} else if (d.action === "update" && !allowUpdate) {
|
|
191
|
+
// reflectionUpdateEnabled=false:旧记忆永不被自动改写。这条要排在
|
|
192
|
+
// allowedActions 之后、update 字段校验之前——先判「这个动作本次不许用」,
|
|
193
|
+
// 再谈字段合不合规,否则同一条会同时报两个错、日志里读不出真实原因。
|
|
194
|
+
local.push(`${at}: update action is disabled (reflectionUpdateEnabled=false)`);
|
|
195
|
+
policyDisabled = true;
|
|
179
196
|
} else if (d.action === "conflict" || d.action === "supersede") {
|
|
180
197
|
if (!d.winner || !d.loser || d.winner === d.loser) {
|
|
181
198
|
local.push(`${at}: ${d.action} needs distinct winner and loser`);
|
|
@@ -328,11 +345,15 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
328
345
|
// title/content)、还是字段给了但值不合规(has_* 为 true 仍报错)。
|
|
329
346
|
// 只加布尔不加原文:原文进 llm_audit_logs.metadata 与 dream_runs.raw_output,
|
|
330
347
|
// 这里保持每条记录足够小(skipped 会按轮累积进一行 JSON)。
|
|
348
|
+
if (policyDisabled) policySkipped++;
|
|
331
349
|
skipped.push({
|
|
332
350
|
index,
|
|
333
351
|
action: d?.action,
|
|
334
352
|
ids,
|
|
335
353
|
error: local.join("; "),
|
|
354
|
+
// true = 用户开关关掉了这个动作(不是模型输出有问题)。调用方据此区分
|
|
355
|
+
// 「这轮白跑」与「这轮输出坏了」。
|
|
356
|
+
...(policyDisabled ? { policyDisabled: true } : {}),
|
|
336
357
|
has_keepSource: d?.keepSource !== undefined && d?.keepSource !== null,
|
|
337
358
|
has_title: typeof d?.title === "string" && d.title.trim().length > 0,
|
|
338
359
|
has_content: typeof d?.content === "string" && d.content.trim().length > 0
|
|
@@ -391,7 +412,9 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
391
412
|
// 子集为空时仍整单拒绝——宽容的前提是「有合法子集可应用」;模型输出整体
|
|
392
413
|
// 非法(如全部幻觉 id)是真故障,不能被隐式 keep 洗成 ok。#104 只豁免
|
|
393
414
|
// 「有合法子集但覆盖率不足」的良性挑重点(见下方 coverageShortfall)。
|
|
394
|
-
|
|
415
|
+
// 只把「非策略性跳过」算进这条:policySkipped 是用户关掉的动作,不是模型故障。
|
|
416
|
+
const modelSkipped = skipped.length - policySkipped;
|
|
417
|
+
if (options.skipInvalid === true && modelSkipped > 0 && survivors.length === 0 && errors.length === 0) {
|
|
395
418
|
errors.push(`all ${skipped.length} decision(s) invalid — nothing valid survived validation`);
|
|
396
419
|
return { ok: false, errors, skipped, resolvedShortIds };
|
|
397
420
|
}
|
|
@@ -514,6 +537,7 @@ function casGuard(service, snapshot, ids) {
|
|
|
514
537
|
*/
|
|
515
538
|
export function applyDecisions(decisions, service, logger = null, snapshot = null, config = {}) {
|
|
516
539
|
let applied = 0;
|
|
540
|
+
let merged = 0;
|
|
517
541
|
const conflicts = [];
|
|
518
542
|
const failures = [];
|
|
519
543
|
const committed = [];
|
|
@@ -528,6 +552,12 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
|
|
|
528
552
|
const outcome = applyOne(d, service, snapshot, config, logger);
|
|
529
553
|
if (outcome === "skipped") continue;
|
|
530
554
|
applied += outcome.applied;
|
|
555
|
+
// `merged` counts creates that hit a same-title row instead of minting a
|
|
556
|
+
// new one. Kept apart from `applied` on purpose: a create that vanished
|
|
557
|
+
// into an existing row is not a new conclusion, and folding it in is how
|
|
558
|
+
// two different dispositions ended up looking like two minted patterns
|
|
559
|
+
// (#619 audit round 5).
|
|
560
|
+
if (outcome.merged) merged += outcome.merged;
|
|
531
561
|
committed.push(outcome.committed);
|
|
532
562
|
} catch (error) {
|
|
533
563
|
if (error instanceof CasConflictError) {
|
|
@@ -539,7 +569,7 @@ export function applyDecisions(decisions, service, logger = null, snapshot = nul
|
|
|
539
569
|
}
|
|
540
570
|
}
|
|
541
571
|
}
|
|
542
|
-
return { applied, conflicts, failures, committed };
|
|
572
|
+
return { applied, merged, conflicts, failures, committed };
|
|
543
573
|
}
|
|
544
574
|
|
|
545
575
|
function applyOne(d, service, snapshot, config = {}, logger = null) {
|
|
@@ -597,20 +627,41 @@ function applyCreate(d, service, config = {}, logger = null) {
|
|
|
597
627
|
// 信它就等于让模型给自造条目盖「人写的」戳(见 applyDecisions 的 @param config)。
|
|
598
628
|
// 取不到就一个字段都不传,落回 saveWithDedupe 原有的兜底,行为与改前一致。
|
|
599
629
|
const mintSource = typeof config?.mintSource === "string" ? config.mintSource.trim() : "";
|
|
630
|
+
// pattern 是「整句重写」而不是「增量补充」:同一 (人, 对象, 情绪) 的标题天然
|
|
631
|
+
// 相同,saveWithDedupe 命中后若走 appendContent,重放一轮就把同一段结论再粘
|
|
632
|
+
// 一遍(实测 161 → 355 → 549 字,外加一串重复的 [证据: …] 尾巴),而 pattern
|
|
633
|
+
// 正是注入面消费的东西。`_overwrite` 是既有逃生口(bootstrap / dream summary /
|
|
634
|
+
// narrative 三处先例):命中同题行时原地刷新而不是追加,旧文进 content_history。
|
|
635
|
+
// 只对 pattern 放开——其他类型被 create 撞上同题行时仍按合并语义走,那多半是
|
|
636
|
+
// 模型自造了不该出现的类型,把既有行整段覆盖掉比追加更危险。
|
|
637
|
+
const overwrite = type === "pattern";
|
|
600
638
|
const created = service.saveWithDedupe({
|
|
601
639
|
type,
|
|
602
640
|
title,
|
|
603
641
|
content: body,
|
|
604
642
|
importance,
|
|
605
|
-
...(mintSource ? { source: mintSource } : {})
|
|
643
|
+
...(mintSource ? { source: mintSource } : {}),
|
|
644
|
+
...(overwrite ? { _overwrite: true } : {})
|
|
606
645
|
});
|
|
607
646
|
const memory = created?.memory;
|
|
608
647
|
if (!memory) return "skipped"; // deduped/subsumed: nothing minted, clean no-op
|
|
648
|
+
// 命中同题活跃行 ⇒ saveWithDedupe 返回 action:"merged"。它确实动过库(刷新了
|
|
649
|
+
// 那一行),但**没有铸出新条目**:把它算进 applied 会让「一条真实结论被同题行
|
|
650
|
+
// 吃掉了」这种情况看起来像成功(#619 审计第五轮:两个不同对象铸出同一条
|
|
651
|
+
// pattern,applied 照样报 2)。两类结局分开报:applied 只数新铸,merged 数刷新。
|
|
652
|
+
const refreshed = created?.action !== "created";
|
|
609
653
|
// #512:这条记忆出生自哪一轮。只记 create——merge/conflict/update/supersede 对
|
|
610
654
|
// 既有行的改动已由 receipt_chain 按 (run_id, record_id) 记账,再造一份就是第二套账。
|
|
655
|
+
//
|
|
656
|
+
// #619 / Ephor 第七轮 ③:`refreshed` 也在这条规矩里。原地刷新没有铸新条目,
|
|
657
|
+
// 它改的是既有行——同一条记忆不该有两份「出生记录」。此前 create_merged 这条
|
|
658
|
+
// 新路没被上面那句规矩覆盖,于是一个 pattern 跑两轮,`memory_mints` 就有两行、
|
|
659
|
+
// run_id 各不相同,面板上显示成「这条记忆出生过两次」(`mintsFor` 由
|
|
660
|
+
// provenance-http.js 读取)。live 库当时还没显形,是因为同题刷新刚上线。
|
|
661
|
+
//
|
|
611
662
|
// 记账失败不许拖垮这次铸造:归因是审计,记忆已经落盘了。
|
|
612
663
|
try {
|
|
613
|
-
if (typeof service?.saveMemoryMint === "function" && config?.runId) {
|
|
664
|
+
if (!refreshed && typeof service?.saveMemoryMint === "function" && config?.runId) {
|
|
614
665
|
service.saveMemoryMint({
|
|
615
666
|
memoryId: memory.id,
|
|
616
667
|
runId: config.runId,
|
|
@@ -622,7 +673,7 @@ function applyCreate(d, service, config = {}, logger = null) {
|
|
|
622
673
|
} catch (mintError) {
|
|
623
674
|
logger?.warn?.(`dsh-mneme dream: failed to record memory mint: ${String(mintError?.message ?? mintError)}`);
|
|
624
675
|
}
|
|
625
|
-
return { applied: 1, committed: { action: "create", id: memory.id, type } };
|
|
676
|
+
return { applied: refreshed ? 0 : 1, merged: refreshed ? 1 : 0, committed: { action: refreshed ? "create_merged" : "create", id: memory.id, type } };
|
|
626
677
|
}
|
|
627
678
|
|
|
628
679
|
function applyArchive(d, service, snapshot) {
|