luckytri 1.0.6 → 1.0.8

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 (168) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +7 -3
  3. package/README.md +7 -3
  4. package/docs/en/CHANGELOG.md +35 -16
  5. package/docs/en/chat-style.md +28 -9
  6. package/docs/en/guide.md +5 -5
  7. package/docs/en/her-life.md +2 -2
  8. package/docs/en/webui.md +1 -1
  9. package/docs/zh/CHANGELOG.md +35 -16
  10. package/docs/zh/chat-style.md +30 -9
  11. package/docs/zh/guide.md +5 -5
  12. package/docs/zh/her-life.md +2 -2
  13. package/docs/zh/webui.md +1 -1
  14. package/package.json +1 -1
  15. package/public/app/assets/{ChatsPage-DCYMkJ91.js → ChatsPage-CshdtTzV.js} +2 -2
  16. package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
  17. package/public/app/assets/{Empty-CLyZkxR5.js → Empty-C7yzOBzy.js} +1 -1
  18. package/public/app/assets/{Empty-DIobYG3x.css → Empty-DmUB2-Bk.css} +1 -1
  19. package/public/app/assets/{Field-BpaRwLI_.js → Field-BjFv4Rfn.js} +1 -1
  20. package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
  21. package/public/app/assets/{HeartPage-BIOMTiwr.js → HeartPage-eqo6ru16.js} +1 -1
  22. package/public/app/assets/LifePage-BUi5euBF.css +1 -0
  23. package/public/app/assets/{LifePage-BCgM4JkR.js → LifePage-CN8GE5NK.js} +1 -1
  24. package/public/app/assets/{MemoryPage-vShvRqMt.js → MemoryPage-CCpMN4Wg.js} +1 -1
  25. package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
  26. package/public/app/assets/{NaturePage-Cy9ptr4G.js → NaturePage-C0JqdBTR.js} +2 -2
  27. package/public/app/assets/NaturePage-CHkpb5NK.css +1 -0
  28. package/public/app/assets/PeoplePage-BTU0H1pM.css +1 -0
  29. package/public/app/assets/{PeoplePage-CUZekB2E.js → PeoplePage-BXGBbO4u.js} +1 -1
  30. package/public/app/assets/{PluginsPage-1RM4rmJP.js → PluginsPage-Dksp5AV5.js} +1 -1
  31. package/public/app/assets/{SystemPage-BI2CB5Ig.css → SystemPage-BenIyTvk.css} +1 -1
  32. package/public/app/assets/{SystemPage-BeT5TmIV.js → SystemPage-Cd7aa8eE.js} +1 -1
  33. package/public/app/assets/TimePage-BOms_n4I.css +1 -0
  34. package/public/app/assets/{TimePage-taTmPMM8.js → TimePage-Bs_zImb4.js} +2 -2
  35. package/public/app/assets/{index-DY5AM0Hz.css → index-BHZcHUMh.css} +1 -1
  36. package/public/app/assets/{index-DdYwC5ZU.js → index-CyRtYxP3.js} +3 -3
  37. package/public/app/assets/{knowledge-DJmfu2KN.js → knowledge-BJOIyuJo.js} +1 -1
  38. package/public/app/assets/{media-BEtnE0Gv.js → media-Bwtx8blz.js} +1 -1
  39. package/public/app/assets/{mind-Dtiw0-QK.js → mind-DtTVrW6Z.js} +1 -1
  40. package/public/app/index.html +5 -6
  41. package/public/guide.en.html +3 -3
  42. package/public/guide.html +3 -3
  43. package/scripts/import-data.js +128 -128
  44. package/scripts/proxy-config.js +167 -167
  45. package/scripts/update-instance.js +201 -201
  46. package/server/channels/adapters.js +27 -27
  47. package/server/channels/onebot/adapter.js +288 -288
  48. package/server/channels/qqbot/adapter.js +253 -253
  49. package/server/channels/qqbot/api.js +199 -199
  50. package/server/channels/qqbot/credentials.js +11 -11
  51. package/server/channels/qqbot/gateway.js +274 -274
  52. package/server/channels/qqbot/index.js +389 -389
  53. package/server/channels/segments.js +40 -40
  54. package/server/channels/session-key.js +153 -153
  55. package/server/channels/state.js +42 -42
  56. package/server/core/api.js +564 -564
  57. package/server/core/context-builder.js +288 -288
  58. package/server/core/context-compactor.js +454 -454
  59. package/server/core/conversation-cues.js +385 -325
  60. package/server/core/conversation-grounding.js +304 -0
  61. package/server/core/conversation-manager.js +87 -87
  62. package/server/core/dialogue-context.js +244 -0
  63. package/server/core/dialogue-contract.js +35 -0
  64. package/server/core/feedback.js +8 -8
  65. package/server/core/group-style.js +72 -72
  66. package/server/core/initiative-context.js +68 -66
  67. package/server/core/input-budget.js +32 -32
  68. package/server/core/local-demo.js +98 -98
  69. package/server/core/message-manager.js +118 -118
  70. package/server/core/message-scheduler.js +78 -78
  71. package/server/core/model-json.js +190 -190
  72. package/server/core/model-manager.js +5 -8
  73. package/server/core/model-presets.js +900 -900
  74. package/server/core/name-call.js +29 -29
  75. package/server/core/network.js +157 -157
  76. package/server/core/person-name.js +22 -22
  77. package/server/core/persona-manager.js +359 -269
  78. package/server/core/reader-check.js +62 -0
  79. package/server/core/reply-delivery.js +537 -404
  80. package/server/core/reply-target-resolver.js +50 -50
  81. package/server/core/response-generator.js +92 -50
  82. package/server/core/response-validator.js +53 -32
  83. package/server/core/retired-prompts.js +51 -51
  84. package/server/core/sessions.js +109 -109
  85. package/server/core/speaker-names.js +92 -92
  86. package/server/core/topic-tracker.js +65 -65
  87. package/server/core/turn.js +82 -15
  88. package/server/http.js +18 -18
  89. package/server/i18n/decisions.js +209 -209
  90. package/server/i18n/errors.js +2 -0
  91. package/server/i18n/index.js +144 -144
  92. package/server/i18n/labels.js +6 -10
  93. package/server/i18n/models.js +111 -111
  94. package/server/knowledge/api.js +342 -342
  95. package/server/knowledge/embedding-profile.js +114 -114
  96. package/server/knowledge/index.js +13 -13
  97. package/server/knowledge/retrieval.js +93 -93
  98. package/server/knowledge/schema.js +158 -158
  99. package/server/knowledge/vector-index.js +90 -90
  100. package/server/mind/affect.js +265 -265
  101. package/server/mind/attention.js +200 -200
  102. package/server/mind/bonds.js +565 -565
  103. package/server/mind/clock.js +83 -83
  104. package/server/mind/context-selection.js +16 -4
  105. package/server/mind/continuity.js +562 -562
  106. package/server/mind/conversation-origin.js +175 -175
  107. package/server/mind/days.js +210 -210
  108. package/server/mind/evidence-read.js +45 -45
  109. package/server/mind/expression-grounding.js +21 -0
  110. package/server/mind/faces.js +373 -373
  111. package/server/mind/guard.js +68 -60
  112. package/server/mind/initiative.js +338 -338
  113. package/server/mind/life/activities.js +21 -21
  114. package/server/mind/life/context.js +330 -330
  115. package/server/mind/life/growth.js +261 -261
  116. package/server/mind/life/presence.js +305 -305
  117. package/server/mind/meetings.js +1069 -1069
  118. package/server/mind/memory-policy.js +48 -48
  119. package/server/mind/memory.js +888 -888
  120. package/server/mind/nature.js +358 -358
  121. package/server/mind/novelty.js +19 -19
  122. package/server/mind/own-voice.js +35 -0
  123. package/server/mind/periods.js +118 -118
  124. package/server/mind/reading.js +132 -120
  125. package/server/mind/relationship-context.js +71 -71
  126. package/server/mind/relationships.js +390 -390
  127. package/server/mind/salience.js +201 -201
  128. package/server/mind/schema.js +567 -567
  129. package/server/mind/self.js +496 -484
  130. package/server/mind/thoughts.js +274 -274
  131. package/server/mind/time/agenda.js +242 -242
  132. package/server/mind/time/api.js +200 -200
  133. package/server/mind/time/attention.js +34 -34
  134. package/server/mind/time/availability.js +23 -1
  135. package/server/mind/time/executor.js +4 -2
  136. package/server/mind/time/games.js +20 -11
  137. package/server/mind/time/index.js +9 -2
  138. package/server/mind/time/kinds.js +29 -2
  139. package/server/mind/time/material-relevance.js +19 -0
  140. package/server/mind/time/model-material.js +77 -77
  141. package/server/mind/time/presentation.js +35 -51
  142. package/server/mind/time/reading-input.js +31 -0
  143. package/server/mind/time/schedule.js +83 -83
  144. package/server/mind/time/schema.js +84 -84
  145. package/server/mind/time/search.js +383 -383
  146. package/server/mind/time/sharing.js +482 -472
  147. package/server/mind/time/task-links.js +311 -311
  148. package/server/mind/time/tasks.js +41 -3
  149. package/server/mind/time/works.js +527 -432
  150. package/server/mind/traits.js +191 -191
  151. package/server/mind/view.js +62 -12
  152. package/server/runtime/installation.js +116 -116
  153. package/server/runtime/update-manager.js +140 -140
  154. package/server/storage/data-package.js +298 -298
  155. package/server/storage/data-transfer-api.js +50 -50
  156. package/server/storage/data-transfer-worker.js +18 -18
  157. package/server/storage/data-transfer.js +416 -416
  158. package/server/studio/display-names.js +139 -139
  159. package/server/studio/management.js +102 -102
  160. package/server/studio/updates.js +144 -144
  161. package/server/version.js +10 -10
  162. package/public/app/assets/ChatsPage-CFMVozoG.css +0 -1
  163. package/public/app/assets/HeartPage-C-2jvgzF.css +0 -1
  164. package/public/app/assets/LifePage-CG1OHbRK.css +0 -1
  165. package/public/app/assets/MemoryPage-C_Bu4rfb.css +0 -1
  166. package/public/app/assets/NaturePage-CPZlImT0.css +0 -1
  167. package/public/app/assets/PeoplePage-3RRUITDz.css +0 -1
  168. package/public/app/assets/TimePage-C-guwPk2.css +0 -1
@@ -1,565 +1,565 @@
1
- import { randomUUID } from "node:crypto";
2
- import { evidenceRoots } from "./evidence.js";
3
- import { isPrivateSession } from "./memory.js";
4
- import {
5
- DAY,
6
- HOUR,
7
- clamp,
8
- evidence,
9
- messageSeqs,
10
- parse,
11
- relax,
12
- text,
13
- zonedTime,
14
- } from "./util.js";
15
-
16
- const TENSION_HALF_LIFE = 12 * HOUR;
17
- // After two quiet weeks closeness drifts down to half of its best, and
18
- // familiarity more slowly to most of it; the first contact after a long
19
- // absence brings back half of what was lost.
20
- const ABSENCE_GRACE = 14 * DAY;
21
- // A feeling written days after the moment is a note, not a new meeting.
22
- // Three days still covers a delayed evening of solitude or the next diary.
23
- const FEELING_WINDOW = 3 * DAY;
24
- const CLOSENESS_HALF_LIFE = 60 * DAY;
25
- const FAMILIARITY_HALF_LIFE = 120 * DAY;
26
- const REWARM = 0.5;
27
- export const LONG_ABSENCE_DAYS = 14;
28
- // Every change she can feel toward someone, with its bounded size.
29
- export const BOND_CHANGES = {
30
- interaction: { familiarity: 0.03 },
31
- warmer: { closeness: 0.03 },
32
- closer: { closeness: 0.05, familiarity: 0.02 },
33
- trust_up: { trust: 0.04 },
34
- trust_down: { trust: -0.06 },
35
- friction: { tension: 0.3 },
36
- repair: { tension: -0.4, closeness: 0.01 },
37
- distance: { closeness: -0.04 },
38
- impression: {},
39
- };
40
- const START = { familiarity: 0, closeness: 0.15, trust: 0.5, tension: 0 };
41
-
42
- // Tension eases with the clock. Closeness and familiarity thin only after
43
- // weeks without a real conversation. A note in between does not restart that.
44
- function easeTension(state, from, to) {
45
- if (!(to > from)) return;
46
- state.tension = relax(state.tension, 0, to - from, TENSION_HALF_LIFE);
47
- }
48
- function easeCloseness(state, contactAt, from, to) {
49
- if (contactAt == null || !(to > from)) return;
50
- const start = Math.max(from, contactAt + ABSENCE_GRACE);
51
- if (to <= start) return;
52
- const elapsed = to - start;
53
- state.closeness = relax(
54
- state.closeness,
55
- Math.max(START.closeness, state.peakCloseness * 0.5),
56
- elapsed,
57
- CLOSENESS_HALF_LIFE,
58
- );
59
- state.familiarity = relax(
60
- state.familiarity,
61
- state.peakFamiliarity * 0.6,
62
- elapsed,
63
- FAMILIARITY_HALF_LIFE,
64
- );
65
- }
66
-
67
- // Everything that happened with someone, up to their last event.
68
- function foldEvents(rows) {
69
- const state = {
70
- ...START,
71
- interactions: 0,
72
- impression: "",
73
- why: "",
74
- peakCloseness: START.closeness,
75
- peakFamiliarity: 0,
76
- firstMetAt: rows[0]?.created ?? null,
77
- lastTalkedAt: null,
78
- lastContactAt: null,
79
- lastEventAt: null,
80
- notes: [],
81
- };
82
- let at = null;
83
- let contactAt = null;
84
- for (const row of rows) {
85
- if (at !== null) {
86
- easeTension(state, at, row.created);
87
- easeCloseness(state, contactAt, at, row.created);
88
- }
89
- at = row.created;
90
- state.lastEventAt = row.created;
91
- if (row.change === "interaction") {
92
- // Being noticed is not a conversation. Only speaking with them ends
93
- // the time since they last talked, and only that can warm a long gap.
94
- const talked = row.origin !== "noticed";
95
- if (
96
- talked &&
97
- state.lastTalkedAt !== null &&
98
- row.created - state.lastTalkedAt > ABSENCE_GRACE
99
- ) {
100
- state.closeness += (state.peakCloseness - state.closeness) * REWARM;
101
- state.familiarity +=
102
- (state.peakFamiliarity - state.familiarity) * REWARM;
103
- }
104
- if (talked) state.lastTalkedAt = row.created;
105
- state.interactions++;
106
- state.familiarity = clamp(
107
- state.familiarity + row.familiarity * (1 - state.familiarity),
108
- );
109
- state.closeness = clamp(state.closeness + row.closeness);
110
- } else {
111
- state.familiarity = clamp(state.familiarity + row.familiarity);
112
- state.closeness = clamp(state.closeness + row.closeness);
113
- state.trust = clamp(state.trust + row.trust);
114
- state.tension = clamp(state.tension + row.tension);
115
- if (row.change === "impression" && row.note) {
116
- state.impression = row.note;
117
- state.notes.push({
118
- kind: "impression",
119
- note: row.note,
120
- session: row.session_id || "",
121
- sources: row.sources || "[]",
122
- });
123
- } else if (row.note) {
124
- state.why = row.note;
125
- state.notes.push({
126
- kind: "why",
127
- note: row.note,
128
- session: row.session_id || "",
129
- sources: row.sources || "[]",
130
- });
131
- }
132
- if (state.notes.length > 24)
133
- state.notes.splice(0, state.notes.length - 24);
134
- state.lastChange = row.change;
135
- state.lastChangeAt = row.created;
136
- }
137
- state.peakCloseness = Math.max(state.peakCloseness, state.closeness);
138
- state.peakFamiliarity = Math.max(state.peakFamiliarity, state.familiarity);
139
- // A note does not count as contact. Being called and staying quiet does not
140
- // either. Other changes still mark the time closeness is measured from.
141
- if (row.change !== "impression" && row.origin !== "noticed") {
142
- contactAt = row.created;
143
- state.lastContactAt = row.created;
144
- }
145
- }
146
- return state;
147
- }
148
-
149
- // How it feels at `now`: the folded history, carried forward to this moment.
150
- function settle(folded, now) {
151
- const state = { ...folded };
152
- if (state.lastEventAt !== null) easeTension(state, state.lastEventAt, now);
153
- if (state.lastContactAt !== null)
154
- easeCloseness(
155
- state,
156
- state.lastContactAt,
157
- state.lastEventAt ?? state.lastContactAt,
158
- now,
159
- );
160
- delete state.lastContactAt;
161
- delete state.lastEventAt;
162
- state.absentDays =
163
- state.lastTalkedAt === null
164
- ? null
165
- : Math.max(0, Math.floor((now - state.lastTalkedAt) / DAY));
166
- for (const key of ["familiarity", "closeness", "trust", "tension"])
167
- state[key] = Math.round(state[key] * 100) / 100;
168
- delete state.peakCloseness;
169
- delete state.peakFamiliarity;
170
- return state;
171
- }
172
-
173
- function fold(rows, now) {
174
- return settle(foldEvents(rows), now);
175
- }
176
-
177
- // How it feels to be in a place, rather than with one person.
178
- export function describeGroup(state) {
179
- if (!state) return "";
180
- const parts = [
181
- state.familiarity >= 0.6
182
- ? "已经很熟悉这里"
183
- : state.familiarity >= 0.3
184
- ? "在这里待了一阵"
185
- : "刚来这里不久",
186
- ];
187
- if (state.closeness >= 0.45) parts.push("有归属感");
188
- if (state.tension >= 0.15) parts.push("最近气氛有点紧");
189
- if (state.absentDays >= LONG_ABSENCE_DAYS) parts.push("好久没在这里说话了");
190
- return parts.join(",");
191
- }
192
-
193
- export function describeBond(state) {
194
- if (!state) return "";
195
- const parts = [
196
- state.familiarity >= 0.6
197
- ? "很熟"
198
- : state.familiarity >= 0.3
199
- ? "认识一段时间了"
200
- : state.familiarity > 0.05
201
- ? "不太熟"
202
- : "几乎不认识",
203
- ];
204
- if (state.closeness >= 0.6) parts.push("很亲近");
205
- else if (state.closeness >= 0.35) parts.push("挺亲近");
206
- if (state.trust >= 0.7) parts.push("信任");
207
- else if (state.trust <= 0.3) parts.push("有点防备");
208
- if (state.tension >= 0.15)
209
- parts.push(`现在有点别扭${state.why ? `(${state.why})` : ""}`);
210
- // Not seen at all is different from around but not talking with her.
211
- // Never having talked is not the same as a gap after real conversations.
212
- if (state.awayDays >= LONG_ABSENCE_DAYS) parts.push("好久不见了");
213
- else if (state.absentDays >= 2 * LONG_ABSENCE_DAYS)
214
- parts.push("最近没怎么说上话");
215
- else if (
216
- state.lastTalkedAt == null &&
217
- state.closeness >= 0.35 &&
218
- (state.unspokenDays ?? 0) >= 7 &&
219
- (state.awayDays ?? 0) < 3
220
- )
221
- parts.push("还没怎么说过话");
222
- return parts.join(",");
223
- }
224
-
225
- export class Bonds {
226
- constructor(mind) {
227
- this.mind = mind;
228
- this.db = mind.db;
229
- this.folded = new Map();
230
- }
231
- // Names and where she has met each person; a person is the same QQ number
232
- // in every group and private chat.
233
- meet(people, session, time = Date.now()) {
234
- const read = this.db.prepare(
235
- "SELECT sessions FROM mind_people WHERE user_id=?",
236
- );
237
- const write = this.db.prepare(
238
- "INSERT INTO mind_people(user_id,name,first_seen,last_seen,sessions) VALUES (?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET name=COALESCE(excluded.name,name),first_seen=COALESCE(first_seen,excluded.first_seen),last_seen=MAX(COALESCE(last_seen,excluded.last_seen),excluded.last_seen),sessions=excluded.sessions",
239
- );
240
- for (const { userId, name } of people) {
241
- if (!userId || userId === "bot") continue;
242
- const sessions = new Set(parse(read.get(userId)?.sessions, []));
243
- sessions.add(session);
244
- write.run(
245
- String(userId),
246
- name || null,
247
- time,
248
- time,
249
- JSON.stringify([...sessions].slice(-40)),
250
- );
251
- }
252
- }
253
- // This change was already taken from these same sources.
254
- #repeatedFeeling(kind, id, change, cited) {
255
- if (!cited.length) return false;
256
- const rows = this.db
257
- .prepare(
258
- `SELECT sources FROM mind_bond_events
259
- WHERE subject_kind=? AND subject_id=? AND change=?
260
- AND id NOT IN (
261
- SELECT target_id FROM mind_revocations WHERE target_kind='bond'
262
- )`,
263
- )
264
- .all(kind, String(id), change);
265
- const seen = new Set();
266
- for (const row of rows)
267
- for (const source of evidenceRoots(this.db, parse(row.sources, [])))
268
- seen.add(source);
269
- return evidenceRoots(this.db, cited).every((source) => seen.has(source));
270
- }
271
- // When the cited moment actually happened. Null when nothing can be dated,
272
- // so a feeling without a readable source is not treated as late.
273
- #evidenceAt(cited) {
274
- let newest = null;
275
- const take = (time) => {
276
- if (Number.isFinite(time) && (newest === null || time > newest))
277
- newest = time;
278
- };
279
- const seqs = messageSeqs(cited);
280
- if (seqs.length) {
281
- const rows = this.db
282
- .prepare(
283
- `SELECT time FROM core_events WHERE seq IN (${seqs.map(() => "?").join(",")})`,
284
- )
285
- .all(...seqs);
286
- for (const row of rows) take(row.time);
287
- }
288
- const meetings = cited
289
- .filter((source) => source.startsWith("g:"))
290
- .map((source) => source.slice(2));
291
- if (meetings.length) {
292
- const rows = this.db
293
- .prepare(
294
- `SELECT created FROM mind_meetings WHERE id IN (${meetings.map(() => "?").join(",")})`,
295
- )
296
- .all(...meetings);
297
- for (const row of rows) take(row.created);
298
- }
299
- const thoughts = cited
300
- .filter((source) => source.startsWith("t:"))
301
- .map((source) => source.slice(2));
302
- if (thoughts.length) {
303
- const rows = this.db
304
- .prepare(
305
- `SELECT created FROM mind_thoughts WHERE id IN (${thoughts.map(() => "?").join(",")})`,
306
- )
307
- .all(...thoughts);
308
- for (const row of rows) take(row.created);
309
- }
310
- for (const source of cited) {
311
- const day = /^d:(\d{4}-\d{2}-\d{2})$/.exec(source);
312
- if (day) take(zonedTime(`${day[1]} 23:59`, this.mind.timeZone()));
313
- }
314
- return newest;
315
- }
316
- #staleFeeling(cited, time) {
317
- const when = this.#evidenceAt(cited);
318
- return when !== null && time - when > FEELING_WINDOW;
319
- }
320
- record({
321
- kind = "person",
322
- id,
323
- change,
324
- note = "",
325
- sources = [],
326
- session = null,
327
- origin = "turn",
328
- time = Date.now(),
329
- }) {
330
- const delta = BOND_CHANGES[change];
331
- if (!delta || !id || id === "bot") return null;
332
- const cited = evidence(sources);
333
- // The same moment cannot be spent again to grow closer, and a feeling
334
- // filled in long afterward does not become a meeting that just happened.
335
- // An impression can still be written; it does not move the numbers.
336
- if (
337
- change !== "interaction" &&
338
- change !== "impression" &&
339
- (this.#repeatedFeeling(kind, id, change, cited) ||
340
- this.#staleFeeling(cited, time))
341
- )
342
- return null;
343
- if (change === "interaction") {
344
- // One felt "we talked" per person, place and hour keeps the ledger small.
345
- const recent = this.db
346
- .prepare(
347
- "SELECT 1 FROM mind_bond_events WHERE subject_kind=? AND subject_id=? AND change='interaction' AND session_id IS ? AND created>?",
348
- )
349
- .get(kind, String(id), session, time - HOUR);
350
- if (recent) return null;
351
- }
352
- const row = randomUUID();
353
- this.db
354
- .prepare(
355
- "INSERT INTO mind_bond_events(id,created,subject_kind,subject_id,change,familiarity,closeness,trust,tension,note,sources,session_id,origin) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
356
- )
357
- .run(
358
- row,
359
- time,
360
- kind,
361
- String(id),
362
- change,
363
- delta.familiarity || 0,
364
- change === "interaction" && (origin === "direct" || kind === "group")
365
- ? 0.005
366
- : delta.closeness || 0,
367
- delta.trust || 0,
368
- delta.tension || 0,
369
- text(note, change === "impression" ? 80 : 60),
370
- JSON.stringify(cited),
371
- session,
372
- origin,
373
- );
374
- return row;
375
- }
376
- // The whole history is folded once and kept until something new happens
377
- // with this person; looking back at an earlier moment always refolds.
378
- state(kind, id, now = Date.now()) {
379
- const subject = String(id);
380
- const meta = this.db
381
- .prepare(
382
- "SELECT COUNT(*) n, MAX(rowid) r, MAX(created) c, (SELECT COUNT(*) FROM mind_revocations WHERE target_kind='bond') revoked FROM mind_bond_events WHERE subject_kind=? AND subject_id=?",
383
- )
384
- .get(kind, subject);
385
- if (!meta.n) return null;
386
- const whole = meta.c <= now;
387
- const key = `${kind}:${subject}`;
388
- const signature = `${meta.n}:${meta.r}:${meta.revoked}`;
389
- const cached = whole ? this.folded.get(key) : null;
390
- if (cached?.signature === signature) return settle(cached.state, now);
391
- const rows = this.db
392
- .prepare(
393
- "SELECT * FROM mind_bond_events WHERE subject_kind=? AND subject_id=? AND created<=? AND id NOT IN (SELECT target_id FROM mind_revocations WHERE target_kind='bond') ORDER BY created",
394
- )
395
- .all(kind, subject, now);
396
- if (!rows.length) return null;
397
- const state = foldEvents(rows);
398
- if (whole) this.folded.set(key, { signature, state });
399
- return settle(state, now);
400
- }
401
- // A private reason stays in that conversation. Another room can still
402
- // feel the closeness or the tension, and can still see an older note
403
- // that was formed in the open.
404
- #noteFits(note, room) {
405
- const hidden = new Set();
406
- if (note.session && isPrivateSession(note.session))
407
- hidden.add(note.session);
408
- const sources = parse(note.sources, []);
409
- for (const row of this.mind.meetings.places(sources))
410
- if (row.discretion === "private" || row.discretion === "secret")
411
- hidden.add(row.session_id);
412
- for (const id of this.mind.meetings.privateRoots(sources)) hidden.add(id);
413
- const seqs = messageSeqs(sources);
414
- if (seqs.length) {
415
- const found = this.db
416
- .prepare(
417
- `SELECT DISTINCT session_id FROM core_events WHERE seq IN (${seqs.map(() => "?").join(",")})`,
418
- )
419
- .all(...seqs);
420
- for (const row of found)
421
- if (isPrivateSession(row.session_id)) hidden.add(row.session_id);
422
- }
423
- return !hidden.size || hidden.has(room);
424
- }
425
- #shownNotes(state, room) {
426
- const pick = (kind, current) => {
427
- const rows = (state.notes || []).filter((note) => note.kind === kind);
428
- if (!room) return rows.at(-1)?.note || current || "";
429
- for (let i = rows.length - 1; i >= 0; i--)
430
- if (this.#noteFits(rows[i], room)) return rows[i].note;
431
- return "";
432
- };
433
- return {
434
- impression: pick("impression", state.impression),
435
- why: pick("why", state.why),
436
- };
437
- }
438
- person(userId, now = Date.now(), { room = "" } = {}) {
439
- const state = this.state("person", userId, now);
440
- const known = this.db
441
- .prepare("SELECT * FROM mind_people WHERE user_id=?")
442
- .get(String(userId));
443
- if (!state && !known) return null;
444
- // When she last saw them at all: in a conversation she read or glanced
445
- // at, or talking with her. A later sighting is unknowable when looking
446
- // back, so it is ignored.
447
- const seen = Math.max(
448
- known?.last_seen && known.last_seen <= now ? known.last_seen : 0,
449
- state?.lastTalkedAt || 0,
450
- );
451
- const merged = {
452
- ...(state || { ...START, interactions: 0 }),
453
- firstMetAt:
454
- state?.firstMetAt ??
455
- (known?.first_seen <= now ? known.first_seen : null),
456
- seenAt: seen || null,
457
- awayDays: seen ? Math.max(0, Math.floor((now - seen) / DAY)) : null,
458
- };
459
- const notes = this.#shownNotes(merged, room);
460
- const described = { ...merged, ...notes };
461
- delete described.notes;
462
- if (described.lastTalkedAt == null && described.firstMetAt != null)
463
- described.unspokenDays = Math.max(
464
- 0,
465
- Math.floor((now - described.firstMetAt) / DAY),
466
- );
467
- const feel = describeBond(described);
468
- const binding = this.mind.relationships.current(userId, { now, room });
469
- const relationship = this.mind.relationships.view(binding, room);
470
- delete described.unspokenDays;
471
- return {
472
- userId: String(userId),
473
- name: relationship?.name || known?.name || String(userId),
474
- relationship,
475
- relationalWeight: this.mind.relationships.weight(binding),
476
- sessions: parse(known?.sessions, []),
477
- lastSeen: known?.last_seen || null,
478
- ...described,
479
- feel,
480
- };
481
- }
482
- group(session, now = Date.now()) {
483
- const state = this.state("group", session, now);
484
- if (!state) return null;
485
- const { notes: _notes, ...rest } = state;
486
- return { session, ...rest, feel: describeGroup(rest) };
487
- }
488
- people({ now = Date.now(), limit = 200 } = {}) {
489
- return this.db
490
- .prepare(
491
- "SELECT user_id FROM mind_people ORDER BY last_seen DESC LIMIT ?",
492
- )
493
- .all(limit)
494
- .map((row) => this.person(row.user_id, now))
495
- .filter(Boolean);
496
- }
497
- // People she has grown close to and not heard from in a while.
498
- missing({ now = Date.now(), limit = 3, days = 7, closeness = 0.35 } = {}) {
499
- return this.db
500
- .prepare(
501
- "SELECT user_id FROM mind_people WHERE first_seen<=? ORDER BY last_seen DESC LIMIT 300",
502
- )
503
- .all(now)
504
- .map((row) => this.person(row.user_id, now))
505
- .filter((p) => p && p.closeness >= closeness && (p.awayDays ?? 0) >= days)
506
- .sort((a, b) => b.closeness - a.closeness)
507
- .slice(0, limit);
508
- }
509
- // Close people she still sees, and has not talked with for a while.
510
- // Being around is not the same as having talked. Never having talked
511
- // still counts: the time starts from when she first met them.
512
- quiet({ now = Date.now(), limit = 3, days = 7, closeness = 0.35 } = {}) {
513
- return this.db
514
- .prepare(
515
- "SELECT user_id FROM mind_people WHERE first_seen<=? ORDER BY last_seen DESC LIMIT 300",
516
- )
517
- .all(now)
518
- .map((row) => this.person(row.user_id, now))
519
- .filter((p) => {
520
- if (!p || p.closeness < closeness || (p.awayDays ?? 0) >= 3)
521
- return false;
522
- const silentFor =
523
- p.absentDays ??
524
- (p.firstMetAt != null
525
- ? Math.max(0, Math.floor((now - p.firstMetAt) / DAY))
526
- : 0);
527
- return silentFor >= days;
528
- })
529
- .sort((a, b) => {
530
- const span = (p) =>
531
- p.absentDays ??
532
- (p.firstMetAt != null ? Math.floor((now - p.firstMetAt) / DAY) : 0);
533
- return span(b) - span(a);
534
- })
535
- .slice(0, limit);
536
- }
537
- groups(now = Date.now()) {
538
- return this.db
539
- .prepare(
540
- "SELECT DISTINCT subject_id FROM mind_bond_events WHERE subject_kind='group'",
541
- )
542
- .all()
543
- .map((row) => this.group(row.subject_id, now))
544
- .filter(Boolean);
545
- }
546
- changes(kind, id, limit = 40) {
547
- return this.db
548
- .prepare(
549
- "SELECT e.*, EXISTS(SELECT 1 FROM mind_revocations r WHERE r.target_kind='bond' AND r.target_id=e.id) revoked FROM mind_bond_events e WHERE e.subject_kind=? AND e.subject_id=? AND e.change!='interaction' ORDER BY e.created DESC LIMIT ?",
550
- )
551
- .all(kind, String(id), limit)
552
- .map((row) => ({
553
- ...row,
554
- revoked: !!row.revoked,
555
- sources: JSON.parse(row.sources),
556
- }));
557
- }
558
- name(userId) {
559
- return (
560
- this.db
561
- .prepare("SELECT name FROM mind_people WHERE user_id=?")
562
- .get(String(userId))?.name || ""
563
- );
564
- }
565
- }
1
+ import { randomUUID } from "node:crypto";
2
+ import { evidenceRoots } from "./evidence.js";
3
+ import { isPrivateSession } from "./memory.js";
4
+ import {
5
+ DAY,
6
+ HOUR,
7
+ clamp,
8
+ evidence,
9
+ messageSeqs,
10
+ parse,
11
+ relax,
12
+ text,
13
+ zonedTime,
14
+ } from "./util.js";
15
+
16
+ const TENSION_HALF_LIFE = 12 * HOUR;
17
+ // After two quiet weeks closeness drifts down to half of its best, and
18
+ // familiarity more slowly to most of it; the first contact after a long
19
+ // absence brings back half of what was lost.
20
+ const ABSENCE_GRACE = 14 * DAY;
21
+ // A feeling written days after the moment is a note, not a new meeting.
22
+ // Three days still covers a delayed evening of solitude or the next diary.
23
+ const FEELING_WINDOW = 3 * DAY;
24
+ const CLOSENESS_HALF_LIFE = 60 * DAY;
25
+ const FAMILIARITY_HALF_LIFE = 120 * DAY;
26
+ const REWARM = 0.5;
27
+ export const LONG_ABSENCE_DAYS = 14;
28
+ // Every change she can feel toward someone, with its bounded size.
29
+ export const BOND_CHANGES = {
30
+ interaction: { familiarity: 0.03 },
31
+ warmer: { closeness: 0.03 },
32
+ closer: { closeness: 0.05, familiarity: 0.02 },
33
+ trust_up: { trust: 0.04 },
34
+ trust_down: { trust: -0.06 },
35
+ friction: { tension: 0.3 },
36
+ repair: { tension: -0.4, closeness: 0.01 },
37
+ distance: { closeness: -0.04 },
38
+ impression: {},
39
+ };
40
+ const START = { familiarity: 0, closeness: 0.15, trust: 0.5, tension: 0 };
41
+
42
+ // Tension eases with the clock. Closeness and familiarity thin only after
43
+ // weeks without a real conversation. A note in between does not restart that.
44
+ function easeTension(state, from, to) {
45
+ if (!(to > from)) return;
46
+ state.tension = relax(state.tension, 0, to - from, TENSION_HALF_LIFE);
47
+ }
48
+ function easeCloseness(state, contactAt, from, to) {
49
+ if (contactAt == null || !(to > from)) return;
50
+ const start = Math.max(from, contactAt + ABSENCE_GRACE);
51
+ if (to <= start) return;
52
+ const elapsed = to - start;
53
+ state.closeness = relax(
54
+ state.closeness,
55
+ Math.max(START.closeness, state.peakCloseness * 0.5),
56
+ elapsed,
57
+ CLOSENESS_HALF_LIFE,
58
+ );
59
+ state.familiarity = relax(
60
+ state.familiarity,
61
+ state.peakFamiliarity * 0.6,
62
+ elapsed,
63
+ FAMILIARITY_HALF_LIFE,
64
+ );
65
+ }
66
+
67
+ // Everything that happened with someone, up to their last event.
68
+ function foldEvents(rows) {
69
+ const state = {
70
+ ...START,
71
+ interactions: 0,
72
+ impression: "",
73
+ why: "",
74
+ peakCloseness: START.closeness,
75
+ peakFamiliarity: 0,
76
+ firstMetAt: rows[0]?.created ?? null,
77
+ lastTalkedAt: null,
78
+ lastContactAt: null,
79
+ lastEventAt: null,
80
+ notes: [],
81
+ };
82
+ let at = null;
83
+ let contactAt = null;
84
+ for (const row of rows) {
85
+ if (at !== null) {
86
+ easeTension(state, at, row.created);
87
+ easeCloseness(state, contactAt, at, row.created);
88
+ }
89
+ at = row.created;
90
+ state.lastEventAt = row.created;
91
+ if (row.change === "interaction") {
92
+ // Being noticed is not a conversation. Only speaking with them ends
93
+ // the time since they last talked, and only that can warm a long gap.
94
+ const talked = row.origin !== "noticed";
95
+ if (
96
+ talked &&
97
+ state.lastTalkedAt !== null &&
98
+ row.created - state.lastTalkedAt > ABSENCE_GRACE
99
+ ) {
100
+ state.closeness += (state.peakCloseness - state.closeness) * REWARM;
101
+ state.familiarity +=
102
+ (state.peakFamiliarity - state.familiarity) * REWARM;
103
+ }
104
+ if (talked) state.lastTalkedAt = row.created;
105
+ state.interactions++;
106
+ state.familiarity = clamp(
107
+ state.familiarity + row.familiarity * (1 - state.familiarity),
108
+ );
109
+ state.closeness = clamp(state.closeness + row.closeness);
110
+ } else {
111
+ state.familiarity = clamp(state.familiarity + row.familiarity);
112
+ state.closeness = clamp(state.closeness + row.closeness);
113
+ state.trust = clamp(state.trust + row.trust);
114
+ state.tension = clamp(state.tension + row.tension);
115
+ if (row.change === "impression" && row.note) {
116
+ state.impression = row.note;
117
+ state.notes.push({
118
+ kind: "impression",
119
+ note: row.note,
120
+ session: row.session_id || "",
121
+ sources: row.sources || "[]",
122
+ });
123
+ } else if (row.note) {
124
+ state.why = row.note;
125
+ state.notes.push({
126
+ kind: "why",
127
+ note: row.note,
128
+ session: row.session_id || "",
129
+ sources: row.sources || "[]",
130
+ });
131
+ }
132
+ if (state.notes.length > 24)
133
+ state.notes.splice(0, state.notes.length - 24);
134
+ state.lastChange = row.change;
135
+ state.lastChangeAt = row.created;
136
+ }
137
+ state.peakCloseness = Math.max(state.peakCloseness, state.closeness);
138
+ state.peakFamiliarity = Math.max(state.peakFamiliarity, state.familiarity);
139
+ // A note does not count as contact. Being called and staying quiet does not
140
+ // either. Other changes still mark the time closeness is measured from.
141
+ if (row.change !== "impression" && row.origin !== "noticed") {
142
+ contactAt = row.created;
143
+ state.lastContactAt = row.created;
144
+ }
145
+ }
146
+ return state;
147
+ }
148
+
149
+ // How it feels at `now`: the folded history, carried forward to this moment.
150
+ function settle(folded, now) {
151
+ const state = { ...folded };
152
+ if (state.lastEventAt !== null) easeTension(state, state.lastEventAt, now);
153
+ if (state.lastContactAt !== null)
154
+ easeCloseness(
155
+ state,
156
+ state.lastContactAt,
157
+ state.lastEventAt ?? state.lastContactAt,
158
+ now,
159
+ );
160
+ delete state.lastContactAt;
161
+ delete state.lastEventAt;
162
+ state.absentDays =
163
+ state.lastTalkedAt === null
164
+ ? null
165
+ : Math.max(0, Math.floor((now - state.lastTalkedAt) / DAY));
166
+ for (const key of ["familiarity", "closeness", "trust", "tension"])
167
+ state[key] = Math.round(state[key] * 100) / 100;
168
+ delete state.peakCloseness;
169
+ delete state.peakFamiliarity;
170
+ return state;
171
+ }
172
+
173
+ function fold(rows, now) {
174
+ return settle(foldEvents(rows), now);
175
+ }
176
+
177
+ // How it feels to be in a place, rather than with one person.
178
+ export function describeGroup(state) {
179
+ if (!state) return "";
180
+ const parts = [
181
+ state.familiarity >= 0.6
182
+ ? "已经很熟悉这里"
183
+ : state.familiarity >= 0.3
184
+ ? "在这里待了一阵"
185
+ : "刚来这里不久",
186
+ ];
187
+ if (state.closeness >= 0.45) parts.push("有归属感");
188
+ if (state.tension >= 0.15) parts.push("最近气氛有点紧");
189
+ if (state.absentDays >= LONG_ABSENCE_DAYS) parts.push("好久没在这里说话了");
190
+ return parts.join(",");
191
+ }
192
+
193
+ export function describeBond(state) {
194
+ if (!state) return "";
195
+ const parts = [
196
+ state.familiarity >= 0.6
197
+ ? "很熟"
198
+ : state.familiarity >= 0.3
199
+ ? "认识一段时间了"
200
+ : state.familiarity > 0.05
201
+ ? "不太熟"
202
+ : "几乎不认识",
203
+ ];
204
+ if (state.closeness >= 0.6) parts.push("很亲近");
205
+ else if (state.closeness >= 0.35) parts.push("挺亲近");
206
+ if (state.trust >= 0.7) parts.push("信任");
207
+ else if (state.trust <= 0.3) parts.push("有点防备");
208
+ if (state.tension >= 0.15)
209
+ parts.push(`现在有点别扭${state.why ? `(${state.why})` : ""}`);
210
+ // Not seen at all is different from around but not talking with her.
211
+ // Never having talked is not the same as a gap after real conversations.
212
+ if (state.awayDays >= LONG_ABSENCE_DAYS) parts.push("好久不见了");
213
+ else if (state.absentDays >= 2 * LONG_ABSENCE_DAYS)
214
+ parts.push("最近没怎么说上话");
215
+ else if (
216
+ state.lastTalkedAt == null &&
217
+ state.closeness >= 0.35 &&
218
+ (state.unspokenDays ?? 0) >= 7 &&
219
+ (state.awayDays ?? 0) < 3
220
+ )
221
+ parts.push("还没怎么说过话");
222
+ return parts.join(",");
223
+ }
224
+
225
+ export class Bonds {
226
+ constructor(mind) {
227
+ this.mind = mind;
228
+ this.db = mind.db;
229
+ this.folded = new Map();
230
+ }
231
+ // Names and where she has met each person; a person is the same QQ number
232
+ // in every group and private chat.
233
+ meet(people, session, time = Date.now()) {
234
+ const read = this.db.prepare(
235
+ "SELECT sessions FROM mind_people WHERE user_id=?",
236
+ );
237
+ const write = this.db.prepare(
238
+ "INSERT INTO mind_people(user_id,name,first_seen,last_seen,sessions) VALUES (?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET name=COALESCE(excluded.name,name),first_seen=COALESCE(first_seen,excluded.first_seen),last_seen=MAX(COALESCE(last_seen,excluded.last_seen),excluded.last_seen),sessions=excluded.sessions",
239
+ );
240
+ for (const { userId, name } of people) {
241
+ if (!userId || userId === "bot") continue;
242
+ const sessions = new Set(parse(read.get(userId)?.sessions, []));
243
+ sessions.add(session);
244
+ write.run(
245
+ String(userId),
246
+ name || null,
247
+ time,
248
+ time,
249
+ JSON.stringify([...sessions].slice(-40)),
250
+ );
251
+ }
252
+ }
253
+ // This change was already taken from these same sources.
254
+ #repeatedFeeling(kind, id, change, cited) {
255
+ if (!cited.length) return false;
256
+ const rows = this.db
257
+ .prepare(
258
+ `SELECT sources FROM mind_bond_events
259
+ WHERE subject_kind=? AND subject_id=? AND change=?
260
+ AND id NOT IN (
261
+ SELECT target_id FROM mind_revocations WHERE target_kind='bond'
262
+ )`,
263
+ )
264
+ .all(kind, String(id), change);
265
+ const seen = new Set();
266
+ for (const row of rows)
267
+ for (const source of evidenceRoots(this.db, parse(row.sources, [])))
268
+ seen.add(source);
269
+ return evidenceRoots(this.db, cited).every((source) => seen.has(source));
270
+ }
271
+ // When the cited moment actually happened. Null when nothing can be dated,
272
+ // so a feeling without a readable source is not treated as late.
273
+ #evidenceAt(cited) {
274
+ let newest = null;
275
+ const take = (time) => {
276
+ if (Number.isFinite(time) && (newest === null || time > newest))
277
+ newest = time;
278
+ };
279
+ const seqs = messageSeqs(cited);
280
+ if (seqs.length) {
281
+ const rows = this.db
282
+ .prepare(
283
+ `SELECT time FROM core_events WHERE seq IN (${seqs.map(() => "?").join(",")})`,
284
+ )
285
+ .all(...seqs);
286
+ for (const row of rows) take(row.time);
287
+ }
288
+ const meetings = cited
289
+ .filter((source) => source.startsWith("g:"))
290
+ .map((source) => source.slice(2));
291
+ if (meetings.length) {
292
+ const rows = this.db
293
+ .prepare(
294
+ `SELECT created FROM mind_meetings WHERE id IN (${meetings.map(() => "?").join(",")})`,
295
+ )
296
+ .all(...meetings);
297
+ for (const row of rows) take(row.created);
298
+ }
299
+ const thoughts = cited
300
+ .filter((source) => source.startsWith("t:"))
301
+ .map((source) => source.slice(2));
302
+ if (thoughts.length) {
303
+ const rows = this.db
304
+ .prepare(
305
+ `SELECT created FROM mind_thoughts WHERE id IN (${thoughts.map(() => "?").join(",")})`,
306
+ )
307
+ .all(...thoughts);
308
+ for (const row of rows) take(row.created);
309
+ }
310
+ for (const source of cited) {
311
+ const day = /^d:(\d{4}-\d{2}-\d{2})$/.exec(source);
312
+ if (day) take(zonedTime(`${day[1]} 23:59`, this.mind.timeZone()));
313
+ }
314
+ return newest;
315
+ }
316
+ #staleFeeling(cited, time) {
317
+ const when = this.#evidenceAt(cited);
318
+ return when !== null && time - when > FEELING_WINDOW;
319
+ }
320
+ record({
321
+ kind = "person",
322
+ id,
323
+ change,
324
+ note = "",
325
+ sources = [],
326
+ session = null,
327
+ origin = "turn",
328
+ time = Date.now(),
329
+ }) {
330
+ const delta = BOND_CHANGES[change];
331
+ if (!delta || !id || id === "bot") return null;
332
+ const cited = evidence(sources);
333
+ // The same moment cannot be spent again to grow closer, and a feeling
334
+ // filled in long afterward does not become a meeting that just happened.
335
+ // An impression can still be written; it does not move the numbers.
336
+ if (
337
+ change !== "interaction" &&
338
+ change !== "impression" &&
339
+ (this.#repeatedFeeling(kind, id, change, cited) ||
340
+ this.#staleFeeling(cited, time))
341
+ )
342
+ return null;
343
+ if (change === "interaction") {
344
+ // One felt "we talked" per person, place and hour keeps the ledger small.
345
+ const recent = this.db
346
+ .prepare(
347
+ "SELECT 1 FROM mind_bond_events WHERE subject_kind=? AND subject_id=? AND change='interaction' AND session_id IS ? AND created>?",
348
+ )
349
+ .get(kind, String(id), session, time - HOUR);
350
+ if (recent) return null;
351
+ }
352
+ const row = randomUUID();
353
+ this.db
354
+ .prepare(
355
+ "INSERT INTO mind_bond_events(id,created,subject_kind,subject_id,change,familiarity,closeness,trust,tension,note,sources,session_id,origin) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
356
+ )
357
+ .run(
358
+ row,
359
+ time,
360
+ kind,
361
+ String(id),
362
+ change,
363
+ delta.familiarity || 0,
364
+ change === "interaction" && (origin === "direct" || kind === "group")
365
+ ? 0.005
366
+ : delta.closeness || 0,
367
+ delta.trust || 0,
368
+ delta.tension || 0,
369
+ text(note, change === "impression" ? 80 : 60),
370
+ JSON.stringify(cited),
371
+ session,
372
+ origin,
373
+ );
374
+ return row;
375
+ }
376
+ // The whole history is folded once and kept until something new happens
377
+ // with this person; looking back at an earlier moment always refolds.
378
+ state(kind, id, now = Date.now()) {
379
+ const subject = String(id);
380
+ const meta = this.db
381
+ .prepare(
382
+ "SELECT COUNT(*) n, MAX(rowid) r, MAX(created) c, (SELECT COUNT(*) FROM mind_revocations WHERE target_kind='bond') revoked FROM mind_bond_events WHERE subject_kind=? AND subject_id=?",
383
+ )
384
+ .get(kind, subject);
385
+ if (!meta.n) return null;
386
+ const whole = meta.c <= now;
387
+ const key = `${kind}:${subject}`;
388
+ const signature = `${meta.n}:${meta.r}:${meta.revoked}`;
389
+ const cached = whole ? this.folded.get(key) : null;
390
+ if (cached?.signature === signature) return settle(cached.state, now);
391
+ const rows = this.db
392
+ .prepare(
393
+ "SELECT * FROM mind_bond_events WHERE subject_kind=? AND subject_id=? AND created<=? AND id NOT IN (SELECT target_id FROM mind_revocations WHERE target_kind='bond') ORDER BY created",
394
+ )
395
+ .all(kind, subject, now);
396
+ if (!rows.length) return null;
397
+ const state = foldEvents(rows);
398
+ if (whole) this.folded.set(key, { signature, state });
399
+ return settle(state, now);
400
+ }
401
+ // A private reason stays in that conversation. Another room can still
402
+ // feel the closeness or the tension, and can still see an older note
403
+ // that was formed in the open.
404
+ #noteFits(note, room) {
405
+ const hidden = new Set();
406
+ if (note.session && isPrivateSession(note.session))
407
+ hidden.add(note.session);
408
+ const sources = parse(note.sources, []);
409
+ for (const row of this.mind.meetings.places(sources))
410
+ if (row.discretion === "private" || row.discretion === "secret")
411
+ hidden.add(row.session_id);
412
+ for (const id of this.mind.meetings.privateRoots(sources)) hidden.add(id);
413
+ const seqs = messageSeqs(sources);
414
+ if (seqs.length) {
415
+ const found = this.db
416
+ .prepare(
417
+ `SELECT DISTINCT session_id FROM core_events WHERE seq IN (${seqs.map(() => "?").join(",")})`,
418
+ )
419
+ .all(...seqs);
420
+ for (const row of found)
421
+ if (isPrivateSession(row.session_id)) hidden.add(row.session_id);
422
+ }
423
+ return !hidden.size || hidden.has(room);
424
+ }
425
+ #shownNotes(state, room) {
426
+ const pick = (kind, current) => {
427
+ const rows = (state.notes || []).filter((note) => note.kind === kind);
428
+ if (!room) return rows.at(-1)?.note || current || "";
429
+ for (let i = rows.length - 1; i >= 0; i--)
430
+ if (this.#noteFits(rows[i], room)) return rows[i].note;
431
+ return "";
432
+ };
433
+ return {
434
+ impression: pick("impression", state.impression),
435
+ why: pick("why", state.why),
436
+ };
437
+ }
438
+ person(userId, now = Date.now(), { room = "" } = {}) {
439
+ const state = this.state("person", userId, now);
440
+ const known = this.db
441
+ .prepare("SELECT * FROM mind_people WHERE user_id=?")
442
+ .get(String(userId));
443
+ if (!state && !known) return null;
444
+ // When she last saw them at all: in a conversation she read or glanced
445
+ // at, or talking with her. A later sighting is unknowable when looking
446
+ // back, so it is ignored.
447
+ const seen = Math.max(
448
+ known?.last_seen && known.last_seen <= now ? known.last_seen : 0,
449
+ state?.lastTalkedAt || 0,
450
+ );
451
+ const merged = {
452
+ ...(state || { ...START, interactions: 0 }),
453
+ firstMetAt:
454
+ state?.firstMetAt ??
455
+ (known?.first_seen <= now ? known.first_seen : null),
456
+ seenAt: seen || null,
457
+ awayDays: seen ? Math.max(0, Math.floor((now - seen) / DAY)) : null,
458
+ };
459
+ const notes = this.#shownNotes(merged, room);
460
+ const described = { ...merged, ...notes };
461
+ delete described.notes;
462
+ if (described.lastTalkedAt == null && described.firstMetAt != null)
463
+ described.unspokenDays = Math.max(
464
+ 0,
465
+ Math.floor((now - described.firstMetAt) / DAY),
466
+ );
467
+ const feel = describeBond(described);
468
+ const binding = this.mind.relationships.current(userId, { now, room });
469
+ const relationship = this.mind.relationships.view(binding, room);
470
+ delete described.unspokenDays;
471
+ return {
472
+ userId: String(userId),
473
+ name: relationship?.name || known?.name || String(userId),
474
+ relationship,
475
+ relationalWeight: this.mind.relationships.weight(binding),
476
+ sessions: parse(known?.sessions, []),
477
+ lastSeen: known?.last_seen || null,
478
+ ...described,
479
+ feel,
480
+ };
481
+ }
482
+ group(session, now = Date.now()) {
483
+ const state = this.state("group", session, now);
484
+ if (!state) return null;
485
+ const { notes: _notes, ...rest } = state;
486
+ return { session, ...rest, feel: describeGroup(rest) };
487
+ }
488
+ people({ now = Date.now(), limit = 200 } = {}) {
489
+ return this.db
490
+ .prepare(
491
+ "SELECT user_id FROM mind_people ORDER BY last_seen DESC LIMIT ?",
492
+ )
493
+ .all(limit)
494
+ .map((row) => this.person(row.user_id, now))
495
+ .filter(Boolean);
496
+ }
497
+ // People she has grown close to and not heard from in a while.
498
+ missing({ now = Date.now(), limit = 3, days = 7, closeness = 0.35 } = {}) {
499
+ return this.db
500
+ .prepare(
501
+ "SELECT user_id FROM mind_people WHERE first_seen<=? ORDER BY last_seen DESC LIMIT 300",
502
+ )
503
+ .all(now)
504
+ .map((row) => this.person(row.user_id, now))
505
+ .filter((p) => p && p.closeness >= closeness && (p.awayDays ?? 0) >= days)
506
+ .sort((a, b) => b.closeness - a.closeness)
507
+ .slice(0, limit);
508
+ }
509
+ // Close people she still sees, and has not talked with for a while.
510
+ // Being around is not the same as having talked. Never having talked
511
+ // still counts: the time starts from when she first met them.
512
+ quiet({ now = Date.now(), limit = 3, days = 7, closeness = 0.35 } = {}) {
513
+ return this.db
514
+ .prepare(
515
+ "SELECT user_id FROM mind_people WHERE first_seen<=? ORDER BY last_seen DESC LIMIT 300",
516
+ )
517
+ .all(now)
518
+ .map((row) => this.person(row.user_id, now))
519
+ .filter((p) => {
520
+ if (!p || p.closeness < closeness || (p.awayDays ?? 0) >= 3)
521
+ return false;
522
+ const silentFor =
523
+ p.absentDays ??
524
+ (p.firstMetAt != null
525
+ ? Math.max(0, Math.floor((now - p.firstMetAt) / DAY))
526
+ : 0);
527
+ return silentFor >= days;
528
+ })
529
+ .sort((a, b) => {
530
+ const span = (p) =>
531
+ p.absentDays ??
532
+ (p.firstMetAt != null ? Math.floor((now - p.firstMetAt) / DAY) : 0);
533
+ return span(b) - span(a);
534
+ })
535
+ .slice(0, limit);
536
+ }
537
+ groups(now = Date.now()) {
538
+ return this.db
539
+ .prepare(
540
+ "SELECT DISTINCT subject_id FROM mind_bond_events WHERE subject_kind='group'",
541
+ )
542
+ .all()
543
+ .map((row) => this.group(row.subject_id, now))
544
+ .filter(Boolean);
545
+ }
546
+ changes(kind, id, limit = 40) {
547
+ return this.db
548
+ .prepare(
549
+ "SELECT e.*, EXISTS(SELECT 1 FROM mind_revocations r WHERE r.target_kind='bond' AND r.target_id=e.id) revoked FROM mind_bond_events e WHERE e.subject_kind=? AND e.subject_id=? AND e.change!='interaction' ORDER BY e.created DESC LIMIT ?",
550
+ )
551
+ .all(kind, String(id), limit)
552
+ .map((row) => ({
553
+ ...row,
554
+ revoked: !!row.revoked,
555
+ sources: JSON.parse(row.sources),
556
+ }));
557
+ }
558
+ name(userId) {
559
+ return (
560
+ this.db
561
+ .prepare("SELECT name FROM mind_people WHERE user_id=?")
562
+ .get(String(userId))?.name || ""
563
+ );
564
+ }
565
+ }