forge-openclaw-plugin 0.2.4 → 0.2.10

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 (114) hide show
  1. package/README.md +186 -6
  2. package/dist/assets/board-C_m78kvK.js +6 -0
  3. package/dist/assets/board-C_m78kvK.js.map +1 -0
  4. package/dist/assets/favicon-BCHm9dUV.ico +0 -0
  5. package/dist/assets/index-BWtLtXwb.js +36 -0
  6. package/dist/assets/index-BWtLtXwb.js.map +1 -0
  7. package/dist/assets/index-Dp5GXY_z.css +1 -0
  8. package/dist/assets/motion-CpZvZumD.js +10 -0
  9. package/dist/assets/motion-CpZvZumD.js.map +1 -0
  10. package/dist/assets/plus-jakarta-sans-latin-ext-wght-normal-DmpS2jIq.woff2 +0 -0
  11. package/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  12. package/dist/assets/plus-jakarta-sans-vietnamese-wght-normal-qRpaaN48.woff2 +0 -0
  13. package/dist/assets/sora-latin-ext-wght-normal-CawQDOvP.woff2 +0 -0
  14. package/dist/assets/sora-latin-wght-normal-DdqRvwsR.woff2 +0 -0
  15. package/dist/assets/space-grotesk-latin-500-normal-CNSSEhBt.woff +0 -0
  16. package/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
  17. package/dist/assets/space-grotesk-latin-700-normal-CwsQ-cCU.woff +0 -0
  18. package/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
  19. package/dist/assets/space-grotesk-latin-ext-500-normal-3dgZTiw9.woff +0 -0
  20. package/dist/assets/space-grotesk-latin-ext-500-normal-DUe3BAxM.woff2 +0 -0
  21. package/dist/assets/space-grotesk-latin-ext-700-normal-BQnZhY3m.woff2 +0 -0
  22. package/dist/assets/space-grotesk-latin-ext-700-normal-HVCqSBdx.woff +0 -0
  23. package/dist/assets/space-grotesk-vietnamese-500-normal-BTqKIpxg.woff +0 -0
  24. package/dist/assets/space-grotesk-vietnamese-500-normal-BmEvtly_.woff2 +0 -0
  25. package/dist/assets/space-grotesk-vietnamese-700-normal-DMty7AZE.woff2 +0 -0
  26. package/dist/assets/space-grotesk-vietnamese-700-normal-Duxec5Rn.woff +0 -0
  27. package/dist/assets/table-DtyXTw03.js +23 -0
  28. package/dist/assets/table-DtyXTw03.js.map +1 -0
  29. package/dist/assets/ui-BXbpiKyS.js +46 -0
  30. package/dist/assets/ui-BXbpiKyS.js.map +1 -0
  31. package/dist/assets/vendor-CRS-psbw.css +1 -0
  32. package/dist/assets/vendor-QBH6qVEe.js +433 -0
  33. package/dist/assets/vendor-QBH6qVEe.js.map +1 -0
  34. package/dist/assets/viz-w-IMeueL.js +34 -0
  35. package/dist/assets/viz-w-IMeueL.js.map +1 -0
  36. package/dist/favicon.ico +0 -0
  37. package/dist/favicon.png +0 -0
  38. package/dist/index.html +29 -0
  39. package/dist/openclaw/api-client.d.ts +9 -0
  40. package/dist/openclaw/api-client.js +31 -4
  41. package/dist/openclaw/local-runtime.d.ts +3 -0
  42. package/dist/openclaw/local-runtime.js +136 -0
  43. package/dist/openclaw/parity.d.ts +4 -4
  44. package/dist/openclaw/parity.js +23 -33
  45. package/dist/openclaw/plugin-entry-shared.d.ts +4 -2
  46. package/dist/openclaw/plugin-entry-shared.js +63 -9
  47. package/dist/openclaw/routes.d.ts +12 -3
  48. package/dist/openclaw/routes.js +156 -924
  49. package/dist/openclaw/tools.js +242 -1100
  50. package/dist/server/app.js +2487 -0
  51. package/dist/server/db.js +313 -0
  52. package/dist/server/demo-data.js +49 -0
  53. package/dist/server/e2e-server.js +20 -0
  54. package/dist/server/errors.js +15 -0
  55. package/dist/server/index.js +16 -0
  56. package/dist/server/managers/base.js +17 -0
  57. package/dist/server/managers/contracts.js +47 -0
  58. package/dist/server/managers/platform/api-gateway-manager.js +11 -0
  59. package/dist/server/managers/platform/audit-manager.js +15 -0
  60. package/dist/server/managers/platform/authentication-manager.js +56 -0
  61. package/dist/server/managers/platform/authorization-manager.js +56 -0
  62. package/dist/server/managers/platform/background-job-manager.js +10 -0
  63. package/dist/server/managers/platform/configuration-manager.js +33 -0
  64. package/dist/server/managers/platform/database-manager.js +14 -0
  65. package/dist/server/managers/platform/event-bus-manager.js +7 -0
  66. package/dist/server/managers/platform/external-service-manager.js +11 -0
  67. package/dist/server/managers/platform/health-manager.js +7 -0
  68. package/dist/server/managers/platform/migration-manager.js +8 -0
  69. package/dist/server/managers/platform/search-index-manager.js +4 -0
  70. package/dist/server/managers/platform/secrets-manager.js +19 -0
  71. package/dist/server/managers/platform/session-manager.js +121 -0
  72. package/dist/server/managers/platform/storage-manager.js +16 -0
  73. package/dist/server/managers/platform/token-manager.js +37 -0
  74. package/dist/server/managers/platform/transaction-manager.js +8 -0
  75. package/dist/server/managers/platform/trusted-network.js +39 -0
  76. package/dist/server/managers/runtime.js +56 -0
  77. package/dist/server/managers/type-guards.js +4 -0
  78. package/dist/server/openapi.js +3553 -0
  79. package/dist/server/psyche-types.js +366 -0
  80. package/dist/server/repositories/activity-events.js +157 -0
  81. package/dist/server/repositories/collaboration.js +497 -0
  82. package/dist/server/repositories/deleted-entities.js +226 -0
  83. package/dist/server/repositories/domains.js +30 -0
  84. package/dist/server/repositories/event-log.js +64 -0
  85. package/dist/server/repositories/goals.js +156 -0
  86. package/dist/server/repositories/notes.js +359 -0
  87. package/dist/server/repositories/projects.js +211 -0
  88. package/dist/server/repositories/psyche.js +1353 -0
  89. package/dist/server/repositories/rewards.js +675 -0
  90. package/dist/server/repositories/settings.js +399 -0
  91. package/dist/server/repositories/tags.js +160 -0
  92. package/dist/server/repositories/task-runs.js +490 -0
  93. package/dist/server/repositories/tasks.js +424 -0
  94. package/dist/server/seed-demo.js +11 -0
  95. package/dist/server/services/context.js +214 -0
  96. package/dist/server/services/dashboard.js +173 -0
  97. package/dist/server/services/entity-crud.js +573 -0
  98. package/dist/server/services/gamification.js +215 -0
  99. package/dist/server/services/insights.js +91 -0
  100. package/dist/server/services/projects.js +77 -0
  101. package/dist/server/services/psyche.js +63 -0
  102. package/dist/server/services/relations.js +28 -0
  103. package/dist/server/services/reviews.js +88 -0
  104. package/dist/server/services/run-recovery.js +13 -0
  105. package/dist/server/services/tagging.js +49 -0
  106. package/dist/server/services/task-run-watchdog.js +92 -0
  107. package/dist/server/services/work-time.js +176 -0
  108. package/dist/server/types.js +1058 -0
  109. package/dist/server/web.js +91 -0
  110. package/openclaw.plugin.json +32 -9
  111. package/package.json +17 -4
  112. package/server/migrations/001_core.sql +411 -0
  113. package/server/migrations/002_psyche.sql +392 -0
  114. package/skills/forge-openclaw/SKILL.md +197 -271
@@ -0,0 +1,359 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDatabase } from "../db.js";
3
+ import { recordActivityEvent } from "./activity-events.js";
4
+ import { filterDeletedEntities, getDeletedEntityRecord, clearDeletedEntityRecord, isEntityDeleted, upsertDeletedEntityRecord } from "./deleted-entities.js";
5
+ import { recordEventLog } from "./event-log.js";
6
+ import { noteSchema, notesListQuerySchema, createNoteSchema, updateNoteSchema } from "../types.js";
7
+ function normalizeAnchorKey(anchorKey) {
8
+ return anchorKey.trim().length > 0 ? anchorKey : null;
9
+ }
10
+ function normalizeLinks(links) {
11
+ const seen = new Set();
12
+ return links.filter((link) => {
13
+ const key = `${link.entityType}:${link.entityId}:${link.anchorKey ?? ""}`;
14
+ if (seen.has(key)) {
15
+ return false;
16
+ }
17
+ seen.add(key);
18
+ return true;
19
+ });
20
+ }
21
+ function stripMarkdown(markdown) {
22
+ return markdown
23
+ .replace(/```[\s\S]*?```/g, (block) => block.replace(/```/g, "").trim())
24
+ .replace(/`([^`]+)`/g, "$1")
25
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
26
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
27
+ .replace(/^>\s?/gm, "")
28
+ .replace(/^#{1,6}\s+/gm, "")
29
+ .replace(/^[-*+]\s+/gm, "")
30
+ .replace(/^\d+\.\s+/gm, "")
31
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
32
+ .replace(/\*([^*]+)\*/g, "$1")
33
+ .replace(/_([^_]+)_/g, "$1")
34
+ .replace(/~~([^~]+)~~/g, "$1")
35
+ .replace(/\r/g, "")
36
+ .trim();
37
+ }
38
+ function describeNote(note) {
39
+ const plain = note.contentPlain.trim() || stripMarkdown(note.contentMarkdown);
40
+ const compact = plain.replace(/\s+/g, " ").trim();
41
+ const title = compact.slice(0, 72) || "Note";
42
+ const subtitle = compact.length > 72 ? compact.slice(72, 168).trim() : "";
43
+ return { title, subtitle };
44
+ }
45
+ function buildFtsQuery(query) {
46
+ const tokens = query
47
+ .trim()
48
+ .split(/\s+/)
49
+ .map((token) => token.replace(/["*']/g, "").trim())
50
+ .filter(Boolean);
51
+ if (tokens.length === 0) {
52
+ return null;
53
+ }
54
+ return tokens.map((token) => `${token}*`).join(" AND ");
55
+ }
56
+ function getNoteRow(noteId) {
57
+ return getDatabase()
58
+ .prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
59
+ FROM notes
60
+ WHERE id = ?`)
61
+ .get(noteId);
62
+ }
63
+ function listLinkRowsForNotes(noteIds) {
64
+ if (noteIds.length === 0) {
65
+ return [];
66
+ }
67
+ const placeholders = noteIds.map(() => "?").join(", ");
68
+ return getDatabase()
69
+ .prepare(`SELECT note_id, entity_type, entity_id, anchor_key, created_at
70
+ FROM note_links
71
+ WHERE note_id IN (${placeholders})
72
+ ORDER BY created_at ASC`)
73
+ .all(...noteIds);
74
+ }
75
+ function mapLinks(rows) {
76
+ return rows.map((row) => ({
77
+ entityType: row.entity_type,
78
+ entityId: row.entity_id,
79
+ anchorKey: normalizeAnchorKey(row.anchor_key)
80
+ }));
81
+ }
82
+ function mapNote(row, linkRows) {
83
+ return noteSchema.parse({
84
+ id: row.id,
85
+ contentMarkdown: row.content_markdown,
86
+ contentPlain: row.content_plain,
87
+ author: row.author,
88
+ source: row.source,
89
+ createdAt: row.created_at,
90
+ updatedAt: row.updated_at,
91
+ links: mapLinks(linkRows)
92
+ });
93
+ }
94
+ function upsertSearchRow(noteId, contentPlain, author) {
95
+ getDatabase().prepare(`DELETE FROM notes_fts WHERE note_id = ?`).run(noteId);
96
+ getDatabase()
97
+ .prepare(`INSERT INTO notes_fts (note_id, content_plain, author) VALUES (?, ?, ?)`)
98
+ .run(noteId, contentPlain, author ?? "");
99
+ }
100
+ function deleteSearchRow(noteId) {
101
+ getDatabase().prepare(`DELETE FROM notes_fts WHERE note_id = ?`).run(noteId);
102
+ }
103
+ function listAllNoteRows() {
104
+ return getDatabase()
105
+ .prepare(`SELECT id, content_markdown, content_plain, author, source, created_at, updated_at
106
+ FROM notes
107
+ ORDER BY created_at DESC`)
108
+ .all();
109
+ }
110
+ function findMatchingNoteIds(query) {
111
+ const ftsQuery = buildFtsQuery(query);
112
+ if (!ftsQuery) {
113
+ return new Set();
114
+ }
115
+ const rows = getDatabase()
116
+ .prepare(`SELECT note_id FROM notes_fts WHERE notes_fts MATCH ?`)
117
+ .all(ftsQuery);
118
+ return new Set(rows.map((row) => row.note_id));
119
+ }
120
+ function insertLinks(noteId, links, createdAt) {
121
+ const statement = getDatabase().prepare(`INSERT OR IGNORE INTO note_links (note_id, entity_type, entity_id, anchor_key, created_at)
122
+ VALUES (?, ?, ?, ?, ?)`);
123
+ for (const link of links) {
124
+ statement.run(noteId, link.entityType, link.entityId, link.anchorKey ?? "", createdAt);
125
+ }
126
+ }
127
+ function replaceLinks(noteId, links, createdAt) {
128
+ getDatabase().prepare(`DELETE FROM note_links WHERE note_id = ?`).run(noteId);
129
+ insertLinks(noteId, links, createdAt);
130
+ }
131
+ function listNoteLinks(noteId) {
132
+ return getDatabase()
133
+ .prepare(`SELECT note_id, entity_type, entity_id, anchor_key, created_at
134
+ FROM note_links
135
+ WHERE note_id = ?
136
+ ORDER BY created_at ASC`)
137
+ .all(noteId);
138
+ }
139
+ function recordNoteActivity(note, eventType, title, context) {
140
+ for (const link of note.links) {
141
+ recordActivityEvent({
142
+ entityType: link.entityType,
143
+ entityId: link.entityId,
144
+ eventType,
145
+ title,
146
+ description: note.contentPlain,
147
+ actor: note.author ?? context.actor ?? null,
148
+ source: context.source,
149
+ metadata: {
150
+ noteId: note.id,
151
+ anchorKey: link.anchorKey ?? ""
152
+ }
153
+ });
154
+ recordEventLog({
155
+ eventKind: eventType,
156
+ entityType: link.entityType,
157
+ entityId: link.entityId,
158
+ actor: note.author ?? context.actor ?? null,
159
+ source: context.source,
160
+ metadata: {
161
+ noteId: note.id,
162
+ anchorKey: link.anchorKey ?? ""
163
+ }
164
+ });
165
+ }
166
+ }
167
+ export function getNoteById(noteId) {
168
+ if (isEntityDeleted("note", noteId)) {
169
+ return undefined;
170
+ }
171
+ const row = getNoteRow(noteId);
172
+ if (!row) {
173
+ return undefined;
174
+ }
175
+ return mapNote(row, listNoteLinks(noteId));
176
+ }
177
+ export function getNoteByIdIncludingDeleted(noteId) {
178
+ const row = getNoteRow(noteId);
179
+ if (!row) {
180
+ const deleted = getDeletedEntityRecord("note", noteId);
181
+ return deleted?.snapshot;
182
+ }
183
+ return mapNote(row, listNoteLinks(noteId));
184
+ }
185
+ export function listNotes(query = {}) {
186
+ const parsed = notesListQuerySchema.parse(query);
187
+ if (parsed.linkedEntityType &&
188
+ parsed.linkedEntityId &&
189
+ isEntityDeleted(parsed.linkedEntityType, parsed.linkedEntityId)) {
190
+ return [];
191
+ }
192
+ const matchingIds = parsed.query ? findMatchingNoteIds(parsed.query) : null;
193
+ const rows = listAllNoteRows();
194
+ const linksByNoteId = new Map();
195
+ for (const link of listLinkRowsForNotes(rows.map((row) => row.id))) {
196
+ const current = linksByNoteId.get(link.note_id) ?? [];
197
+ current.push(link);
198
+ linksByNoteId.set(link.note_id, current);
199
+ }
200
+ return filterDeletedEntities("note", rows
201
+ .filter((row) => (matchingIds ? matchingIds.has(row.id) : true))
202
+ .filter((row) => (parsed.author ? (row.author ?? "").toLowerCase().includes(parsed.author.toLowerCase()) : true))
203
+ .map((row) => mapNote(row, linksByNoteId.get(row.id) ?? []))
204
+ .filter((note) => parsed.linkedEntityType && parsed.linkedEntityId
205
+ ? note.links.some((link) => link.entityType === parsed.linkedEntityType &&
206
+ link.entityId === parsed.linkedEntityId &&
207
+ (parsed.anchorKey === undefined ? true : (link.anchorKey ?? null) === parsed.anchorKey))
208
+ : true)
209
+ .slice(0, parsed.limit ?? 100));
210
+ }
211
+ export function createNote(input, context) {
212
+ const parsed = createNoteSchema.parse({
213
+ ...input,
214
+ links: normalizeLinks(input.links)
215
+ });
216
+ const now = new Date().toISOString();
217
+ const id = `note_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
218
+ const contentPlain = stripMarkdown(parsed.contentMarkdown);
219
+ 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);
223
+ insertLinks(id, parsed.links, now);
224
+ clearDeletedEntityRecord("note", id);
225
+ upsertSearchRow(id, contentPlain, parsed.author ?? context.actor ?? null);
226
+ const note = getNoteById(id);
227
+ recordNoteActivity(note, "note.created", "Note added", context);
228
+ return note;
229
+ }
230
+ export function createLinkedNotes(notes, entityLink, context) {
231
+ if (!notes || notes.length === 0) {
232
+ return [];
233
+ }
234
+ return notes.map((note) => createNote({
235
+ contentMarkdown: note.contentMarkdown,
236
+ author: note.author,
237
+ links: [entityLink, ...note.links]
238
+ }, context));
239
+ }
240
+ export function updateNote(noteId, input, context) {
241
+ const existing = getNoteByIdIncludingDeleted(noteId);
242
+ if (!existing) {
243
+ return undefined;
244
+ }
245
+ const patch = updateNoteSchema.parse({
246
+ ...input,
247
+ links: input.links ? normalizeLinks(input.links) : undefined
248
+ });
249
+ const nextMarkdown = patch.contentMarkdown ?? existing.contentMarkdown;
250
+ const nextPlain = stripMarkdown(nextMarkdown);
251
+ const nextAuthor = patch.author === undefined ? existing.author : patch.author;
252
+ const updatedAt = new Date().toISOString();
253
+ getDatabase()
254
+ .prepare(`UPDATE notes
255
+ SET content_markdown = ?, content_plain = ?, author = ?, updated_at = ?
256
+ WHERE id = ?`)
257
+ .run(nextMarkdown, nextPlain, nextAuthor, updatedAt, noteId);
258
+ if (patch.links) {
259
+ replaceLinks(noteId, patch.links, updatedAt);
260
+ }
261
+ const note = getNoteByIdIncludingDeleted(noteId);
262
+ if (note.links.length > 0) {
263
+ clearDeletedEntityRecord("note", noteId);
264
+ }
265
+ else {
266
+ const details = describeNote(note);
267
+ upsertDeletedEntityRecord({
268
+ entityType: "note",
269
+ entityId: note.id,
270
+ title: details.title,
271
+ subtitle: details.subtitle,
272
+ snapshot: note,
273
+ deleteReason: "Note no longer has any linked entities.",
274
+ context
275
+ });
276
+ }
277
+ upsertSearchRow(noteId, nextPlain, nextAuthor);
278
+ recordNoteActivity(note, "note.updated", "Note updated", context);
279
+ return getNoteById(noteId);
280
+ }
281
+ export function deleteNote(noteId, context) {
282
+ const existing = getNoteByIdIncludingDeleted(noteId);
283
+ if (!existing) {
284
+ return undefined;
285
+ }
286
+ getDatabase().prepare(`DELETE FROM note_links WHERE note_id = ?`).run(noteId);
287
+ getDatabase().prepare(`DELETE FROM notes WHERE id = ?`).run(noteId);
288
+ deleteSearchRow(noteId);
289
+ recordNoteActivity(existing, "note.deleted", "Note deleted", context);
290
+ return existing;
291
+ }
292
+ export function buildNotesSummaryByEntity() {
293
+ const rows = getDatabase()
294
+ .prepare(`SELECT
295
+ note_links.entity_type AS entity_type,
296
+ note_links.entity_id AS entity_id,
297
+ notes.id AS note_id,
298
+ notes.created_at AS created_at
299
+ FROM note_links
300
+ INNER JOIN notes ON notes.id = note_links.note_id
301
+ LEFT JOIN deleted_entities
302
+ ON deleted_entities.entity_type = 'note'
303
+ AND deleted_entities.entity_id = notes.id
304
+ WHERE deleted_entities.entity_id IS NULL
305
+ ORDER BY notes.created_at DESC`)
306
+ .all();
307
+ return rows.reduce((acc, row) => {
308
+ const key = `${row.entity_type}:${row.entity_id}`;
309
+ const current = acc[key];
310
+ if (!current) {
311
+ acc[key] = {
312
+ count: 1,
313
+ latestNoteId: row.note_id,
314
+ latestCreatedAt: row.created_at
315
+ };
316
+ return acc;
317
+ }
318
+ current.count += 1;
319
+ if (!current.latestCreatedAt || row.created_at > current.latestCreatedAt) {
320
+ current.latestCreatedAt = row.created_at;
321
+ current.latestNoteId = row.note_id;
322
+ }
323
+ return acc;
324
+ }, {});
325
+ }
326
+ export function unlinkNotesForEntity(entityType, entityId, context) {
327
+ const noteIds = getDatabase()
328
+ .prepare(`SELECT DISTINCT note_id FROM note_links WHERE entity_type = ? AND entity_id = ?`)
329
+ .all(entityType, entityId);
330
+ if (noteIds.length === 0) {
331
+ return;
332
+ }
333
+ getDatabase()
334
+ .prepare(`DELETE FROM note_links WHERE entity_type = ? AND entity_id = ?`)
335
+ .run(entityType, entityId);
336
+ for (const row of noteIds) {
337
+ const remaining = getDatabase()
338
+ .prepare(`SELECT COUNT(*) AS count FROM note_links WHERE note_id = ?`)
339
+ .get(row.note_id);
340
+ if (remaining.count > 0) {
341
+ clearDeletedEntityRecord("note", row.note_id);
342
+ continue;
343
+ }
344
+ const note = getNoteByIdIncludingDeleted(row.note_id);
345
+ if (!note) {
346
+ continue;
347
+ }
348
+ const details = describeNote(note);
349
+ upsertDeletedEntityRecord({
350
+ entityType: "note",
351
+ entityId: note.id,
352
+ title: details.title,
353
+ subtitle: details.subtitle,
354
+ snapshot: { ...note, links: [] },
355
+ deleteReason: `All links were removed when ${entityType} ${entityId} was deleted.`,
356
+ context
357
+ });
358
+ }
359
+ }
@@ -0,0 +1,211 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDatabase, runInTransaction } from "../db.js";
3
+ import { recordActivityEvent } from "./activity-events.js";
4
+ import { filterDeletedEntities, isEntityDeleted } from "./deleted-entities.js";
5
+ import { createLinkedNotes } from "./notes.js";
6
+ import { assertGoalExists } from "../services/relations.js";
7
+ import { getGoalById } from "./goals.js";
8
+ import { pruneLinkedEntityReferences } from "./psyche.js";
9
+ import { createProjectSchema, projectSchema, updateProjectSchema } from "../types.js";
10
+ function getDefaultProjectTemplate(goal) {
11
+ switch (goal.title) {
12
+ case "Build a durable body and calm energy":
13
+ return {
14
+ title: "Energy Foundation Sprint",
15
+ description: "Build the routines, scheduling, and recovery rhythm that make consistent physical energy possible."
16
+ };
17
+ case "Ship meaningful creative work every week":
18
+ return {
19
+ title: "Weekly Creative Shipping System",
20
+ description: "Create a repeatable system for deep work, reviews, and visible weekly output."
21
+ };
22
+ case "Strengthen shared life systems":
23
+ return {
24
+ title: "Shared Life Admin Reset",
25
+ description: "Reduce friction in logistics, planning, and recurring obligations that support shared life."
26
+ };
27
+ default:
28
+ return {
29
+ title: `${goal.title}: Active Project`,
30
+ description: "Concrete workstream under this life goal so tasks, evidence, and progress have a clear home."
31
+ };
32
+ }
33
+ }
34
+ function mapProject(row) {
35
+ return projectSchema.parse({
36
+ id: row.id,
37
+ goalId: row.goal_id,
38
+ title: row.title,
39
+ description: row.description,
40
+ status: row.status,
41
+ themeColor: row.theme_color,
42
+ targetPoints: row.target_points,
43
+ createdAt: row.created_at,
44
+ updatedAt: row.updated_at
45
+ });
46
+ }
47
+ export function listProjects(filters = {}) {
48
+ const whereClauses = [];
49
+ const params = [];
50
+ if (filters.goalId) {
51
+ whereClauses.push("goal_id = ?");
52
+ params.push(filters.goalId);
53
+ }
54
+ if (filters.status) {
55
+ whereClauses.push("status = ?");
56
+ params.push(filters.status);
57
+ }
58
+ const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
59
+ const limitSql = filters.limit ? "LIMIT ?" : "";
60
+ if (filters.limit) {
61
+ params.push(filters.limit);
62
+ }
63
+ const rows = getDatabase()
64
+ .prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
65
+ FROM projects
66
+ ${whereSql}
67
+ ORDER BY created_at ASC
68
+ ${limitSql}`)
69
+ .all(...params);
70
+ return filterDeletedEntities("project", rows.map(mapProject));
71
+ }
72
+ export function getProjectById(projectId) {
73
+ if (isEntityDeleted("project", projectId)) {
74
+ return undefined;
75
+ }
76
+ const row = getDatabase()
77
+ .prepare(`SELECT id, goal_id, title, description, status, theme_color, target_points, created_at, updated_at
78
+ FROM projects
79
+ WHERE id = ?`)
80
+ .get(projectId);
81
+ return row ? mapProject(row) : undefined;
82
+ }
83
+ export function createProject(input, activity) {
84
+ return runInTransaction(() => {
85
+ const parsed = createProjectSchema.parse(input);
86
+ assertGoalExists(parsed.goalId);
87
+ const now = new Date().toISOString();
88
+ const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
89
+ 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);
93
+ const project = getProjectById(id);
94
+ createLinkedNotes(parsed.notes, { entityType: "project", entityId: project.id, anchorKey: null }, activity ?? { source: "ui", actor: null });
95
+ if (activity) {
96
+ recordActivityEvent({
97
+ entityType: "project",
98
+ entityId: project.id,
99
+ eventType: "project_created",
100
+ title: `Project created: ${project.title}`,
101
+ description: "A new path was added under a life goal.",
102
+ actor: activity.actor ?? null,
103
+ source: activity.source,
104
+ metadata: {
105
+ goalId: project.goalId,
106
+ status: project.status,
107
+ targetPoints: project.targetPoints
108
+ }
109
+ });
110
+ }
111
+ return project;
112
+ });
113
+ }
114
+ export function updateProject(projectId, input, activity) {
115
+ const current = getProjectById(projectId);
116
+ if (!current) {
117
+ return undefined;
118
+ }
119
+ return runInTransaction(() => {
120
+ const parsed = updateProjectSchema.parse(input);
121
+ const nextGoalId = parsed.goalId ?? current.goalId;
122
+ assertGoalExists(nextGoalId);
123
+ const next = {
124
+ goalId: nextGoalId,
125
+ title: parsed.title ?? current.title,
126
+ description: parsed.description ?? current.description,
127
+ status: parsed.status ?? current.status,
128
+ themeColor: parsed.themeColor ?? current.themeColor,
129
+ targetPoints: parsed.targetPoints ?? current.targetPoints,
130
+ updatedAt: new Date().toISOString()
131
+ };
132
+ getDatabase()
133
+ .prepare(`UPDATE projects
134
+ SET goal_id = ?, title = ?, description = ?, status = ?, theme_color = ?, target_points = ?, updated_at = ?
135
+ WHERE id = ?`)
136
+ .run(next.goalId, next.title, next.description, next.status, next.themeColor, next.targetPoints, next.updatedAt, projectId);
137
+ // Keep legacy task.goal_id aligned with the project's parent goal.
138
+ getDatabase()
139
+ .prepare(`UPDATE tasks SET goal_id = ?, updated_at = ? WHERE project_id = ?`)
140
+ .run(next.goalId, next.updatedAt, projectId);
141
+ const project = getProjectById(projectId);
142
+ if (project && activity) {
143
+ recordActivityEvent({
144
+ entityType: "project",
145
+ entityId: project.id,
146
+ eventType: current.status !== project.status ? "project_status_changed" : "project_updated",
147
+ title: current.status !== project.status ? `Project ${project.status}: ${project.title}` : `Project updated: ${project.title}`,
148
+ description: "Project details were updated.",
149
+ actor: activity.actor ?? null,
150
+ source: activity.source,
151
+ metadata: {
152
+ goalId: project.goalId,
153
+ previousGoalId: current.goalId,
154
+ status: project.status,
155
+ previousStatus: current.status
156
+ }
157
+ });
158
+ }
159
+ return project;
160
+ });
161
+ }
162
+ export function ensureDefaultProjectForGoal(goalId) {
163
+ assertGoalExists(goalId);
164
+ const existing = listProjects({ goalId, limit: 1 })[0];
165
+ if (existing) {
166
+ return existing;
167
+ }
168
+ return runInTransaction(() => {
169
+ const goal = getGoalById(goalId);
170
+ if (!goal) {
171
+ throw new Error(`Goal ${goalId} is missing`);
172
+ }
173
+ const template = getDefaultProjectTemplate(goal);
174
+ const now = new Date().toISOString();
175
+ const id = `project_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
176
+ 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)), now, now);
180
+ return getProjectById(id);
181
+ });
182
+ }
183
+ export function deleteProject(projectId, activity) {
184
+ const current = getProjectById(projectId);
185
+ if (!current) {
186
+ return undefined;
187
+ }
188
+ return runInTransaction(() => {
189
+ pruneLinkedEntityReferences("project", projectId);
190
+ getDatabase()
191
+ .prepare(`DELETE FROM projects WHERE id = ?`)
192
+ .run(projectId);
193
+ if (activity) {
194
+ recordActivityEvent({
195
+ entityType: "project",
196
+ entityId: current.id,
197
+ eventType: "project_deleted",
198
+ title: `Project deleted: ${current.title}`,
199
+ description: "Project removed from the system.",
200
+ actor: activity.actor ?? null,
201
+ source: activity.source,
202
+ metadata: {
203
+ goalId: current.goalId,
204
+ status: current.status,
205
+ targetPoints: current.targetPoints
206
+ }
207
+ });
208
+ }
209
+ return current;
210
+ });
211
+ }