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.
- package/LICENSE +21 -21
- package/README.en.md +7 -3
- package/README.md +7 -3
- package/docs/en/CHANGELOG.md +35 -16
- package/docs/en/chat-style.md +28 -9
- package/docs/en/guide.md +5 -5
- package/docs/en/her-life.md +2 -2
- package/docs/en/webui.md +1 -1
- package/docs/zh/CHANGELOG.md +35 -16
- package/docs/zh/chat-style.md +30 -9
- package/docs/zh/guide.md +5 -5
- package/docs/zh/her-life.md +2 -2
- package/docs/zh/webui.md +1 -1
- package/package.json +1 -1
- package/public/app/assets/{ChatsPage-DCYMkJ91.js → ChatsPage-CshdtTzV.js} +2 -2
- package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
- package/public/app/assets/{Empty-CLyZkxR5.js → Empty-C7yzOBzy.js} +1 -1
- package/public/app/assets/{Empty-DIobYG3x.css → Empty-DmUB2-Bk.css} +1 -1
- package/public/app/assets/{Field-BpaRwLI_.js → Field-BjFv4Rfn.js} +1 -1
- package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
- package/public/app/assets/{HeartPage-BIOMTiwr.js → HeartPage-eqo6ru16.js} +1 -1
- package/public/app/assets/LifePage-BUi5euBF.css +1 -0
- package/public/app/assets/{LifePage-BCgM4JkR.js → LifePage-CN8GE5NK.js} +1 -1
- package/public/app/assets/{MemoryPage-vShvRqMt.js → MemoryPage-CCpMN4Wg.js} +1 -1
- package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
- package/public/app/assets/{NaturePage-Cy9ptr4G.js → NaturePage-C0JqdBTR.js} +2 -2
- package/public/app/assets/NaturePage-CHkpb5NK.css +1 -0
- package/public/app/assets/PeoplePage-BTU0H1pM.css +1 -0
- package/public/app/assets/{PeoplePage-CUZekB2E.js → PeoplePage-BXGBbO4u.js} +1 -1
- package/public/app/assets/{PluginsPage-1RM4rmJP.js → PluginsPage-Dksp5AV5.js} +1 -1
- package/public/app/assets/{SystemPage-BI2CB5Ig.css → SystemPage-BenIyTvk.css} +1 -1
- package/public/app/assets/{SystemPage-BeT5TmIV.js → SystemPage-Cd7aa8eE.js} +1 -1
- package/public/app/assets/TimePage-BOms_n4I.css +1 -0
- package/public/app/assets/{TimePage-taTmPMM8.js → TimePage-Bs_zImb4.js} +2 -2
- package/public/app/assets/{index-DY5AM0Hz.css → index-BHZcHUMh.css} +1 -1
- package/public/app/assets/{index-DdYwC5ZU.js → index-CyRtYxP3.js} +3 -3
- package/public/app/assets/{knowledge-DJmfu2KN.js → knowledge-BJOIyuJo.js} +1 -1
- package/public/app/assets/{media-BEtnE0Gv.js → media-Bwtx8blz.js} +1 -1
- package/public/app/assets/{mind-Dtiw0-QK.js → mind-DtTVrW6Z.js} +1 -1
- package/public/app/index.html +5 -6
- package/public/guide.en.html +3 -3
- package/public/guide.html +3 -3
- package/scripts/import-data.js +128 -128
- package/scripts/proxy-config.js +167 -167
- package/scripts/update-instance.js +201 -201
- package/server/channels/adapters.js +27 -27
- package/server/channels/onebot/adapter.js +288 -288
- package/server/channels/qqbot/adapter.js +253 -253
- package/server/channels/qqbot/api.js +199 -199
- package/server/channels/qqbot/credentials.js +11 -11
- package/server/channels/qqbot/gateway.js +274 -274
- package/server/channels/qqbot/index.js +389 -389
- package/server/channels/segments.js +40 -40
- package/server/channels/session-key.js +153 -153
- package/server/channels/state.js +42 -42
- package/server/core/api.js +564 -564
- package/server/core/context-builder.js +288 -288
- package/server/core/context-compactor.js +454 -454
- package/server/core/conversation-cues.js +385 -325
- package/server/core/conversation-grounding.js +304 -0
- package/server/core/conversation-manager.js +87 -87
- package/server/core/dialogue-context.js +244 -0
- package/server/core/dialogue-contract.js +35 -0
- package/server/core/feedback.js +8 -8
- package/server/core/group-style.js +72 -72
- package/server/core/initiative-context.js +68 -66
- package/server/core/input-budget.js +32 -32
- package/server/core/local-demo.js +98 -98
- package/server/core/message-manager.js +118 -118
- package/server/core/message-scheduler.js +78 -78
- package/server/core/model-json.js +190 -190
- package/server/core/model-manager.js +5 -8
- package/server/core/model-presets.js +900 -900
- package/server/core/name-call.js +29 -29
- package/server/core/network.js +157 -157
- package/server/core/person-name.js +22 -22
- package/server/core/persona-manager.js +359 -269
- package/server/core/reader-check.js +62 -0
- package/server/core/reply-delivery.js +537 -404
- package/server/core/reply-target-resolver.js +50 -50
- package/server/core/response-generator.js +92 -50
- package/server/core/response-validator.js +53 -32
- package/server/core/retired-prompts.js +51 -51
- package/server/core/sessions.js +109 -109
- package/server/core/speaker-names.js +92 -92
- package/server/core/topic-tracker.js +65 -65
- package/server/core/turn.js +82 -15
- package/server/http.js +18 -18
- package/server/i18n/decisions.js +209 -209
- package/server/i18n/errors.js +2 -0
- package/server/i18n/index.js +144 -144
- package/server/i18n/labels.js +6 -10
- package/server/i18n/models.js +111 -111
- package/server/knowledge/api.js +342 -342
- package/server/knowledge/embedding-profile.js +114 -114
- package/server/knowledge/index.js +13 -13
- package/server/knowledge/retrieval.js +93 -93
- package/server/knowledge/schema.js +158 -158
- package/server/knowledge/vector-index.js +90 -90
- package/server/mind/affect.js +265 -265
- package/server/mind/attention.js +200 -200
- package/server/mind/bonds.js +565 -565
- package/server/mind/clock.js +83 -83
- package/server/mind/context-selection.js +16 -4
- package/server/mind/continuity.js +562 -562
- package/server/mind/conversation-origin.js +175 -175
- package/server/mind/days.js +210 -210
- package/server/mind/evidence-read.js +45 -45
- package/server/mind/expression-grounding.js +21 -0
- package/server/mind/faces.js +373 -373
- package/server/mind/guard.js +68 -60
- package/server/mind/initiative.js +338 -338
- package/server/mind/life/activities.js +21 -21
- package/server/mind/life/context.js +330 -330
- package/server/mind/life/growth.js +261 -261
- package/server/mind/life/presence.js +305 -305
- package/server/mind/meetings.js +1069 -1069
- package/server/mind/memory-policy.js +48 -48
- package/server/mind/memory.js +888 -888
- package/server/mind/nature.js +358 -358
- package/server/mind/novelty.js +19 -19
- package/server/mind/own-voice.js +35 -0
- package/server/mind/periods.js +118 -118
- package/server/mind/reading.js +132 -120
- package/server/mind/relationship-context.js +71 -71
- package/server/mind/relationships.js +390 -390
- package/server/mind/salience.js +201 -201
- package/server/mind/schema.js +567 -567
- package/server/mind/self.js +496 -484
- package/server/mind/thoughts.js +274 -274
- package/server/mind/time/agenda.js +242 -242
- package/server/mind/time/api.js +200 -200
- package/server/mind/time/attention.js +34 -34
- package/server/mind/time/availability.js +23 -1
- package/server/mind/time/executor.js +4 -2
- package/server/mind/time/games.js +20 -11
- package/server/mind/time/index.js +9 -2
- package/server/mind/time/kinds.js +29 -2
- package/server/mind/time/material-relevance.js +19 -0
- package/server/mind/time/model-material.js +77 -77
- package/server/mind/time/presentation.js +35 -51
- package/server/mind/time/reading-input.js +31 -0
- package/server/mind/time/schedule.js +83 -83
- package/server/mind/time/schema.js +84 -84
- package/server/mind/time/search.js +383 -383
- package/server/mind/time/sharing.js +482 -472
- package/server/mind/time/task-links.js +311 -311
- package/server/mind/time/tasks.js +41 -3
- package/server/mind/time/works.js +527 -432
- package/server/mind/traits.js +191 -191
- package/server/mind/view.js +62 -12
- package/server/runtime/installation.js +116 -116
- package/server/runtime/update-manager.js +140 -140
- package/server/storage/data-package.js +298 -298
- package/server/storage/data-transfer-api.js +50 -50
- package/server/storage/data-transfer-worker.js +18 -18
- package/server/storage/data-transfer.js +416 -416
- package/server/studio/display-names.js +139 -139
- package/server/studio/management.js +102 -102
- package/server/studio/updates.js +144 -144
- package/server/version.js +10 -10
- package/public/app/assets/ChatsPage-CFMVozoG.css +0 -1
- package/public/app/assets/HeartPage-C-2jvgzF.css +0 -1
- package/public/app/assets/LifePage-CG1OHbRK.css +0 -1
- package/public/app/assets/MemoryPage-C_Bu4rfb.css +0 -1
- package/public/app/assets/NaturePage-CPZlImT0.css +0 -1
- package/public/app/assets/PeoplePage-3RRUITDz.css +0 -1
- package/public/app/assets/TimePage-C-guwPk2.css +0 -1
package/server/mind/bonds.js
CHANGED
|
@@ -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
|
+
}
|