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.
Files changed (235) hide show
  1. package/.github/SECURITY.md +71 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +250 -0
  4. package/README.md +250 -0
  5. package/bin/luckytri.js +107 -0
  6. package/docs/brand/banner.png +0 -0
  7. package/docs/brand/palette.png +0 -0
  8. package/docs/brand/portrait.png +0 -0
  9. package/docs/en/CHANGELOG.md +113 -0
  10. package/docs/en/architecture.md +152 -0
  11. package/docs/en/chat-style.md +62 -0
  12. package/docs/en/connect.md +63 -0
  13. package/docs/en/guide.md +216 -0
  14. package/docs/en/her-life.md +189 -0
  15. package/docs/en/npm-release.md +25 -0
  16. package/docs/en/plugins.md +82 -0
  17. package/docs/en/recovery.md +28 -0
  18. package/docs/en/time.md +71 -0
  19. package/docs/en/webui.md +73 -0
  20. package/docs/zh/CHANGELOG.md +113 -0
  21. package/docs/zh/architecture.md +152 -0
  22. package/docs/zh/chat-style.md +62 -0
  23. package/docs/zh/connect.md +63 -0
  24. package/docs/zh/guide.md +216 -0
  25. package/docs/zh/her-life.md +189 -0
  26. package/docs/zh/npm-release.md +33 -0
  27. package/docs/zh/plugins.md +82 -0
  28. package/docs/zh/recovery.md +28 -0
  29. package/docs/zh/time.md +71 -0
  30. package/docs/zh/webui.md +73 -0
  31. package/package.json +108 -0
  32. package/plugins/sdk/index.d.ts +155 -0
  33. package/plugins/sdk/testing.js +58 -0
  34. package/plugins/weather/index.js +82 -0
  35. package/plugins/weather/luckytri-plugin.json +29 -0
  36. package/public/app/assets/ChatsPage-C-FcRapR.js +3 -0
  37. package/public/app/assets/ChatsPage-DfyPCS9N.css +1 -0
  38. package/public/app/assets/Empty-CpP5kQHH.js +2 -0
  39. package/public/app/assets/Empty-DmUB2-Bk.css +1 -0
  40. package/public/app/assets/Field-D4gY-INU.js +1 -0
  41. package/public/app/assets/HeartPage-Bft9wZp5.css +1 -0
  42. package/public/app/assets/HeartPage-CPy_thQd.js +1 -0
  43. package/public/app/assets/LifePage-BUi5euBF.css +1 -0
  44. package/public/app/assets/LifePage-CxWvwmSf.js +1 -0
  45. package/public/app/assets/MemoryPage-DC4rhQ68.js +1 -0
  46. package/public/app/assets/MemoryPage-cZvXfkXb.css +1 -0
  47. package/public/app/assets/NaturePage-CTXHCE2D.css +1 -0
  48. package/public/app/assets/NaturePage-IvYw04Zq.js +3 -0
  49. package/public/app/assets/PeoplePage-BmUxkrOU.css +1 -0
  50. package/public/app/assets/PeoplePage-Cqefm9-q.js +1 -0
  51. package/public/app/assets/PluginsPage-DKSaKvXb.js +2 -0
  52. package/public/app/assets/PluginsPage-HyBe5D8w.css +1 -0
  53. package/public/app/assets/SystemPage-CAMPoYqM.css +1 -0
  54. package/public/app/assets/SystemPage-CJmtJkrK.js +1 -0
  55. package/public/app/assets/TimePage-BOms_n4I.css +1 -0
  56. package/public/app/assets/TimePage-Cw44inM4.js +3 -0
  57. package/public/app/assets/formatters-ZmW_xFHw.js +1 -0
  58. package/public/app/assets/index-BheRW-1t.js +4 -0
  59. package/public/app/assets/index-YDz1ree1.css +1 -0
  60. package/public/app/assets/knowledge-DG0e0uKw.js +1 -0
  61. package/public/app/assets/liquid-garden-IMoHBwzc.png +0 -0
  62. package/public/app/assets/luckytri-home-95QKeMtc.png +0 -0
  63. package/public/app/assets/media-BzPDBm7e.js +1 -0
  64. package/public/app/index.html +27 -0
  65. package/public/guide.en.html +1053 -0
  66. package/public/guide.html +893 -0
  67. package/public/plugin-kit/v1/bridge.js +30 -0
  68. package/public/plugin-kit/v1/kit.css +14 -0
  69. package/public/tutorial.en.md +214 -0
  70. package/public/tutorial.md +214 -0
  71. package/scripts/backup.js +311 -0
  72. package/scripts/browser.js +41 -0
  73. package/scripts/launch.js +94 -0
  74. package/scripts/plugin.js +150 -0
  75. package/scripts/proxy-config.js +167 -0
  76. package/scripts/recover.js +26 -0
  77. package/scripts/runtime.js +25 -0
  78. package/scripts/service.js +47 -0
  79. package/scripts/setup.js +50 -0
  80. package/scripts/start-server.js +41 -0
  81. package/scripts/stop.js +36 -0
  82. package/server/app.js +96 -0
  83. package/server/channels/adapters.js +27 -0
  84. package/server/channels/contract.js +57 -0
  85. package/server/channels/hub.js +165 -0
  86. package/server/channels/index.js +25 -0
  87. package/server/channels/onebot/adapter.js +288 -0
  88. package/server/channels/onebot/index.js +336 -0
  89. package/server/channels/onebot/token.js +3 -0
  90. package/server/channels/qqbot/adapter.js +253 -0
  91. package/server/channels/qqbot/api.js +199 -0
  92. package/server/channels/qqbot/credentials.js +11 -0
  93. package/server/channels/qqbot/gateway.js +274 -0
  94. package/server/channels/qqbot/index.js +389 -0
  95. package/server/channels/segments.js +40 -0
  96. package/server/channels/session-key.js +153 -0
  97. package/server/channels/state.js +42 -0
  98. package/server/core/api.js +564 -0
  99. package/server/core/capabilities.js +208 -0
  100. package/server/core/context-builder.js +288 -0
  101. package/server/core/context-compactor.js +454 -0
  102. package/server/core/conversation-cues.js +325 -0
  103. package/server/core/conversation-manager.js +87 -0
  104. package/server/core/events.js +60 -0
  105. package/server/core/feedback.js +8 -0
  106. package/server/core/group-style.js +72 -0
  107. package/server/core/image-preprocessor.js +208 -0
  108. package/server/core/initiative-context.js +66 -0
  109. package/server/core/input-budget.js +32 -0
  110. package/server/core/local-demo.js +98 -0
  111. package/server/core/message-manager.js +118 -0
  112. package/server/core/message-scheduler.js +78 -0
  113. package/server/core/model-json.js +190 -0
  114. package/server/core/model-manager.js +1213 -0
  115. package/server/core/model-presets.js +900 -0
  116. package/server/core/name-call.js +29 -0
  117. package/server/core/net-guard.js +49 -0
  118. package/server/core/network.js +157 -0
  119. package/server/core/orchestrator.js +569 -0
  120. package/server/core/perceivers.js +91 -0
  121. package/server/core/person-name.js +22 -0
  122. package/server/core/persona-manager.js +243 -0
  123. package/server/core/reply-delivery.js +393 -0
  124. package/server/core/reply-target-resolver.js +50 -0
  125. package/server/core/repository.js +293 -0
  126. package/server/core/response-generator.js +50 -0
  127. package/server/core/response-validator.js +508 -0
  128. package/server/core/retired-prompts.js +51 -0
  129. package/server/core/sessions.js +109 -0
  130. package/server/core/speaker-names.js +92 -0
  131. package/server/core/topic-tracker.js +65 -0
  132. package/server/core/turn-processor.js +630 -0
  133. package/server/core/turn.js +236 -0
  134. package/server/core/vision-manager.js +633 -0
  135. package/server/http.js +18 -0
  136. package/server/i18n/decisions.js +206 -0
  137. package/server/i18n/errors.js +497 -0
  138. package/server/i18n/index.js +144 -0
  139. package/server/i18n/labels.js +653 -0
  140. package/server/i18n/models.js +111 -0
  141. package/server/index.js +102 -0
  142. package/server/knowledge/api.js +342 -0
  143. package/server/knowledge/embedding-profile.js +114 -0
  144. package/server/knowledge/index.js +13 -0
  145. package/server/knowledge/manager.js +386 -0
  146. package/server/knowledge/retrieval.js +93 -0
  147. package/server/knowledge/schema.js +154 -0
  148. package/server/knowledge/vector-index.js +90 -0
  149. package/server/mind/affect.js +265 -0
  150. package/server/mind/anticipations.js +507 -0
  151. package/server/mind/api.js +703 -0
  152. package/server/mind/attention.js +200 -0
  153. package/server/mind/bonds.js +558 -0
  154. package/server/mind/budget.js +152 -0
  155. package/server/mind/clock.js +83 -0
  156. package/server/mind/context-selection.js +61 -0
  157. package/server/mind/continuity.js +562 -0
  158. package/server/mind/conversation-origin.js +175 -0
  159. package/server/mind/day-planner.js +324 -0
  160. package/server/mind/days.js +210 -0
  161. package/server/mind/evidence-read.js +45 -0
  162. package/server/mind/evidence.js +148 -0
  163. package/server/mind/faces.js +138 -0
  164. package/server/mind/guard.js +60 -0
  165. package/server/mind/index.js +445 -0
  166. package/server/mind/initiative.js +333 -0
  167. package/server/mind/life/activities.js +21 -0
  168. package/server/mind/life/context.js +309 -0
  169. package/server/mind/life/diary.js +786 -0
  170. package/server/mind/life/growth.js +252 -0
  171. package/server/mind/life/index.js +1049 -0
  172. package/server/mind/life/presence.js +305 -0
  173. package/server/mind/meetings.js +1069 -0
  174. package/server/mind/memory-policy.js +48 -0
  175. package/server/mind/memory.js +888 -0
  176. package/server/mind/nature.js +262 -0
  177. package/server/mind/novelty.js +19 -0
  178. package/server/mind/observations.js +84 -0
  179. package/server/mind/own-day.js +324 -0
  180. package/server/mind/own-voice.js +318 -0
  181. package/server/mind/periods.js +118 -0
  182. package/server/mind/reading.js +120 -0
  183. package/server/mind/salience.js +201 -0
  184. package/server/mind/schema.js +562 -0
  185. package/server/mind/self.js +484 -0
  186. package/server/mind/senses.js +53 -0
  187. package/server/mind/thoughts.js +274 -0
  188. package/server/mind/time/activity-clock.js +196 -0
  189. package/server/mind/time/agenda.js +242 -0
  190. package/server/mind/time/api.js +200 -0
  191. package/server/mind/time/attention.js +34 -0
  192. package/server/mind/time/availability.js +69 -0
  193. package/server/mind/time/commitment-review.js +198 -0
  194. package/server/mind/time/delivery-links.js +130 -0
  195. package/server/mind/time/executor.js +512 -0
  196. package/server/mind/time/experiences.js +128 -0
  197. package/server/mind/time/games.js +562 -0
  198. package/server/mind/time/index.js +739 -0
  199. package/server/mind/time/intent.js +166 -0
  200. package/server/mind/time/kinds.js +171 -0
  201. package/server/mind/time/model-material.js +77 -0
  202. package/server/mind/time/plugin-activity.js +207 -0
  203. package/server/mind/time/presentation.js +51 -0
  204. package/server/mind/time/schedule.js +83 -0
  205. package/server/mind/time/schema.js +84 -0
  206. package/server/mind/time/search.js +383 -0
  207. package/server/mind/time/sharing.js +472 -0
  208. package/server/mind/time/task-links.js +311 -0
  209. package/server/mind/time/tasks.js +587 -0
  210. package/server/mind/time/works.js +432 -0
  211. package/server/mind/traits.js +191 -0
  212. package/server/mind/util.js +110 -0
  213. package/server/mind/view.js +273 -0
  214. package/server/paths.js +102 -0
  215. package/server/plugins/api.js +204 -0
  216. package/server/plugins/host.js +967 -0
  217. package/server/plugins/install.js +295 -0
  218. package/server/plugins/manifest.js +201 -0
  219. package/server/plugins/permissions.js +42 -0
  220. package/server/plugins/runner/guard.mjs +36 -0
  221. package/server/plugins/runner/worker.mjs +250 -0
  222. package/server/plugins/schema.js +91 -0
  223. package/server/plugins/zip.js +112 -0
  224. package/server/startup-lock.js +118 -0
  225. package/server/storage/archive.js +241 -0
  226. package/server/storage/backup-cleanup-worker.js +8 -0
  227. package/server/storage/backup-cleanup.js +414 -0
  228. package/server/storage/backup-lock.js +76 -0
  229. package/server/storage/backup-scheduler.js +131 -0
  230. package/server/storage/store.js +199 -0
  231. package/server/studio/display-names.js +138 -0
  232. package/server/studio/management.js +102 -0
  233. package/server/studio/readiness.js +190 -0
  234. package/server/studio/settings.js +236 -0
  235. package/server/version.js +10 -0
@@ -0,0 +1,484 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { evidenceRoots, externalEvidence } from "./evidence.js";
3
+ import {
4
+ CORE_THREADS,
5
+ THREAD_FADED,
6
+ THREAD_FADING,
7
+ aboutLife,
8
+ anyTouches,
9
+ leftLife,
10
+ cueList,
11
+ echoes,
12
+ ownLife,
13
+ sameSelf,
14
+ threadSalience,
15
+ } from "./salience.js";
16
+ import { claimedPlay, leaks } from "./guard.js";
17
+ import { isPrivateSession } from "./memory.js";
18
+ import {
19
+ clamp,
20
+ dayKey,
21
+ evidence,
22
+ hasCredential,
23
+ messageSeqs,
24
+ parse,
25
+ similar,
26
+ text,
27
+ } from "./util.js";
28
+
29
+ export const SELF_KINDS = {
30
+ interest: "喜欢",
31
+ view: "看法",
32
+ trait: "特质",
33
+ habit: "习惯",
34
+ intention: "想做的事",
35
+ care: "放在心上",
36
+ curiosity: "好奇",
37
+ };
38
+ const STEP = 0.15;
39
+ const NEW_CAP = 0.35;
40
+ const TRAIT_CAP = 0.25;
41
+
42
+ // Fade follows the last version that cited something not cited before.
43
+ // The first version counts even when it has no source.
44
+ function earnedAt(history, roots) {
45
+ if (!history?.length) return null;
46
+ const seen = new Set(roots(history[0].sources || []));
47
+ let at = history[0].created;
48
+ for (const row of history.slice(1)) {
49
+ const sources = roots(row.sources || []);
50
+ if (sources.some((source) => !seen.has(source))) at = row.created;
51
+ for (const source of sources) seen.add(source);
52
+ }
53
+ return at;
54
+ }
55
+ // Likes, questions, and a small wish of her own can begin without a message.
56
+ // Views, traits, habits, and cares still have to point at something that happened.
57
+ const SELF_ORIGINATED = new Set(["interest", "curiosity", "intention"]);
58
+
59
+ export class Self {
60
+ constructor(mind) {
61
+ this.mind = mind;
62
+ this.db = mind.db;
63
+ this.annotation = null;
64
+ }
65
+ revokedThreads() {
66
+ return new Set(
67
+ this.db
68
+ .prepare(
69
+ "SELECT target_id FROM mind_revocations WHERE target_kind='self'",
70
+ )
71
+ .all()
72
+ .map((row) => row.target_id),
73
+ );
74
+ }
75
+ revokedContents() {
76
+ return this.db
77
+ .prepare("SELECT content FROM mind_revocations WHERE target_kind='self'")
78
+ .all()
79
+ .map((row) => row.content)
80
+ .filter(Boolean);
81
+ }
82
+ latest(before = Number.MAX_SAFE_INTEGER) {
83
+ const revoked = this.revokedThreads();
84
+ return this.db
85
+ .prepare(
86
+ `SELECT s.* FROM mind_self s WHERE s.rowid=(
87
+ SELECT t.rowid FROM mind_self t WHERE t.thread=s.thread AND t.created<=?
88
+ ORDER BY t.created DESC, t.rowid DESC LIMIT 1)`,
89
+ )
90
+ .all(before)
91
+ .filter((row) => !revoked.has(row.thread))
92
+ .map((row) => ({
93
+ ...row,
94
+ sources: parse(row.sources, []),
95
+ days: parse(row.days, []),
96
+ }));
97
+ }
98
+ // Every open thread with how present it is at `now`. The strongest few are
99
+ // her core and never fall out of view.
100
+ annotated({ before = Number.MAX_SAFE_INTEGER, now } = {}) {
101
+ const at = now ?? (before < Number.MAX_SAFE_INTEGER ? before : Date.now());
102
+ const mark = this.db
103
+ .prepare(
104
+ "SELECT total_changes() changes, (SELECT data_version FROM pragma_data_version) external",
105
+ )
106
+ .get();
107
+ const key = `${before}:${at}:${mark.changes}:${mark.external}`;
108
+ if (this.annotation?.key === key)
109
+ return structuredClone(this.annotation.rows);
110
+ const lived = this.mind.days.lived(at);
111
+ const versions = new Map();
112
+ for (const row of this.db
113
+ .prepare(
114
+ "SELECT thread, created, sources FROM mind_self WHERE created<=? ORDER BY created, rowid",
115
+ )
116
+ .all(before)) {
117
+ const list = versions.get(row.thread) || [];
118
+ list.push({ created: row.created, sources: parse(row.sources, []) });
119
+ versions.set(row.thread, list);
120
+ }
121
+ const notes = this.#lifeNotes(before);
122
+ const rows = this.latest(before)
123
+ // The record stays, but a claim to have played what she never played is
124
+ // not handed back to her as who she is.
125
+ .filter((row) => row.status !== "closed" && !claimedPlay(row.content))
126
+ .map((row) => {
127
+ // A rewording is not a new meeting with the thread. Fade follows the
128
+ // last version that actually brought new evidence. A later note she
129
+ // wrote to herself, a meeting that met this wish, words she spoke
130
+ // about it, or a line she later sent about it, are also living it.
131
+ let at =
132
+ earnedAt(versions.get(row.thread), (sources) =>
133
+ evidenceRoots(this.db, sources, before),
134
+ ) ?? row.created;
135
+ if (row.kind === "intention" && ownLife(row.content)) {
136
+ for (const note of notes)
137
+ if (
138
+ note.created > at &&
139
+ aboutLife(row.content, note.content) &&
140
+ !leftLife(row.content, note.content)
141
+ )
142
+ at = note.created;
143
+ const metAt = this.mind.meetings.livedAt(row.thread, before);
144
+ if (metAt != null && metAt > at) at = metAt;
145
+ }
146
+ return {
147
+ ...row,
148
+ salience: threadSalience({ ...row, created: at }, lived),
149
+ };
150
+ });
151
+ // One core seat belongs to the wish she is living. Stronger duties
152
+ // keep the remaining seats; they do not fade her own life out of view.
153
+ const reserved = this.#preferLived(
154
+ rows.filter((row) => row.kind === "intention" && ownLife(row.content)),
155
+ at,
156
+ );
157
+ const rest = rows
158
+ .filter((row) => row.thread !== reserved?.thread)
159
+ .sort((a, b) => b.strength - a.strength || b.created - a.created);
160
+ const core = new Set(
161
+ [reserved, ...rest.slice(0, CORE_THREADS - (reserved ? 1 : 0))]
162
+ .filter(Boolean)
163
+ .map((row) => row.thread),
164
+ );
165
+ const ranked = rows
166
+ .map((row) => ({
167
+ ...row,
168
+ core: core.has(row.thread),
169
+ faded: !core.has(row.thread) && row.salience < THREAD_FADED,
170
+ }))
171
+ .sort((a, b) => b.salience - a.salience || b.created - a.created);
172
+ // Several views in one read use the same exact moment, never a later replay.
173
+ // Writes and external database changes invalidate the derived picture.
174
+ this.annotation = { key, rows: ranked };
175
+ return structuredClone(ranked);
176
+ }
177
+ // The one wish she is living. In a room, a stronger wish that does not
178
+ // belong there does not erase the next wish that does. A duty about
179
+ // how she will treat someone stays a thread; it does not take this seat
180
+ // or the core place that keeps this wish from fading first.
181
+ living({ before = Number.MAX_SAFE_INTEGER, now, room } = {}) {
182
+ const at = now ?? (before < Number.MAX_SAFE_INTEGER ? before : Date.now());
183
+ return this.#preferLived(
184
+ this.annotated({ before, now: at }).filter(
185
+ (row) =>
186
+ row.kind === "intention" &&
187
+ !row.faded &&
188
+ ownLife(row.content) &&
189
+ (room === undefined || this.mind.meetings.stays(row, room)),
190
+ ),
191
+ at,
192
+ );
193
+ }
194
+ // Among her own wishes, the one her latest note or spoken line is about.
195
+ // A later send or answer about another wish takes this seat from an
196
+ // earlier note. A line that puts a wish down does not keep that seat;
197
+ // older notes about the put-down wish do not take it back. Planned
198
+ // drafts and silent meetings do not. No such line: the one still most
199
+ // present. Duties never enter this list.
200
+ #preferLived(candidates, now) {
201
+ if (!candidates.length) return null;
202
+ const notes = this.mind.thoughts
203
+ .open({ now, limit: 8 })
204
+ .filter((t) => t.kind === "expression" || t.kind === "unfinished");
205
+ const lines = this.mind.meetings.recentSaid(now, 8);
206
+ for (const note of notes) {
207
+ if (note.outreach_status === "sent" && note.outreach)
208
+ lines.push({
209
+ text: note.outreach,
210
+ at: note.outreach_at ?? note.created,
211
+ spoken: true,
212
+ });
213
+ lines.push({ text: note.content, at: note.created, spoken: false });
214
+ }
215
+ lines.sort((a, b) => b.at - a.at || Number(b.spoken) - Number(a.spoken));
216
+ const dropped = new Set();
217
+ for (const line of lines) {
218
+ for (const row of candidates)
219
+ if (!dropped.has(row.thread) && leftLife(row.content, line.text))
220
+ dropped.add(row.thread);
221
+ const held = candidates.filter(
222
+ (row) =>
223
+ !dropped.has(row.thread) &&
224
+ aboutLife(row.content, line.text) &&
225
+ !leftLife(row.content, line.text),
226
+ );
227
+ if (held.length)
228
+ return [...held].sort(
229
+ (a, b) => b.salience - a.salience || b.created - a.created,
230
+ )[0];
231
+ }
232
+ const remain = candidates.filter((row) => !dropped.has(row.thread));
233
+ if (!remain.length) return null;
234
+ return [...remain].sort(
235
+ (a, b) => b.salience - a.salience || b.created - a.created,
236
+ )[0];
237
+ }
238
+ // Notes she wrote to herself. Hidden ones do not count; resolved ones
239
+ // still do, because she did think them.
240
+ #lifeNotes(before) {
241
+ return this.db
242
+ .prepare(
243
+ `SELECT created, content FROM mind_thoughts
244
+ WHERE created<=? AND kind IN ('expression','unfinished') AND hidden=0`,
245
+ )
246
+ .all(before);
247
+ }
248
+ // Open threads that are the same wish as this one, including itself.
249
+ // History stays split; reading treats them as one life.
250
+ sameThreads(row, { before = Number.MAX_SAFE_INTEGER } = {}) {
251
+ if (!row?.thread) return [];
252
+ const kind = row.kind || "intention";
253
+ const content = row.content || "";
254
+ return this.latest(before).filter(
255
+ (other) =>
256
+ other.status !== "closed" &&
257
+ other.kind === kind &&
258
+ (other.thread === row.thread ||
259
+ (content && sameSelf(other.content, content))),
260
+ );
261
+ }
262
+ active({ before, limit = 40, now } = {}) {
263
+ const ranked = this.annotated({ before, now }).filter((row) => !row.faded);
264
+ const picked = ranked.filter((row) => row.core).slice(0, limit);
265
+ for (const row of ranked) {
266
+ if (picked.length >= limit) break;
267
+ if (!row.core) picked.push(row);
268
+ }
269
+ return picked.sort(
270
+ (a, b) => b.salience - a.salience || b.created - a.created,
271
+ );
272
+ }
273
+ dormant({ before, now } = {}) {
274
+ return this.annotated({ before, now }).filter((row) => row.faded);
275
+ }
276
+ // Faded threads that what is being said right now brings back to mind.
277
+ reminded({ before, now, cue, cues, limit = 2 } = {}) {
278
+ const sets = cueList(cue, cues);
279
+ if (!sets.length) return [];
280
+ return this.dormant({ before, now })
281
+ .filter((row) => anyTouches(row.content, sets))
282
+ .sort((a, b) => b.strength - a.strength)
283
+ .slice(0, limit);
284
+ }
285
+ // Threads slipping out of view, for her to let go of or find again.
286
+ fading({ before, now, limit = 3 } = {}) {
287
+ return this.annotated({ before, now })
288
+ .filter(
289
+ (row) =>
290
+ !row.core && row.salience < THREAD_FADING && row.salience >= 0.04,
291
+ )
292
+ .sort((a, b) => b.strength - a.strength)
293
+ .slice(0, limit);
294
+ }
295
+ history(thread) {
296
+ return this.db
297
+ .prepare("SELECT * FROM mind_self WHERE thread=? ORDER BY created")
298
+ .all(thread)
299
+ .map((row) => ({
300
+ ...row,
301
+ sources: parse(row.sources, []),
302
+ days: parse(row.days, []),
303
+ }));
304
+ }
305
+ days(sources, time, { external = false } = {}) {
306
+ const timeZone = this.mind.timeZone();
307
+ const seqs = messageSeqs(
308
+ external
309
+ ? externalEvidence(this.db, sources, time)
310
+ : evidenceRoots(this.db, sources, time),
311
+ );
312
+ const found = seqs.length
313
+ ? this.db
314
+ .prepare(
315
+ `SELECT time FROM core_events WHERE seq IN (${seqs.map(() => "?").join(",")})`,
316
+ )
317
+ .all(...seqs)
318
+ .map((row) => dayKey(row.time, timeZone))
319
+ : [];
320
+ // Diary days cited in a review count as the days it happened.
321
+ // A diary written later does not make its underlying experience later.
322
+ return [
323
+ ...new Set(
324
+ found.length ? found : external ? [] : [dayKey(time, timeZone)],
325
+ ),
326
+ ];
327
+ }
328
+ propose(
329
+ input,
330
+ { valid = null, origin = "solitude", time = Date.now() } = {},
331
+ ) {
332
+ const kind = SELF_KINDS[input?.kind] ? input.kind : null;
333
+ const content = text(input?.content, 160);
334
+ let action = ["new", "revise", "close"].includes(input?.action)
335
+ ? input.action
336
+ : input?.thread
337
+ ? "revise"
338
+ : "new";
339
+ if (!content && action !== "close") return { rejected: "空内容" };
340
+ if (hasCredential(content)) return { rejected: "疑似凭据" };
341
+ // What she said to fit in is not a life she has lived.
342
+ if (action !== "close" && claimedPlay(content))
343
+ return { rejected: "没有玩过的经历,不能写成她的样子" };
344
+ const cited = evidence(input?.sources);
345
+ const sources = valid ? cited.filter((s) => valid.has(s)) : cited;
346
+ if (cited.length && !sources.length)
347
+ return { rejected: "来源不在本次经历中" };
348
+ // Memory consolidation only records what she herself said. A wish with
349
+ // no message is hers to form in solitude, not for the summarizer to add.
350
+ if (origin === "memory" && !sources.length) return { rejected: "缺少来源" };
351
+ if (
352
+ content &&
353
+ this.revokedContents().some((old) => similar(old, content, 0.7))
354
+ )
355
+ return { rejected: "与撤销过的内容相同" };
356
+ // Faded threads count too: touching one again brings it back instead of
357
+ // growing a second copy of it.
358
+ const current = this.latest(time).filter((row) => row.status !== "closed");
359
+ let prior = null;
360
+ if (action === "new") {
361
+ if (!kind) return { rejected: "类型无效" };
362
+ prior = current.find(
363
+ (row) => row.kind === kind && sameSelf(row.content, content),
364
+ );
365
+ if (prior) action = "revise";
366
+ else if (!sources.length && !SELF_ORIGINATED.has(kind))
367
+ return { rejected: "缺少来源" };
368
+ } else {
369
+ prior = this.latest(time).find((row) => row.thread === input.thread);
370
+ if (!prior) return { rejected: "线索不存在或已撤销" };
371
+ }
372
+ const spent = new Set();
373
+ if (prior)
374
+ for (const row of this.history(prior.thread))
375
+ for (const source of evidenceRoots(this.db, row.sources || [], time))
376
+ spent.add(source);
377
+ let fresh = sources.filter((source) =>
378
+ evidenceRoots(this.db, [source], time).some((root) => !spent.has(root)),
379
+ );
380
+ const lasting = ["trait", "habit"].includes(prior?.kind || kind);
381
+ const freshWords = fresh;
382
+ if (lasting)
383
+ fresh = fresh.filter((source) =>
384
+ externalEvidence(this.db, [source], time).some(
385
+ (root) => !spent.has(root),
386
+ ),
387
+ );
388
+ const spoken = content || prior?.content || "";
389
+ // The same evidence cannot be spent again to make a thread stronger.
390
+ // An unchanged sentence is not a new version. A rewording can stay,
391
+ // at the strength the earlier evidence already earned.
392
+ if (
393
+ prior &&
394
+ action !== "close" &&
395
+ !fresh.length &&
396
+ (spoken === prior.content || origin === "memory")
397
+ )
398
+ return { rejected: "没有新的经历" };
399
+ // Citing her message is not enough. The words kept have to be hers.
400
+ if (
401
+ origin === "memory" &&
402
+ action !== "close" &&
403
+ !echoes(spoken, this.#spoken(freshWords))
404
+ )
405
+ return { rejected: "对不上她说过的话" };
406
+ // A revision that cites nothing new keeps the old provenance. Dropping
407
+ // it would let a privately learned sentence travel into other rooms.
408
+ const kept = [
409
+ ...new Set([
410
+ ...(prior && !fresh.length ? prior.sources || [] : []),
411
+ ...sources,
412
+ ]),
413
+ ];
414
+ const days = [
415
+ ...new Set([
416
+ ...(prior?.days || []),
417
+ ...this.days(kept, time, { external: lasting }),
418
+ ]),
419
+ ].slice(-60);
420
+ let strength;
421
+ let status;
422
+ if (action === "close") {
423
+ strength = prior.strength;
424
+ status = "closed";
425
+ } else if (prior) {
426
+ const wanted = fresh.length
427
+ ? Number.isFinite(Number(input?.strength))
428
+ ? clamp(input.strength)
429
+ : prior.strength + 0.05
430
+ : prior.strength;
431
+ strength = fresh.length
432
+ ? clamp(prior.strength + clamp(wanted - prior.strength, -STEP, STEP))
433
+ : prior.strength;
434
+ status =
435
+ prior.kind === "trait" && (days.length < 2 || strength < 0.35)
436
+ ? "emerging"
437
+ : "active";
438
+ } else {
439
+ strength = Math.min(
440
+ kind === "trait" ? TRAIT_CAP : NEW_CAP,
441
+ clamp(input?.strength ?? 0.25),
442
+ );
443
+ status = kind === "trait" ? "emerging" : "active";
444
+ }
445
+ const id = randomUUID();
446
+ const home = this.#home(spoken, input?.session);
447
+ this.db
448
+ .prepare(
449
+ "INSERT INTO mind_self(id,thread,created,kind,content,strength,status,sources,days,origin,session_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
450
+ )
451
+ .run(
452
+ id,
453
+ prior?.thread || id,
454
+ time,
455
+ prior?.kind || kind,
456
+ spoken,
457
+ Math.round(strength * 100) / 100,
458
+ status,
459
+ JSON.stringify(kept),
460
+ JSON.stringify(days),
461
+ origin,
462
+ home,
463
+ );
464
+ return { id, thread: prior?.thread || id, action };
465
+ }
466
+ // Wording that matches something she learned in private stays in that room,
467
+ // even when she did not cite a source.
468
+ #home(content, requested) {
469
+ const hit = leaks([content], this.mind.meetings.privateSayings())[0];
470
+ if (hit?.session_id) return hit.session_id;
471
+ return isPrivateSession(requested) ? requested : null;
472
+ }
473
+ #spoken(sources) {
474
+ const seqs = messageSeqs(sources);
475
+ if (!seqs.length) return [];
476
+ return this.db
477
+ .prepare(
478
+ `SELECT role, payload FROM core_events WHERE seq IN (${seqs.map(() => "?").join(",")})`,
479
+ )
480
+ .all(...seqs)
481
+ .filter((row) => row.role === "assistant")
482
+ .map((row) => parse(row.payload, {}).text || "");
483
+ }
484
+ }
@@ -0,0 +1,53 @@
1
+ import { text } from "./util.js";
2
+
3
+ const LIMIT = 4;
4
+ const LINE = 80;
5
+
6
+ // What the world is telling her right now: weather, a song, a room.
7
+ // Kept in memory, never written into who she is. A private line stays out of
8
+ // group conversations.
9
+ export class Senses {
10
+ constructor() {
11
+ this.rows = new Map();
12
+ }
13
+ set(pluginId, key, sense, now = Date.now()) {
14
+ const id = `${pluginId}:${String(key || "now").slice(0, 40)}`;
15
+ const line = text(sense?.text, LINE);
16
+ if (!line) {
17
+ this.rows.delete(id);
18
+ return;
19
+ }
20
+ const ttl = Number(sense?.ttlMinutes);
21
+ this.rows.set(id, {
22
+ pluginId,
23
+ text: line,
24
+ discretion: sense?.discretion === "private" ? "private" : "open",
25
+ until:
26
+ Number.isFinite(ttl) && ttl > 0 ? now + Math.min(ttl, 1440) * 60000 : 0,
27
+ });
28
+ }
29
+ clear(pluginId, key) {
30
+ if (key == null) {
31
+ for (const [id, row] of this.rows)
32
+ if (row.pluginId === pluginId) this.rows.delete(id);
33
+ return;
34
+ }
35
+ this.rows.delete(`${pluginId}:${String(key).slice(0, 40)}`);
36
+ }
37
+ clearPlugin(pluginId) {
38
+ this.clear(pluginId, null);
39
+ }
40
+ lines({ kind = "group", now = Date.now() } = {}) {
41
+ const out = [];
42
+ for (const [id, row] of this.rows) {
43
+ if (row.until && row.until <= now) {
44
+ this.rows.delete(id);
45
+ continue;
46
+ }
47
+ if (kind === "group" && row.discretion === "private") continue;
48
+ out.push(row.text);
49
+ if (out.length >= LIMIT) break;
50
+ }
51
+ return out;
52
+ }
53
+ }