luckytri 1.0.0
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/.github/SECURITY.md +71 -0
- package/LICENSE +21 -0
- package/README.en.md +250 -0
- package/README.md +250 -0
- package/bin/luckytri.js +107 -0
- package/docs/brand/banner.png +0 -0
- package/docs/brand/palette.png +0 -0
- package/docs/brand/portrait.png +0 -0
- package/docs/en/CHANGELOG.md +113 -0
- package/docs/en/architecture.md +152 -0
- package/docs/en/chat-style.md +62 -0
- package/docs/en/connect.md +63 -0
- package/docs/en/guide.md +216 -0
- package/docs/en/her-life.md +189 -0
- package/docs/en/npm-release.md +25 -0
- package/docs/en/plugins.md +82 -0
- package/docs/en/recovery.md +28 -0
- package/docs/en/time.md +71 -0
- package/docs/en/webui.md +73 -0
- package/docs/zh/CHANGELOG.md +113 -0
- package/docs/zh/architecture.md +152 -0
- package/docs/zh/chat-style.md +62 -0
- package/docs/zh/connect.md +63 -0
- package/docs/zh/guide.md +216 -0
- package/docs/zh/her-life.md +189 -0
- package/docs/zh/npm-release.md +33 -0
- package/docs/zh/plugins.md +82 -0
- package/docs/zh/recovery.md +28 -0
- package/docs/zh/time.md +71 -0
- package/docs/zh/webui.md +73 -0
- package/package.json +108 -0
- package/plugins/sdk/index.d.ts +155 -0
- package/plugins/sdk/testing.js +58 -0
- package/plugins/weather/index.js +82 -0
- package/plugins/weather/luckytri-plugin.json +29 -0
- package/public/app/assets/ChatsPage-C-FcRapR.js +3 -0
- package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
- package/public/app/assets/Empty-CpP5kQHH.js +2 -0
- package/public/app/assets/Empty-DmUB2-Bk.css +1 -0
- package/public/app/assets/Field-D4gY-INU.js +1 -0
- package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
- package/public/app/assets/HeartPage-CPy_thQd.js +1 -0
- package/public/app/assets/LifePage-BUi5euBF.css +1 -0
- package/public/app/assets/LifePage-CxWvwmSf.js +1 -0
- package/public/app/assets/MemoryPage-DC4rhQ68.js +1 -0
- package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
- package/public/app/assets/NaturePage-CTXHCE2D.css +1 -0
- package/public/app/assets/NaturePage-IvYw04Zq.js +3 -0
- package/public/app/assets/PeoplePage-BmUxkrOU.css +1 -0
- package/public/app/assets/PeoplePage-Cqefm9-q.js +1 -0
- package/public/app/assets/PluginsPage-DKSaKvXb.js +2 -0
- package/public/app/assets/PluginsPage-HyBe5D8w.css +1 -0
- package/public/app/assets/SystemPage-CAMPoYqM.css +1 -0
- package/public/app/assets/SystemPage-CJmtJkrK.js +1 -0
- package/public/app/assets/TimePage-BOms_n4I.css +1 -0
- package/public/app/assets/TimePage-Cw44inM4.js +3 -0
- package/public/app/assets/formatters-ZmW_xFHw.js +1 -0
- package/public/app/assets/index-BheRW-1t.js +4 -0
- package/public/app/assets/index-YDz1ree1.css +1 -0
- package/public/app/assets/knowledge-DG0e0uKw.js +1 -0
- package/public/app/assets/liquid-garden-IMoHBwzc.png +0 -0
- package/public/app/assets/luckytri-home-95QKeMtc.png +0 -0
- package/public/app/assets/media-BzPDBm7e.js +1 -0
- package/public/app/index.html +27 -0
- package/public/guide.en.html +1053 -0
- package/public/guide.html +893 -0
- package/public/plugin-kit/v1/bridge.js +30 -0
- package/public/plugin-kit/v1/kit.css +14 -0
- package/public/tutorial.en.md +214 -0
- package/public/tutorial.md +214 -0
- package/scripts/backup.js +311 -0
- package/scripts/browser.js +41 -0
- package/scripts/launch.js +94 -0
- package/scripts/plugin.js +150 -0
- package/scripts/proxy-config.js +167 -0
- package/scripts/recover.js +26 -0
- package/scripts/runtime.js +25 -0
- package/scripts/service.js +47 -0
- package/scripts/setup.js +50 -0
- package/scripts/start-server.js +41 -0
- package/scripts/stop.js +36 -0
- package/server/app.js +96 -0
- package/server/channels/adapters.js +27 -0
- package/server/channels/contract.js +57 -0
- package/server/channels/hub.js +165 -0
- package/server/channels/index.js +25 -0
- package/server/channels/onebot/adapter.js +288 -0
- package/server/channels/onebot/index.js +336 -0
- package/server/channels/onebot/token.js +3 -0
- package/server/channels/qqbot/adapter.js +253 -0
- package/server/channels/qqbot/api.js +199 -0
- package/server/channels/qqbot/credentials.js +11 -0
- package/server/channels/qqbot/gateway.js +274 -0
- package/server/channels/qqbot/index.js +389 -0
- package/server/channels/segments.js +40 -0
- package/server/channels/session-key.js +153 -0
- package/server/channels/state.js +42 -0
- package/server/core/api.js +564 -0
- package/server/core/capabilities.js +208 -0
- package/server/core/context-builder.js +288 -0
- package/server/core/context-compactor.js +454 -0
- package/server/core/conversation-cues.js +325 -0
- package/server/core/conversation-manager.js +87 -0
- package/server/core/events.js +60 -0
- package/server/core/feedback.js +8 -0
- package/server/core/group-style.js +72 -0
- package/server/core/image-preprocessor.js +208 -0
- package/server/core/initiative-context.js +66 -0
- package/server/core/input-budget.js +32 -0
- package/server/core/local-demo.js +98 -0
- package/server/core/message-manager.js +118 -0
- package/server/core/message-scheduler.js +78 -0
- package/server/core/model-json.js +190 -0
- package/server/core/model-manager.js +1213 -0
- package/server/core/model-presets.js +900 -0
- package/server/core/name-call.js +29 -0
- package/server/core/net-guard.js +49 -0
- package/server/core/network.js +157 -0
- package/server/core/orchestrator.js +569 -0
- package/server/core/perceivers.js +91 -0
- package/server/core/person-name.js +22 -0
- package/server/core/persona-manager.js +243 -0
- package/server/core/reply-delivery.js +393 -0
- package/server/core/reply-target-resolver.js +50 -0
- package/server/core/repository.js +293 -0
- package/server/core/response-generator.js +50 -0
- package/server/core/response-validator.js +508 -0
- package/server/core/retired-prompts.js +51 -0
- package/server/core/sessions.js +109 -0
- package/server/core/speaker-names.js +92 -0
- package/server/core/topic-tracker.js +65 -0
- package/server/core/turn-processor.js +630 -0
- package/server/core/turn.js +236 -0
- package/server/core/vision-manager.js +633 -0
- package/server/http.js +18 -0
- package/server/i18n/decisions.js +206 -0
- package/server/i18n/errors.js +497 -0
- package/server/i18n/index.js +144 -0
- package/server/i18n/labels.js +653 -0
- package/server/i18n/models.js +111 -0
- package/server/index.js +102 -0
- package/server/knowledge/api.js +342 -0
- package/server/knowledge/embedding-profile.js +114 -0
- package/server/knowledge/index.js +13 -0
- package/server/knowledge/manager.js +386 -0
- package/server/knowledge/retrieval.js +93 -0
- package/server/knowledge/schema.js +154 -0
- package/server/knowledge/vector-index.js +90 -0
- package/server/mind/affect.js +265 -0
- package/server/mind/anticipations.js +507 -0
- package/server/mind/api.js +703 -0
- package/server/mind/attention.js +200 -0
- package/server/mind/bonds.js +558 -0
- package/server/mind/budget.js +152 -0
- package/server/mind/clock.js +83 -0
- package/server/mind/context-selection.js +61 -0
- package/server/mind/continuity.js +562 -0
- package/server/mind/conversation-origin.js +175 -0
- package/server/mind/day-planner.js +324 -0
- package/server/mind/days.js +210 -0
- package/server/mind/evidence-read.js +45 -0
- package/server/mind/evidence.js +148 -0
- package/server/mind/faces.js +138 -0
- package/server/mind/guard.js +60 -0
- package/server/mind/index.js +445 -0
- package/server/mind/initiative.js +333 -0
- package/server/mind/life/activities.js +21 -0
- package/server/mind/life/context.js +309 -0
- package/server/mind/life/diary.js +786 -0
- package/server/mind/life/growth.js +252 -0
- package/server/mind/life/index.js +1049 -0
- package/server/mind/life/presence.js +305 -0
- package/server/mind/meetings.js +1069 -0
- package/server/mind/memory-policy.js +48 -0
- package/server/mind/memory.js +888 -0
- package/server/mind/nature.js +262 -0
- package/server/mind/novelty.js +19 -0
- package/server/mind/observations.js +84 -0
- package/server/mind/own-day.js +324 -0
- package/server/mind/own-voice.js +318 -0
- package/server/mind/periods.js +118 -0
- package/server/mind/reading.js +120 -0
- package/server/mind/salience.js +201 -0
- package/server/mind/schema.js +562 -0
- package/server/mind/self.js +484 -0
- package/server/mind/senses.js +53 -0
- package/server/mind/thoughts.js +274 -0
- package/server/mind/time/activity-clock.js +196 -0
- package/server/mind/time/agenda.js +242 -0
- package/server/mind/time/api.js +200 -0
- package/server/mind/time/attention.js +34 -0
- package/server/mind/time/availability.js +69 -0
- package/server/mind/time/commitment-review.js +198 -0
- package/server/mind/time/delivery-links.js +130 -0
- package/server/mind/time/executor.js +512 -0
- package/server/mind/time/experiences.js +128 -0
- package/server/mind/time/games.js +562 -0
- package/server/mind/time/index.js +739 -0
- package/server/mind/time/intent.js +166 -0
- package/server/mind/time/kinds.js +171 -0
- package/server/mind/time/model-material.js +77 -0
- package/server/mind/time/plugin-activity.js +207 -0
- package/server/mind/time/presentation.js +51 -0
- package/server/mind/time/schedule.js +83 -0
- package/server/mind/time/schema.js +84 -0
- package/server/mind/time/search.js +383 -0
- package/server/mind/time/sharing.js +472 -0
- package/server/mind/time/task-links.js +311 -0
- package/server/mind/time/tasks.js +587 -0
- package/server/mind/time/works.js +432 -0
- package/server/mind/traits.js +191 -0
- package/server/mind/util.js +110 -0
- package/server/mind/view.js +273 -0
- package/server/paths.js +102 -0
- package/server/plugins/api.js +204 -0
- package/server/plugins/host.js +967 -0
- package/server/plugins/install.js +295 -0
- package/server/plugins/manifest.js +201 -0
- package/server/plugins/permissions.js +42 -0
- package/server/plugins/runner/guard.mjs +36 -0
- package/server/plugins/runner/worker.mjs +250 -0
- package/server/plugins/schema.js +91 -0
- package/server/plugins/zip.js +112 -0
- package/server/startup-lock.js +118 -0
- package/server/storage/archive.js +241 -0
- package/server/storage/backup-cleanup-worker.js +8 -0
- package/server/storage/backup-cleanup.js +414 -0
- package/server/storage/backup-lock.js +76 -0
- package/server/storage/backup-scheduler.js +131 -0
- package/server/storage/store.js +199 -0
- package/server/studio/display-names.js +138 -0
- package/server/studio/management.js +102 -0
- package/server/studio/readiness.js +190 -0
- package/server/studio/settings.js +236 -0
- package/server/version.js +10 -0
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { evidenceRoots } from "./evidence.js";
|
|
3
|
+
import { sessionNativeId } from "../channels/session-key.js";
|
|
4
|
+
import { interestTerms } from "./attention.js";
|
|
5
|
+
import { agoLabel, elapsedLabel } from "./clock.js";
|
|
6
|
+
import { leaks, secretRequest } from "./guard.js";
|
|
7
|
+
import { isPrivateSession } from "./memory.js";
|
|
8
|
+
import { lifeDayKey, lifeSpan } from "./nature.js";
|
|
9
|
+
import {
|
|
10
|
+
MEETING_FADED,
|
|
11
|
+
anyTouches,
|
|
12
|
+
cueList,
|
|
13
|
+
meetingSalience,
|
|
14
|
+
aboutLife,
|
|
15
|
+
meetsLife,
|
|
16
|
+
ownLife,
|
|
17
|
+
touches,
|
|
18
|
+
} from "./salience.js";
|
|
19
|
+
import { hasCredential, messageSeqs, parse, text } from "./util.js";
|
|
20
|
+
|
|
21
|
+
// What a meeting meant, and whether the world actually touched what she is
|
|
22
|
+
// living for. Both are facts of that batch: her own wording cannot invent a
|
|
23
|
+
// contact, and a private meeting does not follow her into another room.
|
|
24
|
+
export class Meetings {
|
|
25
|
+
constructor(mind) {
|
|
26
|
+
this.mind = mind;
|
|
27
|
+
this.db = mind.db;
|
|
28
|
+
}
|
|
29
|
+
keep(turn, { session, snapshot, sent = [], time = Date.now() } = {}) {
|
|
30
|
+
const batch = (snapshot?.messages || []).filter((m) =>
|
|
31
|
+
(snapshot?.batchIds || []).includes(m.id),
|
|
32
|
+
);
|
|
33
|
+
const heard = batch.filter((m) => m.role === "user" && m.id != null);
|
|
34
|
+
if (!heard.length) return null;
|
|
35
|
+
const sources = [...new Set(heard.map((m) => m.id))].sort((a, b) => a - b);
|
|
36
|
+
const sourceKey = JSON.stringify(sources);
|
|
37
|
+
if (
|
|
38
|
+
this.db
|
|
39
|
+
.prepare("SELECT 1 FROM mind_meetings WHERE sources=?")
|
|
40
|
+
.get(sourceKey)
|
|
41
|
+
)
|
|
42
|
+
return null;
|
|
43
|
+
const appraisal = hasCredential(turn?.appraisal)
|
|
44
|
+
? ""
|
|
45
|
+
: text(turn?.appraisal, 120);
|
|
46
|
+
const reason = hasCredential(turn?.reason) ? "" : text(turn?.reason, 160);
|
|
47
|
+
const people = [...new Set(heard.map((m) => String(m.speaker)))];
|
|
48
|
+
const living = this.mind.self.living({
|
|
49
|
+
before: time,
|
|
50
|
+
now: time,
|
|
51
|
+
room: session,
|
|
52
|
+
});
|
|
53
|
+
// Each person's own words have to meet a wish she is still living,
|
|
54
|
+
// across the messages they sent in this batch. The wish that holds
|
|
55
|
+
// livingFor is preferred when it was met; another own wish that is
|
|
56
|
+
// still present in this room can be met without taking that seat.
|
|
57
|
+
// Two people cannot be added together, and someone who only stood
|
|
58
|
+
// nearby is not credited.
|
|
59
|
+
const bySpeaker = new Map();
|
|
60
|
+
for (const m of heard) {
|
|
61
|
+
const id = String(m.speaker || "");
|
|
62
|
+
if (!id) continue;
|
|
63
|
+
const texts = bySpeaker.get(id) || [];
|
|
64
|
+
texts.push(m.text || "");
|
|
65
|
+
bySpeaker.set(id, texts);
|
|
66
|
+
}
|
|
67
|
+
const hits = [];
|
|
68
|
+
for (const wish of this.ownWishes(session, time)) {
|
|
69
|
+
const people = [...bySpeaker.entries()]
|
|
70
|
+
.filter(([, texts]) => meetsLife(wish.content, interestTerms(texts)))
|
|
71
|
+
.map(([id]) => id);
|
|
72
|
+
if (people.length) hits.push({ wish, people });
|
|
73
|
+
}
|
|
74
|
+
const chosen =
|
|
75
|
+
(living && hits.find((item) => item.wish.thread === living.thread)) ||
|
|
76
|
+
hits[0] ||
|
|
77
|
+
null;
|
|
78
|
+
const willPeople = chosen?.people || [];
|
|
79
|
+
const willMet = !!chosen;
|
|
80
|
+
const quiet = heard.some((m) => secretRequest(String(m.text || "")));
|
|
81
|
+
const actualWords = sent
|
|
82
|
+
.filter(
|
|
83
|
+
(line) =>
|
|
84
|
+
typeof line === "string" && line.trim() && !hasCredential(line),
|
|
85
|
+
)
|
|
86
|
+
.slice(0, 3);
|
|
87
|
+
if (!appraisal && !willMet && !actualWords.length) return null;
|
|
88
|
+
const id = randomUUID();
|
|
89
|
+
const choice = ["speak", "react", "decline", "silent"].includes(
|
|
90
|
+
turn?.choice,
|
|
91
|
+
)
|
|
92
|
+
? turn.choice
|
|
93
|
+
: "silent";
|
|
94
|
+
const targets = new Set(turn.targetMessageIds || []);
|
|
95
|
+
const exchange = actualWords.length
|
|
96
|
+
? {
|
|
97
|
+
they: heard
|
|
98
|
+
.filter((m) => targets.has(m.id) && !hasCredential(m.text))
|
|
99
|
+
.map((m) => ({
|
|
100
|
+
speaker: String(m.speaker),
|
|
101
|
+
ref: `m:${m.id}`,
|
|
102
|
+
time: m.time || time,
|
|
103
|
+
text: text(m.text, 300),
|
|
104
|
+
})),
|
|
105
|
+
iSaid: actualWords.map((line) => text(line, 500)),
|
|
106
|
+
}
|
|
107
|
+
: null;
|
|
108
|
+
this.db
|
|
109
|
+
.prepare(
|
|
110
|
+
"INSERT INTO mind_meetings(id,created,session_id,choice,appraisal,topic,people,sources,will_thread,will_met,discretion,will_people,exchange,reason) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
111
|
+
)
|
|
112
|
+
.run(
|
|
113
|
+
id,
|
|
114
|
+
time,
|
|
115
|
+
session,
|
|
116
|
+
choice,
|
|
117
|
+
appraisal,
|
|
118
|
+
text(turn?.topic, 40),
|
|
119
|
+
JSON.stringify(people),
|
|
120
|
+
sourceKey,
|
|
121
|
+
willMet ? chosen.wish.thread : null,
|
|
122
|
+
willMet ? 1 : 0,
|
|
123
|
+
isPrivateSession(session) ? "private" : quiet ? "secret" : "open",
|
|
124
|
+
JSON.stringify(willPeople),
|
|
125
|
+
JSON.stringify(exchange),
|
|
126
|
+
reason,
|
|
127
|
+
);
|
|
128
|
+
const link = this.db.prepare(
|
|
129
|
+
"INSERT INTO mind_meeting_people(meeting_id,user_id) VALUES (?,?)",
|
|
130
|
+
);
|
|
131
|
+
for (const userId of people) link.run(id, userId);
|
|
132
|
+
return { id, willMet };
|
|
133
|
+
}
|
|
134
|
+
#open(clause, args) {
|
|
135
|
+
return this.db
|
|
136
|
+
.prepare(
|
|
137
|
+
`SELECT m.* FROM mind_meetings m WHERE ${clause}
|
|
138
|
+
AND NOT EXISTS (
|
|
139
|
+
SELECT 1 FROM mind_revocations r
|
|
140
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
141
|
+
)
|
|
142
|
+
ORDER BY m.created DESC LIMIT 8`,
|
|
143
|
+
)
|
|
144
|
+
.all(...args);
|
|
145
|
+
}
|
|
146
|
+
#visible(row, session) {
|
|
147
|
+
const concealed =
|
|
148
|
+
row.discretion === "private" || row.discretion === "secret";
|
|
149
|
+
return !(concealed && row.session_id !== session);
|
|
150
|
+
}
|
|
151
|
+
#bound(inclusive) {
|
|
152
|
+
return inclusive ? "m.created<=?" : "m.created<?";
|
|
153
|
+
}
|
|
154
|
+
// Meanings that belong with the people in front of her. A private meeting
|
|
155
|
+
// stays in that private conversation.
|
|
156
|
+
recall({
|
|
157
|
+
session,
|
|
158
|
+
people = [],
|
|
159
|
+
cue = [],
|
|
160
|
+
now = Date.now(),
|
|
161
|
+
limit = 2,
|
|
162
|
+
inclusive = false,
|
|
163
|
+
} = {}) {
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
const rows = [];
|
|
166
|
+
const take = (list) => {
|
|
167
|
+
for (const row of list) {
|
|
168
|
+
if (seen.has(row.id) || !row.appraisal || !this.#visible(row, session))
|
|
169
|
+
continue;
|
|
170
|
+
seen.add(row.id);
|
|
171
|
+
rows.push(row);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
const bound = this.#bound(inclusive);
|
|
175
|
+
for (const id of [...new Set(people.map(String))].slice(0, 6))
|
|
176
|
+
take(
|
|
177
|
+
this.#open(
|
|
178
|
+
`m.id IN (SELECT meeting_id FROM mind_meeting_people WHERE user_id=?) AND ${bound}`,
|
|
179
|
+
[id, now],
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
take(this.#open(`m.session_id=? AND ${bound}`, [session, now]));
|
|
183
|
+
const lived = this.mind.days.lived(now);
|
|
184
|
+
const currentWords = interestTerms(
|
|
185
|
+
cue
|
|
186
|
+
.filter((line) => line?.role === "user")
|
|
187
|
+
.map((line) => line.text || ""),
|
|
188
|
+
);
|
|
189
|
+
return rows
|
|
190
|
+
.filter((row) => {
|
|
191
|
+
if (meetingSalience(row.created, lived) < MEETING_FADED) return false;
|
|
192
|
+
if (row.session_id === session || !currentWords.size) return true;
|
|
193
|
+
// A remembered appraisal is an old interpretation, not evidence of
|
|
194
|
+
// what today's speaker just said. Cross-room cues need actual words.
|
|
195
|
+
const exchange = parse(row.exchange, null);
|
|
196
|
+
return exchange?.they?.some(
|
|
197
|
+
(line) =>
|
|
198
|
+
people.map(String).includes(String(line.speaker)) &&
|
|
199
|
+
touches(line.text || "", currentWords, 2),
|
|
200
|
+
);
|
|
201
|
+
})
|
|
202
|
+
.sort((a, b) => b.created - a.created)
|
|
203
|
+
.slice(0, limit)
|
|
204
|
+
.map((row) => {
|
|
205
|
+
const exchange = parse(row.exchange, null);
|
|
206
|
+
const said = exchange?.they?.find((line) =>
|
|
207
|
+
people.map(String).includes(String(line.speaker)),
|
|
208
|
+
);
|
|
209
|
+
if (!said) return this.line(row, people, now);
|
|
210
|
+
const reply = exchange?.iSaid?.[0];
|
|
211
|
+
const name = this.mind.bonds.name(said?.speaker) || "对方";
|
|
212
|
+
const when = elapsedLabel(row.created, now, this.mind.timeZone());
|
|
213
|
+
return `${when}${row.session_id === session ? "" : "在别处"},${name}说「${text(said?.text, 60)}」${reply ? `,我回「${text(reply, 60)}」` : ""}`;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
line(row, people, now) {
|
|
217
|
+
const ids = parse(row.people, []);
|
|
218
|
+
const present = ids.find((id) => people.map(String).includes(String(id)));
|
|
219
|
+
const name = this.mind.bonds.name(present || ids[0]) || "有人";
|
|
220
|
+
const when = elapsedLabel(row.created, now, this.mind.timeZone());
|
|
221
|
+
const ago = when === "刚才" ? "" : `,${when}`;
|
|
222
|
+
const quiet = row.choice === "silent" ? ",那次没出声" : "";
|
|
223
|
+
return `${name}:${text(row.appraisal, 72)}${ago}${quiet}`;
|
|
224
|
+
}
|
|
225
|
+
// How often other people's words met this wish. Her appraisal does not count.
|
|
226
|
+
// A private touch stays in that room. If she meets that person again with no
|
|
227
|
+
// one else in the batch, whether she spoke is taken from that later meeting.
|
|
228
|
+
// A mixed batch does not count as having spoken to them, unless she
|
|
229
|
+
// actually answered that person's words. Words she later sends in a private
|
|
230
|
+
// chat with that person do count, and only in that chat.
|
|
231
|
+
// The count does not grow.
|
|
232
|
+
trace(
|
|
233
|
+
thread,
|
|
234
|
+
{ before = Date.now(), since = 0, inclusive = false, session = "" } = {},
|
|
235
|
+
) {
|
|
236
|
+
if (!thread) return { touched: 0 };
|
|
237
|
+
const compare = inclusive ? "<=" : "<";
|
|
238
|
+
const room = session
|
|
239
|
+
? "AND (m.discretion NOT IN ('private','secret') OR m.session_id = ?)"
|
|
240
|
+
: "";
|
|
241
|
+
const hidden = `AND NOT EXISTS (
|
|
242
|
+
SELECT 1 FROM mind_revocations r
|
|
243
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
244
|
+
)`;
|
|
245
|
+
const wish = this.#wishAt(thread, before);
|
|
246
|
+
if (!wish || wish.status === "closed") return { touched: 0 };
|
|
247
|
+
const threads = this.#willThreads(thread, before);
|
|
248
|
+
const marks = threads.map(() => "?").join(",");
|
|
249
|
+
const args = [...threads, since, before];
|
|
250
|
+
if (session) args.push(session);
|
|
251
|
+
const hits = this.db
|
|
252
|
+
.prepare(
|
|
253
|
+
`SELECT choice, created, people, will_people, sources FROM mind_meetings m
|
|
254
|
+
WHERE will_thread IN (${marks}) AND will_met=1 AND created>=? AND created${compare}?
|
|
255
|
+
${room} ${hidden}
|
|
256
|
+
ORDER BY created DESC`,
|
|
257
|
+
)
|
|
258
|
+
.all(...args)
|
|
259
|
+
.filter((row) => this.#stillMeets(row, wish.content));
|
|
260
|
+
if (!hits.length) return { touched: 0 };
|
|
261
|
+
const credited = new Set();
|
|
262
|
+
for (const row of hits) {
|
|
263
|
+
const named = parse(row.will_people, []);
|
|
264
|
+
for (const id of named.length ? named : parse(row.people, []))
|
|
265
|
+
credited.add(String(id));
|
|
266
|
+
}
|
|
267
|
+
let last = hits[0];
|
|
268
|
+
if (credited.size) {
|
|
269
|
+
const ids = [...credited];
|
|
270
|
+
const marks = ids.map(() => "?").join(",");
|
|
271
|
+
const follow = [...ids, ...ids, hits[0].created, before];
|
|
272
|
+
if (session) follow.push(session);
|
|
273
|
+
const later = this.db
|
|
274
|
+
.prepare(
|
|
275
|
+
`SELECT m.choice, m.created FROM mind_meetings m
|
|
276
|
+
WHERE EXISTS (
|
|
277
|
+
SELECT 1 FROM json_each(m.people) j
|
|
278
|
+
WHERE j.value IN (${marks})
|
|
279
|
+
)
|
|
280
|
+
AND NOT EXISTS (
|
|
281
|
+
SELECT 1 FROM json_each(m.people) j
|
|
282
|
+
WHERE j.value NOT IN (${marks})
|
|
283
|
+
)
|
|
284
|
+
AND m.created>=? AND m.created${compare}? ${room} ${hidden}
|
|
285
|
+
ORDER BY m.created DESC LIMIT 1`,
|
|
286
|
+
)
|
|
287
|
+
.get(...follow);
|
|
288
|
+
if (later) last = later;
|
|
289
|
+
const voiced = this.#voicedPrivately(
|
|
290
|
+
ids,
|
|
291
|
+
last.created,
|
|
292
|
+
before,
|
|
293
|
+
inclusive,
|
|
294
|
+
session,
|
|
295
|
+
);
|
|
296
|
+
if (voiced) last = { choice: voiced.choice, created: voiced.created };
|
|
297
|
+
const addressed = this.#addressed(
|
|
298
|
+
ids,
|
|
299
|
+
last.created,
|
|
300
|
+
before,
|
|
301
|
+
inclusive,
|
|
302
|
+
session,
|
|
303
|
+
);
|
|
304
|
+
if (addressed) last = { choice: "speak", created: addressed.created };
|
|
305
|
+
}
|
|
306
|
+
const touched = hits.length;
|
|
307
|
+
const when = elapsedLabel(last.created, before, this.mind.timeZone());
|
|
308
|
+
const spoke = last.choice === "silent" ? "没出声" : "出了声";
|
|
309
|
+
const followed = last.created !== hits[0].created;
|
|
310
|
+
const summary = followed
|
|
311
|
+
? `被别人的话碰到过 ${touched} 次,后来那次${spoke}`
|
|
312
|
+
: when === "刚才"
|
|
313
|
+
? `被别人的话碰到过 ${touched} 次,刚才那次${spoke}`
|
|
314
|
+
: `被别人的话碰到过 ${touched} 次,上一次${when},那次${spoke}`;
|
|
315
|
+
return {
|
|
316
|
+
touched,
|
|
317
|
+
lastSpoke: last.choice !== "silent",
|
|
318
|
+
text: summary,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// A later private conversation with that person, where she chose to speak
|
|
322
|
+
// without a new batch of their messages. A group message is not assumed
|
|
323
|
+
// to be for them, and a private one does not follow her into another room.
|
|
324
|
+
#voicedPrivately(ids, after, before, inclusive, session) {
|
|
325
|
+
const compare = inclusive ? "<=" : "<";
|
|
326
|
+
const room = session ? "AND session_id=?" : "";
|
|
327
|
+
const args = [after, before];
|
|
328
|
+
if (session) args.push(session);
|
|
329
|
+
const rows = this.db
|
|
330
|
+
.prepare(
|
|
331
|
+
`SELECT choice, created, session_id FROM mind_choices
|
|
332
|
+
WHERE choice!='silent' AND created>? AND created${compare}? ${room}
|
|
333
|
+
ORDER BY created DESC`,
|
|
334
|
+
)
|
|
335
|
+
.all(...args);
|
|
336
|
+
const credited = new Set(ids.map(String));
|
|
337
|
+
return (
|
|
338
|
+
rows.find((row) => {
|
|
339
|
+
if (!isPrivateSession(row.session_id)) return false;
|
|
340
|
+
try {
|
|
341
|
+
return credited.has(String(sessionNativeId(row.session_id)));
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}) || null
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
// She answered that person's own words later. Being in the batch is not
|
|
349
|
+
// enough, and their calling her is not enough if she stayed quiet.
|
|
350
|
+
// A private reply does not follow her into another room.
|
|
351
|
+
#addressed(ids, after, before, inclusive, session) {
|
|
352
|
+
const compare = inclusive ? "<=" : "<";
|
|
353
|
+
const rows = this.db
|
|
354
|
+
.prepare(
|
|
355
|
+
`SELECT e.created, e.session_id, e.origin FROM mind_bond_events e
|
|
356
|
+
WHERE e.subject_kind='person'
|
|
357
|
+
AND e.subject_id IN (${ids.map(() => "?").join(",")})
|
|
358
|
+
AND e.change='interaction' AND e.created>? AND e.created${compare}?
|
|
359
|
+
AND e.id NOT IN (
|
|
360
|
+
SELECT target_id FROM mind_revocations WHERE target_kind='bond'
|
|
361
|
+
)
|
|
362
|
+
ORDER BY e.created DESC`,
|
|
363
|
+
)
|
|
364
|
+
.all(...ids, after, before);
|
|
365
|
+
const spoken = this.db.prepare(
|
|
366
|
+
"SELECT 1 FROM mind_choices WHERE session_id=? AND created=? AND choice!='silent' LIMIT 1",
|
|
367
|
+
);
|
|
368
|
+
return (
|
|
369
|
+
rows.find((row) => {
|
|
370
|
+
if (
|
|
371
|
+
session &&
|
|
372
|
+
isPrivateSession(row.session_id) &&
|
|
373
|
+
row.session_id !== session
|
|
374
|
+
)
|
|
375
|
+
return false;
|
|
376
|
+
if (row.origin === "group") return true;
|
|
377
|
+
if (row.origin === "direct")
|
|
378
|
+
return !!spoken.get(row.session_id, row.created);
|
|
379
|
+
return false;
|
|
380
|
+
}) || null
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
// A faded meaning the current words actually meet. It is only remembered,
|
|
384
|
+
// not written back, and it does not by itself make her look.
|
|
385
|
+
reminded({ session, now = Date.now(), cue, cues, limit = 1 } = {}) {
|
|
386
|
+
const sets = cueList(cue, cues);
|
|
387
|
+
if (!sets.length) return [];
|
|
388
|
+
const lived = this.mind.days.lived(now);
|
|
389
|
+
return this.db
|
|
390
|
+
.prepare(
|
|
391
|
+
`SELECT * FROM mind_meetings m
|
|
392
|
+
WHERE m.appraisal!='' AND m.created<?
|
|
393
|
+
AND NOT EXISTS (
|
|
394
|
+
SELECT 1 FROM mind_revocations r
|
|
395
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
396
|
+
)
|
|
397
|
+
ORDER BY m.created DESC`,
|
|
398
|
+
)
|
|
399
|
+
.all(now)
|
|
400
|
+
.filter(
|
|
401
|
+
(row) =>
|
|
402
|
+
this.#visible(row, session) &&
|
|
403
|
+
meetingSalience(row.created, lived) < MEETING_FADED &&
|
|
404
|
+
anyTouches(row.appraisal, sets),
|
|
405
|
+
)
|
|
406
|
+
.slice(0, limit)
|
|
407
|
+
.map((row) => this.line(row, parse(row.people, []), now));
|
|
408
|
+
}
|
|
409
|
+
// Meanings she already kept, so solitude and the diary can cite them.
|
|
410
|
+
// A revoked meeting is not an experience anymore.
|
|
411
|
+
held({ since = 0, before = Date.now(), limit = 6, inclusive = true } = {}) {
|
|
412
|
+
const compare = inclusive ? "<=" : "<";
|
|
413
|
+
return this.db
|
|
414
|
+
.prepare(
|
|
415
|
+
`SELECT * FROM mind_meetings m
|
|
416
|
+
WHERE m.created>=? AND m.created${compare}? AND m.appraisal!=''
|
|
417
|
+
AND NOT EXISTS (
|
|
418
|
+
SELECT 1 FROM mind_revocations r
|
|
419
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
420
|
+
)
|
|
421
|
+
ORDER BY m.created DESC LIMIT ?`,
|
|
422
|
+
)
|
|
423
|
+
.all(since, before, limit)
|
|
424
|
+
.reverse()
|
|
425
|
+
.map((row) => this.heldLine(row, before));
|
|
426
|
+
}
|
|
427
|
+
heldLine(row, now) {
|
|
428
|
+
const ids = parse(row.people, []);
|
|
429
|
+
const who = [
|
|
430
|
+
...new Set(ids.map((id) => this.mind.bonds.name(id) || "有人")),
|
|
431
|
+
]
|
|
432
|
+
.slice(0, 3)
|
|
433
|
+
.join("、");
|
|
434
|
+
return {
|
|
435
|
+
ref: `g:${row.id}`,
|
|
436
|
+
who: who || "有人",
|
|
437
|
+
meant: text(row.appraisal, 80),
|
|
438
|
+
choice: row.choice,
|
|
439
|
+
when: elapsedLabel(row.created, now, this.mind.timeZone()),
|
|
440
|
+
...(row.discretion === "private" || row.discretion === "secret"
|
|
441
|
+
? { private: true }
|
|
442
|
+
: {}),
|
|
443
|
+
...(row.will_met && this.#touchStill(row, now)
|
|
444
|
+
? { touchedWill: true }
|
|
445
|
+
: {}),
|
|
446
|
+
...(row.reason && this.sayable(row.reason, "")
|
|
447
|
+
? { why: text(row.reason, 80) }
|
|
448
|
+
: {}),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
// Choices she already made, with the reason she kept. A later meeting
|
|
452
|
+
// with the same person or the same matter can continue from here instead
|
|
453
|
+
// of deciding as if she had never chosen.
|
|
454
|
+
stood({ session, people = [], cue = [], now = Date.now(), limit = 2 } = {}) {
|
|
455
|
+
const lived = this.mind.days.lived(now);
|
|
456
|
+
const present = new Set(people.map(String));
|
|
457
|
+
const bySpeaker = new Map();
|
|
458
|
+
for (const line of cue || []) {
|
|
459
|
+
if (!line || line.role === "assistant") continue;
|
|
460
|
+
const id = String(line.userId || line.speaker || "");
|
|
461
|
+
if (!id) continue;
|
|
462
|
+
const texts = bySpeaker.get(id) || [];
|
|
463
|
+
texts.push(String(line.text || ""));
|
|
464
|
+
bySpeaker.set(id, texts);
|
|
465
|
+
}
|
|
466
|
+
const speakerCues = [...bySpeaker.values()]
|
|
467
|
+
.map((texts) => interestTerms(texts))
|
|
468
|
+
.filter((terms) => terms.size);
|
|
469
|
+
const rows = this.db
|
|
470
|
+
.prepare(
|
|
471
|
+
`SELECT m.*, COALESCE(NULLIF(m.reason,''), c.reason, '') AS why
|
|
472
|
+
FROM mind_meetings m
|
|
473
|
+
LEFT JOIN mind_choices c
|
|
474
|
+
ON c.session_id=m.session_id AND c.created=m.created
|
|
475
|
+
WHERE m.created<? AND m.appraisal!=''
|
|
476
|
+
AND NOT EXISTS (
|
|
477
|
+
SELECT 1 FROM mind_revocations r
|
|
478
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
479
|
+
)
|
|
480
|
+
ORDER BY m.created DESC LIMIT 40`,
|
|
481
|
+
)
|
|
482
|
+
.all(now);
|
|
483
|
+
const CHOICE = {
|
|
484
|
+
speak: "开口",
|
|
485
|
+
react: "应了一下",
|
|
486
|
+
decline: "说了不想聊",
|
|
487
|
+
silent: "没出声",
|
|
488
|
+
};
|
|
489
|
+
const out = [];
|
|
490
|
+
for (const row of rows) {
|
|
491
|
+
if (out.length >= limit) break;
|
|
492
|
+
if (!this.#visible(row, session)) continue;
|
|
493
|
+
if (meetingSalience(row.created, lived) < MEETING_FADED) continue;
|
|
494
|
+
if (!this.sayable(row.appraisal, session)) continue;
|
|
495
|
+
const why = text(row.why || row.reason || "", 80);
|
|
496
|
+
if (!why || !this.sayable(why, session)) continue;
|
|
497
|
+
const ids = parse(row.people, []).map(String);
|
|
498
|
+
const withThem = ids.some((id) => present.has(id));
|
|
499
|
+
const about = anyTouches(
|
|
500
|
+
`${row.appraisal} ${row.topic || ""} ${why}`,
|
|
501
|
+
speakerCues,
|
|
502
|
+
);
|
|
503
|
+
if (!withThem && !about) continue;
|
|
504
|
+
const when = elapsedLabel(row.created, now, this.mind.timeZone());
|
|
505
|
+
out.push(
|
|
506
|
+
`${when}我${CHOICE[row.choice] || "看过"}:${text(row.appraisal, 60)}(${why})`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
511
|
+
// People whose own words met the wish she is still living for, and whom she
|
|
512
|
+
// has not seen for a few days. A faded touch does not keep them here.
|
|
513
|
+
// Solitude may remember them; it does not have to speak.
|
|
514
|
+
wishAway({ now = Date.now(), days = 3, limit = 3 } = {}) {
|
|
515
|
+
const wishes = this.mind.self
|
|
516
|
+
.annotated({ before: now, now })
|
|
517
|
+
.filter(
|
|
518
|
+
(row) => row.kind === "intention" && !row.faded && ownLife(row.content),
|
|
519
|
+
);
|
|
520
|
+
if (!wishes.length) return [];
|
|
521
|
+
const lived = this.mind.days.lived(now);
|
|
522
|
+
const buckets = [];
|
|
523
|
+
const clustered = new Set();
|
|
524
|
+
for (const living of wishes) {
|
|
525
|
+
if (clustered.has(living.thread)) continue;
|
|
526
|
+
const threads = this.#willThreads(living.thread, now);
|
|
527
|
+
for (const id of threads) clustered.add(id);
|
|
528
|
+
const marks = threads.map(() => "?").join(",");
|
|
529
|
+
const read = this.db.prepare(
|
|
530
|
+
`SELECT * FROM mind_meetings m
|
|
531
|
+
WHERE will_thread IN (${marks}) AND will_met=1 AND created<?
|
|
532
|
+
AND NOT EXISTS (
|
|
533
|
+
SELECT 1 FROM mind_revocations r
|
|
534
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
535
|
+
)
|
|
536
|
+
ORDER BY created DESC`,
|
|
537
|
+
);
|
|
538
|
+
const bucket = [];
|
|
539
|
+
const seenHere = new Set();
|
|
540
|
+
for (const row of read.all(...threads, now)) {
|
|
541
|
+
if (meetingSalience(row.created, lived) < MEETING_FADED) continue;
|
|
542
|
+
if (!this.#stillMeets(row, living.content)) continue;
|
|
543
|
+
const named = parse(row.will_people, []);
|
|
544
|
+
const who = named.length ? named : parse(row.people, []);
|
|
545
|
+
for (const userId of who.map(String)) {
|
|
546
|
+
if (seenHere.has(userId)) continue;
|
|
547
|
+
const person = this.mind.bonds.person(userId, now);
|
|
548
|
+
if (!person?.seenAt || (person.awayDays ?? 0) < days) continue;
|
|
549
|
+
seenHere.add(userId);
|
|
550
|
+
bucket.push({
|
|
551
|
+
ref: `g:${row.id}`,
|
|
552
|
+
userId,
|
|
553
|
+
name: person.name || userId,
|
|
554
|
+
away: agoLabel(now - person.seenAt),
|
|
555
|
+
session: row.session_id,
|
|
556
|
+
...(row.discretion === "private" || row.discretion === "secret"
|
|
557
|
+
? { private: true }
|
|
558
|
+
: {}),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (bucket.length) buckets.push(bucket);
|
|
563
|
+
}
|
|
564
|
+
// One person from each wish before any wish takes another slot, so a
|
|
565
|
+
// stronger private wish cannot fill the whole list.
|
|
566
|
+
const seen = new Set();
|
|
567
|
+
const out = [];
|
|
568
|
+
while (out.length < limit) {
|
|
569
|
+
let added = false;
|
|
570
|
+
for (const bucket of buckets) {
|
|
571
|
+
while (bucket.length && seen.has(bucket[0].userId)) bucket.shift();
|
|
572
|
+
const next = bucket.shift();
|
|
573
|
+
if (!next) continue;
|
|
574
|
+
seen.add(next.userId);
|
|
575
|
+
out.push(next);
|
|
576
|
+
added = true;
|
|
577
|
+
if (out.length >= limit) break;
|
|
578
|
+
}
|
|
579
|
+
if (!added) break;
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
// People whose words met the wish she is still living for, and who can be
|
|
584
|
+
// seen from this room. An older wish, a private meeting, and a revoked one
|
|
585
|
+
// do not pull her attention in this room.
|
|
586
|
+
touchedPeople(session, userIds, before = Date.now(), thread = "") {
|
|
587
|
+
const ids = [
|
|
588
|
+
...new Set((userIds || []).map((id) => String(id || "")).filter(Boolean)),
|
|
589
|
+
].slice(0, 12);
|
|
590
|
+
if (!thread || !ids.length) return new Set();
|
|
591
|
+
const wish = this.#wishAt(thread, before);
|
|
592
|
+
if (!wish || wish.status === "closed") return new Set();
|
|
593
|
+
const lived = this.mind.days.lived(before);
|
|
594
|
+
const threads = this.#willThreads(thread, before);
|
|
595
|
+
const threadMarks = threads.map(() => "?").join(",");
|
|
596
|
+
return new Set(
|
|
597
|
+
this.db
|
|
598
|
+
.prepare(
|
|
599
|
+
`SELECT p.user_id, m.created, m.will_people, m.sources FROM mind_meeting_people p
|
|
600
|
+
JOIN mind_meetings m ON m.id = p.meeting_id
|
|
601
|
+
WHERE p.user_id IN (${ids.map(() => "?").join(",")})
|
|
602
|
+
AND m.will_met = 1 AND m.will_thread IN (${threadMarks}) AND m.created < ?
|
|
603
|
+
AND (m.discretion NOT IN ('private','secret') OR m.session_id = ?)
|
|
604
|
+
AND NOT EXISTS (
|
|
605
|
+
SELECT 1 FROM mind_revocations r
|
|
606
|
+
WHERE r.target_kind = 'meeting' AND r.target_id = m.id
|
|
607
|
+
)`,
|
|
608
|
+
)
|
|
609
|
+
.all(...ids, ...threads, before, session)
|
|
610
|
+
.filter((row) => {
|
|
611
|
+
if (meetingSalience(row.created, lived) < MEETING_FADED) return false;
|
|
612
|
+
const credited = parse(row.will_people, []);
|
|
613
|
+
// Rows written before speakers were credited individually.
|
|
614
|
+
if (
|
|
615
|
+
credited.length &&
|
|
616
|
+
!credited.map(String).includes(String(row.user_id))
|
|
617
|
+
)
|
|
618
|
+
return false;
|
|
619
|
+
return this.#stillMeets(row, wish.content);
|
|
620
|
+
})
|
|
621
|
+
.map((row) => String(row.user_id)),
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
// Own wishes still present in this room. The current livingFor is
|
|
625
|
+
// among them when it belongs here; it is not the only one that can
|
|
626
|
+
// be met.
|
|
627
|
+
ownWishes(session, time) {
|
|
628
|
+
return this.mind.self
|
|
629
|
+
.annotated({ before: time, now: time })
|
|
630
|
+
.filter(
|
|
631
|
+
(row) =>
|
|
632
|
+
row.kind === "intention" &&
|
|
633
|
+
!row.faded &&
|
|
634
|
+
ownLife(row.content) &&
|
|
635
|
+
this.stays(row, session),
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
// Touches on wishes still present. livingFor first, then another own
|
|
639
|
+
// wish that was actually met. Split wordings of the same wish count once.
|
|
640
|
+
traces({
|
|
641
|
+
session,
|
|
642
|
+
before = Date.now(),
|
|
643
|
+
since = 0,
|
|
644
|
+
inclusive = false,
|
|
645
|
+
limit = 2,
|
|
646
|
+
} = {}) {
|
|
647
|
+
const scoped = session !== undefined;
|
|
648
|
+
const living = this.mind.self.living({
|
|
649
|
+
before,
|
|
650
|
+
now: before,
|
|
651
|
+
...(scoped ? { room: session } : {}),
|
|
652
|
+
});
|
|
653
|
+
const rows = scoped
|
|
654
|
+
? this.ownWishes(session, before)
|
|
655
|
+
: this.mind.self
|
|
656
|
+
.annotated({ before, now: before })
|
|
657
|
+
.filter(
|
|
658
|
+
(row) =>
|
|
659
|
+
row.kind === "intention" && !row.faded && ownLife(row.content),
|
|
660
|
+
);
|
|
661
|
+
const ordered = [
|
|
662
|
+
...(living ? [living] : []),
|
|
663
|
+
...rows.filter((row) => row.thread !== living?.thread),
|
|
664
|
+
];
|
|
665
|
+
const clustered = new Set();
|
|
666
|
+
const out = [];
|
|
667
|
+
for (const wish of ordered) {
|
|
668
|
+
if (clustered.has(wish.thread) || out.length >= limit) continue;
|
|
669
|
+
for (const id of this.#willThreads(wish.thread, before))
|
|
670
|
+
clustered.add(id);
|
|
671
|
+
const hit = this.trace(wish.thread, {
|
|
672
|
+
before,
|
|
673
|
+
since,
|
|
674
|
+
inclusive,
|
|
675
|
+
...(session ? { session } : {}),
|
|
676
|
+
});
|
|
677
|
+
if (!hit.touched) continue;
|
|
678
|
+
out.push({
|
|
679
|
+
thread: wish.thread,
|
|
680
|
+
content: wish.content,
|
|
681
|
+
living: wish.thread === living?.thread,
|
|
682
|
+
...hit,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
return out;
|
|
686
|
+
}
|
|
687
|
+
// The last day this wish was lived: the world met it, she answered, or
|
|
688
|
+
// she sent a planned line about it. Reading only. Silent meetings and
|
|
689
|
+
// unsent drafts do not count as her speaking.
|
|
690
|
+
livedAt(thread, before = Date.now()) {
|
|
691
|
+
if (!thread) return null;
|
|
692
|
+
const wish = this.#wishAt(thread, before);
|
|
693
|
+
if (!wish || wish.status === "closed") return null;
|
|
694
|
+
const threads = this.#willThreads(thread, before);
|
|
695
|
+
const marks = threads.map(() => "?").join(",");
|
|
696
|
+
const hidden = `AND NOT EXISTS (
|
|
697
|
+
SELECT 1 FROM mind_revocations r
|
|
698
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
699
|
+
)`;
|
|
700
|
+
const met = this.db
|
|
701
|
+
.prepare(
|
|
702
|
+
`SELECT created, will_people, sources FROM mind_meetings m
|
|
703
|
+
WHERE will_thread IN (${marks}) AND will_met=1 AND created<=?
|
|
704
|
+
${hidden}
|
|
705
|
+
ORDER BY created DESC`,
|
|
706
|
+
)
|
|
707
|
+
.all(...threads, before);
|
|
708
|
+
let latest = null;
|
|
709
|
+
for (const row of met)
|
|
710
|
+
if (this.#stillMeets(row, wish.content)) {
|
|
711
|
+
latest = row.created;
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
const spoken = this.db
|
|
715
|
+
.prepare(
|
|
716
|
+
`SELECT created, exchange FROM mind_meetings m
|
|
717
|
+
WHERE created<=? AND choice!='silent' AND exchange IS NOT NULL
|
|
718
|
+
AND exchange!='null' ${hidden}
|
|
719
|
+
ORDER BY created DESC`,
|
|
720
|
+
)
|
|
721
|
+
.all(before);
|
|
722
|
+
const keep = (at) => {
|
|
723
|
+
if (at != null && (latest == null || at > latest)) latest = at;
|
|
724
|
+
};
|
|
725
|
+
for (const row of spoken) {
|
|
726
|
+
const exchange = parse(row.exchange, null);
|
|
727
|
+
const said = (exchange?.iSaid || []).join("\n");
|
|
728
|
+
if (said && aboutLife(wish.content, said)) {
|
|
729
|
+
keep(row.created);
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const sent = this.db
|
|
734
|
+
.prepare(
|
|
735
|
+
`SELECT COALESCE(outreach_at, created) AS created, outreach
|
|
736
|
+
FROM mind_thoughts
|
|
737
|
+
WHERE outreach_status='sent' AND hidden=0 AND outreach!=''
|
|
738
|
+
AND COALESCE(outreach_at, created)<=?
|
|
739
|
+
ORDER BY COALESCE(outreach_at, created) DESC`,
|
|
740
|
+
)
|
|
741
|
+
.all(before);
|
|
742
|
+
for (const row of sent)
|
|
743
|
+
if (aboutLife(wish.content, row.outreach)) {
|
|
744
|
+
keep(row.created);
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
return latest;
|
|
748
|
+
}
|
|
749
|
+
// Lines she actually sent in a meeting, newest first. Silent meetings
|
|
750
|
+
// do not count. Used to let livingFor follow what she just said.
|
|
751
|
+
recentSaid(before = Date.now(), limit = 8) {
|
|
752
|
+
const hidden = `AND NOT EXISTS (
|
|
753
|
+
SELECT 1 FROM mind_revocations r
|
|
754
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
755
|
+
)`;
|
|
756
|
+
return this.db
|
|
757
|
+
.prepare(
|
|
758
|
+
`SELECT created, exchange FROM mind_meetings m
|
|
759
|
+
WHERE created<=? AND choice!='silent' AND exchange IS NOT NULL
|
|
760
|
+
AND exchange!='null' ${hidden}
|
|
761
|
+
ORDER BY created DESC LIMIT ?`,
|
|
762
|
+
)
|
|
763
|
+
.all(before, limit)
|
|
764
|
+
.flatMap((row) => {
|
|
765
|
+
const exchange = parse(row.exchange, null);
|
|
766
|
+
const said = (exchange?.iSaid || [])
|
|
767
|
+
.filter((line) => typeof line === "string" && line.trim())
|
|
768
|
+
.join("\n");
|
|
769
|
+
return said ? [{ text: said, at: row.created, spoken: true }] : [];
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
// Already-split wordings of the same wish still count as one life.
|
|
773
|
+
#willThreads(thread, before) {
|
|
774
|
+
const wish = this.#wishAt(thread, before);
|
|
775
|
+
if (!wish) return thread ? [thread] : [];
|
|
776
|
+
const ids = this.mind.self
|
|
777
|
+
.sameThreads(wish, { before })
|
|
778
|
+
.map((row) => row.thread);
|
|
779
|
+
return ids.length ? ids : [thread];
|
|
780
|
+
}
|
|
781
|
+
// The wish as it stood at `before`. A later wording does not inherit touches
|
|
782
|
+
// that only met the old one.
|
|
783
|
+
#wishAt(thread, before) {
|
|
784
|
+
return (
|
|
785
|
+
this.mind.self
|
|
786
|
+
.history(thread)
|
|
787
|
+
.filter((row) => row.created <= before)
|
|
788
|
+
.at(-1) || null
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
#touchStill(row, now) {
|
|
792
|
+
if (!row.will_thread) return false;
|
|
793
|
+
const wish = this.#wishAt(row.will_thread, now);
|
|
794
|
+
if (!wish || wish.status === "closed") return false;
|
|
795
|
+
return this.#stillMeets(row, wish.content);
|
|
796
|
+
}
|
|
797
|
+
// The recorded touch counts only while those people's own words still meet
|
|
798
|
+
// the wish. If the original messages cannot be read, the record stands.
|
|
799
|
+
#stillMeets(row, content) {
|
|
800
|
+
const seqs = parse(row.sources, [])
|
|
801
|
+
.map((id) => Number(id))
|
|
802
|
+
.filter((id) => Number.isSafeInteger(id) && id > 0);
|
|
803
|
+
if (!seqs.length || !content) return true;
|
|
804
|
+
const events = this.db
|
|
805
|
+
.prepare(
|
|
806
|
+
`SELECT role, payload FROM core_events WHERE seq IN (${seqs.map(() => "?").join(",")})`,
|
|
807
|
+
)
|
|
808
|
+
.all(...seqs);
|
|
809
|
+
if (!events.length) return true;
|
|
810
|
+
const bySpeaker = new Map();
|
|
811
|
+
for (const event of events) {
|
|
812
|
+
if (event.role === "assistant") continue;
|
|
813
|
+
const payload = parse(event.payload, {});
|
|
814
|
+
const speaker = String(payload.userId || "");
|
|
815
|
+
if (!speaker) continue;
|
|
816
|
+
const texts = bySpeaker.get(speaker) || [];
|
|
817
|
+
texts.push(payload.text || "");
|
|
818
|
+
bySpeaker.set(speaker, texts);
|
|
819
|
+
}
|
|
820
|
+
const named = parse(row.will_people, []);
|
|
821
|
+
const who = named.length ? named.map(String) : [...bySpeaker.keys()];
|
|
822
|
+
return who.some((id) =>
|
|
823
|
+
meetsLife(content, interestTerms(bySpeaker.get(id) || [])),
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
// Where a cited meeting belongs. A private one cannot be carried elsewhere.
|
|
827
|
+
places(refs) {
|
|
828
|
+
const ids = [
|
|
829
|
+
...new Set(
|
|
830
|
+
(refs || [])
|
|
831
|
+
.filter((ref) => String(ref).startsWith("g:"))
|
|
832
|
+
.map((ref) => String(ref).slice(2)),
|
|
833
|
+
),
|
|
834
|
+
];
|
|
835
|
+
if (!ids.length) return [];
|
|
836
|
+
return this.db
|
|
837
|
+
.prepare(
|
|
838
|
+
`SELECT id, session_id, discretion FROM mind_meetings WHERE id IN (${ids.map(() => "?").join(",")})`,
|
|
839
|
+
)
|
|
840
|
+
.all(...ids);
|
|
841
|
+
}
|
|
842
|
+
// A message that belonged to a meeting she was asked to keep stays with
|
|
843
|
+
// that conversation, even when the later note does not repeat the words.
|
|
844
|
+
#quietSessions(seqs) {
|
|
845
|
+
if (!seqs.length) return [];
|
|
846
|
+
const present = new Map(
|
|
847
|
+
this.db
|
|
848
|
+
.prepare(
|
|
849
|
+
`SELECT seq,session_id,time FROM core_events WHERE seq IN (${seqs.map(() => "?").join(",")})`,
|
|
850
|
+
)
|
|
851
|
+
.all(...seqs)
|
|
852
|
+
.map((row) => [row.seq, row]),
|
|
853
|
+
);
|
|
854
|
+
const requested = new Set(seqs);
|
|
855
|
+
const rooms = [];
|
|
856
|
+
for (const row of this.db
|
|
857
|
+
.prepare(
|
|
858
|
+
"SELECT session_id, sources, created FROM mind_meetings WHERE discretion IN ('private','secret')",
|
|
859
|
+
)
|
|
860
|
+
.all()) {
|
|
861
|
+
const ids = messageSeqs(parse(row.sources, []));
|
|
862
|
+
if (
|
|
863
|
+
ids.some((id) => {
|
|
864
|
+
if (!requested.has(id)) return false;
|
|
865
|
+
const event = present.get(id);
|
|
866
|
+
// A removed source stays private. A reused sequence now belonging to
|
|
867
|
+
// a later event cannot seal that unrelated event into the old room.
|
|
868
|
+
return (
|
|
869
|
+
!event ||
|
|
870
|
+
(event.session_id === row.session_id && event.time <= row.created)
|
|
871
|
+
);
|
|
872
|
+
})
|
|
873
|
+
)
|
|
874
|
+
rooms.push(row.session_id);
|
|
875
|
+
}
|
|
876
|
+
return rooms;
|
|
877
|
+
}
|
|
878
|
+
// Private rooms a citation rests on. Empty means the words are hers, or
|
|
879
|
+
// they came from somewhere she can speak of openly.
|
|
880
|
+
privateRoots(sources, before = Date.now()) {
|
|
881
|
+
const cited = Array.isArray(sources) ? sources : parse(sources, []);
|
|
882
|
+
const refs = [
|
|
883
|
+
...new Set([
|
|
884
|
+
...cited,
|
|
885
|
+
...evidenceRoots(this.db, cited, before, { includeDerived: true }),
|
|
886
|
+
]),
|
|
887
|
+
];
|
|
888
|
+
const rooms = new Set();
|
|
889
|
+
for (const ref of refs.filter((ref) => String(ref).startsWith("a:"))) {
|
|
890
|
+
const plan = this.db
|
|
891
|
+
.prepare(
|
|
892
|
+
"SELECT session_id,discretion FROM mind_anticipations WHERE id=? AND created<=?",
|
|
893
|
+
)
|
|
894
|
+
.get(String(ref).slice(2), before);
|
|
895
|
+
if (plan?.session_id && ["private", "secret"].includes(plan.discretion))
|
|
896
|
+
rooms.add(plan.session_id);
|
|
897
|
+
}
|
|
898
|
+
for (const row of this.places(refs))
|
|
899
|
+
if (row.discretion === "private" || row.discretion === "secret")
|
|
900
|
+
rooms.add(row.session_id);
|
|
901
|
+
const seqs = messageSeqs(refs);
|
|
902
|
+
if (seqs.length) {
|
|
903
|
+
const found = this.db
|
|
904
|
+
.prepare(
|
|
905
|
+
`SELECT DISTINCT session_id FROM core_events WHERE seq IN (${seqs.map(() => "?").join(",")})`,
|
|
906
|
+
)
|
|
907
|
+
.all(...seqs);
|
|
908
|
+
for (const row of found)
|
|
909
|
+
if (isPrivateSession(row.session_id)) rooms.add(row.session_id);
|
|
910
|
+
for (const id of this.#quietSessions(seqs)) rooms.add(id);
|
|
911
|
+
}
|
|
912
|
+
for (const ref of refs) {
|
|
913
|
+
const match = /^d:(\d{4}-\d{2}-\d{2})$/.exec(String(ref));
|
|
914
|
+
if (!match) continue;
|
|
915
|
+
for (const id of this.#privateSessionsOn(match[1], before)) rooms.add(id);
|
|
916
|
+
}
|
|
917
|
+
for (const ref of refs.filter((item) => String(item).startsWith("t:"))) {
|
|
918
|
+
const owned =
|
|
919
|
+
this.mind.thoughts.get(String(ref).slice(2))?.sessions || [];
|
|
920
|
+
const open = owned.filter((id) => !isPrivateSession(id));
|
|
921
|
+
if (!open.length)
|
|
922
|
+
for (const id of owned) if (isPrivateSession(id)) rooms.add(id);
|
|
923
|
+
}
|
|
924
|
+
return [...rooms];
|
|
925
|
+
}
|
|
926
|
+
// A thread stays in a room when its words were not learned in private, or
|
|
927
|
+
// this is the room where they were.
|
|
928
|
+
stays(thread, session, before = Date.now()) {
|
|
929
|
+
const roots = new Set(this.privateRoots(thread?.sources || [], before));
|
|
930
|
+
if (thread?.session_id && isPrivateSession(thread.session_id))
|
|
931
|
+
roots.add(thread.session_id);
|
|
932
|
+
return !roots.size || roots.has(session);
|
|
933
|
+
}
|
|
934
|
+
// Wording she learned in private, or was asked to keep. `exceptSession` is
|
|
935
|
+
// where she may still say it.
|
|
936
|
+
privateSayings(exceptSession = "") {
|
|
937
|
+
const out = [];
|
|
938
|
+
const keep = (content, sessionId) => {
|
|
939
|
+
if (!content || (exceptSession && sessionId === exceptSession)) return;
|
|
940
|
+
out.push({ content, session_id: sessionId || "" });
|
|
941
|
+
};
|
|
942
|
+
for (const discretion of ["private", "secret"])
|
|
943
|
+
for (const row of this.db
|
|
944
|
+
.prepare(
|
|
945
|
+
"SELECT id,content, session_id FROM core_memories WHERE discretion=? AND status='confirmed' ORDER BY updated DESC LIMIT 200",
|
|
946
|
+
)
|
|
947
|
+
.all(discretion))
|
|
948
|
+
if (
|
|
949
|
+
!exceptSession ||
|
|
950
|
+
!this.mind.memory.permission({ ...row, discretion }, exceptSession)
|
|
951
|
+
.disclose
|
|
952
|
+
)
|
|
953
|
+
keep(row.content, row.session_id);
|
|
954
|
+
for (const row of this.db
|
|
955
|
+
.prepare(
|
|
956
|
+
`SELECT appraisal AS content, session_id FROM mind_meetings m
|
|
957
|
+
WHERE discretion IN ('private','secret') AND appraisal!=''
|
|
958
|
+
AND NOT EXISTS (
|
|
959
|
+
SELECT 1 FROM mind_revocations r
|
|
960
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
961
|
+
)
|
|
962
|
+
ORDER BY created DESC LIMIT 200`,
|
|
963
|
+
)
|
|
964
|
+
.all())
|
|
965
|
+
keep(row.content, row.session_id);
|
|
966
|
+
for (const row of this.db
|
|
967
|
+
.prepare(
|
|
968
|
+
"SELECT note AS content, session_id FROM mind_bond_events WHERE note!='' ORDER BY created DESC LIMIT 200",
|
|
969
|
+
)
|
|
970
|
+
.all())
|
|
971
|
+
if (isPrivateSession(row.session_id)) keep(row.content, row.session_id);
|
|
972
|
+
return out;
|
|
973
|
+
}
|
|
974
|
+
// A sentence may be shown here when it does not repeat wording learned elsewhere in private.
|
|
975
|
+
sayable(words, session) {
|
|
976
|
+
const value = String(words || "");
|
|
977
|
+
if (!value) return true;
|
|
978
|
+
return !leaks([value], this.privateSayings(session)).length;
|
|
979
|
+
}
|
|
980
|
+
// A private meeting, a private message, or a privately worded feeling from
|
|
981
|
+
// that life day must not ride along in some other room's diary line. Her
|
|
982
|
+
// own room can still see it. Revoking the meeting releases only the
|
|
983
|
+
// meeting; the words themselves still keep the line where they were said.
|
|
984
|
+
privateBeyond(day, session, before = Date.now()) {
|
|
985
|
+
return this.#privateSessionsOn(day, before).some((id) => id !== session);
|
|
986
|
+
}
|
|
987
|
+
// Private rooms that actually hold words from that life day.
|
|
988
|
+
#privateSessionsOn(day, before) {
|
|
989
|
+
if (!day) return [];
|
|
990
|
+
const nature = this.mind.nature.current(before);
|
|
991
|
+
const zone = this.mind.timeZone();
|
|
992
|
+
const ids = new Set();
|
|
993
|
+
const keep = (sessionId) => {
|
|
994
|
+
if (sessionId && isPrivateSession(sessionId)) ids.add(sessionId);
|
|
995
|
+
};
|
|
996
|
+
for (const row of this.db
|
|
997
|
+
.prepare(
|
|
998
|
+
`SELECT created, session_id FROM mind_meetings m
|
|
999
|
+
WHERE discretion='private' AND appraisal!='' AND created<=?
|
|
1000
|
+
AND NOT EXISTS (
|
|
1001
|
+
SELECT 1 FROM mind_revocations r
|
|
1002
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
1003
|
+
)`,
|
|
1004
|
+
)
|
|
1005
|
+
.all(before))
|
|
1006
|
+
if (lifeDayKey(nature, row.created, zone) === day) keep(row.session_id);
|
|
1007
|
+
const span = lifeSpan(nature, day, zone);
|
|
1008
|
+
if (!span) return [...ids];
|
|
1009
|
+
const end = Math.min(before, span.end - 1);
|
|
1010
|
+
if (span.start > end) return [...ids];
|
|
1011
|
+
const hidden =
|
|
1012
|
+
"(session_id LIKE 'private:%' OR session_id LIKE '%:private:%' OR session_id LIKE '__private__%')";
|
|
1013
|
+
for (const row of this.db
|
|
1014
|
+
.prepare(
|
|
1015
|
+
`SELECT session_id FROM core_events WHERE time>=? AND time<=? AND time<? AND seq NOT IN (SELECT seq FROM mind_unlived) AND ${hidden} LIMIT 20`,
|
|
1016
|
+
)
|
|
1017
|
+
.all(span.start, end, span.end))
|
|
1018
|
+
keep(row.session_id);
|
|
1019
|
+
for (const row of this.db
|
|
1020
|
+
.prepare(
|
|
1021
|
+
`SELECT session_id FROM mind_affect WHERE created>=? AND created<=? AND created<? AND IFNULL(cause,'')!='' AND ${hidden} LIMIT 20`,
|
|
1022
|
+
)
|
|
1023
|
+
.all(span.start, end, span.end))
|
|
1024
|
+
keep(row.session_id);
|
|
1025
|
+
for (const row of this.db
|
|
1026
|
+
.prepare(
|
|
1027
|
+
"SELECT sessions FROM mind_thoughts WHERE created>=? AND created<=? AND hidden=0 LIMIT 20",
|
|
1028
|
+
)
|
|
1029
|
+
.all(span.start, end)) {
|
|
1030
|
+
const sessions = parse(row.sessions, []);
|
|
1031
|
+
if (sessions.some((id) => !isPrivateSession(id))) continue;
|
|
1032
|
+
for (const id of sessions) keep(id);
|
|
1033
|
+
}
|
|
1034
|
+
for (const id of this.mind.anticipations?.privateRooms(day, before) || [])
|
|
1035
|
+
ids.add(id);
|
|
1036
|
+
return [...ids];
|
|
1037
|
+
}
|
|
1038
|
+
withPerson(userId, { before = Date.now(), limit = 8 } = {}) {
|
|
1039
|
+
return this.#open(
|
|
1040
|
+
`m.id IN (SELECT meeting_id FROM mind_meeting_people WHERE user_id=?) AND m.created<=? AND m.appraisal!=''`,
|
|
1041
|
+
[String(userId), before],
|
|
1042
|
+
)
|
|
1043
|
+
.slice(0, limit)
|
|
1044
|
+
.map((row) => ({
|
|
1045
|
+
id: row.id,
|
|
1046
|
+
meant: text(row.appraisal, 120),
|
|
1047
|
+
choice: row.choice,
|
|
1048
|
+
when: elapsedLabel(row.created, before, this.mind.timeZone()),
|
|
1049
|
+
private: row.discretion === "private" || row.discretion === "secret",
|
|
1050
|
+
sessionId: row.session_id,
|
|
1051
|
+
...(row.reason ? { why: text(row.reason, 80) } : {}),
|
|
1052
|
+
}));
|
|
1053
|
+
}
|
|
1054
|
+
latest({ before = Date.now() } = {}) {
|
|
1055
|
+
return (
|
|
1056
|
+
this.db
|
|
1057
|
+
.prepare(
|
|
1058
|
+
`SELECT * FROM mind_meetings m
|
|
1059
|
+
WHERE created<=? AND appraisal!=''
|
|
1060
|
+
AND NOT EXISTS (
|
|
1061
|
+
SELECT 1 FROM mind_revocations r
|
|
1062
|
+
WHERE r.target_kind='meeting' AND r.target_id=m.id
|
|
1063
|
+
)
|
|
1064
|
+
ORDER BY created DESC LIMIT 1`,
|
|
1065
|
+
)
|
|
1066
|
+
.get(before) || null
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
}
|