luckytri 1.1.2 → 1.2.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 (95) hide show
  1. package/README.en.md +14 -12
  2. package/README.md +4 -2
  3. package/docs/en/CHANGELOG.md +24 -0
  4. package/docs/en/architecture.md +12 -10
  5. package/docs/en/chat-style.md +13 -13
  6. package/docs/en/cognition.md +12 -4
  7. package/docs/en/evolution.md +35 -0
  8. package/docs/en/her-life.md +5 -2
  9. package/docs/en/vision.md +50 -0
  10. package/docs/zh/CHANGELOG.md +24 -0
  11. package/docs/zh/architecture.md +12 -10
  12. package/docs/zh/chat-style.md +17 -17
  13. package/docs/zh/cognition.md +12 -4
  14. package/docs/zh/evolution.md +35 -0
  15. package/docs/zh/her-life.md +5 -2
  16. package/docs/zh/vision.md +50 -0
  17. package/package.json +4 -1
  18. package/public/app/assets/{ChatsPage-IfBgNdaD.js → ChatsPage-eDqFIg97.js} +1 -1
  19. package/public/app/assets/{Empty-DK9nQPCf.js → Empty-CAGOJsfD.js} +1 -1
  20. package/public/app/assets/{Field-BzKFhuFd.js → Field-CRz-JmN1.js} +1 -1
  21. package/public/app/assets/{HeartPage-CKxrbfFQ.js → HeartPage-BGWoeMrC.js} +1 -1
  22. package/public/app/assets/{LifePage-CtxPEi6E.js → LifePage-iao3o3wt.js} +1 -1
  23. package/public/app/assets/{MemoryPage-B8FLXzLi.js → MemoryPage-Dwi2yi7E.js} +1 -1
  24. package/public/app/assets/NaturePage-DniImo9U.js +3 -0
  25. package/public/app/assets/{NaturePage-CHkpb5NK.css → NaturePage-zguDpGWY.css} +1 -1
  26. package/public/app/assets/{PeoplePage-D3yn0HV0.js → PeoplePage-D-MC3JEt.js} +1 -1
  27. package/public/app/assets/{PluginsPage-ojv8zawj.js → PluginsPage-D7dJTf96.js} +1 -1
  28. package/public/app/assets/SystemPage-C5CUHjGh.js +1 -0
  29. package/public/app/assets/{SystemPage-CGVTvNZw.css → SystemPage-C_Y5M7JU.css} +1 -1
  30. package/public/app/assets/{TimePage-Dyd-FFbq.js → TimePage-Dwhr3-92.js} +1 -1
  31. package/public/app/assets/{index-BJfzfgnm.js → index-L0bHax8C.js} +3 -3
  32. package/public/app/assets/{knowledge-C-bND0Ub.js → knowledge-BTDNafzl.js} +1 -1
  33. package/public/app/assets/{media-D7G3Nco6.js → media-BBzOAfNG.js} +1 -1
  34. package/public/app/assets/{mind-ec-G6iNZ.js → mind-CBTkFje2.js} +1 -1
  35. package/public/app/index.html +3 -3
  36. package/public/guide.en.html +3 -3
  37. package/public/guide.html +3 -3
  38. package/scripts/migrate-instance.js +27 -0
  39. package/scripts/update-instance.js +21 -2
  40. package/server/channels/contract.js +5 -1
  41. package/server/channels/hub.js +2 -2
  42. package/server/channels/onebot/adapter.js +291 -288
  43. package/server/channels/onebot/index.js +2 -2
  44. package/server/channels/qqbot/api.js +194 -199
  45. package/server/core/context-builder.js +20 -3
  46. package/server/core/conversation-cues.js +5 -4
  47. package/server/core/conversation-grounding.js +20 -17
  48. package/server/core/conversation-needs.js +32 -0
  49. package/server/core/dialogue-context.js +2 -5
  50. package/server/core/dialogue-contract.js +23 -16
  51. package/server/core/message-scheduler.js +85 -78
  52. package/server/core/model-manager.js +113 -16
  53. package/server/core/model-presets.js +1 -1
  54. package/server/core/orchestrator.js +18 -2
  55. package/server/core/persona-manager.js +14 -5
  56. package/server/core/reader-check.js +47 -42
  57. package/server/core/reply-delivery.js +125 -34
  58. package/server/core/response-validator.js +28 -1
  59. package/server/core/retired-prompts.js +5 -0
  60. package/server/core/speech-length.js +43 -0
  61. package/server/core/turn-processor.js +38 -5
  62. package/server/core/turn.js +70 -16
  63. package/server/i18n/errors.js +30 -0
  64. package/server/i18n/labels.js +5 -0
  65. package/server/index.js +3 -0
  66. package/server/knowledge/embedding-profile.js +126 -29
  67. package/server/knowledge/manager.js +103 -34
  68. package/server/knowledge/schema.js +197 -158
  69. package/server/mind/affect.js +267 -265
  70. package/server/mind/aftermath.js +169 -0
  71. package/server/mind/anticipations.js +2 -0
  72. package/server/mind/clock.js +91 -83
  73. package/server/mind/discretion.js +20 -0
  74. package/server/mind/episodes.js +2 -1
  75. package/server/mind/index.js +17 -2
  76. package/server/mind/initiative.js +29 -14
  77. package/server/mind/memory.js +47 -5
  78. package/server/mind/recall/cue.js +67 -0
  79. package/server/mind/recall/index.js +183 -29
  80. package/server/mind/recall/roots.js +231 -0
  81. package/server/mind/recall/schema.js +2 -0
  82. package/server/mind/recall/store.js +113 -30
  83. package/server/mind/salience.js +22 -1
  84. package/server/mind/schema.js +5 -0
  85. package/server/mind/thoughts.js +276 -274
  86. package/server/mind/view.js +100 -60
  87. package/server/plugins/install.js +1 -6
  88. package/server/plugins/zip.js +18 -4
  89. package/server/runtime/data-migration.js +193 -0
  90. package/server/runtime/update-execution.js +43 -4
  91. package/server/runtime/update-manager.js +4 -1
  92. package/server/storage/archive.js +1 -1
  93. package/server/storage/backup-scheduler.js +15 -3
  94. package/public/app/assets/NaturePage-C7i607ul.js +0 -3
  95. package/public/app/assets/SystemPage-Cv3rFvyT.js +0 -1
@@ -1,274 +1,276 @@
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
- }
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
+ // Searchable, and dated in the audit, from the moment it is written.
85
+ this.mind.recall?.sync("thought", id);
86
+ return id;
87
+ }
88
+ get(id) {
89
+ return row(
90
+ this.db.prepare("SELECT * FROM mind_thoughts WHERE id=?").get(id),
91
+ );
92
+ }
93
+ list({
94
+ before = Number.MAX_SAFE_INTEGER,
95
+ session = "",
96
+ q = "",
97
+ limit = 30,
98
+ hidden = true,
99
+ } = {}) {
100
+ return this.db
101
+ .prepare(
102
+ `SELECT * FROM mind_thoughts WHERE created<?${hidden ? "" : " AND hidden=0"}${session ? " AND sessions LIKE ?" : ""}${q ? " AND content LIKE ?" : ""} ORDER BY created DESC LIMIT ?`,
103
+ )
104
+ .all(
105
+ before,
106
+ ...(session ? [`%${JSON.stringify(session).slice(1, -1)}%`] : []),
107
+ ...(q ? [`%${q}%`] : []),
108
+ limit,
109
+ )
110
+ .map(row);
111
+ }
112
+ // Still on her mind at `now`, ranked by how present each one is; a thought
113
+ // let go of later still counts as open when looking back. A later note
114
+ // that only repeats sources already used does not start this clock over.
115
+ weighed({ now = Date.now(), session = "" } = {}) {
116
+ const lived = this.mind.days.lived(now);
117
+ const notes = this.db
118
+ .prepare(
119
+ "SELECT * FROM mind_thoughts WHERE hidden=0 AND created<=? AND (status='open' OR resolved_at>?) ORDER BY created DESC LIMIT 400",
120
+ )
121
+ .all(now, now)
122
+ .map(row)
123
+ .filter((t) => !session || t.sessions.includes(session));
124
+ const clock = sourceClock(notes, (sources) =>
125
+ evidenceRoots(this.db, sources, now),
126
+ );
127
+ return notes
128
+ .map((t) => {
129
+ const earned = clock.get(t.id) ?? t.created;
130
+ // A revisit brings back the note that actually lived the experience.
131
+ // Rewriting it later and setting another time does not.
132
+ const seen = earned < t.created ? { ...t, revisit_at: null } : t;
133
+ return {
134
+ ...t,
135
+ salience: thoughtSalience({ ...seen, created: earned }, lived, now),
136
+ };
137
+ })
138
+ .sort((a, b) => b.salience - a.salience || b.created - a.created);
139
+ }
140
+ open({ now = Date.now(), limit = 12, session = "" } = {}) {
141
+ return this.weighed({ now, session })
142
+ .filter((t) => t.salience >= THOUGHT_FADED)
143
+ .slice(0, limit);
144
+ }
145
+ // The studio card shows the newest note still in view. Salience still
146
+ // decides which thoughts a conversation carries.
147
+ latest(now = Date.now()) {
148
+ return (
149
+ this.open({ now, limit: 40 }).sort((a, b) => b.created - a.created)[0] ||
150
+ null
151
+ );
152
+ }
153
+ // Faded thoughts from this place that the conversation brings back.
154
+ reminded({ now = Date.now(), cue, cues, session = "", limit = 1 } = {}) {
155
+ const sets = cueList(cue, cues);
156
+ if (!sets.length) return [];
157
+ return this.weighed({ now, session })
158
+ .filter((t) => t.salience < THOUGHT_FADED && anyTouches(t.content, sets))
159
+ .slice(0, limit);
160
+ }
161
+ resolve(id, resolution = "", time = Date.now()) {
162
+ return (
163
+ this.db
164
+ .prepare(
165
+ "UPDATE mind_thoughts SET status='resolved',resolved_at=?,resolution=? WHERE id=? AND status='open'",
166
+ )
167
+ .run(time, text(resolution, 120), id).changes > 0
168
+ );
169
+ }
170
+ update(id, { status, hidden }) {
171
+ const current = this.get(id);
172
+ if (!current) throw Error("手记不存在");
173
+ if (status !== undefined && !["open", "resolved"].includes(status))
174
+ throw Error("状态无效");
175
+ if (hidden !== undefined && typeof hidden !== "boolean")
176
+ throw Error("开关无效");
177
+ const next = status ?? current.status;
178
+ const resolved =
179
+ next === current.status
180
+ ? [current.resolved_at ?? null, current.resolution ?? ""]
181
+ : next === "resolved"
182
+ ? [Date.now(), "在工作台里放下"]
183
+ : [null, ""];
184
+ this.db
185
+ .prepare(
186
+ "UPDATE mind_thoughts SET status=?,hidden=?,resolved_at=?,resolution=? WHERE id=?",
187
+ )
188
+ .run(
189
+ next,
190
+ hidden === undefined ? current.hidden : +hidden,
191
+ ...resolved,
192
+ id,
193
+ );
194
+ }
195
+ remove(id) {
196
+ if (
197
+ this.db.prepare("SELECT 1 FROM mind_thoughts WHERE parent_id=?").get(id)
198
+ )
199
+ throw Error("这条手记有后续修正,请隐藏以保留轨迹");
200
+ this.db.prepare("DELETE FROM mind_thoughts WHERE id=?").run(id);
201
+ }
202
+ setOutreach(id, status, reason = "") {
203
+ this.db
204
+ .prepare(
205
+ "UPDATE mind_thoughts SET outreach_status=?,outreach_wait_reason=?,outreach_retry_at=NULL WHERE id=?",
206
+ )
207
+ .run(status, text(reason, 300), id);
208
+ }
209
+ planOutreach(id, { session, words, reason, time }) {
210
+ const current = this.get(id);
211
+ if (!current || current.outreach_status !== "none") return false;
212
+ const draft = words
213
+ .filter((s) => typeof s === "string" && s.trim())
214
+ .slice(0, 3)
215
+ .map((s) => text(s, 180));
216
+ if (!session || !draft.length) return false;
217
+ this.db
218
+ .prepare(
219
+ "UPDATE mind_thoughts SET sessions=?,outreach=?,outreach_session=?,outreach_status='planned',outreach_at=?,outreach_reason=?,outreach_draft=? WHERE id=? AND outreach_status='none'",
220
+ )
221
+ .run(
222
+ // An independent thought is not owned by its first audience. Keep its
223
+ // origin scope; outreach_session records where she chose to share it.
224
+ JSON.stringify(
225
+ current.kind === "expression"
226
+ ? current.sessions
227
+ : [...new Set([...current.sessions, session])],
228
+ ),
229
+ draft.join("\n"),
230
+ session,
231
+ time,
232
+ text(reason, 300),
233
+ JSON.stringify(draft),
234
+ id,
235
+ );
236
+ return true;
237
+ }
238
+ deferOutreach(id, reason, retryAt) {
239
+ this.db
240
+ .prepare(
241
+ "UPDATE mind_thoughts SET outreach_status='planned',outreach_wait_reason=?,outreach_retry_at=? WHERE id=?",
242
+ )
243
+ .run(text(reason, 300), retryAt, id);
244
+ }
245
+ dueOutreach(now = Date.now()) {
246
+ return this.db
247
+ .prepare(
248
+ "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",
249
+ )
250
+ .all(now, now)
251
+ .map(row);
252
+ }
253
+ }
254
+
255
+ // The first time a source was lived. A later note that only repeats those
256
+ // sources keeps that time, so rewriting it does not make it newly present.
257
+ function sourceClock(notes, roots) {
258
+ const earned = new Map();
259
+ const clock = new Map();
260
+ for (const note of [...notes].sort((a, b) => a.created - b.created)) {
261
+ const resolved = roots(note.sources);
262
+ const sources = resolved.length ? resolved : [`t:${note.id}`];
263
+ const fresh = sources.some((source) => !earned.has(source));
264
+ if (fresh) {
265
+ clock.set(note.id, note.created);
266
+ for (const source of sources)
267
+ if (!earned.has(source)) earned.set(source, note.created);
268
+ } else {
269
+ clock.set(
270
+ note.id,
271
+ Math.min(...sources.map((source) => earned.get(source))),
272
+ );
273
+ }
274
+ }
275
+ return clock;
276
+ }