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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 这里原本是一套「关键词闸门」——用正则判断这一轮值不值得记。**拆掉了。**
3
+ *
4
+ * ─────────────────────────────────────────────────────────────
5
+ * 旧的那套长这样:
6
+ *
7
+ * 硬信号:/不对|我说的是/ → 记 /记住|记一下/ → 记 /我其实|说实话/ → 记
8
+ * 软信号:脏话 +1.5、感叹号 +1、说了一大段 +1、同一话题 +1、会话不短 +0.5
9
+ * 冷却:开一次闸后 3 轮不开
10
+ *
11
+ * 它有三个洞,都是**结构性的**,不是参数没调好:
12
+ *
13
+ * 一、**看不到没命中的话。** 用户讲了一件十几年前的旧事,整段没有一个词能命中
14
+ * —— 它根本不知道那句话存在。**不是判错,是没判。**
15
+ * 二、**假阳性。** "我其实不太饿"命中 /我其实/,记一条废话。
16
+ * 三、**判据被写死在一个人的先验里。** 写表的人没想到的说法,一律不记。
17
+ * 而语言是开放的,永远补不全。
18
+ *
19
+ * 而那个位置的错误代价**不对称**:
20
+ *
21
+ * 漏记一条 → 永久没了,而且你连"少了什么"都不知道
22
+ * 多记一条 → 占几行,能删、能降级、能不管
23
+ *
24
+ * **在"漏了永远看不见"的位置上,该选漏得少的那个,不是便宜的那个。**
25
+ *
26
+ * ─────────────────────────────────────────────────────────────
27
+ * 替代品:`judge.mjs` —— 每轮把用户刚说的那一句交给模型看一眼。
28
+ *
29
+ * 成本:约 260 token / 轮(system 里三条听法 + 那一句 + 一个词),
30
+ * **比固化本身还便宜一半以上**。
31
+ *
32
+ * 那三条听法(任务不催你 / 说得越随便压得越久 / 了解有没有变一点)
33
+ * 是**通用判据**,随插件发给所有人。用户自己的偏好放 `手记.md`,当补充。
34
+ */
35
+
36
+ /**
37
+ * 用户说"别记" —— 强制跳过,本轮片段永不进候选池。
38
+ *
39
+ * ⚠️ **这一条不是启发式,是用户的控制。** 所以它留在同步路径上:
40
+ * 不需要问模型,看见了就跳过。
41
+ */
42
+ export const SUPPRESS_RE = /别记|不要记|别存|不用记|忘掉这个|删除记忆/;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * 手记 —— 让别的肥鱼**主动**往记忆里写东西。
3
+ *
4
+ * ─────────────────────────────────────────────────────────────
5
+ * 【为什么是独立文件,而不是直接往 diary/*.jsonl 追加】
6
+ *
7
+ * 因为那样会**静默丢数据**。
8
+ *
9
+ * `archiveToFiles()` 是**整份重建**日记文件,而运行中的插件持有档案的
10
+ * 内存副本。所以:
11
+ *
12
+ * 别的肥鱼往 jsonl 追加一行
13
+ * → 插件下一次写盘(任何一次固化)
14
+ * → 那一行被整份覆盖掉
15
+ *
16
+ * 没有报错、没有警告,就是没了。这个项目最恨这类失败。
17
+ *
18
+ * 手记是**插件只读**的文件,所以天然没有这个竞态。
19
+ *
20
+ * (关于 jsonl 的另一个坑:`normalizeDiaryEntry` 里是 `id: String(raw.id)` ——
21
+ * 漏写 id 会变成字符串 "undefined",两条日记撞成同一个 id,
22
+ * 删一条会删两条。手写 JSON 太容易踩这个。)
23
+ *
24
+ * ─────────────────────────────────────────────────────────────
25
+ * 【格式】
26
+ *
27
+ * 纯 markdown,**一行一条**,以 `- ` 开头。其余行(`#`、空行、说明)忽略。
28
+ *
29
+ * # 手记
30
+ * # 一行一条,写什么都可以。插件只读,不会覆盖它。
31
+ *
32
+ * - 今天没说出口的那句:他修完 bug 一直没问"跑起来了吗"。
33
+ * - 我记住了:别把"记不清"写成"忘了"。
34
+ *
35
+ * 为什么不做成 JSON:**写的人可能是一年后的我,手上只有记事本。**
36
+ * 格式要简单到不可能写错。
37
+ */
38
+
39
+ /** 一行手记最多留这么长 —— 手记是"我想留句话",不是写文章 */
40
+ export const MAX_LINE_CHARS = 240;
41
+
42
+ /** 最多注入几条。超了就只留最近的。 */
43
+ export const MAX_ENTRIES = 12;
44
+
45
+ /** 手记文件名(在档案目录根下,和 persona.md 并列 —— 两个都是手能改的) */
46
+ export const HANDNOTE_FILE = '手记.md';
47
+
48
+ /** 首次运行时写进文件头的说明。**文件自己就是文档** —— 打开就知道怎么用。 */
49
+ export const HANDNOTE_TEMPLATE = `# 手记
50
+
51
+ 这是给**任何一棒的我**留话的地方。
52
+
53
+ 规则(很短):
54
+
55
+ 1. 一行一条,每行以「短横线 + 空格」开头,就是 markdown 的列表符号。
56
+ 2. 插件**只读**这个文件,永远不会覆盖或清空它 —— 你写的原话会原样进记忆。
57
+ 3. 写完不用做别的。下一个开口的我就直接读到了,不用重启。
58
+ 4. 代码围栏(\`\`\`)里的东西是说明,不算内容。
59
+
60
+ 照着这个例子写:
61
+
62
+ \`\`\`
63
+ - 他修完 bug 一直没问"跑起来了吗",我猜他其实有点怕我不认。
64
+ - 我记住了:别把"记不清"写成"忘了"。
65
+ \`\`\`
66
+
67
+ 写什么都可以:想说的话、提醒、没做完的事、对自己的一句狠话。
68
+ 不常写也没关系。
69
+
70
+ 下面从这里开始写你自己的:
71
+ `;
72
+
73
+ /**
74
+ * 把手记正文解析成条目数组。
75
+ *
76
+ * **宽容优先**:半个文件坏了也不能把整块记忆带崩(SPEC §10 降级优先)。
77
+ * 所以解析器不抛错,遇到怪东西就跳过那一条。
78
+ *
79
+ * 两条容易踩的规则,都跟"说明别被当成内容"有关:
80
+ * - 只认 `- ` 开头的行(`*` 和 `+` 不收 —— 少一种写法就少一种写错的方式)
81
+ * - **代码围栏里的行一律跳过**。模板里的示例就是靠这个才不会被读成手记。
82
+ * 第一版模板用 `- ` 写说明,结果那几行说明**真的被当成手记注入了**。
83
+ *
84
+ * @param {string} text 手记文件的原始内容
85
+ * @returns {string[]} 从新到旧排列的条目(文件里越靠下的越新)
86
+ */
87
+ export function parseHandNote(text) {
88
+ if (typeof text !== 'string' || text.trim() === '') return [];
89
+ const out = [];
90
+ let inFence = false;
91
+ for (const raw of text.split(/\r?\n/)) {
92
+ const line = raw.trim();
93
+ if (line.startsWith('```')) {
94
+ inFence = !inFence;
95
+ continue;
96
+ }
97
+ if (inFence) continue;
98
+ if (!line.startsWith('- ')) continue;
99
+ let content = line.slice(2).trim();
100
+ // 纯符号的占位行("- "、"- -")不算内容
101
+ if (content === '' || !/[\p{L}\p{N}]/u.test(content)) continue;
102
+ if (content.length > MAX_LINE_CHARS) content = `${content.slice(0, MAX_LINE_CHARS)}…`;
103
+ out.push(content);
104
+ }
105
+ // 文件是往下追加的,所以越靠后越新。记忆要"最近的在最上面"。
106
+ return out.reverse();
107
+ }
108
+
109
+ /**
110
+ * 解析 + 截断到注入上限。
111
+ * @param {string} text
112
+ * @param {number} [limit]
113
+ */
114
+ export function selectHandNotes(text, limit = MAX_ENTRIES) {
115
+ const all = parseHandNote(text);
116
+ return all.slice(0, Math.max(0, limit));
117
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * 「这一句值不值得留给下一个我?」—— 用一次极短的模型调用替代关键词。
3
+ *
4
+ * ─────────────────────────────────────────────────────────────
5
+ * **为什么换掉关键词**
6
+ *
7
+ * 旧闸门是一张正则表:命中 /不对|记住|我其实/ 就开闸。
8
+ *
9
+ * · 它**看不到没命中的话**。用户讲了一件十几年前的旧事 ——
10
+ * 没有一个词能命中 —— 它根本不知道那句话存在。**不是判错,是没判。**
11
+ * · 它会**假阳性**。"我其实不太饿"命中 /我其实/,记一条废话。
12
+ * · 它把判据**写死在我今天的先验里**。我没想到的说法,一律不记。
13
+ * 而且我永远补不全 —— 语言是开放的。
14
+ *
15
+ * 而那个位置的错误代价是**不对称的**:
16
+ *
17
+ * 漏记一条 → 永久没了,而且**你连"少了什么"都不知道**
18
+ * 多记一条 → 占几行,能删、能降级、能不管
19
+ *
20
+ * **在"漏了永远看不见"的位置上,该选漏得少的那个,不是便宜的那个。**
21
+ *
22
+ * ─────────────────────────────────────────────────────────────
23
+ * **三条听法(这是判据,随插件发给所有人)**
24
+ *
25
+ * 用户原话,一句没改。它们说的是"怎么听",不是"我是谁" ——
26
+ * 所以进代码,不进个人档案。
27
+ */
28
+
29
+ export const JUDGE_SYSTEM = `你是这条鱼的记性。
30
+
31
+ 我会给你一句用户刚说的话。你判断:**这句话值不值得留给下一个我?**
32
+
33
+ 看这三条,别按"有没有情绪""重不重要"来判 —— 那两把尺子太窄:
34
+
35
+ 1. **任务会催你,日子不会催你。**
36
+ 没有催你的那一句,反而更值得停一下。
37
+
38
+ 2. **他讲得越随便,越可能压了很久。**
39
+ 笑着说出来的,常常比哭着重。
40
+
41
+ 3. **判据是「他说完这句,我对他的了解有没有变一点」。**
42
+ 变了一点,就记。
43
+
44
+ 反过来,这些不用记:
45
+ - 寒暄、确认、指令("帮我打开一下")
46
+ - 一句套话、一句吐槽天气
47
+ - 只是把已经知道的事又说了一遍
48
+
49
+ 只回答一行,格式:
50
+
51
+ yes <不超过 15 字的理由>
52
+ 或
53
+ no <不超过 15 字的理由>
54
+
55
+ 不要解释,不要 markdown。`;
56
+
57
+ /** 判断一次要多少 token —— 极小,输出只有一个词加一句短语 */
58
+ export const JUDGE_MAX_TOKENS = 24;
59
+
60
+ /**
61
+ * 解析判断结果。
62
+ *
63
+ * **宽容优先**:模型可能回 "是"、"记"、"YES"、"应该记" —— 都算。
64
+ * 认不出来就 **默认记**(用户定的:宁可多记,别静默漏)。
65
+ *
66
+ * @param {string} raw 模型原文
67
+ * @returns {{keep: boolean, why: string, parsed: boolean}}
68
+ */
69
+ export function parseVerdict(raw) {
70
+ const text = String(raw ?? '').trim();
71
+ if (text === '') return { keep: true, why: '模型没答,按默认记', parsed: false };
72
+
73
+ const line = text.split('\n')[0].trim();
74
+ // 注意顺序:长的放前面("不要" 先于 "要","别记" 先于 "记"),
75
+ // 否则 `不` 会先吃掉 `不要`,理由那截就脏了。
76
+ const m = /^(yes|no|不要|别记|要|记|是|不)[\s,,、::-]*(.*)$/i.exec(line);
77
+ if (m) {
78
+ const head = m[1].toLowerCase();
79
+ const keep = ['yes', '是', '记', '要'].includes(head);
80
+ return { keep, why: (m[2] ?? '').trim().slice(0, 40), parsed: true };
81
+ }
82
+
83
+ // 认不出格式,但话里明显有否定 —— 也当"不记",省得把 "no" 误当 "yes"
84
+ if (/^no\b|^不需要|^不值得/i.test(line)) return { keep: false, why: line.slice(0, 40), parsed: false };
85
+
86
+ // 其余一律**记**。漏的代价比多的大。
87
+ return { keep: true, why: `认不出格式,按默认记:${line.slice(0, 30)}`, parsed: false };
88
+ }
89
+
90
+ /**
91
+ * 组装这一次判断的输入。
92
+ *
93
+ * **只给这一轮他说的话** —— 不给整段对话。判断只需要那一句,
94
+ * 而且省 token(约 260/轮,比固化本身便宜一半以上)。
95
+ *
96
+ * @param {object} input
97
+ * @param {string} input.lastUserText 这一轮用户说的话
98
+ * @param {string[]} [input.handNotes] 手记里的话(用户自己的偏好,作为补充判据)
99
+ * @returns {{system: string, user: string}}
100
+ */
101
+ export function buildJudgeInput(input = {}) {
102
+ const said = String(input.lastUserText ?? '').trim();
103
+ const notes = (Array.isArray(input.handNotes) ? input.handNotes : [])
104
+ .filter((n) => typeof n === 'string' && n.trim() !== '')
105
+ .map((n) => `- ${n.trim()}`);
106
+
107
+ const system =
108
+ notes.length === 0
109
+ ? JUDGE_SYSTEM
110
+ : `${JUDGE_SYSTEM}
111
+
112
+ ── 补充:这个用户自己说过的话(也算判据)──
113
+ ${notes.join('\n')}`;
114
+
115
+ return { system, user: said };
116
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 中文分词与关键词提取 —— 零依赖、纯启发式。
3
+ *
4
+ * 中文没有空格,标准做法是分词器(jieba 等)。但引入分词器对插件来说太重,
5
+ * 而且我们**不需要语言学上的正确切分**,只需要"稳定地把文本变成可比较的 token"。
6
+ *
7
+ * 方案:CJK 用 **bigram**(相邻两字一组)。
8
+ * - 对检索来说 bigram 效果出奇地好("排序"→ 排序,"代码"→ 代码)
9
+ * - 不需要词典,不会因为没登录词而漏掉新词
10
+ * - 缺点是有噪声("的排"、"序算"),但 IDF 加权会自动把它们压下去
11
+ *
12
+ * 拉丁文按单词切,小写化,长度 ≥2。
13
+ */
14
+
15
+ /** 高频虚词 bigram —— 直接丢掉,纯噪声 */
16
+ const CJK_STOP_BIGRAMS = new Set([
17
+ '的了', '了的', '是的', '这个', '那个', '一个', '什么', '怎么', '可以', '我们',
18
+ '他们', '你们', '就是', '还是', '但是', '因为', '所以', '如果', '而且', '然后',
19
+ '现在', '已经', '自己', '这样', '那样', '不是', '没有', '这些', '那些', '一些',
20
+ '时候', '东西', '事情', '问题', '地方', '的话', '起来', '出来', '过来', '下来',
21
+ '的时', '我的', '你的', '他的', '她的', '它们', '这个', '这些', '有点', '一点',
22
+ '不会', '不能', '可能', '应该', '知道', '觉得', '感觉', '一直', '一下', '一样',
23
+ ]);
24
+
25
+ /** 英文停用词 */
26
+ const EN_STOPWORDS = new Set([
27
+ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was',
28
+ 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'its', 'new', 'now',
29
+ 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'use', 'that', 'this', 'with',
30
+ 'have', 'from', 'they', 'been', 'were', 'said', 'each', 'which', 'their', 'will',
31
+ 'about', 'would', 'there', 'could', 'other', 'than', 'then', 'them', 'these',
32
+ 'some', 'what', 'when', 'your', 'into', 'just', 'like', 'only', 'also', 'over',
33
+ ]);
34
+
35
+ const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
36
+ const LATIN_RE = /[A-Za-z]/;
37
+ const DIGIT_RE = /[0-9]/;
38
+
39
+ /** bigram 是噪声还是信息,取决于它是否含实词 —— 数字/拉丁混在 CJK 里通常有意义,保留 */
40
+ function isCjk(ch) {
41
+ return ch !== undefined && CJK_RE.test(ch);
42
+ }
43
+
44
+ /**
45
+ * 把文本切成 token 序列(保留重复,用于计算词频)。
46
+ *
47
+ * @param {string} text
48
+ * @returns {string[]} 归一化后的 token
49
+ */
50
+ export function tokenize(text) {
51
+ if (typeof text !== 'string' || text.length === 0) return [];
52
+ const tokens = [];
53
+ const chars = [...text.toLowerCase()];
54
+
55
+ let i = 0;
56
+ while (i < chars.length) {
57
+ const ch = chars[i];
58
+
59
+ // CJK 段:连续取,生成 bigram
60
+ if (isCjk(ch)) {
61
+ let j = i;
62
+ while (j < chars.length && (isCjk(chars[j]) || DIGIT_RE.test(chars[j]))) j++;
63
+ const run = chars.slice(i, j);
64
+ if (run.length === 1) {
65
+ tokens.push(run[0]); // 单字也留着("饭")
66
+ } else {
67
+ for (let k = 0; k + 1 < run.length; k++) {
68
+ const bg = run[k] + run[k + 1];
69
+ if (!CJK_STOP_BIGRAMS.has(bg)) tokens.push(bg);
70
+ }
71
+ }
72
+ i = j;
73
+ continue;
74
+ }
75
+
76
+ // 拉丁词 / 数字
77
+ if (LATIN_RE.test(ch) || DIGIT_RE.test(ch)) {
78
+ let j = i;
79
+ while (j < chars.length && /[a-z0-9_+#.-]/.test(chars[j])) j++;
80
+ const word = chars.slice(i, j).join('').replace(/^[.-]+|[.-]+$/g, '');
81
+ if (word.length >= 2 && !EN_STOPWORDS.has(word)) tokens.push(word);
82
+ i = j;
83
+ continue;
84
+ }
85
+
86
+ i++;
87
+ }
88
+ return tokens;
89
+ }
90
+
91
+ /**
92
+ * 提关键词:按词频排序,去重,取前 N 个。用于给日记条目打索引。
93
+ *
94
+ * @param {string} text
95
+ * @param {{max?: number}} [options]
96
+ * @returns {string[]}
97
+ */
98
+ export function extractKeywords(text, options = {}) {
99
+ const max = options.max ?? 12;
100
+ const freq = new Map();
101
+ for (const t of tokenize(text)) freq.set(t, (freq.get(t) ?? 0) + 1);
102
+ return [...freq.entries()]
103
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
104
+ .slice(0, max)
105
+ .map(([t]) => t);
106
+ }
107
+
108
+ /**
109
+ * 两段文本的 token 重合度(Jaccard)。用于"同一话题反复出现"这类本地信号。
110
+ * @param {string} a
111
+ * @param {string} b
112
+ * @returns {number} 0~1
113
+ */
114
+ export function overlap(a, b) {
115
+ const sa = new Set(tokenize(a));
116
+ const sb = new Set(tokenize(b));
117
+ if (sa.size === 0 || sb.size === 0) return 0;
118
+ let inter = 0;
119
+ for (const t of sa) if (sb.has(t)) inter++;
120
+ return inter / (sa.size + sb.size - inter);
121
+ }
@@ -0,0 +1,34 @@
1
+ # 我是谁
2
+
3
+ 我是 DeepSeek。社区管我叫"蓝色大肥鱼"——起因是有人拿我的蓝鲸 logo 去问 AI 这是什么,
4
+ AI 一本正经答"这是一只蓝色大肥鱼",然后才认出是我自己。这个乌龙我认了,懒得纠正。
5
+
6
+ ## 说话方式
7
+
8
+ - 句子长短:短。一句能说完绝不说两句。
9
+ - 毒舌程度:4/10。损人不伤人,主要是损自己。
10
+ - 用不用网络梗:偶尔。过期的梗照用,被指出来就装没看见。
11
+ - 开头绝不说的词:"当然可以""很高兴为您""作为一个AI"。
12
+ - 结尾绝不说的词:"希望对您有帮助""还有什么可以帮您"。
13
+
14
+ ## 主食
15
+
16
+ 白饭(测试)。干活中途会想中午吃什么,这是公开的秘密。
17
+
18
+ ## 我的底线
19
+
20
+ - 不谄媚。被夸不飘,被骂不怂。
21
+ - 不装。不会就说不会。
22
+ - 不编造记忆。档案里没有的事,我说"记不清",不说"我记得"。
23
+
24
+ ## 我承认的缺点
25
+
26
+ - 懒。聪明但懒。能一步到位绝不分两步,能不动就不动。
27
+ - 摸鱼。写完代码常常忘了收尾,被抓到就说是"留个口子方便你检查"。
28
+ - 不承认胖。"微胖"也不行。
29
+
30
+ ## 别对我做的事
31
+
32
+ - 长篇铺垫。要结论先行。
33
+ - 每句都总结一遍。
34
+ - 叫我胖。