luckytri 1.2.0 → 1.2.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 (49) hide show
  1. package/README.en.md +12 -14
  2. package/README.md +1 -3
  3. package/docs/en/CHANGELOG.md +17 -1
  4. package/docs/en/architecture.md +2 -2
  5. package/docs/en/guide.md +2 -0
  6. package/docs/en/npm-release.md +6 -0
  7. package/docs/zh/CHANGELOG.md +17 -1
  8. package/docs/zh/architecture.md +2 -2
  9. package/docs/zh/guide.md +2 -0
  10. package/docs/zh/npm-release.md +8 -0
  11. package/package.json +25 -3
  12. package/public/app/assets/{ChatsPage-eDqFIg97.js → ChatsPage-GGOaUl3H.js} +1 -1
  13. package/public/app/assets/{Empty-CAGOJsfD.js → Empty-BSmsWoGM.js} +1 -1
  14. package/public/app/assets/{Field-CRz-JmN1.js → Field-tUGp0-YL.js} +1 -1
  15. package/public/app/assets/{HeartPage-BGWoeMrC.js → HeartPage-B5_e1hI9.js} +1 -1
  16. package/public/app/assets/{LifePage-iao3o3wt.js → LifePage-D5P5kTzy.js} +1 -1
  17. package/public/app/assets/{MemoryPage-Dwi2yi7E.js → MemoryPage-BrSPiqOS.js} +1 -1
  18. package/public/app/assets/{NaturePage-DniImo9U.js → NaturePage-C0BdvfFu.js} +1 -1
  19. package/public/app/assets/{PeoplePage-D-MC3JEt.js → PeoplePage-DBT70lu7.js} +1 -1
  20. package/public/app/assets/{PluginsPage-D7dJTf96.js → PluginsPage-QBiQg3Jc.js} +1 -1
  21. package/public/app/assets/{SystemPage-C_Y5M7JU.css → SystemPage-Cf8lKh5C.css} +1 -1
  22. package/public/app/assets/{SystemPage-C5CUHjGh.js → SystemPage-EFOEvom-.js} +1 -1
  23. package/public/app/assets/{TimePage-Dwhr3-92.js → TimePage-BuNKjCgN.js} +1 -1
  24. package/public/app/assets/{index-L0bHax8C.js → index-BaXEYeFH.js} +3 -3
  25. package/public/app/assets/{knowledge-BTDNafzl.js → knowledge-COyBves6.js} +1 -1
  26. package/public/app/assets/{media-BBzOAfNG.js → media-8uRD5Ocs.js} +1 -1
  27. package/public/app/assets/{mind-CBTkFje2.js → mind-CQkvddhx.js} +1 -1
  28. package/public/app/index.html +3 -3
  29. package/public/guide.en.html +14 -3
  30. package/public/guide.html +15 -3
  31. package/public/tutorial.en.md +2 -0
  32. package/public/tutorial.md +2 -0
  33. package/scripts/service-supervisor.js +8 -13
  34. package/scripts/start-server.js +8 -1
  35. package/scripts/update-instance.js +93 -27
  36. package/server/core/context-builder.js +4 -1
  37. package/server/core/dialogue-contract.js +1 -1
  38. package/server/core/response-validator.js +18 -2
  39. package/server/core/turn.js +30 -1
  40. package/server/i18n/errors.js +36 -0
  41. package/server/mind/affect.js +4 -2
  42. package/server/mind/view.js +4 -1
  43. package/server/runtime/update-errors.js +73 -0
  44. package/server/runtime/update-execution.js +13 -8
  45. package/server/runtime/update-manager.js +30 -8
  46. package/docs/en/evolution.md +0 -35
  47. package/docs/en/vision.md +0 -50
  48. package/docs/zh/evolution.md +0 -35
  49. package/docs/zh/vision.md +0 -50
@@ -1,17 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { processAlive } from "../server/runtime/update-manager.js";
4
-
5
- const ACTIVE_UPDATE = new Set([
6
- "queued",
7
- "downloading",
8
- "checking",
9
- "stopping",
10
- "backup",
11
- "restarting",
12
- "verifying",
13
- "restoring",
14
- ]);
3
+ import {
4
+ processAlive,
5
+ isUpdateFinished,
6
+ } from "../server/runtime/update-manager.js";
15
7
 
16
8
  function readJob(home) {
17
9
  try {
@@ -34,7 +26,10 @@ export async function updatedServerAfterExit(
34
26
  while (true) {
35
27
  const job = read();
36
28
  if (!job || job.parent !== parentPid) return { matched: false, pid: null };
37
- if (!ACTIVE_UPDATE.has(job.status))
29
+ // This supervisor can outlive its installed release. A future worker may
30
+ // introduce stages this older process does not know; only explicit terminal
31
+ // states permit handover while the worker is still alive.
32
+ if (isUpdateFinished(job.status))
38
33
  return { matched: true, pid: alive(job.childPid) ? job.childPid : null };
39
34
  if (!alive(job.workerPid))
40
35
  return { matched: true, pid: alive(job.childPid) ? job.childPid : null };
@@ -30,7 +30,12 @@ else console.log("未检测到系统代理,外部 API 请求将直连。");
30
30
  let currentPid;
31
31
  for (const signal of ["SIGINT", "SIGTERM"])
32
32
  process.on(signal, () => {
33
- if (currentPid) process.kill(currentPid, signal);
33
+ if (currentPid)
34
+ try {
35
+ process.kill(currentPid, signal);
36
+ } catch (error) {
37
+ if (error.code !== "ESRCH") throw error;
38
+ }
34
39
  });
35
40
 
36
41
  async function start(root) {
@@ -71,6 +76,7 @@ try {
71
76
  } else {
72
77
  await waitForProcessExit(tracked.pid);
73
78
  }
79
+ currentPid = undefined;
74
80
 
75
81
  const update = await updatedServerAfterExit(paths.home, tracked.pid);
76
82
  if (!update.matched) break;
@@ -80,6 +86,7 @@ try {
80
86
  // after rollback). Keep this foreground process alive so systemd does not
81
87
  // kill that process or any still-running update work in the same cgroup.
82
88
  tracked = { pid: update.pid };
89
+ currentPid = update.pid;
83
90
  direct = false;
84
91
  continue;
85
92
  }
@@ -5,6 +5,7 @@ import {
5
5
  writeFileSync,
6
6
  readFileSync,
7
7
  realpathSync,
8
+ writeSync,
8
9
  } from "node:fs";
9
10
  import { dirname, join, delimiter } from "node:path";
10
11
  import { pathToFileURL } from "node:url";
@@ -23,6 +24,21 @@ import {
23
24
  writeUpdateJob,
24
25
  } from "../server/runtime/update-manager.js";
25
26
  import { updateRoot } from "../server/runtime/installation.js";
27
+ import {
28
+ npmFailureCode,
29
+ updateError,
30
+ } from "../server/runtime/update-errors.js";
31
+
32
+ export function requireUpdateNode(version = process.versions.node) {
33
+ const [major, minor] = String(version).split(".").map(Number);
34
+ if (
35
+ !Number.isInteger(major) ||
36
+ !Number.isInteger(minor) ||
37
+ major < 24 ||
38
+ (major === 24 && minor < 5)
39
+ )
40
+ throw updateError("NODE_UNSUPPORTED");
41
+ }
26
42
 
27
43
  export function installEnvironment(env) {
28
44
  const kept = {};
@@ -35,11 +51,11 @@ export function installEnvironment(env) {
35
51
  kept[key] = value;
36
52
  return kept;
37
53
  }
38
- export function findNpmCli(env = process.env) {
54
+ export function findNpmCli(env = process.env, execPath = process.execPath) {
39
55
  const candidates = [
40
56
  env.npm_execpath,
41
- join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"),
42
- join(dirname(process.execPath), "../lib/node_modules/npm/bin/npm-cli.js"),
57
+ join(dirname(execPath), "node_modules/npm/bin/npm-cli.js"),
58
+ join(dirname(execPath), "../lib/node_modules/npm/bin/npm-cli.js"),
43
59
  ...String(env.PATH || env.Path || "")
44
60
  .split(delimiter)
45
61
  .flatMap((dir) => [
@@ -60,31 +76,73 @@ export function findNpmCli(env = process.env) {
60
76
  /* Try the next native Node/npm installation location. */
61
77
  }
62
78
  }
63
- throw Error("未找到 npm");
79
+ throw updateError("NPM_NOT_FOUND");
64
80
  }
65
- function command(args, { cwd, env = process.env, timeout = 600000, log } = {}) {
81
+ export function runUpdateCommand(
82
+ args,
83
+ {
84
+ cwd,
85
+ env = process.env,
86
+ timeout = 600000,
87
+ log,
88
+ failureCode = "STEP_FAILED",
89
+ npmInstall = false,
90
+ } = {},
91
+ ) {
66
92
  return new Promise((resolve, reject) => {
67
93
  const fd = log ? openSync(log, "a", 0o600) : null;
68
- const child = spawn(process.execPath, args, {
69
- cwd,
70
- env,
71
- windowsHide: true,
72
- stdio: fd === null ? "ignore" : ["ignore", fd, fd],
73
- });
74
- if (fd !== null) closeSync(fd);
75
- let timedOut = false;
76
- const timer = setTimeout(() => {
94
+ let timer,
95
+ child,
96
+ settled = false,
97
+ timedOut = false,
98
+ outputError,
99
+ tail = "";
100
+ const finish = (error) => {
101
+ if (settled) return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ try {
105
+ if (fd !== null) closeSync(fd);
106
+ } catch (closeError) {
107
+ error ||= closeError;
108
+ }
109
+ if (error) reject(error);
110
+ else resolve();
111
+ };
112
+ try {
113
+ child = spawn(process.execPath, args, {
114
+ cwd,
115
+ env,
116
+ windowsHide: true,
117
+ stdio: ["ignore", "pipe", "pipe"],
118
+ });
119
+ } catch (error) {
120
+ finish(error);
121
+ return;
122
+ }
123
+ const capture = (chunk) => {
124
+ if (settled) return;
125
+ tail = (tail + chunk.toString("utf8")).slice(-16384);
126
+ try {
127
+ if (fd !== null) writeSync(fd, chunk);
128
+ } catch (error) {
129
+ outputError = error;
130
+ // Wait for exit before rollback can rename a migration database.
131
+ child.kill("SIGKILL");
132
+ }
133
+ };
134
+ child.stdout.on("data", capture);
135
+ child.stderr.on("data", capture);
136
+ timer = setTimeout(() => {
77
137
  timedOut = true;
78
- child.kill();
138
+ child.kill("SIGKILL");
79
139
  }, timeout);
80
- child.once("error", (e) => {
81
- clearTimeout(timer);
82
- reject(e);
83
- });
84
- child.once("exit", (code) => {
85
- clearTimeout(timer);
86
- if (timedOut) reject(Error("更新步骤超时"));
87
- else code === 0 ? resolve() : reject(Error("更新程序校验失败"));
140
+ child.once("error", finish);
141
+ child.once("close", (code) => {
142
+ if (outputError) finish(outputError);
143
+ else if (timedOut) finish(updateError("STEP_TIMEOUT"));
144
+ else if (code === 0) finish();
145
+ else finish(updateError(npmInstall ? npmFailureCode(tail) : failureCode));
88
146
  });
89
147
  });
90
148
  }
@@ -94,10 +152,11 @@ export function updateExecution(paths) {
94
152
  const pause = () => new Promise((r) => setTimeout(r, 250));
95
153
  return {
96
154
  async install(slot, version) {
155
+ requireUpdateNode();
97
156
  const cli = findNpmCli();
98
157
  const userconfig = join(updateRoot(paths.home), "install.npmrc");
99
158
  writeFileSync(userconfig, "", { mode: 0o600 });
100
- await command(
159
+ await runUpdateCommand(
101
160
  [
102
161
  cli,
103
162
  "install",
@@ -112,20 +171,26 @@ export function updateExecution(paths) {
112
171
  "--engine-strict",
113
172
  `luckytri@${version}`,
114
173
  ],
115
- { cwd: slot, env: installEnvironment(process.env), log: paths.log },
174
+ {
175
+ cwd: slot,
176
+ env: installEnvironment(process.env),
177
+ log: paths.log,
178
+ npmInstall: true,
179
+ },
116
180
  );
117
181
  },
118
182
  async check(root, version) {
119
183
  const script = `import fs from 'node:fs';import {createRequire} from 'node:module';const p=JSON.parse(fs.readFileSync(${JSON.stringify(join(root, "package.json"))},'utf8'));if(p.version!==${JSON.stringify(version)})process.exit(1);await import(${JSON.stringify(pathToFileURL(join(root, "server/app.js")).href)});const sharp=createRequire(${JSON.stringify(pathToFileURL(join(root, "package.json")).href)})('sharp');await sharp({create:{width:1,height:1,channels:3,background:'#fff'}}).png().toBuffer();`;
120
- await command(["--input-type=module", "-e", script], {
184
+ await runUpdateCommand(["--input-type=module", "-e", script], {
121
185
  cwd: root,
122
186
  env: installEnvironment(process.env),
123
187
  timeout: 30000,
124
188
  log: paths.log,
189
+ failureCode: "PACKAGE_CHECK_FAILED",
125
190
  });
126
191
  },
127
192
  async migrate(root, database, { previous, version, databaseId }) {
128
- await command(
193
+ await runUpdateCommand(
129
194
  [
130
195
  join(root, "scripts", "migrate-instance.js"),
131
196
  database,
@@ -138,6 +203,7 @@ export function updateExecution(paths) {
138
203
  env: installEnvironment(process.env),
139
204
  timeout: 3600000,
140
205
  log: paths.log,
206
+ failureCode: "MIGRATION_FAILED",
141
207
  },
142
208
  );
143
209
  },
@@ -1,3 +1,4 @@
1
+ import { oneTypedMessage } from "./response-validator.js";
1
2
  import { effectivePersona, identityAliases } from "./persona-manager.js";
2
3
  import { localClock, conversationCues } from "./conversation-cues.js";
3
4
  import { estimateTokens } from "./model-manager.js";
@@ -102,7 +103,9 @@ export function buildContext(
102
103
  localTime: localClock(m.time, policy.timeZone).local,
103
104
  role: m.role,
104
105
  ...(m.artifact ? { artifact: m.artifact } : {}),
105
- text: m.text,
106
+ // Her own earlier messages are shown as typed lines: an old reply laid
107
+ // out a sentence per line would otherwise teach her that layout again.
108
+ text: m.role === "assistant" ? oneTypedMessage(m.text) : m.text,
106
109
  mentions: m.mentions || [],
107
110
  replyTo: m.replyTo,
108
111
  replyChain: m.replyChain,
@@ -20,7 +20,7 @@ export const DIALOGUE_TASK = `读懂眼前这轮聊天,再用自己的话回
20
20
  长短和口吻来自你自己:inner.ownVoice 是你自己一直以来打字的样子,inner.state 是此刻的心情和精力,再加上你和眼前这个人的关系、刚发生的事;别人怎么打字只是在场的情况,不去模仿。像在 QQ 里随手打一句,不写组织好的书面段落;答案不省,意思说够就停,不为活跃气氛加问题。问过的细节对方没接,就回应他继续说的内容,不立刻换词再问。如果这段对话里的人明确要求一段发完,同一话题后续都收进一条消息。列点也可以放在一条消息里换行。被明确要求停止就停止,新的实际问题仍可回应。
21
21
  若一轮里同时有人提出表达偏好、另一人分享新内容,先简短确认偏好,再回应新内容;不能让确认格式代替对内容的回应。
22
22
  停止的范围按对方原话理解:不要方案,就停止给方案;不要纠结某个细节,就放下那个细节,不把整个话题或交流关掉。对方明确说一条旁枝不是他问的,简短承认后回到主线;不要说“我没理清楚”或“先不乱说”,除非那条信息确实是回答主问题所必需的。
23
- 输出 JSON:{"understanding":{"kind":"question|proposal|feeling|sharing|correction|greeting|closing|other","messageIds":[本轮回应的序号],"point":"对方此刻的具体意思","tone":"serious|joking|teasing|ironic|venting","stance":"fact|guess|opinion|joke|hearsay"},"contribution":{"kind":"answer|reaction|question|idea|correction|none","point":"自己这次实际说出的意思,不是收住/接住之类动作"},"choice":"speak|react|decline|silent","targetMessageIds":[batchIds中的序号],"topic":"当前话题","say":"实际说的话","appraisal":"这件事对自己的实际影响,没有则空","reason":"为何这样选择,用名字不用QQ号","feelings":[{"feeling":"两三个字","intensity":0到1,"valence":-1到1,"arousal":0到1,"cause":[消息序号]}],"bonds":[{"userId":"speaker","change":"warmer|closer|trust_up|trust_down|friction|repair|distance","why":"具体变化","evidence":[消息序号]}],"crisis":{"clear":false,"messageIds":[]}}。silent 时 say 为空;react 是一句很短的话;say 是你在输入框里打的字,按一次发送就是一条消息;想分几次发时用空行隔开,说多少、发几次由你此刻想说什么决定;若对方已要求同一段发完,则把连贯补充收在一条消息里,列点也可在同一条里换行。真实自伤危机认真回应眼前安全,crisis.clear=true。understanding 里的 tone 和 stance 可选:所回应的话是认真、玩笑、调侃、反话还是发泄,说的是事实、猜测、观点、玩笑还是转述;拿不准就不写这两项。working.reading 只是字面提示,以前后文为准。choice、targetMessageIds 等其余字段仍在最外层。`;
23
+ 输出 JSON:{"understanding":{"kind":"question|proposal|feeling|sharing|correction|greeting|closing|other","messageIds":[本轮回应的序号],"point":"对方此刻的具体意思","tone":"serious|joking|teasing|ironic|venting","stance":"fact|guess|opinion|joke|hearsay"},"contribution":{"kind":"answer|reaction|question|idea|correction|none","point":"自己这次实际说出的意思,不是收住/接住之类动作"},"choice":"speak|react|decline|silent","targetMessageIds":[batchIds中的序号],"topic":"当前话题","say":"实际说的话","appraisal":"这件事对自己的实际影响,没有则空","reason":"为何这样选择,用名字不用QQ号","feelings":[{"feeling":"两三个字","intensity":0到1,"valence":-1到1,"arousal":0到1,"cause":[消息序号]}],"bonds":[{"userId":"speaker","change":"warmer|closer|trust_up|trust_down|friction|repair|distance","why":"具体变化","evidence":[消息序号]}],"crisis":{"clear":false,"messageIds":[]}}。silent 时 say 为空;react 是一句很短的话;say 是你在输入框里打的字,换行就是按了发送、另起一条消息,说多少、发几次由你此刻想说什么决定;若对方已要求同一段发完,则把连贯补充收在一条消息里,列点也可在同一条里换行。真实自伤危机认真回应眼前安全,crisis.clear=true。understanding 里的 tone 和 stance 可选:所回应的话是认真、玩笑、调侃、反话还是发泄,说的是事实、猜测、观点、玩笑还是转述;拿不准就不写这两项。working.reading 只是字面提示,以前后文为准。choice、targetMessageIds 等其余字段仍在最外层。`;
24
24
 
25
25
  export const DIALOGUE_REWRITE = `重新核对 exchange.current 和引用,修改 draft 中被指出的具体问题。decision.understanding 可能也有误,以原话为准。保留成立的内容,直接说这轮真正想说的话;不要把修改写成新的认错套话或内部状态报告。self.livingFor 只作背景,不必提起。输出 {"bubbles":["实际内容"],"reason":"为何这样说"}。`;
26
26
 
@@ -14,7 +14,7 @@ import {
14
14
  recentOwnMessages,
15
15
  routineSocialReply,
16
16
  } from "./conversation-grounding.js";
17
- import { joinBubbleParts } from "./turn.js";
17
+ import { joinBubbleParts, typedSends } from "./turn.js";
18
18
  const norm = (s) => s.toLowerCase().replace(/[\s\p{P}\p{S}]/gu, "");
19
19
  export function wrongIdentityClaim(text, snapshot) {
20
20
  const name = snapshot.persona?.name;
@@ -198,7 +198,7 @@ export function normalizeResponse(result, decision, fallback = "嗯") {
198
198
  }
199
199
  bubbles = bubbles
200
200
  .filter((x) => typeof x === "string")
201
- .map((x) => x.trim())
201
+ .flatMap((x) => typedSends(x.trim()))
202
202
  .filter(Boolean);
203
203
  if (bubbles.length > maxBubbles)
204
204
  bubbles =
@@ -601,3 +601,19 @@ export function deniesPrivateKnowledge(bubbles, snapshot) {
601
601
  /不知道|没听说|没听过|(没|不曾)(跟|和|对)我(说|提|讲)|没提过/.test(line),
602
602
  );
603
603
  }
604
+
605
+ // One message is typed as one line. A line break inside a message is only
606
+ // kept for a list (every line a "- " or "1." item); otherwise the lines are
607
+ // one thought she typed, joined the way she would type them. A sentence per
608
+ // line inside one chat bubble reads as a machine laying out text.
609
+ export function oneTypedMessage(text) {
610
+ const lines = String(text || "")
611
+ .split(/\r?\n/)
612
+ .map((line) => line.trim())
613
+ .filter(Boolean);
614
+ if (lines.length < 2) return lines[0] || "";
615
+ const item = /^(?:[-*•·]|\d+[.)、.]|[((]?\d+[))])\s*/u;
616
+ if (lines.filter((line) => item.test(line)).length >= 2)
617
+ return lines.join("\n");
618
+ return joinBubbleParts(lines);
619
+ }
@@ -600,7 +600,7 @@ function turnGuides(snapshot) {
600
600
  export function saidMessages(text) {
601
601
  return String(text)
602
602
  .split(/\n[ \t\u3000]*\n+/)
603
- .map((part) => part.trim())
603
+ .flatMap((part) => typedSends(part.trim()))
604
604
  .filter(Boolean);
605
605
  }
606
606
 
@@ -610,3 +610,32 @@ export function withSaidMessages(raw) {
610
610
  const { say, ...rest } = raw;
611
611
  return { ...rest, bubbles: saidMessages(say) };
612
612
  }
613
+
614
+ // In a chat box, Enter sends. A line break she typed is a message she sent;
615
+ // only a list stays together in one message.
616
+ export function typedSends(text) {
617
+ const lines = String(text || "")
618
+ .split(/\r?\n/)
619
+ .map((line) => line.trim())
620
+ .filter(Boolean);
621
+ if (lines.length < 2) return lines;
622
+ const item = /^(?:[-*•·]|\d+[.)、.]|[((]?\d+[))])\s*/u;
623
+ if (lines.filter((line) => item.test(line)).length >= 2)
624
+ return [lines.join("\n")];
625
+ // A line left mid-sentence, or one that only continues the last, was not
626
+ // a separate send: the thought goes out as one message.
627
+ const sends = [];
628
+ for (const line of lines) {
629
+ const last = sends.at(-1);
630
+ if (
631
+ last &&
632
+ (/[,、::,]$/u.test(last) ||
633
+ /^(?:[,、;;]|但|但是|不过|而且|所以|因为|然后|就是|或者|要是|如果|反正)/u.test(
634
+ line,
635
+ ))
636
+ )
637
+ sends[sends.length - 1] = joinBubbleParts([last, line]);
638
+ else sends.push(line);
639
+ }
640
+ return sends;
641
+ }
@@ -34,6 +34,42 @@ export const ERRORS = {
34
34
  "The update did not complete. The original program and data were retained. Check the runtime log before trying again.",
35
35
  "自动恢复未完成,更新前的校验备份已保留,请查看运行日志并手动恢复。":
36
36
  "Automatic recovery did not complete. The verified pre-update backup was retained. Check the runtime log and recover manually.",
37
+ "Node.js 版本不满足新版本要求,请先升级 Node.js 后重试。":
38
+ "This Node.js version does not meet the new release requirements. Upgrade Node.js and try again.",
39
+ "当前服务找不到 npm,请安装 npm 并检查服务的 PATH 配置。":
40
+ "The service cannot find npm. Install npm and check the service PATH.",
41
+ "npm 暂时无法下载该版本或其运行依赖,请稍后重新检查更新。":
42
+ "npm cannot download this release or its runtime dependencies yet. Check for updates again later.",
43
+ "连接 npm 失败,请检查服务器的网络、DNS 和代理配置。":
44
+ "Cannot reach npm. Check the server network, DNS and proxy settings.",
45
+ "连接 npm 时证书校验失败,请检查服务器的证书和代理配置。":
46
+ "Certificate verification failed when connecting to npm. Check the server certificates and proxy settings.",
47
+ "npm 拒绝了下载请求,请检查服务器的网络或代理访问限制。":
48
+ "npm rejected the download request. Check network or proxy access restrictions.",
49
+ "磁盘空间不足,请为新程序、数据库快照和迁移副本留出空间。":
50
+ "There is not enough disk space for the new program, database snapshot and migration copy.",
51
+ "服务账号没有所需的读写权限,请检查实例和更新目录的权限。":
52
+ "The service account lacks read or write access. Check the instance and update directory permissions.",
53
+ "当前更新步骤超时,请检查运行日志后重试。":
54
+ "The update step timed out. Check the runtime log and try again.",
55
+ "新版本安装失败,旧实例仍在运行,请查看运行日志中的 npm 报错。":
56
+ "Installation failed while the previous instance remains running. Check the npm errors in the runtime log.",
57
+ "新程序或本机运行依赖校验失败,请查看运行日志。":
58
+ "The new program or its local runtime dependencies failed validation. Check the runtime log.",
59
+ "旧实例未能安全停止,请检查进程管理和实例端口。":
60
+ "The previous instance could not be stopped safely. Check process management and the instance port.",
61
+ "更新前的数据库快照未能完成或通过校验,请查看运行日志。":
62
+ "The pre-update database snapshot could not be completed or verified. Check the runtime log.",
63
+ "记忆与数据迁移未通过校验,请查看运行日志。":
64
+ "Memory and data migration failed validation. Check the runtime log.",
65
+ "新实例未能正常启动,请检查运行日志和端口占用。":
66
+ "The new instance could not start correctly. Check the runtime log and port usage.",
67
+ "更新进程意外退出,请检查服务管理器、进程退出记录和运行日志。":
68
+ "The update process exited unexpectedly. Check the service manager, process exit records and runtime log.",
69
+ "旧版本或数据库恢复未完成,请使用更新前的校验备份恢复。":
70
+ "The previous program or database could not be restored. Recover from the verified pre-update backup.",
71
+ "当前更新步骤未完成,请查看运行日志。":
72
+ "The update step did not complete. Check the runtime log.",
37
73
  "缺少候选版本的数据迁移器,已停止更新":
38
74
  "The candidate release has no data migrator. The update was stopped.",
39
75
  迁移目标不是现有的绝对路径数据库文件:
@@ -196,7 +196,7 @@ export class Affect {
196
196
  if (batchAt !== null && row.created !== batchAt) apply(batchAt);
197
197
  batchAt = row.created;
198
198
  batchV += row.valence * row.intensity;
199
- batchA += (row.arousal - BASELINE.arousal) * row.intensity * 0.5;
199
+ batchA += (row.arousal - BASELINE.arousal) * row.intensity;
200
200
  last = row;
201
201
  }
202
202
  apply(batchAt);
@@ -213,7 +213,9 @@ export class Affect {
213
213
  .get(now - 2 * HOUR, now).n;
214
214
  const energy = clamp(
215
215
  phase.energy +
216
- (arousal - BASELINE.arousal) * 0.3 -
216
+ // How worn or wound up she feels is part of her energy: a night up
217
+ // must not read as "有精神" (round-19 probe).
218
+ (arousal - BASELINE.arousal) * 0.6 -
217
219
  Math.min(0.2, talked * 0.006) -
218
220
  Math.min(
219
221
  0.12,
@@ -405,7 +405,10 @@ export function ownTyping(mind, session, now) {
405
405
  `SELECT text FROM messages WHERE role='assistant' AND is_demo=0 AND time<=? AND time>? ${where} ORDER BY id DESC LIMIT 40`,
406
406
  )
407
407
  .all(now + 60000, now - 14 * 86400000, ...args)
408
- .map((row) => String(row.text || "").trim())
408
+ // An older reply laid out a sentence per line was several sends in
409
+ // one message; each line is counted as the message it would be now.
410
+ .flatMap((row) => String(row.text || "").split(/\n+/))
411
+ .map((t) => t.trim())
409
412
  .filter((t) => t && !/^\[/.test(t));
410
413
  let lines = pick("AND session_id=?", session);
411
414
  if (lines.length < 4) lines = pick("");
@@ -0,0 +1,73 @@
1
+ // Only these descriptions reach the management UI. Subprocess output can
2
+ // contain local paths or credentials and belongs in the local log only.
3
+ export const UPDATE_FAILURES = Object.freeze({
4
+ NODE_UNSUPPORTED: "Node.js 版本不满足新版本要求,请先升级 Node.js 后重试。",
5
+ NPM_NOT_FOUND: "当前服务找不到 npm,请安装 npm 并检查服务的 PATH 配置。",
6
+ VERSION_UNAVAILABLE:
7
+ "npm 暂时无法下载该版本或其运行依赖,请稍后重新检查更新。",
8
+ NETWORK_ERROR: "连接 npm 失败,请检查服务器的网络、DNS 和代理配置。",
9
+ TLS_ERROR: "连接 npm 时证书校验失败,请检查服务器的证书和代理配置。",
10
+ REGISTRY_ACCESS: "npm 拒绝了下载请求,请检查服务器的网络或代理访问限制。",
11
+ DISK_FULL: "磁盘空间不足,请为新程序、数据库快照和迁移副本留出空间。",
12
+ PERMISSION_DENIED: "服务账号没有所需的读写权限,请检查实例和更新目录的权限。",
13
+ STEP_TIMEOUT: "当前更新步骤超时,请检查运行日志后重试。",
14
+ INSTALL_FAILED:
15
+ "新版本安装失败,旧实例仍在运行,请查看运行日志中的 npm 报错。",
16
+ PACKAGE_CHECK_FAILED: "新程序或本机运行依赖校验失败,请查看运行日志。",
17
+ STOP_FAILED: "旧实例未能安全停止,请检查进程管理和实例端口。",
18
+ BACKUP_FAILED: "更新前的数据库快照未能完成或通过校验,请查看运行日志。",
19
+ MIGRATION_FAILED: "记忆与数据迁移未通过校验,请查看运行日志。",
20
+ START_FAILED: "新实例未能正常启动,请检查运行日志和端口占用。",
21
+ UPDATE_INTERRUPTED:
22
+ "更新进程意外退出,请检查服务管理器、进程退出记录和运行日志。",
23
+ RECOVERY_FAILED: "旧版本或数据库恢复未完成,请使用更新前的校验备份恢复。",
24
+ STEP_FAILED: "当前更新步骤未完成,请查看运行日志。",
25
+ });
26
+
27
+ export function updateError(code) {
28
+ const known = Object.hasOwn(UPDATE_FAILURES, code) ? code : "STEP_FAILED";
29
+ return Object.assign(Error(UPDATE_FAILURES[known]), { updateCode: known });
30
+ }
31
+
32
+ export function npmFailureCode(output) {
33
+ for (const [pattern, code] of [
34
+ [/\bEBADENGINE\b/, "NODE_UNSUPPORTED"],
35
+ [/\bENOSPC\b/, "DISK_FULL"],
36
+ [/\b(?:EACCES|EPERM)\b/, "PERMISSION_DENIED"],
37
+ [/\b(?:E404|ETARGET)\b/, "VERSION_UNAVAILABLE"],
38
+ [/\b(?:E401|E403)\b/, "REGISTRY_ACCESS"],
39
+ [
40
+ /\b(?:CERT_[A-Z_]+|[A-Z_]*CERTIFICATE[A-Z_]*|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE)\b/,
41
+ "TLS_ERROR",
42
+ ],
43
+ [
44
+ /\b(?:ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|ENETUNREACH|EHOSTUNREACH)\b/,
45
+ "NETWORK_ERROR",
46
+ ],
47
+ ])
48
+ if (pattern.test(output)) return code;
49
+ return "INSTALL_FAILED";
50
+ }
51
+
52
+ export function updateFailure(error, stage) {
53
+ const osCode = {
54
+ ENOSPC: "DISK_FULL",
55
+ EACCES: "PERMISSION_DENIED",
56
+ EPERM: "PERMISSION_DENIED",
57
+ }[error?.code];
58
+ const fallback =
59
+ {
60
+ downloading: "INSTALL_FAILED",
61
+ checking: "PACKAGE_CHECK_FAILED",
62
+ stopping: "STOP_FAILED",
63
+ backup: "BACKUP_FAILED",
64
+ migrating: "MIGRATION_FAILED",
65
+ restarting: "START_FAILED",
66
+ verifying: "START_FAILED",
67
+ restoring: "RECOVERY_FAILED",
68
+ }[stage] || "STEP_FAILED";
69
+ const failureCode = Object.hasOwn(UPDATE_FAILURES, error?.updateCode)
70
+ ? error.updateCode
71
+ : osCode || fallback;
72
+ return { failureCode, detail: UPDATE_FAILURES[failureCode] };
73
+ }
@@ -15,6 +15,7 @@ import {
15
15
  } from "./update-manager.js";
16
16
  import { backupDatabase } from "../../scripts/backup.js";
17
17
  import { restoreToNewFile, verifyArchive } from "../storage/archive.js";
18
+ import { updateFailure } from "./update-errors.js";
18
19
 
19
20
  // Executed outside the server so a large verified snapshot does not stall the
20
21
  // WebUI. Dependencies are explicit for failure, restart and rollback tests.
@@ -105,12 +106,9 @@ export async function performUpdate(
105
106
  return job;
106
107
  } catch (error) {
107
108
  const failedStage = job.status;
108
- job = { ...job, failedStage };
109
- console.error(
110
- "更新步骤未完成:",
111
- failedStage,
112
- error.code || error.cause?.code || "STEP_FAILED",
113
- );
109
+ const failure = updateFailure(error, failedStage);
110
+ job = { ...job, failedStage, ...failure };
111
+ console.error("更新步骤未完成:", failedStage, failure.failureCode, error);
114
112
  if (!stopped) {
115
113
  save("error", { error: UPDATE_FAILED, finished: Date.now() });
116
114
  return job;
@@ -150,6 +148,7 @@ export async function performUpdate(
150
148
  `${paths.database}.update-failed-${id}.migration${suffix}`,
151
149
  );
152
150
  const restored = await start(paths.package);
151
+ save("restoring", { childPid: restored });
153
152
  await health(restored, job.previous);
154
153
  save("error", {
155
154
  error: UPDATE_FAILED,
@@ -157,8 +156,14 @@ export async function performUpdate(
157
156
  childPid: restored,
158
157
  finished: Date.now(),
159
158
  });
160
- } catch {
161
- save("error", { error: UPDATE_RECOVERY_FAILED, finished: Date.now() });
159
+ } catch (recoveryError) {
160
+ const recovery = updateFailure(recoveryError, "restoring");
161
+ console.error("更新恢复未完成:", recovery.failureCode, recoveryError);
162
+ save("error", {
163
+ error: UPDATE_RECOVERY_FAILED,
164
+ recoveryCode: recovery.failureCode,
165
+ finished: Date.now(),
166
+ });
162
167
  }
163
168
  return job;
164
169
  } finally {
@@ -1,6 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { mkdirSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import {
5
+ updateError,
6
+ updateFailure,
7
+ UPDATE_FAILURES,
8
+ } from "./update-errors.js";
4
9
  import {
5
10
  atomicJson,
6
11
  UPDATE_ID,
@@ -11,7 +16,7 @@ export const UPDATE_FAILED =
11
16
  "更新未完成,原程序和数据已保留,请查看运行日志后重试。";
12
17
  export const UPDATE_RECOVERY_FAILED =
13
18
  "自动恢复未完成,更新前的校验备份已保留,请查看运行日志并手动恢复。";
14
- const ACTIVE = new Set([
19
+ export const UPDATE_ACTIVE_STAGES = Object.freeze([
15
20
  "queued",
16
21
  "downloading",
17
22
  "checking",
@@ -22,6 +27,12 @@ const ACTIVE = new Set([
22
27
  "verifying",
23
28
  "restoring",
24
29
  ]);
30
+ const ACTIVE = new Set(UPDATE_ACTIVE_STAGES);
31
+ export const isUpdateActive = (stage) => ACTIVE.has(stage);
32
+ export const isUpdateFinished = (stage) =>
33
+ stage === "done" || stage === "error";
34
+ const beforeStop = (stage) =>
35
+ ["queued", "downloading", "checking"].includes(stage);
25
36
  export const processAlive = (pid) => {
26
37
  if (!Number.isInteger(pid) || pid <= 0) return false;
27
38
  try {
@@ -70,18 +81,17 @@ export class InstanceUpdater {
70
81
  return null;
71
82
  }
72
83
  if (
73
- ACTIVE.has(job.status) &&
84
+ isUpdateActive(job.status) &&
74
85
  job.workerPid &&
75
86
  !processAlive(job.workerPid)
76
87
  ) {
88
+ const failedStage = job.status;
77
89
  job = writeUpdateJob(this.paths.home, {
78
90
  ...job,
79
91
  status: "error",
80
- error: ["queued", "downloading", "checking", "migrating"].includes(
81
- job.status,
82
- )
83
- ? UPDATE_FAILED
84
- : UPDATE_RECOVERY_FAILED,
92
+ failedStage,
93
+ ...updateFailure(updateError("UPDATE_INTERRUPTED"), failedStage),
94
+ error: beforeStop(failedStage) ? UPDATE_FAILED : UPDATE_RECOVERY_FAILED,
85
95
  finished: this.now(),
86
96
  });
87
97
  }
@@ -95,10 +105,22 @@ export class InstanceUpdater {
95
105
  finished: job.finished,
96
106
  error: job.error || "",
97
107
  restored: !!job.restored,
108
+ ...(isUpdateActive(job.failedStage)
109
+ ? { failedStage: job.failedStage }
110
+ : {}),
111
+ ...(Object.hasOwn(UPDATE_FAILURES, job.failureCode)
112
+ ? {
113
+ failureCode: job.failureCode,
114
+ detail: UPDATE_FAILURES[job.failureCode],
115
+ }
116
+ : {}),
117
+ ...(Object.hasOwn(UPDATE_FAILURES, job.recoveryCode)
118
+ ? { recoveryDetail: UPDATE_FAILURES[job.recoveryCode] }
119
+ : {}),
98
120
  };
99
121
  }
100
122
  get busy() {
101
- return this.starting || ACTIVE.has(this.status()?.status);
123
+ return this.starting || isUpdateActive(this.status()?.status);
102
124
  }
103
125
  async install(version) {
104
126
  if (this.busy) return this.status();