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,274 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { evidenceRoots } from "./evidence.js";
3
+ import {
4
+ THOUGHT_FADED,
5
+ anyTouches,
6
+ cueList,
7
+ thoughtSalience,
8
+ } from "./salience.js";
9
+ import { HOUR, clamp, evidence, parse, text } from "./util.js";
10
+
11
+ export const THOUGHT_KINDS = {
12
+ reflection: "后来想到",
13
+ revision: "重新理解",
14
+ unfinished: "仍放在心上",
15
+ reconnection: "久别想起",
16
+ expression: "自己的念头",
17
+ };
18
+
19
+ function row(value) {
20
+ return value
21
+ ? {
22
+ ...value,
23
+ sessions: parse(value.sessions, []),
24
+ sources: parse(value.sources, []),
25
+ outreach_draft: parse(value.outreach_draft, []),
26
+ }
27
+ : null;
28
+ }
29
+
30
+ // Her journal between conversations. A later understanding is a new entry
31
+ // pointing at the old one; the old words stay as they were.
32
+ export class Thoughts {
33
+ constructor(mind) {
34
+ this.mind = mind;
35
+ this.db = mind.db;
36
+ }
37
+ add({
38
+ kind,
39
+ content,
40
+ sessions = [],
41
+ sources = [],
42
+ parentId = null,
43
+ importance = 0.5,
44
+ revisitHours = 0,
45
+ outreach = "",
46
+ outreachSession = null,
47
+ outreachAfterHours,
48
+ outreachReason = "",
49
+ runId = null,
50
+ time = Date.now(),
51
+ }) {
52
+ const id = randomUUID();
53
+ const hours = Number(revisitHours);
54
+ this.db
55
+ .prepare(
56
+ "INSERT INTO mind_thoughts(id,created,kind,content,sessions,sources,parent_id,importance,revisit_at,status,hidden,outreach,outreach_session,outreach_status,run_id,outreach_at,outreach_reason) VALUES (?,?,?,?,?,?,?,?,?,'open',0,?,?,?,?,?,?)",
57
+ )
58
+ .run(
59
+ id,
60
+ time,
61
+ THOUGHT_KINDS[kind] ? kind : "reflection",
62
+ text(content, 600),
63
+ JSON.stringify([...new Set(sessions)].slice(0, 12)),
64
+ JSON.stringify(evidence(sources)),
65
+ parentId || null,
66
+ clamp(importance),
67
+ Number.isFinite(hours) && hours > 0
68
+ ? time + Math.min(8760, Math.max(1, hours)) * HOUR
69
+ : null,
70
+ text(outreach, 120),
71
+ outreach ? outreachSession : null,
72
+ outreach ? "planned" : "none",
73
+ runId,
74
+ outreach
75
+ ? time +
76
+ Math.min(
77
+ 8760,
78
+ Math.max(0, Number(outreachAfterHours ?? revisitHours) || 0),
79
+ ) *
80
+ HOUR
81
+ : null,
82
+ text(outreachReason, 300),
83
+ );
84
+ return id;
85
+ }
86
+ get(id) {
87
+ return row(
88
+ this.db.prepare("SELECT * FROM mind_thoughts WHERE id=?").get(id),
89
+ );
90
+ }
91
+ list({
92
+ before = Number.MAX_SAFE_INTEGER,
93
+ session = "",
94
+ q = "",
95
+ limit = 30,
96
+ hidden = true,
97
+ } = {}) {
98
+ return this.db
99
+ .prepare(
100
+ `SELECT * FROM mind_thoughts WHERE created<?${hidden ? "" : " AND hidden=0"}${session ? " AND sessions LIKE ?" : ""}${q ? " AND content LIKE ?" : ""} ORDER BY created DESC LIMIT ?`,
101
+ )
102
+ .all(
103
+ before,
104
+ ...(session ? [`%${JSON.stringify(session).slice(1, -1)}%`] : []),
105
+ ...(q ? [`%${q}%`] : []),
106
+ limit,
107
+ )
108
+ .map(row);
109
+ }
110
+ // Still on her mind at `now`, ranked by how present each one is; a thought
111
+ // let go of later still counts as open when looking back. A later note
112
+ // that only repeats sources already used does not start this clock over.
113
+ weighed({ now = Date.now(), session = "" } = {}) {
114
+ const lived = this.mind.days.lived(now);
115
+ const notes = this.db
116
+ .prepare(
117
+ "SELECT * FROM mind_thoughts WHERE hidden=0 AND created<=? AND (status='open' OR resolved_at>?) ORDER BY created DESC LIMIT 400",
118
+ )
119
+ .all(now, now)
120
+ .map(row)
121
+ .filter((t) => !session || t.sessions.includes(session));
122
+ const clock = sourceClock(notes, (sources) =>
123
+ evidenceRoots(this.db, sources, now),
124
+ );
125
+ return notes
126
+ .map((t) => {
127
+ const earned = clock.get(t.id) ?? t.created;
128
+ // A revisit brings back the note that actually lived the experience.
129
+ // Rewriting it later and setting another time does not.
130
+ const seen = earned < t.created ? { ...t, revisit_at: null } : t;
131
+ return {
132
+ ...t,
133
+ salience: thoughtSalience({ ...seen, created: earned }, lived, now),
134
+ };
135
+ })
136
+ .sort((a, b) => b.salience - a.salience || b.created - a.created);
137
+ }
138
+ open({ now = Date.now(), limit = 12, session = "" } = {}) {
139
+ return this.weighed({ now, session })
140
+ .filter((t) => t.salience >= THOUGHT_FADED)
141
+ .slice(0, limit);
142
+ }
143
+ // The studio card shows the newest note still in view. Salience still
144
+ // decides which thoughts a conversation carries.
145
+ latest(now = Date.now()) {
146
+ return (
147
+ this.open({ now, limit: 40 }).sort((a, b) => b.created - a.created)[0] ||
148
+ null
149
+ );
150
+ }
151
+ // Faded thoughts from this place that the conversation brings back.
152
+ reminded({ now = Date.now(), cue, cues, session = "", limit = 1 } = {}) {
153
+ const sets = cueList(cue, cues);
154
+ if (!sets.length) return [];
155
+ return this.weighed({ now, session })
156
+ .filter((t) => t.salience < THOUGHT_FADED && anyTouches(t.content, sets))
157
+ .slice(0, limit);
158
+ }
159
+ resolve(id, resolution = "", time = Date.now()) {
160
+ return (
161
+ this.db
162
+ .prepare(
163
+ "UPDATE mind_thoughts SET status='resolved',resolved_at=?,resolution=? WHERE id=? AND status='open'",
164
+ )
165
+ .run(time, text(resolution, 120), id).changes > 0
166
+ );
167
+ }
168
+ update(id, { status, hidden }) {
169
+ const current = this.get(id);
170
+ if (!current) throw Error("手记不存在");
171
+ if (status !== undefined && !["open", "resolved"].includes(status))
172
+ throw Error("状态无效");
173
+ if (hidden !== undefined && typeof hidden !== "boolean")
174
+ throw Error("开关无效");
175
+ const next = status ?? current.status;
176
+ const resolved =
177
+ next === current.status
178
+ ? [current.resolved_at ?? null, current.resolution ?? ""]
179
+ : next === "resolved"
180
+ ? [Date.now(), "在工作台里放下"]
181
+ : [null, ""];
182
+ this.db
183
+ .prepare(
184
+ "UPDATE mind_thoughts SET status=?,hidden=?,resolved_at=?,resolution=? WHERE id=?",
185
+ )
186
+ .run(
187
+ next,
188
+ hidden === undefined ? current.hidden : +hidden,
189
+ ...resolved,
190
+ id,
191
+ );
192
+ }
193
+ remove(id) {
194
+ if (
195
+ this.db.prepare("SELECT 1 FROM mind_thoughts WHERE parent_id=?").get(id)
196
+ )
197
+ throw Error("这条手记有后续修正,请隐藏以保留轨迹");
198
+ this.db.prepare("DELETE FROM mind_thoughts WHERE id=?").run(id);
199
+ }
200
+ setOutreach(id, status, reason = "") {
201
+ this.db
202
+ .prepare(
203
+ "UPDATE mind_thoughts SET outreach_status=?,outreach_wait_reason=?,outreach_retry_at=NULL WHERE id=?",
204
+ )
205
+ .run(status, text(reason, 300), id);
206
+ }
207
+ planOutreach(id, { session, words, reason, time }) {
208
+ const current = this.get(id);
209
+ if (!current || current.outreach_status !== "none") return false;
210
+ const draft = words
211
+ .filter((s) => typeof s === "string" && s.trim())
212
+ .slice(0, 3)
213
+ .map((s) => text(s, 180));
214
+ if (!session || !draft.length) return false;
215
+ this.db
216
+ .prepare(
217
+ "UPDATE mind_thoughts SET sessions=?,outreach=?,outreach_session=?,outreach_status='planned',outreach_at=?,outreach_reason=?,outreach_draft=? WHERE id=? AND outreach_status='none'",
218
+ )
219
+ .run(
220
+ // An independent thought is not owned by its first audience. Keep its
221
+ // origin scope; outreach_session records where she chose to share it.
222
+ JSON.stringify(
223
+ current.kind === "expression"
224
+ ? current.sessions
225
+ : [...new Set([...current.sessions, session])],
226
+ ),
227
+ draft.join("\n"),
228
+ session,
229
+ time,
230
+ text(reason, 300),
231
+ JSON.stringify(draft),
232
+ id,
233
+ );
234
+ return true;
235
+ }
236
+ deferOutreach(id, reason, retryAt) {
237
+ this.db
238
+ .prepare(
239
+ "UPDATE mind_thoughts SET outreach_status='planned',outreach_wait_reason=?,outreach_retry_at=? WHERE id=?",
240
+ )
241
+ .run(text(reason, 300), retryAt, id);
242
+ }
243
+ dueOutreach(now = Date.now()) {
244
+ return this.db
245
+ .prepare(
246
+ "SELECT * FROM mind_thoughts WHERE outreach_status='planned' AND hidden=0 AND status='open' AND outreach!='' AND COALESCE(outreach_at,revisit_at,created)<=? AND COALESCE(outreach_retry_at,0)<=? ORDER BY importance DESC, created LIMIT 5",
247
+ )
248
+ .all(now, now)
249
+ .map(row);
250
+ }
251
+ }
252
+
253
+ // The first time a source was lived. A later note that only repeats those
254
+ // sources keeps that time, so rewriting it does not make it newly present.
255
+ function sourceClock(notes, roots) {
256
+ const earned = new Map();
257
+ const clock = new Map();
258
+ for (const note of [...notes].sort((a, b) => a.created - b.created)) {
259
+ const resolved = roots(note.sources);
260
+ const sources = resolved.length ? resolved : [`t:${note.id}`];
261
+ const fresh = sources.some((source) => !earned.has(source));
262
+ if (fresh) {
263
+ clock.set(note.id, note.created);
264
+ for (const source of sources)
265
+ if (!earned.has(source)) earned.set(source, note.created);
266
+ } else {
267
+ clock.set(
268
+ note.id,
269
+ Math.min(...sources.map((source) => earned.get(source))),
270
+ );
271
+ }
272
+ }
273
+ return clock;
274
+ }
@@ -0,0 +1,196 @@
1
+ import { text } from "../util.js";
2
+ import { activityMinutes } from "./kinds.js";
3
+ const MINUTE = 60000;
4
+ export function activityEstimate(activity, hint, speed = 1.25) {
5
+ const fallback = activityMinutes(activity),
6
+ proposed = Number(hint?.minutes);
7
+ const referenceMinutes =
8
+ Number.isFinite(proposed) && proposed >= 5 && proposed <= 240
9
+ ? proposed
10
+ : fallback[0];
11
+ const pace =
12
+ Number.isFinite(speed) && speed >= 1 && speed <= 2 ? speed : 1.25;
13
+ return {
14
+ referenceMinutes,
15
+ plannedMs: Math.ceil((referenceMinutes * MINUTE) / pace),
16
+ speed: pace,
17
+ basis: text(hint?.basis, 240) || fallback[1],
18
+ scope: "本段",
19
+ chapterMinutes:
20
+ Number.isFinite(hint?.chapterMinutes) &&
21
+ hint.chapterMinutes >= referenceMinutes &&
22
+ hint.chapterMinutes <= 6000
23
+ ? hint.chapterMinutes
24
+ : null,
25
+ };
26
+ }
27
+ export class ActivityClock {
28
+ constructor(time) {
29
+ this.time = time;
30
+ this.db = time.db;
31
+ }
32
+ committed(id) {
33
+ return this.db
34
+ .prepare(
35
+ "SELECT COALESCE(SUM(engaged_ms),0) n FROM mind_time_spans WHERE task_id=?",
36
+ )
37
+ .get(id).n;
38
+ }
39
+ record(span, ms) {
40
+ if (ms > 0)
41
+ this.db
42
+ .prepare(
43
+ "INSERT OR IGNORE INTO mind_time_activity_ticks(span_id,started,ended) VALUES(?,?,?)",
44
+ )
45
+ .run(span.id, span.updated, span.updated + ms);
46
+ }
47
+ today(spanId, start, now) {
48
+ return this.db
49
+ .prepare(
50
+ "SELECT COALESCE(SUM(MAX(0,MIN(ended,?)-MAX(started,?))),0) n FROM mind_time_activity_ticks WHERE span_id=? AND ended>? AND started<?",
51
+ )
52
+ .get(now, start, spanId, start, now).n;
53
+ }
54
+ credit(task, delta) {
55
+ const clock = task.checkpoint?.activityClock;
56
+ if (
57
+ !clock ||
58
+ clock.phase !== "engaged" ||
59
+ task.lease ||
60
+ task.state !== "doing"
61
+ )
62
+ return 0;
63
+ return Math.max(
64
+ 0,
65
+ Math.min(
66
+ delta,
67
+ Math.round(
68
+ clock.plannedMs - (this.committed(task.id) - clock.baselineMs),
69
+ ),
70
+ ),
71
+ );
72
+ }
73
+ elapsed(task, now = this.time.now()) {
74
+ const total = this.committed(task.id),
75
+ span = this.db
76
+ .prepare(
77
+ "SELECT updated FROM mind_time_spans WHERE task_id=? AND ended IS NULL",
78
+ )
79
+ .get(task.id);
80
+ return (
81
+ total +
82
+ (span
83
+ ? this.credit(task, Math.max(0, Math.min(MINUTE, now - span.updated)))
84
+ : 0)
85
+ );
86
+ }
87
+ plan(task, hint, now) {
88
+ const estimate = activityEstimate(
89
+ task.activity,
90
+ hint,
91
+ this.time.settings().paceSpeed,
92
+ );
93
+ if (task.checkpoint.schedule?.chosenAt) {
94
+ const used =
95
+ this.db
96
+ .prepare(
97
+ "SELECT engaged_ms FROM mind_time_spans WHERE task_id=? AND ended IS NULL",
98
+ )
99
+ .get(task.id)?.engaged_ms || 0;
100
+ estimate.plannedMs = Math.min(
101
+ estimate.plannedMs,
102
+ Math.max(MINUTE, this.time.focusMs(task) - used),
103
+ );
104
+ }
105
+ return {
106
+ version: 1,
107
+ phase: "engaged",
108
+ ...estimate,
109
+ baselineMs: this.committed(task.id),
110
+ started: now,
111
+ };
112
+ }
113
+ view(task, now = this.time.now()) {
114
+ const clock = task.checkpoint?.activityClock;
115
+ if (!clock)
116
+ return {
117
+ phase:
118
+ !this.time.fixtureImmediate &&
119
+ this.time.operationElapsed(task.id, now) > 0
120
+ ? "legacy"
121
+ : "unstarted",
122
+ elapsedMs: this.time.fixtureImmediate
123
+ ? this.time.operationElapsed(task.id, now)
124
+ : null,
125
+ plannedMs: null,
126
+ };
127
+ const elapsedMs = this.elapsed(task, now),
128
+ stepMs = Math.max(0, elapsedMs - clock.baselineMs);
129
+ return {
130
+ phase: clock.phase,
131
+ elapsedMs,
132
+ stepMs,
133
+ plannedMs: clock.plannedMs,
134
+ remainingMs: Math.max(0, Math.round(clock.plannedMs - stepMs)),
135
+ progress: clock.plannedMs > 0 ? Math.min(1, stepMs / clock.plannedMs) : 0,
136
+ referenceMinutes: clock.referenceMinutes,
137
+ speed: clock.speed,
138
+ basis: clock.basis,
139
+ chapterMinutes: clock.chapterMinutes,
140
+ };
141
+ }
142
+ remaining(task, now) {
143
+ return this.view(task, now).remainingMs || 0;
144
+ }
145
+ release(task, checkpoint, now) {
146
+ const slot = checkpoint.schedule,
147
+ clock = checkpoint.activityClock;
148
+ if (
149
+ task.activity !== "game" &&
150
+ slot?.chosenAt &&
151
+ clock.phase === "engaged"
152
+ ) {
153
+ const base = Math.max(clock.baselineMs, slot.baselineMs || 0);
154
+ const remainingAtStart = Math.max(
155
+ 0,
156
+ clock.plannedMs - (base - clock.baselineMs),
157
+ );
158
+ const quota = Math.max(
159
+ MINUTE,
160
+ this.time.focusMs(task) - Math.max(0, base - (slot.baselineMs || 0)),
161
+ );
162
+ if (remainingAtStart > quota)
163
+ checkpoint = {
164
+ ...checkpoint,
165
+ activityClock: {
166
+ ...clock,
167
+ originalEstimateMs: clock.originalEstimateMs || clock.plannedMs,
168
+ baselineMs: base,
169
+ plannedMs: quota,
170
+ },
171
+ };
172
+ }
173
+ this.db
174
+ .prepare(
175
+ "UPDATE mind_time_spans SET updated=? WHERE task_id=? AND ended IS NULL",
176
+ )
177
+ .run(now, task.id);
178
+ const rest = this.time.focusMs(task),
179
+ remaining = Math.max(
180
+ 0,
181
+ Math.round(
182
+ checkpoint.activityClock.plannedMs -
183
+ (this.committed(task.id) - checkpoint.activityClock.baselineMs),
184
+ ),
185
+ );
186
+ this.db
187
+ .prepare(
188
+ "UPDATE mind_time_tasks SET checkpoint=?,next_step=?,lease=NULL,lease_at=NULL,revision=revision+1 WHERE id=?",
189
+ )
190
+ .run(
191
+ JSON.stringify(checkpoint),
192
+ now + Math.min(rest, remaining),
193
+ task.id,
194
+ );
195
+ }
196
+ }
@@ -0,0 +1,242 @@
1
+ import { dayKey, zonedTime, parse } from "../util.js";
2
+ import { activityPresentation } from "./presentation.js";
3
+ import { executionBlock } from "./availability.js";
4
+
5
+ // A view of saved choices and actual spans. Reading it never makes a plan.
6
+ export class Agenda {
7
+ constructor(time) {
8
+ this.time = time;
9
+ this.db = time.db;
10
+ }
11
+ blocked(task, now) {
12
+ if (task.state === "paused" && task.next_step >= Number.MAX_SAFE_INTEGER)
13
+ return task.wait_reason || "自己选择暂停,等待重新安排";
14
+ return executionBlock(this.time, task, now);
15
+ }
16
+ view(now = this.time.now()) {
17
+ const time = this.time,
18
+ zone = time.mind.timeZone(),
19
+ date = dayKey(now, zone);
20
+ const nextDate = new Date(Date.parse(date + "T12:00:00Z") + 86400000)
21
+ .toISOString()
22
+ .slice(0, 10);
23
+ const start = zonedTime(date, zone),
24
+ end = zonedTime(nextDate, zone),
25
+ today = time.today(now),
26
+ primary = time.primary();
27
+ const actual = today.spans
28
+ .map((s) => {
29
+ const rename = this.db
30
+ .prepare(
31
+ "SELECT data FROM mind_time_events WHERE task_id=? AND kind='self-scheduled' AND created>=? ORDER BY created,id LIMIT 1",
32
+ )
33
+ .get(s.task_id, s.started);
34
+ const title = parse(rename?.data, {}).previousTitle || s.title;
35
+ return {
36
+ id: s.id,
37
+ taskId: s.task_id,
38
+ title,
39
+ activity: s.activity,
40
+ start: Math.max(start, s.started),
41
+ end: Math.min(now, s.ended ?? s.updated),
42
+ engagedMs: s.clocked || time.fixtureImmediate ? s.todayMs : null,
43
+ ongoing: !s.ended && primary?.id === s.task_id,
44
+ kind: "actual",
45
+ label:
46
+ !s.ended && primary?.id === s.task_id
47
+ ? primary.checkpoint.activityClock?.phase === "preparing"
48
+ ? "准备中"
49
+ : "正在做"
50
+ : s.clocked && !s.todayMs
51
+ ? "准备时段"
52
+ : "已发生",
53
+ };
54
+ })
55
+ .filter((s) => s.end > s.start);
56
+ const planned = [],
57
+ waiting = [],
58
+ unarranged = [],
59
+ paused = [];
60
+ const rows = this.db
61
+ .prepare(
62
+ "SELECT id FROM mind_time_tasks WHERE created<=? AND state IN ('todo','scheduled','doing','paused','waiting') AND json_extract(checkpoint,'$.mergedInto') IS NULL ORDER BY priority DESC,created LIMIT 100",
63
+ )
64
+ .all(now);
65
+ for (const row of rows) {
66
+ const task = time.tasks.get(row.id),
67
+ view = activityPresentation(task),
68
+ slot = task.checkpoint.schedule;
69
+ const why = this.blocked(task, now);
70
+ if (task.state === "paused") {
71
+ if (paused.length < 12)
72
+ paused.push({
73
+ id: task.id,
74
+ title: view.title,
75
+ reason: task.wait_reason || "暂时停下来,进度已保存",
76
+ });
77
+ continue;
78
+ }
79
+ if (why || task.state === "waiting") {
80
+ if (waiting.length < 12)
81
+ waiting.push({
82
+ id: task.id,
83
+ title: view.title,
84
+ reason: why || task.wait_reason,
85
+ retryAt:
86
+ task.next_step < Number.MAX_SAFE_INTEGER ? task.next_step : null,
87
+ });
88
+ continue;
89
+ }
90
+ if (
91
+ !slot?.chosenAt ||
92
+ task.checkpoint.reschedule ||
93
+ (slot.endedAt <= now && task.state !== "doing")
94
+ ) {
95
+ if (unarranged.length < 12)
96
+ unarranged.push({
97
+ id: task.id,
98
+ title: view.title,
99
+ reason: slot?.chosenAt
100
+ ? "原安排已过去,等她重新选时间"
101
+ : "事情已经记下,等她选择几点做、做多久",
102
+ });
103
+ continue;
104
+ }
105
+ if (slot.proposedAt >= end || slot.endedAt <= start) {
106
+ if (unarranged.length < 12)
107
+ unarranged.push({
108
+ id: task.id,
109
+ title: view.title,
110
+ reason: "安排在今天之外",
111
+ startAt: slot.proposedAt,
112
+ });
113
+ continue;
114
+ }
115
+ planned.push({
116
+ id: "slot:" + task.id,
117
+ kind: "activity",
118
+ taskId: task.id,
119
+ title: view.title,
120
+ activity: task.activity,
121
+ kindOfTask: task.kind,
122
+ priority: task.priority,
123
+ start: Math.max(start, slot.proposedAt),
124
+ end: Math.min(end, slot.endedAt),
125
+ chosenStart: slot.proposedAt,
126
+ chosenEnd: slot.endedAt,
127
+ durationMinutes: slot.durationMinutes,
128
+ why: slot.reason || view.why,
129
+ label:
130
+ task.state === "doing"
131
+ ? "正在执行这份安排"
132
+ : slot.chosenBy === "admin"
133
+ ? "管理台安排"
134
+ : "她选定的安排",
135
+ estimated: false,
136
+ scope: "这次投入",
137
+ state: task.state,
138
+ });
139
+ }
140
+ const rhythm = time.mind.nature.current(now).rhythm;
141
+ if (rhythm?.enabled) {
142
+ const bed = zonedTime(date + " " + rhythm.sleep, zone),
143
+ wake = zonedTime(date + " " + rhythm.wake, zone);
144
+ for (const [a, b] of bed < wake
145
+ ? [[bed, wake]]
146
+ : [
147
+ [start, wake],
148
+ [bed, end],
149
+ ])
150
+ if (b > now)
151
+ planned.push({
152
+ id: "sleep:" + a,
153
+ kind: "sleep",
154
+ title: "作息中的睡眠时间",
155
+ start: Math.max(now, a),
156
+ end: b,
157
+ });
158
+ }
159
+ for (const block of planned.filter((b) => b.taskId)) {
160
+ const urgent = planned
161
+ .filter(
162
+ (b) =>
163
+ b.taskId &&
164
+ b.taskId !== block.taskId &&
165
+ b.priority > block.priority &&
166
+ b.start < block.end &&
167
+ b.end > block.start,
168
+ )
169
+ .sort((a, b) => a.start - b.start)[0];
170
+ const bedtime = planned
171
+ .filter(
172
+ (b) =>
173
+ b.kind === "sleep" && b.start < block.end && b.end > block.start,
174
+ )
175
+ .sort((a, b) => a.start - b.start)[0];
176
+ const stop = Math.min(
177
+ urgent ? Math.max(block.start, urgent.start) : block.end,
178
+ bedtime ? Math.max(block.start, bedtime.start) : block.end,
179
+ );
180
+ block.chartEnd = stop;
181
+ if (stop < block.end) {
182
+ block.interruptedAt = stop;
183
+ block.interruption =
184
+ bedtime && stop === bedtime.start ? "sleep" : "priority";
185
+ }
186
+ }
187
+ planned.sort(
188
+ (a, b) => a.start - b.start || (b.priority || 0) - (a.priority || 0),
189
+ );
190
+ const next =
191
+ planned
192
+ .filter((b) => b.taskId && b.state !== "doing" && b.chosenStart > now)
193
+ .sort((a, b) => a.chosenStart - b.chosenStart)[0] || null;
194
+ const life = time.search.life,
195
+ planning = life?.planner?.working || life?.ownDay?.working,
196
+ phase = life?.phase(now);
197
+ const idle = {
198
+ title: primary
199
+ ? "有主活动在继续"
200
+ : planning
201
+ ? "正在想自己的安排"
202
+ : phase?.key === "asleep"
203
+ ? "在睡觉"
204
+ : next
205
+ ? "这会儿还没有主活动"
206
+ : "这会儿没有正在推进的主活动",
207
+ reason: primary
208
+ ? "普通交流可以伴随进行"
209
+ : planning
210
+ ? "把想做的事、开始时间和投入时长想清楚"
211
+ : phase?.key === "asleep"
212
+ ? "醒来后再接着自己的安排"
213
+ : next
214
+ ? "下一项已经选好时间,之前可以聊天、歇着或随手折腾"
215
+ : paused.length
216
+ ? "有活动保留了续接位置,之后由她重新安排"
217
+ : unarranged.length
218
+ ? "待办已经记下,等她选择具体时间"
219
+ : "可以自己找件小事做,也可以选择歇着",
220
+ next,
221
+ };
222
+ return {
223
+ date,
224
+ zone,
225
+ start,
226
+ end,
227
+ now,
228
+ actual,
229
+ planned,
230
+ waiting,
231
+ unarranged,
232
+ paused,
233
+ idle,
234
+ interactions: today.interactions.map((p) => ({
235
+ id: p.id,
236
+ at: p.created,
237
+ taskId: p.task_id,
238
+ })),
239
+ note: "下方是她已选定的开始时间和这次想投入的时长。实际经过单独记录;提前完成、暂停或优先级切换后会保留原安排并重新选择。空白不代表已经在做什么。",
240
+ };
241
+ }
242
+ }