luckytri 1.0.0 → 1.0.2

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 (139) hide show
  1. package/.github/SECURITY.md +4 -4
  2. package/README.en.md +252 -250
  3. package/README.md +252 -250
  4. package/bin/luckytri.js +5 -1
  5. package/docs/en/CHANGELOG.md +8 -0
  6. package/docs/en/connect.md +18 -18
  7. package/docs/en/guide.md +202 -216
  8. package/docs/en/npm-release.md +25 -25
  9. package/docs/zh/CHANGELOG.md +8 -0
  10. package/docs/zh/connect.md +17 -17
  11. package/docs/zh/guide.md +202 -216
  12. package/docs/zh/npm-release.md +33 -33
  13. package/package.json +110 -108
  14. package/plugins/sdk/index.d.ts +155 -155
  15. package/plugins/sdk/testing.js +58 -58
  16. package/plugins/weather/index.js +82 -82
  17. package/plugins/weather/luckytri-plugin.json +29 -29
  18. package/public/app/assets/ChatsPage-BhlJHseG.js +3 -0
  19. package/public/app/assets/Empty-DP6WMGHi.js +2 -0
  20. package/public/app/assets/Field-CtbdqaiB.js +1 -0
  21. package/public/app/assets/HeartPage-32BeTPn2.js +1 -0
  22. package/public/app/assets/LifePage-CxSm0XKI.js +1 -0
  23. package/public/app/assets/MemoryPage-BeKBcPCY.js +1 -0
  24. package/public/app/assets/NaturePage-NEPYl2HS.js +3 -0
  25. package/public/app/assets/PeoplePage-DxtpekwM.js +1 -0
  26. package/public/app/assets/PluginsPage-CVVQwEBI.css +1 -0
  27. package/public/app/assets/PluginsPage-DksEZbUU.js +2 -0
  28. package/public/app/assets/{SystemPage-CAMPoYqM.css → SystemPage-BkRu0Rvx.css} +1 -1
  29. package/public/app/assets/SystemPage-BvzJlHuY.js +1 -0
  30. package/public/app/assets/TimePage-7-awH5XL.js +3 -0
  31. package/public/app/assets/index-4rItIkD-.js +4 -0
  32. package/public/app/assets/{index-YDz1ree1.css → index-DY4e15Kh.css} +1 -1
  33. package/public/app/assets/{knowledge-DG0e0uKw.js → knowledge-C6_HvoyB.js} +1 -1
  34. package/public/app/assets/media-SMfTT-yc.js +1 -0
  35. package/public/app/index.html +3 -3
  36. package/public/guide.en.html +72 -99
  37. package/public/guide.html +44 -86
  38. package/public/tutorial.en.md +25 -39
  39. package/public/tutorial.md +25 -39
  40. package/scripts/backup.js +311 -311
  41. package/scripts/browser.js +41 -41
  42. package/scripts/import-data.js +128 -0
  43. package/scripts/launch.js +101 -94
  44. package/scripts/plugin.js +150 -150
  45. package/scripts/reset-password.js +22 -0
  46. package/scripts/runtime.js +25 -25
  47. package/scripts/service.js +56 -47
  48. package/scripts/setup.js +50 -50
  49. package/scripts/start-server.js +41 -41
  50. package/scripts/stop.js +36 -36
  51. package/server/app.js +112 -96
  52. package/server/auth.js +217 -0
  53. package/server/channels/contract.js +57 -57
  54. package/server/channels/hub.js +165 -165
  55. package/server/channels/index.js +28 -25
  56. package/server/channels/onebot/index.js +335 -336
  57. package/server/channels/onebot/token.js +22 -3
  58. package/server/core/capabilities.js +208 -208
  59. package/server/core/events.js +64 -60
  60. package/server/core/image-preprocessor.js +208 -208
  61. package/server/core/model-manager.js +1213 -1213
  62. package/server/core/net-guard.js +49 -49
  63. package/server/core/orchestrator.js +569 -569
  64. package/server/core/perceivers.js +91 -91
  65. package/server/core/persona-manager.js +243 -243
  66. package/server/core/repository.js +293 -293
  67. package/server/core/response-validator.js +508 -508
  68. package/server/core/turn-processor.js +630 -630
  69. package/server/core/turn.js +236 -236
  70. package/server/core/vision-manager.js +633 -633
  71. package/server/i18n/errors.js +548 -497
  72. package/server/i18n/labels.js +649 -653
  73. package/server/index.js +151 -102
  74. package/server/knowledge/manager.js +386 -386
  75. package/server/knowledge/schema.js +12 -8
  76. package/server/local-access.js +32 -0
  77. package/server/mind/anticipations.js +507 -507
  78. package/server/mind/api.js +703 -703
  79. package/server/mind/budget.js +152 -152
  80. package/server/mind/context-selection.js +61 -61
  81. package/server/mind/day-planner.js +324 -324
  82. package/server/mind/evidence.js +148 -148
  83. package/server/mind/index.js +445 -445
  84. package/server/mind/life/diary.js +786 -786
  85. package/server/mind/life/index.js +1049 -1049
  86. package/server/mind/observations.js +84 -84
  87. package/server/mind/own-day.js +324 -324
  88. package/server/mind/own-voice.js +318 -318
  89. package/server/mind/senses.js +53 -53
  90. package/server/mind/time/activity-clock.js +196 -196
  91. package/server/mind/time/availability.js +69 -69
  92. package/server/mind/time/commitment-review.js +198 -198
  93. package/server/mind/time/executor.js +512 -512
  94. package/server/mind/time/experiences.js +128 -128
  95. package/server/mind/time/games.js +562 -562
  96. package/server/mind/time/index.js +739 -739
  97. package/server/mind/time/intent.js +166 -166
  98. package/server/mind/time/kinds.js +171 -171
  99. package/server/mind/time/plugin-activity.js +207 -207
  100. package/server/mind/time/tasks.js +587 -587
  101. package/server/mind/util.js +110 -110
  102. package/server/mind/view.js +273 -273
  103. package/server/paths.js +102 -102
  104. package/server/plugins/api.js +204 -204
  105. package/server/plugins/host.js +967 -967
  106. package/server/plugins/install.js +295 -295
  107. package/server/plugins/manifest.js +201 -201
  108. package/server/plugins/permissions.js +42 -42
  109. package/server/plugins/runner/guard.mjs +36 -36
  110. package/server/plugins/runner/worker.mjs +250 -250
  111. package/server/plugins/schema.js +91 -91
  112. package/server/plugins/zip.js +112 -112
  113. package/server/startup-lock.js +118 -118
  114. package/server/storage/archive.js +242 -241
  115. package/server/storage/backup-cleanup-worker.js +8 -8
  116. package/server/storage/backup-cleanup.js +415 -414
  117. package/server/storage/backup-lock.js +76 -76
  118. package/server/storage/backup-scheduler.js +131 -131
  119. package/server/storage/data-package.js +298 -0
  120. package/server/storage/data-transfer-api.js +50 -0
  121. package/server/storage/data-transfer-worker.js +18 -0
  122. package/server/storage/data-transfer.js +415 -0
  123. package/server/storage/store.js +202 -199
  124. package/server/studio/readiness.js +181 -190
  125. package/server/studio/settings.js +236 -236
  126. package/public/app/assets/ChatsPage-C-FcRapR.js +0 -3
  127. package/public/app/assets/Empty-CpP5kQHH.js +0 -2
  128. package/public/app/assets/Field-D4gY-INU.js +0 -1
  129. package/public/app/assets/HeartPage-CPy_thQd.js +0 -1
  130. package/public/app/assets/LifePage-CxWvwmSf.js +0 -1
  131. package/public/app/assets/MemoryPage-DC4rhQ68.js +0 -1
  132. package/public/app/assets/NaturePage-IvYw04Zq.js +0 -3
  133. package/public/app/assets/PeoplePage-Cqefm9-q.js +0 -1
  134. package/public/app/assets/PluginsPage-DKSaKvXb.js +0 -2
  135. package/public/app/assets/PluginsPage-HyBe5D8w.css +0 -1
  136. package/public/app/assets/SystemPage-CJmtJkrK.js +0 -1
  137. package/public/app/assets/TimePage-Cw44inM4.js +0 -3
  138. package/public/app/assets/index-BheRW-1t.js +0 -4
  139. package/public/app/assets/media-BzPDBm7e.js +0 -1
@@ -1,507 +1,507 @@
1
- import { randomUUID } from "node:crypto";
2
- import { localClock } from "../core/conversation-cues.js";
3
- import { hasCredential } from "./guard.js";
4
- import { isPrivateSession } from "./memory.js";
5
- import { lifeSpan } from "./nature.js";
6
- import { classifyActivity, isExecutable } from "./time/tasks.js";
7
- import { taskIntent } from "./time/intent.js";
8
- import { DAY, evidence, parse, similar, text, zonedTime } from "./util.js";
9
-
10
- // What she is looking ahead to: promises she made, things she means to do,
11
- // what others told her is coming, and the dates that come back every year.
12
- export const ANTICIPATION_KINDS = {
13
- promise: "答应的事",
14
- plan: "想做的事",
15
- event: "别人的安排",
16
- date: "每年的日子",
17
- };
18
- // How long after its day something still waits to be asked about or done
19
- // before it quietly counts as missed.
20
- const GRACE = { event: 3 * DAY, promise: 7 * DAY, plan: 7 * DAY, date: 0 };
21
- const HORIZON = 400 * DAY;
22
- const CLOSE = new Set(["done", "missed", "let_go"]);
23
-
24
- function row(value) {
25
- return value
26
- ? {
27
- ...value,
28
- sources: parse(value.sources, []),
29
- closed_sources: parse(value.closed_sources, []),
30
- }
31
- : null;
32
- }
33
-
34
- export class Anticipations {
35
- constructor(mind) {
36
- this.mind = mind;
37
- this.db = mind.db;
38
- }
39
- day(time) {
40
- return localClock(time, this.mind.timeZone()).local.slice(0, 10);
41
- }
42
- // A yearly date's next return (or today's), otherwise the one due moment.
43
- occurrence(a, now) {
44
- if (a.recurrence !== "yearly") return a.due_at;
45
- const monthDay = this.day(a.due_at).slice(5);
46
- const year = Number(this.day(now).slice(0, 4));
47
- for (const y of [year - 1, year, year + 1]) {
48
- const at = zonedTime(`${y}-${monthDay}`, this.mind.timeZone());
49
- if (at !== null && at + DAY > now) return at;
50
- }
51
- return a.due_at;
52
- }
53
- ends(a, due) {
54
- return due + (a.due_precision === "time" ? 0 : DAY);
55
- }
56
- // From the start of the day before.
57
- opens(due) {
58
- const midnight = zonedTime(this.day(due), this.mind.timeZone());
59
- return (midnight ?? due) - DAY;
60
- }
61
- // Her name for someone, or the name they last used where it was said.
62
- who(a) {
63
- if (!a.subject) return "";
64
- const known = this.mind.bonds.name(a.subject);
65
- if (known) return known;
66
- const said = parse(
67
- this.db
68
- .prepare(
69
- "SELECT payload FROM core_events WHERE session_id=? AND role='user' AND json_extract(payload,'$.userId')=? ORDER BY seq DESC LIMIT 1",
70
- )
71
- .get(a.session_id, String(a.subject))?.payload,
72
- {},
73
- );
74
- return said.name || a.subject;
75
- }
76
- lapsed(a, now) {
77
- return (
78
- a.recurrence !== "yearly" &&
79
- now > this.ends(a, a.due_at) + (GRACE[a.kind] ?? GRACE.event)
80
- );
81
- }
82
- // A private promise can be written in her diary. It cannot be carried out
83
- // of that room, or into the story she retells everywhere.
84
- concealed(a) {
85
- return a.discretion !== "open" || isPrivateSession(a.session_id);
86
- }
87
- // Private rooms whose plans, promises, or dates the diary of `day` would see.
88
- privateRooms(day, before = Date.now()) {
89
- const nature = this.mind.nature.current(before);
90
- const zone = this.mind.timeZone();
91
- const span = lifeSpan(nature, day, zone);
92
- if (!span || span.start > before) return [];
93
- const end = Math.min(before, span.end);
94
- const rooms = new Set();
95
- for (const a of this.db
96
- .prepare(
97
- "SELECT * FROM mind_anticipations WHERE created<=? AND status!='revoked'",
98
- )
99
- .all(before)
100
- .map(row)) {
101
- if (!this.concealed(a) || !this.#onDay(a, span, end)) continue;
102
- // The room where it was said may still see that day's diary. A marker
103
- // with no room would hide the diary everywhere, including there.
104
- rooms.add(a.session_id || `a:${a.id}`);
105
- }
106
- return [...rooms];
107
- }
108
- #onDay(a, span, before) {
109
- const pending =
110
- a.status === "pending" || (a.closed_at != null && a.closed_at > before);
111
- if (
112
- !pending &&
113
- a.closed_at >= span.start &&
114
- a.closed_at < span.end &&
115
- a.closed_at <= before
116
- )
117
- return true;
118
- if (a.recurrence === "yearly")
119
- return this.day(this.occurrence(a, span.start)) === this.day(span.start);
120
- if (a.due_at >= span.start && a.due_at < span.end) return true;
121
- const lapse = this.ends(a, a.due_at) + (GRACE[a.kind] ?? GRACE.event);
122
- return pending && lapse >= span.start && lapse < span.end;
123
- }
124
- revokedContents() {
125
- return this.db
126
- .prepare(
127
- "SELECT content FROM mind_revocations WHERE target_kind='anticipation'",
128
- )
129
- .all()
130
- .map((r) => r.content)
131
- .filter(Boolean);
132
- }
133
- add({
134
- kind,
135
- subject = null,
136
- session = null,
137
- content,
138
- due,
139
- recurrence = "none",
140
- discretion = "open",
141
- sources = [],
142
- origin = "memory",
143
- activity = "",
144
- time = Date.now(),
145
- }) {
146
- if (!ANTICIPATION_KINDS[kind]) return { rejected: "类型无效" };
147
- const words = text(content, 120);
148
- if (!words || hasCredential(words)) return { rejected: "内容无效" };
149
- const refs = evidence(sources);
150
- if (!refs.length) return { rejected: "缺少来源" };
151
- const when = String(due ?? "").trim();
152
- const at = zonedTime(when, this.mind.timeZone());
153
- if (at === null) return { rejected: "日期无效" };
154
- const yearly = kind === "date" || recurrence === "yearly";
155
- const precision = /\d{2}:\d{2}$/.test(when) ? "time" : "day";
156
- if (
157
- !yearly &&
158
- (this.ends({ due_precision: precision }, at) <= time - DAY ||
159
- at > time + HORIZON)
160
- )
161
- return { rejected: "日期不在合理范围" };
162
- const who = subject ? String(subject) : "";
163
- if (
164
- this.revokedContents().some((old) =>
165
- similar(old, `${who}\n${words}`, 0.75),
166
- )
167
- )
168
- return { rejected: "与撤销过的内容相同" };
169
- if (!yearly && ["plan", "promise"].includes(kind)) {
170
- const input = {
171
- title: words,
172
- activity: activity || classifyActivity(words),
173
- subject,
174
- session,
175
- sources: refs,
176
- },
177
- intent = taskIntent(this.db, input);
178
- if (intent.key) {
179
- const tracked = this.mind.time?.tasks.links.find(input);
180
- const prior = tracked
181
- ? this.mind.time.tasks.links
182
- .anticipations(tracked)
183
- .map((id) => this.get(id))
184
- .find((a) => a && a.status !== "revoked")
185
- : this.db
186
- .prepare(
187
- "SELECT * FROM mind_anticipations WHERE kind IN ('plan','promise') AND status='pending' ORDER BY created",
188
- )
189
- .all()
190
- .find(
191
- (a) =>
192
- taskIntent(this.db, {
193
- title: a.content,
194
- activity: a.activity || classifyActivity(a.content),
195
- subject: a.subject,
196
- session: a.session_id,
197
- sources: parse(a.sources, []),
198
- }).key === intent.key,
199
- );
200
- if (tracked || prior) {
201
- if (tracked) {
202
- this.mind.time.tasks.links.mergeSource(
203
- tracked,
204
- {
205
- sources: refs,
206
- kind,
207
- subject: who || intent.recipient,
208
- dueAt: at,
209
- duePrecision: precision,
210
- origin,
211
- },
212
- time,
213
- );
214
- if (tracked.state === "abandoned")
215
- this.mind.time.tasks.links.close(
216
- tracked,
217
- "原事项已放下,重复整理不能恢复",
218
- time,
219
- );
220
- } else
221
- this.db
222
- .prepare("UPDATE mind_anticipations SET sources=? WHERE id=?")
223
- .run(
224
- JSON.stringify(
225
- evidence([...parse(prior.sources, []), ...refs]),
226
- ),
227
- prior.id,
228
- );
229
- return {
230
- duplicate: prior?.id || tracked.id,
231
- task: tracked?.id,
232
- suppressed: tracked?.state === "abandoned",
233
- };
234
- }
235
- }
236
- }
237
- const key = yearly ? this.day(at).slice(5) : this.day(at);
238
- const same = this.db
239
- .prepare(
240
- "SELECT * FROM mind_anticipations WHERE status='pending' AND COALESCE(subject,'')=? AND kind=?",
241
- )
242
- .all(who, kind)
243
- .find(
244
- (r) =>
245
- (yearly ? this.day(r.due_at).slice(5) : this.day(r.due_at)) === key &&
246
- similar(r.content, words, 0.6),
247
- );
248
- if (same) return { duplicate: same.id };
249
- const id = randomUUID();
250
- this.db
251
- .prepare(
252
- "INSERT INTO mind_anticipations(id,created,kind,subject,session_id,content,due_at,due_precision,recurrence,discretion,sources,origin,activity) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
253
- )
254
- .run(
255
- id,
256
- time,
257
- kind,
258
- who || null,
259
- session,
260
- words,
261
- at,
262
- precision,
263
- yearly ? "yearly" : "none",
264
- ["open", "private", "secret"].includes(discretion)
265
- ? discretion
266
- : "open",
267
- JSON.stringify(refs),
268
- origin,
269
- kind === "plan" && isExecutable(activity) ? activity : "",
270
- );
271
- return { id };
272
- }
273
- get(id) {
274
- return row(
275
- this.db.prepare("SELECT * FROM mind_anticipations WHERE id=?").get(id),
276
- );
277
- }
278
- // Still open at `now`; something closed later still counts when looking back.
279
- pending(now = Date.now()) {
280
- return this.db
281
- .prepare(
282
- "SELECT * FROM mind_anticipations WHERE created<=? AND (status='pending' OR (closed_at IS NOT NULL AND closed_at>? AND status!='revoked')) ORDER BY due_at",
283
- )
284
- .all(now, now)
285
- .map(row);
286
- }
287
- close(id, { status, note = "", sources = [], time = Date.now() } = {}) {
288
- if (!CLOSE.has(status)) return false;
289
- const a = this.get(id);
290
- if (!a || a.status !== "pending" || a.recurrence === "yearly") return false;
291
- if (status === "done") {
292
- if (
293
- ["plan", "promise"].includes(a.kind) &&
294
- (a.activity || classifyActivity(a.content) !== "unknown")
295
- )
296
- this.mind.time?.tasks.sync(time);
297
- const task = this.db
298
- .prepare(
299
- "SELECT state,share_state FROM mind_time_tasks WHERE anticipation_id=?",
300
- )
301
- .get(id);
302
- if (
303
- task &&
304
- (task.state !== "done" || !["none", "sent"].includes(task.share_state))
305
- )
306
- return false;
307
- }
308
- return (
309
- this.db
310
- .prepare(
311
- "UPDATE mind_anticipations SET status=?,closed_at=?,closed_note=?,closed_sources=? WHERE id=? AND status='pending'",
312
- )
313
- .run(
314
- status,
315
- time,
316
- text(note, 120),
317
- JSON.stringify(evidence(sources)),
318
- id,
319
- ).changes > 0
320
- );
321
- }
322
- revoke(id, reason = "") {
323
- const a = this.get(id);
324
- if (!a) throw Error("约定不存在");
325
- this.db
326
- .prepare(
327
- "UPDATE mind_anticipations SET status='revoked',closed_at=?,closed_note=? WHERE id=?",
328
- )
329
- .run(Date.now(), text(reason, 120), id);
330
- return `${a.subject || ""}\n${a.content}`;
331
- }
332
- relative(due, now, precision = "day") {
333
- const zone = this.mind.timeZone();
334
- const at = (t) =>
335
- Date.parse(`${localClock(t, zone).local.slice(0, 10)}T00:00:00Z`);
336
- const days = Math.round((at(due) - at(now)) / DAY);
337
- const clock =
338
- precision === "time" ? ` ${localClock(due, zone).local.slice(11)}` : "";
339
- const named = {
340
- 0: "今天",
341
- 1: "明天",
342
- 2: "后天",
343
- [-1]: "昨天",
344
- [-2]: "前天",
345
- };
346
- if (named[days] !== undefined) return named[days] + clock;
347
- return days > 0 ? `${days} 天后` : `${-days} 天前`;
348
- }
349
- label(a, due, now) {
350
- const who = this.who(a);
351
- const when = this.relative(due, now, a.due_precision);
352
- if (a.kind === "promise") {
353
- const task = this.db
354
- .prepare(
355
- "SELECT state,share_state,checkpoint FROM mind_time_tasks WHERE anticipation_id=?",
356
- )
357
- .get(a.id);
358
- const progress =
359
- task?.state === "done" && task.share_state !== "sent"
360
- ? "作品完成,尚未交付"
361
- : task?.state === "doing"
362
- ? "正在做"
363
- : parse(task?.checkpoint, {}).workId
364
- ? "已有草稿,尚未完成"
365
- : "还没做";
366
- return `我答应${who ? `${who}` : ""}:${a.content}(${when}${a.status === "pending" ? "," + progress : ""})`;
367
- }
368
- if (a.kind === "plan") return `我打算:${a.content}(${when})`;
369
- if (a.kind === "event")
370
- return `${who ? `${who}计划:` : "有人计划:"}${a.content}(${when},结果未确认)`;
371
- return `${who ? `${who}:` : ""}${a.content}(${when})`;
372
- }
373
- // Near enough to matter in a conversation: from the day before until a few
374
- // days after, and only where it belongs.
375
- upcoming({ now = Date.now(), people = [], session = "", limit = 3 } = {}) {
376
- const here = new Set(session ? this.mind.memory.scopes(session) : []);
377
- const present = new Set(people.map(String));
378
- const out = [];
379
- for (const a of this.pending(now)) {
380
- const due = this.occurrence(a, now);
381
- const after =
382
- a.recurrence === "yearly" ? 0 : (GRACE[a.kind] ?? GRACE.event);
383
- if (now < this.opens(due) || now >= this.ends(a, due) + after) continue;
384
- // A plan by someone else in this room belongs to that person. Its
385
- // presence in the same group must not make it A's plan when B said it.
386
- const relevant = a.subject
387
- ? present.has(String(a.subject))
388
- : a.session_id === session || a.kind === "plan";
389
- if (!relevant) continue;
390
- const local = here.has(a.session_id);
391
- if (a.discretion !== "open" && !local) continue;
392
- out.push({ id: a.id, due, text: this.label(a, due, now) });
393
- if (out.length >= limit) break;
394
- }
395
- return out;
396
- }
397
- // What she might think about when alone: due soon, just past, or quietly
398
- // missed within the last week.
399
- // What is still ahead comes first, soonest first. Then what was due but still
400
- // waits (inside its grace), then what already lapsed; both the most recent
401
- // first, so a week-old plan of someone else does not take the places from
402
- // what is near now.
403
- due({ now = Date.now(), limit = 5, shareable = false } = {}) {
404
- const found = [];
405
- for (const a of this.pending(now)) {
406
- if (shareable && this.concealed(a)) continue;
407
- const due = this.occurrence(a, now);
408
- const after =
409
- a.recurrence === "yearly"
410
- ? 0
411
- : (GRACE[a.kind] ?? GRACE.event) + 7 * DAY;
412
- if (now < this.opens(due) || now >= this.ends(a, due) + after) continue;
413
- const lapsed = this.lapsed(a, now);
414
- // 0: its day has not passed yet; 1: due, still inside the grace;
415
- // 2: already lapsed.
416
- const rank = lapsed ? 2 : this.ends(a, due) <= now ? 1 : 0;
417
- found.push({
418
- due,
419
- rank,
420
- item: {
421
- ref: `a:${a.id}`,
422
- kind: ANTICIPATION_KINDS[a.kind],
423
- ...(a.subject ? { who: this.who(a), userId: a.subject } : {}),
424
- content: a.content,
425
- when: this.relative(due, now, a.due_precision),
426
- ...(lapsed ? { lapsed: true } : {}),
427
- ...(a.recurrence === "yearly" ? { yearly: true } : {}),
428
- },
429
- });
430
- }
431
- return found
432
- .sort(
433
- (a, b) => a.rank - b.rank || (a.rank ? b.due - a.due : a.due - b.due),
434
- )
435
- .slice(0, limit)
436
- .map((entry) => entry.item);
437
- }
438
- // Something she was looking ahead to has come close, or just passed.
439
- newlyDue(since, now = Date.now()) {
440
- return this.pending(now).some((a) => {
441
- const due = this.occurrence(a, now);
442
- const edges = [this.opens(due), this.ends(a, due)];
443
- return edges.some((edge) => edge > since && edge <= now);
444
- });
445
- }
446
- // The day's promises kept and missed, and the dates that fall on it.
447
- today({ start, end, shareable = false }) {
448
- const keep = (a) => !shareable || !this.concealed(a);
449
- const closed = this.db
450
- .prepare(
451
- "SELECT * FROM mind_anticipations WHERE closed_at>=? AND closed_at<? AND status IN ('done','missed','let_go') ORDER BY closed_at",
452
- )
453
- .all(start, end)
454
- .map(row)
455
- .filter(keep);
456
- const lapsedToday = this.pending(end).filter((a) => {
457
- if (!keep(a) || a.recurrence === "yearly") return false;
458
- const at = this.ends(a, a.due_at) + (GRACE[a.kind] ?? GRACE.event);
459
- return at >= start && at < end;
460
- });
461
- const line = (a) => this.label(a, this.occurrence(a, start), start);
462
- const byStatus = (status) =>
463
- closed.filter((a) => a.status === status).map(line);
464
- return {
465
- kept: byStatus("done"),
466
- missed: [...byStatus("missed"), ...lapsedToday.map(line)],
467
- letGo: byStatus("let_go"),
468
- dates: this.pending(end)
469
- .filter(
470
- (a) =>
471
- keep(a) &&
472
- a.recurrence === "yearly" &&
473
- this.day(this.occurrence(a, start)) === this.day(start),
474
- )
475
- .map(line),
476
- dueToday: this.pending(end)
477
- .filter(
478
- (a) =>
479
- keep(a) &&
480
- a.recurrence !== "yearly" &&
481
- a.due_at >= start &&
482
- a.due_at < end,
483
- )
484
- .map(line),
485
- };
486
- }
487
- list({ now = Date.now(), limit = 100 } = {}) {
488
- return this.db
489
- .prepare(
490
- "SELECT * FROM mind_anticipations ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, due_at LIMIT ?",
491
- )
492
- .all(limit)
493
- .map(row)
494
- .map((a) => {
495
- const due = this.occurrence(a, now);
496
- return {
497
- ...a,
498
- name: this.who(a),
499
- occurrence: due,
500
- when: this.relative(due, now, a.due_precision),
501
- state:
502
- a.status === "pending" && this.lapsed(a, now) ? "lapsed" : a.status,
503
- private: a.discretion !== "open" || isPrivateSession(a.session_id),
504
- };
505
- });
506
- }
507
- }
1
+ import { randomUUID } from "node:crypto";
2
+ import { localClock } from "../core/conversation-cues.js";
3
+ import { hasCredential } from "./guard.js";
4
+ import { isPrivateSession } from "./memory.js";
5
+ import { lifeSpan } from "./nature.js";
6
+ import { classifyActivity, isExecutable } from "./time/tasks.js";
7
+ import { taskIntent } from "./time/intent.js";
8
+ import { DAY, evidence, parse, similar, text, zonedTime } from "./util.js";
9
+
10
+ // What she is looking ahead to: promises she made, things she means to do,
11
+ // what others told her is coming, and the dates that come back every year.
12
+ export const ANTICIPATION_KINDS = {
13
+ promise: "答应的事",
14
+ plan: "想做的事",
15
+ event: "别人的安排",
16
+ date: "每年的日子",
17
+ };
18
+ // How long after its day something still waits to be asked about or done
19
+ // before it quietly counts as missed.
20
+ const GRACE = { event: 3 * DAY, promise: 7 * DAY, plan: 7 * DAY, date: 0 };
21
+ const HORIZON = 400 * DAY;
22
+ const CLOSE = new Set(["done", "missed", "let_go"]);
23
+
24
+ function row(value) {
25
+ return value
26
+ ? {
27
+ ...value,
28
+ sources: parse(value.sources, []),
29
+ closed_sources: parse(value.closed_sources, []),
30
+ }
31
+ : null;
32
+ }
33
+
34
+ export class Anticipations {
35
+ constructor(mind) {
36
+ this.mind = mind;
37
+ this.db = mind.db;
38
+ }
39
+ day(time) {
40
+ return localClock(time, this.mind.timeZone()).local.slice(0, 10);
41
+ }
42
+ // A yearly date's next return (or today's), otherwise the one due moment.
43
+ occurrence(a, now) {
44
+ if (a.recurrence !== "yearly") return a.due_at;
45
+ const monthDay = this.day(a.due_at).slice(5);
46
+ const year = Number(this.day(now).slice(0, 4));
47
+ for (const y of [year - 1, year, year + 1]) {
48
+ const at = zonedTime(`${y}-${monthDay}`, this.mind.timeZone());
49
+ if (at !== null && at + DAY > now) return at;
50
+ }
51
+ return a.due_at;
52
+ }
53
+ ends(a, due) {
54
+ return due + (a.due_precision === "time" ? 0 : DAY);
55
+ }
56
+ // From the start of the day before.
57
+ opens(due) {
58
+ const midnight = zonedTime(this.day(due), this.mind.timeZone());
59
+ return (midnight ?? due) - DAY;
60
+ }
61
+ // Her name for someone, or the name they last used where it was said.
62
+ who(a) {
63
+ if (!a.subject) return "";
64
+ const known = this.mind.bonds.name(a.subject);
65
+ if (known) return known;
66
+ const said = parse(
67
+ this.db
68
+ .prepare(
69
+ "SELECT payload FROM core_events WHERE session_id=? AND role='user' AND json_extract(payload,'$.userId')=? ORDER BY seq DESC LIMIT 1",
70
+ )
71
+ .get(a.session_id, String(a.subject))?.payload,
72
+ {},
73
+ );
74
+ return said.name || a.subject;
75
+ }
76
+ lapsed(a, now) {
77
+ return (
78
+ a.recurrence !== "yearly" &&
79
+ now > this.ends(a, a.due_at) + (GRACE[a.kind] ?? GRACE.event)
80
+ );
81
+ }
82
+ // A private promise can be written in her diary. It cannot be carried out
83
+ // of that room, or into the story she retells everywhere.
84
+ concealed(a) {
85
+ return a.discretion !== "open" || isPrivateSession(a.session_id);
86
+ }
87
+ // Private rooms whose plans, promises, or dates the diary of `day` would see.
88
+ privateRooms(day, before = Date.now()) {
89
+ const nature = this.mind.nature.current(before);
90
+ const zone = this.mind.timeZone();
91
+ const span = lifeSpan(nature, day, zone);
92
+ if (!span || span.start > before) return [];
93
+ const end = Math.min(before, span.end);
94
+ const rooms = new Set();
95
+ for (const a of this.db
96
+ .prepare(
97
+ "SELECT * FROM mind_anticipations WHERE created<=? AND status!='revoked'",
98
+ )
99
+ .all(before)
100
+ .map(row)) {
101
+ if (!this.concealed(a) || !this.#onDay(a, span, end)) continue;
102
+ // The room where it was said may still see that day's diary. A marker
103
+ // with no room would hide the diary everywhere, including there.
104
+ rooms.add(a.session_id || `a:${a.id}`);
105
+ }
106
+ return [...rooms];
107
+ }
108
+ #onDay(a, span, before) {
109
+ const pending =
110
+ a.status === "pending" || (a.closed_at != null && a.closed_at > before);
111
+ if (
112
+ !pending &&
113
+ a.closed_at >= span.start &&
114
+ a.closed_at < span.end &&
115
+ a.closed_at <= before
116
+ )
117
+ return true;
118
+ if (a.recurrence === "yearly")
119
+ return this.day(this.occurrence(a, span.start)) === this.day(span.start);
120
+ if (a.due_at >= span.start && a.due_at < span.end) return true;
121
+ const lapse = this.ends(a, a.due_at) + (GRACE[a.kind] ?? GRACE.event);
122
+ return pending && lapse >= span.start && lapse < span.end;
123
+ }
124
+ revokedContents() {
125
+ return this.db
126
+ .prepare(
127
+ "SELECT content FROM mind_revocations WHERE target_kind='anticipation'",
128
+ )
129
+ .all()
130
+ .map((r) => r.content)
131
+ .filter(Boolean);
132
+ }
133
+ add({
134
+ kind,
135
+ subject = null,
136
+ session = null,
137
+ content,
138
+ due,
139
+ recurrence = "none",
140
+ discretion = "open",
141
+ sources = [],
142
+ origin = "memory",
143
+ activity = "",
144
+ time = Date.now(),
145
+ }) {
146
+ if (!ANTICIPATION_KINDS[kind]) return { rejected: "类型无效" };
147
+ const words = text(content, 120);
148
+ if (!words || hasCredential(words)) return { rejected: "内容无效" };
149
+ const refs = evidence(sources);
150
+ if (!refs.length) return { rejected: "缺少来源" };
151
+ const when = String(due ?? "").trim();
152
+ const at = zonedTime(when, this.mind.timeZone());
153
+ if (at === null) return { rejected: "日期无效" };
154
+ const yearly = kind === "date" || recurrence === "yearly";
155
+ const precision = /\d{2}:\d{2}$/.test(when) ? "time" : "day";
156
+ if (
157
+ !yearly &&
158
+ (this.ends({ due_precision: precision }, at) <= time - DAY ||
159
+ at > time + HORIZON)
160
+ )
161
+ return { rejected: "日期不在合理范围" };
162
+ const who = subject ? String(subject) : "";
163
+ if (
164
+ this.revokedContents().some((old) =>
165
+ similar(old, `${who}\n${words}`, 0.75),
166
+ )
167
+ )
168
+ return { rejected: "与撤销过的内容相同" };
169
+ if (!yearly && ["plan", "promise"].includes(kind)) {
170
+ const input = {
171
+ title: words,
172
+ activity: activity || classifyActivity(words),
173
+ subject,
174
+ session,
175
+ sources: refs,
176
+ },
177
+ intent = taskIntent(this.db, input);
178
+ if (intent.key) {
179
+ const tracked = this.mind.time?.tasks.links.find(input);
180
+ const prior = tracked
181
+ ? this.mind.time.tasks.links
182
+ .anticipations(tracked)
183
+ .map((id) => this.get(id))
184
+ .find((a) => a && a.status !== "revoked")
185
+ : this.db
186
+ .prepare(
187
+ "SELECT * FROM mind_anticipations WHERE kind IN ('plan','promise') AND status='pending' ORDER BY created",
188
+ )
189
+ .all()
190
+ .find(
191
+ (a) =>
192
+ taskIntent(this.db, {
193
+ title: a.content,
194
+ activity: a.activity || classifyActivity(a.content),
195
+ subject: a.subject,
196
+ session: a.session_id,
197
+ sources: parse(a.sources, []),
198
+ }).key === intent.key,
199
+ );
200
+ if (tracked || prior) {
201
+ if (tracked) {
202
+ this.mind.time.tasks.links.mergeSource(
203
+ tracked,
204
+ {
205
+ sources: refs,
206
+ kind,
207
+ subject: who || intent.recipient,
208
+ dueAt: at,
209
+ duePrecision: precision,
210
+ origin,
211
+ },
212
+ time,
213
+ );
214
+ if (tracked.state === "abandoned")
215
+ this.mind.time.tasks.links.close(
216
+ tracked,
217
+ "原事项已放下,重复整理不能恢复",
218
+ time,
219
+ );
220
+ } else
221
+ this.db
222
+ .prepare("UPDATE mind_anticipations SET sources=? WHERE id=?")
223
+ .run(
224
+ JSON.stringify(
225
+ evidence([...parse(prior.sources, []), ...refs]),
226
+ ),
227
+ prior.id,
228
+ );
229
+ return {
230
+ duplicate: prior?.id || tracked.id,
231
+ task: tracked?.id,
232
+ suppressed: tracked?.state === "abandoned",
233
+ };
234
+ }
235
+ }
236
+ }
237
+ const key = yearly ? this.day(at).slice(5) : this.day(at);
238
+ const same = this.db
239
+ .prepare(
240
+ "SELECT * FROM mind_anticipations WHERE status='pending' AND COALESCE(subject,'')=? AND kind=?",
241
+ )
242
+ .all(who, kind)
243
+ .find(
244
+ (r) =>
245
+ (yearly ? this.day(r.due_at).slice(5) : this.day(r.due_at)) === key &&
246
+ similar(r.content, words, 0.6),
247
+ );
248
+ if (same) return { duplicate: same.id };
249
+ const id = randomUUID();
250
+ this.db
251
+ .prepare(
252
+ "INSERT INTO mind_anticipations(id,created,kind,subject,session_id,content,due_at,due_precision,recurrence,discretion,sources,origin,activity) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
253
+ )
254
+ .run(
255
+ id,
256
+ time,
257
+ kind,
258
+ who || null,
259
+ session,
260
+ words,
261
+ at,
262
+ precision,
263
+ yearly ? "yearly" : "none",
264
+ ["open", "private", "secret"].includes(discretion)
265
+ ? discretion
266
+ : "open",
267
+ JSON.stringify(refs),
268
+ origin,
269
+ kind === "plan" && isExecutable(activity) ? activity : "",
270
+ );
271
+ return { id };
272
+ }
273
+ get(id) {
274
+ return row(
275
+ this.db.prepare("SELECT * FROM mind_anticipations WHERE id=?").get(id),
276
+ );
277
+ }
278
+ // Still open at `now`; something closed later still counts when looking back.
279
+ pending(now = Date.now()) {
280
+ return this.db
281
+ .prepare(
282
+ "SELECT * FROM mind_anticipations WHERE created<=? AND (status='pending' OR (closed_at IS NOT NULL AND closed_at>? AND status!='revoked')) ORDER BY due_at",
283
+ )
284
+ .all(now, now)
285
+ .map(row);
286
+ }
287
+ close(id, { status, note = "", sources = [], time = Date.now() } = {}) {
288
+ if (!CLOSE.has(status)) return false;
289
+ const a = this.get(id);
290
+ if (!a || a.status !== "pending" || a.recurrence === "yearly") return false;
291
+ if (status === "done") {
292
+ if (
293
+ ["plan", "promise"].includes(a.kind) &&
294
+ (a.activity || classifyActivity(a.content) !== "unknown")
295
+ )
296
+ this.mind.time?.tasks.sync(time);
297
+ const task = this.db
298
+ .prepare(
299
+ "SELECT state,share_state FROM mind_time_tasks WHERE anticipation_id=?",
300
+ )
301
+ .get(id);
302
+ if (
303
+ task &&
304
+ (task.state !== "done" || !["none", "sent"].includes(task.share_state))
305
+ )
306
+ return false;
307
+ }
308
+ return (
309
+ this.db
310
+ .prepare(
311
+ "UPDATE mind_anticipations SET status=?,closed_at=?,closed_note=?,closed_sources=? WHERE id=? AND status='pending'",
312
+ )
313
+ .run(
314
+ status,
315
+ time,
316
+ text(note, 120),
317
+ JSON.stringify(evidence(sources)),
318
+ id,
319
+ ).changes > 0
320
+ );
321
+ }
322
+ revoke(id, reason = "") {
323
+ const a = this.get(id);
324
+ if (!a) throw Error("约定不存在");
325
+ this.db
326
+ .prepare(
327
+ "UPDATE mind_anticipations SET status='revoked',closed_at=?,closed_note=? WHERE id=?",
328
+ )
329
+ .run(Date.now(), text(reason, 120), id);
330
+ return `${a.subject || ""}\n${a.content}`;
331
+ }
332
+ relative(due, now, precision = "day") {
333
+ const zone = this.mind.timeZone();
334
+ const at = (t) =>
335
+ Date.parse(`${localClock(t, zone).local.slice(0, 10)}T00:00:00Z`);
336
+ const days = Math.round((at(due) - at(now)) / DAY);
337
+ const clock =
338
+ precision === "time" ? ` ${localClock(due, zone).local.slice(11)}` : "";
339
+ const named = {
340
+ 0: "今天",
341
+ 1: "明天",
342
+ 2: "后天",
343
+ [-1]: "昨天",
344
+ [-2]: "前天",
345
+ };
346
+ if (named[days] !== undefined) return named[days] + clock;
347
+ return days > 0 ? `${days} 天后` : `${-days} 天前`;
348
+ }
349
+ label(a, due, now) {
350
+ const who = this.who(a);
351
+ const when = this.relative(due, now, a.due_precision);
352
+ if (a.kind === "promise") {
353
+ const task = this.db
354
+ .prepare(
355
+ "SELECT state,share_state,checkpoint FROM mind_time_tasks WHERE anticipation_id=?",
356
+ )
357
+ .get(a.id);
358
+ const progress =
359
+ task?.state === "done" && task.share_state !== "sent"
360
+ ? "作品完成,尚未交付"
361
+ : task?.state === "doing"
362
+ ? "正在做"
363
+ : parse(task?.checkpoint, {}).workId
364
+ ? "已有草稿,尚未完成"
365
+ : "还没做";
366
+ return `我答应${who ? `${who}` : ""}:${a.content}(${when}${a.status === "pending" ? "," + progress : ""})`;
367
+ }
368
+ if (a.kind === "plan") return `我打算:${a.content}(${when})`;
369
+ if (a.kind === "event")
370
+ return `${who ? `${who}计划:` : "有人计划:"}${a.content}(${when},结果未确认)`;
371
+ return `${who ? `${who}:` : ""}${a.content}(${when})`;
372
+ }
373
+ // Near enough to matter in a conversation: from the day before until a few
374
+ // days after, and only where it belongs.
375
+ upcoming({ now = Date.now(), people = [], session = "", limit = 3 } = {}) {
376
+ const here = new Set(session ? this.mind.memory.scopes(session) : []);
377
+ const present = new Set(people.map(String));
378
+ const out = [];
379
+ for (const a of this.pending(now)) {
380
+ const due = this.occurrence(a, now);
381
+ const after =
382
+ a.recurrence === "yearly" ? 0 : (GRACE[a.kind] ?? GRACE.event);
383
+ if (now < this.opens(due) || now >= this.ends(a, due) + after) continue;
384
+ // A plan by someone else in this room belongs to that person. Its
385
+ // presence in the same group must not make it A's plan when B said it.
386
+ const relevant = a.subject
387
+ ? present.has(String(a.subject))
388
+ : a.session_id === session || a.kind === "plan";
389
+ if (!relevant) continue;
390
+ const local = here.has(a.session_id);
391
+ if (a.discretion !== "open" && !local) continue;
392
+ out.push({ id: a.id, due, text: this.label(a, due, now) });
393
+ if (out.length >= limit) break;
394
+ }
395
+ return out;
396
+ }
397
+ // What she might think about when alone: due soon, just past, or quietly
398
+ // missed within the last week.
399
+ // What is still ahead comes first, soonest first. Then what was due but still
400
+ // waits (inside its grace), then what already lapsed; both the most recent
401
+ // first, so a week-old plan of someone else does not take the places from
402
+ // what is near now.
403
+ due({ now = Date.now(), limit = 5, shareable = false } = {}) {
404
+ const found = [];
405
+ for (const a of this.pending(now)) {
406
+ if (shareable && this.concealed(a)) continue;
407
+ const due = this.occurrence(a, now);
408
+ const after =
409
+ a.recurrence === "yearly"
410
+ ? 0
411
+ : (GRACE[a.kind] ?? GRACE.event) + 7 * DAY;
412
+ if (now < this.opens(due) || now >= this.ends(a, due) + after) continue;
413
+ const lapsed = this.lapsed(a, now);
414
+ // 0: its day has not passed yet; 1: due, still inside the grace;
415
+ // 2: already lapsed.
416
+ const rank = lapsed ? 2 : this.ends(a, due) <= now ? 1 : 0;
417
+ found.push({
418
+ due,
419
+ rank,
420
+ item: {
421
+ ref: `a:${a.id}`,
422
+ kind: ANTICIPATION_KINDS[a.kind],
423
+ ...(a.subject ? { who: this.who(a), userId: a.subject } : {}),
424
+ content: a.content,
425
+ when: this.relative(due, now, a.due_precision),
426
+ ...(lapsed ? { lapsed: true } : {}),
427
+ ...(a.recurrence === "yearly" ? { yearly: true } : {}),
428
+ },
429
+ });
430
+ }
431
+ return found
432
+ .sort(
433
+ (a, b) => a.rank - b.rank || (a.rank ? b.due - a.due : a.due - b.due),
434
+ )
435
+ .slice(0, limit)
436
+ .map((entry) => entry.item);
437
+ }
438
+ // Something she was looking ahead to has come close, or just passed.
439
+ newlyDue(since, now = Date.now()) {
440
+ return this.pending(now).some((a) => {
441
+ const due = this.occurrence(a, now);
442
+ const edges = [this.opens(due), this.ends(a, due)];
443
+ return edges.some((edge) => edge > since && edge <= now);
444
+ });
445
+ }
446
+ // The day's promises kept and missed, and the dates that fall on it.
447
+ today({ start, end, shareable = false }) {
448
+ const keep = (a) => !shareable || !this.concealed(a);
449
+ const closed = this.db
450
+ .prepare(
451
+ "SELECT * FROM mind_anticipations WHERE closed_at>=? AND closed_at<? AND status IN ('done','missed','let_go') ORDER BY closed_at",
452
+ )
453
+ .all(start, end)
454
+ .map(row)
455
+ .filter(keep);
456
+ const lapsedToday = this.pending(end).filter((a) => {
457
+ if (!keep(a) || a.recurrence === "yearly") return false;
458
+ const at = this.ends(a, a.due_at) + (GRACE[a.kind] ?? GRACE.event);
459
+ return at >= start && at < end;
460
+ });
461
+ const line = (a) => this.label(a, this.occurrence(a, start), start);
462
+ const byStatus = (status) =>
463
+ closed.filter((a) => a.status === status).map(line);
464
+ return {
465
+ kept: byStatus("done"),
466
+ missed: [...byStatus("missed"), ...lapsedToday.map(line)],
467
+ letGo: byStatus("let_go"),
468
+ dates: this.pending(end)
469
+ .filter(
470
+ (a) =>
471
+ keep(a) &&
472
+ a.recurrence === "yearly" &&
473
+ this.day(this.occurrence(a, start)) === this.day(start),
474
+ )
475
+ .map(line),
476
+ dueToday: this.pending(end)
477
+ .filter(
478
+ (a) =>
479
+ keep(a) &&
480
+ a.recurrence !== "yearly" &&
481
+ a.due_at >= start &&
482
+ a.due_at < end,
483
+ )
484
+ .map(line),
485
+ };
486
+ }
487
+ list({ now = Date.now(), limit = 100 } = {}) {
488
+ return this.db
489
+ .prepare(
490
+ "SELECT * FROM mind_anticipations ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END, due_at LIMIT ?",
491
+ )
492
+ .all(limit)
493
+ .map(row)
494
+ .map((a) => {
495
+ const due = this.occurrence(a, now);
496
+ return {
497
+ ...a,
498
+ name: this.who(a),
499
+ occurrence: due,
500
+ when: this.relative(due, now, a.due_precision),
501
+ state:
502
+ a.status === "pending" && this.lapsed(a, now) ? "lapsed" : a.status,
503
+ private: a.discretion !== "open" || isPrivateSession(a.session_id),
504
+ };
505
+ });
506
+ }
507
+ }