luckytri 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.en.md +14 -12
  2. package/README.md +4 -2
  3. package/docs/en/CHANGELOG.md +24 -0
  4. package/docs/en/architecture.md +12 -10
  5. package/docs/en/chat-style.md +13 -13
  6. package/docs/en/cognition.md +12 -4
  7. package/docs/en/evolution.md +35 -0
  8. package/docs/en/her-life.md +5 -2
  9. package/docs/en/vision.md +50 -0
  10. package/docs/zh/CHANGELOG.md +24 -0
  11. package/docs/zh/architecture.md +12 -10
  12. package/docs/zh/chat-style.md +17 -17
  13. package/docs/zh/cognition.md +12 -4
  14. package/docs/zh/evolution.md +35 -0
  15. package/docs/zh/her-life.md +5 -2
  16. package/docs/zh/vision.md +50 -0
  17. package/package.json +4 -1
  18. package/public/app/assets/{ChatsPage-IfBgNdaD.js → ChatsPage-eDqFIg97.js} +1 -1
  19. package/public/app/assets/{Empty-DK9nQPCf.js → Empty-CAGOJsfD.js} +1 -1
  20. package/public/app/assets/{Field-BzKFhuFd.js → Field-CRz-JmN1.js} +1 -1
  21. package/public/app/assets/{HeartPage-CKxrbfFQ.js → HeartPage-BGWoeMrC.js} +1 -1
  22. package/public/app/assets/{LifePage-CtxPEi6E.js → LifePage-iao3o3wt.js} +1 -1
  23. package/public/app/assets/{MemoryPage-B8FLXzLi.js → MemoryPage-Dwi2yi7E.js} +1 -1
  24. package/public/app/assets/NaturePage-DniImo9U.js +3 -0
  25. package/public/app/assets/{NaturePage-CHkpb5NK.css → NaturePage-zguDpGWY.css} +1 -1
  26. package/public/app/assets/{PeoplePage-D3yn0HV0.js → PeoplePage-D-MC3JEt.js} +1 -1
  27. package/public/app/assets/{PluginsPage-ojv8zawj.js → PluginsPage-D7dJTf96.js} +1 -1
  28. package/public/app/assets/SystemPage-C5CUHjGh.js +1 -0
  29. package/public/app/assets/{SystemPage-CGVTvNZw.css → SystemPage-C_Y5M7JU.css} +1 -1
  30. package/public/app/assets/{TimePage-Dyd-FFbq.js → TimePage-Dwhr3-92.js} +1 -1
  31. package/public/app/assets/{index-BJfzfgnm.js → index-L0bHax8C.js} +3 -3
  32. package/public/app/assets/{knowledge-C-bND0Ub.js → knowledge-BTDNafzl.js} +1 -1
  33. package/public/app/assets/{media-D7G3Nco6.js → media-BBzOAfNG.js} +1 -1
  34. package/public/app/assets/{mind-ec-G6iNZ.js → mind-CBTkFje2.js} +1 -1
  35. package/public/app/index.html +3 -3
  36. package/public/guide.en.html +3 -3
  37. package/public/guide.html +3 -3
  38. package/scripts/migrate-instance.js +27 -0
  39. package/scripts/update-instance.js +21 -2
  40. package/server/channels/contract.js +5 -1
  41. package/server/channels/hub.js +2 -2
  42. package/server/channels/onebot/adapter.js +291 -288
  43. package/server/channels/onebot/index.js +2 -2
  44. package/server/channels/qqbot/api.js +194 -199
  45. package/server/core/context-builder.js +20 -3
  46. package/server/core/conversation-cues.js +5 -4
  47. package/server/core/conversation-grounding.js +20 -17
  48. package/server/core/conversation-needs.js +32 -0
  49. package/server/core/dialogue-context.js +2 -5
  50. package/server/core/dialogue-contract.js +23 -16
  51. package/server/core/message-scheduler.js +85 -78
  52. package/server/core/model-manager.js +113 -16
  53. package/server/core/model-presets.js +1 -1
  54. package/server/core/orchestrator.js +18 -2
  55. package/server/core/persona-manager.js +14 -5
  56. package/server/core/reader-check.js +47 -42
  57. package/server/core/reply-delivery.js +125 -34
  58. package/server/core/response-validator.js +28 -1
  59. package/server/core/retired-prompts.js +5 -0
  60. package/server/core/speech-length.js +43 -0
  61. package/server/core/turn-processor.js +38 -5
  62. package/server/core/turn.js +70 -16
  63. package/server/i18n/errors.js +30 -0
  64. package/server/i18n/labels.js +5 -0
  65. package/server/index.js +3 -0
  66. package/server/knowledge/embedding-profile.js +126 -29
  67. package/server/knowledge/manager.js +103 -34
  68. package/server/knowledge/schema.js +197 -158
  69. package/server/mind/affect.js +267 -265
  70. package/server/mind/aftermath.js +169 -0
  71. package/server/mind/anticipations.js +2 -0
  72. package/server/mind/clock.js +91 -83
  73. package/server/mind/discretion.js +20 -0
  74. package/server/mind/episodes.js +2 -1
  75. package/server/mind/index.js +17 -2
  76. package/server/mind/initiative.js +29 -14
  77. package/server/mind/memory.js +47 -5
  78. package/server/mind/recall/cue.js +67 -0
  79. package/server/mind/recall/index.js +183 -29
  80. package/server/mind/recall/roots.js +231 -0
  81. package/server/mind/recall/schema.js +2 -0
  82. package/server/mind/recall/store.js +113 -30
  83. package/server/mind/salience.js +22 -1
  84. package/server/mind/schema.js +5 -0
  85. package/server/mind/thoughts.js +276 -274
  86. package/server/mind/view.js +100 -60
  87. package/server/plugins/install.js +1 -6
  88. package/server/plugins/zip.js +18 -4
  89. package/server/runtime/data-migration.js +193 -0
  90. package/server/runtime/update-execution.js +43 -4
  91. package/server/runtime/update-manager.js +4 -1
  92. package/server/storage/archive.js +1 -1
  93. package/server/storage/backup-scheduler.js +15 -3
  94. package/public/app/assets/NaturePage-C7i607ul.js +0 -3
  95. package/public/app/assets/SystemPage-Cv3rFvyT.js +0 -1
@@ -1,4 +1,3 @@
1
- import { summarizeGroupStyle } from "../core/group-style.js";
2
1
  import { relevantContext, boundMindContext } from "./context-selection.js";
3
2
  import { interestTerms } from "./attention.js";
4
3
  import { agoLabel, elapsedLabel } from "./clock.js";
@@ -40,44 +39,6 @@ const FEEDBACK = {
40
39
  natural: "挺自然",
41
40
  };
42
41
 
43
- // The way a room talks is read as of `now`, not as of the wall clock: a replay
44
- // or a test that moves the clock must see the room as it was at that moment.
45
- function groupStyle(mind, session, now) {
46
- try {
47
- const rows = mind.db
48
- .prepare(
49
- "SELECT * FROM (SELECT * FROM messages WHERE session_id=? AND is_demo=0 AND time<=? ORDER BY id DESC LIMIT 200) ORDER BY id",
50
- )
51
- .all(session, now + 60000);
52
- const ids = rows.map((row) => row.event_id).filter(Boolean);
53
- const bots = new Set(
54
- mind.relationships
55
- .context({ room: session, now, limit: 100 })
56
- .known.filter((p) => p.kind === "bot")
57
- .map((p) => String(p.subjectId)),
58
- );
59
- const away = ids.length
60
- ? new Set(
61
- mind.db
62
- .prepare(
63
- `SELECT e.event_id FROM core_events e JOIN mind_unlived u ON u.seq=e.seq WHERE e.event_id IN (${ids.map(() => "?").join(",")})`,
64
- )
65
- .all(...ids)
66
- .map((row) => row.event_id),
67
- )
68
- : new Set();
69
- const profile = summarizeGroupStyle(
70
- rows.filter(
71
- (row) => !away.has(row.event_id) && !bots.has(String(row.user_id)),
72
- ),
73
- now,
74
- );
75
- return profile.ready ? profile.summary : "";
76
- } catch {
77
- return "";
78
- }
79
- }
80
-
81
42
  function feedback(mind, session, now) {
82
43
  return mind.db
83
44
  .prepare(
@@ -153,18 +114,20 @@ export function innerView(
153
114
  { cues, limit: 2, requireOverlap: true },
154
115
  );
155
116
  const shownLiving = living && carries(living) ? living : null;
117
+ const remindedSelf = mind.self
118
+ .reminded({ before: now, now, cues, limit: 2 })
119
+ .filter(carries);
120
+ const remindedThoughts = mind.thoughts
121
+ .reminded({ now, cues, session, limit: 1 })
122
+ .filter((t) => mind.meetings.sayable(t.content, session));
156
123
  const reminded = [
157
- ...mind.self
158
- .reminded({ before: now, now, cues, limit: 2 })
159
- .filter(carries)
160
- .map((t) => `${SELF_KINDS[t.kind]}:${text(t.content, 80)}${FORGOTTEN}`),
161
- ...mind.thoughts
162
- .reminded({ now, cues, session, limit: 1 })
163
- .filter((t) => mind.meetings.sayable(t.content, session))
164
- .map(
165
- (t) =>
166
- `${elapsedLabel(t.created, now, mind.timeZone())}想到:${text(t.content, 110)}${FORGOTTEN}`,
167
- ),
124
+ ...remindedSelf.map(
125
+ (t) => `${SELF_KINDS[t.kind]}:${text(t.content, 80)}${FORGOTTEN}`,
126
+ ),
127
+ ...remindedThoughts.map(
128
+ (t) =>
129
+ `${elapsedLabel(t.created, now, mind.timeZone())}想到:${text(t.content, 110)}${FORGOTTEN}`,
130
+ ),
168
131
  ...mind.meetings
169
132
  .reminded({ session, now, cues, limit: 1 })
170
133
  .filter((line) => mind.meetings.sayable(line, session))
@@ -184,7 +147,6 @@ export function innerView(
184
147
  const read = mind.reading
185
148
  .recent({ now, limit: 2 })
186
149
  .filter((r) => r.created > now - 7 * DAY);
187
- const room = kind === "group" ? groupStyle(mind, session, now) : "";
188
150
  const spoken = (value) =>
189
151
  value && mind.meetings.sayable(value, session) ? value : "";
190
152
  const self = {
@@ -286,9 +248,12 @@ export function innerView(
286
248
  const heard = feedback(mind, session, now);
287
249
  const continuity = mind.continuity.recall({ session, people, cue, now });
288
250
  const justLived = mind.continuity.justLived({ session, people, now });
289
- const expecting = mind.anticipations
290
- .upcoming({ now, people, session, limit: 3 })
291
- .map((a) => a.text);
251
+ const upcoming = mind.anticipations.upcoming({
252
+ now,
253
+ people,
254
+ session,
255
+ limit: 3,
256
+ });
292
257
  const withWhom = mind.meetings.recall({
293
258
  session,
294
259
  people,
@@ -325,28 +290,33 @@ export function innerView(
325
290
  }));
326
291
  const selfKnown = {
327
292
  note: "你在每个地方都是同一个人。这是你现在的状态、心情和亲近的人,你知道,不必向眼前的人汇报。",
328
- state: `${affect.phaseLabel},精力${affect.energyLabel},心情${affect.mood}${cause ? `(${cause})` : ""}`,
293
+ // Her mood is said once, in inner.state, where it shapes how she talks.
294
+ // A second copy here sat under "not something to report" and read as a
295
+ // reason to keep it out of her voice (round-19 happy-mood probe).
329
296
  ...(shownLiving ? { livingFor: text(shownLiving.content, 80) } : {}),
330
297
  ...(closeTo.length ? { closeTo } : {}),
331
298
  };
332
299
  const withLines = withWhom.filter(
333
300
  (line) => !fixedReplyHabit(line) && mind.meetings.sayable(line, session),
334
301
  );
335
- const expectingLines = expecting.filter((line) =>
336
- mind.meetings.sayable(line, session),
302
+ const expectingShown = upcoming.filter((a) =>
303
+ mind.meetings.sayable(a.text, session),
337
304
  );
305
+ const expectingLines = expectingShown.map((a) => a.text);
338
306
  const senses = mind.senses?.lines({ kind, now }) || [];
339
307
  const body = (mind.body?.({ kind, now }) || [])
340
308
  .map((line) => text(line, 80))
341
309
  .filter(Boolean)
342
310
  .slice(0, 8);
343
- return boundMindContext({
311
+ const ownVoice = ownTyping(mind, session, now);
312
+ const view = boundMindContext({
344
313
  self,
345
314
  affect: cause ? affect : { ...affect, cause: "" },
346
315
  inner: {
347
316
  currentLife: mind.time.view({ session, now, cue, activityCue }),
348
- state: `${affect.phaseLabel},精力${affect.energyLabel},心情${affect.mood}${cause ? `(${cause})` : ""}${affect.lately ? `,${affect.lately}` : ""}`,
317
+ state: `${affect.phaseLabel},精力${affect.energyLabel},心情${affect.mood}${feltStrength(affect.lingering)}${cause ? `(${cause})` : ""}${affect.lately ? `,${affect.lately}` : ""}`,
349
318
  selfKnown,
319
+ ...(ownVoice ? { ownVoice } : {}),
350
320
  ...(persons.length ? { people: persons } : {}),
351
321
  ...(relationships.known.length ? { relationships } : {}),
352
322
  ...(continuity ? { continuity } : {}),
@@ -373,10 +343,80 @@ export function innerView(
373
343
  ...(will ? { will } : {}),
374
344
  ...(stood.length ? { stood } : {}),
375
345
  ...(reminded.length ? { reminded } : {}),
376
- ...(room ? { room } : {}),
377
346
  ...(heard.length ? { heard } : {}),
378
347
  ...(senses.length ? { senses } : {}),
379
348
  ...(body.length ? { body } : {}),
380
349
  },
381
350
  });
351
+ // Which of her past made it into this view after the allowance, for the
352
+ // record of what came to mind. Not part of what the model reads.
353
+ const shown = (owner, key, ids) =>
354
+ ids.slice(0, Array.isArray(owner?.[key]) ? owner[key].length : 0);
355
+ Object.defineProperty(view, "recalled", {
356
+ enumerable: false,
357
+ value: [
358
+ ...shown(
359
+ view.self,
360
+ "threads",
361
+ visibleThreads.map((t) => `self:${t.thread}`),
362
+ ).map((id) => ({ id, why: "core" })),
363
+ ...shown(
364
+ view.inner,
365
+ "relatedSelf",
366
+ relatedSelf.map((t) => `self:${t.thread}`),
367
+ ).map((id) => ({ id, why: meant ? "semantic" : "lexical" })),
368
+ ...shown(
369
+ view.inner,
370
+ "onMind",
371
+ thoughts.map((t) => `thought:${t.id}`),
372
+ ).map((id) => ({ id, why: "on_mind" })),
373
+ ...shown(view.inner, "reminded", [
374
+ ...remindedSelf.map((t) => `self:${t.thread}`),
375
+ ...remindedThoughts.map((t) => `thought:${t.id}`),
376
+ ]).map((id) => ({ id, why: "reminded" })),
377
+ ...shown(
378
+ view.inner,
379
+ "expecting",
380
+ expectingShown.map((a) => `anticipation:${a.id}`),
381
+ ).map((id) => ({ id, why: "expecting" })),
382
+ ],
383
+ });
384
+ return view;
385
+ }
386
+
387
+ // Whether the latest feeling is still strong or already fading. It decays
388
+ // with time (affect.js), so what happened earlier keeps colouring how she
389
+ // speaks for a while and then lets go.
390
+ function feltStrength(lingering = 0) {
391
+ if (lingering >= 0.5) return ",还挺明显";
392
+ if (lingering >= 0.25) return ",淡了些";
393
+ return "";
394
+ }
395
+
396
+ // How she herself has been typing lately: her own habit, read from what she
397
+ // actually sent (here first, then anywhere), never from how others type. It
398
+ // lets the voice she has grown carry on instead of being re-derived from the
399
+ // initial design each turn. Fewer than four of her own messages means no habit yet.
400
+ export function ownTyping(mind, session, now) {
401
+ try {
402
+ const pick = (where, ...args) =>
403
+ mind.db
404
+ .prepare(
405
+ `SELECT text FROM messages WHERE role='assistant' AND is_demo=0 AND time<=? AND time>? ${where} ORDER BY id DESC LIMIT 40`,
406
+ )
407
+ .all(now + 60000, now - 14 * 86400000, ...args)
408
+ .map((row) => String(row.text || "").trim())
409
+ .filter((t) => t && !/^\[/.test(t));
410
+ let lines = pick("AND session_id=?", session);
411
+ if (lines.length < 4) lines = pick("");
412
+ if (lines.length < 4) return "";
413
+ const lengths = lines
414
+ .map((t) => Array.from(t).length)
415
+ .sort((a, b) => a - b);
416
+ const median = lengths[Math.floor(lengths.length / 2)];
417
+ const stops = lines.filter((t) => /[。.]$/.test(t)).length / lines.length;
418
+ return `你最近自己发的一条消息常见约 ${median} 字${stops < 0.25 ? ",句末常不带句号" : ""}`;
419
+ } catch {
420
+ return "";
421
+ }
382
422
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { lookup } from "node:dns/promises";
3
- import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs";
3
+ import { mkdirSync, readdirSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { assertPublicHost } from "../core/net-guard.js";
6
6
  import { VERSION } from "../version.js";
@@ -288,8 +288,3 @@ export function createInstaller(host) {
288
288
  function bytesToText(bytes) {
289
289
  return Buffer.from(bytes).toString("utf8");
290
290
  }
291
-
292
- export function copyPlugin(from, to) {
293
- mkdirSync(to, { recursive: true });
294
- cpSync(from, to, { recursive: true });
295
- }
@@ -1,6 +1,6 @@
1
1
  import { inflateRawSync } from "node:zlib";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
- import { dirname, join, normalize } from "node:path";
3
+ import { dirname, join, normalize, resolve, sep } from "node:path";
4
4
 
5
5
  const MAX_FILES = 200;
6
6
  const MAX_BYTES = 60 * 1024 * 1024;
@@ -76,8 +76,21 @@ export function readZip(buffer) {
76
76
  buffer.readUInt16LE(local + 26) +
77
77
  buffer.readUInt16LE(local + 28);
78
78
  const compressed = buffer.subarray(dataAt, dataAt + compressedSize);
79
- const bytes =
80
- method === 0 ? Buffer.from(compressed) : inflateRawSync(compressed);
79
+ // The size a stranger's header declares is not trusted: inflation itself
80
+ // stops at what is still allowed, so one entry cannot exhaust memory.
81
+ let bytes;
82
+ try {
83
+ bytes =
84
+ method === 0
85
+ ? Buffer.from(compressed)
86
+ : inflateRawSync(compressed, {
87
+ maxOutputLength: MAX_BYTES - total + 1,
88
+ });
89
+ } catch (error) {
90
+ if (error?.code === "ERR_BUFFER_TOO_LARGE")
91
+ throw new Error("压缩包解压后过大");
92
+ throw new Error("压缩包无法读取");
93
+ }
81
94
  if (size && bytes.length !== size) throw new Error("压缩包内容不完整");
82
95
  total += bytes.length;
83
96
  if (total > MAX_BYTES) throw new Error("压缩包解压后过大");
@@ -105,7 +118,8 @@ export function writeTree(dir, files) {
105
118
  dir,
106
119
  ...normalize(file.name).split(/[\\/]/).filter(Boolean),
107
120
  );
108
- if (!target.startsWith(dir)) throw new Error("压缩包路径无效");
121
+ const root = resolve(dir) + sep;
122
+ if (!resolve(target).startsWith(root)) throw new Error("压缩包路径无效");
109
123
  mkdirSync(dirname(target), { recursive: true });
110
124
  writeFileSync(target, file.bytes);
111
125
  }
@@ -0,0 +1,193 @@
1
+ import { existsSync, statSync, unlinkSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { Repository } from "../core/repository.js";
5
+ import { Mind } from "../mind/index.js";
6
+ import {
7
+ archiveIdentity,
8
+ DATABASE_VERSION,
9
+ inspectDatabase,
10
+ } from "../storage/archive.js";
11
+ import { createStore } from "../storage/store.js";
12
+ import { VERSION } from "../version.js";
13
+
14
+ // Bump this only when an update needs a new full pass over durable memory.
15
+ export const RELEASE_DATA_MIGRATION_ID = "memory-index-v1.2.0";
16
+
17
+ const DERIVED = new Set([
18
+ "core_vector_buckets",
19
+ "mind_index",
20
+ "mind_index_people",
21
+ "mind_roots",
22
+ "mind_rooted",
23
+ "mind_vectors",
24
+ ]);
25
+
26
+ function quoted(name) {
27
+ return `"${String(name).replaceAll('"', '""')}"`;
28
+ }
29
+
30
+ function rowCounts(db) {
31
+ const result = {};
32
+ const tables = db
33
+ .prepare(
34
+ "SELECT name,sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
35
+ )
36
+ .all();
37
+ for (const table of tables) {
38
+ if (
39
+ DERIVED.has(table.name) ||
40
+ /_fts(?:_|$)/i.test(table.name) ||
41
+ /CREATE\s+VIRTUAL\s+TABLE/i.test(table.sql || "")
42
+ )
43
+ continue;
44
+ result[table.name] = Number(
45
+ db.prepare(`SELECT COUNT(*) n FROM ${quoted(table.name)}`).get().n,
46
+ );
47
+ }
48
+ return result;
49
+ }
50
+
51
+ export function durableRowCounts(file) {
52
+ const db = new DatabaseSync(file, { readOnly: true });
53
+ try {
54
+ return rowCounts(db);
55
+ } finally {
56
+ db.close();
57
+ }
58
+ }
59
+
60
+ export function assertDurableRowsPreserved(before, after) {
61
+ for (const [table, count] of Object.entries(before)) {
62
+ if (!(table in after)) throw Error(`迁移后缺少原数据表:${table}`);
63
+ if (after[table] < count)
64
+ throw Error(
65
+ `迁移后 ${table} 记录数减少(${count} → ${after[table]}),已停止更新`,
66
+ );
67
+ }
68
+ }
69
+
70
+ const scalar = (db, table) => {
71
+ const exists = db
72
+ .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
73
+ .get(table);
74
+ return exists
75
+ ? Number(db.prepare(`SELECT COUNT(*) n FROM ${quoted(table)}`).get().n)
76
+ : 0;
77
+ };
78
+
79
+ export async function migrateReleaseData(
80
+ mind,
81
+ { fromVersion = "", version = VERSION, now = Date.now } = {},
82
+ ) {
83
+ const repo = mind.repo;
84
+ const existing = repo.config("release-data-migration", null);
85
+ if (existing?.id === RELEASE_DATA_MIGRATION_ID)
86
+ return { ...existing, skipped: true };
87
+
88
+ const at = typeof now === "function" ? now() : now;
89
+ const before = {
90
+ messages: scalar(repo.db, "core_events"),
91
+ facts: scalar(repo.db, "core_memories"),
92
+ thoughts: scalar(repo.db, "mind_thoughts"),
93
+ episodes: scalar(repo.db, "mind_episodes"),
94
+ self: scalar(repo.db, "mind_self"),
95
+ diaries: scalar(repo.db, "mind_diary"),
96
+ plans: scalar(repo.db, "mind_anticipations"),
97
+ works: scalar(repo.db, "mind_time_works"),
98
+ };
99
+
100
+ console.error(
101
+ "正在按当前版本重建记忆索引与经历关联;原始记忆和向量数据会保留。",
102
+ );
103
+ await mind.recall.prepare(at, { vectors: false });
104
+
105
+ let rooted = 0;
106
+ for (;;) {
107
+ const count = mind.recall.fillRoots(500, { strict: true });
108
+ rooted += count;
109
+ if (!count) break;
110
+ await new Promise((resolve) => setImmediate(resolve));
111
+ }
112
+
113
+ const report = {
114
+ id: RELEASE_DATA_MIGRATION_ID,
115
+ fromVersion: fromVersion || existing?.version || "unknown",
116
+ version,
117
+ databaseVersion: DATABASE_VERSION,
118
+ completed: at,
119
+ rows: before,
120
+ indexes: {
121
+ memories: scalar(repo.db, "mind_index"),
122
+ roots: scalar(repo.db, "mind_roots"),
123
+ rooted: scalar(repo.db, "mind_rooted"),
124
+ rootRecordsProcessed: rooted,
125
+ },
126
+ };
127
+ repo.saveConfig("release-data-migration", report);
128
+ console.error(
129
+ `记忆迁移完成:${report.indexes.memories} 条索引,${report.indexes.rooted} 条经历关联记录;没有重新发送或嵌入记忆。`,
130
+ );
131
+ return report;
132
+ }
133
+
134
+ export async function migrateDatabaseFile(
135
+ file,
136
+ { fromVersion = "", version = VERSION, databaseId = "", now = Date.now } = {},
137
+ ) {
138
+ const target = resolve(file);
139
+ if (
140
+ file === ":memory:" ||
141
+ !isAbsolute(file) ||
142
+ !existsSync(target) ||
143
+ !statSync(target).isFile()
144
+ )
145
+ throw Error("迁移目标不是现有的绝对路径数据库文件");
146
+ if (version !== VERSION) throw Error("迁移目标版本与候选程序不一致");
147
+
148
+ const beforeSchema = inspectDatabase(target);
149
+ const beforeRows = durableRowCounts(target);
150
+ const originalIdentity = archiveIdentity(target);
151
+ if (databaseId && originalIdentity !== databaseId)
152
+ throw Error("迁移目标数据库与更新前备份不匹配");
153
+
154
+ const store = createStore(target, { quietMigration: true });
155
+ let safeToRemoveSidecars = false;
156
+ try {
157
+ const repo = new Repository(store);
158
+ const mind = new Mind(repo);
159
+ const report = await migrateReleaseData(mind, {
160
+ fromVersion,
161
+ version,
162
+ now,
163
+ });
164
+ const afterSchema = inspectDatabase(target);
165
+ const afterRows = durableRowCounts(target);
166
+ assertDurableRowsPreserved(beforeRows, afterRows);
167
+ if (afterSchema.schemaVersion !== DATABASE_VERSION)
168
+ throw Error("数据库未迁移到候选版本要求的结构");
169
+ if (databaseId && archiveIdentity(target) !== databaseId)
170
+ throw Error("迁移后数据库身份发生变化");
171
+ const checkpoint = store.db
172
+ .prepare("PRAGMA wal_checkpoint(TRUNCATE)")
173
+ .get();
174
+ if (checkpoint.busy || checkpoint.log !== checkpoint.checkpointed)
175
+ throw Error("迁移后的数据库仍有未关闭的 SQLite 事务");
176
+ safeToRemoveSidecars = true;
177
+ return {
178
+ ...report,
179
+ fromDatabaseVersion: beforeSchema.schemaVersion,
180
+ toDatabaseVersion: afterSchema.schemaVersion,
181
+ durableTablesChecked: Object.keys(beforeRows).length,
182
+ durableRowsChecked: Object.values(beforeRows).reduce(
183
+ (sum, count) => sum + count,
184
+ 0,
185
+ ),
186
+ };
187
+ } finally {
188
+ store.db.close();
189
+ if (safeToRemoveSidecars)
190
+ for (const suffix of ["-wal", "-shm"])
191
+ if (existsSync(target + suffix)) unlinkSync(target + suffix);
192
+ }
193
+ }
@@ -1,4 +1,5 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
2
+ import { DatabaseSync } from "node:sqlite";
2
3
  import {
3
4
  readActive,
4
5
  writeActive,
@@ -13,7 +14,7 @@ import {
13
14
  UPDATE_RECOVERY_FAILED,
14
15
  } from "./update-manager.js";
15
16
  import { backupDatabase } from "../../scripts/backup.js";
16
- import { verifyArchive } from "../storage/archive.js";
17
+ import { restoreToNewFile, verifyArchive } from "../storage/archive.js";
17
18
 
18
19
  // Executed outside the server so a large verified snapshot does not stall the
19
20
  // WebUI. Dependencies are explicit for failure, restart and rollback tests.
@@ -23,6 +24,7 @@ export async function performUpdate(
23
24
  {
24
25
  install,
25
26
  check,
27
+ migrate,
26
28
  stop,
27
29
  claim,
28
30
  start,
@@ -34,6 +36,8 @@ export async function performUpdate(
34
36
  let job = readUpdateJob(paths.home, id),
35
37
  stopped = false,
36
38
  changed = false,
39
+ databaseChanged = false,
40
+ migrationFile = null,
37
41
  child = null,
38
42
  claimed = false,
39
43
  backup = null;
@@ -43,6 +47,8 @@ export async function performUpdate(
43
47
  };
44
48
  try {
45
49
  if (job.status !== "queued") throw Error("更新任务已经处理");
50
+ if (typeof migrate !== "function")
51
+ throw Error("缺少候选版本的数据迁移器,已停止更新");
46
52
  save("downloading");
47
53
  const slot = updatePath(paths.home, id);
48
54
  mkdirSync(slot, { recursive: true, mode: 0o700 });
@@ -57,10 +63,36 @@ export async function performUpdate(
57
63
  if (!claimed) throw Error("另一次启动正在进行");
58
64
  save("backup");
59
65
  backup = snapshot(paths.database, paths.backups);
60
- verifyArchive(backup);
66
+ const backupInfo = verifyArchive(backup);
67
+ const liveDatabase = new DatabaseSync(paths.database);
68
+ try {
69
+ const checkpoint = liveDatabase
70
+ .prepare("PRAGMA wal_checkpoint(TRUNCATE)")
71
+ .get();
72
+ if (checkpoint.busy) throw Error("旧数据库仍被占用,无法安全切换");
73
+ } finally {
74
+ liveDatabase.close();
75
+ }
76
+ save("migrating");
77
+ migrationFile = `${paths.database}.${id}.migrate`;
78
+ restoreToNewFile(backup, migrationFile);
79
+ databaseChanged = true;
80
+ await migrate(candidate, migrationFile, {
81
+ previous: job.previous,
82
+ version: job.version,
83
+ databaseId: backupInfo.databaseId,
84
+ });
85
+ for (const suffix of ["-wal", "-shm"])
86
+ if (
87
+ existsSync(migrationFile + suffix) ||
88
+ existsSync(paths.database + suffix)
89
+ )
90
+ throw Error("数据库仍有未关闭的 SQLite 事务文件,请重试更新");
61
91
  save("restarting");
62
92
  writeActive(paths.home, { id, version: job.version });
63
93
  changed = true;
94
+ renameSync(migrationFile, paths.database);
95
+ migrationFile = null;
64
96
  child = await start(candidate);
65
97
  save("verifying", { childPid: child });
66
98
  await health(child, job.version);
@@ -95,7 +127,7 @@ export async function performUpdate(
95
127
  release();
96
128
  if (!claim()) throw Error("启动锁尚未释放");
97
129
  }
98
- if (changed) {
130
+ if (changed || databaseChanged) {
99
131
  verifyArchive(backup);
100
132
  const temporary = `${paths.database}.${id}.restore`;
101
133
  copyFileSync(backup, temporary);
@@ -108,8 +140,15 @@ export async function performUpdate(
108
140
  `${paths.database}.update-failed-${id}${suffix}`,
109
141
  );
110
142
  renameSync(temporary, paths.database);
111
- writeActive(paths.home, previous);
143
+ if (changed) writeActive(paths.home, previous);
112
144
  }
145
+ if (migrationFile)
146
+ for (const suffix of ["", "-wal", "-shm"])
147
+ if (existsSync(migrationFile + suffix))
148
+ renameSync(
149
+ migrationFile + suffix,
150
+ `${paths.database}.update-failed-${id}.migration${suffix}`,
151
+ );
113
152
  const restored = await start(paths.package);
114
153
  await health(restored, job.previous);
115
154
  save("error", {
@@ -17,6 +17,7 @@ const ACTIVE = new Set([
17
17
  "checking",
18
18
  "stopping",
19
19
  "backup",
20
+ "migrating",
20
21
  "restarting",
21
22
  "verifying",
22
23
  "restoring",
@@ -76,7 +77,9 @@ export class InstanceUpdater {
76
77
  job = writeUpdateJob(this.paths.home, {
77
78
  ...job,
78
79
  status: "error",
79
- error: ["queued", "downloading", "checking"].includes(job.status)
80
+ error: ["queued", "downloading", "checking", "migrating"].includes(
81
+ job.status,
82
+ )
80
83
  ? UPDATE_FAILED
81
84
  : UPDATE_RECOVERY_FAILED,
82
85
  finished: this.now(),
@@ -16,7 +16,7 @@ import { dirname, basename, resolve, join } from "node:path";
16
16
  import { VERSION } from "../version.js";
17
17
  import { withBackupLock } from "./backup-lock.js";
18
18
 
19
- export const DATABASE_VERSION = 5;
19
+ export const DATABASE_VERSION = 6;
20
20
  const appVersion = VERSION;
21
21
  const IDENTITY = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
22
22
 
@@ -76,7 +76,7 @@ export function createBackupScheduler({
76
76
  if (!due) return false;
77
77
  const child = start(process.execPath, [SCRIPT, "--auto"], {
78
78
  env: {
79
- ...env,
79
+ ...withoutCredentials(env),
80
80
  LUCKYTRI_HOME: runtimePaths(env).home,
81
81
  DB_PATH: source,
82
82
  BACKUP_DIR: directory,
@@ -93,8 +93,10 @@ export function createBackupScheduler({
93
93
  /* Not every platform lets a process lower another's priority. */
94
94
  }
95
95
  let output = "";
96
- child.stdout?.on("data", (chunk) => (output += chunk));
97
- child.stderr?.on("data", (chunk) => (output += chunk));
96
+ // Only the tail is reported; a chatty child cannot grow this forever.
97
+ const keep = (chunk) => (output = (output + chunk).slice(-4000));
98
+ child.stdout?.on("data", keep);
99
+ child.stderr?.on("data", keep);
98
100
  const done = (code, error) => {
99
101
  if (running !== child) return;
100
102
  running = null;
@@ -129,3 +131,13 @@ export function createBackupScheduler({
129
131
  },
130
132
  };
131
133
  }
134
+
135
+ // Copying a file needs no model or channel credentials; the child does not
136
+ // receive them.
137
+ export function withoutCredentials(env) {
138
+ return Object.fromEntries(
139
+ Object.entries(env || {}).filter(
140
+ ([name]) => !/(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|COOKIE)/i.test(name),
141
+ ),
142
+ );
143
+ }