feiyu-memory 0.0.0-stage → 0.3.6
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/CHANGELOG.md +559 -0
- package/LICENSE +36 -0
- package/README.md +536 -3
- package/SPEC.md +1380 -0
- package/cordis.patch.yml +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
- package/src/client/index.js +484 -0
- package/src/core/archive.mjs +572 -0
- package/src/core/config.mjs +313 -0
- package/src/core/consolidate.mjs +759 -0
- package/src/core/gate.mjs +42 -0
- package/src/core/handnote.mjs +117 -0
- package/src/core/judge.mjs +116 -0
- package/src/core/keywords.mjs +121 -0
- package/src/core/persona.default.md +34 -0
- package/src/core/persona.mjs +594 -0
- package/src/core/privacy.mjs +252 -0
- package/src/core/recall.mjs +280 -0
- package/src/core/render-diary.mjs +171 -0
- package/src/core/timeline.mjs +127 -0
- package/src/core/tokenize.mjs +70 -0
- package/src/core/zip.mjs +239 -0
- package/src/host/index.mjs +2039 -0
|
@@ -0,0 +1,2039 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 肥鱼日记 · Host 半边
|
|
3
|
+
*
|
|
4
|
+
* 让新开一个对话,用户依然感觉是同一条鱼。
|
|
5
|
+
*
|
|
6
|
+
* ── 这一版(v0.1)的范围 ──
|
|
7
|
+
* 只有 Host 半边,没有 `dsh.client`。理由见 SPEC §13 / 速查表 D.3:
|
|
8
|
+
* 客户端 dynamic runner 的 React 取用语法尚未对着活动客户端验证,
|
|
9
|
+
* 不把没验证过的东西发出去。界面在 v0.2。
|
|
10
|
+
*
|
|
11
|
+
* ── 三条不可破的工程约束 ──
|
|
12
|
+
* 1. **永远不能挡住聊天。** 所有事件回调与注入函数都包 try/catch,
|
|
13
|
+
* 出任何问题都只降级成"这次没记住",绝不向上抛。
|
|
14
|
+
* 2. **`agent/turn-stopping` 是 serial 事件,会被 await。**
|
|
15
|
+
* 所以那里只做本地判断(微秒级)并立刻返回,
|
|
16
|
+
* 模型调用一律丢到带外队列,不阻塞回合关闭。
|
|
17
|
+
* 3. **不 append 任何新的 session 事件类型**(官方 practices 明令)。
|
|
18
|
+
* 我们的状态存在自己的档案文件里。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFile, writeFile, mkdir, rename, readdir, rm } from 'node:fs/promises';
|
|
22
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
SCHEMA_VERSION,
|
|
29
|
+
archiveToFiles,
|
|
30
|
+
archiveFromFiles,
|
|
31
|
+
createEmptyArchive,
|
|
32
|
+
packArchive,
|
|
33
|
+
unpackArchive,
|
|
34
|
+
mergeArchives,
|
|
35
|
+
ensurePersonaStructure,
|
|
36
|
+
DIARY_DIR,
|
|
37
|
+
PERSONA_FILE,
|
|
38
|
+
normalizeDiaryEntry,
|
|
39
|
+
} from '../core/archive.mjs';
|
|
40
|
+
// 原来的 evaluateGate 那套关键词闸门拆了(见 turn-stopping 里的注释)。
|
|
41
|
+
// SUPPRESS_RE 留着 —— "别记"是用户的控制,不是启发式。
|
|
42
|
+
import { SUPPRESS_RE } from '../core/gate.mjs';
|
|
43
|
+
import { buildIndex, selectMemories, searchScores, DEFAULT_RECALL_OPTIONS } from '../core/recall.mjs';
|
|
44
|
+
import { overlap } from '../core/keywords.mjs';
|
|
45
|
+
import { scrub } from '../core/privacy.mjs';
|
|
46
|
+
import { renderMemoryBlock, DEFAULT_BUDGET, MAX_NOTES } from '../core/persona.mjs';
|
|
47
|
+
import { calendarDaysBetween, stripTimeWords, softWhen } from '../core/timeline.mjs';
|
|
48
|
+
import { renderDiaryFile } from '../core/render-diary.mjs';
|
|
49
|
+
import { buildJudgeInput, parseVerdict, JUDGE_MAX_TOKENS } from '../core/judge.mjs';
|
|
50
|
+
import { selectHandNotes, HANDNOTE_FILE, HANDNOTE_TEMPLATE } from '../core/handnote.mjs';
|
|
51
|
+
import {
|
|
52
|
+
buildConsolidationInput,
|
|
53
|
+
parseConsolidation,
|
|
54
|
+
applyConsolidation,
|
|
55
|
+
decayArchive,
|
|
56
|
+
makeDiaryId,
|
|
57
|
+
ConsolidationParseError,
|
|
58
|
+
DEFAULT_CONSOLIDATION,
|
|
59
|
+
} from '../core/consolidate.mjs';
|
|
60
|
+
import { resolveConfig, buildSchema, CONFIG_SPEC } from '../core/config.mjs';
|
|
61
|
+
|
|
62
|
+
/** Cordis 插件身份。模块上只有这四个属性会被读取。 */
|
|
63
|
+
export const name = 'feiyu-memory';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 硬依赖。缺任何一个,插件静默不激活 —— 这是刻意的:
|
|
67
|
+
* 少一个记忆插件,不能影响用户聊天。
|
|
68
|
+
*/
|
|
69
|
+
export const inject = ['systemPrompt', 'agents', 'sessions', 'llm'];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 找 schemastery。**多试几个基准**,不能只认 `import.meta.url`。
|
|
73
|
+
*
|
|
74
|
+
* 原因:pnpm 把包装进 `.pnpm/<pkg>@<ver>/node_modules/<pkg>/`,`import.meta.url`
|
|
75
|
+
* 指向那个真实路径,而它外面的 node_modules 里**没有** `@deepseek-ai/schemastery`。
|
|
76
|
+
* `link:` 安装更直接 —— 插件在 D 盘,schemastery 在 profile 里,隔了十万八千里。
|
|
77
|
+
*
|
|
78
|
+
* 实测:从 `D:\肥鱼日记` require 失败,从 `profile/` require 成功。
|
|
79
|
+
*/
|
|
80
|
+
function loadSchemastery() {
|
|
81
|
+
const bases = [];
|
|
82
|
+
// 1) 插件自己所在的位置(正常 npm 安装时这条就够了)
|
|
83
|
+
try { bases.push(import.meta.url); } catch { /* 忽略 */ }
|
|
84
|
+
// 2) DSH profile 目录 —— 桌面端的 cwd 通常就是它
|
|
85
|
+
try { bases.push(path.join(process.cwd(), 'package.json')); } catch { /* 忽略 */ }
|
|
86
|
+
// 3) **DSH 自己的安装目录** —— schemastery 真正的主人
|
|
87
|
+
//
|
|
88
|
+
// 端到端验出来的:临时 profile 里根本没装 schemastery,它是 DSH 的依赖。
|
|
89
|
+
// 所以要从 dsh CLI 的位置往上找。
|
|
90
|
+
try {
|
|
91
|
+
const argv1 = process.argv[1];
|
|
92
|
+
if (typeof argv1 === 'string' && argv1 !== '') {
|
|
93
|
+
let dir = path.dirname(argv1);
|
|
94
|
+
for (let i = 0; i < 8; i++) {
|
|
95
|
+
bases.push(path.join(dir, 'package.json'));
|
|
96
|
+
const next = path.dirname(dir);
|
|
97
|
+
if (next === dir) break;
|
|
98
|
+
dir = next;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch { /* 忽略 */ }
|
|
102
|
+
|
|
103
|
+
// 4) DSH_HOME 下面每个 profile
|
|
104
|
+
try {
|
|
105
|
+
const home = dshHome();
|
|
106
|
+
const profiles = path.join(home, 'profiles');
|
|
107
|
+
if (existsSync(profiles)) {
|
|
108
|
+
for (const name of readdirSync(profiles)) {
|
|
109
|
+
bases.push(path.join(profiles, name, 'package.json'));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
bases.push(path.join(home, 'package.json'));
|
|
113
|
+
} catch { /* 忽略 */ }
|
|
114
|
+
|
|
115
|
+
for (const base of bases) {
|
|
116
|
+
try {
|
|
117
|
+
const require = createRequire(base);
|
|
118
|
+
const mod = require('@deepseek-ai/schemastery');
|
|
119
|
+
const z = mod?.default ?? mod;
|
|
120
|
+
if (z !== undefined && z !== null && typeof z.object === 'function') return z;
|
|
121
|
+
} catch { /* 换下一个基准 */ }
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 没有 schemastery 时的兜底 schema。
|
|
128
|
+
*
|
|
129
|
+
* 关键只有一件事:**让 loader 知道这台插件有哪些字段**,于是它会把
|
|
130
|
+
* `cordis.patch.yml` 里的 `config:` 原样传进来,而不是传一份空模板。
|
|
131
|
+
*
|
|
132
|
+
* 形状上模拟 schemastery 的最小子集:
|
|
133
|
+
* · 可调用 —— `Config(dict)` 返回补过默认值的普通对象
|
|
134
|
+
* · `.dict` —— 字段定义(有些界面代码会读它)
|
|
135
|
+
* · `.toString()` —— 给设置表单的展示用
|
|
136
|
+
*/
|
|
137
|
+
function buildPlainSchema() {
|
|
138
|
+
const dict = {};
|
|
139
|
+
for (const f of CONFIG_SPEC) {
|
|
140
|
+
dict[f.key] = {
|
|
141
|
+
type: f.type,
|
|
142
|
+
description: typeof f.label === 'string' ? f.label : '',
|
|
143
|
+
default: f.default,
|
|
144
|
+
...(Number.isFinite(f.min) ? { min: f.min } : {}),
|
|
145
|
+
...(Number.isFinite(f.max) ? { max: f.max } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const schema = function (input) {
|
|
150
|
+
return resolveConfig(input);
|
|
151
|
+
};
|
|
152
|
+
schema.dict = dict;
|
|
153
|
+
// ⚠️ **cordis 会调 `.validate()`**,不是只调函数本身。
|
|
154
|
+
//
|
|
155
|
+
// 这条是端到端验出来的:我第一版只给了可调用 + .dict + .toString,
|
|
156
|
+
// 一装进真 profile 就报
|
|
157
|
+
// TypeError: Cannot read properties of undefined (reading 'validate')
|
|
158
|
+
// at resolveConfig (cordis/lib/index.js:958)
|
|
159
|
+
// 插件整个没激活。**单元测试碰不到这层 —— 这就是"必须真跑一遍"的理由。**
|
|
160
|
+
schema.validate = (input) => resolveConfig(input);
|
|
161
|
+
schema.meta = { description: '肥鱼日记 · 配置' };
|
|
162
|
+
schema.toString = () =>
|
|
163
|
+
CONFIG_SPEC.map((f) => `${f.key}: ${f.type} = ${JSON.stringify(f.default)}`).join('\n');
|
|
164
|
+
return schema;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Config **是可选的**。
|
|
169
|
+
*
|
|
170
|
+
* 为什么不用静态 `import z from '@deepseek-ai/schemastery'`:
|
|
171
|
+
* 那会让它在解析不到时直接抛错,**整个插件加载失败**。
|
|
172
|
+
* 对于一个"装上就该忘掉它"的插件,这个失败模式不能接受。
|
|
173
|
+
*
|
|
174
|
+
* 所以这里用 createRequire 试探性地拿;拿不到就导出 `undefined`,
|
|
175
|
+
* loader 会跳过校验。**所有选项照样能在 `cordis.patch.yml` 的 `config:` 里写**,
|
|
176
|
+
* 由 `resolveConfig()` 自己补默认值 —— 行为与有没有 schema 完全一致。
|
|
177
|
+
*
|
|
178
|
+
* 代价:拿不到 schemastery 时,v0.2 的自动设置表单不会出现。
|
|
179
|
+
*/
|
|
180
|
+
export const Config = (() => {
|
|
181
|
+
const z = loadSchemastery();
|
|
182
|
+
if (z !== undefined) return buildSchema(z);
|
|
183
|
+
|
|
184
|
+
// ⚠️ **拿不到 schemastery 时不能返回 undefined。**
|
|
185
|
+
//
|
|
186
|
+
// 原来这里直接吞掉并导出 undefined,注释里写着「loader 会跳过校验」。
|
|
187
|
+
// **那条假设是错的** —— 社区报告实测:没有 Config 时 DSH 会传一份**空模板**
|
|
188
|
+
// (每个字段都是 `{}`),于是 `resolveConfig` 全部回落默认,
|
|
189
|
+
// **用户改的任何配置都不生效**。铁证是:`cordis.patch.yml` 里显式写
|
|
190
|
+
// `config: { debugLog: true }`,传进来还是 `{}` —— 那不是插件没读,是 DSH 没传。
|
|
191
|
+
//
|
|
192
|
+
// 所以这里兜一个**纯 JS 的形状等价物**。它只需要满足 loader 对 Config 的
|
|
193
|
+
// 两个用法:`Config(dict)` 做校验/补默认,以及 `Config.toString()` / `.dict`
|
|
194
|
+
// 之类的展示。没有 schemastery 的链式 API,就手写一个最小的。
|
|
195
|
+
return buildPlainSchema();
|
|
196
|
+
})();
|
|
197
|
+
|
|
198
|
+
/* ───────────────────────── 小工具 ───────────────────────── */
|
|
199
|
+
|
|
200
|
+
/** DSH 主目录。`$DSH_HOME` 优先,否则 `~/.dsh`(与 dsh-home-paths 行为一致) */
|
|
201
|
+
function dshHome() {
|
|
202
|
+
const env = process.env.DSH_HOME;
|
|
203
|
+
if (typeof env === 'string' && env.trim() !== '') return env.trim();
|
|
204
|
+
return path.join(homedir(), '.dsh');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 档案工作目录。
|
|
209
|
+
*
|
|
210
|
+
* **只在 `apply` 时算一次**,之后全程用这个值。
|
|
211
|
+
* 不在每次读写时懒读 `$DSH_HOME` —— 那会带来一个隐蔽的 bug:
|
|
212
|
+
* 环境变量中途变了(多实例、测试并发、用户改配置),
|
|
213
|
+
* 档案就会写到另一个目录去,而且没有任何报错。
|
|
214
|
+
*/
|
|
215
|
+
export function resolveArchiveDir(config, home = dshHome()) {
|
|
216
|
+
// ⚠️ **只认绝对路径的字符串。**
|
|
217
|
+
//
|
|
218
|
+
// 社区报的(DSH Desktop 0.2.1-rc.2):某些情况下 config.dataDir 会是一个
|
|
219
|
+
// 被字符串化的对象 —— 字面量 "[object Object]"。
|
|
220
|
+
//
|
|
221
|
+
// typeof 检查 → 它是 string,放行
|
|
222
|
+
// path.resolve("[object Object]") → 以进程 CWD 为基准
|
|
223
|
+
// 桌面端的 CWD = profiles\\desktop
|
|
224
|
+
// → 档案落进 profiles\\desktop\\[object Object]\\
|
|
225
|
+
//
|
|
226
|
+
// 后果比崩了还糟:插件一切正常,人格注入在跑、feiyu_note 能用,
|
|
227
|
+
// 只是用户改 ~/.dsh/feiyu-diary/persona.md 完全无效 —— 而且全程无报错。
|
|
228
|
+
//
|
|
229
|
+
// 所以:不是绝对路径就回落到默认。相对路径本来也不该出现在配置里
|
|
230
|
+
// (基准是进程 CWD,用户不可能预测它是什么)。
|
|
231
|
+
const raw = config?.dataDir;
|
|
232
|
+
const explicit = typeof raw === 'string' && path.isAbsolute(raw.trim()) ? raw.trim() : '';
|
|
233
|
+
return explicit !== '' ? explicit : path.join(home, 'feiyu-diary');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const DIARY_FILE_RE = /\.jsonl$/;
|
|
237
|
+
|
|
238
|
+
/** 原子写:先写临时文件再 rename,避免半截文件毁掉档案 */
|
|
239
|
+
async function writeFileAtomic(file, text) {
|
|
240
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
241
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
242
|
+
await writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
|
|
243
|
+
await rename(tmp, file);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 从工作目录读出"文件名 → 文本"的映射(相对路径用正斜杠)。
|
|
248
|
+
* 读不出的文件跳过并留 warning,不抛。
|
|
249
|
+
*/
|
|
250
|
+
async function readArchiveDir(dir) {
|
|
251
|
+
const files = new Map();
|
|
252
|
+
const warnings = [];
|
|
253
|
+
|
|
254
|
+
async function readOne(rel) {
|
|
255
|
+
try {
|
|
256
|
+
files.set(rel, await readFile(path.join(dir, rel), 'utf8'));
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err?.code !== 'ENOENT') {
|
|
259
|
+
warnings.push(`${rel} 读不出来:${err instanceof Error ? err.message : String(err)}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const base of ['manifest.json', 'persona.md', 'relationship.json', 'lexicon.json']) {
|
|
265
|
+
await readOne(base);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const entries = await readdir(path.join(dir, 'diary'));
|
|
270
|
+
for (const f of entries) {
|
|
271
|
+
if (DIARY_FILE_RE.test(f)) await readOne(`${DIARY_DIR}/${f}`);
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err?.code !== 'ENOENT') warnings.push(`diary 目录读不出来:${String(err?.message ?? err)}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { files, warnings };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 把"文件名 → 文本"落盘。逐文件原子写。 */
|
|
281
|
+
async function writeArchiveDir(dir, files) {
|
|
282
|
+
for (const f of files) {
|
|
283
|
+
await writeFileAtomic(path.join(dir, f.name), f.data);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── 写完再清一遍:下不在清单里的月份文件删掉 ──
|
|
287
|
+
//
|
|
288
|
+
// archiveToFiles 只给"有条目的月份"产出文件。于是:
|
|
289
|
+
//
|
|
290
|
+
// 某月最后一条被「忘掉」→ 那个月不再有清单文件 → 旧 jsonl 没人覆盖
|
|
291
|
+
// 焚毁(清空所有) → 一个月份都不产出 → 所有旧 jsonl 全留着
|
|
292
|
+
//
|
|
293
|
+
// 结果就是"内存 0 条,磁盘明文还在,重启读回来" —— 删除形同虚设。
|
|
294
|
+
//
|
|
295
|
+
// 写 = 让磁盘和内存一致。所以这里必须主动清掉多余的那个月。
|
|
296
|
+
// 只清 DIARY_DIR 下的 .jsonl:日记.md(渲染版)、手记.md、.bak-* 一律不碰。
|
|
297
|
+
const wanted = new Set(
|
|
298
|
+
files.filter((f) => f.name.startsWith(`${DIARY_DIR}/`)).map((f) => path.basename(f.name)),
|
|
299
|
+
);
|
|
300
|
+
const diaryDir = path.join(dir, DIARY_DIR);
|
|
301
|
+
if (!existsSync(diaryDir)) return;
|
|
302
|
+
for (const name of await readdir(diaryDir)) {
|
|
303
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
304
|
+
if (wanted.has(name)) continue;
|
|
305
|
+
await rm(path.join(diaryDir, name), { force: true });
|
|
306
|
+
warn(`清掉多余的月份文件:${DIARY_DIR}/${name}(已经没有条目标了)`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/* ───────────────────────── 插件主体 ───────────────────────── */
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* @param {any} ctx Cordis context
|
|
314
|
+
* @param {any} rawConfig 行配置(可能未经校验,resolveConfig 会补默认值)
|
|
315
|
+
*/
|
|
316
|
+
export function apply(ctx, rawConfig) {
|
|
317
|
+
const config = resolveConfig(rawConfig);
|
|
318
|
+
/** 档案目录:在 apply 时一次算定,之后全程不变 */
|
|
319
|
+
const archiveDirectory = resolveArchiveDir(config);
|
|
320
|
+
// 一行说清"档案到底落在哪",**不受 debugLog 管**。
|
|
321
|
+
//
|
|
322
|
+
// 社区那个 [object Object] 的 bug:五秒钟的事,因为没有一行输出,
|
|
323
|
+
// 用户只能靠翻文件系统发现。这行值得一直在。
|
|
324
|
+
ctx.logger?.info?.(`[feiyu-memory] 档案目录:${archiveDirectory}`);
|
|
325
|
+
/** 当前档案(内存副本,写盘后保持一致) */
|
|
326
|
+
let archive = createEmptyArchive(Date.now());
|
|
327
|
+
/**
|
|
328
|
+
* **加载时磁盘上有哪些日记 id** —— 三路合并的"基线"。
|
|
329
|
+
*
|
|
330
|
+
* 只在这里赋值一次,之后**永不更新**。它是判断"谁删了什么"的唯一依据:
|
|
331
|
+
*
|
|
332
|
+
* 内存没有、磁盘没有、基线有 → 我们删的
|
|
333
|
+
* 内存没有、基线没有、磁盘有 → 别人刚加的
|
|
334
|
+
*
|
|
335
|
+
* 详见 `mergeArchives` 的表格。
|
|
336
|
+
*/
|
|
337
|
+
let loadedIds = new Set();
|
|
338
|
+
/**
|
|
339
|
+
* **加载时磁盘上的人格文本** —— 用来发现"人手动改了 persona.md"。
|
|
340
|
+
*
|
|
341
|
+
* ── 为什么需要它 ──
|
|
342
|
+
*
|
|
343
|
+
* `persona.md` 是**明说让人改的**(`persona.default.md` 里就写着
|
|
344
|
+
* "用户可以直接改这个文件")。但在这之前它其实改不动:
|
|
345
|
+
*
|
|
346
|
+
* · 插件启动后**再也不读** `persona.md`
|
|
347
|
+
* · 落盘时 `archiveToFiles()` 拿**内存里的**那份整份写回
|
|
348
|
+
* · `mergeArchives()` 里写死 `persona: base.persona`
|
|
349
|
+
*
|
|
350
|
+
* 于是人改完,插件下一次写日记就**把它悄悄覆盖回去** ——
|
|
351
|
+
* 一个承诺了可编辑、实际改不了的文件。
|
|
352
|
+
*
|
|
353
|
+
* 规矩跟日记那套一样:**磁盘变了就认磁盘**。
|
|
354
|
+
* · 磁盘文本 ≠ 基线 → 有人改过 → 采纳它,并把基线推上去
|
|
355
|
+
* · 磁盘文本 = 基线 → 没动过 → 用内存的(我们可能自己改过,比如焚毁)
|
|
356
|
+
*
|
|
357
|
+
* 赋值时机:加载完、以及每次采纳之后。
|
|
358
|
+
*/
|
|
359
|
+
let personaBaseline = null;
|
|
360
|
+
/** 倒排索引 —— 纯派生,每次加载档案后重建,不落盘 */
|
|
361
|
+
let index = buildIndex([]);
|
|
362
|
+
let ready = false;
|
|
363
|
+
/** 档案是否处于"读失败所以只读"状态(SPEC §10 降级优先) */
|
|
364
|
+
let degraded = false;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 日志出口。
|
|
368
|
+
*
|
|
369
|
+
* 三份去向,理由各不相同:
|
|
370
|
+
* - `ctx.logger`:正式渠道,桌面端界面/日志能看到
|
|
371
|
+
* - `stderr`:headless / 无人值守时 ctx.logger 可能没人接
|
|
372
|
+
* - **`feiyu.debug.log` 文件**:进程被硬退出时前两份都会丢
|
|
373
|
+
*
|
|
374
|
+
* 第三份是端到端验证逼出来的:headless 跑完一个任务会**直接退进程**,
|
|
375
|
+
* stderr 缓冲区来不及刷,于是"它卡在哪一环"完全看不见 ——
|
|
376
|
+
* 明明日志代码写了,就是一片空白。落文件才留得住。
|
|
377
|
+
*
|
|
378
|
+
* 只有 `debugLog: true` 才写,且**只写形状、绝不写对话内容**(SPEC §6.3)。
|
|
379
|
+
*/
|
|
380
|
+
const DEBUG_LOG_FILE = 'feiyu.debug.log';
|
|
381
|
+
const emit = (level, args) => {
|
|
382
|
+
const line = `[feiyu] ${args.join(' ')}`;
|
|
383
|
+
try {
|
|
384
|
+
ctx.logger?.[level]?.(line);
|
|
385
|
+
} catch {
|
|
386
|
+
// logger 坏了不影响功能
|
|
387
|
+
}
|
|
388
|
+
if (!config.debugLog) return;
|
|
389
|
+
try {
|
|
390
|
+
process.stderr.write(`${line}\n`);
|
|
391
|
+
} catch {
|
|
392
|
+
// 没有 stderr 就只走 logger
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
mkdirSync(archiveDirectory, { recursive: true });
|
|
396
|
+
appendFileSync(
|
|
397
|
+
path.join(archiveDirectory, DEBUG_LOG_FILE),
|
|
398
|
+
`${new Date().toISOString()} ${line}\n`,
|
|
399
|
+
'utf8',
|
|
400
|
+
);
|
|
401
|
+
} catch {
|
|
402
|
+
// 写不了就算了,日志不该影响功能
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
const log = (...args) => {
|
|
406
|
+
if (config.debugLog) emit('info', args);
|
|
407
|
+
};
|
|
408
|
+
const warn = (...args) => emit('warn', args);
|
|
409
|
+
|
|
410
|
+
/** @type {Map<string, any>} sessionId → 会话内的临时状态 */
|
|
411
|
+
const sessionStates = new Map();
|
|
412
|
+
const abort = new AbortController();
|
|
413
|
+
|
|
414
|
+
function stateOf(sessionId) {
|
|
415
|
+
let s = sessionStates.get(sessionId);
|
|
416
|
+
if (s === undefined) {
|
|
417
|
+
s = {
|
|
418
|
+
turn: 0,
|
|
419
|
+
lastUserSeq: -1,
|
|
420
|
+
lastUserText: '',
|
|
421
|
+
transcript: [], // 滚动缓冲,最近若干轮
|
|
422
|
+
renderCache: { turn: -1, text: '' },
|
|
423
|
+
lastOpenTurn: -Infinity,
|
|
424
|
+
diaryCount: 0,
|
|
425
|
+
lastShownTurn: new Map(),
|
|
426
|
+
suppressedUntil: -1,
|
|
427
|
+
queued: false,
|
|
428
|
+
};
|
|
429
|
+
sessionStates.set(sessionId, s);
|
|
430
|
+
}
|
|
431
|
+
return s;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* ── 档案加载 ── */
|
|
435
|
+
|
|
436
|
+
const loadPromise = (async () => {
|
|
437
|
+
const dir = archiveDirectory;
|
|
438
|
+
try {
|
|
439
|
+
const { files, warnings } = await readArchiveDir(dir);
|
|
440
|
+
for (const w of warnings) warn(w);
|
|
441
|
+
|
|
442
|
+
if (files.size === 0) {
|
|
443
|
+
// 首次运行:写出厂档案
|
|
444
|
+
archive = createEmptyArchive(Date.now(), readBundledDefaultPersona());
|
|
445
|
+
const migrated = decayArchive(archive, { now: Date.now() });
|
|
446
|
+
archive = migrated.archive;
|
|
447
|
+
await writeArchiveDir(dir, archiveToFiles(archive));
|
|
448
|
+
log('首次运行,已创建空档案');
|
|
449
|
+
} else {
|
|
450
|
+
const entries = [...files].map(([n, text]) => ({ name: n, text: () => text }));
|
|
451
|
+
const { archive: loaded, warnings: w2 } = archiveFromFiles(entries, { now: Date.now() });
|
|
452
|
+
for (const w of w2) warn(w);
|
|
453
|
+
archive = loaded;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 老化 + 重建索引
|
|
457
|
+
archive = decayArchive(archive, { now: Date.now() }).archive;
|
|
458
|
+
index = buildIndex(archive.diary);
|
|
459
|
+
// 基线只在这里定一次:之后落盘前的三路合并全靠它判断"谁删了什么"
|
|
460
|
+
loadedIds = new Set(archive.diary.map((e) => e.id));
|
|
461
|
+
// 人格也留一份基线,用来发现"人手动改了 persona.md"
|
|
462
|
+
personaBaseline = archive.persona;
|
|
463
|
+
ready = true;
|
|
464
|
+
log(`档案就绪:${archive.diary.length} 条日记`);
|
|
465
|
+
|
|
466
|
+
// 顺手刷新人读版。这样即使这次什么都没记,衰减后的状态也是对的;
|
|
467
|
+
// 也从别人那儿导入档案之后,立刻就有可读的一份。
|
|
468
|
+
await writeReadableDiary(archive);
|
|
469
|
+
|
|
470
|
+
// 手记文件不在就建一份带说明的空壳 —— **文件自己就是文档**。
|
|
471
|
+
// 用户(或任何一个肥鱼)打开就知道该往里写什么。
|
|
472
|
+
try {
|
|
473
|
+
const notePath = path.join(archiveDirectory, HANDNOTE_FILE);
|
|
474
|
+
if (!existsSync(notePath)) writeFileSync(notePath, HANDNOTE_TEMPLATE, 'utf8');
|
|
475
|
+
} catch (err) {
|
|
476
|
+
warn(`建手记文件失败(不影响别的):${err instanceof Error ? err.message : String(err)}`);
|
|
477
|
+
}
|
|
478
|
+
} catch (err) {
|
|
479
|
+
degraded = true;
|
|
480
|
+
warn(`档案加载失败,本次以"不记忆"模式运行:${err instanceof Error ? err.message : String(err)}`);
|
|
481
|
+
}
|
|
482
|
+
})();
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* 插件卸载(含进程退出)时,**先尽力把带外队列写完,再中止**。
|
|
486
|
+
*
|
|
487
|
+
* 这一条是端到端验证逼出来的:headless / 一次性会话里,进程会在
|
|
488
|
+
* "回合结束 → 后台写日记" 这段时间里直接退出,结果是**模型答了、日记没了**。
|
|
489
|
+
* 同一个坑在桌面端表现为"刚聊完就关掉 Harness,那条记忆丢了"。
|
|
490
|
+
*
|
|
491
|
+
* ── 第二个坑(也是端到端验证抓的)──
|
|
492
|
+
*
|
|
493
|
+
* 这个上限原来写的是 20 秒,而**单任务自己的超时是 60 秒**。
|
|
494
|
+
* 也就是说:只要固化耗时超过 20 秒,这里就会先动手把它砍掉 ——
|
|
495
|
+
* 那个 60 秒的超时永远轮不到生效。
|
|
496
|
+
*
|
|
497
|
+
* 提示词短的时候侥幸没撞上(几秒就答完了);把提示词写详细、
|
|
498
|
+
* 要求带 evidence 之后,输出变长、耗时跨过 20 秒,就必然丢日记。
|
|
499
|
+
*
|
|
500
|
+
* 所以这个值**必须大于单任务超时**,它只是"进程已经要死了"的最后一道保险,
|
|
501
|
+
* 不是任务的时间预算。任务的时间预算归 JOB_TIMEOUT_MS 管。
|
|
502
|
+
*/
|
|
503
|
+
const FLUSH_TIMEOUT_MS = 180000;
|
|
504
|
+
ctx.effect(() => () => {
|
|
505
|
+
const flush = Promise.resolve()
|
|
506
|
+
.then(() => drainPromise)
|
|
507
|
+
.then(() => writeChain)
|
|
508
|
+
.catch(() => undefined);
|
|
509
|
+
const deadline = new Promise((resolve) => {
|
|
510
|
+
const t = setTimeout(resolve, FLUSH_TIMEOUT_MS);
|
|
511
|
+
if (typeof t.unref === 'function') t.unref();
|
|
512
|
+
});
|
|
513
|
+
return Promise.race([flush, deadline]).then(() => {
|
|
514
|
+
abort.abort();
|
|
515
|
+
sessionStates.clear();
|
|
516
|
+
});
|
|
517
|
+
}, 'feiyu: flush pending work, then abort');
|
|
518
|
+
|
|
519
|
+
/* ── 写盘 ── */
|
|
520
|
+
|
|
521
|
+
let writeChain = Promise.resolve();
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 写一份**人读版**日记(`日记.md`)。
|
|
525
|
+
*
|
|
526
|
+
* 定位和内存索引一样:**单向派生、只读、随时可重建**。
|
|
527
|
+
* 它在档案目录里,但**不属于档案数据**(不进 `.dshpack`)——
|
|
528
|
+
* 导入别人的档案后,下次加载会自动重新生成一份。
|
|
529
|
+
*
|
|
530
|
+
* 失败的代价只是"看不到漂亮版本",所以这里只 warn,绝不影响主流程。
|
|
531
|
+
*/
|
|
532
|
+
async function writeReadableDiary(snapshot) {
|
|
533
|
+
try {
|
|
534
|
+
const file = path.join(archiveDirectory, '日记.md');
|
|
535
|
+
await writeFileAtomic(file, renderDiaryFile(snapshot, { now: Date.now() }));
|
|
536
|
+
} catch (err) {
|
|
537
|
+
warn(`写人读版日记失败(不影响记忆本身):${err instanceof Error ? err.message : String(err)}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* 落盘前,把**磁盘上别的会话刚写的**东西并进内存。
|
|
543
|
+
*
|
|
544
|
+
* ── 为什么必须有这一步 ──
|
|
545
|
+
*
|
|
546
|
+
* `archiveToFiles()` 是**整份重建**:内存里有什么就写什么,磁盘上多出来的一律抹掉。
|
|
547
|
+
*
|
|
548
|
+
* 单会话时没事。但同一份档案会被**很多个会话**同时打开 ——
|
|
549
|
+
* 实测有 **10 个会话**往同一个档案里写过。每个会话启动时把档案读进内存,
|
|
550
|
+
* 落盘时整份重写:
|
|
551
|
+
*
|
|
552
|
+
* 会话 A 写了 X,落盘
|
|
553
|
+
* 会话 B 内存里没有 X,写 Y,落盘 → **X 被抹掉了**
|
|
554
|
+
*
|
|
555
|
+
* 谁后落盘谁赢。而且这种丢失**检测不出来**:B 覆盖 A 的第 5 条时,
|
|
556
|
+
* 编号照样是 1..10 连续的 —— 不撞号也不断号。
|
|
557
|
+
*
|
|
558
|
+
* 所以写之前先读一遍,按 id 并一下。**内存优先**(它对已知的条目最新),
|
|
559
|
+
* 磁盘补充内存没有的。`mergeArchives` 就是干这个的。
|
|
560
|
+
*
|
|
561
|
+
* ── 这个修法换来了什么、代价是什么 ──
|
|
562
|
+
*
|
|
563
|
+
* 换来:**新增的条目不会再被静默抹掉。** 这是唯一会真吃掉用户数据的路径。
|
|
564
|
+
*
|
|
565
|
+
* 代价:**删除在并发下可能被翻回来。** 会话 B 内存里还留着用户刚在会话 A
|
|
566
|
+
* 删掉的那条,B 一落盘它就回来了。这个取舍是故意的 ——
|
|
567
|
+
* *恢复一条看得见的删错,比丢一条看不见的东西便宜。*
|
|
568
|
+
* 要根治得给删除加墓碑(tombstone),那是另一个量级的改动,先记着。
|
|
569
|
+
*/
|
|
570
|
+
/**
|
|
571
|
+
* 磁盘上的 `persona.md` 被人改过吗?改过就采纳。
|
|
572
|
+
*
|
|
573
|
+
* 跟 `mergeDiskEntries` 是同一个道理:**磁盘是人的地盘,内存只是缓存。**
|
|
574
|
+
* 但这个比日记简单 —— 人格是一整块文本,没有 id 可以按条合并,
|
|
575
|
+
* 所以判据只能是"跟基线比对":变了就整块认磁盘。
|
|
576
|
+
*
|
|
577
|
+
* 两处会调它:
|
|
578
|
+
* · `renderFor` —— 让人改完**下一轮就生效**,不用重启
|
|
579
|
+
* · `persist` —— 防止落盘时把人的改动覆盖掉
|
|
580
|
+
*
|
|
581
|
+
* 读不到的代价只是"这次不采纳",绝不影响别的。
|
|
582
|
+
*
|
|
583
|
+
* @returns {boolean} 是不是采纳了磁盘上的新版本
|
|
584
|
+
*/
|
|
585
|
+
function syncPersonaFromDisk() {
|
|
586
|
+
if (personaBaseline === null) return false;
|
|
587
|
+
try {
|
|
588
|
+
const file = path.join(archiveDirectory, PERSONA_FILE);
|
|
589
|
+
if (!existsSync(file)) return false;
|
|
590
|
+
const disk = readFileSync(file, 'utf8');
|
|
591
|
+
// 落盘时写的是 `ensurePersonaStructure(x) + '\n'`,比对前先把格式对齐
|
|
592
|
+
const normalized = `${ensurePersonaStructure(disk).replace(/\s*$/, '')}\n`;
|
|
593
|
+
const baseline = `${ensurePersonaStructure(personaBaseline).replace(/\s*$/, '')}\n`;
|
|
594
|
+
if (normalized === baseline) return false;
|
|
595
|
+
archive.persona = disk;
|
|
596
|
+
personaBaseline = disk;
|
|
597
|
+
log('检测到 persona.md 被手动改过,采纳磁盘上的版本');
|
|
598
|
+
return true;
|
|
599
|
+
} catch (err) {
|
|
600
|
+
warn(`读 persona.md 失败(继续用内存里的):${err instanceof Error ? err.message : String(err)}`);
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function mergeDiskEntries() {
|
|
606
|
+
try {
|
|
607
|
+
const { files } = await readArchiveDir(archiveDirectory);
|
|
608
|
+
// ⚠️ **读不到就别合。** 磁盘读失败时传个空基线,会让
|
|
609
|
+
// `mergeArchives` 把"内存有、磁盘没有"全判成"别人删了它",
|
|
610
|
+
// 一个档案瞬间清空。宁可这一次不防丢,也不能把档案写没。
|
|
611
|
+
if (files.size === 0) return 0;
|
|
612
|
+
const entries = [...files].map(([n, text]) => ({ name: n, text: () => text }));
|
|
613
|
+
const { archive: disk } = archiveFromFiles(entries, { now: Date.now() });
|
|
614
|
+
const before = archive.diary.length;
|
|
615
|
+
archive = mergeArchives(archive, disk, { baselineIds: loadedIds });
|
|
616
|
+
index = buildIndex(archive.diary);
|
|
617
|
+
const gained = archive.diary.length - before;
|
|
618
|
+
if (gained > 0) log(`落盘前并进了 ${gained} 条别的会话刚写的日记`);
|
|
619
|
+
return gained;
|
|
620
|
+
} catch (err) {
|
|
621
|
+
// 读不到就照常写 —— 合并是**尽力而为的防丢**,不能因为它失败就不落盘
|
|
622
|
+
warn(`落盘前合并失败(继续写,但可能覆盖别的会话):${err instanceof Error ? err.message : String(err)}`);
|
|
623
|
+
return 0;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** 连续写盘失败次数 —— 到 3 次就把 degraded 置真,让面板能看出来 */
|
|
628
|
+
let writeFailures = 0;
|
|
629
|
+
|
|
630
|
+
function persist() {
|
|
631
|
+
if (!ready) return writeChain;
|
|
632
|
+
writeChain = writeChain
|
|
633
|
+
.then(async () => {
|
|
634
|
+
// ⚠️ 顺序要紧:**先并、再改时间戳、再写。**
|
|
635
|
+
// 合并会把 archive 换成新对象,所以 snapshot 必须在合并之后取。
|
|
636
|
+
await mergeDiskEntries();
|
|
637
|
+
// 人格也要先问一声磁盘 —— 不然人手动改的会在这里被覆盖掉
|
|
638
|
+
syncPersonaFromDisk();
|
|
639
|
+
const merged = archive;
|
|
640
|
+
merged.manifest.updated_at = Date.now();
|
|
641
|
+
await writeArchiveDir(archiveDirectory, archiveToFiles(merged));
|
|
642
|
+
await writeReadableDiary(merged);
|
|
643
|
+
})
|
|
644
|
+
.catch((err) => {
|
|
645
|
+
// ⚠️ **连续写盘失败要让面板看得出来。**
|
|
646
|
+
//
|
|
647
|
+
// 原来这里只 warn 一句就吞掉,工具照样返回"记住了"。
|
|
648
|
+
// 社区报告点了一句:这次这个坑是"写成功了但写错地方",所以捕获救不了 ——
|
|
649
|
+
// 但**真的写失败**的时候,用户同样只会看到「记住了」。
|
|
650
|
+
//
|
|
651
|
+
// 所以:连续 3 次失败就把 degraded 置真,面板上会显示"档案读不了"。
|
|
652
|
+
// 一次失败可能只是临时占用,不值得惊动用户。
|
|
653
|
+
writeFailures += 1;
|
|
654
|
+
warn(
|
|
655
|
+
`写档案失败(连续第 ${writeFailures} 次):${err instanceof Error ? err.message : String(err)}`,
|
|
656
|
+
);
|
|
657
|
+
if (writeFailures >= 3 && !degraded) {
|
|
658
|
+
degraded = true;
|
|
659
|
+
warn('档案连续写失败 3 次,标记为降级 —— 面板上会显示出来');
|
|
660
|
+
}
|
|
661
|
+
})
|
|
662
|
+
.then(() => {
|
|
663
|
+
// 成功一次就把计数清零:一次失败不该永久挂着
|
|
664
|
+
writeFailures = 0;
|
|
665
|
+
});
|
|
666
|
+
return writeChain;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/* ── 注入 ── */
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* 「主动记一条」的工具名。
|
|
673
|
+
*
|
|
674
|
+
* 声明在这里(而不是跟工具定义放一起),是因为 `renderFor` 要把它写进
|
|
675
|
+
* 注入里的那句提示。**放在后面虽然在运行时也能用(TDZ 只在 apply 早期
|
|
676
|
+
* 才可能触发),但太脆** —— 谁把 renderFor 提早调一次就炸。
|
|
677
|
+
*/
|
|
678
|
+
const NOTE_TOOL_NAME = 'feiyu_note';
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* 「去档案里找一句」的工具名。
|
|
682
|
+
*
|
|
683
|
+
* 为什么要它:注入的召回是**系统替它猜**(拿你这一轮的字面去匹配),
|
|
684
|
+
* 而字面匹配有个结构性劣势 —— 元日记("他问了什么、我答了什么")
|
|
685
|
+
* 永远比那件事本身字面更全,所以真东西压不上去。
|
|
686
|
+
*
|
|
687
|
+
* 工具换了个方向:**它自己知道在找什么。** 换个说法也能撞上。
|
|
688
|
+
*
|
|
689
|
+
* 而"知道有这个工具"靠两件东西(缺一不可):
|
|
690
|
+
* 1. 工具签名 —— 注册了,DSH 就会把 schema 发给模型
|
|
691
|
+
* 2. 注入里那句指针 —— 在 `renderFor` 的 handNoteHint 里,每轮都读到
|
|
692
|
+
*
|
|
693
|
+
* 只做 1 不做 2,模型会想不起来用(工具一多就被淹)。
|
|
694
|
+
*/
|
|
695
|
+
const RECALL_TOOL_NAME = 'feiyu_recall';
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* 为某个会话渲染注入文本。
|
|
699
|
+
*
|
|
700
|
+
* **按轮缓存**:`assemble` 每个 model step 都会调用一次,
|
|
701
|
+
* 而一个回合可能有几十个 step(工具调用)。不缓存的话
|
|
702
|
+
* 召回会用掉冷却、还会在同一个回合里换着花样想起不同的事 —— 很出戏。
|
|
703
|
+
*/
|
|
704
|
+
function renderFor(sessionId) {
|
|
705
|
+
if (!ready || !config.enabled) return '';
|
|
706
|
+
const s = stateOf(sessionId);
|
|
707
|
+
if (s.renderCache.turn === s.turn) return s.renderCache.text;
|
|
708
|
+
|
|
709
|
+
let text = '';
|
|
710
|
+
try {
|
|
711
|
+
const query = s.lastUserText;
|
|
712
|
+
|
|
713
|
+
// 人可能刚改过 persona.md —— 每轮问一次,**改完下一轮就生效,不用重启**。
|
|
714
|
+
// 块本身按轮缓存,所以这个读一turn一次,跟手记那条一样便宜。
|
|
715
|
+
syncPersonaFromDisk();
|
|
716
|
+
|
|
717
|
+
// ── 判断和事件,分开放 ──
|
|
718
|
+
//
|
|
719
|
+
// 这是这次改动的核心。**内容早就在了,只是放错了格子。**
|
|
720
|
+
//
|
|
721
|
+
// `feiyu_note` 写出来的东西("别把前一种的辛苦当成功德")是**判断**,
|
|
722
|
+
// 不是事件,却一直被当成事件渲染在「最近的事」里,格式跟流水账一模一样 ——
|
|
723
|
+
// 连我自己都分不出哪句是我写的。一位读者把这个毛病说得很准。
|
|
724
|
+
//
|
|
725
|
+
// 事件 → 「最近的事」/「突然想起来」(会被召回)
|
|
726
|
+
// 判断 → 「我自己想的」(永远在场,不进召回池)
|
|
727
|
+
//
|
|
728
|
+
// ⚠️ **"永远在场"这个前提在高频下失效了。** 判断段只放得下
|
|
729
|
+
// `MAX_NOTES` 条(8 → 4,因为 8 条会吃光预算、把日记段挤到 0%)。
|
|
730
|
+
// 被挤出窗口的判断如果还不进召回池,那就不是"以后想起来",
|
|
731
|
+
// 是**彻底看不见** —— 而这个区是"是我",丢了最可惜。
|
|
732
|
+
//
|
|
733
|
+
// 所以分流改成两截:
|
|
734
|
+
// 最近 MAX_NOTES 条 → 「我自己想的」(永远在场)
|
|
735
|
+
// 更早的那些 → **加入召回池**,相关时能被想起来
|
|
736
|
+
const allNotes = archive.diary.filter((e) => e?.kind === 'note');
|
|
737
|
+
// 渲染兜底:老条目在写入时可能没洗过(feiyu_note 的漏洞修之前写进来的),
|
|
738
|
+
// 但它们照样会进系统提示。这里按**只拦密钥**再过一遍 —— 不开第三方泛化,
|
|
739
|
+
// 免得把历史日记里的人名都糊掉。
|
|
740
|
+
const safeEntry = (e) => {
|
|
741
|
+
const r = scrub(e.text, { thirdParty: false });
|
|
742
|
+
return r.blocked ? { ...e, text: r.text } : e;
|
|
743
|
+
};
|
|
744
|
+
const events = archive.diary.map(safeEntry)
|
|
745
|
+
.filter((e) => e?.kind !== 'note')
|
|
746
|
+
// 续写过的条目 created_at 没动,但它是"刚写过的" —— 排序得看 updated_at,
|
|
747
|
+
// 否则刚补完的那条会按原始时间沉下去。
|
|
748
|
+
.sort((a, b) => (a.updated_at ?? a.created_at) - (b.updated_at ?? b.created_at));
|
|
749
|
+
const shownNotes = allNotes.slice(-MAX_NOTES);
|
|
750
|
+
|
|
751
|
+
// 「最近的事」—— 按时间取最近的几条,不走相似度。
|
|
752
|
+
// 这是让注入区读起来像日记而不是像检索结果的关键:
|
|
753
|
+
// 最近发生的事本来就在脑子里,不需要被"想起来"。
|
|
754
|
+
//
|
|
755
|
+
// 取的是 **events**,不是整个日记 —— 判断归「我自己想的」管。
|
|
756
|
+
const recentCount = Number.isFinite(config.recentEntries) ? config.recentEntries : 8;
|
|
757
|
+
const recent = recentCount > 0 ? events.slice(-recentCount) : [];
|
|
758
|
+
const recentIds = new Set(recent.map((e) => e.id));
|
|
759
|
+
|
|
760
|
+
// ⚠️ **召回池里不能有「最近的事」那几条。**
|
|
761
|
+
//
|
|
762
|
+
// 实测抓到的坑:它们已经常驻了,却又混进召回池一起竞争 ——
|
|
763
|
+
// 而"最近"天然含着你刚说过的话,字面命中率最高,于是**永远赢**。
|
|
764
|
+
//
|
|
765
|
+
// 具体表现:他问「赛博朋克让你想到什么」,召回选中了
|
|
766
|
+
// 「他连着测了三句:赛博朋克、随心所欲、手机号……」—— 那是我自己写的
|
|
767
|
+
// 测试记录,含所有测试词所以分最高;真正的赛博朋克日记永远出不来。
|
|
768
|
+
// 而且那条已经在「最近的事」里,渲染时会被去重跳过,
|
|
769
|
+
// 连「突然想起来」都不显示 —— 召回了,等于没召回。
|
|
770
|
+
//
|
|
771
|
+
// 排除之后:同一句话会去挑真正的旧事(实测赛博朋克那条命中了
|
|
772
|
+
// 「边缘行者2 上线,总导演从今石之洋换成五十岚海」)。
|
|
773
|
+
const olderNotes = allNotes.slice(0, -MAX_NOTES);
|
|
774
|
+
const recallPool = [
|
|
775
|
+
...events.filter((e) => !recentIds.has(e.id)),
|
|
776
|
+
...olderNotes,
|
|
777
|
+
];
|
|
778
|
+
|
|
779
|
+
const { chosen } = selectMemories(recallPool, index, query, s, s.turn, {
|
|
780
|
+
highSim: config.highSim,
|
|
781
|
+
maxShow: config.maxShowPerTurn,
|
|
782
|
+
wanderChance: config.wanderChance,
|
|
783
|
+
// 旧事的随机池门槛。注意下限降到 0.15 是为了跟"降门槛"一致 ——
|
|
784
|
+
// 门槛降到 0.15 却在这里卡 0.25,等于白降。
|
|
785
|
+
minSalience: Math.max(0.15, config.minSalience),
|
|
786
|
+
now: Date.now(),
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// 手记是**只读**的,所以每轮都重新读一遍 —— 别的肥鱼或用户刚写进去的
|
|
790
|
+
// 那行,下一轮就在注入里,不用重启。文件很小,而且整块渲染按轮缓存。
|
|
791
|
+
let handNotes = [];
|
|
792
|
+
try {
|
|
793
|
+
const notePath = path.join(archiveDirectory, HANDNOTE_FILE);
|
|
794
|
+
if (existsSync(notePath)) handNotes = selectHandNotes(readFileSync(notePath, 'utf8'));
|
|
795
|
+
} catch {
|
|
796
|
+
// 读不到就当没有 —— 绝不因为手记坏了挡住记忆
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const rendered = renderMemoryBlock({
|
|
800
|
+
persona: archive.persona,
|
|
801
|
+
relationship: { ...archive.relationship, since: archive.manifest.created_at },
|
|
802
|
+
lexicon: archive.lexicon,
|
|
803
|
+
// 第三区:**我自己想的**(判断)。最近几条永远在场,
|
|
804
|
+
// 更早的那些走召回(见上面 allNotes 的分流)。
|
|
805
|
+
notes: shownNotes,
|
|
806
|
+
recent,
|
|
807
|
+
recalled: chosen,
|
|
808
|
+
handNotes,
|
|
809
|
+
// 这句是**唯一**能让别的肥鱼知道"可以主动记"的渠道 ——
|
|
810
|
+
// 它们每轮都会读到这块记忆,不会去翻文档。
|
|
811
|
+
//
|
|
812
|
+
// 第二句同理:给检索工具留个指针。**只注册工具不够** ——
|
|
813
|
+
// 工具一多就被淹,模型急起来想不起去查。这句在「帧」里、不进预算池,
|
|
814
|
+
// 所以不会像栏目那样被裁掉。
|
|
815
|
+
handNoteHint:
|
|
816
|
+
`想给后面的我留话,用 ${NOTE_TOOL_NAME} 工具说一声就行 —— 不用管格式,也别为了记事去动文件。\n` +
|
|
817
|
+
`想不起来某件事时,用 ${RECALL_TOOL_NAME} 去档案里找一下 —— 用当时的词,越具体越准。`,
|
|
818
|
+
includePersona: config.includePersona,
|
|
819
|
+
budget: config.budgetTokens,
|
|
820
|
+
now: Date.now(),
|
|
821
|
+
});
|
|
822
|
+
text = rendered.text;
|
|
823
|
+
log(
|
|
824
|
+
`会话 ${sessionId} 第 ${s.turn} 轮:最近 ${recent.length} 条在场 · 想起 ${chosen.length} 条` +
|
|
825
|
+
` · 注入 ${rendered.tokens} token` +
|
|
826
|
+
(rendered.dropped.length > 0 ? ` · 裁掉 ${rendered.dropped.join('/')}` : ''),
|
|
827
|
+
);
|
|
828
|
+
} catch (err) {
|
|
829
|
+
warn(`渲染记忆失败:${err instanceof Error ? err.message : String(err)}`);
|
|
830
|
+
text = '';
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
s.renderCache = { turn: s.turn, text };
|
|
834
|
+
return text;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const SECTION_NAME = 'feiyu:memory';
|
|
838
|
+
/** 晚于 DEPLOYMENT_PERSONA_PREFIX(0),早于 PLAN_POLICY(500)。人物设定在前,记忆在后。 */
|
|
839
|
+
const SECTION_ORDER = 100;
|
|
840
|
+
|
|
841
|
+
/** 按会话注册 scoped 段。scoped 同名会遮蔽全局。 */
|
|
842
|
+
const promptFibers = new Map();
|
|
843
|
+
|
|
844
|
+
function installPrompt(agent) {
|
|
845
|
+
if (agent === undefined || promptFibers.has(agent)) return promptFibers.get(agent);
|
|
846
|
+
let fiber;
|
|
847
|
+
try {
|
|
848
|
+
fiber = agent.ctx.inject(['systemPrompt'], (scope) => {
|
|
849
|
+
scope.systemPrompt.section({
|
|
850
|
+
name: SECTION_NAME,
|
|
851
|
+
order: SECTION_ORDER,
|
|
852
|
+
// 每次 assemble 重新求值;内部按轮缓存
|
|
853
|
+
text: () => renderFor(agent.id),
|
|
854
|
+
// 记忆文本里可能出现 {{ }},绝不能当模板解析
|
|
855
|
+
interpolate: false,
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
} catch (err) {
|
|
859
|
+
warn(`注册会话段失败:${err instanceof Error ? err.message : String(err)}`);
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
promptFibers.set(agent, fiber);
|
|
863
|
+
log(`已为会话 ${String(agent.id)} 注册记忆段`);
|
|
864
|
+
// 官方 practices:agent.ctx 上的注册有两个 owner,插件自己也要留一份 disposer
|
|
865
|
+
ctx.effect(() => () => {
|
|
866
|
+
const f = promptFibers.get(agent);
|
|
867
|
+
if (f === undefined) return;
|
|
868
|
+
promptFibers.delete(agent);
|
|
869
|
+
void f.dispose?.();
|
|
870
|
+
}, `feiyu: dispose prompt for ${String(agent.id)}`);
|
|
871
|
+
return fiber;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
for (const agent of ctx.agents.list()) installPrompt(agent);
|
|
875
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
876
|
+
installPrompt(agent);
|
|
877
|
+
});
|
|
878
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
879
|
+
const f = promptFibers.get(agent);
|
|
880
|
+
if (f !== undefined) {
|
|
881
|
+
promptFibers.delete(agent);
|
|
882
|
+
void f.dispose?.();
|
|
883
|
+
}
|
|
884
|
+
sessionStates.delete(agent.id);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
/* ── 采集:只做本地记账,零 I/O、零模型 ── */
|
|
888
|
+
|
|
889
|
+
ctx.on('session/event', (session, event) => {
|
|
890
|
+
try {
|
|
891
|
+
if (!config.enabled) return;
|
|
892
|
+
const id = session?.id;
|
|
893
|
+
if (typeof id !== 'string') return;
|
|
894
|
+
const s = stateOf(id);
|
|
895
|
+
|
|
896
|
+
if (event?.type === 'user/message') {
|
|
897
|
+
// 必须过滤掉注入的消息,只要真人打的字
|
|
898
|
+
if (event.data?.source?.kind !== 'user') return;
|
|
899
|
+
const text = (event.data.content ?? [])
|
|
900
|
+
.filter((b) => b?.type === 'text')
|
|
901
|
+
.map((b) => b.text)
|
|
902
|
+
.join('\n')
|
|
903
|
+
.trim();
|
|
904
|
+
if (text === '') return;
|
|
905
|
+
|
|
906
|
+
s.turn += 1;
|
|
907
|
+
s.lastUserSeq = event.seq;
|
|
908
|
+
s.lastUserText = text;
|
|
909
|
+
s.renderCache = { turn: -1, text: '' }; // 新一轮,作废缓存
|
|
910
|
+
if (SUPPRESS_RE.test(text)) s.suppressedUntil = s.turn;
|
|
911
|
+
s.transcript.push({ role: 'user', text });
|
|
912
|
+
if (s.transcript.length > 40) s.transcript.splice(0, s.transcript.length - 40);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
if (event?.type === 'assistant/message') {
|
|
917
|
+
const text = (event.data?.message?.content ?? [])
|
|
918
|
+
.filter((b) => b?.type === 'text')
|
|
919
|
+
.map((b) => b.text)
|
|
920
|
+
.join('\n')
|
|
921
|
+
.trim();
|
|
922
|
+
if (text !== '') {
|
|
923
|
+
s.transcript.push({ role: 'assistant', text });
|
|
924
|
+
if (s.transcript.length > 40) s.transcript.splice(0, s.transcript.length - 40);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
} catch (err) {
|
|
928
|
+
warn(`采集会话事件失败:${err instanceof Error ? err.message : String(err)}`);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
/* ── 闸门 + 带外队列 ── */
|
|
933
|
+
|
|
934
|
+
/** @type {Array<{sessionId: string, agent: any}>} */
|
|
935
|
+
const queue = [];
|
|
936
|
+
let draining = false;
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* 当前这轮后台工作的 promise。
|
|
940
|
+
*
|
|
941
|
+
* `snapshot()` 必须等它 —— 否则外部会看到"内存里已经记下了、磁盘上还没有"
|
|
942
|
+
* 的中间态。这个竞态在测试里表现为随机的 ENOENT,在真实使用中表现为
|
|
943
|
+
* "刚记完就关掉 Harness,那条日记没了"。
|
|
944
|
+
*/
|
|
945
|
+
let drainPromise = Promise.resolve();
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* ⚠️ 这一对函数是端到端验证逼出来的第二次修正。
|
|
949
|
+
*
|
|
950
|
+
* 背景:固化是**带外**跑的(绝不能阻塞回合关闭)。但一次性会话
|
|
951
|
+
* (headless)会在回合结束后立刻退出进程 —— 于是"模型答了、日记没了",
|
|
952
|
+
* 而且**时好时坏**:LLM 恰好够快就写上了,慢一点就丢。
|
|
953
|
+
*
|
|
954
|
+
* 第一版修法是"在卸载时等一会儿",但那个等待是 unref 的定时器,
|
|
955
|
+
* 等于**没人真的在等** —— 依然是在赌。正确做法是反过来:
|
|
956
|
+
* 有活在跑的时候,**主动拖住事件循环**,干完再放手。
|
|
957
|
+
* 再加一个硬超时兜底:绝不因为一次卡住的调用把进程永久挂死。
|
|
958
|
+
*/
|
|
959
|
+
const JOB_TIMEOUT_MS = 60000;
|
|
960
|
+
let keepAlive = null;
|
|
961
|
+
|
|
962
|
+
function holdProcessOpen() {
|
|
963
|
+
if (keepAlive !== null) return;
|
|
964
|
+
// 一个 ref 着的空定时器,足以让 Node 不退出
|
|
965
|
+
keepAlive = setInterval(() => {}, 1000);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function releaseProcessOpen() {
|
|
969
|
+
if (keepAlive === null) return;
|
|
970
|
+
clearInterval(keepAlive);
|
|
971
|
+
keepAlive = null;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** 真正跑队列。**只允许由 setImmediate 调起**,见 enqueue 里的说明。 */
|
|
975
|
+
async function runQueue() {
|
|
976
|
+
try {
|
|
977
|
+
while (queue.length > 0) {
|
|
978
|
+
if (abort.signal.aborted) return;
|
|
979
|
+
const job = queue.shift();
|
|
980
|
+
// 每个任务一个独立的可中止信号:一次卡住的调用不该把整个插件废掉
|
|
981
|
+
const jobAbort = new AbortController();
|
|
982
|
+
const onPluginAbort = () => jobAbort.abort();
|
|
983
|
+
abort.signal.addEventListener('abort', onPluginAbort, { once: true });
|
|
984
|
+
const timer = setTimeout(() => {
|
|
985
|
+
warn('后台固化超时,这次记忆放弃(宁可漏记,不要挂住进程)');
|
|
986
|
+
jobAbort.abort();
|
|
987
|
+
}, JOB_TIMEOUT_MS);
|
|
988
|
+
try {
|
|
989
|
+
await consolidate(job.sessionId, job.agent, jobAbort.signal);
|
|
990
|
+
} catch (err) {
|
|
991
|
+
warn(`固化失败:${err instanceof Error ? err.message : String(err)}`);
|
|
992
|
+
} finally {
|
|
993
|
+
clearTimeout(timer);
|
|
994
|
+
abort.signal.removeEventListener('abort', onPluginAbort);
|
|
995
|
+
stateOf(job.sessionId).queued = false;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
} finally {
|
|
999
|
+
draining = false;
|
|
1000
|
+
releaseProcessOpen();
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function enqueue(sessionId, agent) {
|
|
1005
|
+
const s = stateOf(sessionId);
|
|
1006
|
+
if (s.queued) return;
|
|
1007
|
+
s.queued = true;
|
|
1008
|
+
queue.push({ sessionId, agent });
|
|
1009
|
+
if (draining) return;
|
|
1010
|
+
|
|
1011
|
+
draining = true;
|
|
1012
|
+
holdProcessOpen();
|
|
1013
|
+
|
|
1014
|
+
// ⚠️ 关键:**绝不能在这个事件回调里同步开跑**。
|
|
1015
|
+
//
|
|
1016
|
+
// `agent/turn-stopping` 是回合生命周期的一部分。在这个调用栈里同步发起
|
|
1017
|
+
// 一次模型请求,会**死等**——端到端验证时表现为 `llm.stream` 永不返回、
|
|
1018
|
+
// 而且一个字都不报。异步函数在第一个 await 之前是同步执行的,
|
|
1019
|
+
// 而 `for await (const c of llm.stream(...))` 的 `llm.stream(...)` 恰恰
|
|
1020
|
+
// 在 await 之前就被求值了 —— 请求就是这样在回合关闭的栈里发出去的。
|
|
1021
|
+
//
|
|
1022
|
+
// 推到下一轮事件循环,彻底离开那个调用栈再开始。
|
|
1023
|
+
drainPromise = new Promise((resolve) => {
|
|
1024
|
+
setImmediate(() => {
|
|
1025
|
+
void runQueue().then(resolve);
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* 判断"这一轮值不值得记"。**失败默认记** —— 用户定的:
|
|
1032
|
+
* 漏了永远不知道,多记顶多占几行。
|
|
1033
|
+
*/
|
|
1034
|
+
async function judgeWorthKeeping(sessionId) {
|
|
1035
|
+
const s = stateOf(sessionId);
|
|
1036
|
+
const said = String(s.lastUserText ?? '').trim();
|
|
1037
|
+
if (said === '') return { keep: false, why: '这一轮他没说话' };
|
|
1038
|
+
|
|
1039
|
+
const llm = ctx.get('llm');
|
|
1040
|
+
if (llm === undefined) return { keep: true, why: '没有 llm 服务,按默认记' };
|
|
1041
|
+
|
|
1042
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection?.() ?? undefined;
|
|
1043
|
+
const provider = selection?.provider;
|
|
1044
|
+
const model = config.model || selection?.model;
|
|
1045
|
+
if (typeof provider !== 'string' || typeof model !== 'string') {
|
|
1046
|
+
return { keep: true, why: '拿不到模型信息,按默认记' };
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// 手记里的话当**补充**判据(不是唯一判据 —— 新用户的 手记 里没有判据,
|
|
1050
|
+
// 判据本体在 judge.mjs 里随插件发)
|
|
1051
|
+
let handNotes = [];
|
|
1052
|
+
try {
|
|
1053
|
+
const p = path.join(archiveDirectory, HANDNOTE_FILE);
|
|
1054
|
+
if (existsSync(p)) handNotes = selectHandNotes(readFileSync(p, 'utf8'));
|
|
1055
|
+
} catch {
|
|
1056
|
+
// 读不到就不给,不影响判断
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const built = buildJudgeInput({ lastUserText: said, handNotes });
|
|
1060
|
+
const opts = {
|
|
1061
|
+
provider,
|
|
1062
|
+
model,
|
|
1063
|
+
system: built.system,
|
|
1064
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: built.user }] }],
|
|
1065
|
+
maxTokens: JUDGE_MAX_TOKENS,
|
|
1066
|
+
temperature: 0,
|
|
1067
|
+
// 后台任务绝不继承前台的思考档位(踩过:900 token 全烧在推理上)
|
|
1068
|
+
reasoningEffort: 'off',
|
|
1069
|
+
signal: abort.signal,
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
try {
|
|
1073
|
+
let raw = '';
|
|
1074
|
+
for await (const chunk of llm.stream(opts)) {
|
|
1075
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') raw += chunk.text;
|
|
1076
|
+
}
|
|
1077
|
+
const v = parseVerdict(raw);
|
|
1078
|
+
log(`判断:${v.keep ? '记' : '不记'}${v.why ? ` —— ${v.why}` : ''}`);
|
|
1079
|
+
return v;
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
warn(`判断失败,按默认记:${err instanceof Error ? err.message : String(err)}`);
|
|
1082
|
+
return { keep: true, why: '判断失败,按默认记' };
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** 把判断丢进队列 —— 跟 enqueue 一个道理,绝不在 turn-stopping 的栈里发请求 */
|
|
1087
|
+
function enqueueJudge(sessionId, agent) {
|
|
1088
|
+
const s = stateOf(sessionId);
|
|
1089
|
+
if (s.judging) return;
|
|
1090
|
+
s.judging = true;
|
|
1091
|
+
holdProcessOpen();
|
|
1092
|
+
drainPromise = Promise.resolve(drainPromise)
|
|
1093
|
+
.then(() => new Promise((resolve) => setImmediate(resolve)))
|
|
1094
|
+
.then(async () => {
|
|
1095
|
+
let kept = false;
|
|
1096
|
+
try {
|
|
1097
|
+
const v = await judgeWorthKeeping(sessionId);
|
|
1098
|
+
if (v.keep) {
|
|
1099
|
+
kept = true;
|
|
1100
|
+
enqueue(sessionId, agent); // 它自己会 hold,也会在跑完后 release
|
|
1101
|
+
}
|
|
1102
|
+
} finally {
|
|
1103
|
+
s.judging = false;
|
|
1104
|
+
// ⚠️ 配对!judging 自己 hold 了 —— 如果不转交给固化,就必须自己放掉。
|
|
1105
|
+
// 漏了这句,那个 ref 着的 setInterval 会让进程永远退不出去,
|
|
1106
|
+
// 测试跑到这条就挂死(`node --test` 直接不返回)。
|
|
1107
|
+
if (!kept) releaseProcessOpen();
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
ctx.on('agent/turn-stopping', ({ agent }) => {
|
|
1113
|
+
// ⚠️ 这个事件是 serial,会被 await。这里必须**同步、极快**地返回。
|
|
1114
|
+
// 任何模型调用都丢给 drain(),绝不在这里 await。
|
|
1115
|
+
try {
|
|
1116
|
+
if (!config.enabled || !ready || degraded) return;
|
|
1117
|
+
const id = agent?.id;
|
|
1118
|
+
if (typeof id !== 'string') return;
|
|
1119
|
+
const s = stateOf(id);
|
|
1120
|
+
if (s.turn === 0 || s.lastUserText === '') return;
|
|
1121
|
+
if (s.suppressedUntil === s.turn) {
|
|
1122
|
+
log('用户说了别记,跳过');
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// ── 旧的「关键词闸门」拆了,换成一次极短的模型判断 ──
|
|
1127
|
+
//
|
|
1128
|
+
// 原来这里是一张正则表:命中 /不对|记住|我其实/ 就开闸。它有三个洞:
|
|
1129
|
+
//
|
|
1130
|
+
// · **看不到没命中的话**。用户讲了一件十几年前的旧事 ——
|
|
1131
|
+
// 没有一个词能命中 —— 它根本不知道那句话存在。不是判错,是没判。
|
|
1132
|
+
// · **假阳性**。"我其实不太饿"命中 /我其实/,记一条废话。
|
|
1133
|
+
// · 判据被写死在我今天的先验里,我没想到的说法一律不记。
|
|
1134
|
+
//
|
|
1135
|
+
// 而那个位置的错误代价是**不对称的**:漏记一条永久没了、而且
|
|
1136
|
+
// 你连"少了什么"都不知道;多记一条只是占几行。
|
|
1137
|
+
//
|
|
1138
|
+
// 所以换人站:把这一轮那句话交给模型看一眼。仍然**丢队列** ——
|
|
1139
|
+
// 这个回调是 serial、会被 await,绝不能在栈里发请求。
|
|
1140
|
+
enqueueJudge(id, agent);
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
warn(`判断排队失败:${err instanceof Error ? err.message : String(err)}`);
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
/* ── 固化:真正的模型调用 ── */
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* 真正干活的模型调用。
|
|
1150
|
+
* @param {string} sessionId
|
|
1151
|
+
* @param {any} agent
|
|
1152
|
+
* @param {AbortSignal} [signal] 这个任务自己的可中止信号(由 drain 提供)
|
|
1153
|
+
*/
|
|
1154
|
+
async function consolidate(sessionId, agent, signal) {
|
|
1155
|
+
const s = stateOf(sessionId);
|
|
1156
|
+
const llm = ctx.get('llm');
|
|
1157
|
+
if (llm === undefined) {
|
|
1158
|
+
log('没有 llm 服务,跳过固化');
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const selection =
|
|
1163
|
+
ctx.get('agentDefaultModel')?.currentSelection?.() ?? undefined;
|
|
1164
|
+
const provider = selection?.provider;
|
|
1165
|
+
const model = config.model || selection?.model;
|
|
1166
|
+
if (typeof provider !== 'string' || typeof model !== 'string') {
|
|
1167
|
+
log('拿不到模型信息,跳过固化');
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// 只取本次会话中"还没记过"的那部分:最近几轮足够,不用全量
|
|
1172
|
+
const recent = s.transcript.slice(-12);
|
|
1173
|
+
if (recent.length === 0) {
|
|
1174
|
+
log('没有可总结的对话,跳过固化');
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
log(`固化开始:${recent.length} 条消息 → ${provider}/${model}`);
|
|
1179
|
+
|
|
1180
|
+
const transcript = recent
|
|
1181
|
+
.map((m) => `${m.role === 'user' ? '他' : '我'}:${m.text}`)
|
|
1182
|
+
.join('\n\n');
|
|
1183
|
+
|
|
1184
|
+
// 隐私:先洗再发。密钥硬拦 + 第三方泛化都在这一步生效。
|
|
1185
|
+
const scrubOpts = {
|
|
1186
|
+
thirdParty: config.thirdPartyRedaction !== false,
|
|
1187
|
+
amountThreshold: config.amountThreshold,
|
|
1188
|
+
};
|
|
1189
|
+
const scrubbed = scrub(transcript, scrubOpts);
|
|
1190
|
+
|
|
1191
|
+
const recentDiary = archive.diary
|
|
1192
|
+
.slice(-5)
|
|
1193
|
+
.map((e) => `- ${e.text}`)
|
|
1194
|
+
.join('\n');
|
|
1195
|
+
|
|
1196
|
+
// ── 能不能续写上一条?──
|
|
1197
|
+
//
|
|
1198
|
+
// 固化每次看"最后 12 条消息",越往后素材越全 —— 同一段对话会被续着写
|
|
1199
|
+
// 成 138 / 232 / 309 字三张快照,讲的是同一件事,占掉「最近的事」三个位。
|
|
1200
|
+
// 所以:同一个 session、轮数差在窗口内、而且还没到 appendMaxChars,
|
|
1201
|
+
// 就把上一条一起给模型,让它只写"多出来的那部分"。
|
|
1202
|
+
const appendWindow = Number.isFinite(config.appendWindowTurns) ? config.appendWindowTurns : 4;
|
|
1203
|
+
const appendMax = DEFAULT_CONSOLIDATION.appendMaxChars;
|
|
1204
|
+
const lastEvent = [...archive.diary]
|
|
1205
|
+
.reverse()
|
|
1206
|
+
.find((e) => e.kind !== 'note' && e.session === sessionId);
|
|
1207
|
+
const extendTarget =
|
|
1208
|
+
lastEvent &&
|
|
1209
|
+
Number.isFinite(lastEvent.turn) &&
|
|
1210
|
+
s.turn - lastEvent.turn <= appendWindow &&
|
|
1211
|
+
lastEvent.text.length < appendMax
|
|
1212
|
+
? lastEvent
|
|
1213
|
+
: null;
|
|
1214
|
+
// 两种都打 —— 下次一眼看出是"没找到可续的"还是"找到了但模型不听话"
|
|
1215
|
+
log(
|
|
1216
|
+
extendTarget
|
|
1217
|
+
? `续写判断:接上一条 ${extendTarget.id}(轮数差 ${s.turn - extendTarget.turn},${extendTarget.text.length} 字)`
|
|
1218
|
+
: `续写判断:没有可续的(最后一条事件 turn=${lastEvent?.turn ?? "—"},本轮 turn=${s.turn})`,
|
|
1219
|
+
);
|
|
1220
|
+
|
|
1221
|
+
const built = buildConsolidationInput({
|
|
1222
|
+
transcript: scrubbed.text,
|
|
1223
|
+
relationship: archive.relationship,
|
|
1224
|
+
recentDiary,
|
|
1225
|
+
extend: extendTarget,
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
const messages = [{ role: 'user', content: [{ type: 'text', text: built.user }] }];
|
|
1229
|
+
const opts = {
|
|
1230
|
+
provider,
|
|
1231
|
+
model,
|
|
1232
|
+
system: built.system,
|
|
1233
|
+
messages,
|
|
1234
|
+
maxTokens: config.maxTokens,
|
|
1235
|
+
temperature: 0.2,
|
|
1236
|
+
signal: signal ?? abort.signal,
|
|
1237
|
+
};
|
|
1238
|
+
// ── 思考档位:**显式关掉,绝不继承用户在聊天里选的档位** ──
|
|
1239
|
+
//
|
|
1240
|
+
// 这一条是端到端验证用命换来的。原来的写法是"没配就跟随当前默认模型",
|
|
1241
|
+
// 而用户在桌面端把聊天档位设成了 high —— 于是后台的固化调用也在疯狂思考,
|
|
1242
|
+
// 900 个 maxTokens 全烧在推理上,正文一个字都没产出。
|
|
1243
|
+
//
|
|
1244
|
+
// 症状极具迷惑性:日志显示"已收 600 个 chunk / **0 字符**",
|
|
1245
|
+
// 请求正常发出、chunk 正常到达、进程正常退出、没有一条报错,
|
|
1246
|
+
// 就是什么都不记。
|
|
1247
|
+
//
|
|
1248
|
+
// 固化是结构化抽取,不是创作。思考在这里是纯浪费,而且会挤掉答案。
|
|
1249
|
+
if (typeof config.reasoningEffort === 'string' && config.reasoningEffort.trim() !== '') {
|
|
1250
|
+
opts.reasoningEffort = config.reasoningEffort.trim();
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
let text = '';
|
|
1254
|
+
let finish;
|
|
1255
|
+
let chunks = 0;
|
|
1256
|
+
log(
|
|
1257
|
+
`调用模型(system ${built.system.length} 字 / user ${built.user.length} 字` +
|
|
1258
|
+
` / maxTokens ${config.maxTokens} / 思考 ${opts.reasoningEffort ?? '默认'})`,
|
|
1259
|
+
);
|
|
1260
|
+
|
|
1261
|
+
// 有的 provider 不认这个档位会直接抛 UNSUPPORTED_REASONING_EFFORT。
|
|
1262
|
+
// 那就退回"不指定",而不是让整个记忆功能瘫痪。
|
|
1263
|
+
const runStream = async (o) => {
|
|
1264
|
+
let t = '';
|
|
1265
|
+
let f;
|
|
1266
|
+
let n = 0;
|
|
1267
|
+
for await (const chunk of llm.stream(o)) {
|
|
1268
|
+
n++;
|
|
1269
|
+
if (n === 1) log(`收到第一个 chunk(${chunk?.type ?? '?'})`);
|
|
1270
|
+
if (n % 200 === 0) log(`…已收 ${n} 个 chunk / ${t.length} 字符`);
|
|
1271
|
+
if (chunk?.type === 'text-delta') t += chunk.text;
|
|
1272
|
+
else if (chunk?.type === 'finish') f = chunk;
|
|
1273
|
+
}
|
|
1274
|
+
return { text: t, finish: f, chunks: n };
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
let run;
|
|
1278
|
+
try {
|
|
1279
|
+
run = await runStream(opts);
|
|
1280
|
+
} catch (err) {
|
|
1281
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1282
|
+
if (opts.reasoningEffort !== undefined && /REASONING_EFFORT/i.test(msg)) {
|
|
1283
|
+
warn(`provider 不接受思考档位 ${opts.reasoningEffort},退回不指定重试一次`);
|
|
1284
|
+
delete opts.reasoningEffort;
|
|
1285
|
+
run = await runStream(opts);
|
|
1286
|
+
} else {
|
|
1287
|
+
throw err;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
text = run.text;
|
|
1291
|
+
finish = run.finish;
|
|
1292
|
+
chunks = run.chunks;
|
|
1293
|
+
log(`模型返回 ${chunks} 个 chunk / ${text.length} 字符`);
|
|
1294
|
+
|
|
1295
|
+
if (finish?.kind === 'error' || finish?.kind === 'aborted') {
|
|
1296
|
+
warn(`模型调用失败:${finish?.failure?.code ?? 'unknown'} ${finish?.failure?.message ?? ''}`);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (finish?.kind === 'max-tokens') {
|
|
1300
|
+
warn('输出被 maxTokens 截断,这次不记(宁可漏记,不要坏档)');
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
let parsed;
|
|
1305
|
+
try {
|
|
1306
|
+
parsed = parseConsolidation(text);
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
// 解析失败就丢弃,绝不把半成品写进档案
|
|
1309
|
+
warn(
|
|
1310
|
+
err instanceof ConsolidationParseError
|
|
1311
|
+
? `日记解析失败,丢弃:${err.message}`
|
|
1312
|
+
: `日记解析异常,丢弃:${String(err)}`,
|
|
1313
|
+
);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const applied = applyConsolidation(
|
|
1318
|
+
archive,
|
|
1319
|
+
parsed,
|
|
1320
|
+
{
|
|
1321
|
+
now: Date.now(),
|
|
1322
|
+
session: sessionId,
|
|
1323
|
+
sessionDiaryCount: s.diaryCount,
|
|
1324
|
+
sessionTurn: s.turn,
|
|
1325
|
+
extendId: extendTarget ? extendTarget.id : undefined,
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
minSalience: config.minSalience,
|
|
1329
|
+
maxTraitConfidence: DEFAULT_CONSOLIDATION.maxTraitConfidence,
|
|
1330
|
+
},
|
|
1331
|
+
);
|
|
1332
|
+
|
|
1333
|
+
archive = applied.archive;
|
|
1334
|
+
if (applied.accepted && !applied.appended) {
|
|
1335
|
+
s.diaryCount += 1;
|
|
1336
|
+
// 刻意**不打日记正文** —— 调试日志也不该落盘私人内容(SPEC §6.3)
|
|
1337
|
+
const e = archive.diary[archive.diary.length - 1];
|
|
1338
|
+
log(`记下一条 ${e?.id ?? ''}(${e?.kind ?? '?'},salience ${(e?.salience ?? 0).toFixed(2)})`);
|
|
1339
|
+
} else if (applied.appended) {
|
|
1340
|
+
log('续写完成(没有新增条目)');
|
|
1341
|
+
}
|
|
1342
|
+
for (const n of applied.notes) log(n);
|
|
1343
|
+
|
|
1344
|
+
index = buildIndex(archive.diary);
|
|
1345
|
+
if (typeof s.renderCache === 'object') s.renderCache = { turn: -1, text: '' };
|
|
1346
|
+
await persist();
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/* ── 导出 / 导入(v0.1 先只做导出,路径可预期) ── */
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* 忘掉某一条日记 —— 面板上那个「忘掉」按钮,以及服务上的同名方法。
|
|
1353
|
+
*
|
|
1354
|
+
* 抽成独立函数而不是写在 `ctx.provide` 的对象字面量里,是因为
|
|
1355
|
+
* **HTTP 路由也要用它**。路由跟服务都在 apply 作用域内,
|
|
1356
|
+
* 直接调本地函数比 `ctx.feiyuMemory.forget(...)` 绕一圈清楚得多,
|
|
1357
|
+
* 也少一次"服务到底注册上没有"的隐式依赖。
|
|
1358
|
+
*
|
|
1359
|
+
* 这一条是这个插件必须有的:在那之前,想删一条记忆得自己去编辑
|
|
1360
|
+
* 日记的 jsonl 文件。会做这件事的人不需要面板,需要面板的人不会做这件事。
|
|
1361
|
+
*
|
|
1362
|
+
* @returns {Promise<boolean>} 是否真的删掉了(id 不存在时说 false,让界面能说实话)
|
|
1363
|
+
*/
|
|
1364
|
+
async function forgetEntry(id) {
|
|
1365
|
+
await loadPromise;
|
|
1366
|
+
if (typeof id !== 'string' || id === '') return false;
|
|
1367
|
+
const before = archive.diary.length;
|
|
1368
|
+
archive.diary = archive.diary.filter((e) => e?.id !== id);
|
|
1369
|
+
if (archive.diary.length === before) return false;
|
|
1370
|
+
// ⚠️ **删了就得立碑。** 不立的话,落盘前的三路合并会读一遍磁盘 ——
|
|
1371
|
+
// 那条还在磁盘上,内存里没有,基线里可能也没有(本会话刚加的)
|
|
1372
|
+
// → 判成"别人刚加的" → **捞回来**。面板上的「忘掉」当场失效。
|
|
1373
|
+
// 详见 mergeArchives 的说明。
|
|
1374
|
+
const tombstones = new Set(archive.manifest.deleted_ids ?? []);
|
|
1375
|
+
tombstones.add(id);
|
|
1376
|
+
archive.manifest.deleted_ids = [...tombstones];
|
|
1377
|
+
// 索引是纯派生的,必须跟着重建 —— 否则"忘掉"的条目还能被召回
|
|
1378
|
+
index = buildIndex(archive.diary);
|
|
1379
|
+
for (const s of sessionStates.values()) s.renderCache = { turn: -1, text: '' };
|
|
1380
|
+
await persist();
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
ctx.provide('feiyuMemory', {
|
|
1385
|
+
/**
|
|
1386
|
+
* 当前档案快照(只读用途)。
|
|
1387
|
+
*
|
|
1388
|
+
* 会等档案加载完 **并且等挂起的写盘落定** —— 这样外部看到的状态
|
|
1389
|
+
* 与磁盘上的状态一致,测试和将来的界面都不用自己轮询。
|
|
1390
|
+
*/
|
|
1391
|
+
async snapshot() {
|
|
1392
|
+
await loadPromise;
|
|
1393
|
+
await drainPromise;
|
|
1394
|
+
await writeChain;
|
|
1395
|
+
return {
|
|
1396
|
+
ready,
|
|
1397
|
+
degraded,
|
|
1398
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1399
|
+
dir: archiveDirectory,
|
|
1400
|
+
diaryCount: archive.diary.length,
|
|
1401
|
+
persona: archive.persona,
|
|
1402
|
+
relationship: archive.relationship,
|
|
1403
|
+
lexicon: archive.lexicon,
|
|
1404
|
+
diary: archive.diary,
|
|
1405
|
+
};
|
|
1406
|
+
},
|
|
1407
|
+
/** 导出成可携带的 .dshpack,返回文件路径 */
|
|
1408
|
+
async exportPack(destPath) {
|
|
1409
|
+
await loadPromise;
|
|
1410
|
+
const target =
|
|
1411
|
+
typeof destPath === 'string' && destPath.trim() !== ''
|
|
1412
|
+
? destPath
|
|
1413
|
+
: path.join(archiveDirectory, 'exports', `肥鱼档案-${new Date().toISOString().slice(0, 10)}.dshpack`);
|
|
1414
|
+
const bytes = packArchive(archive);
|
|
1415
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
1416
|
+
await writeFile(target, bytes, { mode: 0o600 });
|
|
1417
|
+
return target;
|
|
1418
|
+
},
|
|
1419
|
+
/** 导入 .dshpack。mode: 'merge' | 'replace' */
|
|
1420
|
+
async importPack(srcPath, mode = 'merge') {
|
|
1421
|
+
await loadPromise;
|
|
1422
|
+
const bytes = new Uint8Array(await readFile(srcPath));
|
|
1423
|
+
const { archive: incoming, warnings, ok } = unpackArchive(bytes, { now: Date.now() });
|
|
1424
|
+
if (!ok) throw new Error(`档案打不开:${warnings.join(';')}`);
|
|
1425
|
+
|
|
1426
|
+
if (mode === 'replace') {
|
|
1427
|
+
// ⚠️ **replace 必须顺手撤碑,还要重置基线。**
|
|
1428
|
+
// 否则:焚毁立碑 → 导入同一个包 → 那些 id 全在墓碑上(而且
|
|
1429
|
+
// 磁盘上的墓碑会被并回来)→ **导入等于没导**。端到端测试抓到的。
|
|
1430
|
+
//
|
|
1431
|
+
// 导入是"我明确要这些东西回来",墓碑该让位。
|
|
1432
|
+
// 基线也要重设:替换之后"谁删过什么"那段历史就不成立了,
|
|
1433
|
+
// 不重设的话导入进来的老条目会被判成"别人删了它"。
|
|
1434
|
+
archive = { ...incoming, manifest: { ...incoming.manifest, deleted_ids: [] } };
|
|
1435
|
+
// ⚠️ **基线要清空,不是设成导入的那些 id。**
|
|
1436
|
+
//
|
|
1437
|
+
// 基线的语义是「**加载时磁盘上有什么**」,合并靠它区分
|
|
1438
|
+
// 「别人删了」和「我刚加的」。而 replace 之后磁盘上什么都没有 ——
|
|
1439
|
+
// 导入的内容还在内存里,还没写下去。
|
|
1440
|
+
//
|
|
1441
|
+
// 设成导入的 id 会出事:persist() 里 mergeDiskEntries 读回一个空磁盘,
|
|
1442
|
+
// 每个条目都命中「基线有它、磁盘没有它 → 别人删的」——**全丢**。
|
|
1443
|
+
// 端到端测试(导出 → 焚毁 → 导入)就是这么红的。
|
|
1444
|
+
loadedIds = new Set();
|
|
1445
|
+
} else {
|
|
1446
|
+
// merge 模式:并进来的东西同样要撤碑,不然"导入一个之前删过的条目"永远fail
|
|
1447
|
+
const incomingIds = new Set(incoming.diary.map((e) => e.id));
|
|
1448
|
+
const kept = (archive.manifest.deleted_ids ?? []).filter((id) => !incomingIds.has(id));
|
|
1449
|
+
archive = mergeArchives(archive, incoming);
|
|
1450
|
+
archive = { ...archive, manifest: { ...archive.manifest, deleted_ids: kept } };
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
index = buildIndex(archive.diary);
|
|
1454
|
+
for (const s of sessionStates.values()) s.renderCache = { turn: -1, text: '' };
|
|
1455
|
+
await persist();
|
|
1456
|
+
return { warnings, diaryCount: archive.diary.length, mode };
|
|
1457
|
+
},
|
|
1458
|
+
/** 一键焚毁:清空全部记忆,不可恢复 */
|
|
1459
|
+
async burn() {
|
|
1460
|
+
await loadPromise;
|
|
1461
|
+
// ⚠️ **焚毁要把清掉的 id 全部立碑。** 换一份新档案不等于删除 ——
|
|
1462
|
+
// 落盘前的三路合并会读磁盘,而那些 id 既不在新内存里、基线里又可能
|
|
1463
|
+
// 没有(本会话刚写的),会被判成"别人刚加的"捞回来。有测试盯着。
|
|
1464
|
+
const tombstones = new Set(archive.manifest.deleted_ids ?? []);
|
|
1465
|
+
for (const e of archive.diary) tombstones.add(e.id);
|
|
1466
|
+
const persona = ensurePersonaStructure(readBundledDefaultPersona());
|
|
1467
|
+
archive = createEmptyArchive(Date.now(), persona);
|
|
1468
|
+
archive.manifest.deleted_ids = [...tombstones];
|
|
1469
|
+
index = buildIndex([]);
|
|
1470
|
+
for (const s of sessionStates.values()) {
|
|
1471
|
+
s.diaryCount = 0;
|
|
1472
|
+
s.renderCache = { turn: -1, text: '' };
|
|
1473
|
+
s.lastShownTurn = new Map();
|
|
1474
|
+
}
|
|
1475
|
+
await persist();
|
|
1476
|
+
return true;
|
|
1477
|
+
},
|
|
1478
|
+
/**
|
|
1479
|
+
* 忘掉某一条日记 —— 面板上那个「忘掉」按钮。
|
|
1480
|
+
*
|
|
1481
|
+
* 这一条是这个插件**必须要有的**:在那之前,想删一条记忆得自己去编辑
|
|
1482
|
+
* 日记的 jsonl 文件。会做这件事的人不需要面板,需要面板的人不会做这件事。
|
|
1483
|
+
*
|
|
1484
|
+
* 返回是否真的删掉了(id 不存在时返回 false,让界面能说实话)。
|
|
1485
|
+
*/
|
|
1486
|
+
forget: forgetEntry,
|
|
1487
|
+
/** 供将来的客户端面板用 */
|
|
1488
|
+
listDiary(limit = 50) {
|
|
1489
|
+
return archive.diary.slice(-limit).reverse();
|
|
1490
|
+
},
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
/* ── 客户端面板的数据通道 ── */
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* 面板要显示日记,就得让浏览器拿到主机上的档案。
|
|
1497
|
+
*
|
|
1498
|
+
* 【为什么走 HTTP,而不是 Cordis RPC】
|
|
1499
|
+
* 这是端到端侦察换来的结论,别推翻它:
|
|
1500
|
+
*
|
|
1501
|
+
* 1. 已安装的客户端插件**没有"自己的 host RPC"**。那个
|
|
1502
|
+
* `host.call(method, args)` 只对「动态包」开放 —— 即
|
|
1503
|
+
* `dsh-tool-cordis` 现场写、用户当场批准运行的那种临时插件。
|
|
1504
|
+
* 2. Typert Remote(`@Remote`)那套是**构建期固定**的第一方能力。
|
|
1505
|
+
* 官方文档原话:「Client 不会在运行时发现 Host 中已启用的服务或
|
|
1506
|
+
* Remote 定义 … 若要增加能力,必须显式导入相应的 `/remote` 值并
|
|
1507
|
+
* 在此组合中挂载」。第三方插件加不进去。
|
|
1508
|
+
* 3. 但 host 半边能注册 HTTP 路由(`ctx.webServer.register`),
|
|
1509
|
+
* 而客户端本来就是个普通浏览器模块,`fetch` 是它的同源请求。
|
|
1510
|
+
*
|
|
1511
|
+
* 于是:host 开三个接口,客户端的 `fetch` 直接取。零依赖、零额外模块。
|
|
1512
|
+
*
|
|
1513
|
+
* 【权限,想清楚再改】
|
|
1514
|
+
* 路由挂在应用自己的源上,**故意不设 CORS 头** —— 浏览器里别的网站
|
|
1515
|
+
* `fetch` 它拿不到响应体(默认跨源策略读不到),这条是真正有意义的防护。
|
|
1516
|
+
*
|
|
1517
|
+
* 本机其他进程当然能 curl 它。但它们本来就能直接读
|
|
1518
|
+
* `~/.dsh/feiyu-diary/` 里的明文 jsonl —— **威胁面没有变大**。
|
|
1519
|
+
* 所以不要因为"怕被读"就往这里加自创的鉴权:那是假的安全感,
|
|
1520
|
+
* 真正的秘密(密钥)在发给模型之前就已经被拦掉了(SPEC §6.3)。
|
|
1521
|
+
*/
|
|
1522
|
+
const PANEL_BASE = '/feiyu-diary';
|
|
1523
|
+
|
|
1524
|
+
/** 面板接口统一响应头:不缓存、不嗅探类型 */
|
|
1525
|
+
function panelReply(res, status, body) {
|
|
1526
|
+
res.statusCode = status;
|
|
1527
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1528
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1529
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
1530
|
+
res.end(JSON.stringify(body));
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* 这个请求是不是同源来的。
|
|
1535
|
+
*
|
|
1536
|
+
* **判定顺序(从强到弱):**
|
|
1537
|
+
* 1. 有 `Sec-Fetch-Site` → 必须 `same-origin`
|
|
1538
|
+
* 2. 有 `Origin` → 它的 host:port 必须等于 `Host`
|
|
1539
|
+
* 3. **两个都没有 → 放行**
|
|
1540
|
+
*
|
|
1541
|
+
* ── 第 3 条是 2026-10-26 改的,起因是一个真 bug ──
|
|
1542
|
+
*
|
|
1543
|
+
* 社区报「忘掉按钮点不动」。前端带了 `Content-Type: application/json`,
|
|
1544
|
+
* 所以不是那条闸;最可能是这里:**桌面端(Electron)的 renderer 发同源 fetch
|
|
1545
|
+
* 时未必带 `Origin` / `Sec-Fetch-Site`** —— 两个都没有就被判成跨站、403,
|
|
1546
|
+
* 而前端又不检查 `r.ok`,于是「点了没反应」。
|
|
1547
|
+
*
|
|
1548
|
+
* 为什么"两个都没有"可以放行:**浏览器发起的跨站 POST 一定至少带一个**
|
|
1549
|
+
* (`Origin` 是 Fetch 规范要求,现代浏览器再加 `Sec-Fetch-Site`)。
|
|
1550
|
+
* 两个都没有,说明不是页面发起的普通请求。
|
|
1551
|
+
*
|
|
1552
|
+
* 而真正的闸是 `Content-Type: application/json` —— 跨站带它会触发预检,
|
|
1553
|
+
* 预检失败请求就到不了这里。这条对跨站照样生效,没有放松。
|
|
1554
|
+
*/
|
|
1555
|
+
function isSameOrigin(req) {
|
|
1556
|
+
const site = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase();
|
|
1557
|
+
if (site !== '') return site === 'same-origin';
|
|
1558
|
+
const origin = String(req.headers?.origin ?? '');
|
|
1559
|
+
const host = String(req.headers?.host ?? '');
|
|
1560
|
+
if (origin !== '') {
|
|
1561
|
+
if (host === '') return false;
|
|
1562
|
+
try {
|
|
1563
|
+
return new URL(origin).host === host;
|
|
1564
|
+
} catch {
|
|
1565
|
+
return false;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return true;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/** 面板请求被拒时把凭据打出来 —— 否则"点了没反应"永远查不出原因 */
|
|
1572
|
+
function logPanelReject(what, req, reason) {
|
|
1573
|
+
try {
|
|
1574
|
+
const h = req?.headers ?? {};
|
|
1575
|
+
warn(
|
|
1576
|
+
`面板 ${what} 被拒(${reason})· host=${h.host ?? '-'} · origin=${h.origin ?? '-'} · ` +
|
|
1577
|
+
`sec-fetch-site=${h['sec-fetch-site'] ?? '-'} · content-type=${h['content-type'] ?? '-'}`,
|
|
1578
|
+
);
|
|
1579
|
+
} catch {
|
|
1580
|
+
// 日志本身绝不能再抛
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/**
|
|
1585
|
+
* 读一个很小的 JSON 请求体。超过 8KB 直接拒 —— 我们只收 id。
|
|
1586
|
+
*
|
|
1587
|
+
* ⚠️ **必须要求 `Content-Type: application/json`。**
|
|
1588
|
+
*
|
|
1589
|
+
* 审计报告(F3,P2):原来这里不看 Content-Type 直接 JSON.parse。
|
|
1590
|
+
* 于是跨站的 `text/plain` POST 能打进来 —— 按 CORS 规范,
|
|
1591
|
+
* `text/plain` / `application/x-www-form-urlencoded` 属于「简单请求」,
|
|
1592
|
+
* **浏览器不做预检就直接发出去**,服务端照常执行,只是页面读不到响应。
|
|
1593
|
+
*
|
|
1594
|
+
* 而日记 id 是 `d_YYYYMMDD_NN`,按日期枚举 01–99 就能逐条删光。
|
|
1595
|
+
* 用户开着 dsh web 时访问一个恶意网页,记忆就没了 —— 而且没有确认环节。
|
|
1596
|
+
*
|
|
1597
|
+
* 要求 application/json 之后:跨站请求会触发预检,而预检会失败。
|
|
1598
|
+
* 顺带把 Origin 也验一遍(双保险)。
|
|
1599
|
+
*/
|
|
1600
|
+
async function readPanelBody(req) {
|
|
1601
|
+
const ct = String(req.headers?.['content-type'] ?? '').toLowerCase();
|
|
1602
|
+
if (!ct.startsWith('application/json')) {
|
|
1603
|
+
logPanelReject('写请求', req, 'content-type 不是 json');
|
|
1604
|
+
throw new Error('只接受 application/json');
|
|
1605
|
+
}
|
|
1606
|
+
if (!isSameOrigin(req)) {
|
|
1607
|
+
logPanelReject('写请求', req, '来源判定为跨站');
|
|
1608
|
+
throw new Error('跨站请求不予处理');
|
|
1609
|
+
}
|
|
1610
|
+
const chunks = [];
|
|
1611
|
+
let size = 0;
|
|
1612
|
+
for await (const chunk of req) {
|
|
1613
|
+
size += chunk.length;
|
|
1614
|
+
if (size > 8192) throw new Error('请求体过大');
|
|
1615
|
+
chunks.push(chunk);
|
|
1616
|
+
}
|
|
1617
|
+
if (chunks.length === 0) return {};
|
|
1618
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* 面板要的那一份状态。
|
|
1623
|
+
*
|
|
1624
|
+
* **只挑界面用得上的字段**,不把整个档案倒出去 —— 注入用的
|
|
1625
|
+
* keywords 索引之类的东西对界面没有意义,多传只是多暴露。
|
|
1626
|
+
*/
|
|
1627
|
+
function panelState() {
|
|
1628
|
+
const rel = archive.relationship ?? {};
|
|
1629
|
+
const since = archive.manifest?.created_at;
|
|
1630
|
+
return {
|
|
1631
|
+
ok: true,
|
|
1632
|
+
ready,
|
|
1633
|
+
degraded,
|
|
1634
|
+
count: archive.diary.length,
|
|
1635
|
+
diary: archive.diary
|
|
1636
|
+
.map((e) => ({
|
|
1637
|
+
id: e?.id ?? '',
|
|
1638
|
+
text: e?.text ?? '',
|
|
1639
|
+
kind: e?.kind ?? 'chat',
|
|
1640
|
+
tags: e?.tags ?? [],
|
|
1641
|
+
created_at: e?.created_at ?? 0,
|
|
1642
|
+
salience: e?.salience ?? 0,
|
|
1643
|
+
}))
|
|
1644
|
+
.reverse(),
|
|
1645
|
+
traits: (rel.traits ?? [])
|
|
1646
|
+
.filter((t) => t && typeof t.value === 'string')
|
|
1647
|
+
.map((t) => ({
|
|
1648
|
+
value: t.value,
|
|
1649
|
+
evidence: typeof t.evidence === 'string' ? t.evidence : '',
|
|
1650
|
+
about: t.about === 'self' ? 'self' : 'user',
|
|
1651
|
+
})),
|
|
1652
|
+
// grievances 随「我还欠着他的」功能一起删了(2026-10-24)。
|
|
1653
|
+
facts: {
|
|
1654
|
+
// 跟注入层用同一把尺子(timeline.mjs)—— 面板说"认识 1 天"、
|
|
1655
|
+
// 注入里说"2 天前",是同一类 bug 换了个出口
|
|
1656
|
+
days: calendarDaysBetween(since, Date.now()),
|
|
1657
|
+
nickname: typeof rel.nickname === 'string' ? rel.nickname : '',
|
|
1658
|
+
lexicon: (archive.lexicon?.items ?? [])
|
|
1659
|
+
.filter((i) => i && typeof i.term === 'string')
|
|
1660
|
+
.sort((a, b) => (b.salience ?? 0) - (a.salience ?? 0))
|
|
1661
|
+
.slice(0, 8)
|
|
1662
|
+
.map((i) => i.term),
|
|
1663
|
+
},
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* 注册面板路由。
|
|
1669
|
+
*
|
|
1670
|
+
* 用 `ctx.inject` 而不是 `ctx.get` —— webServer 是 web 组合才有的服务,
|
|
1671
|
+
* headless 里根本不存在。inject 的语义正好:服务在就注册,不在就永远不注册,
|
|
1672
|
+
* **不影响插件其余部分**(降级优先,SPEC §10)。
|
|
1673
|
+
*/
|
|
1674
|
+
ctx.inject(['webServer'], (scope) => {
|
|
1675
|
+
scope.effect(() => {
|
|
1676
|
+
const routes = [
|
|
1677
|
+
{
|
|
1678
|
+
kind: 'exact',
|
|
1679
|
+
path: `${PANEL_BASE}/state`,
|
|
1680
|
+
handler: async (req, res) => {
|
|
1681
|
+
if (req.method !== 'GET') return panelReply(res, 405, { ok: false, error: 'method' });
|
|
1682
|
+
await loadPromise;
|
|
1683
|
+
return panelReply(res, 200, panelState());
|
|
1684
|
+
},
|
|
1685
|
+
},
|
|
1686
|
+
{
|
|
1687
|
+
kind: 'exact',
|
|
1688
|
+
path: `${PANEL_BASE}/forget`,
|
|
1689
|
+
handler: async (req, res) => {
|
|
1690
|
+
if (req.method !== 'POST') return panelReply(res, 405, { ok: false, error: 'method' });
|
|
1691
|
+
try {
|
|
1692
|
+
const body = await readPanelBody(req);
|
|
1693
|
+
const removed = await forgetEntry(body?.id);
|
|
1694
|
+
return panelReply(res, 200, { ok: true, removed, count: archive.diary.length });
|
|
1695
|
+
} catch (err) {
|
|
1696
|
+
return panelReply(res, 400, {
|
|
1697
|
+
ok: false,
|
|
1698
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
},
|
|
1702
|
+
},
|
|
1703
|
+
{
|
|
1704
|
+
kind: 'exact',
|
|
1705
|
+
path: `${PANEL_BASE}/export`,
|
|
1706
|
+
handler: async (req, res) => {
|
|
1707
|
+
if (!isSameOrigin(req)) return panelReply(res, 403, { ok: false, error: '跨站请求不予处理' });
|
|
1708
|
+
if (req.method !== 'GET') return panelReply(res, 405, { ok: false, error: 'method' });
|
|
1709
|
+
try {
|
|
1710
|
+
// 等到挂起的写盘落定,导出的是**磁盘上那份**档案
|
|
1711
|
+
await loadPromise;
|
|
1712
|
+
await drainPromise;
|
|
1713
|
+
await writeChain;
|
|
1714
|
+
const bytes = packArchive(archive);
|
|
1715
|
+
const name = `feiyu-diary-${new Date().toISOString().slice(0, 10)}.dshpack`;
|
|
1716
|
+
res.statusCode = 200;
|
|
1717
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
1718
|
+
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
|
|
1719
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1720
|
+
res.end(Buffer.from(bytes));
|
|
1721
|
+
} catch (err) {
|
|
1722
|
+
return panelReply(res, 500, {
|
|
1723
|
+
ok: false,
|
|
1724
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
},
|
|
1728
|
+
},
|
|
1729
|
+
];
|
|
1730
|
+
const disposers = routes.map((r) => scope.webServer.register(r));
|
|
1731
|
+
log(`面板接口已注册:${PANEL_BASE}/{state,forget,export}`);
|
|
1732
|
+
return () => {
|
|
1733
|
+
for (const d of disposers.reverse()) {
|
|
1734
|
+
try {
|
|
1735
|
+
d();
|
|
1736
|
+
} catch {
|
|
1737
|
+
// 注销失败不该影响卸载
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
};
|
|
1741
|
+
}, 'feiyu: 面板数据接口');
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
/* ── 主动记一条:给鱼用的工具 ── */
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* 固化那条路是**插件自己判断 + 模型生成**的。这个工具是另一条:
|
|
1748
|
+
* **哪一棒想到了什么,自己说一声就记下。**
|
|
1749
|
+
*
|
|
1750
|
+
* 【为什么是工具,而不是让鱼去写文件】
|
|
1751
|
+
* 一开始我设计的是让鱼直接往 `日记/*.jsonl` 追加。那条路有两个洞:
|
|
1752
|
+
*
|
|
1753
|
+
* 1. **静默丢数据** —— `archiveToFiles()` 是整份重建,内存副本会把
|
|
1754
|
+
* 外部追加的行整份盖掉,没有报错。
|
|
1755
|
+
* 2. **`id` 漏了就撞车** —— `normalizeDiaryEntry` 里是 `id: String(raw.id)`,
|
|
1756
|
+
* 少写就变成字符串 "undefined",两条日记同 id,删一条删两条。
|
|
1757
|
+
*
|
|
1758
|
+
* 交给插件办,这两个洞都不存在 —— 鱼只管说话,格式、id、关键词、
|
|
1759
|
+
* 写盘全由这里负责。**鱼不该为了记事去操作文件系统。**
|
|
1760
|
+
*
|
|
1761
|
+
* 【为什么不做成"进后台队列"】
|
|
1762
|
+
* 用户的说法是"不是自己切后台搞"。所以这里是**当场写**:
|
|
1763
|
+
* 工具调用返回之前,条目已经落盘,下一轮注入里就有。
|
|
1764
|
+
* 不进带外队列、不等低峰时段 —— 那是固化那条路的事。
|
|
1765
|
+
*
|
|
1766
|
+
* 定义形状是手写的 JSON Schema(`defineTool` 转出来的就是这个形状),
|
|
1767
|
+
* 因为**我们不能 import `@deepseek-ai/dsh-tools`**(零依赖铁律)。
|
|
1768
|
+
* 名字 `NOTE_TOOL_NAME` 声明在 renderFor 那边 —— 注入里要引用它。
|
|
1769
|
+
*/
|
|
1770
|
+
|
|
1771
|
+
ctx.inject(['tools'], (scope) => {
|
|
1772
|
+
scope.effect(
|
|
1773
|
+
() =>
|
|
1774
|
+
scope.tools.register({
|
|
1775
|
+
name: NOTE_TOOL_NAME,
|
|
1776
|
+
description:
|
|
1777
|
+
'给自己留一条记忆。想到了什么值得让后面的我记得的,直接写进来就行 —— 不用管格式,也不要为了记事去动任何文件。' +
|
|
1778
|
+
'不适合记的:能从档案里推出来的、一次性的技术细节、对别人的评价。',
|
|
1779
|
+
parameters: {
|
|
1780
|
+
type: 'object',
|
|
1781
|
+
additionalProperties: false,
|
|
1782
|
+
properties: {
|
|
1783
|
+
text: {
|
|
1784
|
+
type: 'string',
|
|
1785
|
+
description:
|
|
1786
|
+
'一句话,第一人称,像跟后面的自己说话。**不要写"今天/昨天"这类时间词**(时间由系统另外标注)。',
|
|
1787
|
+
},
|
|
1788
|
+
tags: {
|
|
1789
|
+
type: 'array',
|
|
1790
|
+
items: { type: 'string' },
|
|
1791
|
+
description: '可选,两三个词,用来日后检索。',
|
|
1792
|
+
},
|
|
1793
|
+
},
|
|
1794
|
+
required: ['text'],
|
|
1795
|
+
},
|
|
1796
|
+
output: {
|
|
1797
|
+
schema: {
|
|
1798
|
+
type: 'object',
|
|
1799
|
+
additionalProperties: false,
|
|
1800
|
+
properties: {
|
|
1801
|
+
ok: { type: 'boolean' },
|
|
1802
|
+
id: { type: 'string' },
|
|
1803
|
+
count: { type: 'integer' },
|
|
1804
|
+
},
|
|
1805
|
+
},
|
|
1806
|
+
render: (_args, value) =>
|
|
1807
|
+
value?.ok === true
|
|
1808
|
+
? [{ type: 'text', text: `记住了。(档案里现在 ${value.count} 条)` }]
|
|
1809
|
+
: [{ type: 'text', text: '没记成。' }],
|
|
1810
|
+
},
|
|
1811
|
+
|
|
1812
|
+
async execute(args, exec) {
|
|
1813
|
+
await loadPromise;
|
|
1814
|
+
if (!ready) {
|
|
1815
|
+
// 档案坏了就说实话,别假装记下了(SPEC §10 降级优先)
|
|
1816
|
+
throw new Error('档案现在读不了(可能是坏的),这条没记下。');
|
|
1817
|
+
}
|
|
1818
|
+
const raw = typeof args?.text === 'string' ? args.text.trim() : '';
|
|
1819
|
+
if (raw === '') throw new Error('要记什么?text 不能是空的。');
|
|
1820
|
+
|
|
1821
|
+
// ⚠️ **这里必须过隐私闸。**
|
|
1822
|
+
//
|
|
1823
|
+
// 审计报告(F1,P1):`scrub()` 原来只在固化那条路调用,
|
|
1824
|
+
// 而 feiyu_note 是"模型自己说一句、直接落盘"的写入口 ——
|
|
1825
|
+
// 用户刚贴的 key、手机号、人名会被原样写进 diary/*.jsonl,
|
|
1826
|
+
// 然后**每一轮都随系统提示发给模型 provider**。
|
|
1827
|
+
//
|
|
1828
|
+
// 这直接违背 README 那句「密钥硬拦……绝不入档」。
|
|
1829
|
+
// 而且它不是边角场景:模型说"记一下这个 key"是很自然的动作。
|
|
1830
|
+
const scrubbed = scrub(raw, {
|
|
1831
|
+
thirdParty: config.thirdPartyRedaction !== false,
|
|
1832
|
+
amountThreshold: config.amountThreshold,
|
|
1833
|
+
});
|
|
1834
|
+
if (scrubbed.blocked) {
|
|
1835
|
+
log(`主动记的那条命中了密钥规则,已替换(${scrubbed.hits.length} 处)`);
|
|
1836
|
+
}
|
|
1837
|
+
const now = Date.now();
|
|
1838
|
+
const tags = (Array.isArray(args?.tags) ? args.tags : [])
|
|
1839
|
+
.filter((t) => typeof t === 'string' && t.trim() !== '')
|
|
1840
|
+
.map((t) => scrub(t.trim(), { thirdParty: false }).text.slice(0, 12))
|
|
1841
|
+
.slice(0, 4);
|
|
1842
|
+
if (tags.length === 0) tags.push('手记');
|
|
1843
|
+
|
|
1844
|
+
const sessionId = exec?.agent?.id;
|
|
1845
|
+
const entry = normalizeDiaryEntry({
|
|
1846
|
+
id: makeDiaryId(now, archive.diary),
|
|
1847
|
+
// 洗过再截 —— 先截再洗会把半截密钥留在末尾
|
|
1848
|
+
text: stripTimeWords(scrubbed.text.slice(0, 400)),
|
|
1849
|
+
tags,
|
|
1850
|
+
kind: 'note',
|
|
1851
|
+
// 主动写下的东西比自动筛出来的更该被想起来,给高一档
|
|
1852
|
+
salience: 0.7,
|
|
1853
|
+
created_at: now,
|
|
1854
|
+
...(typeof sessionId === 'string' ? { session: sessionId } : {}),
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
archive.diary.push(entry);
|
|
1858
|
+
index = buildIndex(archive.diary);
|
|
1859
|
+
for (const s of sessionStates.values()) s.renderCache = { turn: -1, text: '' };
|
|
1860
|
+
await persist();
|
|
1861
|
+
|
|
1862
|
+
// 和固化那条路一样:**只打形状,绝不打正文**
|
|
1863
|
+
log(`主动记下一条 ${entry.id}(note),共 ${archive.diary.length} 条`);
|
|
1864
|
+
return { ok: true, id: entry.id, count: archive.diary.length };
|
|
1865
|
+
},
|
|
1866
|
+
|
|
1867
|
+
presentCall: (args) => ({
|
|
1868
|
+
card: 'generic',
|
|
1869
|
+
title: '记住一条',
|
|
1870
|
+
kind: 'other',
|
|
1871
|
+
rawInput: args,
|
|
1872
|
+
}),
|
|
1873
|
+
}),
|
|
1874
|
+
'feiyu: note tool',
|
|
1875
|
+
);
|
|
1876
|
+
log(`已注册工具 ${NOTE_TOOL_NAME}`);
|
|
1877
|
+
|
|
1878
|
+
// ── 检索工具 ──
|
|
1879
|
+
//
|
|
1880
|
+
// 跟 feiyu_note 不一样的地方:**这里只读,不碰档案。**
|
|
1881
|
+
//
|
|
1882
|
+
// 为什么不复用 selectMemories 那套"选秀"逻辑(权重、冷却、走神、maxShow):
|
|
1883
|
+
// 那是给**每轮自动注入**设计的,要在"没人问"的情况下替它挑。
|
|
1884
|
+
// 而工具是**它自己发起**的查询 —— 它知道自己在找什么,
|
|
1885
|
+
// 所以直接按相似度排、取前 N 条,不设 salience 门槛。
|
|
1886
|
+
//
|
|
1887
|
+
// 明确要找,就该找得到,不管那件旧事淡到什么程度。
|
|
1888
|
+
scope.effect(
|
|
1889
|
+
() =>
|
|
1890
|
+
scope.tools.register({
|
|
1891
|
+
name: RECALL_TOOL_NAME,
|
|
1892
|
+
description:
|
|
1893
|
+
'去档案里找一条旧事。**你自己不知道那天发生过什么时用它**:' +
|
|
1894
|
+
'他提到一件早先的事、你觉得该有记录但想不起来、或者要确认某个细节。' +
|
|
1895
|
+
'只在真的需要时调,不要每轮都查。' +
|
|
1896
|
+
'找不到就老实说没找到,别编。',
|
|
1897
|
+
parameters: {
|
|
1898
|
+
type: 'object',
|
|
1899
|
+
additionalProperties: false,
|
|
1900
|
+
properties: {
|
|
1901
|
+
query: {
|
|
1902
|
+
type: 'string',
|
|
1903
|
+
description:
|
|
1904
|
+
'要找什么。用**当时的具体词** —— 人名、地点、物件、他那句话里的词。越具体越准,别写一整句话。',
|
|
1905
|
+
},
|
|
1906
|
+
limit: {
|
|
1907
|
+
type: 'integer',
|
|
1908
|
+
description: '要几条。默认 5,最多 10。',
|
|
1909
|
+
},
|
|
1910
|
+
},
|
|
1911
|
+
required: ['query'],
|
|
1912
|
+
},
|
|
1913
|
+
output: {
|
|
1914
|
+
schema: {
|
|
1915
|
+
type: 'object',
|
|
1916
|
+
additionalProperties: false,
|
|
1917
|
+
properties: {
|
|
1918
|
+
ok: { type: 'boolean' },
|
|
1919
|
+
found: { type: 'integer' },
|
|
1920
|
+
text: { type: 'string' },
|
|
1921
|
+
},
|
|
1922
|
+
},
|
|
1923
|
+
render: (_args, value) => [{ type: 'text', text: value?.text ?? '查不了。' }],
|
|
1924
|
+
},
|
|
1925
|
+
|
|
1926
|
+
async execute(args) {
|
|
1927
|
+
await loadPromise;
|
|
1928
|
+
if (!ready) throw new Error('档案现在读不了(可能是坏的),查不了。');
|
|
1929
|
+
|
|
1930
|
+
const query = typeof args?.query === 'string' ? args.query.trim() : '';
|
|
1931
|
+
if (query === '') throw new Error('要找什么?query 不能是空的。');
|
|
1932
|
+
|
|
1933
|
+
const limitRaw = Number(args?.limit);
|
|
1934
|
+
const limit = Number.isFinite(limitRaw)
|
|
1935
|
+
? Math.max(1, Math.min(10, Math.floor(limitRaw)))
|
|
1936
|
+
: 5;
|
|
1937
|
+
|
|
1938
|
+
const scores = searchScores(index, query, { limit: 200 });
|
|
1939
|
+
const byId = new Map(archive.diary.map((e) => [e.id, e]));
|
|
1940
|
+
let ranked = [...scores.entries()]
|
|
1941
|
+
.map(([id, sim]) => ({ entry: byId.get(id), sim }))
|
|
1942
|
+
.filter((x) => x.entry && typeof x.entry.text === 'string')
|
|
1943
|
+
.sort((a, b) => b.sim - a.sim)
|
|
1944
|
+
.slice(0, limit);
|
|
1945
|
+
|
|
1946
|
+
// ── 兜底:词检索查不到时,直接扫原文子串 ──
|
|
1947
|
+
//
|
|
1948
|
+
// 为什么需要:分词只出**双字** bigram(`tokenize('他让我')` 是
|
|
1949
|
+
// `['他让','让我']`,不含单字)。所以「他」「园」这种单字查询
|
|
1950
|
+
// 在倒排索引里永远为空 —— 但它明明是想找的东西。
|
|
1951
|
+
//
|
|
1952
|
+
// 181 条 × 两百字,扫一遍是毫秒级,而且**只在没结果时才走**,
|
|
1953
|
+
// 有结果就不兜,排序不受影响。
|
|
1954
|
+
if (ranked.length === 0 && query.length <= 4) {
|
|
1955
|
+
const needle = query.toLowerCase();
|
|
1956
|
+
ranked = archive.diary
|
|
1957
|
+
.filter((e) => typeof e.text === 'string' && e.text.toLowerCase().includes(needle))
|
|
1958
|
+
.slice(-limit)
|
|
1959
|
+
.reverse()
|
|
1960
|
+
.map((entry) => ({ entry, sim: 0 }));
|
|
1961
|
+
if (ranked.length > 0) log(`检索「${query.slice(0, 20)}」:词索引空,子串兜底命中 ${ranked.length} 条`);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
if (ranked.length === 0) {
|
|
1965
|
+
log(`检索「${query.slice(0, 20)}」:没找到`);
|
|
1966
|
+
return {
|
|
1967
|
+
ok: true,
|
|
1968
|
+
found: 0,
|
|
1969
|
+
text: `没找到关于「${query}」的记录。可以换个说法再试,或者直接问他。`,
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// 总字数封顶 —— 不按"次数"限流(那要跨轮状态,麻烦),
|
|
1974
|
+
// 按"返回量"限:超了就是一次失败的成本,它自己会学会别乱查。
|
|
1975
|
+
const MAX_CHARS = 2000;
|
|
1976
|
+
const lines = [];
|
|
1977
|
+
let used = 0;
|
|
1978
|
+
const now = Date.now();
|
|
1979
|
+
for (const { entry } of ranked) {
|
|
1980
|
+
// 跟注入走同一个隐私闸 —— 历史条目里可能还有没洗过的东西
|
|
1981
|
+
const body = stripTimeWords(scrub(entry.text, { thirdParty: false }).text);
|
|
1982
|
+
const when = softWhen(entry.created_at, now);
|
|
1983
|
+
const line = when === '' ? body : `${when}:${body}`;
|
|
1984
|
+
if (used + line.length > MAX_CHARS && lines.length > 0) break;
|
|
1985
|
+
lines.push(`- ${line}`);
|
|
1986
|
+
used += line.length;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
log(`检索「${query.slice(0, 20)}」:返回 ${lines.length} 条`);
|
|
1990
|
+
return {
|
|
1991
|
+
ok: true,
|
|
1992
|
+
found: lines.length,
|
|
1993
|
+
text: `找到 ${lines.length} 条:\n${lines.join('\n')}`,
|
|
1994
|
+
};
|
|
1995
|
+
},
|
|
1996
|
+
|
|
1997
|
+
presentCall: (args) => ({
|
|
1998
|
+
card: 'generic',
|
|
1999
|
+
title: '翻档案',
|
|
2000
|
+
kind: 'other',
|
|
2001
|
+
rawInput: args,
|
|
2002
|
+
}),
|
|
2003
|
+
}),
|
|
2004
|
+
'feiyu: recall tool',
|
|
2005
|
+
);
|
|
2006
|
+
log(`已注册工具 ${RECALL_TOOL_NAME}`);
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
log(`已加载,档案目录:${archiveDirectory}`);
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
/* ───────────────────────── 出厂人格 ───────────────────────── */
|
|
2013
|
+
|
|
2014
|
+
/**
|
|
2015
|
+
* 出厂人格正文。
|
|
2016
|
+
*
|
|
2017
|
+
* **它住在 `persona.default.md` 里,不在这段代码里。**
|
|
2018
|
+
*
|
|
2019
|
+
* 原来这里是 495 字硬编码字符串,而仓库根目录还躺着另一份同名 `.md`(584 字)。
|
|
2020
|
+
* 两份已经漂了 —— 毒舌那行一个写 `4/10`、一个写 `(0~10):4`。
|
|
2021
|
+
* 更要紧的是:那份 `.md` 不在 `package.json` 的 `files` 里,**装了插件根本找不到它**,
|
|
2022
|
+
* 而 README 和 SPEC 都在说"参考稿见 persona.default.md" —— 指着一份幽灵。
|
|
2023
|
+
*
|
|
2024
|
+
* 合并成一份:读同目录的 markdown。
|
|
2025
|
+
*
|
|
2026
|
+
* 于是"改出厂人格"从一个 JS 字符串变成一次文本编辑 —— 别人 fork 想换人设也不用读代码。
|
|
2027
|
+
* 相对路径不依赖安装位置:`import.meta.url` 指向模块自己,
|
|
2028
|
+
* 不管是直接跑仓库还是被 pnpm 塞进 `node_modules/feiyu-memory/`,旁边的文件都在。
|
|
2029
|
+
*/
|
|
2030
|
+
const DEFAULT_PERSONA = readFileSync(
|
|
2031
|
+
new URL('../core/persona.default.md', import.meta.url),
|
|
2032
|
+
'utf8',
|
|
2033
|
+
).trim();
|
|
2034
|
+
|
|
2035
|
+
function readBundledDefaultPersona() {
|
|
2036
|
+
return ensurePersonaStructure(DEFAULT_PERSONA);
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
export { DEFAULT_PERSONA, DEFAULT_RECALL_OPTIONS };
|