luckytri 1.0.0 → 1.0.2

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 (139) hide show
  1. package/.github/SECURITY.md +4 -4
  2. package/README.en.md +252 -250
  3. package/README.md +252 -250
  4. package/bin/luckytri.js +5 -1
  5. package/docs/en/CHANGELOG.md +8 -0
  6. package/docs/en/connect.md +18 -18
  7. package/docs/en/guide.md +202 -216
  8. package/docs/en/npm-release.md +25 -25
  9. package/docs/zh/CHANGELOG.md +8 -0
  10. package/docs/zh/connect.md +17 -17
  11. package/docs/zh/guide.md +202 -216
  12. package/docs/zh/npm-release.md +33 -33
  13. package/package.json +110 -108
  14. package/plugins/sdk/index.d.ts +155 -155
  15. package/plugins/sdk/testing.js +58 -58
  16. package/plugins/weather/index.js +82 -82
  17. package/plugins/weather/luckytri-plugin.json +29 -29
  18. package/public/app/assets/ChatsPage-BhlJHseG.js +3 -0
  19. package/public/app/assets/Empty-DP6WMGHi.js +2 -0
  20. package/public/app/assets/Field-CtbdqaiB.js +1 -0
  21. package/public/app/assets/HeartPage-32BeTPn2.js +1 -0
  22. package/public/app/assets/LifePage-CxSm0XKI.js +1 -0
  23. package/public/app/assets/MemoryPage-BeKBcPCY.js +1 -0
  24. package/public/app/assets/NaturePage-NEPYl2HS.js +3 -0
  25. package/public/app/assets/PeoplePage-DxtpekwM.js +1 -0
  26. package/public/app/assets/PluginsPage-CVVQwEBI.css +1 -0
  27. package/public/app/assets/PluginsPage-DksEZbUU.js +2 -0
  28. package/public/app/assets/{SystemPage-CAMPoYqM.css → SystemPage-BkRu0Rvx.css} +1 -1
  29. package/public/app/assets/SystemPage-BvzJlHuY.js +1 -0
  30. package/public/app/assets/TimePage-7-awH5XL.js +3 -0
  31. package/public/app/assets/index-4rItIkD-.js +4 -0
  32. package/public/app/assets/{index-YDz1ree1.css → index-DY4e15Kh.css} +1 -1
  33. package/public/app/assets/{knowledge-DG0e0uKw.js → knowledge-C6_HvoyB.js} +1 -1
  34. package/public/app/assets/media-SMfTT-yc.js +1 -0
  35. package/public/app/index.html +3 -3
  36. package/public/guide.en.html +72 -99
  37. package/public/guide.html +44 -86
  38. package/public/tutorial.en.md +25 -39
  39. package/public/tutorial.md +25 -39
  40. package/scripts/backup.js +311 -311
  41. package/scripts/browser.js +41 -41
  42. package/scripts/import-data.js +128 -0
  43. package/scripts/launch.js +101 -94
  44. package/scripts/plugin.js +150 -150
  45. package/scripts/reset-password.js +22 -0
  46. package/scripts/runtime.js +25 -25
  47. package/scripts/service.js +56 -47
  48. package/scripts/setup.js +50 -50
  49. package/scripts/start-server.js +41 -41
  50. package/scripts/stop.js +36 -36
  51. package/server/app.js +112 -96
  52. package/server/auth.js +217 -0
  53. package/server/channels/contract.js +57 -57
  54. package/server/channels/hub.js +165 -165
  55. package/server/channels/index.js +28 -25
  56. package/server/channels/onebot/index.js +335 -336
  57. package/server/channels/onebot/token.js +22 -3
  58. package/server/core/capabilities.js +208 -208
  59. package/server/core/events.js +64 -60
  60. package/server/core/image-preprocessor.js +208 -208
  61. package/server/core/model-manager.js +1213 -1213
  62. package/server/core/net-guard.js +49 -49
  63. package/server/core/orchestrator.js +569 -569
  64. package/server/core/perceivers.js +91 -91
  65. package/server/core/persona-manager.js +243 -243
  66. package/server/core/repository.js +293 -293
  67. package/server/core/response-validator.js +508 -508
  68. package/server/core/turn-processor.js +630 -630
  69. package/server/core/turn.js +236 -236
  70. package/server/core/vision-manager.js +633 -633
  71. package/server/i18n/errors.js +548 -497
  72. package/server/i18n/labels.js +649 -653
  73. package/server/index.js +151 -102
  74. package/server/knowledge/manager.js +386 -386
  75. package/server/knowledge/schema.js +12 -8
  76. package/server/local-access.js +32 -0
  77. package/server/mind/anticipations.js +507 -507
  78. package/server/mind/api.js +703 -703
  79. package/server/mind/budget.js +152 -152
  80. package/server/mind/context-selection.js +61 -61
  81. package/server/mind/day-planner.js +324 -324
  82. package/server/mind/evidence.js +148 -148
  83. package/server/mind/index.js +445 -445
  84. package/server/mind/life/diary.js +786 -786
  85. package/server/mind/life/index.js +1049 -1049
  86. package/server/mind/observations.js +84 -84
  87. package/server/mind/own-day.js +324 -324
  88. package/server/mind/own-voice.js +318 -318
  89. package/server/mind/senses.js +53 -53
  90. package/server/mind/time/activity-clock.js +196 -196
  91. package/server/mind/time/availability.js +69 -69
  92. package/server/mind/time/commitment-review.js +198 -198
  93. package/server/mind/time/executor.js +512 -512
  94. package/server/mind/time/experiences.js +128 -128
  95. package/server/mind/time/games.js +562 -562
  96. package/server/mind/time/index.js +739 -739
  97. package/server/mind/time/intent.js +166 -166
  98. package/server/mind/time/kinds.js +171 -171
  99. package/server/mind/time/plugin-activity.js +207 -207
  100. package/server/mind/time/tasks.js +587 -587
  101. package/server/mind/util.js +110 -110
  102. package/server/mind/view.js +273 -273
  103. package/server/paths.js +102 -102
  104. package/server/plugins/api.js +204 -204
  105. package/server/plugins/host.js +967 -967
  106. package/server/plugins/install.js +295 -295
  107. package/server/plugins/manifest.js +201 -201
  108. package/server/plugins/permissions.js +42 -42
  109. package/server/plugins/runner/guard.mjs +36 -36
  110. package/server/plugins/runner/worker.mjs +250 -250
  111. package/server/plugins/schema.js +91 -91
  112. package/server/plugins/zip.js +112 -112
  113. package/server/startup-lock.js +118 -118
  114. package/server/storage/archive.js +242 -241
  115. package/server/storage/backup-cleanup-worker.js +8 -8
  116. package/server/storage/backup-cleanup.js +415 -414
  117. package/server/storage/backup-lock.js +76 -76
  118. package/server/storage/backup-scheduler.js +131 -131
  119. package/server/storage/data-package.js +298 -0
  120. package/server/storage/data-transfer-api.js +50 -0
  121. package/server/storage/data-transfer-worker.js +18 -0
  122. package/server/storage/data-transfer.js +415 -0
  123. package/server/storage/store.js +202 -199
  124. package/server/studio/readiness.js +181 -190
  125. package/server/studio/settings.js +236 -236
  126. package/public/app/assets/ChatsPage-C-FcRapR.js +0 -3
  127. package/public/app/assets/Empty-CpP5kQHH.js +0 -2
  128. package/public/app/assets/Field-D4gY-INU.js +0 -1
  129. package/public/app/assets/HeartPage-CPy_thQd.js +0 -1
  130. package/public/app/assets/LifePage-CxWvwmSf.js +0 -1
  131. package/public/app/assets/MemoryPage-DC4rhQ68.js +0 -1
  132. package/public/app/assets/NaturePage-IvYw04Zq.js +0 -3
  133. package/public/app/assets/PeoplePage-Cqefm9-q.js +0 -1
  134. package/public/app/assets/PluginsPage-DKSaKvXb.js +0 -2
  135. package/public/app/assets/PluginsPage-HyBe5D8w.css +0 -1
  136. package/public/app/assets/SystemPage-CJmtJkrK.js +0 -1
  137. package/public/app/assets/TimePage-Cw44inM4.js +0 -3
  138. package/public/app/assets/index-BheRW-1t.js +0 -4
  139. package/public/app/assets/media-BzPDBm7e.js +0 -1
@@ -1,569 +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
- }
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
+ }