luckytri 1.0.3 → 1.0.5

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 (71) hide show
  1. package/bin/luckytri.js +5 -1
  2. package/docs/en/CHANGELOG.md +14 -0
  3. package/docs/en/guide.md +5 -1
  4. package/docs/en/webui.md +3 -1
  5. package/docs/zh/CHANGELOG.md +14 -0
  6. package/docs/zh/guide.md +5 -1
  7. package/docs/zh/webui.md +3 -1
  8. package/package.json +4 -3
  9. package/public/app/assets/{ChatsPage-B83Evc79.js → ChatsPage-CshdtTzV.js} +1 -1
  10. package/public/app/assets/{Empty-ddEfu0w8.js → Empty-C7yzOBzy.js} +1 -1
  11. package/public/app/assets/{Field-mIhV5YBA.js → Field-BjFv4Rfn.js} +1 -1
  12. package/public/app/assets/{HeartPage-BGEn24rc.js → HeartPage-eqo6ru16.js} +1 -1
  13. package/public/app/assets/{LifePage-BleqPj4O.js → LifePage-CN8GE5NK.js} +1 -1
  14. package/public/app/assets/{MemoryPage-BM3-0_lF.js → MemoryPage-CCpMN4Wg.js} +1 -1
  15. package/public/app/assets/{NaturePage-CxxMnQIc.js → NaturePage-C0JqdBTR.js} +2 -2
  16. package/public/app/assets/{PeoplePage-DDDHESQp.css → PeoplePage-BTU0H1pM.css} +1 -1
  17. package/public/app/assets/PeoplePage-BXGBbO4u.js +1 -0
  18. package/public/app/assets/{PluginsPage-DE_-Q3ra.js → PluginsPage-Dksp5AV5.js} +1 -1
  19. package/public/app/assets/{SystemPage-Be1a6UDP.css → SystemPage-BenIyTvk.css} +1 -1
  20. package/public/app/assets/{SystemPage-BWQKEQRU.js → SystemPage-Cd7aa8eE.js} +1 -1
  21. package/public/app/assets/{TimePage-Dzt8t8kL.js → TimePage-Bs_zImb4.js} +1 -1
  22. package/public/app/assets/{index-DY4e15Kh.css → index-BHZcHUMh.css} +1 -1
  23. package/public/app/assets/{index-oLIXWxsn.js → index-CyRtYxP3.js} +3 -3
  24. package/public/app/assets/{knowledge-Dvtmpd45.js → knowledge-BJOIyuJo.js} +1 -1
  25. package/public/app/assets/{media-keE52cIX.js → media-Bwtx8blz.js} +1 -1
  26. package/public/app/assets/mind-DtTVrW6Z.js +1 -0
  27. package/public/app/index.html +4 -4
  28. package/public/guide.en.html +31 -8
  29. package/public/guide.html +14 -5
  30. package/public/tutorial.en.md +5 -1
  31. package/public/tutorial.md +5 -1
  32. package/scripts/launch.js +11 -4
  33. package/scripts/service.js +57 -7
  34. package/scripts/start-server.js +4 -1
  35. package/scripts/update-instance.js +201 -0
  36. package/server/app.js +1 -1
  37. package/server/core/context-builder.js +2 -2
  38. package/server/core/context-compactor.js +2 -2
  39. package/server/core/orchestrator.js +1 -0
  40. package/server/core/persona-manager.js +11 -0
  41. package/server/core/reply-delivery.js +11 -0
  42. package/server/core/response-validator.js +33 -1
  43. package/server/i18n/decisions.js +4 -1
  44. package/server/i18n/errors.js +29 -0
  45. package/server/index.js +45 -2
  46. package/server/mind/api.js +25 -0
  47. package/server/mind/bonds.js +9 -2
  48. package/server/mind/index.js +2 -0
  49. package/server/mind/initiative.js +6 -1
  50. package/server/mind/life/context.js +22 -4
  51. package/server/mind/life/diary.js +20 -0
  52. package/server/mind/life/growth.js +13 -5
  53. package/server/mind/life/index.js +10 -2
  54. package/server/mind/nature.js +23 -2
  55. package/server/mind/relationship-context.js +71 -0
  56. package/server/mind/relationships.js +390 -0
  57. package/server/mind/schema.js +2 -0
  58. package/server/mind/time/tasks.js +2 -1
  59. package/server/mind/view.js +11 -1
  60. package/server/runtime/installation.js +116 -0
  61. package/server/runtime/update-execution.js +127 -0
  62. package/server/runtime/update-manager.js +140 -0
  63. package/server/startup-lock.js +12 -12
  64. package/server/storage/archive.js +10 -2
  65. package/server/storage/backup-lock.js +23 -5
  66. package/server/storage/data-transfer.js +2 -1
  67. package/server/studio/display-names.js +1 -0
  68. package/server/studio/settings.js +1 -0
  69. package/server/studio/updates.js +14 -3
  70. package/public/app/assets/PeoplePage-D12cvuy7.js +0 -1
  71. package/public/app/assets/mind-DYRfFMwh.js +0 -1
@@ -0,0 +1,390 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { parseSessionKey } from "../channels/session-key.js";
3
+ import { hasCredential, text } from "./util.js";
4
+ import { normalized } from "./util.js";
5
+ import { leaks } from "./guard.js";
6
+ import { mentionsRelationship } from "./relationship-context.js";
7
+
8
+ export const RELATIONSHIP_ROLES = [
9
+ "妹妹",
10
+ "姐姐",
11
+ "弟弟",
12
+ "哥哥",
13
+ "朋友",
14
+ "伙伴",
15
+ ];
16
+ const FAMILY = new Set(RELATIONSHIP_ROLES.slice(0, 4));
17
+ const scope = (room) => {
18
+ try {
19
+ return room ? parseSessionKey(room) : null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ // Account identity and an explicit arrangement, never invented encounters or
26
+ // earned trust. Changes append history; nickname equality does not bind peers.
27
+ export class Relationships {
28
+ constructor(mind) {
29
+ this.mind = mind;
30
+ this.db = mind.db;
31
+ }
32
+ version() {
33
+ return this.db
34
+ .prepare("SELECT COALESCE(MAX(rowid),0) n FROM mind_relationships")
35
+ .get().n;
36
+ }
37
+ current(subject, { now = Date.now(), room = "" } = {}) {
38
+ const place = scope(room);
39
+ const row = this.db
40
+ .prepare(
41
+ "SELECT * FROM mind_relationships WHERE subject_id=? AND created<=? ORDER BY created DESC,rowid DESC",
42
+ )
43
+ .all(String(subject), now)
44
+ .find(
45
+ (r) =>
46
+ !place ||
47
+ (r.channel === place.channel &&
48
+ (r.account_id === "_" || r.account_id === place.accountId)),
49
+ );
50
+ return row?.status === "active" ? row : null;
51
+ }
52
+ visible(row, room) {
53
+ if (!row || !room || row.discretion === "open") return !!row;
54
+ const place = scope(room);
55
+ return (
56
+ place?.kind === "private" &&
57
+ place.nativeId === row.subject_id &&
58
+ place.channel === row.channel &&
59
+ (row.account_id === "_" || place.accountId === row.account_id)
60
+ );
61
+ }
62
+ discloses(bubbles, room, now = Date.now()) {
63
+ const hidden = this.list(now).filter((row) => !this.visible(row, room));
64
+ if (
65
+ leaks(
66
+ bubbles,
67
+ hidden.filter((r) => r.note).map((r) => ({ content: r.note })),
68
+ ).length
69
+ )
70
+ return true;
71
+ const said = normalized(bubbles.join(" "));
72
+ const english = {
73
+ 妹妹: "youngersister",
74
+ 姐姐: "oldersister",
75
+ 弟弟: "youngerbrother",
76
+ 哥哥: "olderbrother",
77
+ 朋友: "friend",
78
+ 伙伴: "companion",
79
+ };
80
+ return hidden.some((row) => {
81
+ const names = [
82
+ row.name,
83
+ this.mind.bonds.name(row.subject_id),
84
+ row.subject_id,
85
+ ]
86
+ .filter(Boolean)
87
+ .map(normalized);
88
+ const role = normalized(row.peer_role),
89
+ en = english[row.peer_role] || role;
90
+ return names.some((name) =>
91
+ [
92
+ "我的" + role + name,
93
+ "我" + role + name,
94
+ name + "是我的" + role,
95
+ name + "是我" + role,
96
+ name + role,
97
+ "my" + en + name,
98
+ name + "ismy" + en,
99
+ ].some((line) => said.includes(line)),
100
+ );
101
+ });
102
+ }
103
+ weight(row) {
104
+ return row ? (FAMILY.has(row.peer_role) ? 0.15 : 0.05) : 0;
105
+ }
106
+ view(row, room = "") {
107
+ if (!this.visible(row, room)) return null;
108
+ return {
109
+ id: row.id,
110
+ subjectId: row.subject_id,
111
+ channel: row.channel,
112
+ accountId: row.account_id,
113
+ kind: row.kind,
114
+ name: row.name,
115
+ peerRole: row.peer_role,
116
+ selfRole: row.self_role,
117
+ discretion: row.discretion,
118
+ note: row.note,
119
+ created: row.created,
120
+ origin: "owner",
121
+ };
122
+ }
123
+ list(now = Date.now()) {
124
+ const seen = new Set();
125
+ return this.db
126
+ .prepare(
127
+ "SELECT * FROM mind_relationships WHERE created<=? ORDER BY created DESC,rowid DESC",
128
+ )
129
+ .all(now)
130
+ .filter((r) => {
131
+ const key = JSON.stringify([r.subject_id, r.channel, r.account_id]);
132
+ if (seen.has(key)) return false;
133
+ seen.add(key);
134
+ return r.status === "active";
135
+ });
136
+ }
137
+ // Knowing someone does not require them to be speaking in this room.
138
+ // Select from the same current ledger used by the management UI, while
139
+ // keeping account identity and disclosure boundaries at the read boundary.
140
+ context({
141
+ room = "",
142
+ people = [],
143
+ cue = [],
144
+ now = Date.now(),
145
+ open = false,
146
+ limit = 6,
147
+ } = {}) {
148
+ const place = scope(room);
149
+ const present = new Set(people.map(String));
150
+ const words = (cue || [])
151
+ .filter((c) => typeof c !== "object" || c?.role !== "assistant")
152
+ .map((c) => (typeof c === "object" ? c?.text || "" : String(c)));
153
+ const rows = this.list(now)
154
+ .filter(
155
+ (r) => this.visible(r, room) && (!open || r.discretion === "open"),
156
+ )
157
+ .filter(
158
+ (r) =>
159
+ !place ||
160
+ (r.channel === place.channel &&
161
+ (r.account_id === "_" || r.account_id === place.accountId)),
162
+ )
163
+ .map((r) => {
164
+ const view = this.view(r, room);
165
+ const accountName = this.mind.bonds.name(r.subject_id);
166
+ view.name ||= accountName;
167
+ if (
168
+ accountName &&
169
+ accountName !== view.name &&
170
+ accountName !== r.subject_id
171
+ )
172
+ view.knownAs = [accountName];
173
+ const mentioned = words.some((word) =>
174
+ mentionsRelationship(word, view),
175
+ );
176
+ const speaker = present.has(r.subject_id);
177
+ return {
178
+ view,
179
+ mentioned,
180
+ score: Number(mentioned) * 2 + Number(speaker),
181
+ };
182
+ })
183
+ .sort((a, b) => b.score - a.score || b.view.created - a.view.created);
184
+ const selected = rows.slice(0, limit);
185
+ // Bond changes are intentionally capped; that counter is not the number
186
+ // of conversations. Count actual addressed replies without scanning the
187
+ // much larger model traces or assuming adjacent messages were exchanges.
188
+ const replies = selected.length
189
+ ? this.db
190
+ .prepare(
191
+ `
192
+ SELECT json_extract(t.payload,'$.userId') subject,
193
+ COALESCE(json_extract(t.payload,'$.channel'),'onebot') channel,
194
+ t.account_id account,COUNT(DISTINCT a.seq) replies,MAX(a.time) last
195
+ FROM core_events a JOIN json_each(a.payload,'$.replyTargetIds') refs
196
+ JOIN core_events t ON t.seq=refs.value
197
+ WHERE a.role='assistant' AND t.role='user' AND a.time<=? AND t.time<=?
198
+ AND COALESCE(json_extract(a.payload,'$.simulated'),0)=0
199
+ AND COALESCE(json_extract(t.payload,'$.simulated'),0)=0
200
+ AND t.seq NOT IN (SELECT seq FROM mind_unlived)
201
+ AND json_extract(t.payload,'$.userId') IN (${selected.map(() => "?").join(",")})
202
+ GROUP BY subject,channel,account
203
+ `,
204
+ )
205
+ .all(now, now, ...selected.map(({ view }) => view.subjectId))
206
+ : [];
207
+ return {
208
+ known: selected.map(({ view }) => {
209
+ const person = this.mind.bonds.person(view.subjectId, now, {
210
+ room: open ? "group:__open__" : room,
211
+ });
212
+ const history = replies.filter(
213
+ (r) =>
214
+ String(r.subject) === view.subjectId &&
215
+ r.channel === view.channel &&
216
+ (view.accountId === "_" || r.account === view.accountId),
217
+ );
218
+ return {
219
+ ...view,
220
+ ...(person
221
+ ? {
222
+ withMe: {
223
+ feel: person.feel,
224
+ spokenReplies: history.reduce((n, r) => n + r.replies, 0),
225
+ firstMetAt: person.firstMetAt,
226
+ lastTalkedAt:
227
+ Math.max(
228
+ person.lastTalkedAt || 0,
229
+ ...history.map((r) => r.last),
230
+ ) || null,
231
+ ...(person.impression
232
+ ? { impression: text(person.impression, 120) }
233
+ : {}),
234
+ },
235
+ }
236
+ : {}),
237
+ };
238
+ }),
239
+ requested:
240
+ rows.some((r) => r.mentioned) ||
241
+ (rows.some((r) => r.score) &&
242
+ words.some((w) =>
243
+ /(?:我|你).*(?:是谁|谁|关系|认识|认得)|who|relationship/i.test(w),
244
+ )),
245
+ };
246
+ }
247
+ history(subject, limit = 30) {
248
+ return this.db
249
+ .prepare(
250
+ "SELECT * FROM mind_relationships WHERE subject_id=? ORDER BY created DESC,rowid DESC LIMIT ?",
251
+ )
252
+ .all(String(subject), limit);
253
+ }
254
+ save(input, now = Date.now()) {
255
+ const subject = text(input?.subjectId, 128),
256
+ channel = String(input?.channel || "onebot"),
257
+ account = text(input?.accountId || "_", 128);
258
+ if (
259
+ String(input?.subjectId || "").length > 128 ||
260
+ String(input?.accountId || "").length > 128 ||
261
+ !/^[\w.-]{1,128}$/.test(subject) ||
262
+ ["bot", "self", "_"].includes(subject) ||
263
+ !["onebot", "qqbot"].includes(channel) ||
264
+ !/^[\w.-]{1,128}$/.test(account) ||
265
+ (channel === "onebot" && !/^\d+$/.test(subject))
266
+ )
267
+ throw Error("关系对象账号无效");
268
+ if (channel === "qqbot" && account === "_")
269
+ throw Error("官方机器人关系需要指定应用账号");
270
+ if (
271
+ this.db
272
+ .prepare("SELECT 1 FROM core_events WHERE account_id=? LIMIT 1")
273
+ .get(subject)
274
+ )
275
+ throw Error("不能把当前实例绑定为自己的关系对象");
276
+ const kind = String(input.kind || "human"),
277
+ role = text(input.peerRole, 24),
278
+ name = text(input.name, 40),
279
+ note = text(input.note, 300),
280
+ discretion = input.discretion || "open";
281
+ if (
282
+ !["human", "bot"].includes(kind) ||
283
+ !role ||
284
+ String(input.peerRole).length > 24 ||
285
+ String(input.name || "").length > 40 ||
286
+ String(input.note || "").length > 300 ||
287
+ !["open", "private", "secret"].includes(discretion)
288
+ )
289
+ throw Error("关系设置无效");
290
+ const gender = this.mind.nature.current(now).gender;
291
+ const older =
292
+ gender === "female" ? "姐姐" : gender === "male" ? "哥哥" : "年长手足";
293
+ const younger =
294
+ gender === "female" ? "妹妹" : gender === "male" ? "弟弟" : "年幼手足";
295
+ const opposite = {
296
+ 妹妹: older,
297
+ 弟弟: older,
298
+ 姐姐: younger,
299
+ 哥哥: younger,
300
+ 朋友: "朋友",
301
+ 伙伴: "伙伴",
302
+ };
303
+ const selfRole = text(input.selfRole || opposite[role] || "", 24);
304
+ if (
305
+ !selfRole ||
306
+ String(input.selfRole || "").length > 24 ||
307
+ hasCredential([role, selfRole, name, note].join(" "))
308
+ )
309
+ throw Error("关系称呼或备注无效");
310
+ const room = `${channel}:${account}:private:${subject}`,
311
+ previous = this.current(subject, { now, room });
312
+ if (
313
+ input.expectedId !== undefined &&
314
+ input.expectedId !== (previous?.id || null)
315
+ )
316
+ throw Error("关系已经变化,请刷新后再修改");
317
+ if (
318
+ previous &&
319
+ previous.kind === kind &&
320
+ previous.peer_role === role &&
321
+ previous.self_role === selfRole &&
322
+ previous.name === name &&
323
+ previous.note === note &&
324
+ previous.discretion === discretion
325
+ )
326
+ return this.view(previous);
327
+ this.db.exec("BEGIN IMMEDIATE");
328
+ try {
329
+ this.db
330
+ .prepare(
331
+ "INSERT INTO mind_relationships(id,created,subject_id,channel,account_id,kind,name,peer_role,self_role,discretion,note,status) VALUES (?,?,?,?,?,?,?,?,?,?,?,'active')",
332
+ )
333
+ .run(
334
+ randomUUID(),
335
+ now,
336
+ subject,
337
+ channel,
338
+ account,
339
+ kind,
340
+ name,
341
+ role,
342
+ selfRole,
343
+ discretion,
344
+ note,
345
+ );
346
+ this.db
347
+ .prepare(
348
+ "INSERT INTO mind_people(user_id,name,first_seen,last_seen,sessions) VALUES (?,NULL,NULL,NULL,'[]') ON CONFLICT(user_id) DO NOTHING",
349
+ )
350
+ .run(subject);
351
+ this.db.exec("COMMIT");
352
+ this.mind.repo.store.revision++;
353
+ return this.view(this.current(subject, { now, room }));
354
+ } catch (error) {
355
+ this.db.exec("ROLLBACK");
356
+ throw error;
357
+ }
358
+ }
359
+ end(subject, input = {}, now = Date.now()) {
360
+ if (
361
+ String(input.note || "").length > 300 ||
362
+ hasCredential(String(input.note || ""))
363
+ )
364
+ throw Error("关系称呼或备注无效");
365
+ const room = `${input.channel || "onebot"}:${input.accountId || "_"}:private:${subject}`,
366
+ row = this.current(subject, { now, room });
367
+ if (!row) throw Error("关系不存在或已解除");
368
+ if (input.expectedId !== undefined && input.expectedId !== row.id)
369
+ throw Error("关系已经变化,请刷新后再修改");
370
+ this.db
371
+ .prepare(
372
+ "INSERT INTO mind_relationships(id,created,subject_id,channel,account_id,kind,name,peer_role,self_role,discretion,note,status) VALUES (?,?,?,?,?,?,?,?,?,?,?,'ended')",
373
+ )
374
+ .run(
375
+ randomUUID(),
376
+ now,
377
+ row.subject_id,
378
+ row.channel,
379
+ row.account_id,
380
+ row.kind,
381
+ row.name,
382
+ row.peer_role,
383
+ row.self_role,
384
+ row.discretion,
385
+ text(input.note || "在管理台解除关系", 300),
386
+ );
387
+ this.mind.repo.store.revision++;
388
+ return { ended: true };
389
+ }
390
+ }
@@ -26,6 +26,8 @@ export function migrateMind(db, store) {
26
26
  CREATE INDEX IF NOT EXISTS mind_self_thread ON mind_self(thread,created);
27
27
  CREATE TABLE IF NOT EXISTS mind_faces (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, created INTEGER NOT NULL, role TEXT NOT NULL DEFAULT '', tone TEXT NOT NULL DEFAULT '', aspiration TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', sources TEXT NOT NULL DEFAULT '[]', origin TEXT NOT NULL);
28
28
  CREATE INDEX IF NOT EXISTS mind_faces_session ON mind_faces(session_id,created);
29
+ CREATE TABLE IF NOT EXISTS mind_relationships(id TEXT PRIMARY KEY,created INTEGER NOT NULL,subject_id TEXT NOT NULL,channel TEXT NOT NULL,account_id TEXT NOT NULL,kind TEXT NOT NULL,name TEXT NOT NULL DEFAULT '',peer_role TEXT NOT NULL,self_role TEXT NOT NULL,discretion TEXT NOT NULL DEFAULT 'open',note TEXT NOT NULL DEFAULT '',status TEXT NOT NULL);
30
+ CREATE INDEX IF NOT EXISTS mind_relationships_subject ON mind_relationships(subject_id,channel,account_id,created);
29
31
  CREATE TABLE IF NOT EXISTS mind_thoughts (id TEXT PRIMARY KEY, created INTEGER NOT NULL, kind TEXT NOT NULL, content TEXT NOT NULL, sessions TEXT NOT NULL DEFAULT '[]', sources TEXT NOT NULL DEFAULT '[]', parent_id TEXT, importance REAL NOT NULL DEFAULT 0.5, revisit_at INTEGER, status TEXT NOT NULL DEFAULT 'open', hidden INTEGER NOT NULL DEFAULT 0, outreach TEXT NOT NULL DEFAULT '', outreach_session TEXT, outreach_status TEXT NOT NULL DEFAULT 'none', run_id TEXT);
30
32
  CREATE INDEX IF NOT EXISTS mind_thoughts_time ON mind_thoughts(created);
31
33
  CREATE TABLE IF NOT EXISTS mind_diary (id TEXT PRIMARY KEY, day TEXT NOT NULL, created INTEGER NOT NULL, content TEXT NOT NULL, mood TEXT, compare TEXT, sources TEXT NOT NULL DEFAULT '[]', run_id TEXT);
@@ -367,7 +367,8 @@ export class Tasks {
367
367
  ? 2
368
368
  : task.priority) *
369
369
  100 +
370
- Math.min(8, Number(person?.closeness || 0) * 8)
370
+ Math.min(8, Number(person?.closeness || 0) * 8) +
371
+ Math.min(2, Number(person?.relationalWeight || 0) * 8)
371
372
  );
372
373
  }
373
374
  arrange(
@@ -5,6 +5,7 @@ import { agoLabel, elapsedLabel } from "./clock.js";
5
5
  import { describeFace } from "./faces.js";
6
6
  import { SELF_KINDS } from "./self.js";
7
7
  import { DAY, text } from "./util.js";
8
+ import { relationshipHistory } from "./relationship-context.js";
8
9
 
9
10
  const FORGOTTEN = "(很久没想起了)";
10
11
 
@@ -89,6 +90,12 @@ export function innerView(
89
90
  const face = mind.faces.current(session, now);
90
91
  const living = mind.self.living({ before: now, now, room: session });
91
92
  const cues = speakerCues(cue);
93
+ const relationships = mind.relationships.context({
94
+ room: session,
95
+ people,
96
+ cue,
97
+ now,
98
+ });
92
99
  const carries = (thread) => mind.meetings.stays(thread, session, now);
93
100
  const visibleThreads = relevantContext(
94
101
  mind.self
@@ -150,7 +157,7 @@ export function innerView(
150
157
  ? {
151
158
  threads: visibleThreads.map(
152
159
  (t) =>
153
- `${SELF_KINDS[t.kind]}:${text(t.content, 80)}${t.status === "emerging" ? "(刚开始有这种感觉)" : ""}`,
160
+ `${SELF_KINDS[t.kind]}:${relationshipHistory(text(t.content, 80), t.created, relationships)}${t.status === "emerging" ? "(刚开始有这种感觉)" : ""}`,
154
161
  ),
155
162
  }
156
163
  : {}),
@@ -179,6 +186,7 @@ export function innerView(
179
186
  (!bond.interactions &&
180
187
  !bond.impression &&
181
188
  !bond.lastChange &&
189
+ !bond.relationship &&
182
190
  bond.tension < 0.15)
183
191
  )
184
192
  continue;
@@ -186,6 +194,7 @@ export function innerView(
186
194
  id,
187
195
  name: bond.name,
188
196
  feel: bond.feel,
197
+ ...(bond.relationship ? { relationship: bond.relationship } : {}),
189
198
  ...(bond.impression && mind.meetings.sayable(bond.impression, session)
190
199
  ? { impression: bond.impression }
191
200
  : {}),
@@ -252,6 +261,7 @@ export function innerView(
252
261
  currentLife: mind.time.view({ session, now, cue }),
253
262
  state: `${affect.phaseLabel},精力${affect.energyLabel},心情${affect.mood}${cause ? `(${cause})` : ""}${affect.lately ? `,${affect.lately}` : ""}`,
254
263
  ...(persons.length ? { people: persons } : {}),
264
+ ...(relationships.known.length ? { relationships } : {}),
255
265
  ...(continuity ? { continuity } : {}),
256
266
  ...(group ? { thisGroup: group.feel } : {}),
257
267
  ...(thoughts.length
@@ -0,0 +1,116 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ readdirSync,
8
+ renameSync,
9
+ rmdirSync,
10
+ writeFileSync,
11
+ unlinkSync,
12
+ } from "node:fs";
13
+ import { join, relative, isAbsolute } from "node:path";
14
+ import { randomUUID } from "node:crypto";
15
+ import { compareVersions } from "../studio/updates.js";
16
+ export const UPDATE_ID =
17
+ /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
18
+ export const updateRoot = (home) => join(home, "runtime");
19
+ export function updatePath(home, id) {
20
+ if (!UPDATE_ID.test(String(id))) throw Error("更新任务无效");
21
+ const root = updateRoot(home),
22
+ path = join(root, "releases", id);
23
+ // Managed paths cannot be symlinked into an unrelated installation.
24
+ for (const p of [root, join(root, "releases"), path])
25
+ if (existsSync(p) && lstatSync(p).isSymbolicLink())
26
+ throw Error("更新目录无效");
27
+ return path;
28
+ }
29
+ export function readActive(home) {
30
+ try {
31
+ return JSON.parse(
32
+ readFileSync(join(updateRoot(home), "active.json"), "utf8"),
33
+ );
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+ export function atomicJson(file, value) {
39
+ const tmp = `${file}.${randomUUID()}.partial`;
40
+ try {
41
+ writeFileSync(tmp, JSON.stringify(value), {
42
+ mode: 0o600,
43
+ flag: "wx",
44
+ flush: true,
45
+ });
46
+ renameSync(tmp, file);
47
+ } finally {
48
+ if (existsSync(tmp)) unlinkSync(tmp);
49
+ }
50
+ }
51
+ export function writeActive(home, value) {
52
+ const root = updateRoot(home);
53
+ if (value) updatePath(home, value.id);
54
+ mkdirSync(root, { recursive: true, mode: 0o700 });
55
+ if (value) atomicJson(join(root, "active.json"), value);
56
+ else if (existsSync(join(root, "active.json")))
57
+ unlinkSync(join(root, "active.json"));
58
+ }
59
+ export function installedRelease(home, id, version) {
60
+ const slot = updatePath(home, id),
61
+ root = join(slot, "node_modules", "luckytri");
62
+ const part = relative(realpathSync(slot), realpathSync(root));
63
+ if (part.startsWith("..") || isAbsolute(part)) throw Error("更新目录无效");
64
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
65
+ if (
66
+ pkg.name !== "luckytri" ||
67
+ pkg.version !== version ||
68
+ !existsSync(join(root, "server", "index.js")) ||
69
+ !existsSync(join(root, "public", "app", "index.html"))
70
+ )
71
+ throw Error("更新程序校验失败");
72
+ return root;
73
+ }
74
+ export function resolveInstallation(home, basePackage, baseVersion) {
75
+ const active = readActive(home);
76
+ if (active) {
77
+ try {
78
+ // An explicit newer/equal global npm or source install takes precedence.
79
+ if (compareVersions(active.version, baseVersion) > 0)
80
+ return {
81
+ package: installedRelease(home, active.id, active.version),
82
+ version: active.version,
83
+ };
84
+ } catch {
85
+ /* Invalid receipts never route the launcher to arbitrary code. */
86
+ }
87
+ }
88
+ return { package: basePackage, version: baseVersion };
89
+ }
90
+ export function pruneInstallations(home, keep) {
91
+ const root = join(updateRoot(home), "releases");
92
+ if (!existsSync(root)) return;
93
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
94
+ if (
95
+ !entry.isDirectory() ||
96
+ !UPDATE_ID.test(entry.name) ||
97
+ keep.includes(entry.name)
98
+ )
99
+ continue;
100
+ const path = updatePath(home, entry.name),
101
+ part = relative(realpathSync(root), realpathSync(path));
102
+ if (!part || part.startsWith("..") || isAbsolute(part))
103
+ throw Error("更新目录无效");
104
+ // File removal uses unlink, including Unicode/short Windows paths where
105
+ // recursive rm can return without actually removing a lock or directory.
106
+ const remove = (directory) => {
107
+ for (const child of readdirSync(directory, { withFileTypes: true })) {
108
+ const target = join(directory, child.name);
109
+ if (child.isDirectory() && !child.isSymbolicLink()) remove(target);
110
+ else unlinkSync(target);
111
+ }
112
+ rmdirSync(directory);
113
+ };
114
+ remove(path);
115
+ }
116
+ }