luckytri 1.0.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 (235) hide show
  1. package/.github/SECURITY.md +71 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +250 -0
  4. package/README.md +250 -0
  5. package/bin/luckytri.js +107 -0
  6. package/docs/brand/banner.png +0 -0
  7. package/docs/brand/palette.png +0 -0
  8. package/docs/brand/portrait.png +0 -0
  9. package/docs/en/CHANGELOG.md +113 -0
  10. package/docs/en/architecture.md +152 -0
  11. package/docs/en/chat-style.md +62 -0
  12. package/docs/en/connect.md +63 -0
  13. package/docs/en/guide.md +216 -0
  14. package/docs/en/her-life.md +189 -0
  15. package/docs/en/npm-release.md +25 -0
  16. package/docs/en/plugins.md +82 -0
  17. package/docs/en/recovery.md +28 -0
  18. package/docs/en/time.md +71 -0
  19. package/docs/en/webui.md +73 -0
  20. package/docs/zh/CHANGELOG.md +113 -0
  21. package/docs/zh/architecture.md +152 -0
  22. package/docs/zh/chat-style.md +62 -0
  23. package/docs/zh/connect.md +63 -0
  24. package/docs/zh/guide.md +216 -0
  25. package/docs/zh/her-life.md +189 -0
  26. package/docs/zh/npm-release.md +33 -0
  27. package/docs/zh/plugins.md +82 -0
  28. package/docs/zh/recovery.md +28 -0
  29. package/docs/zh/time.md +71 -0
  30. package/docs/zh/webui.md +73 -0
  31. package/package.json +108 -0
  32. package/plugins/sdk/index.d.ts +155 -0
  33. package/plugins/sdk/testing.js +58 -0
  34. package/plugins/weather/index.js +82 -0
  35. package/plugins/weather/luckytri-plugin.json +29 -0
  36. package/public/app/assets/ChatsPage-C-FcRapR.js +3 -0
  37. package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
  38. package/public/app/assets/Empty-CpP5kQHH.js +2 -0
  39. package/public/app/assets/Empty-DmUB2-Bk.css +1 -0
  40. package/public/app/assets/Field-D4gY-INU.js +1 -0
  41. package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
  42. package/public/app/assets/HeartPage-CPy_thQd.js +1 -0
  43. package/public/app/assets/LifePage-BUi5euBF.css +1 -0
  44. package/public/app/assets/LifePage-CxWvwmSf.js +1 -0
  45. package/public/app/assets/MemoryPage-DC4rhQ68.js +1 -0
  46. package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
  47. package/public/app/assets/NaturePage-CTXHCE2D.css +1 -0
  48. package/public/app/assets/NaturePage-IvYw04Zq.js +3 -0
  49. package/public/app/assets/PeoplePage-BmUxkrOU.css +1 -0
  50. package/public/app/assets/PeoplePage-Cqefm9-q.js +1 -0
  51. package/public/app/assets/PluginsPage-DKSaKvXb.js +2 -0
  52. package/public/app/assets/PluginsPage-HyBe5D8w.css +1 -0
  53. package/public/app/assets/SystemPage-CAMPoYqM.css +1 -0
  54. package/public/app/assets/SystemPage-CJmtJkrK.js +1 -0
  55. package/public/app/assets/TimePage-BOms_n4I.css +1 -0
  56. package/public/app/assets/TimePage-Cw44inM4.js +3 -0
  57. package/public/app/assets/formatters-ZmW_xFHw.js +1 -0
  58. package/public/app/assets/index-BheRW-1t.js +4 -0
  59. package/public/app/assets/index-YDz1ree1.css +1 -0
  60. package/public/app/assets/knowledge-DG0e0uKw.js +1 -0
  61. package/public/app/assets/liquid-garden-IMoHBwzc.png +0 -0
  62. package/public/app/assets/luckytri-home-95QKeMtc.png +0 -0
  63. package/public/app/assets/media-BzPDBm7e.js +1 -0
  64. package/public/app/index.html +27 -0
  65. package/public/guide.en.html +1053 -0
  66. package/public/guide.html +893 -0
  67. package/public/plugin-kit/v1/bridge.js +30 -0
  68. package/public/plugin-kit/v1/kit.css +14 -0
  69. package/public/tutorial.en.md +214 -0
  70. package/public/tutorial.md +214 -0
  71. package/scripts/backup.js +311 -0
  72. package/scripts/browser.js +41 -0
  73. package/scripts/launch.js +94 -0
  74. package/scripts/plugin.js +150 -0
  75. package/scripts/proxy-config.js +167 -0
  76. package/scripts/recover.js +26 -0
  77. package/scripts/runtime.js +25 -0
  78. package/scripts/service.js +47 -0
  79. package/scripts/setup.js +50 -0
  80. package/scripts/start-server.js +41 -0
  81. package/scripts/stop.js +36 -0
  82. package/server/app.js +96 -0
  83. package/server/channels/adapters.js +27 -0
  84. package/server/channels/contract.js +57 -0
  85. package/server/channels/hub.js +165 -0
  86. package/server/channels/index.js +25 -0
  87. package/server/channels/onebot/adapter.js +288 -0
  88. package/server/channels/onebot/index.js +336 -0
  89. package/server/channels/onebot/token.js +3 -0
  90. package/server/channels/qqbot/adapter.js +253 -0
  91. package/server/channels/qqbot/api.js +199 -0
  92. package/server/channels/qqbot/credentials.js +11 -0
  93. package/server/channels/qqbot/gateway.js +274 -0
  94. package/server/channels/qqbot/index.js +389 -0
  95. package/server/channels/segments.js +40 -0
  96. package/server/channels/session-key.js +153 -0
  97. package/server/channels/state.js +42 -0
  98. package/server/core/api.js +564 -0
  99. package/server/core/capabilities.js +208 -0
  100. package/server/core/context-builder.js +288 -0
  101. package/server/core/context-compactor.js +454 -0
  102. package/server/core/conversation-cues.js +325 -0
  103. package/server/core/conversation-manager.js +87 -0
  104. package/server/core/events.js +60 -0
  105. package/server/core/feedback.js +8 -0
  106. package/server/core/group-style.js +72 -0
  107. package/server/core/image-preprocessor.js +208 -0
  108. package/server/core/initiative-context.js +66 -0
  109. package/server/core/input-budget.js +32 -0
  110. package/server/core/local-demo.js +98 -0
  111. package/server/core/message-manager.js +118 -0
  112. package/server/core/message-scheduler.js +78 -0
  113. package/server/core/model-json.js +190 -0
  114. package/server/core/model-manager.js +1213 -0
  115. package/server/core/model-presets.js +900 -0
  116. package/server/core/name-call.js +29 -0
  117. package/server/core/net-guard.js +49 -0
  118. package/server/core/network.js +157 -0
  119. package/server/core/orchestrator.js +569 -0
  120. package/server/core/perceivers.js +91 -0
  121. package/server/core/person-name.js +22 -0
  122. package/server/core/persona-manager.js +243 -0
  123. package/server/core/reply-delivery.js +393 -0
  124. package/server/core/reply-target-resolver.js +50 -0
  125. package/server/core/repository.js +293 -0
  126. package/server/core/response-generator.js +50 -0
  127. package/server/core/response-validator.js +508 -0
  128. package/server/core/retired-prompts.js +51 -0
  129. package/server/core/sessions.js +109 -0
  130. package/server/core/speaker-names.js +92 -0
  131. package/server/core/topic-tracker.js +65 -0
  132. package/server/core/turn-processor.js +630 -0
  133. package/server/core/turn.js +236 -0
  134. package/server/core/vision-manager.js +633 -0
  135. package/server/http.js +18 -0
  136. package/server/i18n/decisions.js +206 -0
  137. package/server/i18n/errors.js +497 -0
  138. package/server/i18n/index.js +144 -0
  139. package/server/i18n/labels.js +653 -0
  140. package/server/i18n/models.js +111 -0
  141. package/server/index.js +102 -0
  142. package/server/knowledge/api.js +342 -0
  143. package/server/knowledge/embedding-profile.js +114 -0
  144. package/server/knowledge/index.js +13 -0
  145. package/server/knowledge/manager.js +386 -0
  146. package/server/knowledge/retrieval.js +93 -0
  147. package/server/knowledge/schema.js +154 -0
  148. package/server/knowledge/vector-index.js +90 -0
  149. package/server/mind/affect.js +265 -0
  150. package/server/mind/anticipations.js +507 -0
  151. package/server/mind/api.js +703 -0
  152. package/server/mind/attention.js +200 -0
  153. package/server/mind/bonds.js +558 -0
  154. package/server/mind/budget.js +152 -0
  155. package/server/mind/clock.js +83 -0
  156. package/server/mind/context-selection.js +61 -0
  157. package/server/mind/continuity.js +562 -0
  158. package/server/mind/conversation-origin.js +175 -0
  159. package/server/mind/day-planner.js +324 -0
  160. package/server/mind/days.js +210 -0
  161. package/server/mind/evidence-read.js +45 -0
  162. package/server/mind/evidence.js +148 -0
  163. package/server/mind/faces.js +138 -0
  164. package/server/mind/guard.js +60 -0
  165. package/server/mind/index.js +445 -0
  166. package/server/mind/initiative.js +333 -0
  167. package/server/mind/life/activities.js +21 -0
  168. package/server/mind/life/context.js +309 -0
  169. package/server/mind/life/diary.js +786 -0
  170. package/server/mind/life/growth.js +252 -0
  171. package/server/mind/life/index.js +1049 -0
  172. package/server/mind/life/presence.js +305 -0
  173. package/server/mind/meetings.js +1069 -0
  174. package/server/mind/memory-policy.js +48 -0
  175. package/server/mind/memory.js +888 -0
  176. package/server/mind/nature.js +262 -0
  177. package/server/mind/novelty.js +19 -0
  178. package/server/mind/observations.js +84 -0
  179. package/server/mind/own-day.js +324 -0
  180. package/server/mind/own-voice.js +318 -0
  181. package/server/mind/periods.js +118 -0
  182. package/server/mind/reading.js +120 -0
  183. package/server/mind/salience.js +201 -0
  184. package/server/mind/schema.js +562 -0
  185. package/server/mind/self.js +484 -0
  186. package/server/mind/senses.js +53 -0
  187. package/server/mind/thoughts.js +274 -0
  188. package/server/mind/time/activity-clock.js +196 -0
  189. package/server/mind/time/agenda.js +242 -0
  190. package/server/mind/time/api.js +200 -0
  191. package/server/mind/time/attention.js +34 -0
  192. package/server/mind/time/availability.js +69 -0
  193. package/server/mind/time/commitment-review.js +198 -0
  194. package/server/mind/time/delivery-links.js +130 -0
  195. package/server/mind/time/executor.js +512 -0
  196. package/server/mind/time/experiences.js +128 -0
  197. package/server/mind/time/games.js +562 -0
  198. package/server/mind/time/index.js +739 -0
  199. package/server/mind/time/intent.js +166 -0
  200. package/server/mind/time/kinds.js +171 -0
  201. package/server/mind/time/model-material.js +77 -0
  202. package/server/mind/time/plugin-activity.js +207 -0
  203. package/server/mind/time/presentation.js +51 -0
  204. package/server/mind/time/schedule.js +83 -0
  205. package/server/mind/time/schema.js +84 -0
  206. package/server/mind/time/search.js +383 -0
  207. package/server/mind/time/sharing.js +472 -0
  208. package/server/mind/time/task-links.js +311 -0
  209. package/server/mind/time/tasks.js +587 -0
  210. package/server/mind/time/works.js +432 -0
  211. package/server/mind/traits.js +191 -0
  212. package/server/mind/util.js +110 -0
  213. package/server/mind/view.js +273 -0
  214. package/server/paths.js +102 -0
  215. package/server/plugins/api.js +204 -0
  216. package/server/plugins/host.js +967 -0
  217. package/server/plugins/install.js +295 -0
  218. package/server/plugins/manifest.js +201 -0
  219. package/server/plugins/permissions.js +42 -0
  220. package/server/plugins/runner/guard.mjs +36 -0
  221. package/server/plugins/runner/worker.mjs +250 -0
  222. package/server/plugins/schema.js +91 -0
  223. package/server/plugins/zip.js +112 -0
  224. package/server/startup-lock.js +118 -0
  225. package/server/storage/archive.js +241 -0
  226. package/server/storage/backup-cleanup-worker.js +8 -0
  227. package/server/storage/backup-cleanup.js +414 -0
  228. package/server/storage/backup-lock.js +76 -0
  229. package/server/storage/backup-scheduler.js +131 -0
  230. package/server/storage/store.js +199 -0
  231. package/server/studio/display-names.js +138 -0
  232. package/server/studio/management.js +102 -0
  233. package/server/studio/readiness.js +190 -0
  234. package/server/studio/settings.js +236 -0
  235. package/server/version.js +10 -0
@@ -0,0 +1,888 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ parseSessionKey,
4
+ scopedSessionAliases,
5
+ } from "../channels/session-key.js";
6
+ import { localClock } from "../core/conversation-cues.js";
7
+ import { lexicalTerms } from "../knowledge/retrieval.js";
8
+ import { indexMemory } from "../knowledge/schema.js";
9
+ import {
10
+ applySpeakerNames,
11
+ readableName,
12
+ speakerNames,
13
+ } from "../core/speaker-names.js";
14
+ import { agoLabel } from "./clock.js";
15
+ import { hasCredential, secretRequest } from "./guard.js";
16
+ import { MEMORY_IDLE_DAYS, grounded } from "./salience.js";
17
+ import { DAY, clamp, similar, text } from "./util.js";
18
+ import { recallIntent } from "./continuity.js";
19
+ import { consolidationDecision } from "./memory-policy.js";
20
+ import { requestedName } from "../core/person-name.js";
21
+
22
+ const RECALL_CONFIDENCE = 0.5;
23
+ const RECALL_LIMIT = 12;
24
+ const RECALL_TERMS = 64;
25
+ const FTS_CANDIDATES = 200;
26
+ const BLOCK_USERS = 40;
27
+ // One stretch of talk adds at most this many views to who she is.
28
+ const MEMORY_VIEWS_PER_BLOCK = 2;
29
+ const SENSITIVE = /密码|验证码|密钥|身份证|银行卡|api.?key|token/i;
30
+ const DISCRETIONS = new Set(["open", "private", "secret"]);
31
+
32
+ // A request to keep something quiet stays with that person for the rest of
33
+ // this stretch. Other people's messages in between do not wash it out, and
34
+ // it does not make anyone else's words a secret.
35
+ function askedSecret(block, subject, beforeSeq) {
36
+ if (!subject) return false;
37
+ return block.some(
38
+ (m) =>
39
+ m.role === "user" &&
40
+ String(m.userId) === String(subject) &&
41
+ m.seq <= beforeSeq &&
42
+ secretRequest(m.text),
43
+ );
44
+ }
45
+
46
+ // Words from the newest messages first, so a long batch still searches for
47
+ // what was just said.
48
+ function recallQuery(rows) {
49
+ const terms = [];
50
+ const seen = new Set();
51
+ for (const row of [...rows].reverse()) {
52
+ if (row.role === "assistant") continue;
53
+ for (const term of lexicalTerms(row.text || ""))
54
+ if (!seen.has(term)) {
55
+ seen.add(term);
56
+ terms.push(term);
57
+ }
58
+ }
59
+ return terms
60
+ .slice(0, RECALL_TERMS)
61
+ .map((term) => `"${term.replace(/"/g, "")}"`)
62
+ .filter((term) => term.length > 2)
63
+ .join(" OR ");
64
+ }
65
+ const marks = (list) => list.map(() => "?").join(",");
66
+
67
+ export function isPrivateSession(session) {
68
+ const value = String(session || "");
69
+ if (value.startsWith("__private__")) return true;
70
+ try {
71
+ return parseSessionKey(value).kind === "private";
72
+ } catch {
73
+ return value.startsWith("private");
74
+ }
75
+ }
76
+
77
+ // One memory for all of her conversations. Where a fact came from travels
78
+ // with it, and so does how freely she may repeat it.
79
+ export class MemoryManager {
80
+ constructor(repo, models, mind) {
81
+ this.repo = repo;
82
+ this.models = models;
83
+ this.mind = mind;
84
+ this.busy = new Set();
85
+ this.lastAttempt = new Map();
86
+ }
87
+ scopes(session) {
88
+ const scopes = new Set([session, "__shared__"]);
89
+ try {
90
+ for (const id of scopedSessionAliases(this.repo.db, session))
91
+ scopes.add(id);
92
+ const parsed = parseSessionKey(session);
93
+ if (parsed.kind === "private")
94
+ scopes.add(`__private__:${parsed.nativeId}`);
95
+ } catch {
96
+ /* keep session + shared */
97
+ }
98
+ return [...scopes];
99
+ }
100
+ retrieve(
101
+ session,
102
+ rows,
103
+ cutoff = Date.now(),
104
+ { people = [], touch = true, forSpeech = false } = {},
105
+ ) {
106
+ const db = this.repo.db;
107
+ const here = new Set(this.scopes(session));
108
+ here.delete("__shared__");
109
+ const speakers = new Set(
110
+ rows
111
+ .filter((r) => r.role !== "assistant")
112
+ .map((r) => String(r.userId || ""))
113
+ .filter(Boolean),
114
+ );
115
+ const present = new Set(people.map(String));
116
+ // Two people cannot be added together, and her own line does not count
117
+ // as the world talking about what she remembers.
118
+ const saidBy = new Map();
119
+ for (const row of rows) {
120
+ if (row.role === "assistant") continue;
121
+ const id = String(row.userId || "");
122
+ if (!id) continue;
123
+ const terms = saidBy.get(id) || new Set();
124
+ for (const term of lexicalTerms(row.text || "")) terms.add(term);
125
+ saidBy.set(id, terms);
126
+ }
127
+ const oneSpeakerOverlap = (content) => {
128
+ const terms = lexicalTerms(content);
129
+ let best = 0;
130
+ for (const said of saidBy.values()) {
131
+ let count = 0;
132
+ for (const term of terms) if (said.has(term)) count++;
133
+ if (count > best) best = count;
134
+ }
135
+ return best;
136
+ };
137
+ const fts = new Set();
138
+ const match = recallQuery(rows);
139
+ if (match)
140
+ try {
141
+ for (const hit of db
142
+ .prepare(
143
+ "SELECT memory_id FROM core_memory_fts WHERE tokens MATCH ? ORDER BY rank LIMIT ?",
144
+ )
145
+ .all(match, FTS_CANDIDATES))
146
+ fts.add(hit.memory_id);
147
+ } catch {
148
+ /* MATCH syntax */
149
+ }
150
+ // Only what could matter is read: words in common, people here, or what
151
+ // was locked to stay in mind.
152
+ const ids = [...fts];
153
+ const subjects = [...new Set([...speakers, ...present])].filter(Boolean);
154
+ const candidates = db
155
+ .prepare(
156
+ `SELECT * FROM core_memories WHERE status='confirmed' AND created<=? AND (expires IS NULL OR expires>?) AND COALESCE(confidence,1)>=? AND (locked=1${ids.length ? ` OR id IN (${marks(ids)})` : ""}${subjects.length ? ` OR subject IN (${marks(subjects)})` : ""})`,
157
+ )
158
+ .all(cutoff, cutoff, RECALL_CONFIDENCE, ...ids, ...subjects);
159
+ const lived = this.mind?.days.lived(cutoff);
160
+ const scored = [];
161
+ for (const m of candidates) {
162
+ const local = here.has(m.session_id) || m.session_id === "__shared__";
163
+ if (!this.permission(m, session, cutoff).recall) continue;
164
+ const overlap = oneSpeakerOverlap(m.content);
165
+ const relevance = overlap + (fts.has(m.id) ? 2 : 0);
166
+ const about = speakers.has(m.subject)
167
+ ? 3
168
+ : present.has(m.subject)
169
+ ? 1.5
170
+ : 0;
171
+ if (!relevance && !about && !m.locked) continue;
172
+ if (!local && !relevance && about < 3) continue;
173
+ // Something long untouched comes back only when it is really being
174
+ // talked about, or its person is speaking.
175
+ const idle = lived
176
+ ? lived.since(Math.max(m.created || 0, m.last_access || 0))
177
+ : 0;
178
+ const strong =
179
+ overlap >= 2 || speakers.has(String(m.subject)) || !!m.locked;
180
+ if (!strong && Number(m.importance || 0) < 0.6 && idle > MEMORY_IDLE_DAYS)
181
+ continue;
182
+ scored.push({
183
+ m,
184
+ local,
185
+ strong,
186
+ score:
187
+ relevance +
188
+ about +
189
+ (local ? 1 : 0) +
190
+ (m.locked ? 2 : 0) +
191
+ Number(m.importance || 0) +
192
+ (Number(m.confidence ?? 1) - 0.5) +
193
+ 0.5 * 0.5 ** (idle / 30),
194
+ why: fts.has(m.id) ? "fts" : about ? "subject" : "related",
195
+ });
196
+ }
197
+ scored.sort((a, b) => b.score - a.score || b.m.created - a.m.created);
198
+ const selected = [];
199
+ if (recallIntent(rows)) {
200
+ // An explicit question about our past needs a range of places and
201
+ // aspects, not twelve local events with almost identical scores.
202
+ const elsewhere = scored.filter(
203
+ (item) =>
204
+ !item.local &&
205
+ item.m.discretion === "open" &&
206
+ speakers.has(String(item.m.subject)),
207
+ );
208
+ const covered = new Set();
209
+ for (const item of elsewhere) {
210
+ const key = `${item.m.subject}:${item.m.session_id}`;
211
+ if (covered.has(key)) continue;
212
+ selected.push(item);
213
+ covered.add(key);
214
+ if (selected.length === 4) break;
215
+ }
216
+ const kinds = new Set(selected.map((item) => item.m.type));
217
+ for (const item of elsewhere) {
218
+ if (selected.length >= 4) break;
219
+ if (selected.includes(item) || kinds.has(item.m.type)) continue;
220
+ selected.push(item);
221
+ kinds.add(item.m.type);
222
+ }
223
+ }
224
+ selected.push(
225
+ ...scored
226
+ .filter((item) => !selected.includes(item))
227
+ .slice(0, RECALL_LIMIT - selected.length),
228
+ );
229
+ if (touch) {
230
+ const access = db.prepare(
231
+ "UPDATE core_memories SET last_access=? WHERE id=?",
232
+ );
233
+ // A weak overlap does not count as remembering. Only a real cue, the
234
+ // person speaking, or a locked memory starts the quiet stretch again.
235
+ for (const { m, strong } of selected)
236
+ if (strong) access.run(cutoff, m.id);
237
+ }
238
+ return selected.map(({ m, local, why }) => {
239
+ const age = cutoff - Number(m.created || cutoff);
240
+ return {
241
+ id: m.id,
242
+ subject: m.subject,
243
+ content:
244
+ forSpeech && !this.permission(m, session, cutoff).disclose
245
+ ? "(关于此人的一条受限记忆;没有在这里复述具体内容的授权)"
246
+ : m.content,
247
+ type: m.type,
248
+ confidence: m.confidence,
249
+ importance: m.importance,
250
+ whySelected: why,
251
+ source: local
252
+ ? "这里"
253
+ : isPrivateSession(m.session_id)
254
+ ? "私聊里"
255
+ : "别的群里",
256
+ // Old news may no longer be true; she should know how old it is.
257
+ ...(age >= 7 * DAY ? { when: `${agoLabel(age)}知道的` } : {}),
258
+ ...(m.discretion === "private" && !local
259
+ ? { discretion: "私下知道的,不要当众说出口,也不要说出从哪听来" }
260
+ : {}),
261
+ };
262
+ });
263
+ }
264
+ secretsOutside(session) {
265
+ return this.#outside(session, "secret");
266
+ }
267
+ permission(memory, session, now = Date.now()) {
268
+ const local = this.scopes(session).includes(memory.session_id);
269
+ const saved = this.repo.db
270
+ .prepare(
271
+ "SELECT recall,disclose,expires FROM mind_memory_permissions WHERE memory_id=? AND session_id=?",
272
+ )
273
+ .get(memory.id, session);
274
+ if (saved && (!saved.expires || saved.expires > now))
275
+ return { recall: !!saved.recall, disclose: !!saved.disclose };
276
+ return {
277
+ recall: memory.discretion !== "secret" || local,
278
+ disclose: memory.discretion === "open" || local,
279
+ };
280
+ }
281
+ permissions(id) {
282
+ if (!this.repo.db.prepare("SELECT 1 FROM core_memories WHERE id=?").get(id))
283
+ throw Error("记忆不存在");
284
+ return this.repo.db
285
+ .prepare(
286
+ "SELECT session_id,recall,disclose,expires,updated FROM mind_memory_permissions WHERE memory_id=?",
287
+ )
288
+ .all(id);
289
+ }
290
+ setPermission(id, { session, recall, disclose, expires = null }) {
291
+ if (
292
+ !this.repo.db
293
+ .prepare("SELECT 1 FROM core_memories WHERE id=? AND status!='deleted'")
294
+ .get(id)
295
+ )
296
+ throw Error("记忆不存在");
297
+ if (!this.repo.db.prepare("SELECT 1 FROM sessions WHERE id=?").get(session))
298
+ throw Error("会话不存在");
299
+ if (
300
+ typeof recall !== "boolean" ||
301
+ typeof disclose !== "boolean" ||
302
+ (disclose && !recall)
303
+ )
304
+ throw Error("允许复述时也必须允许想起");
305
+ if (
306
+ expires !== null &&
307
+ (!Number.isSafeInteger(expires) || expires <= Date.now())
308
+ )
309
+ throw Error("授权到期时间无效");
310
+ const value = { recall, disclose, expires };
311
+ const db = this.repo.db,
312
+ now = Date.now();
313
+ db.exec("SAVEPOINT memory_permission");
314
+ try {
315
+ db.prepare(
316
+ "INSERT INTO mind_memory_permissions VALUES (?,?,?,?,?,?) ON CONFLICT(memory_id,session_id) DO UPDATE SET recall=excluded.recall,disclose=excluded.disclose,expires=excluded.expires,updated=excluded.updated",
317
+ ).run(id, session, +recall, +disclose, expires, now);
318
+ db.prepare(
319
+ "INSERT INTO mind_permission_events(memory_id,session_id,created,value) VALUES (?,?,?,?)",
320
+ ).run(id, session, now, JSON.stringify(value));
321
+ db.exec("RELEASE memory_permission");
322
+ } catch (error) {
323
+ db.exec("ROLLBACK TO memory_permission");
324
+ db.exec("RELEASE memory_permission");
325
+ throw error;
326
+ }
327
+ this.repo.store.revision++;
328
+ return value;
329
+ }
330
+ // Private facts may be recalled, with a warning. They still must not leave
331
+ // in the words she actually sends.
332
+ privateOutside(session) {
333
+ return this.#outside(session, "private");
334
+ }
335
+ #outside(session, discretion) {
336
+ const here = new Set(this.scopes(session));
337
+ return this.repo.db
338
+ .prepare(
339
+ "SELECT id,session_id,subject,content FROM core_memories WHERE discretion=? AND status='confirmed' ORDER BY updated DESC LIMIT 200",
340
+ )
341
+ .all(discretion)
342
+ .filter(
343
+ (m) =>
344
+ !here.has(m.session_id) &&
345
+ !this.permission({ ...m, discretion }, session).disclose,
346
+ );
347
+ }
348
+ update(id, patch) {
349
+ const db = this.repo.db,
350
+ old = db.prepare("SELECT * FROM core_memories WHERE id=?").get(id);
351
+ if (!old) throw Error("记忆不存在");
352
+ const m = { ...old, ...patch };
353
+ if (patch.discretion !== undefined && !DISCRETIONS.has(patch.discretion))
354
+ throw Error("分寸标签无效");
355
+ db.exec("SAVEPOINT memory_update");
356
+ try {
357
+ db.prepare(
358
+ "INSERT INTO core_memory_versions(memory_id,time,value) VALUES (?,?,?)",
359
+ ).run(id, Date.now(), JSON.stringify(old));
360
+ db.prepare(
361
+ "UPDATE core_memories SET content=?,status=?,locked=?,confidence=?,importance=?,expires=?,sources=?,discretion=?,updated=?,version=version+1 WHERE id=?",
362
+ ).run(
363
+ m.content,
364
+ m.status,
365
+ +m.locked,
366
+ m.confidence,
367
+ m.importance,
368
+ m.expires || null,
369
+ m.sources,
370
+ m.discretion || "open",
371
+ Date.now(),
372
+ id,
373
+ );
374
+ indexMemory(db, id, m.content);
375
+ db.exec("RELEASE memory_update");
376
+ } catch (e) {
377
+ db.exec("ROLLBACK TO memory_update");
378
+ db.exec("RELEASE memory_update");
379
+ throw e;
380
+ }
381
+ this.repo.store.revision++;
382
+ }
383
+ // Withdrawal leaves a tombstone, so a later consolidation cannot bring the
384
+ // same belief back.
385
+ revoke(id, reason = "") {
386
+ const row = this.repo.db
387
+ .prepare("SELECT * FROM core_memories WHERE id=?")
388
+ .get(id);
389
+ if (!row) throw Error("记忆不存在");
390
+ if (row.locked) throw Error("记忆已锁定,请先解锁");
391
+ this.update(id, { status: "deleted" });
392
+ this.repo.db
393
+ .prepare(
394
+ "INSERT INTO mind_revocations(id,created,target_kind,target_id,content,reason) VALUES (?,?,?,?,?,?)",
395
+ )
396
+ .run(
397
+ randomUUID(),
398
+ Date.now(),
399
+ "memory",
400
+ id,
401
+ `${row.subject}\n${row.content}`,
402
+ text(reason, 200),
403
+ );
404
+ }
405
+ revokedFor(subject) {
406
+ return this.repo.db
407
+ .prepare(
408
+ "SELECT content FROM mind_revocations WHERE target_kind='memory' AND content LIKE ?",
409
+ )
410
+ .all(`${subject}\n%`)
411
+ .map((row) => row.content.slice(String(subject).length + 1));
412
+ }
413
+ deleteBatch(session, ids = [], { all = false } = {}) {
414
+ const db = this.repo.db;
415
+ if (
416
+ typeof session !== "string" ||
417
+ !session ||
418
+ !db.prepare("SELECT id FROM sessions WHERE id=?").get(session)
419
+ )
420
+ throw Error("会话不存在");
421
+ if (!all && !Array.isArray(ids)) throw Error("请选择要删除的记忆");
422
+ const uniqueIds = [...new Set((Array.isArray(ids) ? ids : []).map(String))];
423
+ if (!all && (!uniqueIds.length || uniqueIds.length > 500))
424
+ throw Error("请选择 1–500 条记忆");
425
+ if (all && uniqueIds.length) throw Error("全选删除不需要传入记忆 ID");
426
+ const rows = all
427
+ ? db
428
+ .prepare(
429
+ "SELECT id,session_id,locked FROM core_memories WHERE session_id=? AND status!='deleted'",
430
+ )
431
+ .all(session)
432
+ : uniqueIds.map((id) =>
433
+ db
434
+ .prepare(
435
+ "SELECT id,session_id,locked FROM core_memories WHERE id=?",
436
+ )
437
+ .get(id),
438
+ );
439
+ if (!all && (rows.some((row) => !row) || rows.length !== uniqueIds.length))
440
+ throw Error("只能删除当前会话中存在的记忆");
441
+ if (rows.some((row) => row.session_id !== session))
442
+ throw Error("只能删除当前会话的记忆,共享或继承记忆请单独管理");
443
+ if (rows.some((row) => row.locked))
444
+ throw Error("包含已锁定记忆,请先解锁后再删除");
445
+ if (!rows.length) return { deleted: 0 };
446
+ const placeholders = rows.map(() => "?").join(",");
447
+ const rowIds = rows.map((row) => row.id);
448
+ db.exec("BEGIN IMMEDIATE");
449
+ try {
450
+ db.prepare(
451
+ `DELETE FROM core_memory_versions WHERE memory_id IN (${placeholders})`,
452
+ ).run(...rowIds);
453
+ db.prepare(
454
+ `DELETE FROM core_memory_fts WHERE memory_id IN (${placeholders})`,
455
+ ).run(...rowIds);
456
+ db.prepare(`DELETE FROM core_memories WHERE id IN (${placeholders})`).run(
457
+ ...rowIds,
458
+ );
459
+ db.exec("COMMIT");
460
+ } catch (e) {
461
+ db.exec("ROLLBACK");
462
+ throw e;
463
+ }
464
+ this.repo.store.revision++;
465
+ return { deleted: rows.length };
466
+ }
467
+ insert({
468
+ session,
469
+ subject,
470
+ content,
471
+ type = "event",
472
+ confidence = 0.8,
473
+ importance = 0.5,
474
+ sources = [],
475
+ discretion = "open",
476
+ locked = false,
477
+ time = Date.now(),
478
+ }) {
479
+ const db = this.repo.db;
480
+ if (
481
+ db
482
+ .prepare(
483
+ "SELECT 1 FROM core_memories WHERE subject=? AND content=? AND status!='deleted'",
484
+ )
485
+ .get(subject, content)
486
+ )
487
+ return null;
488
+ if (this.revokedFor(subject).some((old) => similar(old, content, 0.75)))
489
+ return null;
490
+ const id = randomUUID();
491
+ db.prepare(
492
+ "INSERT INTO core_memories(id,session_id,subject,content,type,confidence,importance,status,locked,sources,created,updated,discretion) VALUES (?,?,?,?,?,?,?,'confirmed',?,?,?,?,?)",
493
+ ).run(
494
+ id,
495
+ session,
496
+ subject,
497
+ content,
498
+ type,
499
+ clamp(confidence),
500
+ clamp(importance),
501
+ +locked,
502
+ JSON.stringify(sources),
503
+ time,
504
+ time,
505
+ DISCRETIONS.has(discretion) ? discretion : "open",
506
+ );
507
+ indexMemory(db, id, content);
508
+ return id;
509
+ }
510
+ // "记住,我……" is a friend asking to be remembered, not a draft for review.
511
+ remember(message) {
512
+ const value = String(message.text || "");
513
+ if (/不要记|别记|不许记/.test(value)) return null;
514
+ const match = value.match(
515
+ /(?:请)?(?:记住|记一下)[,,::\s]*(我[^\n]{2,200})[。!!]?$/u,
516
+ );
517
+ const name = requestedName(value);
518
+ if ((!match && !name) || SENSITIVE.test(value) || hasCredential(value))
519
+ return null;
520
+ const said = name
521
+ ? `我希望被称呼为${name}`
522
+ : match[1].replace(/[。!!]+$/, "").trim();
523
+ const content = /我/.test(said.slice(1))
524
+ ? `原话:${said}`
525
+ : said.replace(/^我/, "");
526
+ const privateChat = message.kind === "private";
527
+ const quiet =
528
+ secretRequest(value) ||
529
+ this.#recentSecret(message.sessionId, message.userId, message.seq);
530
+ const id = this.insert({
531
+ session: message.sessionId,
532
+ subject: String(message.userId),
533
+ content,
534
+ type: "preference",
535
+ confidence: 0.95,
536
+ importance: 0.7,
537
+ sources: [
538
+ {
539
+ id: message.seq,
540
+ text: value.slice(-300),
541
+ speaker: message.userId,
542
+ time: message.time,
543
+ certainty: "self_report",
544
+ },
545
+ ],
546
+ discretion: quiet ? "secret" : privateChat ? "private" : "open",
547
+ time: Number(message.time) || Date.now(),
548
+ });
549
+ if (id) this.repo.store.revision++;
550
+ return id;
551
+ }
552
+ // "别告诉别人" then, after more of their own messages, "记住,我……" is still
553
+ // a secret until this stretch is consolidated. Only words she could have
554
+ // heard count. A request from the previous stretch does not carry over.
555
+ #recentSecret(session, userId, seq) {
556
+ if (!session || seq == null) return false;
557
+ const rows = this.repo.db
558
+ .prepare(
559
+ `SELECT payload FROM core_events
560
+ WHERE session_id=? AND role='user' AND seq<?
561
+ AND seq>COALESCE((SELECT seq FROM core_cursors WHERE session_id=?),0)
562
+ AND json_extract(payload,'$.userId')=?
563
+ AND seq NOT IN (SELECT seq FROM mind_unlived)
564
+ ORDER BY seq DESC LIMIT 40`,
565
+ )
566
+ .all(session, seq, session, String(userId));
567
+ return rows.some((row) => {
568
+ let payload = {};
569
+ try {
570
+ payload = JSON.parse(row.payload);
571
+ } catch {
572
+ payload = {};
573
+ }
574
+ return secretRequest(payload.text);
575
+ });
576
+ }
577
+ pending(session) {
578
+ return this.repo.db
579
+ .prepare(
580
+ "SELECT COUNT(*) n FROM core_events WHERE session_id=? AND role='user' AND seq>COALESCE((SELECT seq FROM core_cursors WHERE session_id=?),0) AND seq NOT IN (SELECT seq FROM mind_unlived) AND COALESCE(json_extract(payload,'$.simulated'),0)=0",
581
+ )
582
+ .get(session, session).n;
583
+ }
584
+ due(session, now = Date.now()) {
585
+ return this.mind
586
+ ? consolidationDecision(this.mind, session, now)
587
+ : { due: this.pending(session) >= BLOCK_USERS };
588
+ }
589
+ // What she already remembers about the people in this stretch, so the
590
+ // same fact is not written twice and a changed one can replace the old.
591
+ known(session, block) {
592
+ const here = new Set(this.scopes(session));
593
+ const known = [];
594
+ const refs = new Map();
595
+ const read = this.repo.db.prepare(
596
+ "SELECT id,subject,content,session_id,discretion FROM core_memories WHERE subject=? AND status='confirmed' ORDER BY importance DESC, updated DESC LIMIT 5",
597
+ );
598
+ const subjects = new Set(
599
+ block.filter((m) => m.role === "user").map((m) => String(m.userId)),
600
+ );
601
+ for (const subject of subjects)
602
+ for (const k of read.all(subject)) {
603
+ if (known.length >= 20) return { known, refs };
604
+ if (k.discretion !== "open" && !here.has(k.session_id)) continue;
605
+ const ref = `k${known.length + 1}`;
606
+ refs.set(ref, k);
607
+ known.push({ ref, subject: k.subject, content: text(k.content, 80) });
608
+ }
609
+ return { known, refs };
610
+ }
611
+ // A newer fact replaces an older one; the old stays visible with its
612
+ // versions but is no longer recalled.
613
+ supersede(id, by) {
614
+ const old = this.repo.db
615
+ .prepare("SELECT * FROM core_memories WHERE id=?")
616
+ .get(id);
617
+ if (!old || old.locked || old.status !== "confirmed" || id === by)
618
+ return false;
619
+ this.update(id, { status: "superseded" });
620
+ this.repo.db
621
+ .prepare("UPDATE core_memories SET superseded_by=? WHERE id=?")
622
+ .run(by, id);
623
+ return true;
624
+ }
625
+ // Things said in this stretch that are still to come. Someone's plans and
626
+ // dates must come from their own words; a promise must be her own.
627
+ anticipate(list, { block, session, names, privateChat, now }) {
628
+ if (!this.mind?.anticipations) return 0;
629
+ let added = 0;
630
+ for (const a of (Array.isArray(list) ? list : []).slice(0, 8)) {
631
+ if (!a || typeof a.content !== "string" || !a.content.trim()) continue;
632
+ const kind = ["event", "promise", "date"].includes(a.kind)
633
+ ? a.kind
634
+ : null;
635
+ const cited = block.filter((m) =>
636
+ (Array.isArray(a.sources) ? a.sources : []).map(Number).includes(m.seq),
637
+ );
638
+ if (!kind || !cited.length || cited.some((m) => m.artifact)) continue;
639
+ let subject = a.subject == null ? null : String(a.subject);
640
+ if (kind === "promise") {
641
+ if (!cited.every((m) => m.role === "assistant")) continue;
642
+ if (
643
+ subject &&
644
+ !block.some((m) => m.role === "user" && String(m.userId) === subject)
645
+ )
646
+ subject = null;
647
+ } else if (
648
+ !subject ||
649
+ !cited.some((m) => m.role === "user" && String(m.userId) === subject)
650
+ )
651
+ continue;
652
+ const first = Math.min(...cited.map((m) => m.seq));
653
+ const last = Math.max(...cited.map((m) => m.seq));
654
+ const secret =
655
+ block.some(
656
+ (m) =>
657
+ m.role === "user" &&
658
+ m.seq <= last &&
659
+ m.seq >= first - 6 &&
660
+ secretRequest(m.text),
661
+ ) || askedSecret(block, subject, last);
662
+ if (
663
+ !grounded(
664
+ a.content,
665
+ cited.map((m) => m.text || ""),
666
+ )
667
+ )
668
+ continue;
669
+ const result = this.mind.anticipations.add({
670
+ kind,
671
+ subject,
672
+ session,
673
+ content: applySpeakerNames(a.content.trim(), names),
674
+ due: a.due,
675
+ recurrence: a.recurrence,
676
+ discretion: secret ? "secret" : privateChat ? "private" : "open",
677
+ sources: cited.map((m) => m.seq),
678
+ origin: "memory",
679
+ time: now,
680
+ });
681
+ if (result.id) added++;
682
+ }
683
+ return added;
684
+ }
685
+ async consolidate(
686
+ session,
687
+ profile,
688
+ prompt,
689
+ trace,
690
+ {
691
+ force = false,
692
+ simulated = false,
693
+ models = this.models,
694
+ now = Date.now(),
695
+ } = {},
696
+ ) {
697
+ if (this.busy.has(session)) return;
698
+ if (!force && now - (this.lastAttempt.get(session) || 0) < 60000) return;
699
+ this.busy.add(session);
700
+ try {
701
+ const db = this.repo.db;
702
+ const cursor =
703
+ db
704
+ .prepare("SELECT seq FROM core_cursors WHERE session_id=?")
705
+ .get(session)?.seq || 0;
706
+ const aside = new Set(
707
+ db
708
+ .prepare("SELECT seq FROM mind_unlived")
709
+ .all()
710
+ .map((row) => row.seq),
711
+ );
712
+ const rows = this.repo
713
+ .eventsAfter(session, cursor, { simulated, limit: 200 })
714
+ .filter(
715
+ (m) =>
716
+ m.time <= now &&
717
+ !aside.has(m.seq) &&
718
+ !/^\[(图片|表情|媒体)\]+$/.test(m.text || ""),
719
+ );
720
+ const users = rows.filter((m) => m.role === "user");
721
+ if (!users.length || (!force && !this.due(session, now).due)) return;
722
+ this.lastAttempt.set(session, now);
723
+ // Her answers to the block's last messages belong with that block.
724
+ const next = users[BLOCK_USERS]?.seq ?? Number.MAX_SAFE_INTEGER;
725
+ const block = rows.filter((m) => m.seq < next);
726
+ const last = block.at(-1).seq;
727
+ const names = speakerNames(db, [session]);
728
+ const self = this.mind?.nature.current().name || "self";
729
+ const zone = this.mind?.timeZone() || "Asia/Shanghai";
730
+ const { known, refs } = this.known(session, block);
731
+ const value = await models.call(
732
+ profile,
733
+ "memory",
734
+ prompt +
735
+ "\nartifact 标记的是作品正文,fiction 的人物/剧情和 reference 的游戏资料情节不能作为现实人物事实;不要把正文中的约定提取成她现实接受的承诺。读者讨论作品时保留作品语境。",
736
+ {
737
+ sessionId: session,
738
+ self,
739
+ privateChat: isPrivateSession(session),
740
+ ...(known.length ? { known } : {}),
741
+ messages: block.map((m) => ({
742
+ id: m.seq,
743
+ userId: m.role === "assistant" ? "self" : m.userId,
744
+ name:
745
+ m.role === "assistant"
746
+ ? self
747
+ : readableName(m.name, m.userId) ||
748
+ names.get(String(m.userId)) ||
749
+ m.userId,
750
+ role: m.role,
751
+ ...(m.artifact ? { artifact: m.artifact } : {}),
752
+ text: String(m.text || "").slice(0, 600),
753
+ // A readable local time lets "明天" become a date.
754
+ localTime: localClock(m.time, zone).local,
755
+ })),
756
+ },
757
+ trace,
758
+ );
759
+ if (typeof value?.summary !== "string" || !Array.isArray(value.facts))
760
+ throw Error("记忆整理格式无效");
761
+ db.exec("BEGIN IMMEDIATE");
762
+ try {
763
+ const said = block.map((m) => m.text || "");
764
+ const raw = value.summary.trim();
765
+ const summary =
766
+ raw && grounded(raw, said) ? applySpeakerNames(raw, names) : "";
767
+ const privateChat = isPrivateSession(session);
768
+ const keptFacts = [];
769
+ for (const f of value.facts.slice(0, 30)) {
770
+ if (
771
+ typeof f?.content !== "string" ||
772
+ !f.content.trim() ||
773
+ f.content.length > 2000 ||
774
+ !Array.isArray(f.sources) ||
775
+ !f.sources.length ||
776
+ !Number.isFinite(f.confidence) ||
777
+ !Number.isFinite(f.importance) ||
778
+ ["joke", "hearsay"].includes(f.certainty) ||
779
+ SENSITIVE.test(f.content) ||
780
+ hasCredential(f.content)
781
+ )
782
+ continue;
783
+ const sources = block.filter((m) => f.sources.includes(m.seq));
784
+ if (
785
+ sources.length !== f.sources.length ||
786
+ !sources.every(
787
+ (m) =>
788
+ m.role === "user" && String(m.userId) === String(f.subject),
789
+ )
790
+ )
791
+ continue;
792
+ if (
793
+ !grounded(
794
+ f.content,
795
+ sources.map((m) => m.text || ""),
796
+ )
797
+ )
798
+ continue;
799
+ const secret =
800
+ f.discretion === "secret" ||
801
+ askedSecret(block, f.subject, Math.max(...f.sources));
802
+ const content = applySpeakerNames(f.content.trim(), names);
803
+ const discretion = secret
804
+ ? "secret"
805
+ : privateChat || f.discretion === "private"
806
+ ? "private"
807
+ : "open";
808
+ const id = this.insert({
809
+ session,
810
+ subject: String(f.subject),
811
+ content,
812
+ type: String(f.type || "event").slice(0, 20),
813
+ confidence:
814
+ f.certainty === "inferred"
815
+ ? Math.min(0.6, f.confidence)
816
+ : f.confidence,
817
+ importance: f.importance,
818
+ sources: sources.map((m) => ({
819
+ id: m.seq,
820
+ text: m.text,
821
+ speaker: m.userId,
822
+ time: m.time,
823
+ certainty: f.certainty || "unknown",
824
+ })),
825
+ discretion,
826
+ time: now,
827
+ });
828
+ if (!id) continue;
829
+ keptFacts.push({
830
+ subject: String(f.subject),
831
+ content,
832
+ discretion,
833
+ });
834
+ for (const ref of Array.isArray(f.supersedes) ? f.supersedes : []) {
835
+ const old = refs.get(String(ref));
836
+ if (old && String(old.subject) === String(f.subject))
837
+ this.supersede(old.id, id);
838
+ }
839
+ }
840
+ db.prepare(
841
+ "INSERT OR IGNORE INTO core_stages VALUES (?,?,?,?,?,?)",
842
+ ).run(
843
+ randomUUID(),
844
+ session,
845
+ block[0].seq,
846
+ last,
847
+ now,
848
+ JSON.stringify({ summary, facts: keptFacts }),
849
+ );
850
+ this.anticipate(value.anticipations, {
851
+ block,
852
+ session,
853
+ names,
854
+ privateChat,
855
+ now,
856
+ });
857
+ // What she herself said and meant becomes part of who she is. Her
858
+ // opinions about topics are not: one stretch of talk can add only a
859
+ // couple of views, and the rest stays in the summary.
860
+ const valid = new Set(
861
+ block.filter((m) => m.role === "assistant").map((m) => `m:${m.seq}`),
862
+ );
863
+ let views = 0;
864
+ for (const note of (Array.isArray(value.self) ? value.self : []).slice(
865
+ 0,
866
+ 6,
867
+ )) {
868
+ if (note?.kind === "view" && ++views > MEMORY_VIEWS_PER_BLOCK)
869
+ continue;
870
+ this.mind?.self.propose(
871
+ { ...note, action: "new", session },
872
+ { valid, origin: "memory", time: now },
873
+ );
874
+ }
875
+ db.prepare(
876
+ "INSERT INTO core_cursors VALUES (?,?) ON CONFLICT(session_id) DO UPDATE SET seq=excluded.seq",
877
+ ).run(session, last);
878
+ db.exec("COMMIT");
879
+ this.lastAttempt.delete(session);
880
+ } catch (e) {
881
+ db.exec("ROLLBACK");
882
+ throw e;
883
+ }
884
+ } finally {
885
+ this.busy.delete(session);
886
+ }
887
+ }
888
+ }