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,558 @@
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),last_seen=MAX(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
+ seenAt: seen || null,
454
+ awayDays: seen ? Math.max(0, Math.floor((now - seen) / DAY)) : null,
455
+ };
456
+ const notes = this.#shownNotes(merged, room);
457
+ const described = { ...merged, ...notes };
458
+ delete described.notes;
459
+ if (described.lastTalkedAt == null && described.firstMetAt != null)
460
+ described.unspokenDays = Math.max(
461
+ 0,
462
+ Math.floor((now - described.firstMetAt) / DAY),
463
+ );
464
+ const feel = describeBond(described);
465
+ delete described.unspokenDays;
466
+ return {
467
+ userId: String(userId),
468
+ name: known?.name || String(userId),
469
+ sessions: parse(known?.sessions, []),
470
+ lastSeen: known?.last_seen || null,
471
+ ...described,
472
+ feel,
473
+ };
474
+ }
475
+ group(session, now = Date.now()) {
476
+ const state = this.state("group", session, now);
477
+ if (!state) return null;
478
+ const { notes: _notes, ...rest } = state;
479
+ return { session, ...rest, feel: describeGroup(rest) };
480
+ }
481
+ people({ now = Date.now(), limit = 200 } = {}) {
482
+ return this.db
483
+ .prepare(
484
+ "SELECT user_id FROM mind_people ORDER BY last_seen DESC LIMIT ?",
485
+ )
486
+ .all(limit)
487
+ .map((row) => this.person(row.user_id, now))
488
+ .filter(Boolean);
489
+ }
490
+ // People she has grown close to and not heard from in a while.
491
+ missing({ now = Date.now(), limit = 3, days = 7, closeness = 0.35 } = {}) {
492
+ return this.db
493
+ .prepare(
494
+ "SELECT user_id FROM mind_people WHERE first_seen<=? ORDER BY last_seen DESC LIMIT 300",
495
+ )
496
+ .all(now)
497
+ .map((row) => this.person(row.user_id, now))
498
+ .filter((p) => p && p.closeness >= closeness && (p.awayDays ?? 0) >= days)
499
+ .sort((a, b) => b.closeness - a.closeness)
500
+ .slice(0, limit);
501
+ }
502
+ // Close people she still sees, and has not talked with for a while.
503
+ // Being around is not the same as having talked. Never having talked
504
+ // still counts: the time starts from when she first met them.
505
+ quiet({ now = Date.now(), limit = 3, days = 7, closeness = 0.35 } = {}) {
506
+ return this.db
507
+ .prepare(
508
+ "SELECT user_id FROM mind_people WHERE first_seen<=? ORDER BY last_seen DESC LIMIT 300",
509
+ )
510
+ .all(now)
511
+ .map((row) => this.person(row.user_id, now))
512
+ .filter((p) => {
513
+ if (!p || p.closeness < closeness || (p.awayDays ?? 0) >= 3)
514
+ return false;
515
+ const silentFor =
516
+ p.absentDays ??
517
+ (p.firstMetAt != null
518
+ ? Math.max(0, Math.floor((now - p.firstMetAt) / DAY))
519
+ : 0);
520
+ return silentFor >= days;
521
+ })
522
+ .sort((a, b) => {
523
+ const span = (p) =>
524
+ p.absentDays ??
525
+ (p.firstMetAt != null ? Math.floor((now - p.firstMetAt) / DAY) : 0);
526
+ return span(b) - span(a);
527
+ })
528
+ .slice(0, limit);
529
+ }
530
+ groups(now = Date.now()) {
531
+ return this.db
532
+ .prepare(
533
+ "SELECT DISTINCT subject_id FROM mind_bond_events WHERE subject_kind='group'",
534
+ )
535
+ .all()
536
+ .map((row) => this.group(row.subject_id, now))
537
+ .filter(Boolean);
538
+ }
539
+ changes(kind, id, limit = 40) {
540
+ return this.db
541
+ .prepare(
542
+ "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 ?",
543
+ )
544
+ .all(kind, String(id), limit)
545
+ .map((row) => ({
546
+ ...row,
547
+ revoked: !!row.revoked,
548
+ sources: JSON.parse(row.sources),
549
+ }));
550
+ }
551
+ name(userId) {
552
+ return (
553
+ this.db
554
+ .prepare("SELECT name FROM mind_people WHERE user_id=?")
555
+ .get(String(userId))?.name || ""
556
+ );
557
+ }
558
+ }
@@ -0,0 +1,152 @@
1
+ import { DAY, clamp } from "./util.js";
2
+
3
+ // Talking to people always comes first; thinking alone and bookkeeping live
4
+ // on a share of the same daily allowance.
5
+ export const STAGE_CATEGORY = {
6
+ turn: "conversation",
7
+ rewrite: "conversation",
8
+ generation: "conversation",
9
+ validation: "conversation",
10
+ vision: "conversation",
11
+ decision: "conversation",
12
+ reflection: "inner",
13
+ expression: "inner",
14
+ daily: "inner",
15
+ weekly: "inner",
16
+ memory: "upkeep",
17
+ summary: "upkeep",
18
+ plugin: "plugins",
19
+ };
20
+ export const BUDGET_DEFAULTS = {
21
+ dailyTokens: 0,
22
+ innerShare: 0.25,
23
+ upkeepShare: 0.15,
24
+ pluginShare: 0.1,
25
+ };
26
+
27
+ const estimate = (value) =>
28
+ Math.ceil(Buffer.byteLength(String(value || ""), "utf8") / 2);
29
+
30
+ export function recordUsage(db, trace) {
31
+ const insert = db.prepare(
32
+ "INSERT INTO mind_usage(time,category,stage,session_id,input,cached,output,estimated) VALUES (?,?,?,?,?,?,?,?)",
33
+ );
34
+ for (const call of trace?.calls || []) {
35
+ if (call.ledgered || !STAGE_CATEGORY[call.stage]) continue;
36
+ call.ledgered = true;
37
+ const tokens = call.tokens;
38
+ insert.run(
39
+ call.started || Date.now(),
40
+ STAGE_CATEGORY[call.stage],
41
+ call.stage,
42
+ trace.sessionId || null,
43
+ tokens ? tokens.input : Number(call.inputEstimate) || 0,
44
+ tokens ? tokens.cachedRead : 0,
45
+ tokens ? tokens.output : estimate(call.raw),
46
+ tokens ? 0 : 1,
47
+ );
48
+ }
49
+ }
50
+
51
+ export class Budget {
52
+ constructor(repo) {
53
+ this.repo = repo;
54
+ this.db = repo.db;
55
+ }
56
+ settings() {
57
+ return { ...BUDGET_DEFAULTS, ...this.repo.config("budget", {}) };
58
+ }
59
+ save(value) {
60
+ const next = { ...this.settings(), ...value };
61
+ if (
62
+ !Number.isInteger(next.dailyTokens) ||
63
+ next.dailyTokens < 0 ||
64
+ next.dailyTokens > 1e10
65
+ )
66
+ throw Error("每日 Token 上限无效");
67
+ for (const key of ["innerShare", "upkeepShare", "pluginShare"])
68
+ if (!Number.isFinite(next[key]) || next[key] < 0 || next[key] > 0.9)
69
+ throw Error("预算份额应在 0–0.9");
70
+ if (next.innerShare + next.upkeepShare + next.pluginShare > 0.95)
71
+ throw Error("后台份额之和不能超过 95%");
72
+ this.repo.saveConfig(
73
+ "budget",
74
+ Object.fromEntries(Object.keys(BUDGET_DEFAULTS).map((k) => [k, next[k]])),
75
+ );
76
+ return this.settings();
77
+ }
78
+ usage(now = Date.now()) {
79
+ const rows = this.db
80
+ .prepare(
81
+ "SELECT category,stage,COUNT(*) calls,SUM(input) input,SUM(cached) cached,SUM(output) output FROM mind_usage WHERE time>? AND time<=? GROUP BY category,stage",
82
+ )
83
+ .all(now - DAY, now);
84
+ const out = {
85
+ total: 0,
86
+ calls: 0,
87
+ cached: 0,
88
+ conversation: 0,
89
+ inner: 0,
90
+ upkeep: 0,
91
+ plugins: 0,
92
+ stages: {},
93
+ };
94
+ for (const row of rows) {
95
+ const tokens = Number(row.input || 0) + Number(row.output || 0);
96
+ out.total += tokens;
97
+ out.calls += row.calls;
98
+ out.cached += Number(row.cached || 0);
99
+ out[row.category] = (out[row.category] || 0) + tokens;
100
+ out.stages[row.stage] = {
101
+ calls: row.calls,
102
+ input: Number(row.input || 0),
103
+ cached: Number(row.cached || 0),
104
+ output: Number(row.output || 0),
105
+ };
106
+ }
107
+ return out;
108
+ }
109
+ limits() {
110
+ const s = this.settings();
111
+ if (!s.dailyTokens)
112
+ return { total: 0, conversation: 0, inner: 0, upkeep: 0, plugins: 0 };
113
+ return {
114
+ total: s.dailyTokens,
115
+ conversation: s.dailyTokens,
116
+ inner: Math.floor(s.dailyTokens * s.innerShare),
117
+ upkeep: Math.floor(s.dailyTokens * s.upkeepShare),
118
+ plugins: Math.floor(s.dailyTokens * s.pluginShare),
119
+ };
120
+ }
121
+ // 0 means unlimited or idle; 1 means the allowance is spent.
122
+ pressure(category = "conversation", now = Date.now()) {
123
+ const limit = this.limits()[category];
124
+ if (!limit) return 0;
125
+ const used =
126
+ category === "conversation"
127
+ ? this.usage(now).total
128
+ : this.usage(now)[category];
129
+ return clamp(used / limit, 0, 2);
130
+ }
131
+ // The daily cap bounds everything; each background share bounds itself.
132
+ allows(category, now = Date.now()) {
133
+ return (
134
+ this.pressure(category, now) < 1 && this.pressure("conversation", now) < 1
135
+ );
136
+ }
137
+ report(now = Date.now()) {
138
+ const usage = this.usage(now);
139
+ const limits = this.limits();
140
+ return {
141
+ settings: this.settings(),
142
+ usage,
143
+ limits,
144
+ pressure: {
145
+ conversation: this.pressure("conversation", now),
146
+ inner: this.pressure("inner", now),
147
+ upkeep: this.pressure("upkeep", now),
148
+ plugins: this.pressure("plugins", now),
149
+ },
150
+ };
151
+ }
152
+ }