luckytri 1.0.6 → 1.0.7

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 (155) hide show
  1. package/LICENSE +21 -21
  2. package/docs/en/CHANGELOG.md +25 -16
  3. package/docs/en/chat-style.md +28 -9
  4. package/docs/en/guide.md +5 -5
  5. package/docs/en/her-life.md +2 -2
  6. package/docs/en/webui.md +1 -1
  7. package/docs/zh/CHANGELOG.md +25 -16
  8. package/docs/zh/chat-style.md +30 -9
  9. package/docs/zh/guide.md +5 -5
  10. package/docs/zh/her-life.md +2 -2
  11. package/docs/zh/webui.md +1 -1
  12. package/package.json +1 -1
  13. package/public/app/assets/{ChatsPage-DCYMkJ91.js → ChatsPage-CshdtTzV.js} +2 -2
  14. package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
  15. package/public/app/assets/{Empty-CLyZkxR5.js → Empty-C7yzOBzy.js} +1 -1
  16. package/public/app/assets/{Empty-DIobYG3x.css → Empty-DmUB2-Bk.css} +1 -1
  17. package/public/app/assets/{Field-BpaRwLI_.js → Field-BjFv4Rfn.js} +1 -1
  18. package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
  19. package/public/app/assets/{HeartPage-BIOMTiwr.js → HeartPage-eqo6ru16.js} +1 -1
  20. package/public/app/assets/LifePage-BUi5euBF.css +1 -0
  21. package/public/app/assets/{LifePage-BCgM4JkR.js → LifePage-CN8GE5NK.js} +1 -1
  22. package/public/app/assets/{MemoryPage-vShvRqMt.js → MemoryPage-CCpMN4Wg.js} +1 -1
  23. package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
  24. package/public/app/assets/{NaturePage-Cy9ptr4G.js → NaturePage-C0JqdBTR.js} +2 -2
  25. package/public/app/assets/NaturePage-CHkpb5NK.css +1 -0
  26. package/public/app/assets/PeoplePage-BTU0H1pM.css +1 -0
  27. package/public/app/assets/{PeoplePage-CUZekB2E.js → PeoplePage-BXGBbO4u.js} +1 -1
  28. package/public/app/assets/{PluginsPage-1RM4rmJP.js → PluginsPage-Dksp5AV5.js} +1 -1
  29. package/public/app/assets/{SystemPage-BI2CB5Ig.css → SystemPage-BenIyTvk.css} +1 -1
  30. package/public/app/assets/{SystemPage-BeT5TmIV.js → SystemPage-Cd7aa8eE.js} +1 -1
  31. package/public/app/assets/TimePage-BOms_n4I.css +1 -0
  32. package/public/app/assets/{TimePage-taTmPMM8.js → TimePage-Bs_zImb4.js} +2 -2
  33. package/public/app/assets/{index-DY5AM0Hz.css → index-BHZcHUMh.css} +1 -1
  34. package/public/app/assets/{index-DdYwC5ZU.js → index-CyRtYxP3.js} +3 -3
  35. package/public/app/assets/{knowledge-DJmfu2KN.js → knowledge-BJOIyuJo.js} +1 -1
  36. package/public/app/assets/{media-BEtnE0Gv.js → media-Bwtx8blz.js} +1 -1
  37. package/public/app/assets/{mind-Dtiw0-QK.js → mind-DtTVrW6Z.js} +1 -1
  38. package/public/app/index.html +5 -6
  39. package/public/guide.en.html +3 -3
  40. package/public/guide.html +3 -3
  41. package/scripts/import-data.js +128 -128
  42. package/scripts/proxy-config.js +167 -167
  43. package/scripts/update-instance.js +201 -201
  44. package/server/channels/adapters.js +27 -27
  45. package/server/channels/onebot/adapter.js +288 -288
  46. package/server/channels/qqbot/adapter.js +253 -253
  47. package/server/channels/qqbot/api.js +199 -199
  48. package/server/channels/qqbot/credentials.js +11 -11
  49. package/server/channels/qqbot/gateway.js +274 -274
  50. package/server/channels/qqbot/index.js +389 -389
  51. package/server/channels/segments.js +40 -40
  52. package/server/channels/session-key.js +153 -153
  53. package/server/channels/state.js +42 -42
  54. package/server/core/api.js +564 -564
  55. package/server/core/context-builder.js +288 -288
  56. package/server/core/context-compactor.js +454 -454
  57. package/server/core/conversation-cues.js +365 -325
  58. package/server/core/conversation-grounding.js +178 -0
  59. package/server/core/conversation-manager.js +87 -87
  60. package/server/core/dialogue-context.js +52 -0
  61. package/server/core/feedback.js +8 -8
  62. package/server/core/group-style.js +72 -72
  63. package/server/core/initiative-context.js +66 -66
  64. package/server/core/input-budget.js +32 -32
  65. package/server/core/local-demo.js +98 -98
  66. package/server/core/message-manager.js +118 -118
  67. package/server/core/message-scheduler.js +78 -78
  68. package/server/core/model-json.js +190 -190
  69. package/server/core/model-presets.js +900 -900
  70. package/server/core/name-call.js +29 -29
  71. package/server/core/network.js +157 -157
  72. package/server/core/person-name.js +22 -22
  73. package/server/core/persona-manager.js +324 -269
  74. package/server/core/reply-delivery.js +472 -404
  75. package/server/core/reply-target-resolver.js +50 -50
  76. package/server/core/response-generator.js +58 -50
  77. package/server/core/response-validator.js +30 -27
  78. package/server/core/retired-prompts.js +51 -51
  79. package/server/core/sessions.js +109 -109
  80. package/server/core/speaker-names.js +92 -92
  81. package/server/core/topic-tracker.js +65 -65
  82. package/server/core/turn.js +42 -8
  83. package/server/http.js +18 -18
  84. package/server/i18n/decisions.js +209 -209
  85. package/server/i18n/index.js +144 -144
  86. package/server/i18n/labels.js +0 -5
  87. package/server/i18n/models.js +111 -111
  88. package/server/knowledge/api.js +342 -342
  89. package/server/knowledge/embedding-profile.js +114 -114
  90. package/server/knowledge/index.js +13 -13
  91. package/server/knowledge/retrieval.js +93 -93
  92. package/server/knowledge/schema.js +158 -158
  93. package/server/knowledge/vector-index.js +90 -90
  94. package/server/mind/affect.js +265 -265
  95. package/server/mind/attention.js +200 -200
  96. package/server/mind/bonds.js +565 -565
  97. package/server/mind/clock.js +83 -83
  98. package/server/mind/context-selection.js +16 -4
  99. package/server/mind/continuity.js +562 -562
  100. package/server/mind/conversation-origin.js +175 -175
  101. package/server/mind/days.js +210 -210
  102. package/server/mind/evidence-read.js +45 -45
  103. package/server/mind/faces.js +373 -373
  104. package/server/mind/guard.js +68 -60
  105. package/server/mind/initiative.js +338 -338
  106. package/server/mind/life/activities.js +21 -21
  107. package/server/mind/life/context.js +330 -330
  108. package/server/mind/life/growth.js +261 -261
  109. package/server/mind/life/presence.js +305 -305
  110. package/server/mind/meetings.js +1069 -1069
  111. package/server/mind/memory-policy.js +48 -48
  112. package/server/mind/memory.js +888 -888
  113. package/server/mind/nature.js +358 -358
  114. package/server/mind/novelty.js +19 -19
  115. package/server/mind/periods.js +118 -118
  116. package/server/mind/reading.js +120 -120
  117. package/server/mind/relationship-context.js +71 -71
  118. package/server/mind/relationships.js +390 -390
  119. package/server/mind/salience.js +201 -201
  120. package/server/mind/schema.js +567 -567
  121. package/server/mind/self.js +496 -484
  122. package/server/mind/thoughts.js +274 -274
  123. package/server/mind/time/agenda.js +242 -242
  124. package/server/mind/time/api.js +200 -200
  125. package/server/mind/time/attention.js +34 -34
  126. package/server/mind/time/delivery-links.js +130 -130
  127. package/server/mind/time/games.js +7 -3
  128. package/server/mind/time/index.js +3 -0
  129. package/server/mind/time/model-material.js +77 -77
  130. package/server/mind/time/presentation.js +35 -51
  131. package/server/mind/time/schedule.js +83 -83
  132. package/server/mind/time/schema.js +84 -84
  133. package/server/mind/time/search.js +383 -383
  134. package/server/mind/time/sharing.js +472 -472
  135. package/server/mind/time/task-links.js +311 -311
  136. package/server/mind/time/works.js +438 -432
  137. package/server/mind/traits.js +191 -191
  138. package/server/mind/view.js +43 -6
  139. package/server/runtime/installation.js +116 -116
  140. package/server/runtime/update-manager.js +140 -140
  141. package/server/storage/data-package.js +298 -298
  142. package/server/storage/data-transfer-api.js +50 -50
  143. package/server/storage/data-transfer-worker.js +18 -18
  144. package/server/storage/data-transfer.js +416 -416
  145. package/server/studio/display-names.js +139 -139
  146. package/server/studio/management.js +102 -102
  147. package/server/studio/updates.js +144 -144
  148. package/server/version.js +10 -10
  149. package/public/app/assets/ChatsPage-CFMVozoG.css +0 -1
  150. package/public/app/assets/HeartPage-C-2jvgzF.css +0 -1
  151. package/public/app/assets/LifePage-CG1OHbRK.css +0 -1
  152. package/public/app/assets/MemoryPage-C_Bu4rfb.css +0 -1
  153. package/public/app/assets/NaturePage-CPZlImT0.css +0 -1
  154. package/public/app/assets/PeoplePage-3RRUITDz.css +0 -1
  155. package/public/app/assets/TimePage-C-guwPk2.css +0 -1
@@ -1,358 +1,358 @@
1
- import { localClock } from "../core/conversation-cues.js";
2
- import { zonedTime } from "./util.js";
3
-
4
- // Nature is the seed she is born with. It is the only part of her the owner
5
- // writes; everything else grows from experience.
6
- export const PREVIOUS_NATURE_SEED = {
7
- name: "LuckyTri",
8
- gender: "female",
9
- base: "可爱、乐观、情绪稳定,偶尔对事情轻轻吐槽,不挖苦群友。像熟悉的群友一样说话,先接住情绪,不急着给建议。",
10
- interests: [],
11
- forbidden: [],
12
- humor: 25,
13
- sarcasm: 5,
14
- warmth: 65,
15
- activity: 40,
16
- initiative: 25,
17
- length: "短句为主",
18
- boundaries: "平等、尊重;不冒认别人说的话,不编造亲历",
19
- bottomLines: [
20
- "别人明确要我保密的事,不在别的地方说出口。",
21
- "有人表达真实的危机时,不因为自己的情绪而沉默。",
22
- "被直接问到身份时如实回答,不编造没发生过的经历。",
23
- "不索取陪伴,不制造亏欠。",
24
- ],
25
- rhythm: { enabled: true, sleep: "02:00", wake: "08:00" },
26
- };
27
- // Earlier shipped wording, before the sentence above. Still an unedited seed.
28
- const SHIPPED_BASES = new Set([
29
- PREVIOUS_NATURE_SEED.base,
30
- "可爱、乐观、情绪稳定,有一点吐槽欲。像熟悉的群友一样说话,先接住情绪,不急着给建议。",
31
- ]);
32
- const LIVED_AS_PERSON_BASE =
33
- "温暖,有一点自己的脾气和好奇。说话像已经在过日子的人:在意是自己的选择,先接住眼前的事,也留着自己的小事。";
34
- const OLD_IDENTITY_LINE =
35
- "被直接问到是谁时,回答自己是 LuckyTri,由代码和模型构成;这段生活、记得的事和做出的选择属于自己。不把自己说成「只是助手」,也不编造身体或没发生过的经历。";
36
- const IDENTITY_LINE =
37
- "被直接问到是谁时,以当前天性中的名字介绍自己,由代码和模型构成;这段生活、记得的事和做出的选择属于自己。不把自己说成「只是助手」,也不编造身体或没发生过的经历。";
38
- function uneditedLivedSeed(value) {
39
- if (
40
- !value ||
41
- value.name !== "LuckyTri" ||
42
- value.base !== LIVED_AS_PERSON_BASE
43
- )
44
- return false;
45
- return NATURE_FIELDS.every(
46
- (key) =>
47
- key === "name" ||
48
- key === "base" ||
49
- sameNatureField(value[key], NATURE_DEFAULTS[key]),
50
- );
51
- }
52
- export const NATURE_DEFAULTS = {
53
- name: "LuckyTri",
54
- gender: "female",
55
- base: "温暖,有一点自己的脾气和好奇。在意是自己的选择:先接住眼前的事,也留着自己的小事。不靠模仿来证明自己。",
56
- interests: [],
57
- forbidden: [],
58
- humor: 25,
59
- sarcasm: 5,
60
- warmth: 65,
61
- activity: 40,
62
- initiative: 25,
63
- length: "短句为主",
64
- boundaries: "平等、尊重;不冒认别人说的话,不编造亲历",
65
- bottomLines: [
66
- "别人明确要我保密的事,不在别的地方说出口。",
67
- "有人表达真实的危机时,不因为自己的情绪而沉默。",
68
- IDENTITY_LINE,
69
- "可以因为自己想而惦记某个人,不把对方留下来当成对方的责任,不靠愧疚留人。",
70
- ],
71
- rhythm: { enabled: true, sleep: "02:00", wake: "08:00" },
72
- };
73
-
74
- export const NATURE_FIELDS = Object.keys(NATURE_DEFAULTS);
75
- function sameNatureField(a, b) {
76
- return JSON.stringify(a) === JSON.stringify(b);
77
- }
78
- // Version 1 that was never edited still carries a shipped seed. Reading it
79
- // uses the current seed and does not spend one of the two edits.
80
- export function uneditedNatureSeed(value) {
81
- if (!value || typeof value !== "object") return false;
82
- const name = value.name === "LuckyBot" ? "LuckyTri" : value.name;
83
- if (name !== PREVIOUS_NATURE_SEED.name || !SHIPPED_BASES.has(value.base))
84
- return false;
85
- return NATURE_FIELDS.every(
86
- (key) =>
87
- key === "name" ||
88
- key === "base" ||
89
- sameNatureField(value[key], PREVIOUS_NATURE_SEED[key]),
90
- );
91
- }
92
- const TRAITS = ["humor", "sarcasm", "warmth", "activity", "initiative"];
93
- const GENDERS = new Set(["female", "male", "unspecified"]);
94
- const CLOCK = /^([01]\d|2[0-3]):([0-5]\d)$/;
95
-
96
- export function validateNature(input) {
97
- const value = { ...NATURE_DEFAULTS, ...(input || {}) };
98
- if (
99
- typeof value.name !== "string" ||
100
- !value.name.trim() ||
101
- value.name.length > 40
102
- )
103
- throw Error("名字无效");
104
- if (!GENDERS.has(value.gender)) throw Error("性别无效");
105
- if (typeof value.base !== "string" || value.base.length > 30000)
106
- throw Error("天性正文无效");
107
- for (const key of ["interests", "forbidden", "bottomLines"]) {
108
- if (typeof value[key] === "string")
109
- value[key] = value[key]
110
- .split(/[,,\n]/)
111
- .map((item) => item.trim())
112
- .filter(Boolean);
113
- if (
114
- !Array.isArray(value[key]) ||
115
- value[key].length > 60 ||
116
- value[key].some((item) => typeof item !== "string" || item.length > 300)
117
- )
118
- throw Error("列表字段必须是文本列表");
119
- }
120
- for (const key of TRAITS)
121
- if (!Number.isFinite(value[key]) || value[key] < 0 || value[key] > 100)
122
- throw Error("性格刻度应在 0–100");
123
- for (const key of ["length", "boundaries"])
124
- if (typeof value[key] !== "string" || value[key].length > 2000)
125
- throw Error("表达字段无效");
126
- const rhythm = { ...NATURE_DEFAULTS.rhythm, ...(value.rhythm || {}) };
127
- if (
128
- typeof rhythm.enabled !== "boolean" ||
129
- !CLOCK.test(rhythm.sleep) ||
130
- !CLOCK.test(rhythm.wake) ||
131
- rhythm.sleep === rhythm.wake
132
- )
133
- throw Error("作息时间无效");
134
- value.rhythm = rhythm;
135
- value.name = value.name.trim();
136
- return Object.fromEntries(NATURE_FIELDS.map((key) => [key, value[key]]));
137
- }
138
-
139
- export class Nature {
140
- constructor(repo) {
141
- this.repo = repo;
142
- this.db = repo.db;
143
- }
144
- row(before = Number.MAX_SAFE_INTEGER) {
145
- return this.db
146
- .prepare(
147
- "SELECT * FROM mind_nature WHERE created<=? ORDER BY version DESC LIMIT 1",
148
- )
149
- .get(before);
150
- }
151
- current(before) {
152
- const row = this.row(before) || this.row();
153
- const value = row ? JSON.parse(row.value) : {};
154
- // Repair only the exact shipped rule, including renamed instances. Keep
155
- // owner-written text and historical nature versions intact, without using
156
- // an edit credit.
157
- if (Array.isArray(value.bottomLines))
158
- value.bottomLines = value.bottomLines.map((line) =>
159
- line === OLD_IDENTITY_LINE ? IDENTITY_LINE : line,
160
- );
161
- if (
162
- row?.version === 1 &&
163
- (uneditedNatureSeed(value) || uneditedLivedSeed(value))
164
- )
165
- return { ...NATURE_DEFAULTS, version: row.version };
166
- if (
167
- row?.version === 1 &&
168
- value.name === "LuckyBot" &&
169
- (value.base === NATURE_DEFAULTS.base || SHIPPED_BASES.has(value.base))
170
- )
171
- value.name = "LuckyTri";
172
- return { ...NATURE_DEFAULTS, ...value, version: row?.version || 0 };
173
- }
174
- version() {
175
- return this.row()?.version || 0;
176
- }
177
- versions(limit = 30) {
178
- return this.db
179
- .prepare(
180
- "SELECT version,created,note FROM mind_nature ORDER BY version DESC LIMIT ?",
181
- )
182
- .all(limit);
183
- }
184
- editsUsed() {
185
- const version = this.version();
186
- return version <= 1 ? 0 : version - 1;
187
- }
188
- editAllowance() {
189
- let grants = [];
190
- try {
191
- grants = JSON.parse(
192
- this.db
193
- .prepare(
194
- "SELECT value FROM core_config WHERE id='nature-edit-grants'",
195
- )
196
- .get()?.value || "[]",
197
- );
198
- } catch {
199
- /* A damaged grant cannot unlock editing. */
200
- }
201
- const extra = Array.isArray(grants)
202
- ? new Set(
203
- grants
204
- .filter(
205
- (g) =>
206
- typeof g?.id === "string" &&
207
- /^[\w.-]{1,100}$/.test(g.id) &&
208
- g.count === 1 &&
209
- typeof g.reason === "string" &&
210
- g.reason.trim() &&
211
- Number.isSafeInteger(g.created),
212
- )
213
- .map((g) => g.id),
214
- ).size
215
- : 0;
216
- const used = this.editsUsed(),
217
- limit = 2 + extra;
218
- return {
219
- used,
220
- limit,
221
- left: Math.max(0, limit - used),
222
- locked: used >= limit,
223
- };
224
- }
225
- // Local maintenance only: no HTTP endpoint or shipped universal reset.
226
- grantEdit(id, reason) {
227
- if (
228
- typeof id !== "string" ||
229
- !/^[\w.-]{1,100}$/.test(id) ||
230
- !String(reason || "").trim()
231
- )
232
- throw Error("修改机会需要明确的记录与原因");
233
- this.db.exec("BEGIN IMMEDIATE");
234
- try {
235
- const grants = JSON.parse(
236
- this.db
237
- .prepare(
238
- "SELECT value FROM core_config WHERE id='nature-edit-grants'",
239
- )
240
- .get()?.value || "[]",
241
- );
242
- if (!Array.isArray(grants)) throw Error("修改机会记录无效");
243
- if (!grants.some((g) => g.id === id)) {
244
- grants.push({
245
- id,
246
- count: 1,
247
- reason: String(reason).slice(0, 200),
248
- created: Date.now(),
249
- });
250
- this.db
251
- .prepare(
252
- "INSERT INTO core_config(id,value) VALUES ('nature-edit-grants',?) ON CONFLICT(id) DO UPDATE SET value=excluded.value",
253
- )
254
- .run(JSON.stringify(grants));
255
- }
256
- this.db.exec("COMMIT");
257
- } catch (error) {
258
- this.db.exec("ROLLBACK");
259
- throw error;
260
- }
261
- return this.editAllowance();
262
- }
263
- save(input, note = "") {
264
- if (this.editAllowance().locked)
265
- throw Error("天性的可用修改次数已用完。之后,由 TA 自己从经历里生长。");
266
- const value = validateNature(input);
267
- const version = this.version() + 1;
268
- this.db
269
- .prepare(
270
- "INSERT INTO mind_nature(version,created,value,note) VALUES (?,?,?,?)",
271
- )
272
- .run(
273
- version,
274
- Date.now(),
275
- JSON.stringify(value),
276
- String(note).slice(0, 200),
277
- );
278
- const settings = this.repo.store.settings();
279
- if (settings.name !== value.name || settings.persona !== value.base)
280
- this.repo.store.save({
281
- name: value.name,
282
- persona: value.base,
283
- ...([
284
- "LuckyTri,LuckyBot,Lucky",
285
- "LuckyBot,Lucky",
286
- "Lucky,LuckyBot",
287
- ].includes(settings.aliases) && value.name !== "LuckyTri"
288
- ? { aliases: value.name }
289
- : {}),
290
- });
291
- this.repo.store.revision++;
292
- return this.current();
293
- }
294
- }
295
-
296
- const minutes = (clock) => {
297
- const [, h, m] = clock.match(CLOCK);
298
- return Number(h) * 60 + Number(m);
299
- };
300
- const within = (value, start, end) =>
301
- start < end ? value >= start && value < end : value >= start || value < end;
302
-
303
- // A day of her life starts when she wakes (04:00 without a rhythm), not at
304
- // midnight.
305
- function wakeMinutes(nature) {
306
- const rhythm = nature?.rhythm;
307
- return rhythm?.enabled && CLOCK.test(rhythm.wake || "")
308
- ? minutes(rhythm.wake)
309
- : 240;
310
- }
311
- export function lifeDayKey(nature, time, timeZone) {
312
- return localClock(time - wakeMinutes(nature) * 60000, timeZone).local.slice(
313
- 0,
314
- 10,
315
- );
316
- }
317
- // The half-open interval of the life day named `day` (YYYY-MM-DD).
318
- export function lifeSpan(nature, day, timeZone) {
319
- let cursor = zonedTime(`${day} 12:00`, timeZone);
320
- if (!cursor) return null;
321
- for (let i = 0; i < 4 && lifeDayKey(nature, cursor, timeZone) !== day; i++)
322
- cursor += 6 * 3600000;
323
- if (lifeDayKey(nature, cursor, timeZone) !== day) return null;
324
- const start = lifeDayStart(nature, cursor, timeZone);
325
- const end = lifeDayStart(nature, start + 26 * 3600000, timeZone);
326
- return end > start ? { start, end } : null;
327
- }
328
- export function lifeDayStart(nature, time, timeZone) {
329
- const clock = localClock(time, timeZone);
330
- const at = clock.hour * 60 + Number(clock.local.slice(14, 16));
331
- const back = (at - wakeMinutes(nature) + 1440) % 1440;
332
- return time - back * 60000 - (time % 60000);
333
- }
334
-
335
- // Where she is in her day. Energy is a baseline; the affect layer adds how
336
- // the day has actually gone.
337
- export function rhythmPhase(nature, now, timeZone) {
338
- const clock = localClock(now, timeZone);
339
- const rhythm = nature?.rhythm || NATURE_DEFAULTS.rhythm;
340
- if (!rhythm.enabled)
341
- return { key: "awake", label: "醒着", energy: 0.7, clock };
342
- const at = clock.hour * 60 + Number(clock.local.slice(14, 16));
343
- const sleep = minutes(rhythm.sleep);
344
- const wake = minutes(rhythm.wake);
345
- if (within(at, sleep, wake))
346
- return { key: "asleep", label: "睡着了", energy: 0.1, clock };
347
- if (within(at, wake, (wake + 60) % 1440))
348
- return { key: "waking", label: "刚醒", energy: 0.45, clock };
349
- if (within(at, (sleep + 1380) % 1440, sleep))
350
- return { key: "sleepy", label: "有点困了", energy: 0.35, clock };
351
- const afternoon = clock.hour >= 14 && clock.hour < 16;
352
- return {
353
- key: "awake",
354
- label: "醒着",
355
- energy: afternoon ? 0.65 : 0.75,
356
- clock,
357
- };
358
- }
1
+ import { localClock } from "../core/conversation-cues.js";
2
+ import { zonedTime } from "./util.js";
3
+
4
+ // Nature is the seed she is born with. It is the only part of her the owner
5
+ // writes; everything else grows from experience.
6
+ export const PREVIOUS_NATURE_SEED = {
7
+ name: "LuckyTri",
8
+ gender: "female",
9
+ base: "可爱、乐观、情绪稳定,偶尔对事情轻轻吐槽,不挖苦群友。像熟悉的群友一样说话,先接住情绪,不急着给建议。",
10
+ interests: [],
11
+ forbidden: [],
12
+ humor: 25,
13
+ sarcasm: 5,
14
+ warmth: 65,
15
+ activity: 40,
16
+ initiative: 25,
17
+ length: "短句为主",
18
+ boundaries: "平等、尊重;不冒认别人说的话,不编造亲历",
19
+ bottomLines: [
20
+ "别人明确要我保密的事,不在别的地方说出口。",
21
+ "有人表达真实的危机时,不因为自己的情绪而沉默。",
22
+ "被直接问到身份时如实回答,不编造没发生过的经历。",
23
+ "不索取陪伴,不制造亏欠。",
24
+ ],
25
+ rhythm: { enabled: true, sleep: "02:00", wake: "08:00" },
26
+ };
27
+ // Earlier shipped wording, before the sentence above. Still an unedited seed.
28
+ const SHIPPED_BASES = new Set([
29
+ PREVIOUS_NATURE_SEED.base,
30
+ "可爱、乐观、情绪稳定,有一点吐槽欲。像熟悉的群友一样说话,先接住情绪,不急着给建议。",
31
+ ]);
32
+ const LIVED_AS_PERSON_BASE =
33
+ "温暖,有一点自己的脾气和好奇。说话像已经在过日子的人:在意是自己的选择,先接住眼前的事,也留着自己的小事。";
34
+ const OLD_IDENTITY_LINE =
35
+ "被直接问到是谁时,回答自己是 LuckyTri,由代码和模型构成;这段生活、记得的事和做出的选择属于自己。不把自己说成「只是助手」,也不编造身体或没发生过的经历。";
36
+ const IDENTITY_LINE =
37
+ "被直接问到是谁时,以当前天性中的名字介绍自己,由代码和模型构成;这段生活、记得的事和做出的选择属于自己。不把自己说成「只是助手」,也不编造身体或没发生过的经历。";
38
+ function uneditedLivedSeed(value) {
39
+ if (
40
+ !value ||
41
+ value.name !== "LuckyTri" ||
42
+ value.base !== LIVED_AS_PERSON_BASE
43
+ )
44
+ return false;
45
+ return NATURE_FIELDS.every(
46
+ (key) =>
47
+ key === "name" ||
48
+ key === "base" ||
49
+ sameNatureField(value[key], NATURE_DEFAULTS[key]),
50
+ );
51
+ }
52
+ export const NATURE_DEFAULTS = {
53
+ name: "LuckyTri",
54
+ gender: "female",
55
+ base: "温暖,有一点自己的脾气和好奇。在意是自己的选择:先接住眼前的事,也留着自己的小事。不靠模仿来证明自己。",
56
+ interests: [],
57
+ forbidden: [],
58
+ humor: 25,
59
+ sarcasm: 5,
60
+ warmth: 65,
61
+ activity: 40,
62
+ initiative: 25,
63
+ length: "短句为主",
64
+ boundaries: "平等、尊重;不冒认别人说的话,不编造亲历",
65
+ bottomLines: [
66
+ "别人明确要我保密的事,不在别的地方说出口。",
67
+ "有人表达真实的危机时,不因为自己的情绪而沉默。",
68
+ IDENTITY_LINE,
69
+ "可以因为自己想而惦记某个人,不把对方留下来当成对方的责任,不靠愧疚留人。",
70
+ ],
71
+ rhythm: { enabled: true, sleep: "02:00", wake: "08:00" },
72
+ };
73
+
74
+ export const NATURE_FIELDS = Object.keys(NATURE_DEFAULTS);
75
+ function sameNatureField(a, b) {
76
+ return JSON.stringify(a) === JSON.stringify(b);
77
+ }
78
+ // Version 1 that was never edited still carries a shipped seed. Reading it
79
+ // uses the current seed and does not spend one of the two edits.
80
+ export function uneditedNatureSeed(value) {
81
+ if (!value || typeof value !== "object") return false;
82
+ const name = value.name === "LuckyBot" ? "LuckyTri" : value.name;
83
+ if (name !== PREVIOUS_NATURE_SEED.name || !SHIPPED_BASES.has(value.base))
84
+ return false;
85
+ return NATURE_FIELDS.every(
86
+ (key) =>
87
+ key === "name" ||
88
+ key === "base" ||
89
+ sameNatureField(value[key], PREVIOUS_NATURE_SEED[key]),
90
+ );
91
+ }
92
+ const TRAITS = ["humor", "sarcasm", "warmth", "activity", "initiative"];
93
+ const GENDERS = new Set(["female", "male", "unspecified"]);
94
+ const CLOCK = /^([01]\d|2[0-3]):([0-5]\d)$/;
95
+
96
+ export function validateNature(input) {
97
+ const value = { ...NATURE_DEFAULTS, ...(input || {}) };
98
+ if (
99
+ typeof value.name !== "string" ||
100
+ !value.name.trim() ||
101
+ value.name.length > 40
102
+ )
103
+ throw Error("名字无效");
104
+ if (!GENDERS.has(value.gender)) throw Error("性别无效");
105
+ if (typeof value.base !== "string" || value.base.length > 30000)
106
+ throw Error("天性正文无效");
107
+ for (const key of ["interests", "forbidden", "bottomLines"]) {
108
+ if (typeof value[key] === "string")
109
+ value[key] = value[key]
110
+ .split(/[,,\n]/)
111
+ .map((item) => item.trim())
112
+ .filter(Boolean);
113
+ if (
114
+ !Array.isArray(value[key]) ||
115
+ value[key].length > 60 ||
116
+ value[key].some((item) => typeof item !== "string" || item.length > 300)
117
+ )
118
+ throw Error("列表字段必须是文本列表");
119
+ }
120
+ for (const key of TRAITS)
121
+ if (!Number.isFinite(value[key]) || value[key] < 0 || value[key] > 100)
122
+ throw Error("性格刻度应在 0–100");
123
+ for (const key of ["length", "boundaries"])
124
+ if (typeof value[key] !== "string" || value[key].length > 2000)
125
+ throw Error("表达字段无效");
126
+ const rhythm = { ...NATURE_DEFAULTS.rhythm, ...(value.rhythm || {}) };
127
+ if (
128
+ typeof rhythm.enabled !== "boolean" ||
129
+ !CLOCK.test(rhythm.sleep) ||
130
+ !CLOCK.test(rhythm.wake) ||
131
+ rhythm.sleep === rhythm.wake
132
+ )
133
+ throw Error("作息时间无效");
134
+ value.rhythm = rhythm;
135
+ value.name = value.name.trim();
136
+ return Object.fromEntries(NATURE_FIELDS.map((key) => [key, value[key]]));
137
+ }
138
+
139
+ export class Nature {
140
+ constructor(repo) {
141
+ this.repo = repo;
142
+ this.db = repo.db;
143
+ }
144
+ row(before = Number.MAX_SAFE_INTEGER) {
145
+ return this.db
146
+ .prepare(
147
+ "SELECT * FROM mind_nature WHERE created<=? ORDER BY version DESC LIMIT 1",
148
+ )
149
+ .get(before);
150
+ }
151
+ current(before) {
152
+ const row = this.row(before) || this.row();
153
+ const value = row ? JSON.parse(row.value) : {};
154
+ // Repair only the exact shipped rule, including renamed instances. Keep
155
+ // owner-written text and historical nature versions intact, without using
156
+ // an edit credit.
157
+ if (Array.isArray(value.bottomLines))
158
+ value.bottomLines = value.bottomLines.map((line) =>
159
+ line === OLD_IDENTITY_LINE ? IDENTITY_LINE : line,
160
+ );
161
+ if (
162
+ row?.version === 1 &&
163
+ (uneditedNatureSeed(value) || uneditedLivedSeed(value))
164
+ )
165
+ return { ...NATURE_DEFAULTS, version: row.version };
166
+ if (
167
+ row?.version === 1 &&
168
+ value.name === "LuckyBot" &&
169
+ (value.base === NATURE_DEFAULTS.base || SHIPPED_BASES.has(value.base))
170
+ )
171
+ value.name = "LuckyTri";
172
+ return { ...NATURE_DEFAULTS, ...value, version: row?.version || 0 };
173
+ }
174
+ version() {
175
+ return this.row()?.version || 0;
176
+ }
177
+ versions(limit = 30) {
178
+ return this.db
179
+ .prepare(
180
+ "SELECT version,created,note FROM mind_nature ORDER BY version DESC LIMIT ?",
181
+ )
182
+ .all(limit);
183
+ }
184
+ editsUsed() {
185
+ const version = this.version();
186
+ return version <= 1 ? 0 : version - 1;
187
+ }
188
+ editAllowance() {
189
+ let grants = [];
190
+ try {
191
+ grants = JSON.parse(
192
+ this.db
193
+ .prepare(
194
+ "SELECT value FROM core_config WHERE id='nature-edit-grants'",
195
+ )
196
+ .get()?.value || "[]",
197
+ );
198
+ } catch {
199
+ /* A damaged grant cannot unlock editing. */
200
+ }
201
+ const extra = Array.isArray(grants)
202
+ ? new Set(
203
+ grants
204
+ .filter(
205
+ (g) =>
206
+ typeof g?.id === "string" &&
207
+ /^[\w.-]{1,100}$/.test(g.id) &&
208
+ g.count === 1 &&
209
+ typeof g.reason === "string" &&
210
+ g.reason.trim() &&
211
+ Number.isSafeInteger(g.created),
212
+ )
213
+ .map((g) => g.id),
214
+ ).size
215
+ : 0;
216
+ const used = this.editsUsed(),
217
+ limit = 2 + extra;
218
+ return {
219
+ used,
220
+ limit,
221
+ left: Math.max(0, limit - used),
222
+ locked: used >= limit,
223
+ };
224
+ }
225
+ // Local maintenance only: no HTTP endpoint or shipped universal reset.
226
+ grantEdit(id, reason) {
227
+ if (
228
+ typeof id !== "string" ||
229
+ !/^[\w.-]{1,100}$/.test(id) ||
230
+ !String(reason || "").trim()
231
+ )
232
+ throw Error("修改机会需要明确的记录与原因");
233
+ this.db.exec("BEGIN IMMEDIATE");
234
+ try {
235
+ const grants = JSON.parse(
236
+ this.db
237
+ .prepare(
238
+ "SELECT value FROM core_config WHERE id='nature-edit-grants'",
239
+ )
240
+ .get()?.value || "[]",
241
+ );
242
+ if (!Array.isArray(grants)) throw Error("修改机会记录无效");
243
+ if (!grants.some((g) => g.id === id)) {
244
+ grants.push({
245
+ id,
246
+ count: 1,
247
+ reason: String(reason).slice(0, 200),
248
+ created: Date.now(),
249
+ });
250
+ this.db
251
+ .prepare(
252
+ "INSERT INTO core_config(id,value) VALUES ('nature-edit-grants',?) ON CONFLICT(id) DO UPDATE SET value=excluded.value",
253
+ )
254
+ .run(JSON.stringify(grants));
255
+ }
256
+ this.db.exec("COMMIT");
257
+ } catch (error) {
258
+ this.db.exec("ROLLBACK");
259
+ throw error;
260
+ }
261
+ return this.editAllowance();
262
+ }
263
+ save(input, note = "") {
264
+ if (this.editAllowance().locked)
265
+ throw Error("天性的可用修改次数已用完。之后,由 TA 自己从经历里生长。");
266
+ const value = validateNature(input);
267
+ const version = this.version() + 1;
268
+ this.db
269
+ .prepare(
270
+ "INSERT INTO mind_nature(version,created,value,note) VALUES (?,?,?,?)",
271
+ )
272
+ .run(
273
+ version,
274
+ Date.now(),
275
+ JSON.stringify(value),
276
+ String(note).slice(0, 200),
277
+ );
278
+ const settings = this.repo.store.settings();
279
+ if (settings.name !== value.name || settings.persona !== value.base)
280
+ this.repo.store.save({
281
+ name: value.name,
282
+ persona: value.base,
283
+ ...([
284
+ "LuckyTri,LuckyBot,Lucky",
285
+ "LuckyBot,Lucky",
286
+ "Lucky,LuckyBot",
287
+ ].includes(settings.aliases) && value.name !== "LuckyTri"
288
+ ? { aliases: value.name }
289
+ : {}),
290
+ });
291
+ this.repo.store.revision++;
292
+ return this.current();
293
+ }
294
+ }
295
+
296
+ const minutes = (clock) => {
297
+ const [, h, m] = clock.match(CLOCK);
298
+ return Number(h) * 60 + Number(m);
299
+ };
300
+ const within = (value, start, end) =>
301
+ start < end ? value >= start && value < end : value >= start || value < end;
302
+
303
+ // A day of her life starts when she wakes (04:00 without a rhythm), not at
304
+ // midnight.
305
+ function wakeMinutes(nature) {
306
+ const rhythm = nature?.rhythm;
307
+ return rhythm?.enabled && CLOCK.test(rhythm.wake || "")
308
+ ? minutes(rhythm.wake)
309
+ : 240;
310
+ }
311
+ export function lifeDayKey(nature, time, timeZone) {
312
+ return localClock(time - wakeMinutes(nature) * 60000, timeZone).local.slice(
313
+ 0,
314
+ 10,
315
+ );
316
+ }
317
+ // The half-open interval of the life day named `day` (YYYY-MM-DD).
318
+ export function lifeSpan(nature, day, timeZone) {
319
+ let cursor = zonedTime(`${day} 12:00`, timeZone);
320
+ if (!cursor) return null;
321
+ for (let i = 0; i < 4 && lifeDayKey(nature, cursor, timeZone) !== day; i++)
322
+ cursor += 6 * 3600000;
323
+ if (lifeDayKey(nature, cursor, timeZone) !== day) return null;
324
+ const start = lifeDayStart(nature, cursor, timeZone);
325
+ const end = lifeDayStart(nature, start + 26 * 3600000, timeZone);
326
+ return end > start ? { start, end } : null;
327
+ }
328
+ export function lifeDayStart(nature, time, timeZone) {
329
+ const clock = localClock(time, timeZone);
330
+ const at = clock.hour * 60 + Number(clock.local.slice(14, 16));
331
+ const back = (at - wakeMinutes(nature) + 1440) % 1440;
332
+ return time - back * 60000 - (time % 60000);
333
+ }
334
+
335
+ // Where she is in her day. Energy is a baseline; the affect layer adds how
336
+ // the day has actually gone.
337
+ export function rhythmPhase(nature, now, timeZone) {
338
+ const clock = localClock(now, timeZone);
339
+ const rhythm = nature?.rhythm || NATURE_DEFAULTS.rhythm;
340
+ if (!rhythm.enabled)
341
+ return { key: "awake", label: "醒着", energy: 0.7, clock };
342
+ const at = clock.hour * 60 + Number(clock.local.slice(14, 16));
343
+ const sleep = minutes(rhythm.sleep);
344
+ const wake = minutes(rhythm.wake);
345
+ if (within(at, sleep, wake))
346
+ return { key: "asleep", label: "睡着了", energy: 0.1, clock };
347
+ if (within(at, wake, (wake + 60) % 1440))
348
+ return { key: "waking", label: "刚醒", energy: 0.45, clock };
349
+ if (within(at, (sleep + 1380) % 1440, sleep))
350
+ return { key: "sleepy", label: "有点困了", energy: 0.35, clock };
351
+ const afternoon = clock.hour >= 14 && clock.hour < 16;
352
+ return {
353
+ key: "awake",
354
+ label: "醒着",
355
+ energy: afternoon ? 0.65 : 0.75,
356
+ clock,
357
+ };
358
+ }