@modusensus/dsh-mneme 0.11.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 +54 -21
- package/docs/CONFIGURATION.md +45 -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 +744 -26
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3752 -819
- package/lib/config.js +171 -2
- 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/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- 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 +131 -5
- package/lib/inject.js +192 -19
- 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 +115 -4
- package/lib/peak-hours.js +57 -2
- package/lib/profile-facts.js +356 -0
- package/lib/semantic.js +347 -60
- package/lib/service.js +302 -70
- package/lib/settings.js +117 -14
- package/lib/store.js +830 -55
- package/lib/summarize.js +38 -11
- package/lib/tools.js +173 -25
- package/lib/user-settings.js +175 -23
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +264 -10
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +4 -1
- package/src/api.js +744 -26
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +171 -2
- 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/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- 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 +131 -5
- package/src/inject.js +192 -19
- 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 +115 -4
- package/src/peak-hours.js +57 -2
- package/src/profile-facts.js +356 -0
- package/src/semantic.js +347 -60
- package/src/service.js +302 -70
- package/src/settings.js +117 -14
- package/src/store.js +830 -55
- package/src/summarize.js +38 -11
- package/src/tools.js +173 -25
- package/src/user-settings.js +175 -23
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +264 -10
- 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 +377 -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 +908 -138
- package/test/config.test.js +79 -0
- 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/document.test.js +32 -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-cache-f32.test.js +276 -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/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -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 +38 -7
- package/test/profile-facts.test.js +492 -0
- 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/session-gate.test.js +1 -1
- 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/test-count-sync.test.js +14 -3
- package/test/tool-state-ignore.test.js +65 -0
- package/test/tools-dto-schema-matrix.test.js +23 -2
- package/test/tools.test.js +144 -7
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +476 -1
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +91 -1
- package/test/wiki-t1-jobs.test.js +125 -0
- package/test/wiki-tools.test.js +131 -76
|
@@ -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/document.js
CHANGED
|
@@ -276,8 +276,9 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
276
276
|
}
|
|
277
277
|
// #275 拍板 5(#230 验收口径的补丁):升格吸收的 evidence 行随之退出活跃面——
|
|
278
278
|
// 一次吸收 20 条,库里就是「21 行不是 1 行」。四条口径:①与本行同一事务(要么
|
|
279
|
-
// 都成、要么都不成);②pinned
|
|
280
|
-
//
|
|
279
|
+
// 都成、要么都不成);②pinned 永不自动归档——升格不能绕过它,而 pinned 有**两个**
|
|
280
|
+
// 判据:显式钉住的行(pinned_memories,#593 起为唯一正源)与 #249 的逐字保真池
|
|
281
|
+
// 类型(constraint / preference);③自有生命周期的类型(document / summary,见
|
|
281
282
|
// ABSORB_EXEMPT_TYPES)不吸收,吸收的是原子条;④只翻标志位、内容与审计全留
|
|
282
283
|
// (可恢复),opt-out 走 archiveEvidence。
|
|
283
284
|
// pinnedTypes 拿不到就不做这一步:宁可少做,也不能把保真池当普通行收走。
|
|
@@ -294,7 +295,12 @@ export function createDocumentRegistrar({ store, config, embedQuery, pushContent
|
|
|
294
295
|
const row = store.getById(id);
|
|
295
296
|
if (!row || row.archived || row.forgotten) continue;
|
|
296
297
|
if (isImportedRow(row)) { evidenceSkipped += 1; continue; }
|
|
297
|
-
|
|
298
|
+
// #593:显式 pin(pinned_memories)与类型保真池是**两个**判据。上一版只问
|
|
299
|
+
// pinnedTypes.has(row.type),等于把「被钉住的 pattern / decision」当普通行
|
|
300
|
+
// 收走——用户钉过的东西在文档升格时被静默归档,正是「钉着 ≠ 会注入」的
|
|
301
|
+
// 反面:这里连「钉着」都没保住。store.isPinned 缺席时退回类型口径(旧行为)。
|
|
302
|
+
const explicitlyPinned = typeof store.isPinned === "function" && store.isPinned(row.id);
|
|
303
|
+
if (explicitlyPinned || pinnedTypes.has(row.type) || ABSORB_EXEMPT_TYPES.has(row.type)) continue;
|
|
298
304
|
store.setArchived(id, true);
|
|
299
305
|
evidenceArchived += 1;
|
|
300
306
|
}
|
package/src/dream/clustering.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// Cosine similarity of two equal-length numeric vectors. Returns 0 for empty
|
|
2
2
|
// or mismatched-length inputs, and for zero-norm vectors (numerically stable).
|
|
3
|
+
// Accepts typed arrays (Float32Array) as well as plain arrays: store.js caches
|
|
4
|
+
// vectors as Float32Array and `Array.isArray` is false for typed arrays — guarding
|
|
5
|
+
// on it alone would silently score every cached vector as 0.
|
|
6
|
+
const isVec = (v) => Array.isArray(v) || (ArrayBuffer.isView(v) && !(v instanceof DataView));
|
|
7
|
+
|
|
3
8
|
export function cosineSimilarity(a, b) {
|
|
4
|
-
if (!
|
|
9
|
+
if (!isVec(a) || !isVec(b) || a.length === 0 || a.length !== b.length) return 0;
|
|
5
10
|
let dot = 0, na = 0, nb = 0;
|
|
6
11
|
for (let i = 0; i < a.length; i++) {
|
|
7
12
|
dot += a[i] * b[i];
|