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.
- package/README.en.md +14 -12
- package/README.md +4 -2
- package/docs/en/CHANGELOG.md +24 -0
- package/docs/en/architecture.md +12 -10
- package/docs/en/chat-style.md +13 -13
- package/docs/en/cognition.md +12 -4
- package/docs/en/evolution.md +35 -0
- package/docs/en/her-life.md +5 -2
- package/docs/en/vision.md +50 -0
- package/docs/zh/CHANGELOG.md +24 -0
- package/docs/zh/architecture.md +12 -10
- package/docs/zh/chat-style.md +17 -17
- package/docs/zh/cognition.md +12 -4
- package/docs/zh/evolution.md +35 -0
- package/docs/zh/her-life.md +5 -2
- package/docs/zh/vision.md +50 -0
- package/package.json +4 -1
- package/public/app/assets/{ChatsPage-IfBgNdaD.js → ChatsPage-eDqFIg97.js} +1 -1
- package/public/app/assets/{Empty-DK9nQPCf.js → Empty-CAGOJsfD.js} +1 -1
- package/public/app/assets/{Field-BzKFhuFd.js → Field-CRz-JmN1.js} +1 -1
- package/public/app/assets/{HeartPage-CKxrbfFQ.js → HeartPage-BGWoeMrC.js} +1 -1
- package/public/app/assets/{LifePage-CtxPEi6E.js → LifePage-iao3o3wt.js} +1 -1
- package/public/app/assets/{MemoryPage-B8FLXzLi.js → MemoryPage-Dwi2yi7E.js} +1 -1
- package/public/app/assets/NaturePage-DniImo9U.js +3 -0
- package/public/app/assets/{NaturePage-CHkpb5NK.css → NaturePage-zguDpGWY.css} +1 -1
- package/public/app/assets/{PeoplePage-D3yn0HV0.js → PeoplePage-D-MC3JEt.js} +1 -1
- package/public/app/assets/{PluginsPage-ojv8zawj.js → PluginsPage-D7dJTf96.js} +1 -1
- package/public/app/assets/SystemPage-C5CUHjGh.js +1 -0
- package/public/app/assets/{SystemPage-CGVTvNZw.css → SystemPage-C_Y5M7JU.css} +1 -1
- package/public/app/assets/{TimePage-Dyd-FFbq.js → TimePage-Dwhr3-92.js} +1 -1
- package/public/app/assets/{index-BJfzfgnm.js → index-L0bHax8C.js} +3 -3
- package/public/app/assets/{knowledge-C-bND0Ub.js → knowledge-BTDNafzl.js} +1 -1
- package/public/app/assets/{media-D7G3Nco6.js → media-BBzOAfNG.js} +1 -1
- package/public/app/assets/{mind-ec-G6iNZ.js → mind-CBTkFje2.js} +1 -1
- package/public/app/index.html +3 -3
- package/public/guide.en.html +3 -3
- package/public/guide.html +3 -3
- package/scripts/migrate-instance.js +27 -0
- package/scripts/update-instance.js +21 -2
- package/server/channels/contract.js +5 -1
- package/server/channels/hub.js +2 -2
- package/server/channels/onebot/adapter.js +291 -288
- package/server/channels/onebot/index.js +2 -2
- package/server/channels/qqbot/api.js +194 -199
- package/server/core/context-builder.js +20 -3
- package/server/core/conversation-cues.js +5 -4
- package/server/core/conversation-grounding.js +20 -17
- package/server/core/conversation-needs.js +32 -0
- package/server/core/dialogue-context.js +2 -5
- package/server/core/dialogue-contract.js +23 -16
- package/server/core/message-scheduler.js +85 -78
- package/server/core/model-manager.js +113 -16
- package/server/core/model-presets.js +1 -1
- package/server/core/orchestrator.js +18 -2
- package/server/core/persona-manager.js +14 -5
- package/server/core/reader-check.js +47 -42
- package/server/core/reply-delivery.js +125 -34
- package/server/core/response-validator.js +28 -1
- package/server/core/retired-prompts.js +5 -0
- package/server/core/speech-length.js +43 -0
- package/server/core/turn-processor.js +38 -5
- package/server/core/turn.js +70 -16
- package/server/i18n/errors.js +30 -0
- package/server/i18n/labels.js +5 -0
- package/server/index.js +3 -0
- package/server/knowledge/embedding-profile.js +126 -29
- package/server/knowledge/manager.js +103 -34
- package/server/knowledge/schema.js +197 -158
- package/server/mind/affect.js +267 -265
- package/server/mind/aftermath.js +169 -0
- package/server/mind/anticipations.js +2 -0
- package/server/mind/clock.js +91 -83
- package/server/mind/discretion.js +20 -0
- package/server/mind/episodes.js +2 -1
- package/server/mind/index.js +17 -2
- package/server/mind/initiative.js +29 -14
- package/server/mind/memory.js +47 -5
- package/server/mind/recall/cue.js +67 -0
- package/server/mind/recall/index.js +183 -29
- package/server/mind/recall/roots.js +231 -0
- package/server/mind/recall/schema.js +2 -0
- package/server/mind/recall/store.js +113 -30
- package/server/mind/salience.js +22 -1
- package/server/mind/schema.js +5 -0
- package/server/mind/thoughts.js +276 -274
- package/server/mind/view.js +100 -60
- package/server/plugins/install.js +1 -6
- package/server/plugins/zip.js +18 -4
- package/server/runtime/data-migration.js +193 -0
- package/server/runtime/update-execution.js +43 -4
- package/server/runtime/update-manager.js +4 -1
- package/server/storage/archive.js +1 -1
- package/server/storage/backup-scheduler.js +15 -3
- package/public/app/assets/NaturePage-C7i607ul.js +0 -3
- package/public/app/assets/SystemPage-Cv3rFvyT.js +0 -1
package/server/mind/view.js
CHANGED
|
@@ -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
|
-
...
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
|
336
|
-
mind.meetings.sayable(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
}
|
package/server/plugins/zip.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
97
|
-
|
|
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
|
+
}
|