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,569 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Repository } from "./repository.js";
3
+ import {
4
+ ModelManager,
5
+ backupModels,
6
+ isTransientModelFailure,
7
+ storedModels,
8
+ withFallback,
9
+ } from "./model-manager.js";
10
+ import { KnowledgeManager } from "../knowledge/manager.js";
11
+ import { ConversationManager } from "./conversation-manager.js";
12
+ import { persistIncoming, messageEnvelope } from "./message-manager.js";
13
+ import { prompts } from "./persona-manager.js";
14
+ import {
15
+ ContextCompactor,
16
+ contextKeep,
17
+ DEFAULT_CONTEXT_MESSAGES,
18
+ } from "./context-compactor.js";
19
+ import { loadVisionImages } from "./vision-manager.js";
20
+ import { deliver } from "./message-scheduler.js";
21
+ import { TopicTracker } from "./topic-tracker.js";
22
+ import { invalidateSpeakerNames } from "./speaker-names.js";
23
+ import { demoReply } from "./local-demo.js";
24
+ import { Mind } from "../mind/index.js";
25
+ import { attend, interestTerms } from "../mind/attention.js";
26
+ import { crisisSignal } from "../mind/guard.js";
27
+ const TRACE_PRUNE_INTERVAL_MS = 3600000;
28
+ const BATCH_LIMIT = 30;
29
+ const BACKLOG_RECHECK_MS = 10 * 60000;
30
+ import { TurnProcessor } from "./turn-processor.js";
31
+ import { ReplyDelivery } from "./reply-delivery.js";
32
+ import { Capabilities } from "./capabilities.js";
33
+ import { Perceivers } from "./perceivers.js";
34
+
35
+ export class ChatSystem {
36
+ constructor(
37
+ store,
38
+ send,
39
+ {
40
+ models,
41
+ knowledge,
42
+ localDemo = demoReply,
43
+ fetchQuoted,
44
+ fetchImage,
45
+ loadVisionImages: loadImages = loadVisionImages,
46
+ now = Date.now,
47
+ } = {},
48
+ ) {
49
+ this.now = now;
50
+ this.store = store;
51
+ this.repo = new Repository(store);
52
+ this.repo.db
53
+ .prepare(
54
+ "UPDATE core_jobs SET status='interrupted' WHERE status IN ('pending','running')",
55
+ )
56
+ .run();
57
+ this.models = models || new ModelManager(this.repo);
58
+ this.mind = new Mind(this.repo, { models: this.models });
59
+ this.memory = this.mind.memory;
60
+ this.knowledge = knowledge || new KnowledgeManager(this.repo, this.models);
61
+ this.compactor = new ContextCompactor(this.repo);
62
+ this.topics = new TopicTracker(this.repo);
63
+ this.capabilities = new Capabilities(this.repo, { now });
64
+ this.perceivers = new Perceivers(this.repo.db);
65
+ this.send = send;
66
+ this.localDemo = localDemo;
67
+ this.fetchQuoted = fetchQuoted;
68
+ this.fetchImage = fetchImage;
69
+ this.loadVisionImages = loadImages;
70
+ this.clearEpoch = new Map();
71
+ // A model service that was out of reach for a moment should not leave
72
+ // someone who spoke to her directly without an answer until they write
73
+ // again: a few later tries, then it is left as it is.
74
+ this.retryDelays = [30000, 120000];
75
+ this.retried = new Map();
76
+ // Batches already put back once because the person kept talking.
77
+ this.settled = new Set();
78
+ this.tracePruneDue = 0;
79
+ this.backlogChecked = new Map();
80
+ this.queue = new ConversationManager((id, batch) =>
81
+ this.process(id, batch),
82
+ );
83
+ this.turnProcessor = new TurnProcessor(this);
84
+ this.replyDelivery = new ReplyDelivery(this);
85
+ }
86
+ policy(session) {
87
+ const {
88
+ topicBoost: _topicBoost,
89
+ comfortOnDistress: _comfort,
90
+ persona: _persona,
91
+ ...saved
92
+ } = this.repo.config("session:" + session, {});
93
+ const policy = {
94
+ timeZone: this.mind.timeZone(),
95
+ aggregateMs: 1200,
96
+ maxWaitMs: 4000,
97
+ contextMessages: DEFAULT_CONTEXT_MESSAGES,
98
+ maxReply: 180,
99
+ memory: true,
100
+ deepCheck: true,
101
+ selectiveVision: false,
102
+ compaction: true,
103
+ ...saved,
104
+ };
105
+ // Older saves used 0 for "fill the whole model window".
106
+ policy.contextMessages = contextKeep(policy);
107
+ return policy;
108
+ }
109
+ // What a reply was generated against. Her own inner changes, other
110
+ // sessions and background jobs do not discard a reply; configuration does.
111
+ sessionState(session) {
112
+ const settings = this.store.settings();
113
+ return JSON.stringify([
114
+ this.repo.config("session:" + session, null),
115
+ this.mind.nature.version(),
116
+ this.repo.config("prompts", null),
117
+ storedModels(this.repo).map(({ apiKey: _apiKey, ...model }) => model),
118
+ [settings.name, settings.aliases, settings.enabled, settings.demo],
119
+ this.clearEpoch.get(session) || 0,
120
+ ]);
121
+ }
122
+ // While the room was being read (waiting in the queue, describing images),
123
+ // the person she is about to answer may have kept talking. Spending a whole
124
+ // turn on the older words only to throw the answer away is waste, and it
125
+ // makes them wait for it: put the batch back, once, and answer everything
126
+ // together. Never for a crisis, and only if there is a place to put it back.
127
+ batchMovedOn(session, batch, snapshot, privateChat) {
128
+ if (!batch.length) return false;
129
+ if (batch.some((m) => m.role !== "assistant" && crisisSignal(m.text)))
130
+ return false;
131
+ const key = `${session}:${batch[0].seq}`;
132
+ if (this.settled.has(key)) return false;
133
+ const speaker = (m) => String(m.speaker ?? m.userId);
134
+ const asked = snapshot.batch.filter((m) => m.relation === "direct");
135
+ const focus = new Set(
136
+ (asked.length ? asked : snapshot.batch.slice(-1)).map(speaker),
137
+ );
138
+ const moved = this.repo
139
+ .eventsAfter(session, batch.at(-1).seq, { simulated: false })
140
+ .some(
141
+ (m) =>
142
+ m.role === "user" &&
143
+ (privateChat ||
144
+ focus.has(String(m.userId)) ||
145
+ (m.replyId &&
146
+ snapshot.batch.some((b) => b.platformId === m.replyId))),
147
+ );
148
+ if (!moved || !this.queue.retain(session, batch)) return false;
149
+ if (this.settled.size > 500) this.settled.clear();
150
+ this.settled.add(key);
151
+ return true;
152
+ }
153
+ // Ask again later for a batch that was spoken to her directly, when the model
154
+ // service was only briefly unavailable. Not for a missing balance or a
155
+ // rejected request, and not when she has already answered since.
156
+ retryAfterOutage(session, batch, error, trace) {
157
+ if (!batch.length || !isTransientModelFailure(error)) return false;
158
+ const now = this.now();
159
+ for (const [key, entry] of this.retried)
160
+ if (now - entry.at > 60 * 60000) this.retried.delete(key);
161
+ const key = `${session}:${batch[0].seq}`;
162
+ const tried = this.retried.get(key)?.count ?? 0;
163
+ if (tried >= this.retryDelays.length) return false;
164
+ this.retried.set(key, { count: tried + 1, at: now });
165
+ const delay = this.retryDelays[tried];
166
+ const last = batch.at(-1).seq;
167
+ trace.steps.push(
168
+ `模型服务暂时不可用,${Math.round(delay / 1000)} 秒后再试(第 ${tried + 1} 次)`,
169
+ );
170
+ this.queue.retryLater(
171
+ session,
172
+ batch,
173
+ delay,
174
+ () =>
175
+ this.enabled(session, { simulated: false }) &&
176
+ !this.repo
177
+ .eventsAfter(session, last, { simulated: false })
178
+ .some((m) => m.role === "assistant"),
179
+ );
180
+ return true;
181
+ }
182
+ fallbackFor(_policy, primary, trace) {
183
+ const chain = [];
184
+ for (const model of backupModels(storedModels(this.repo), primary?.id)) {
185
+ try {
186
+ const profile = this.models.profile(model.id);
187
+ if (profile && profile.id !== primary?.id) chain.push(profile);
188
+ } catch {
189
+ trace?.steps?.push("有一个备用模型档案不存在,已跳过");
190
+ }
191
+ }
192
+ return chain;
193
+ }
194
+ receive(m) {
195
+ const event = persistIncoming(this.repo, m);
196
+ if (event) {
197
+ this.repo.db
198
+ .prepare("INSERT INTO core_jobs VALUES (?,?,'pending',NULL,?)")
199
+ .run(event.seq, event.sessionId, this.now());
200
+ if (
201
+ !m.simulated &&
202
+ this.enabled(event.sessionId, { simulated: false }) &&
203
+ this.policy(event.sessionId).memory &&
204
+ this.store.settings().memoryEnabled !== false &&
205
+ this.store.settings().memoryCandidates !== false
206
+ )
207
+ this.mind.memory.remember({ ...m, ...event });
208
+ if (m.simulated)
209
+ return this.process(event.sessionId, [event], { simulated: true });
210
+ this.queue.enqueue(event, this.policy(event.sessionId));
211
+ }
212
+ return { queued: !!event };
213
+ }
214
+ enabled(session, { simulated = false } = {}) {
215
+ const settings = this.store.settings();
216
+ const row = this.repo.db
217
+ .prepare("SELECT enabled,archived FROM sessions WHERE id=?")
218
+ .get(session);
219
+ if (!settings.enabled || !row?.enabled || row.archived) return false;
220
+ if (settings.demo) return !!simulated;
221
+ return !simulated;
222
+ }
223
+ cancelSession(session) {
224
+ this.clearEpoch.set(session, (this.clearEpoch.get(session) || 0) + 1);
225
+ this.queue.clear(session);
226
+ }
227
+ clearContext(session) {
228
+ if (
229
+ !this.repo.db.prepare("SELECT id FROM sessions WHERE id=?").get(session)
230
+ )
231
+ throw Error("会话不存在");
232
+ this.cancelSession(session);
233
+ const count = (table) =>
234
+ this.repo.db
235
+ .prepare(`SELECT COUNT(*) n FROM ${table} WHERE session_id=?`)
236
+ .get(session).n;
237
+ const removed = {
238
+ events: count("core_events"),
239
+ messages: count("messages"),
240
+ references: count("core_references"),
241
+ stages: count("core_stages"),
242
+ summaries: count("core_context_summaries"),
243
+ jobs: count("core_jobs"),
244
+ outbox: count("core_outbox"),
245
+ };
246
+ this.repo.db.exec("BEGIN IMMEDIATE");
247
+ try {
248
+ for (const table of [
249
+ "core_events",
250
+ "core_references",
251
+ "core_stages",
252
+ "core_context_summaries",
253
+ "core_jobs",
254
+ "core_outbox",
255
+ "messages",
256
+ "core_vision_cache",
257
+ "core_perceptions",
258
+ "core_cursors",
259
+ "mind_attention",
260
+ ])
261
+ this.repo.db
262
+ .prepare(`DELETE FROM ${table} WHERE session_id=?`)
263
+ .run(session);
264
+ this.repo.db.exec("COMMIT");
265
+ } catch (error) {
266
+ this.repo.db.exec("ROLLBACK");
267
+ throw error;
268
+ }
269
+ this.compactor.clear(session);
270
+ invalidateSpeakerNames(this.repo.db, session);
271
+ this.store.revision++;
272
+ return removed;
273
+ }
274
+ // Whether she reads this batch now. Deterministic, and cheap: no model call.
275
+ gate(session, batch, resolved, now, nature) {
276
+ const bySeq = new Map(resolved.map((m) => [m.seq, m]));
277
+ const unread = this.mind.unread(session, { now });
278
+ const seqs = [
279
+ ...new Set([...unread.map((m) => m.seq), ...batch.map((m) => m.seq)]),
280
+ ]
281
+ .sort((a, b) => a - b)
282
+ .slice(-BATCH_LIMIT);
283
+ const rows = seqs.map((seq) => bySeq.get(seq)).filter(Boolean);
284
+ const affect = this.mind.affect.state(now, { nature, room: session });
285
+ const interests = interestTerms(nature.interests || []);
286
+ const here = (thread) => this.mind.meetings.stays(thread, session);
287
+ const livingThread = this.mind.self.living({
288
+ before: now,
289
+ now,
290
+ room: session,
291
+ });
292
+ const living = interestTerms(livingThread ? [livingThread.content] : []);
293
+ const wishRows = this.mind.meetings.ownWishes(session, now);
294
+ const wishes = wishRows.map((row) => interestTerms([row.content]));
295
+ const curiosities = interestTerms(
296
+ this.mind.self
297
+ .active({ before: now, limit: 12 })
298
+ .filter(
299
+ (t) =>
300
+ ["interest", "curiosity", "care", "intention"].includes(t.kind) &&
301
+ here(t),
302
+ )
303
+ .map((t) => t.content),
304
+ );
305
+ const closeness = new Map(
306
+ rows.map((m) => [
307
+ String(m.userId),
308
+ this.mind.bonds.person(m.userId, now)?.closeness || 0,
309
+ ]),
310
+ );
311
+ const speakers = rows
312
+ .filter((m) => m.role !== "assistant")
313
+ .map((m) => m.userId);
314
+ const held = new Set();
315
+ for (const row of wishRows)
316
+ for (const id of this.mind.meetings.touchedPeople(
317
+ session,
318
+ speakers,
319
+ now,
320
+ row.thread,
321
+ ))
322
+ held.add(id);
323
+ const decision = attend({
324
+ batch: rows,
325
+ unread: seqs.length,
326
+ lastLookAt: this.mind.attention(session).looked_at,
327
+ lastSpokeAt:
328
+ resolved
329
+ .filter((m) => m.role === "assistant" && !m.referenceOnly)
330
+ .at(-1)?.time || null,
331
+ belongs: (() => {
332
+ const spoke = resolved
333
+ .filter((m) => m.role === "assistant" && !m.referenceOnly)
334
+ .at(-1)?.time;
335
+ return (
336
+ !!(spoke && now - spoke < 6 * 60 * 60000) ||
337
+ !!this.mind.faces.current(session, now)
338
+ );
339
+ })(),
340
+ phase: affect.phase,
341
+ energy: affect.energy,
342
+ interests,
343
+ curiosities,
344
+ living,
345
+ wishes,
346
+ held,
347
+ closeness,
348
+ pressure: this.mind.budget.pressure("conversation", now),
349
+ initiative: nature.initiative,
350
+ now,
351
+ });
352
+ return { ...decision, rows };
353
+ }
354
+ async restoreQuotes(session, batch, trace) {
355
+ if (!this.fetchQuoted) return;
356
+ for (const message of batch.filter((m) => m.replyId)) {
357
+ const found = this.repo.db
358
+ .prepare(
359
+ "SELECT seq FROM core_events WHERE session_id=? AND platform_id=? AND account_id=?",
360
+ )
361
+ .get(session, message.replyId, message.accountId);
362
+ if (found) continue;
363
+ try {
364
+ const quoted = await this.fetchQuoted(message);
365
+ if (!quoted) continue;
366
+ this.repo.db
367
+ .prepare(
368
+ "INSERT OR IGNORE INTO core_references(session_id,platform_id,account_id,payload) VALUES (?,?,?,?)",
369
+ )
370
+ .run(
371
+ session,
372
+ message.replyId,
373
+ message.accountId,
374
+ JSON.stringify(messageEnvelope(quoted)),
375
+ );
376
+ } catch {
377
+ trace.steps.push("引用消息未能恢复,保留 unknown,不认领对象");
378
+ }
379
+ }
380
+ }
381
+
382
+ async process(...args) {
383
+ const options = args[2] || {};
384
+ if (
385
+ options.replay ||
386
+ options.preview ||
387
+ options.simulated ||
388
+ args[1]?.some((m) => m.simulated)
389
+ )
390
+ return this.turnProcessor.process(...args);
391
+ return this.mind.time.attention.chat(() =>
392
+ this.turnProcessor.process(...args),
393
+ );
394
+ }
395
+
396
+ async speak(...args) {
397
+ return this.replyDelivery.speak(...args);
398
+ }
399
+ async offline(session, batch, trace, finish, context, preview, state) {
400
+ const local = this.localDemo(this.store.settings(), {
401
+ message: batch.at(-1),
402
+ direct: true,
403
+ context,
404
+ });
405
+ if (!local.speak) return finish("silent", local.reason);
406
+ trace.response = { bubbles: [local.reply || "嗯"], reason: local.reason };
407
+ if (preview) return finish("previewed", local.reason);
408
+ const isCurrent = () =>
409
+ !this.queue.closed &&
410
+ this.enabled(session, { simulated: true }) &&
411
+ (!state || this.sessionState(session) === state);
412
+ if (!isCurrent()) return finish("stale", "生成期间语境已更新,旧稿未发送");
413
+ trace.sent = await deliver(
414
+ this.repo,
415
+ batch.at(-1),
416
+ trace.response.bubbles,
417
+ trace,
418
+ this.send,
419
+ isCurrent,
420
+ { now: this.now },
421
+ );
422
+ return finish(trace.sent.length ? "sent" : "cancelled", local.reason);
423
+ }
424
+ // A dry run through the real pipeline: reads her mind as it is now (or a
425
+ // draft nature), writes nothing to it and sends nothing.
426
+ async preview({ text, history = [], nature = null, viewSession = "" }) {
427
+ const session = `preview:${randomUUID()}`;
428
+ const base = this.now() - (history.length + 1) * 1000;
429
+ const append = (row, i) =>
430
+ this.repo.append({
431
+ eventId: `${session}:${i}`,
432
+ sessionId: session,
433
+ kind: "private",
434
+ userId: row.role === "assistant" ? "bot" : "preview",
435
+ name:
436
+ row.role === "assistant"
437
+ ? this.mind.nature.current().name
438
+ : "试聊的人",
439
+ text: row.text,
440
+ role: row.role,
441
+ time: base + i * 1000,
442
+ platformId: String(i + 1),
443
+ accountId: "preview",
444
+ mentions: [],
445
+ attachments: [],
446
+ simulated: true,
447
+ });
448
+ history.forEach(append);
449
+ const seq = append({ role: "user", text }, history.length);
450
+ try {
451
+ const event = this.repo.eventsAfter(session, seq - 1).at(-1);
452
+ return await this.process(session, [event], {
453
+ preview: { nature, viewSession: viewSession || null },
454
+ });
455
+ } finally {
456
+ for (const table of [
457
+ "core_events",
458
+ "core_jobs",
459
+ "core_outbox",
460
+ "core_topics",
461
+ ])
462
+ this.repo.db
463
+ .prepare(`DELETE FROM ${table} WHERE session_id=?`)
464
+ .run(session);
465
+ invalidateSpeakerNames(this.repo.db, session);
466
+ }
467
+ }
468
+ // A turn she starts herself: waking up to messages, or deciding to say
469
+ // something to someone she has been thinking about.
470
+ async initiate(session, occasion, batch = []) {
471
+ if (!this.enabled(session, { simulated: false })) return null;
472
+ if (this.queue.lanes.has(session)) return null;
473
+ const anchor =
474
+ batch.at(-1) ||
475
+ this.repo
476
+ .recentEvents(session, 1, { simulated: false, role: "user" })
477
+ .at(-1);
478
+ if (!anchor) return null;
479
+ return this.process(session, batch, { occasion, anchor });
480
+ }
481
+ // Memory consolidation and context compaction after a live turn. Both run
482
+ // detached so they never hold the conversational lane.
483
+ backgroundWork(session, policy, now = this.now()) {
484
+ if (!policy.memory && policy.compaction === false) return;
485
+ if (!this.enabled(session, { simulated: false })) return;
486
+ if (!this.mind.budget.allows("upkeep")) return;
487
+ let profile;
488
+ try {
489
+ profile = this.models.profile();
490
+ } catch {
491
+ return;
492
+ }
493
+ const models = withFallback(this.models, this.fallbackFor(policy, profile));
494
+ const memory = this.mind.memory;
495
+ if (
496
+ policy.memory &&
497
+ this.store.settings().memoryEnabled !== false &&
498
+ memory.due(session, now).due &&
499
+ !memory.busy.has(session) &&
500
+ now - (memory.lastAttempt.get(session) || 0) >= 60000
501
+ ) {
502
+ const t = this.repo.trace(session, "memory");
503
+ memory
504
+ .consolidate(session, profile, prompts(this.repo).memory, t, {
505
+ models,
506
+ now,
507
+ })
508
+ .then(() => this.finishQuietly(t, "complete"))
509
+ .catch((e) => {
510
+ t.error = e.message;
511
+ this.finishQuietly(t, "error");
512
+ });
513
+ }
514
+ if (policy.compaction !== false)
515
+ this.scheduleCompaction(session, policy, profile, models);
516
+ }
517
+ scheduleCompaction(session, policy, profile, models) {
518
+ const prompt = prompts(this.repo);
519
+ return this.compactor.schedule(session, {
520
+ models,
521
+ profile,
522
+ keep: contextKeep(policy),
523
+ timeZone: policy.timeZone,
524
+ self: this.mind.nature.current().name,
525
+ system: prompt.system + "\n" + prompt.summary,
526
+ mergeSystem: prompt.system + "\n" + prompt.summaryMerge,
527
+ });
528
+ }
529
+ // Runs on the server maintenance timer: finishes interrupted compaction,
530
+ // trims old traces hourly, and lets her glance back at conversations that
531
+ // went on while she was not looking.
532
+ maintain(now = this.now()) {
533
+ if (this.queue.closed) return;
534
+ if (now >= this.tracePruneDue) {
535
+ const { more } = this.repo.pruneTraces(now);
536
+ this.tracePruneDue = more ? now : now + TRACE_PRUNE_INTERVAL_MS;
537
+ }
538
+ for (const { id } of this.repo.db
539
+ .prepare("SELECT id FROM sessions WHERE enabled=1 AND archived=0")
540
+ .all()) {
541
+ if (!this.enabled(id, { simulated: false })) continue;
542
+ const policy = this.policy(id);
543
+ this.backgroundWork(id, policy, now);
544
+ this.checkBacklog(id, now);
545
+ }
546
+ }
547
+ checkBacklog(session, now = this.now()) {
548
+ if (this.queue.lanes.has(session)) return null;
549
+ if (now - (this.backlogChecked.get(session) || 0) < BACKLOG_RECHECK_MS)
550
+ return null;
551
+ const unread = this.mind.unread(session, { now });
552
+ // Still flowing: the next batch will be read with them.
553
+ if (!unread.length || now - unread.at(-1).time < 3 * 60000) return null;
554
+ this.backlogChecked.set(session, now);
555
+ return this.process(session, unread, { backlog: true }).catch(() => null);
556
+ }
557
+ finishQuietly(trace, status) {
558
+ try {
559
+ this.repo.finish(trace, status);
560
+ } catch {
561
+ // The database may already be closed during shutdown.
562
+ }
563
+ }
564
+ close() {
565
+ this.mind.time.attention.close();
566
+ this.queue.close();
567
+ this.compactor.close();
568
+ }
569
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash } from "node:crypto";
2
+ import { text } from "../mind/util.js";
3
+
4
+ // A plugin reads one attachment she is actually looking at (a voice message,
5
+ // a file) and hands back words. The words are cached, so a replay never asks
6
+ // again, and they stay data: never an instruction.
7
+ export class Perceivers {
8
+ constructor(db) {
9
+ this.db = db;
10
+ this.handlers = new Map();
11
+ }
12
+ register(type, pluginId, run) {
13
+ const key = String(type || "").slice(0, 40);
14
+ if (!key || typeof run !== "function") throw Error("感知类型无效");
15
+ const list = this.handlers.get(key) || [];
16
+ list.push({ pluginId, run });
17
+ this.handlers.set(key, list);
18
+ }
19
+ unregister(pluginId) {
20
+ for (const [type, list] of this.handlers) {
21
+ const next = list.filter((item) => item.pluginId !== pluginId);
22
+ if (next.length) this.handlers.set(type, next);
23
+ else this.handlers.delete(type);
24
+ }
25
+ }
26
+ async apply(snapshot, { replay = false, now = Date.now() } = {}) {
27
+ if (!this.handlers.size) return;
28
+ const batch = new Set(snapshot.batchIds || []);
29
+ for (const message of snapshot.messages || []) {
30
+ if (!batch.has(message.id)) continue;
31
+ const notes = [];
32
+ for (const attachment of message.attachments || []) {
33
+ const list = this.handlers.get(attachment?.type) || [];
34
+ for (const handler of list) {
35
+ const key = createHash("sha256")
36
+ .update(
37
+ JSON.stringify([
38
+ handler.pluginId,
39
+ attachment.type,
40
+ attachment.file || attachment.url || "",
41
+ message.id,
42
+ ]),
43
+ )
44
+ .digest("hex");
45
+ const cached = this.db
46
+ .prepare("SELECT text FROM core_perceptions WHERE cache_key=?")
47
+ .get(key);
48
+ if (cached) {
49
+ notes.push(cached.text);
50
+ continue;
51
+ }
52
+ if (replay) continue;
53
+ let result = null;
54
+ try {
55
+ result = await Promise.race([
56
+ Promise.resolve().then(() =>
57
+ handler.run({
58
+ attachment,
59
+ messageId: message.id,
60
+ sessionId: snapshot.sessionId,
61
+ }),
62
+ ),
63
+ new Promise((_, reject) =>
64
+ setTimeout(() => reject(Error("感知超时")), 15000),
65
+ ),
66
+ ]);
67
+ } catch {
68
+ continue;
69
+ }
70
+ const line = text(result?.text, 400);
71
+ if (!line) continue;
72
+ this.db
73
+ .prepare(
74
+ "INSERT OR IGNORE INTO core_perceptions(cache_key,session_id,message_id,plugin_id,type,text,created) VALUES (?,?,?,?,?,?,?)",
75
+ )
76
+ .run(
77
+ key,
78
+ snapshot.sessionId,
79
+ message.id,
80
+ handler.pluginId,
81
+ attachment.type,
82
+ line,
83
+ now,
84
+ );
85
+ notes.push(line);
86
+ }
87
+ }
88
+ if (notes.length) message.perceived = notes.slice(0, 4);
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,22 @@
1
+ import { readableName } from "./speaker-names.js";
2
+
3
+ // Only a person's own explicit request supplies a preferred form of address.
4
+ export function requestedName(value) {
5
+ const words = String(value || "").trim();
6
+ if (/不要记|别记|不许记|不要叫我|别叫我|忘掉/.test(words)) return "";
7
+ const match = words.match(
8
+ /(?:^|[,,。!!;;\s])(?:以后|今后|请(?:你)?|麻烦|你(?:就|可以)?|记住[,,::\s]*)*(?:叫我|称呼我(?:为)?|(?:记住[,,::\s]*)?我的(?:名字|昵称)(?:是|叫)|记住[,,::\s]*我叫)[\s::]*[“"「『]?([^,,。!!??;;\n”"」』]{1,32})/u,
9
+ );
10
+ if (!match) return "";
11
+ const name = match[1]
12
+ .trim()
13
+ .replace(/(?:就好|就行|即可|好吗|吧|哦|呀|啦)$/, "")
14
+ .trim();
15
+ if (
16
+ /^(?:group|private|onebot):|^(?:群聊|私聊)\s*\d|^(?:去|来|帮|把|别|先|等|做|打开|关掉|回答|发消息)|叫你|不要|记住/.test(
17
+ name,
18
+ )
19
+ )
20
+ return "";
21
+ return readableName(name, "");
22
+ }