forge-openclaw-plugin 0.2.18 → 0.2.19
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.
- package/README.md +36 -4
- package/dist/assets/{board-2KevHCI0.js → board-8L3uX7_O.js} +2 -2
- package/dist/assets/{board-2KevHCI0.js.map → board-8L3uX7_O.js.map} +1 -1
- package/dist/assets/index-Cj1IBH_w.js +36 -0
- package/dist/assets/index-Cj1IBH_w.js.map +1 -0
- package/dist/assets/index-DQT6EbuS.css +1 -0
- package/dist/assets/{motion-q19HPmWs.js → motion-1GAqqi8M.js} +2 -2
- package/dist/assets/{motion-q19HPmWs.js.map → motion-1GAqqi8M.js.map} +1 -1
- package/dist/assets/{table-BDMHBY4a.js → table-DBGlgRjk.js} +2 -2
- package/dist/assets/{table-BDMHBY4a.js.map → table-DBGlgRjk.js.map} +1 -1
- package/dist/assets/{ui-CQ_AsFs8.js → ui-iTluWjC4.js} +2 -2
- package/dist/assets/{ui-CQ_AsFs8.js.map → ui-iTluWjC4.js.map} +1 -1
- package/dist/assets/{vendor-5HifrnRK.js → vendor-BvM2F9Dp.js} +139 -84
- package/dist/assets/vendor-BvM2F9Dp.js.map +1 -0
- package/dist/assets/{viz-CQzkRnTu.js → viz-CNeunkfu.js} +2 -2
- package/dist/assets/{viz-CQzkRnTu.js.map → viz-CNeunkfu.js.map} +1 -1
- package/dist/index.html +8 -8
- package/dist/openclaw/parity.js +1 -0
- package/dist/openclaw/routes.js +7 -0
- package/dist/openclaw/tools.js +183 -16
- package/dist/server/app.js +2509 -263
- package/dist/server/managers/platform/secrets-manager.js +44 -1
- package/dist/server/managers/runtime.js +3 -1
- package/dist/server/openapi.js +2037 -172
- package/dist/server/repositories/calendar.js +1101 -0
- package/dist/server/repositories/deleted-entities.js +10 -2
- package/dist/server/repositories/notes.js +161 -28
- package/dist/server/repositories/projects.js +45 -13
- package/dist/server/repositories/rewards.js +114 -6
- package/dist/server/repositories/settings.js +47 -5
- package/dist/server/repositories/task-runs.js +46 -10
- package/dist/server/repositories/tasks.js +25 -9
- package/dist/server/repositories/weekly-reviews.js +109 -0
- package/dist/server/repositories/work-adjustments.js +105 -0
- package/dist/server/services/calendar-runtime.js +1301 -0
- package/dist/server/services/entity-crud.js +94 -3
- package/dist/server/services/projects.js +32 -8
- package/dist/server/services/reviews.js +15 -1
- package/dist/server/services/work-time.js +27 -0
- package/dist/server/types.js +934 -49
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/006_work_adjustments.sql +14 -0
- package/server/migrations/007_weekly_review_closures.sql +17 -0
- package/server/migrations/008_calendar_execution.sql +147 -0
- package/server/migrations/009_true_calendar_events.sql +195 -0
- package/server/migrations/010_calendar_selection_state.sql +6 -0
- package/server/migrations/011_calendar_timezone_backfill.sql +11 -0
- package/server/migrations/012_work_block_ranges.sql +7 -0
- package/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
- package/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
- package/skills/forge-openclaw/SKILL.md +117 -11
- package/dist/assets/index-CDYW4WDH.js +0 -36
- package/dist/assets/index-CDYW4WDH.js.map +0 -1
- package/dist/assets/index-yroQr6YZ.css +0 -1
- package/dist/assets/vendor-5HifrnRK.js.map +0 -1
|
@@ -114,6 +114,8 @@ export function cascadeSoftDeleteAnchoredCollaboration(parentEntityType, parentE
|
|
|
114
114
|
notes.content_plain AS content_plain,
|
|
115
115
|
notes.author AS author,
|
|
116
116
|
notes.source AS source,
|
|
117
|
+
notes.tags_json AS tags_json,
|
|
118
|
+
notes.destroy_at AS destroy_at,
|
|
117
119
|
notes.created_at AS created_at,
|
|
118
120
|
notes.updated_at AS updated_at
|
|
119
121
|
FROM notes
|
|
@@ -145,18 +147,24 @@ export function cascadeSoftDeleteAnchoredCollaboration(parentEntityType, parentE
|
|
|
145
147
|
linksByNoteId.set(link.note_id, current);
|
|
146
148
|
}
|
|
147
149
|
for (const row of noteRows) {
|
|
148
|
-
const compact = (row.content_plain || row.content_markdown)
|
|
150
|
+
const compact = (row.content_plain || row.content_markdown)
|
|
151
|
+
.replace(/\s+/g, " ")
|
|
152
|
+
.trim();
|
|
149
153
|
upsertDeletedEntityRecord({
|
|
150
154
|
entityType: "note",
|
|
151
155
|
entityId: row.id,
|
|
152
156
|
title: compact.slice(0, 72) || "Note",
|
|
153
|
-
subtitle: compact.length > 72
|
|
157
|
+
subtitle: compact.length > 72
|
|
158
|
+
? compact.slice(72, 168).trim()
|
|
159
|
+
: `Linked to ${parentEntityType.replaceAll("_", " ")}`,
|
|
154
160
|
snapshot: {
|
|
155
161
|
id: row.id,
|
|
156
162
|
contentMarkdown: row.content_markdown,
|
|
157
163
|
contentPlain: row.content_plain,
|
|
158
164
|
author: row.author,
|
|
159
165
|
source: row.source,
|
|
166
|
+
tags: JSON.parse(row.tags_json),
|
|
167
|
+
destroyAt: row.destroy_at,
|
|
160
168
|
createdAt: row.created_at,
|
|
161
169
|
updatedAt: row.updated_at,
|
|
162
170
|
links: linksByNoteId.get(row.id) ?? []
|
|
@@ -18,6 +18,53 @@ function normalizeLinks(links) {
|
|
|
18
18
|
return true;
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
|
+
function normalizeTags(tags) {
|
|
22
|
+
if (!tags) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
return tags
|
|
27
|
+
.map((tag) => tag.trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.filter((tag) => {
|
|
30
|
+
const normalized = tag.toLowerCase();
|
|
31
|
+
if (seen.has(normalized)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
seen.add(normalized);
|
|
35
|
+
return true;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function parseTagsJson(raw) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
return Array.isArray(parsed)
|
|
42
|
+
? normalizeTags(parsed.filter((value) => typeof value === "string"))
|
|
43
|
+
: [];
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function noteMatchesTextTerm(note, term) {
|
|
50
|
+
const normalized = term.trim().toLowerCase();
|
|
51
|
+
if (!normalized) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return note.tags.some((tag) => tag.toLowerCase().includes(normalized));
|
|
55
|
+
}
|
|
56
|
+
function cleanupExpiredNotes() {
|
|
57
|
+
const expiredRows = getDatabase()
|
|
58
|
+
.prepare(`SELECT id
|
|
59
|
+
FROM notes
|
|
60
|
+
WHERE destroy_at IS NOT NULL
|
|
61
|
+
AND destroy_at != ''
|
|
62
|
+
AND destroy_at <= ?`)
|
|
63
|
+
.all(new Date().toISOString());
|
|
64
|
+
for (const row of expiredRows) {
|
|
65
|
+
deleteNoteInternal(row.id, { source: "system", actor: null }, "Ephemeral note expired");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
21
68
|
function stripMarkdown(markdown) {
|
|
22
69
|
return markdown
|
|
23
70
|
.replace(/```[\s\S]*?```/g, (block) => block.replace(/```/g, "").trim())
|
|
@@ -55,7 +102,7 @@ function buildFtsQuery(query) {
|
|
|
55
102
|
}
|
|
56
103
|
function getNoteRow(noteId) {
|
|
57
104
|
return getDatabase()
|
|
58
|
-
.prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
|
|
105
|
+
.prepare(`SELECT id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at
|
|
59
106
|
FROM notes
|
|
60
107
|
WHERE id = ?`)
|
|
61
108
|
.get(noteId);
|
|
@@ -86,6 +133,8 @@ function mapNote(row, linkRows) {
|
|
|
86
133
|
contentPlain: row.content_plain,
|
|
87
134
|
author: row.author,
|
|
88
135
|
source: row.source,
|
|
136
|
+
tags: parseTagsJson(row.tags_json),
|
|
137
|
+
destroyAt: row.destroy_at,
|
|
89
138
|
createdAt: row.created_at,
|
|
90
139
|
updatedAt: row.updated_at,
|
|
91
140
|
links: mapLinks(linkRows)
|
|
@@ -102,7 +151,7 @@ function deleteSearchRow(noteId) {
|
|
|
102
151
|
}
|
|
103
152
|
function listAllNoteRows() {
|
|
104
153
|
return getDatabase()
|
|
105
|
-
.prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
|
|
154
|
+
.prepare(`SELECT id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at
|
|
106
155
|
FROM notes
|
|
107
156
|
ORDER BY created_at DESC`)
|
|
108
157
|
.all();
|
|
@@ -117,6 +166,19 @@ function findMatchingNoteIds(query) {
|
|
|
117
166
|
.all(ftsQuery);
|
|
118
167
|
return new Set(rows.map((row) => row.note_id));
|
|
119
168
|
}
|
|
169
|
+
function findMatchingNoteIdsForTextTerms(terms) {
|
|
170
|
+
const normalizedTerms = terms.map((term) => term.trim()).filter(Boolean);
|
|
171
|
+
if (normalizedTerms.length === 0) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const matches = new Set();
|
|
175
|
+
for (const term of normalizedTerms) {
|
|
176
|
+
for (const noteId of findMatchingNoteIds(term)) {
|
|
177
|
+
matches.add(noteId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return matches;
|
|
181
|
+
}
|
|
120
182
|
function insertLinks(noteId, links, createdAt) {
|
|
121
183
|
const statement = getDatabase().prepare(`INSERT OR IGNORE INTO note_links (note_id, entity_type, entity_id, anchor_key, created_at)
|
|
122
184
|
VALUES (?, ?, ?, ?, ?)`);
|
|
@@ -164,7 +226,10 @@ function recordNoteActivity(note, eventType, title, context) {
|
|
|
164
226
|
});
|
|
165
227
|
}
|
|
166
228
|
}
|
|
167
|
-
export function getNoteById(noteId) {
|
|
229
|
+
export function getNoteById(noteId, options = {}) {
|
|
230
|
+
if (!options.skipCleanup) {
|
|
231
|
+
cleanupExpiredNotes();
|
|
232
|
+
}
|
|
168
233
|
if (isEntityDeleted("note", noteId)) {
|
|
169
234
|
return undefined;
|
|
170
235
|
}
|
|
@@ -174,7 +239,10 @@ export function getNoteById(noteId) {
|
|
|
174
239
|
}
|
|
175
240
|
return mapNote(row, listNoteLinks(noteId));
|
|
176
241
|
}
|
|
177
|
-
export function getNoteByIdIncludingDeleted(noteId) {
|
|
242
|
+
export function getNoteByIdIncludingDeleted(noteId, options = {}) {
|
|
243
|
+
if (!options.skipCleanup) {
|
|
244
|
+
cleanupExpiredNotes();
|
|
245
|
+
}
|
|
178
246
|
const row = getNoteRow(noteId);
|
|
179
247
|
if (!row) {
|
|
180
248
|
const deleted = getDeletedEntityRecord("note", noteId);
|
|
@@ -183,13 +251,26 @@ export function getNoteByIdIncludingDeleted(noteId) {
|
|
|
183
251
|
return mapNote(row, listNoteLinks(noteId));
|
|
184
252
|
}
|
|
185
253
|
export function listNotes(query = {}) {
|
|
254
|
+
cleanupExpiredNotes();
|
|
186
255
|
const parsed = notesListQuerySchema.parse(query);
|
|
187
|
-
|
|
188
|
-
parsed.
|
|
189
|
-
|
|
256
|
+
const linkedFilters = [
|
|
257
|
+
...(parsed.linkedEntityType && parsed.linkedEntityId
|
|
258
|
+
? [
|
|
259
|
+
{
|
|
260
|
+
entityType: parsed.linkedEntityType,
|
|
261
|
+
entityId: parsed.linkedEntityId
|
|
262
|
+
}
|
|
263
|
+
]
|
|
264
|
+
: []),
|
|
265
|
+
...parsed.linkedTo
|
|
266
|
+
];
|
|
267
|
+
if (linkedFilters.some((filter) => isEntityDeleted(filter.entityType, filter.entityId))) {
|
|
190
268
|
return [];
|
|
191
269
|
}
|
|
192
|
-
const matchingIds =
|
|
270
|
+
const matchingIds = findMatchingNoteIdsForTextTerms([
|
|
271
|
+
...(parsed.query ? [parsed.query] : []),
|
|
272
|
+
...parsed.textTerms
|
|
273
|
+
]);
|
|
193
274
|
const rows = listAllNoteRows();
|
|
194
275
|
const linksByNoteId = new Map();
|
|
195
276
|
for (const link of listLinkRowsForNotes(rows.map((row) => row.id))) {
|
|
@@ -198,32 +279,63 @@ export function listNotes(query = {}) {
|
|
|
198
279
|
linksByNoteId.set(link.note_id, current);
|
|
199
280
|
}
|
|
200
281
|
return filterDeletedEntities("note", rows
|
|
201
|
-
.filter((row) =>
|
|
202
|
-
|
|
282
|
+
.filter((row) => parsed.author
|
|
283
|
+
? (row.author ?? "")
|
|
284
|
+
.toLowerCase()
|
|
285
|
+
.includes(parsed.author.toLowerCase())
|
|
286
|
+
: true)
|
|
203
287
|
.map((row) => mapNote(row, linksByNoteId.get(row.id) ?? []))
|
|
204
|
-
.filter((note) =>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
288
|
+
.filter((note) => {
|
|
289
|
+
if (!matchingIds) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
return (matchingIds.has(note.id) ||
|
|
293
|
+
parsed.textTerms.some((term) => noteMatchesTextTerm(note, term)) ||
|
|
294
|
+
(parsed.query ? noteMatchesTextTerm(note, parsed.query) : false));
|
|
295
|
+
})
|
|
296
|
+
.filter((note) => linkedFilters.length > 0
|
|
297
|
+
? note.links.some((link) => linkedFilters.some((filter) => link.entityType === filter.entityType &&
|
|
298
|
+
link.entityId === filter.entityId &&
|
|
299
|
+
(parsed.anchorKey === undefined
|
|
300
|
+
? true
|
|
301
|
+
: (link.anchorKey ?? null) === parsed.anchorKey)))
|
|
302
|
+
: true)
|
|
303
|
+
.filter((note) => parsed.tags.length > 0
|
|
304
|
+
? parsed.tags.every((filterTag) => note.tags.some((noteTag) => noteTag.toLowerCase() === filterTag.toLowerCase()))
|
|
208
305
|
: true)
|
|
306
|
+
.filter((note) => {
|
|
307
|
+
if (!parsed.updatedFrom && !parsed.updatedTo) {
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
const updatedDate = note.updatedAt.slice(0, 10);
|
|
311
|
+
if (parsed.updatedFrom && updatedDate < parsed.updatedFrom) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
if (parsed.updatedTo && updatedDate > parsed.updatedTo) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
return true;
|
|
318
|
+
})
|
|
209
319
|
.slice(0, parsed.limit ?? 100));
|
|
210
320
|
}
|
|
211
321
|
export function createNote(input, context) {
|
|
322
|
+
cleanupExpiredNotes();
|
|
212
323
|
const parsed = createNoteSchema.parse({
|
|
213
324
|
...input,
|
|
214
|
-
links: normalizeLinks(input.links)
|
|
325
|
+
links: normalizeLinks(input.links),
|
|
326
|
+
tags: normalizeTags(input.tags)
|
|
215
327
|
});
|
|
216
328
|
const now = new Date().toISOString();
|
|
217
329
|
const id = `note_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
218
330
|
const contentPlain = stripMarkdown(parsed.contentMarkdown);
|
|
219
331
|
getDatabase()
|
|
220
|
-
.prepare(`INSERT INTO notes (id, content_markdown, content_plain, author, source, created_at, updated_at)
|
|
221
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
222
|
-
.run(id, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, now, now);
|
|
332
|
+
.prepare(`INSERT INTO notes (id, content_markdown, content_plain, author, source, tags_json, destroy_at, created_at, updated_at)
|
|
333
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
334
|
+
.run(id, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, JSON.stringify(parsed.tags), parsed.destroyAt, now, now);
|
|
223
335
|
insertLinks(id, parsed.links, now);
|
|
224
336
|
clearDeletedEntityRecord("note", id);
|
|
225
337
|
upsertSearchRow(id, contentPlain, parsed.author ?? context.actor ?? null);
|
|
226
|
-
const note = getNoteById(id);
|
|
338
|
+
const note = getNoteById(id, { skipCleanup: true });
|
|
227
339
|
recordNoteActivity(note, "note.created", "Note added", context);
|
|
228
340
|
return note;
|
|
229
341
|
}
|
|
@@ -234,31 +346,37 @@ export function createLinkedNotes(notes, entityLink, context) {
|
|
|
234
346
|
return notes.map((note) => createNote({
|
|
235
347
|
contentMarkdown: note.contentMarkdown,
|
|
236
348
|
author: note.author,
|
|
349
|
+
tags: note.tags,
|
|
350
|
+
destroyAt: note.destroyAt,
|
|
237
351
|
links: [entityLink, ...note.links]
|
|
238
352
|
}, context));
|
|
239
353
|
}
|
|
240
354
|
export function updateNote(noteId, input, context) {
|
|
241
|
-
|
|
355
|
+
cleanupExpiredNotes();
|
|
356
|
+
const existing = getNoteByIdIncludingDeleted(noteId, { skipCleanup: true });
|
|
242
357
|
if (!existing) {
|
|
243
358
|
return undefined;
|
|
244
359
|
}
|
|
245
360
|
const patch = updateNoteSchema.parse({
|
|
246
361
|
...input,
|
|
247
|
-
links: input.links ? normalizeLinks(input.links) : undefined
|
|
362
|
+
links: input.links ? normalizeLinks(input.links) : undefined,
|
|
363
|
+
tags: input.tags ? normalizeTags(input.tags) : undefined
|
|
248
364
|
});
|
|
249
365
|
const nextMarkdown = patch.contentMarkdown ?? existing.contentMarkdown;
|
|
250
366
|
const nextPlain = stripMarkdown(nextMarkdown);
|
|
251
367
|
const nextAuthor = patch.author === undefined ? existing.author : patch.author;
|
|
368
|
+
const nextTags = patch.tags ?? existing.tags;
|
|
369
|
+
const nextDestroyAt = patch.destroyAt === undefined ? existing.destroyAt : patch.destroyAt;
|
|
252
370
|
const updatedAt = new Date().toISOString();
|
|
253
371
|
getDatabase()
|
|
254
372
|
.prepare(`UPDATE notes
|
|
255
|
-
SET content_markdown = ?, content_plain = ?, author = ?, updated_at = ?
|
|
373
|
+
SET content_markdown = ?, content_plain = ?, author = ?, tags_json = ?, destroy_at = ?, updated_at = ?
|
|
256
374
|
WHERE id = ?`)
|
|
257
|
-
.run(nextMarkdown, nextPlain, nextAuthor, updatedAt, noteId);
|
|
375
|
+
.run(nextMarkdown, nextPlain, nextAuthor, JSON.stringify(nextTags), nextDestroyAt, updatedAt, noteId);
|
|
258
376
|
if (patch.links) {
|
|
259
377
|
replaceLinks(noteId, patch.links, updatedAt);
|
|
260
378
|
}
|
|
261
|
-
const note = getNoteByIdIncludingDeleted(noteId);
|
|
379
|
+
const note = getNoteByIdIncludingDeleted(noteId, { skipCleanup: true });
|
|
262
380
|
if (note.links.length > 0) {
|
|
263
381
|
clearDeletedEntityRecord("note", noteId);
|
|
264
382
|
}
|
|
@@ -275,21 +393,34 @@ export function updateNote(noteId, input, context) {
|
|
|
275
393
|
});
|
|
276
394
|
}
|
|
277
395
|
upsertSearchRow(noteId, nextPlain, nextAuthor);
|
|
396
|
+
if (nextDestroyAt && Date.parse(nextDestroyAt) <= Date.now()) {
|
|
397
|
+
deleteNoteInternal(noteId, { source: "system", actor: null }, "Ephemeral note expired");
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
278
400
|
recordNoteActivity(note, "note.updated", "Note updated", context);
|
|
279
401
|
return getNoteById(noteId);
|
|
280
402
|
}
|
|
281
|
-
|
|
282
|
-
const existing =
|
|
403
|
+
function deleteNoteInternal(noteId, context, title) {
|
|
404
|
+
const existing = getNoteRow(noteId)
|
|
405
|
+
? mapNote(getNoteRow(noteId), listNoteLinks(noteId))
|
|
406
|
+
: getDeletedEntityRecord("note", noteId)?.snapshot;
|
|
283
407
|
if (!existing) {
|
|
284
408
|
return undefined;
|
|
285
409
|
}
|
|
410
|
+
clearDeletedEntityRecord("note", noteId);
|
|
286
411
|
getDatabase().prepare(`DELETE FROM note_links WHERE note_id = ?`).run(noteId);
|
|
287
412
|
getDatabase().prepare(`DELETE FROM notes WHERE id = ?`).run(noteId);
|
|
288
413
|
deleteSearchRow(noteId);
|
|
289
|
-
|
|
414
|
+
clearDeletedEntityRecord("note", noteId);
|
|
415
|
+
recordNoteActivity(existing, "note.deleted", title, context);
|
|
290
416
|
return existing;
|
|
291
417
|
}
|
|
418
|
+
export function deleteNote(noteId, context) {
|
|
419
|
+
cleanupExpiredNotes();
|
|
420
|
+
return deleteNoteInternal(noteId, context, "Note deleted");
|
|
421
|
+
}
|
|
292
422
|
export function buildNotesSummaryByEntity() {
|
|
423
|
+
cleanupExpiredNotes();
|
|
293
424
|
const rows = getDatabase()
|
|
294
425
|
.prepare(`SELECT
|
|
295
426
|
note_links.entity_type AS entity_type,
|
|
@@ -302,8 +433,9 @@ export function buildNotesSummaryByEntity() {
|
|
|
302
433
|
ON deleted_entities.entity_type = 'note'
|
|
303
434
|
AND deleted_entities.entity_id = notes.id
|
|
304
435
|
WHERE deleted_entities.entity_id IS NULL
|
|
436
|
+
AND (notes.destroy_at IS NULL OR notes.destroy_at = '' OR notes.destroy_at > ?)
|
|
305
437
|
ORDER BY notes.created_at DESC`)
|
|
306
|
-
.all();
|
|
438
|
+
.all(new Date().toISOString());
|
|
307
439
|
return rows.reduce((acc, row) => {
|
|
308
440
|
const key = `${row.entity_type}:${row.entity_id}`;
|
|
309
441
|
const current = acc[key];
|
|
@@ -324,6 +456,7 @@ export function buildNotesSummaryByEntity() {
|
|
|
324
456
|
}, {});
|
|
325
457
|
}
|
|
326
458
|
export function unlinkNotesForEntity(entityType, entityId, context) {
|
|
459
|
+
cleanupExpiredNotes();
|
|
327
460
|
const noteIds = getDatabase()
|
|
328
461
|
.prepare(`SELECT DISTINCT note_id FROM note_links WHERE entity_type = ? AND entity_id = ?`)
|
|
329
462
|
.all(entityType, entityId);
|
|
@@ -6,7 +6,8 @@ import { createLinkedNotes } from "./notes.js";
|
|
|
6
6
|
import { assertGoalExists } from "../services/relations.js";
|
|
7
7
|
import { getGoalById } from "./goals.js";
|
|
8
8
|
import { pruneLinkedEntityReferences } from "./psyche.js";
|
|
9
|
-
import {
|
|
9
|
+
import { listTasks, updateTaskInTransaction } from "./tasks.js";
|
|
10
|
+
import { calendarSchedulingRulesSchema, createProjectSchema, projectSchema, updateProjectSchema } from "../types.js";
|
|
10
11
|
function getDefaultProjectTemplate(goal) {
|
|
11
12
|
switch (goal.title) {
|
|
12
13
|
case "Build a durable body and calm energy":
|
|
@@ -40,10 +41,18 @@ function mapProject(row) {
|
|
|
40
41
|
status: row.status,
|
|
41
42
|
themeColor: row.theme_color,
|
|
42
43
|
targetPoints: row.target_points,
|
|
44
|
+
schedulingRules: calendarSchedulingRulesSchema.parse(JSON.parse(row.scheduling_rules_json || "{}")),
|
|
43
45
|
createdAt: row.created_at,
|
|
44
46
|
updatedAt: row.updated_at
|
|
45
47
|
});
|
|
46
48
|
}
|
|
49
|
+
function completeLinkedProjectTasks(projectId, activity) {
|
|
50
|
+
const openTasks = listTasks({ projectId }).filter((task) => task.status !== "done");
|
|
51
|
+
for (const task of openTasks) {
|
|
52
|
+
updateTaskInTransaction(task.id, { status: "done" }, activity);
|
|
53
|
+
}
|
|
54
|
+
return openTasks.length;
|
|
55
|
+
}
|
|
47
56
|
export function listProjects(filters = {}) {
|
|
48
57
|
const whereClauses = [];
|
|
49
58
|
const params = [];
|
|
@@ -62,6 +71,7 @@ export function listProjects(filters = {}) {
|
|
|
62
71
|
}
|
|
63
72
|
const rows = getDatabase()
|
|
64
73
|
.prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
|
|
74
|
+
, scheduling_rules_json
|
|
65
75
|
FROM projects
|
|
66
76
|
${whereSql}
|
|
67
77
|
ORDER BY created_at ASC
|
|
@@ -75,6 +85,7 @@ export function getProjectById(projectId) {
|
|
|
75
85
|
}
|
|
76
86
|
const row = getDatabase()
|
|
77
87
|
.prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
|
|
88
|
+
, scheduling_rules_json
|
|
78
89
|
FROM projects
|
|
79
90
|
WHERE id = ?`)
|
|
80
91
|
.get(projectId);
|
|
@@ -87,9 +98,9 @@ export function createProject(input, activity) {
|
|
|
87
98
|
const now = new Date().toISOString();
|
|
88
99
|
const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
89
100
|
getDatabase()
|
|
90
|
-
.prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at)
|
|
91
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
92
|
-
.run(id, parsed.goalId, parsed.title, parsed.description, parsed.status, parsed.themeColor, parsed.targetPoints, now, now);
|
|
101
|
+
.prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, scheduling_rules_json, created_at, updated_at)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
103
|
+
.run(id, parsed.goalId, parsed.title, parsed.description, parsed.status, parsed.themeColor, parsed.targetPoints, JSON.stringify(parsed.schedulingRules), now, now);
|
|
93
104
|
const project = getProjectById(id);
|
|
94
105
|
createLinkedNotes(parsed.notes, { entityType: "project", entityId: project.id, anchorKey: null }, activity ?? { source: "ui", actor: null });
|
|
95
106
|
if (activity) {
|
|
@@ -127,32 +138,42 @@ export function updateProject(projectId, input, activity) {
|
|
|
127
138
|
status: parsed.status ?? current.status,
|
|
128
139
|
themeColor: parsed.themeColor ?? current.themeColor,
|
|
129
140
|
targetPoints: parsed.targetPoints ?? current.targetPoints,
|
|
141
|
+
schedulingRules: parsed.schedulingRules ?? current.schedulingRules,
|
|
130
142
|
updatedAt: new Date().toISOString()
|
|
131
143
|
};
|
|
132
144
|
getDatabase()
|
|
133
145
|
.prepare(`UPDATE projects
|
|
134
|
-
SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, updated_at = ?
|
|
146
|
+
SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, scheduling_rules_json = ?, updated_at = ?
|
|
135
147
|
WHERE id = ?`)
|
|
136
|
-
.run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, next.updatedAt, projectId);
|
|
148
|
+
.run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, JSON.stringify(next.schedulingRules), next.updatedAt, projectId);
|
|
137
149
|
// Keep legacy task.goal_id aligned with the project's parent goal.
|
|
138
150
|
getDatabase()
|
|
139
151
|
.prepare(`UPDATE tasks SET goal_id = ?, updated_at = ? WHERE project_id = ?`)
|
|
140
152
|
.run(next.goalId, next.updatedAt, projectId);
|
|
153
|
+
const completedLinkedTaskCount = current.status !== "completed" && next.status === "completed"
|
|
154
|
+
? completeLinkedProjectTasks(projectId, activity)
|
|
155
|
+
: 0;
|
|
141
156
|
const project = getProjectById(projectId);
|
|
142
157
|
if (project && activity) {
|
|
158
|
+
const statusChanged = current.status !== project.status;
|
|
143
159
|
recordActivityEvent({
|
|
144
160
|
entityType: "project",
|
|
145
161
|
entityId: project.id,
|
|
146
|
-
eventType:
|
|
147
|
-
title:
|
|
148
|
-
description:
|
|
162
|
+
eventType: statusChanged ? "project_status_changed" : "project_updated",
|
|
163
|
+
title: statusChanged ? `Project ${project.status}: ${project.title}` : `Project updated: ${project.title}`,
|
|
164
|
+
description: statusChanged && project.status === "completed"
|
|
165
|
+
? `Project finished and auto-completed ${completedLinkedTaskCount} linked unfinished task${completedLinkedTaskCount === 1 ? "" : "s"}.`
|
|
166
|
+
: statusChanged
|
|
167
|
+
? `Project status changed from ${current.status} to ${project.status}.`
|
|
168
|
+
: "Project details were updated.",
|
|
149
169
|
actor: activity.actor ?? null,
|
|
150
170
|
source: activity.source,
|
|
151
171
|
metadata: {
|
|
152
172
|
goalId: project.goalId,
|
|
153
173
|
previousGoalId: current.goalId,
|
|
154
174
|
status: project.status,
|
|
155
|
-
previousStatus: current.status
|
|
175
|
+
previousStatus: current.status,
|
|
176
|
+
completedLinkedTaskCount
|
|
156
177
|
}
|
|
157
178
|
});
|
|
158
179
|
}
|
|
@@ -174,9 +195,20 @@ export function ensureDefaultProjectForGoal(goalId) {
|
|
|
174
195
|
const now = new Date().toISOString();
|
|
175
196
|
const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
176
197
|
getDatabase()
|
|
177
|
-
.prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at)
|
|
178
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
179
|
-
.run(id, goalId, template.title, template.description, goal.status === "completed" ? "completed" : goal.status === "paused" ? "paused" : "active", goal.themeColor, Math.max(100, Math.round(goal.targetPoints / 2)),
|
|
198
|
+
.prepare(`INSERT INTO projects (id, goal_id, title, description, status, theme_color, target_points, scheduling_rules_json, created_at, updated_at)
|
|
199
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
200
|
+
.run(id, goalId, template.title, template.description, goal.status === "completed" ? "completed" : goal.status === "paused" ? "paused" : "active", goal.themeColor, Math.max(100, Math.round(goal.targetPoints / 2)), JSON.stringify({
|
|
201
|
+
allowWorkBlockKinds: [],
|
|
202
|
+
blockWorkBlockKinds: [],
|
|
203
|
+
allowCalendarIds: [],
|
|
204
|
+
blockCalendarIds: [],
|
|
205
|
+
allowEventTypes: [],
|
|
206
|
+
blockEventTypes: [],
|
|
207
|
+
allowEventKeywords: [],
|
|
208
|
+
blockEventKeywords: [],
|
|
209
|
+
allowAvailability: [],
|
|
210
|
+
blockAvailability: []
|
|
211
|
+
}), now, now);
|
|
180
212
|
return getProjectById(id);
|
|
181
213
|
});
|
|
182
214
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { getDatabase } from "../db.js";
|
|
3
3
|
import { recordEventLog } from "./event-log.js";
|
|
4
|
-
import { createManualRewardGrantSchema, rewardLedgerEventSchema, rewardRuleSchema, sessionEventSchema, updateRewardRuleSchema } from "../types.js";
|
|
4
|
+
import { createManualRewardGrantSchema, workAdjustmentEntityTypeSchema, rewardLedgerEventSchema, rewardRuleSchema, sessionEventSchema, updateRewardRuleSchema } from "../types.js";
|
|
5
5
|
const DEFAULT_RULES = [
|
|
6
6
|
{
|
|
7
7
|
id: "reward_rule_task_completion",
|
|
@@ -107,6 +107,14 @@ const DEFAULT_RULES = [
|
|
|
107
107
|
description: "Reward giving a recurring mode enough shape to recognize it later.",
|
|
108
108
|
config: { fixedXp: 4 }
|
|
109
109
|
},
|
|
110
|
+
{
|
|
111
|
+
id: "reward_rule_weekly_review_completed",
|
|
112
|
+
family: "alignment",
|
|
113
|
+
code: "weekly_review_completed",
|
|
114
|
+
title: "Weekly review completed",
|
|
115
|
+
description: "Reward closing the current weekly review cycle and turning it into explicit evidence.",
|
|
116
|
+
config: { fixedXp: 250 }
|
|
117
|
+
},
|
|
110
118
|
{
|
|
111
119
|
id: "reward_rule_session_dwell",
|
|
112
120
|
family: "ambient",
|
|
@@ -194,6 +202,17 @@ export function getRewardRuleById(ruleId) {
|
|
|
194
202
|
function getRuleByCode(code) {
|
|
195
203
|
return listRewardRules().find((rule) => rule.code === code);
|
|
196
204
|
}
|
|
205
|
+
export function getTaskRunProgressRewardCadence() {
|
|
206
|
+
ensureDefaultRewardRules();
|
|
207
|
+
const rule = getRuleByCode("task_run_progress");
|
|
208
|
+
const intervalMinutes = Math.max(1, Number(rule?.config.intervalMinutes ?? 10));
|
|
209
|
+
return {
|
|
210
|
+
rule,
|
|
211
|
+
intervalMinutes,
|
|
212
|
+
intervalSeconds: intervalMinutes * 60,
|
|
213
|
+
fixedXp: Number(rule?.config.fixedXp ?? 4)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
197
216
|
export function updateRewardRule(ruleId, input, activity) {
|
|
198
217
|
ensureDefaultRewardRules();
|
|
199
218
|
const current = getRewardRuleById(ruleId);
|
|
@@ -279,6 +298,17 @@ export function listRewardLedger(filters = {}) {
|
|
|
279
298
|
.all(...params);
|
|
280
299
|
return rows.map(mapLedger);
|
|
281
300
|
}
|
|
301
|
+
export function getRewardLedgerEventById(rewardId) {
|
|
302
|
+
ensureDefaultRewardRules();
|
|
303
|
+
const row = getDatabase()
|
|
304
|
+
.prepare(`SELECT
|
|
305
|
+
id, rule_id, event_log_id, entity_type, entity_id, actor, source, delta_xp, reason_title, reason_summary,
|
|
306
|
+
reversible_group, reversed_by_reward_id, metadata_json, created_at
|
|
307
|
+
FROM reward_ledger
|
|
308
|
+
WHERE id = ?`)
|
|
309
|
+
.get(rewardId);
|
|
310
|
+
return row ? mapLedger(row) : null;
|
|
311
|
+
}
|
|
282
312
|
export function getTotalXp() {
|
|
283
313
|
ensureDefaultRewardRules();
|
|
284
314
|
const row = getDatabase().prepare(`SELECT COALESCE(SUM(delta_xp), 0) AS total FROM reward_ledger`).get();
|
|
@@ -537,11 +567,7 @@ export function recordTaskRunStartReward(taskRunId, taskId, actor, source) {
|
|
|
537
567
|
});
|
|
538
568
|
}
|
|
539
569
|
export function recordTaskRunProgressRewards(taskRunId, taskId, actor, source, creditedSeconds) {
|
|
540
|
-
|
|
541
|
-
const rule = getRuleByCode("task_run_progress");
|
|
542
|
-
const intervalMinutes = Math.max(1, Number(rule?.config.intervalMinutes ?? 10));
|
|
543
|
-
const intervalSeconds = intervalMinutes * 60;
|
|
544
|
-
const fixedXp = Number(rule?.config.fixedXp ?? 4);
|
|
570
|
+
const { rule, intervalMinutes, intervalSeconds, fixedXp } = getTaskRunProgressRewardCadence();
|
|
545
571
|
const earnedBuckets = Math.floor(Math.max(0, creditedSeconds) / intervalSeconds);
|
|
546
572
|
if (earnedBuckets <= 0) {
|
|
547
573
|
return [];
|
|
@@ -593,6 +619,55 @@ export function recordTaskRunProgressRewards(taskRunId, taskId, actor, source, c
|
|
|
593
619
|
}
|
|
594
620
|
return rewards;
|
|
595
621
|
}
|
|
622
|
+
export function recordWorkAdjustmentReward(input) {
|
|
623
|
+
const { rule, intervalMinutes, intervalSeconds, fixedXp } = getTaskRunProgressRewardCadence();
|
|
624
|
+
const entityType = workAdjustmentEntityTypeSchema.parse(input.entityType);
|
|
625
|
+
const previousBuckets = Math.floor(Math.max(0, input.previousCreditedSeconds) / intervalSeconds);
|
|
626
|
+
const nextBuckets = Math.floor(Math.max(0, input.nextCreditedSeconds) / intervalSeconds);
|
|
627
|
+
const bucketDelta = nextBuckets - previousBuckets;
|
|
628
|
+
if (bucketDelta === 0) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
const deltaXp = bucketDelta * fixedXp;
|
|
632
|
+
const direction = bucketDelta > 0 ? "added" : "removed";
|
|
633
|
+
const appliedMinutes = Math.abs(input.appliedDeltaMinutes);
|
|
634
|
+
const eventLog = recordEventLog({
|
|
635
|
+
eventKind: "reward.work_adjustment",
|
|
636
|
+
entityType,
|
|
637
|
+
entityId: input.entityId,
|
|
638
|
+
actor: input.actor ?? null,
|
|
639
|
+
source: input.source,
|
|
640
|
+
metadata: {
|
|
641
|
+
adjustmentId: input.adjustmentId,
|
|
642
|
+
requestedDeltaMinutes: input.requestedDeltaMinutes,
|
|
643
|
+
appliedDeltaMinutes: input.appliedDeltaMinutes,
|
|
644
|
+
bucketDelta,
|
|
645
|
+
deltaXp
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
return insertLedgerEvent({
|
|
649
|
+
ruleId: rule?.id ?? null,
|
|
650
|
+
eventLogId: eventLog.id,
|
|
651
|
+
entityType,
|
|
652
|
+
entityId: input.entityId,
|
|
653
|
+
actor: input.actor ?? null,
|
|
654
|
+
source: input.source,
|
|
655
|
+
deltaXp,
|
|
656
|
+
reasonTitle: bucketDelta > 0 ? "Manual work minutes added" : "Manual work minutes removed",
|
|
657
|
+
reasonSummary: `${appliedMinutes} manual minute${appliedMinutes === 1 ? "" : "s"} ${direction}, shifting ${Math.abs(bucketDelta)} ${intervalMinutes}-minute reward bucket${Math.abs(bucketDelta) === 1 ? "" : "s"} for ${input.targetTitle}.`,
|
|
658
|
+
reversibleGroup: `work_adjustment:${entityType}:${input.entityId}:${input.adjustmentId}`,
|
|
659
|
+
metadata: {
|
|
660
|
+
adjustmentId: input.adjustmentId,
|
|
661
|
+
requestedDeltaMinutes: input.requestedDeltaMinutes,
|
|
662
|
+
appliedDeltaMinutes: input.appliedDeltaMinutes,
|
|
663
|
+
previousCreditedSeconds: input.previousCreditedSeconds,
|
|
664
|
+
nextCreditedSeconds: input.nextCreditedSeconds,
|
|
665
|
+
bucketDelta,
|
|
666
|
+
intervalMinutes,
|
|
667
|
+
rewardCategory: "manual_work_adjustment"
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
}
|
|
596
671
|
export function recordSessionEvent(input, activity, now = new Date()) {
|
|
597
672
|
ensureDefaultRewardRules();
|
|
598
673
|
const sessionEvent = sessionEventSchema.parse({
|
|
@@ -695,6 +770,39 @@ export function recordHabitCheckInReward(habit, status, dateKey, activity) {
|
|
|
695
770
|
}
|
|
696
771
|
});
|
|
697
772
|
}
|
|
773
|
+
export function recordWeeklyReviewCompletionReward(input, activity) {
|
|
774
|
+
ensureDefaultRewardRules();
|
|
775
|
+
const rule = getRuleByCode("weekly_review_completed");
|
|
776
|
+
const deltaXp = Math.max(0, Number(rule?.config.fixedXp ?? input.rewardXp));
|
|
777
|
+
const eventLog = recordEventLog({
|
|
778
|
+
eventKind: "reward.weekly_review_completed",
|
|
779
|
+
entityType: "system",
|
|
780
|
+
entityId: input.weekKey,
|
|
781
|
+
actor: activity.actor ?? null,
|
|
782
|
+
source: activity.source,
|
|
783
|
+
metadata: {
|
|
784
|
+
weekKey: input.weekKey,
|
|
785
|
+
windowLabel: input.windowLabel,
|
|
786
|
+
deltaXp
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
return insertLedgerEvent({
|
|
790
|
+
ruleId: rule?.id ?? null,
|
|
791
|
+
eventLogId: eventLog.id,
|
|
792
|
+
entityType: "system",
|
|
793
|
+
entityId: input.weekKey,
|
|
794
|
+
actor: activity.actor ?? null,
|
|
795
|
+
source: activity.source,
|
|
796
|
+
deltaXp,
|
|
797
|
+
reasonTitle: rule?.title ?? "Weekly review completed",
|
|
798
|
+
reasonSummary: `Closed the review for ${input.windowLabel}.`,
|
|
799
|
+
reversibleGroup: `weekly_review_completed:${input.weekKey}`,
|
|
800
|
+
metadata: {
|
|
801
|
+
weekKey: input.weekKey,
|
|
802
|
+
windowLabel: input.windowLabel
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
}
|
|
698
806
|
export function listSessionEvents(limit = 50) {
|
|
699
807
|
const rows = getDatabase()
|
|
700
808
|
.prepare(`SELECT id, session_id, event_type, actor, source, metrics_json, created_at
|