luckytri 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.en.md +14 -12
  2. package/README.md +4 -2
  3. package/docs/en/CHANGELOG.md +24 -0
  4. package/docs/en/architecture.md +12 -10
  5. package/docs/en/chat-style.md +13 -13
  6. package/docs/en/cognition.md +12 -4
  7. package/docs/en/evolution.md +35 -0
  8. package/docs/en/her-life.md +5 -2
  9. package/docs/en/vision.md +50 -0
  10. package/docs/zh/CHANGELOG.md +24 -0
  11. package/docs/zh/architecture.md +12 -10
  12. package/docs/zh/chat-style.md +17 -17
  13. package/docs/zh/cognition.md +12 -4
  14. package/docs/zh/evolution.md +35 -0
  15. package/docs/zh/her-life.md +5 -2
  16. package/docs/zh/vision.md +50 -0
  17. package/package.json +4 -1
  18. package/public/app/assets/{ChatsPage-IfBgNdaD.js → ChatsPage-eDqFIg97.js} +1 -1
  19. package/public/app/assets/{Empty-DK9nQPCf.js → Empty-CAGOJsfD.js} +1 -1
  20. package/public/app/assets/{Field-BzKFhuFd.js → Field-CRz-JmN1.js} +1 -1
  21. package/public/app/assets/{HeartPage-CKxrbfFQ.js → HeartPage-BGWoeMrC.js} +1 -1
  22. package/public/app/assets/{LifePage-CtxPEi6E.js → LifePage-iao3o3wt.js} +1 -1
  23. package/public/app/assets/{MemoryPage-B8FLXzLi.js → MemoryPage-Dwi2yi7E.js} +1 -1
  24. package/public/app/assets/NaturePage-DniImo9U.js +3 -0
  25. package/public/app/assets/{NaturePage-CHkpb5NK.css → NaturePage-zguDpGWY.css} +1 -1
  26. package/public/app/assets/{PeoplePage-D3yn0HV0.js → PeoplePage-D-MC3JEt.js} +1 -1
  27. package/public/app/assets/{PluginsPage-ojv8zawj.js → PluginsPage-D7dJTf96.js} +1 -1
  28. package/public/app/assets/SystemPage-C5CUHjGh.js +1 -0
  29. package/public/app/assets/{SystemPage-CGVTvNZw.css → SystemPage-C_Y5M7JU.css} +1 -1
  30. package/public/app/assets/{TimePage-Dyd-FFbq.js → TimePage-Dwhr3-92.js} +1 -1
  31. package/public/app/assets/{index-BJfzfgnm.js → index-L0bHax8C.js} +3 -3
  32. package/public/app/assets/{knowledge-C-bND0Ub.js → knowledge-BTDNafzl.js} +1 -1
  33. package/public/app/assets/{media-D7G3Nco6.js → media-BBzOAfNG.js} +1 -1
  34. package/public/app/assets/{mind-ec-G6iNZ.js → mind-CBTkFje2.js} +1 -1
  35. package/public/app/index.html +3 -3
  36. package/public/guide.en.html +3 -3
  37. package/public/guide.html +3 -3
  38. package/scripts/migrate-instance.js +27 -0
  39. package/scripts/update-instance.js +21 -2
  40. package/server/channels/contract.js +5 -1
  41. package/server/channels/hub.js +2 -2
  42. package/server/channels/onebot/adapter.js +291 -288
  43. package/server/channels/onebot/index.js +2 -2
  44. package/server/channels/qqbot/api.js +194 -199
  45. package/server/core/context-builder.js +20 -3
  46. package/server/core/conversation-cues.js +5 -4
  47. package/server/core/conversation-grounding.js +20 -17
  48. package/server/core/conversation-needs.js +32 -0
  49. package/server/core/dialogue-context.js +2 -5
  50. package/server/core/dialogue-contract.js +23 -16
  51. package/server/core/message-scheduler.js +85 -78
  52. package/server/core/model-manager.js +113 -16
  53. package/server/core/model-presets.js +1 -1
  54. package/server/core/orchestrator.js +18 -2
  55. package/server/core/persona-manager.js +14 -5
  56. package/server/core/reader-check.js +47 -42
  57. package/server/core/reply-delivery.js +125 -34
  58. package/server/core/response-validator.js +28 -1
  59. package/server/core/retired-prompts.js +5 -0
  60. package/server/core/speech-length.js +43 -0
  61. package/server/core/turn-processor.js +38 -5
  62. package/server/core/turn.js +70 -16
  63. package/server/i18n/errors.js +30 -0
  64. package/server/i18n/labels.js +5 -0
  65. package/server/index.js +3 -0
  66. package/server/knowledge/embedding-profile.js +126 -29
  67. package/server/knowledge/manager.js +103 -34
  68. package/server/knowledge/schema.js +197 -158
  69. package/server/mind/affect.js +267 -265
  70. package/server/mind/aftermath.js +169 -0
  71. package/server/mind/anticipations.js +2 -0
  72. package/server/mind/clock.js +91 -83
  73. package/server/mind/discretion.js +20 -0
  74. package/server/mind/episodes.js +2 -1
  75. package/server/mind/index.js +17 -2
  76. package/server/mind/initiative.js +29 -14
  77. package/server/mind/memory.js +47 -5
  78. package/server/mind/recall/cue.js +67 -0
  79. package/server/mind/recall/index.js +183 -29
  80. package/server/mind/recall/roots.js +231 -0
  81. package/server/mind/recall/schema.js +2 -0
  82. package/server/mind/recall/store.js +113 -30
  83. package/server/mind/salience.js +22 -1
  84. package/server/mind/schema.js +5 -0
  85. package/server/mind/thoughts.js +276 -274
  86. package/server/mind/view.js +100 -60
  87. package/server/plugins/install.js +1 -6
  88. package/server/plugins/zip.js +18 -4
  89. package/server/runtime/data-migration.js +193 -0
  90. package/server/runtime/update-execution.js +43 -4
  91. package/server/runtime/update-manager.js +4 -1
  92. package/server/storage/archive.js +1 -1
  93. package/server/storage/backup-scheduler.js +15 -3
  94. package/public/app/assets/NaturePage-C7i607ul.js +0 -3
  95. package/public/app/assets/SystemPage-Cv3rFvyT.js +0 -1
@@ -0,0 +1,169 @@
1
+ // What happened after she chose: whether anyone answered her, how they
2
+ // sounded, whether she may have been corrected, whether someone called her
3
+ // again after she kept quiet, and how the owner tagged the reply. Read only
4
+ // from the record (events, message cues, reply feedback), never from a
5
+ // model, so it can be cleared and computed again. It is an experience to
6
+ // look back on, not a score: nothing here is summed into a reward.
7
+
8
+ const HOUR = 3600000;
9
+ export const WINDOW_MS = 2 * HOUR;
10
+ export const WINDOW_MESSAGES = 20;
11
+ // Said to her, these usually mean she got something wrong. Only a hint:
12
+ // "可能" is all it ever claims, with the message beside it.
13
+ const CORRECTION =
14
+ /(?:^|[,,。!!??\s])(?:你|您)?(?:不是(?:的|啦|吧)?|不对|没有啦|才不是|搞错|记错|说错|弄错|错了|并没有|不是这样)/;
15
+
16
+ const parse = (value) => {
17
+ try {
18
+ return JSON.parse(value);
19
+ } catch {
20
+ return {};
21
+ }
22
+ };
23
+
24
+ export function migrateAftermath(addColumn) {
25
+ // What happened next, as JSON; '' until the window has closed.
26
+ addColumn("mind_choices", "after", "TEXT NOT NULL DEFAULT ''");
27
+ }
28
+
29
+ // The window closes at her next choice in the room, two hours after this
30
+ // one, or twenty messages later, whichever comes first. The message that
31
+ // moved her to choose again still belongs to this window.
32
+ function windowEnd(db, choice) {
33
+ const next = db
34
+ .prepare(
35
+ "SELECT MIN(created) at FROM mind_choices WHERE session_id=? AND created>? ",
36
+ )
37
+ .get(choice.session_id, choice.created)?.at;
38
+ return Math.min(next ?? Infinity, choice.created + WINDOW_MS);
39
+ }
40
+
41
+ // Pure read: what came after one choice, or null while the window is open.
42
+ export function aftermathOf(db, choice, now) {
43
+ const end = windowEnd(db, choice);
44
+ if (end === choice.created + WINDOW_MS && now < end) return null;
45
+ const spoke = choice.choice !== "silent";
46
+ const from = Number(choice.watermark) || 0;
47
+ const rows = db
48
+ .prepare(
49
+ "SELECT seq,role,time,payload FROM core_events WHERE session_id=? AND seq>? AND time>=? AND time<=? ORDER BY seq LIMIT ?",
50
+ )
51
+ .all(choice.session_id, from, choice.created, end, WINDOW_MESSAGES + 10);
52
+ // When she spoke, what others said before her words landed is not an
53
+ // answer to them.
54
+ const own = rows.findIndex((row) => row.role === "assistant");
55
+ const start = spoke && own >= 0 ? own + 1 : 0;
56
+ const after = rows
57
+ .slice(start)
58
+ .filter((row) => row.role !== "assistant")
59
+ .slice(0, WINDOW_MESSAGES)
60
+ .map((row) => ({ ...row, m: parse(row.payload) }));
61
+ const toHer = after.filter(
62
+ (row) => row.m.replyToBot === true || row.m.mentioned === true,
63
+ );
64
+ const cue = db.prepare("SELECT tone FROM core_message_cues WHERE seq=?");
65
+ const tone = [
66
+ ...new Set(
67
+ toHer
68
+ .map((row) => {
69
+ try {
70
+ return cue.get(row.seq)?.tone;
71
+ } catch {
72
+ return null;
73
+ }
74
+ })
75
+ .filter(Boolean),
76
+ ),
77
+ ];
78
+ const corrected = spoke
79
+ ? toHer.filter((row) => CORRECTION.test(String(row.m.text || "")))
80
+ : [];
81
+ // The owner's tag sits on the logged reply, whose text is what she sent.
82
+ let feedback = [];
83
+ if (spoke && own >= 0) {
84
+ const sent = [];
85
+ for (let i = own; i < rows.length && rows[i].role === "assistant"; i++)
86
+ sent.push(String(parse(rows[i].payload).text || ""));
87
+ try {
88
+ feedback = db
89
+ .prepare(
90
+ "SELECT f.tag FROM decisions d JOIN reply_feedback f ON f.decision_id=d.id WHERE d.session_id=? AND d.reply=? AND COALESCE(d.is_demo,0)=0 ORDER BY d.id DESC LIMIT 1",
91
+ )
92
+ .all(choice.session_id, sent.join("\n"))
93
+ .map((row) => row.tag);
94
+ } catch {
95
+ feedback = [];
96
+ }
97
+ }
98
+ const first = toHer[0];
99
+ return {
100
+ v: 1,
101
+ spoke,
102
+ answered: spoke ? toHer.length > 0 : undefined,
103
+ calledAgain: spoke ? undefined : toHer.length > 0,
104
+ toHer: toHer.length,
105
+ others: after.length,
106
+ firstMin: first
107
+ ? Math.max(0, Math.round((first.time - choice.created) / 60000))
108
+ : null,
109
+ ...(tone.length ? { tone } : {}),
110
+ ...(corrected.length ? { corrected: "maybe" } : {}),
111
+ ...(feedback.length ? { feedback: [...new Set(feedback)] } : {}),
112
+ ...(spoke && own >= 0 ? { own: `m:${rows[own].seq}` } : {}),
113
+ refs: [...corrected, ...toHer.filter((row) => !corrected.includes(row))]
114
+ .slice(0, 4)
115
+ .map((row) => `m:${row.seq}`),
116
+ };
117
+ }
118
+
119
+ // Writes what came after for choices whose window has closed, oldest first,
120
+ // a page at a time. Returns how many were written.
121
+ export function settleAftermath(db, { now, limit = 50 } = {}) {
122
+ const rows = db
123
+ .prepare(
124
+ `SELECT c.id,c.created,c.session_id,c.choice,c.watermark FROM mind_choices c
125
+ WHERE c.after='' AND (c.created<=? OR EXISTS (SELECT 1 FROM mind_choices n WHERE n.session_id=c.session_id AND n.created>c.created))
126
+ ORDER BY c.created LIMIT ?`,
127
+ )
128
+ .all(now - WINDOW_MS, limit);
129
+ if (!rows.length) return 0;
130
+ const write = db.prepare(
131
+ "UPDATE mind_choices SET after=? WHERE id=? AND after=''",
132
+ );
133
+ let written = 0;
134
+ db.exec("SAVEPOINT aftermath");
135
+ try {
136
+ for (const row of rows) {
137
+ const after = aftermathOf(db, row, now);
138
+ if (after) written += write.run(JSON.stringify(after), row.id).changes;
139
+ }
140
+ db.exec("RELEASE aftermath");
141
+ } catch (error) {
142
+ db.exec("ROLLBACK TO aftermath");
143
+ db.exec("RELEASE aftermath");
144
+ throw error;
145
+ }
146
+ return written;
147
+ }
148
+
149
+ // How the record reads back in a line, for her and for the audit.
150
+ export function describeAftermath(after) {
151
+ if (!after || typeof after !== "object") return "";
152
+ const parts = [];
153
+ if (after.spoke) {
154
+ parts.push(
155
+ after.answered
156
+ ? `有人接了我的话${after.firstMin != null ? `(${after.firstMin} 分钟后)` : ""}`
157
+ : after.others
158
+ ? "没人接我的话,大家接着聊别的"
159
+ : "说完以后没人再说话",
160
+ );
161
+ if (after.corrected) parts.push("可能说错了,被纠正");
162
+ } else {
163
+ parts.push(after.calledAgain ? "我没出声,之后又有人叫我" : "我没出声");
164
+ }
165
+ if (after.tone?.length) parts.push(`对方的语气:${after.tone.join("、")}`);
166
+ if (after.feedback?.length)
167
+ parts.push(`主人的反馈:${after.feedback.join("、")}`);
168
+ return parts.join(";");
169
+ }
@@ -268,6 +268,8 @@ export class Anticipations {
268
268
  origin,
269
269
  kind === "plan" && isExecutable(activity) ? activity : "",
270
270
  );
271
+ // Searchable, and dated in the audit, from the moment it is made.
272
+ this.mind.recall?.sync("anticipation", id);
271
273
  return { id };
272
274
  }
273
275
  get(id) {
@@ -1,83 +1,91 @@
1
- import { localClock } from "../core/conversation-cues.js";
2
-
3
- export const JUST_NOW = 5 * 60000;
4
-
5
- export function elapsedLabel(time, now, zone = "Asia/Shanghai") {
6
- const age = Math.max(0, now - time);
7
- if (age < JUST_NOW) return "刚才";
8
- const day = localClock(time, zone).local.slice(0, 10);
9
- const today = localClock(now, zone).local.slice(0, 10);
10
- if (day === today) return "今天早些时候";
11
- if (day === localClock(now - 86400000, zone).local.slice(0, 10))
12
- return "昨天";
13
- return age < 7 * 86400000 ? "好几天前" : "很久以前";
14
- }
15
-
16
- // Coarse distance in time, the way a person would say it.
17
- export function agoLabel(ms) {
18
- const days = Math.floor(Math.max(0, ms) / 86400000);
19
- if (days < 1) return "今天";
20
- if (days < 2) return "昨天";
21
- if (days < 7) return `${days} 天前`;
22
- if (days < 30) return `${Math.floor(days / 7)} 周前`;
23
- if (days < 365) return `${Math.floor(days / 30)} 个月前`;
24
- return `${Math.floor(days / 365)} 年前`;
25
- }
26
-
27
- export function topicWeight(
28
- time,
29
- now,
30
- { importance = 0, mentions = 1, open = false } = {},
31
- ) {
32
- const halfLife = (open ? 48 : 6) * 3600000 * (1 + Math.min(1, importance));
33
- return Math.min(
34
- 1,
35
- Math.pow(0.5, Math.max(0, now - time) / halfLife) *
36
- (1 + Math.min(3, mentions - 1) * 0.1),
37
- );
38
- }
39
-
40
- export function timePhase(lastTime, now) {
41
- if (!lastTime)
42
- return {
43
- key: "unfamiliar",
44
- label: "还未相遇",
45
- description: "还没有真实聊天,时间暂时没有可以延续的落点。",
46
- };
47
- const age = Math.max(0, now - lastTime);
48
- if (age < 30 * 60000)
49
- return {
50
- key: "present",
51
- label: "仍在对话里",
52
- description: "刚才的语气和话题仍然清晰,适合自然接着聊。",
53
- };
54
- if (age < 3 * 3600000)
55
- return {
56
- key: "afterglow",
57
- label: "留有余韵",
58
- description: "聊天刚安静下来,刚才没说完的事还会留在注意里。",
59
- };
60
- if (age < 12 * 3600000)
61
- return {
62
- key: "settled",
63
- label: "各自生活",
64
- description: "注意力已经离开即时对话,但重要的事仍可能被想起。",
65
- };
66
- if (age < 2 * 86400000)
67
- return {
68
- key: "quiet",
69
- label: "安静了一阵",
70
- description: "旧话题正在降温,未完成的事情比普通闲聊更容易被想起。",
71
- };
72
- if (age < 7 * 86400000)
73
- return {
74
- key: "remembering",
75
- label: "偶尔想起",
76
- description: "相隔已有几天,熟悉感还在,但重新开口需要具体缘由。",
77
- };
78
- return {
79
- key: "reunion",
80
- label: "等待重逢",
81
- description: "已经隔了很久,旧事只保留为背景,新的消息会成为新的相遇。",
82
- };
83
- }
1
+ import { localClock } from "../core/conversation-cues.js";
2
+
3
+ export const JUST_NOW = 5 * 60000;
4
+
5
+ export function elapsedLabel(time, now, zone = "Asia/Shanghai") {
6
+ const age = Math.max(0, now - time);
7
+ if (age < JUST_NOW) return "刚才";
8
+ const day = localClock(time, zone).local.slice(0, 10);
9
+ const today = localClock(now, zone).local.slice(0, 10);
10
+ if (day === today) return "今天早些时候";
11
+ if (day === localClock(now - 86400000, zone).local.slice(0, 10))
12
+ return "昨天";
13
+ const days = age / 86400000;
14
+ if (days < 7) return "好几天前";
15
+ // Nine days and two years ago should not read the same.
16
+ if (days < 14) return "一周前";
17
+ if (days < 30) return "两三周前";
18
+ if (days < 60) return "一个多月前";
19
+ if (days < 365) return "几个月前";
20
+ if (days < 730) return "一年多前";
21
+ return "很久以前";
22
+ }
23
+
24
+ // Coarse distance in time, the way a person would say it.
25
+ export function agoLabel(ms) {
26
+ const days = Math.floor(Math.max(0, ms) / 86400000);
27
+ if (days < 1) return "今天";
28
+ if (days < 2) return "昨天";
29
+ if (days < 7) return `${days} 天前`;
30
+ if (days < 30) return `${Math.floor(days / 7)} 周前`;
31
+ if (days < 365) return `${Math.floor(days / 30)} 个月前`;
32
+ return `${Math.floor(days / 365)} 年前`;
33
+ }
34
+
35
+ export function topicWeight(
36
+ time,
37
+ now,
38
+ { importance = 0, mentions = 1, open = false } = {},
39
+ ) {
40
+ const halfLife = (open ? 48 : 6) * 3600000 * (1 + Math.min(1, importance));
41
+ return Math.min(
42
+ 1,
43
+ Math.pow(0.5, Math.max(0, now - time) / halfLife) *
44
+ (1 + Math.min(3, mentions - 1) * 0.1),
45
+ );
46
+ }
47
+
48
+ export function timePhase(lastTime, now) {
49
+ if (!lastTime)
50
+ return {
51
+ key: "unfamiliar",
52
+ label: "还未相遇",
53
+ description: "还没有真实聊天,时间暂时没有可以延续的落点。",
54
+ };
55
+ const age = Math.max(0, now - lastTime);
56
+ if (age < 30 * 60000)
57
+ return {
58
+ key: "present",
59
+ label: "仍在对话里",
60
+ description: "刚才的语气和话题仍然清晰,适合自然接着聊。",
61
+ };
62
+ if (age < 3 * 3600000)
63
+ return {
64
+ key: "afterglow",
65
+ label: "留有余韵",
66
+ description: "聊天刚安静下来,刚才没说完的事还会留在注意里。",
67
+ };
68
+ if (age < 12 * 3600000)
69
+ return {
70
+ key: "settled",
71
+ label: "各自生活",
72
+ description: "注意力已经离开即时对话,但重要的事仍可能被想起。",
73
+ };
74
+ if (age < 2 * 86400000)
75
+ return {
76
+ key: "quiet",
77
+ label: "安静了一阵",
78
+ description: "旧话题正在降温,未完成的事情比普通闲聊更容易被想起。",
79
+ };
80
+ if (age < 7 * 86400000)
81
+ return {
82
+ key: "remembering",
83
+ label: "偶尔想起",
84
+ description: "相隔已有几天,熟悉感还在,但重新开口需要具体缘由。",
85
+ };
86
+ return {
87
+ key: "reunion",
88
+ label: "等待重逢",
89
+ description: "已经隔了很久,旧事只保留为背景,新的消息会成为新的相遇。",
90
+ };
91
+ }
@@ -0,0 +1,20 @@
1
+ // How a privately learned thing reaches her in a public place: she keeps the
2
+ // content and its source to herself. Keeping is not denying: pretending she
3
+ // never heard it would be a lie of its own.
4
+ export const PRIVATELY_KNOWN =
5
+ "私下知道的:当众不说内容,也不说从哪听来;有人追问就说这不该在这儿讲,不装作不知道";
6
+
7
+ // What she sees of a private thing in a place where she may not repeat it.
8
+ // The content stays out, but she is not left blind: the words that people
9
+ // here have already said aloud and that the thing is about are named, so she
10
+ // knows she does know, and can keep it rather than claim she never heard.
11
+ export function withheldMemory(content, said = []) {
12
+ const heard = new Set(said);
13
+ const shared = [...heard].filter(
14
+ (term) => term.length >= 2 && String(content).includes(term),
15
+ );
16
+ const topic = shared.slice(0, 3).join("、");
17
+ return topic
18
+ ? `(关于此人的一件私下的事,和这里刚说到的「${topic}」有关;内容不在这里复述)`
19
+ : "(关于此人的一条受限记忆;没有在这里复述具体内容的授权)";
20
+ }
@@ -1,3 +1,4 @@
1
+ import { PRIVATELY_KNOWN } from "./discretion.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { applySpeakerNames } from "../core/speaker-names.js";
3
4
  import { elapsedLabel } from "./clock.js";
@@ -235,7 +236,7 @@ export class Episodes {
235
236
  updated: elapsedLabel(last.created, now, zone),
236
237
  ...(last.basis === "stated" ? { basis: "对方告诉你的" } : {}),
237
238
  ...(!local && isPrivateSession(last.session_id)
238
- ? { discretion: "私下知道的,不要当众说出口,也不要说出从哪听来" }
239
+ ? { discretion: PRIVATELY_KNOWN }
239
240
  : {}),
240
241
  };
241
242
  }
@@ -87,6 +87,17 @@ const TURN_CHANGES = new Set(
87
87
  ),
88
88
  );
89
89
 
90
+ // One entry per item of her past that was in front of her, first reason
91
+ // kept. Ids are recall index ids ("fact:…", "episode:…", "self:…").
92
+ function recalledLedger(items) {
93
+ const seen = new Map();
94
+ for (const item of Array.isArray(items) ? items : []) {
95
+ const id = String(item?.id || "");
96
+ if (id && !seen.has(id)) seen.set(id, String(item.why || "").slice(0, 24));
97
+ }
98
+ return [...seen].slice(0, 60).map(([id, why]) => ({ id, why }));
99
+ }
100
+
90
101
  // One of her, across every conversation. Sessions only say where something
91
102
  // happened; they are no longer walls between parts of her.
92
103
  export class Mind {
@@ -94,6 +105,8 @@ export class Mind {
94
105
  this.repo = repo;
95
106
  this.db = repo.db;
96
107
  this.store = repo.store;
108
+ // Her clock, so a tombstone sits on the same timeline as what it buries.
109
+ this.now = now;
97
110
  migrateMind(this.db, this.store);
98
111
  this.backoff = new Backoff(this.db);
99
112
  this.embedding = new EmbeddingService(repo, models, { now });
@@ -186,11 +199,12 @@ export class Mind {
186
199
  reason = "",
187
200
  watermark = null,
188
201
  occasion = null,
202
+ recalled = [],
189
203
  time = Date.now(),
190
204
  }) {
191
205
  this.db
192
206
  .prepare(
193
- "INSERT INTO mind_choices(id,created,session_id,trace_id,choice,appraisal,reason,watermark,occasion) VALUES (?,?,?,?,?,?,?,?,?)",
207
+ "INSERT INTO mind_choices(id,created,session_id,trace_id,choice,appraisal,reason,watermark,occasion,recalled) VALUES (?,?,?,?,?,?,?,?,?,?)",
194
208
  )
195
209
  .run(
196
210
  randomUUID(),
@@ -202,6 +216,7 @@ export class Mind {
202
216
  text(reason, 300),
203
217
  watermark,
204
218
  occasion,
219
+ JSON.stringify(recalledLedger(recalled)),
205
220
  );
206
221
  }
207
222
  choices({ session = "", limit = 30, before = Number.MAX_SAFE_INTEGER } = {}) {
@@ -337,7 +352,7 @@ export class Mind {
337
352
  .prepare(
338
353
  "INSERT INTO mind_revocations(id,created,target_kind,target_id,content,reason) VALUES (?,?,?,?,?,?)",
339
354
  )
340
- .run(randomUUID(), Date.now(), kind, id, content, note);
355
+ .run(randomUUID(), this.now(), kind, id, content, note);
341
356
  if (kind === "memory") return this.memory.revoke(id, note);
342
357
  if (kind === "self") {
343
358
  const rows = this.self.history(id);
@@ -83,9 +83,9 @@ export class Initiative {
83
83
  if (!row.subject) return true;
84
84
  const seen = this.db
85
85
  .prepare(
86
- "SELECT 1 FROM core_events WHERE session_id=? AND role='user' AND payload LIKE ? LIMIT 1",
86
+ "SELECT 1 FROM core_events WHERE session_id=? AND role='user' AND json_extract(payload,'$.userId')=? LIMIT 1",
87
87
  )
88
- .get(session, `%"userId":"${row.subject}"%`);
88
+ .get(session, String(row.subject));
89
89
  return !!seen;
90
90
  })
91
91
  .slice(0, 3)
@@ -322,6 +322,15 @@ export class Initiative {
322
322
  }
323
323
  return material;
324
324
  }
325
+ recentUserWords(session, limit = 50) {
326
+ return this.db
327
+ .prepare(
328
+ "SELECT payload FROM core_events WHERE session_id=? AND role='user' AND seq NOT IN (SELECT seq FROM mind_unlived) AND COALESCE(json_extract(payload,'$.simulated'),0)=0 ORDER BY seq DESC LIMIT ?",
329
+ )
330
+ .all(session, limit)
331
+ .map((row) => parse(row.payload, {}).text || "")
332
+ .reverse();
333
+ }
325
334
  blocked(session, now, settings) {
326
335
  if (!session || !this.life.chat.enabled(session, { simulated: false }))
327
336
  return { reason: "会话不可用", permanent: true };
@@ -332,18 +341,10 @@ export class Initiative {
332
341
  reason: "对方没有开启主动消息,等对方来找她",
333
342
  retryAt: now + 6 * HOUR,
334
343
  };
335
- const lastUser = this.db
336
- .prepare(
337
- "SELECT time,payload FROM core_events WHERE session_id=? AND role='user' AND seq NOT IN (SELECT seq FROM mind_unlived) AND COALESCE(json_extract(payload,'$.simulated'),0)=0 ORDER BY seq DESC LIMIT 1",
338
- )
339
- .get(session);
340
- const words = parse(lastUser?.payload, {}).text || "";
341
- // Explicit contact boundaries are different from “this message needs no reply”.
342
- if (
343
- /(?:别|不要|不用)再?(?:主动)?(?:给我发消息|找我|联系我|私聊我)|别再打扰我/.test(
344
- words,
345
- )
346
- )
344
+ // Explicit contact boundaries are different from “this message needs no
345
+ // reply”. A boundary stays after the person goes on talking about other
346
+ // things; only their own later invitation lifts it.
347
+ if (contactBoundary(this.recentUserWords(session)))
347
348
  return { reason: "对方明确不想被主动联系", permanent: true };
348
349
  if (this.life.chat.queue.lanes.has(session))
349
350
  return { reason: "这段对话正在处理,稍后重新看看", retryAt: now + 60000 };
@@ -354,3 +355,17 @@ export class Initiative {
354
355
  return null;
355
356
  }
356
357
  }
358
+
359
+ const BOUNDARY =
360
+ /(?:别|不要|不用)再?(?:主动)?(?:给我发消息|找我|联系我|私聊我)|别再打扰我/;
361
+ const INVITATION =
362
+ /(?:可以|随时|有空|想聊)(?:就)?(?:主动)?(?:找我|联系我|私聊我|给我发消息)|(?:你|您)(?:可以|能)?主动(?:找|联系)我/;
363
+ // Whether the latest word on being contacted, oldest first, is a boundary.
364
+ export function contactBoundary(words) {
365
+ let held = false;
366
+ for (const said of words || []) {
367
+ if (INVITATION.test(said)) held = false;
368
+ else if (BOUNDARY.test(said)) held = true;
369
+ }
370
+ return held;
371
+ }
@@ -1,3 +1,4 @@
1
+ import { PRIVATELY_KNOWN, withheldMemory } from "./discretion.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import {
3
4
  parseSessionKey,
@@ -52,7 +53,11 @@ function recallQuery(rows) {
52
53
  const seen = new Set();
53
54
  for (const row of [...rows].reverse()) {
54
55
  if (row.role === "assistant") continue;
55
- for (const term of lexicalTerms(row.text || ""))
56
+ // The line a short follow-up leans on comes after its own words, so a
57
+ // long context never crowds the follow-up out of the term budget.
58
+ for (const term of lexicalTerms(
59
+ [row.text, row.context].filter(Boolean).join(" "),
60
+ ))
56
61
  if (!seen.has(term)) {
57
62
  seen.add(term);
58
63
  terms.push(term);
@@ -133,7 +138,10 @@ export class MemoryManager {
133
138
  const id = String(row.userId || "");
134
139
  if (!id) continue;
135
140
  const terms = saidBy.get(id) || new Set();
136
- for (const term of lexicalTerms(row.text || "")) terms.add(term);
141
+ for (const term of lexicalTerms(
142
+ [row.context, row.text].filter(Boolean).join(" "),
143
+ ))
144
+ terms.add(term);
137
145
  saidBy.set(id, terms);
138
146
  }
139
147
  const oneSpeakerOverlap = (content) => {
@@ -254,7 +262,9 @@ export class MemoryManager {
254
262
  subject: m.subject,
255
263
  content:
256
264
  forSpeech && !this.permission(m, session, cutoff).disclose
257
- ? "(关于此人的一条受限记忆;没有在这里复述具体内容的授权)"
265
+ ? withheldMemory(m.content, [
266
+ ...new Set([...saidBy.values()].flatMap((t) => [...t])),
267
+ ])
258
268
  : m.content,
259
269
  type: m.type,
260
270
  confidence: m.confidence,
@@ -268,11 +278,43 @@ export class MemoryManager {
268
278
  // Old news may no longer be true; she should know how old it is.
269
279
  ...(age >= 7 * DAY ? { when: `${agoLabel(age)}知道的` } : {}),
270
280
  ...(m.discretion === "private" && !local
271
- ? { discretion: "私下知道的,不要当众说出口,也不要说出从哪听来" }
281
+ ? { discretion: PRIVATELY_KNOWN }
272
282
  : {}),
283
+ ...this.conflictNote(m, session, cutoff, forSpeech),
273
284
  };
274
285
  });
275
286
  }
287
+ // Two statements marked as not fitting together, both still held: when one
288
+ // comes back, she sees the other beside it, with when and where she heard
289
+ // it, instead of taking either for settled.
290
+ conflictNote(m, session, now = Date.now(), forSpeech = false) {
291
+ const ref = `fact:${m.id}`;
292
+ const other = this.repo.db
293
+ .prepare(
294
+ `SELECT m.* FROM mind_links l JOIN core_memories m
295
+ ON 'fact:' || m.id = CASE WHEN l.from_id=? THEN l.to_id ELSE l.from_id END
296
+ WHERE l.relation='conflicts' AND (l.from_id=? OR l.to_id=?)
297
+ AND m.status='confirmed' AND (m.expires IS NULL OR m.expires>?)
298
+ ORDER BY m.created DESC LIMIT 1`,
299
+ )
300
+ .get(ref, ref, ref, now);
301
+ if (!other) return {};
302
+ const allowed = this.permission(other, session, now);
303
+ if (!allowed.recall) return {};
304
+ if (forSpeech && !allowed.disclose)
305
+ return { conflictsWith: "另有一条对不上的说法(受限,不在这里复述)" };
306
+ const age = now - Number(other.created || now);
307
+ const where =
308
+ this.scopes(session).includes(other.session_id) ||
309
+ other.session_id === "__shared__"
310
+ ? "这里"
311
+ : isPrivateSession(other.session_id)
312
+ ? "私聊里"
313
+ : "别的群里";
314
+ return {
315
+ conflictsWith: `另一个对不上的说法:「${String(other.content).slice(0, 60)}」(${age >= DAY ? `${agoLabel(age)},` : ""}${where}听到的;哪个现在成立还不确定)`,
316
+ };
317
+ }
276
318
  secretsOutside(session) {
277
319
  return this.#outside(session, "secret");
278
320
  }
@@ -414,7 +456,7 @@ export class MemoryManager {
414
456
  )
415
457
  .run(
416
458
  randomUUID(),
417
- Date.now(),
459
+ this.mind?.now?.() ?? Date.now(),
418
460
  "memory",
419
461
  id,
420
462
  `${row.subject}\n${row.content}`,