forge-openclaw-plugin 0.2.27 → 0.2.29

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 (39) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{board-C6jCchjI.js → board-q8cfwaAW.js} +2 -2
  3. package/dist/assets/{board-C6jCchjI.js.map → board-q8cfwaAW.js.map} +1 -1
  4. package/dist/assets/index-C6PCeHD_.css +1 -0
  5. package/dist/assets/index-bfHIqj0-.js +85 -0
  6. package/dist/assets/index-bfHIqj0-.js.map +1 -0
  7. package/dist/assets/{motion-DFHrH2rd.js → motion-DHfqFntt.js} +2 -2
  8. package/dist/assets/{motion-DFHrH2rd.js.map → motion-DHfqFntt.js.map} +1 -1
  9. package/dist/assets/{table-ZL7Di_u3.js → table-DLweENXt.js} +2 -2
  10. package/dist/assets/{table-ZL7Di_u3.js.map → table-DLweENXt.js.map} +1 -1
  11. package/dist/assets/{ui-CKNPpz7q.js → ui-BV0OYxkH.js} +2 -2
  12. package/dist/assets/{ui-CKNPpz7q.js.map → ui-BV0OYxkH.js.map} +1 -1
  13. package/dist/assets/{vendor-DoNZuFhn.js → vendor-OwcH20PM.js} +204 -204
  14. package/dist/assets/vendor-OwcH20PM.js.map +1 -0
  15. package/dist/index.html +7 -7
  16. package/dist/server/server/migrations/044_macos_local_calendar_provider.sql +21 -0
  17. package/dist/server/server/src/app.js +331 -14
  18. package/dist/server/server/src/openapi.js +828 -3
  19. package/dist/server/server/src/repositories/calendar.js +295 -12
  20. package/dist/server/server/src/repositories/tasks.js +36 -17
  21. package/dist/server/server/src/services/calendar-runtime.js +613 -32
  22. package/dist/server/server/src/services/life-force-model.js +20 -0
  23. package/dist/server/server/src/services/life-force.js +1333 -97
  24. package/dist/server/server/src/services/macos-calendar-helper.js +748 -0
  25. package/dist/server/server/src/types.js +67 -3
  26. package/dist/server/src/lib/api-error.js +2 -0
  27. package/dist/server/src/lib/api.js +39 -2
  28. package/dist/server/src/lib/calendar-name-deduper.js +2 -0
  29. package/dist/server/src/lib/snapshot-normalizer.js +2 -0
  30. package/openclaw.plugin.json +1 -1
  31. package/package.json +1 -1
  32. package/server/migrations/044_macos_local_calendar_provider.sql +21 -0
  33. package/skills/forge-openclaw/SKILL.md +38 -5
  34. package/skills/forge-openclaw/entity_conversation_playbooks.md +326 -5
  35. package/skills/forge-openclaw/psyche_entity_playbooks.md +57 -0
  36. package/dist/assets/index-DVvS8iiU.css +0 -1
  37. package/dist/assets/index-zYB-9Dfo.js +0 -85
  38. package/dist/assets/index-zYB-9Dfo.js.map +0 -1
  39. package/dist/assets/vendor-DoNZuFhn.js.map +0 -1
@@ -4,6 +4,7 @@ import { recordActivityEvent } from "./activity-events.js";
4
4
  import { decorateOwnedEntity, filterOwnedEntities, inferFirstOwnedUserId, setEntityOwner } from "./entity-ownership.js";
5
5
  import { getProjectById } from "./projects.js";
6
6
  import { getTaskById } from "./tasks.js";
7
+ import { buildCalendarEventActionProfile, buildTaskTimeboxActionProfile, buildWorkBlockTemplateActionProfile, readEntityActionProfile, upsertEntityActionProfile } from "../services/life-force.js";
7
8
  import { calendarConnectionSchema, calendarContextConflictSchema, calendarEventSchema, calendarEventLinkSchema, calendarEventSourceSchema, calendarOverviewPayloadSchema, calendarSchema, calendarSchedulingRulesSchema, taskTimeboxSchema, workBlockInstanceSchema, workBlockTemplateSchema } from "../types.js";
8
9
  const DEFAULT_SCHEDULING_RULES = {
9
10
  allowWorkBlockKinds: [],
@@ -63,6 +64,12 @@ function mapCalendar(row) {
63
64
  canWrite: Boolean(row.can_write),
64
65
  selectedForSync: Boolean(row.selected_for_sync),
65
66
  forgeManaged: Boolean(row.forge_managed),
67
+ sourceId: row.source_id,
68
+ sourceTitle: row.source_title,
69
+ sourceType: row.source_type,
70
+ calendarType: row.calendar_type,
71
+ hostCalendarId: row.host_calendar_id,
72
+ canonicalKey: row.canonical_key,
66
73
  lastSyncedAt: row.last_synced_at,
67
74
  createdAt: row.created_at,
68
75
  updatedAt: row.updated_at
@@ -149,6 +156,11 @@ function mapEvent(row) {
149
156
  categories: JSON.parse(row.categories_json || "[]"),
150
157
  sourceMappings,
151
158
  links: listEventLinksForEvent(row.id),
159
+ actionProfile: readEntityActionProfile("calendar_event", row.id, {
160
+ profileKey: `calendar_event_${row.id}`,
161
+ title: row.title,
162
+ entityType: "calendar_event"
163
+ }),
152
164
  remoteUpdatedAt: primarySource?.lastSyncedAt ?? null,
153
165
  deletedAt: row.deleted_at,
154
166
  createdAt: row.created_at,
@@ -168,11 +180,17 @@ function mapWorkBlockTemplate(row) {
168
180
  startsOn: row.starts_on,
169
181
  endsOn: row.ends_on,
170
182
  blockingState: row.blocking_state,
183
+ actionProfile: readEntityActionProfile("work_block_template", row.id, {
184
+ profileKey: `work_block_template_${row.id}`,
185
+ title: row.title,
186
+ entityType: "work_block_template"
187
+ }),
171
188
  createdAt: row.created_at,
172
189
  updatedAt: row.updated_at
173
190
  });
174
191
  }
175
192
  function mapTimebox(row) {
193
+ const task = getTaskById(row.task_id);
176
194
  return taskTimeboxSchema.parse({
177
195
  id: row.id,
178
196
  taskId: row.task_id,
@@ -187,6 +205,21 @@ function mapTimebox(row) {
187
205
  startsAt: row.starts_at,
188
206
  endsAt: row.ends_at,
189
207
  overrideReason: row.override_reason,
208
+ actionProfile: readEntityActionProfile("task_timebox", row.id, {
209
+ profileKey: `task_timebox_${row.id}`,
210
+ title: row.title,
211
+ entityType: "task_timebox"
212
+ }) ??
213
+ (task
214
+ ? buildTaskTimeboxActionProfile({
215
+ timeboxId: row.id,
216
+ title: row.title,
217
+ taskId: row.task_id,
218
+ taskPlannedDurationSeconds: task.plannedDurationSeconds,
219
+ startsAt: row.starts_at,
220
+ endsAt: row.ends_at
221
+ })
222
+ : null),
190
223
  createdAt: row.created_at,
191
224
  updatedAt: row.updated_at
192
225
  });
@@ -228,6 +261,13 @@ export function readEncryptedSecret(secretId) {
228
261
  export function deleteEncryptedSecret(secretId) {
229
262
  getDatabase().prepare(`DELETE FROM stored_secrets WHERE id = ?`).run(secretId);
230
263
  }
264
+ export function isSupersededCalendarConnection(connectionId) {
265
+ const connection = getCalendarConnectionById(connectionId);
266
+ if (!connection) {
267
+ return false;
268
+ }
269
+ return isSupersededConnection(connection);
270
+ }
231
271
  export function listCalendarConnections() {
232
272
  const rows = getDatabase()
233
273
  .prepare(`SELECT id, provider, label, account_label, status, config_json, credentials_secret_id, forge_calendar_id,
@@ -237,6 +277,15 @@ export function listCalendarConnections() {
237
277
  .all();
238
278
  return rows.map(mapConnection);
239
279
  }
280
+ function isSupersededConnection(connection) {
281
+ return (typeof connection.config.replacedByConnectionId === "string" &&
282
+ connection.config.replacedByConnectionId.trim().length > 0);
283
+ }
284
+ function activeConnectionIds() {
285
+ return new Set(listCalendarConnections()
286
+ .filter((connection) => !isSupersededConnection(connection))
287
+ .map((connection) => connection.id));
288
+ }
240
289
  export function getCalendarConnectionById(connectionId) {
241
290
  const row = getDatabase()
242
291
  .prepare(`SELECT id, provider, label, account_label, status, config_json, credentials_secret_id, forge_calendar_id,
@@ -303,6 +352,70 @@ export function deleteExternalEventsForConnection(connectionId) {
303
352
  }
304
353
  return rows.map((row) => row.id);
305
354
  }
355
+ export function rehomeCalendarConnectionReferences(input) {
356
+ return runInTransaction(() => {
357
+ const fromCalendars = listCalendars(input.fromConnectionId, {
358
+ includeUnselected: true
359
+ });
360
+ const toCalendars = listCalendars(input.toConnectionId, {
361
+ includeUnselected: true
362
+ });
363
+ const toForgeCalendar = toCalendars.find((calendar) => calendar.forgeManaged) ??
364
+ toCalendars.find((calendar) => calendar.canWrite) ??
365
+ null;
366
+ const toByCanonicalKey = new Map(toCalendars
367
+ .filter((calendar) => typeof calendar.canonicalKey === "string" &&
368
+ calendar.canonicalKey.trim().length > 0)
369
+ .map((calendar) => [calendar.canonicalKey, calendar]));
370
+ const mappedCalendarIds = new Map();
371
+ for (const fromCalendar of fromCalendars) {
372
+ const mapped = (fromCalendar.canonicalKey
373
+ ? toByCanonicalKey.get(fromCalendar.canonicalKey)
374
+ : null) ??
375
+ (fromCalendar.forgeManaged ? toForgeCalendar : null) ??
376
+ null;
377
+ mappedCalendarIds.set(fromCalendar.id, mapped?.id ?? null);
378
+ }
379
+ const now = nowIso();
380
+ const forgeEventRows = getDatabase()
381
+ .prepare(`SELECT id, preferred_calendar_id
382
+ FROM forge_events
383
+ WHERE ownership = 'forge' AND preferred_connection_id = ?`)
384
+ .all(input.fromConnectionId);
385
+ const updateForgeEvent = getDatabase().prepare(`UPDATE forge_events
386
+ SET preferred_connection_id = ?, preferred_calendar_id = ?, updated_at = ?
387
+ WHERE id = ?`);
388
+ for (const row of forgeEventRows) {
389
+ const nextCalendarId = row.preferred_calendar_id
390
+ ? (mappedCalendarIds.get(row.preferred_calendar_id) ?? toForgeCalendar?.id ?? null)
391
+ : (toForgeCalendar?.id ?? null);
392
+ updateForgeEvent.run(nextCalendarId ? input.toConnectionId : null, nextCalendarId, now, row.id);
393
+ }
394
+ const timeboxRows = getDatabase()
395
+ .prepare(`SELECT id, calendar_id
396
+ FROM task_timeboxes
397
+ WHERE connection_id = ?`)
398
+ .all(input.fromConnectionId);
399
+ const updateTimebox = getDatabase().prepare(`UPDATE task_timeboxes
400
+ SET connection_id = ?, calendar_id = ?, remote_event_id = NULL, updated_at = ?
401
+ WHERE id = ?`);
402
+ for (const row of timeboxRows) {
403
+ const nextCalendarId = row.calendar_id
404
+ ? (mappedCalendarIds.get(row.calendar_id) ?? toForgeCalendar?.id ?? null)
405
+ : (toForgeCalendar?.id ?? null);
406
+ updateTimebox.run(nextCalendarId ? input.toConnectionId : null, nextCalendarId, now, row.id);
407
+ }
408
+ getDatabase()
409
+ .prepare(`DELETE FROM forge_event_sources
410
+ WHERE connection_id = ?
411
+ AND forge_event_id IN (
412
+ SELECT id
413
+ FROM forge_events
414
+ WHERE ownership = 'forge'
415
+ )`)
416
+ .run(input.fromConnectionId);
417
+ });
418
+ }
306
419
  export function detachConnectionFromForgeEvents(connectionId) {
307
420
  const now = nowIso();
308
421
  getDatabase()
@@ -325,16 +438,23 @@ export function listCalendars(connectionId, options = {}) {
325
438
  : "WHERE (selected_for_sync = 1 OR forge_managed = 1)";
326
439
  const rows = getDatabase()
327
440
  .prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
441
+ source_id, source_title, source_type, calendar_type, host_calendar_id, canonical_key,
328
442
  last_synced_at, created_at, updated_at
329
443
  FROM calendar_calendars
330
444
  ${connectionId ? `WHERE connection_id = ? ${visibilityClause}` : visibilityClause}
331
445
  ORDER BY forge_managed DESC, title ASC`)
332
446
  .all(...(connectionId ? [connectionId] : []));
333
- return rows.map(mapCalendar);
447
+ const mapped = rows.map(mapCalendar);
448
+ if (connectionId) {
449
+ return mapped;
450
+ }
451
+ const activeIds = activeConnectionIds();
452
+ return mapped.filter((calendar) => activeIds.has(calendar.connectionId));
334
453
  }
335
454
  export function getCalendarById(calendarId) {
336
455
  const row = getDatabase()
337
456
  .prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
457
+ source_id, source_title, source_type, calendar_type, host_calendar_id, canonical_key,
338
458
  last_synced_at, created_at, updated_at
339
459
  FROM calendar_calendars
340
460
  WHERE id = ?`)
@@ -344,6 +464,7 @@ export function getCalendarById(calendarId) {
344
464
  function getDefaultWritableCalendar() {
345
465
  const row = getDatabase()
346
466
  .prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
467
+ source_id, source_title, source_type, calendar_type, host_calendar_id, canonical_key,
347
468
  last_synced_at, created_at, updated_at
348
469
  FROM calendar_calendars
349
470
  WHERE can_write = 1
@@ -356,6 +477,7 @@ function getDefaultWritableCalendar() {
356
477
  export function getCalendarByRemoteId(connectionId, remoteId) {
357
478
  const row = getDatabase()
358
479
  .prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
480
+ source_id, source_title, source_type, calendar_type, host_calendar_id, canonical_key,
359
481
  last_synced_at, created_at, updated_at
360
482
  FROM calendar_calendars
361
483
  WHERE connection_id = ? AND remote_id = ?`)
@@ -368,18 +490,21 @@ export function upsertCalendarRecord(connectionId, input) {
368
490
  if (existing) {
369
491
  getDatabase()
370
492
  .prepare(`UPDATE calendar_calendars
371
- SET title = ?, description = ?, color = ?, timezone = ?, is_primary = ?, can_write = ?, selected_for_sync = ?, forge_managed = ?, last_synced_at = ?, updated_at = ?
493
+ SET title = ?, description = ?, color = ?, timezone = ?, is_primary = ?, can_write = ?, selected_for_sync = ?, forge_managed = ?,
494
+ source_id = ?, source_title = ?, source_type = ?, calendar_type = ?, host_calendar_id = ?, canonical_key = ?,
495
+ last_synced_at = ?, updated_at = ?
372
496
  WHERE id = ?`)
373
- .run(input.title, input.description ?? existing.description, input.color ?? existing.color, normalizeTimezone(input.timezone ?? existing.timezone), input.isPrimary ? 1 : 0, input.canWrite === false ? 0 : 1, input.selectedForSync === false ? 0 : 1, input.forgeManaged ? 1 : 0, now, now, existing.id);
497
+ .run(input.title, input.description ?? existing.description, input.color ?? existing.color, normalizeTimezone(input.timezone ?? existing.timezone), input.isPrimary ? 1 : 0, input.canWrite === false ? 0 : 1, input.selectedForSync === false ? 0 : 1, input.forgeManaged ? 1 : 0, input.sourceId ?? existing.sourceId, input.sourceTitle ?? existing.sourceTitle, input.sourceType ?? existing.sourceType, input.calendarType ?? existing.calendarType, input.hostCalendarId ?? existing.hostCalendarId, input.canonicalKey ?? existing.canonicalKey ?? existing.remoteId, now, now, existing.id);
374
498
  return getCalendarById(existing.id);
375
499
  }
376
500
  const id = `calendar_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
377
501
  getDatabase()
378
502
  .prepare(`INSERT INTO calendar_calendars (
379
- id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed, last_synced_at, created_at, updated_at
503
+ id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
504
+ source_id, source_title, source_type, calendar_type, host_calendar_id, canonical_key, last_synced_at, created_at, updated_at
380
505
  )
381
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
382
- .run(id, connectionId, input.remoteId, input.title, input.description ?? "", input.color ?? "#7dd3fc", normalizeTimezone(input.timezone), input.isPrimary ? 1 : 0, input.canWrite === false ? 0 : 1, input.selectedForSync === false ? 0 : 1, input.forgeManaged ? 1 : 0, now, now, now);
506
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
507
+ .run(id, connectionId, input.remoteId, input.title, input.description ?? "", input.color ?? "#7dd3fc", normalizeTimezone(input.timezone), input.isPrimary ? 1 : 0, input.canWrite === false ? 0 : 1, input.selectedForSync === false ? 0 : 1, input.forgeManaged ? 1 : 0, input.sourceId ?? null, input.sourceTitle ?? null, input.sourceType ?? null, input.calendarType ?? null, input.hostCalendarId ?? null, input.canonicalKey ?? input.remoteId, now, now, now);
383
508
  return getCalendarById(id);
384
509
  }
385
510
  export function listCalendarEvents(query) {
@@ -415,7 +540,12 @@ export function listCalendarEvents(query) {
415
540
  WHERE ${clauses.join(" AND ")}
416
541
  ORDER BY start_at ASC, title ASC`)
417
542
  .all(...params);
418
- return filterOwnedEntities("calendar_event", rows.map(mapEvent), query.userIds);
543
+ const activeIds = activeConnectionIds();
544
+ return filterOwnedEntities("calendar_event", rows
545
+ .map(mapEvent)
546
+ .filter((event) => event.ownership !== "external" ||
547
+ event.connectionId === null ||
548
+ activeIds.has(event.connectionId)), query.userIds);
419
549
  }
420
550
  export function getCalendarEventById(eventId) {
421
551
  const row = getDatabase()
@@ -529,10 +659,20 @@ export function upsertCalendarEventRecord(connectionId, input) {
529
659
  calendarId: calendar.id,
530
660
  remoteCalendarId: calendar.remoteId,
531
661
  remoteEventId: input.remoteId,
532
- remoteUid: typeof input.rawPayload?.uid === "string" ? String(input.rawPayload.uid) : null,
662
+ remoteUid: typeof input.rawPayload?.uid === "string"
663
+ ? String(input.rawPayload.uid)
664
+ : typeof input.rawPayload?.externalId === "string"
665
+ ? String(input.rawPayload.externalId)
666
+ : typeof input.rawPayload?.iCalUID === "string"
667
+ ? String(input.rawPayload.iCalUID)
668
+ : typeof input.rawPayload?.iCalUId === "string"
669
+ ? String(input.rawPayload.iCalUId)
670
+ : null,
533
671
  recurrenceInstanceId: typeof input.rawPayload?.recurrenceid === "string"
534
672
  ? String(input.rawPayload.recurrenceid)
535
- : null,
673
+ : typeof input.rawPayload?.occurrenceDate === "string"
674
+ ? String(input.rawPayload.occurrenceDate)
675
+ : null,
536
676
  isMasterRecurring: Boolean(input.rawPayload?.rrule),
537
677
  remoteHref: input.remoteHref ?? null,
538
678
  remoteEtag: input.remoteEtag ?? null,
@@ -558,10 +698,20 @@ export function upsertCalendarEventRecord(connectionId, input) {
558
698
  calendarId: calendar.id,
559
699
  remoteCalendarId: calendar.remoteId,
560
700
  remoteEventId: input.remoteId,
561
- remoteUid: typeof input.rawPayload?.uid === "string" ? String(input.rawPayload.uid) : null,
701
+ remoteUid: typeof input.rawPayload?.uid === "string"
702
+ ? String(input.rawPayload.uid)
703
+ : typeof input.rawPayload?.externalId === "string"
704
+ ? String(input.rawPayload.externalId)
705
+ : typeof input.rawPayload?.iCalUID === "string"
706
+ ? String(input.rawPayload.iCalUID)
707
+ : typeof input.rawPayload?.iCalUId === "string"
708
+ ? String(input.rawPayload.iCalUId)
709
+ : null,
562
710
  recurrenceInstanceId: typeof input.rawPayload?.recurrenceid === "string"
563
711
  ? String(input.rawPayload.recurrenceid)
564
- : null,
712
+ : typeof input.rawPayload?.occurrenceDate === "string"
713
+ ? String(input.rawPayload.occurrenceDate)
714
+ : null,
565
715
  isMasterRecurring: Boolean(input.rawPayload?.rrule),
566
716
  remoteHref: input.remoteHref ?? null,
567
717
  remoteEtag: input.remoteEtag ?? null,
@@ -597,6 +747,20 @@ export function createCalendarEvent(input) {
597
747
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
598
748
  .run(id, preferredCalendar?.connectionId ?? null, preferredCalendar?.id ?? null, "forge", "native", "confirmed", input.title, input.description, input.location, place.label || input.location, place.address, place.timezone, place.latitude, place.longitude, place.source, place.externalPlaceId, input.startAt, input.endAt, normalizeTimezone(input.timezone), input.isAllDay ? 1 : 0, input.availability, input.eventType, JSON.stringify(input.categories), now, now);
599
749
  replaceEventLinks(id, input.links);
750
+ upsertEntityActionProfile({
751
+ entityType: "calendar_event",
752
+ entityId: id,
753
+ profile: buildCalendarEventActionProfile({
754
+ eventId: id,
755
+ title: input.title,
756
+ eventType: input.eventType,
757
+ availability: input.availability,
758
+ startAt: input.startAt,
759
+ endAt: input.endAt,
760
+ activityPresetKey: input.activityPresetKey ?? null,
761
+ customSustainRateApPerHour: input.customSustainRateApPerHour ?? null
762
+ })
763
+ });
600
764
  setEntityOwner("calendar_event", id, inferCalendarEventOwnerId(input));
601
765
  return getCalendarEventById(id);
602
766
  }
@@ -652,6 +816,34 @@ export function updateCalendarEvent(eventId, patch) {
652
816
  if (patch.links) {
653
817
  replaceEventLinks(eventId, patch.links);
654
818
  }
819
+ if (patch.title !== undefined ||
820
+ patch.startAt !== undefined ||
821
+ patch.endAt !== undefined ||
822
+ patch.availability !== undefined ||
823
+ patch.eventType !== undefined ||
824
+ patch.activityPresetKey !== undefined ||
825
+ patch.customSustainRateApPerHour !== undefined) {
826
+ upsertEntityActionProfile({
827
+ entityType: "calendar_event",
828
+ entityId: eventId,
829
+ profile: buildCalendarEventActionProfile({
830
+ eventId,
831
+ title: next.title,
832
+ eventType: next.eventType,
833
+ availability: next.availability,
834
+ startAt: next.startAt,
835
+ endAt: next.endAt,
836
+ activityPresetKey: patch.activityPresetKey === undefined
837
+ ? current.actionProfile?.metadata?.activityPresetKey
838
+ : patch.activityPresetKey,
839
+ customSustainRateApPerHour: patch.customSustainRateApPerHour === undefined
840
+ ? (typeof current.actionProfile?.metadata?.customSustainRateApPerHour === "number"
841
+ ? current.actionProfile.metadata.customSustainRateApPerHour
842
+ : null)
843
+ : patch.customSustainRateApPerHour
844
+ })
845
+ });
846
+ }
655
847
  if (patch.userId !== undefined || patch.links !== undefined) {
656
848
  setEntityOwner("calendar_event", eventId, patch.userId === undefined
657
849
  ? inferCalendarEventOwnerId({
@@ -699,6 +891,19 @@ export function createWorkBlockTemplate(input) {
699
891
  )
700
892
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
701
893
  .run(id, input.title, input.kind, input.color, normalizeTimezone(input.timezone), JSON.stringify(input.weekDays), input.startMinute, input.endMinute, input.startsOn ?? null, input.endsOn ?? null, input.blockingState, now, now);
894
+ upsertEntityActionProfile({
895
+ entityType: "work_block_template",
896
+ entityId: id,
897
+ profile: buildWorkBlockTemplateActionProfile({
898
+ templateId: id,
899
+ title: input.title,
900
+ kind: input.kind,
901
+ startMinute: input.startMinute,
902
+ endMinute: input.endMinute,
903
+ activityPresetKey: input.activityPresetKey ?? null,
904
+ customSustainRateApPerHour: input.customSustainRateApPerHour ?? null
905
+ })
906
+ });
702
907
  setEntityOwner("work_block_template", id, input.userId);
703
908
  return getWorkBlockTemplateById(id);
704
909
  });
@@ -744,6 +949,32 @@ export function updateWorkBlockTemplate(templateId, patch) {
744
949
  SET title = ?, kind = ?, color = ?, timezone = ?, weekdays_json = ?, start_minute = ?, end_minute = ?, starts_on = ?, ends_on = ?, blocking_state = ?, updated_at = ?
745
950
  WHERE id = ?`)
746
951
  .run(next.title, next.kind, next.color, next.timezone, JSON.stringify(next.weekDays), next.startMinute, next.endMinute, next.startsOn, next.endsOn, next.blockingState, next.updatedAt, templateId);
952
+ if (patch.title !== undefined ||
953
+ patch.kind !== undefined ||
954
+ patch.startMinute !== undefined ||
955
+ patch.endMinute !== undefined ||
956
+ patch.activityPresetKey !== undefined ||
957
+ patch.customSustainRateApPerHour !== undefined) {
958
+ upsertEntityActionProfile({
959
+ entityType: "work_block_template",
960
+ entityId: templateId,
961
+ profile: buildWorkBlockTemplateActionProfile({
962
+ templateId,
963
+ title: next.title,
964
+ kind: next.kind,
965
+ startMinute: next.startMinute,
966
+ endMinute: next.endMinute,
967
+ activityPresetKey: patch.activityPresetKey === undefined
968
+ ? current.actionProfile?.metadata?.activityPresetKey
969
+ : patch.activityPresetKey,
970
+ customSustainRateApPerHour: patch.customSustainRateApPerHour === undefined
971
+ ? (typeof current.actionProfile?.metadata?.customSustainRateApPerHour === "number"
972
+ ? current.actionProfile.metadata.customSustainRateApPerHour
973
+ : null)
974
+ : patch.customSustainRateApPerHour
975
+ })
976
+ });
977
+ }
747
978
  if (patch.userId !== undefined) {
748
979
  setEntityOwner("work_block_template", templateId, patch.userId);
749
980
  }
@@ -790,6 +1021,7 @@ function deriveWorkBlockInstances(template, query) {
790
1021
  color: template.color,
791
1022
  blockingState: template.blockingState,
792
1023
  calendarEventId: null,
1024
+ actionProfile: template.actionProfile ?? null,
793
1025
  createdAt: template.createdAt,
794
1026
  updatedAt: template.updatedAt
795
1027
  }));
@@ -822,7 +1054,10 @@ export function listTaskTimeboxes(query) {
822
1054
  WHERE ${clauses.join(" AND ")}
823
1055
  ORDER BY starts_at ASC`)
824
1056
  .all(...params);
825
- return filterOwnedEntities("task_timebox", rows.map(mapTimebox), query.userIds);
1057
+ const activeIds = activeConnectionIds();
1058
+ return filterOwnedEntities("task_timebox", rows
1059
+ .map(mapTimebox)
1060
+ .filter((timebox) => timebox.connectionId === null || activeIds.has(timebox.connectionId)), query.userIds);
826
1061
  }
827
1062
  export function getTaskTimeboxById(timeboxId) {
828
1063
  const row = getDatabase()
@@ -836,12 +1071,27 @@ export function getTaskTimeboxById(timeboxId) {
836
1071
  export function createTaskTimebox(input) {
837
1072
  const now = nowIso();
838
1073
  const id = `timebox_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1074
+ const task = getTaskById(input.taskId);
839
1075
  getDatabase()
840
1076
  .prepare(`INSERT INTO task_timeboxes (
841
1077
  id, task_id, project_id, connection_id, calendar_id, linked_task_run_id, status, source, title, starts_at, ends_at, override_reason, created_at, updated_at
842
1078
  )
843
1079
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
844
1080
  .run(id, input.taskId, input.projectId ?? null, input.connectionId ?? null, input.calendarId ?? null, input.linkedTaskRunId ?? null, input.status ?? "planned", input.source ?? "manual", input.title, input.startsAt, input.endsAt, input.overrideReason ?? null, now, now);
1081
+ upsertEntityActionProfile({
1082
+ entityType: "task_timebox",
1083
+ entityId: id,
1084
+ profile: buildTaskTimeboxActionProfile({
1085
+ timeboxId: id,
1086
+ title: input.title,
1087
+ taskId: input.taskId,
1088
+ taskPlannedDurationSeconds: task?.plannedDurationSeconds ?? null,
1089
+ startsAt: input.startsAt,
1090
+ endsAt: input.endsAt,
1091
+ activityPresetKey: input.activityPresetKey ?? null,
1092
+ customSustainRateApPerHour: input.customSustainRateApPerHour ?? null
1093
+ })
1094
+ });
845
1095
  setEntityOwner("task_timebox", id, inferTaskTimeboxOwnerId(input));
846
1096
  return getTaskTimeboxById(id);
847
1097
  }
@@ -869,6 +1119,33 @@ export function updateTaskTimebox(timeboxId, patch) {
869
1119
  starts_at = ?, ends_at = ?, override_reason = ?, updated_at = ?
870
1120
  WHERE id = ?`)
871
1121
  .run(next.connectionId, next.calendarId, next.remoteEventId, next.linkedTaskRunId, next.status, next.source, next.title, next.startsAt, next.endsAt, next.overrideReason, next.updatedAt, timeboxId);
1122
+ if (patch.title !== undefined ||
1123
+ patch.startsAt !== undefined ||
1124
+ patch.endsAt !== undefined ||
1125
+ patch.activityPresetKey !== undefined ||
1126
+ patch.customSustainRateApPerHour !== undefined) {
1127
+ const task = getTaskById(current.taskId);
1128
+ upsertEntityActionProfile({
1129
+ entityType: "task_timebox",
1130
+ entityId: timeboxId,
1131
+ profile: buildTaskTimeboxActionProfile({
1132
+ timeboxId,
1133
+ title: next.title,
1134
+ taskId: current.taskId,
1135
+ taskPlannedDurationSeconds: task?.plannedDurationSeconds ?? null,
1136
+ startsAt: next.startsAt,
1137
+ endsAt: next.endsAt,
1138
+ activityPresetKey: patch.activityPresetKey === undefined
1139
+ ? current.actionProfile?.metadata?.activityPresetKey
1140
+ : patch.activityPresetKey,
1141
+ customSustainRateApPerHour: patch.customSustainRateApPerHour === undefined
1142
+ ? (typeof current.actionProfile?.metadata?.customSustainRateApPerHour === "number"
1143
+ ? current.actionProfile.metadata.customSustainRateApPerHour
1144
+ : null)
1145
+ : patch.customSustainRateApPerHour
1146
+ })
1147
+ });
1148
+ }
872
1149
  if (patch.userId !== undefined) {
873
1150
  setEntityOwner("task_timebox", timeboxId, patch.userId);
874
1151
  }
@@ -1151,6 +1428,12 @@ export function getCalendarOverview(query) {
1151
1428
  label: "Custom CalDAV",
1152
1429
  supportsDedicatedForgeCalendar: true,
1153
1430
  connectionHelp: "Use an account-level CalDAV base URL, then let Forge discover the calendars before selecting sync and write targets."
1431
+ },
1432
+ {
1433
+ provider: "macos_local",
1434
+ label: "Calendars On This Mac",
1435
+ supportsDedicatedForgeCalendar: true,
1436
+ connectionHelp: "Use EventKit to access the calendars already configured in Calendar.app on this Mac. Forge replaces overlapping remote account connections instead of showing duplicate copies."
1154
1437
  }
1155
1438
  ],
1156
1439
  connections: listCalendarConnections().map(({ credentialsSecretId: _secret, ...connection }) => connection),
@@ -70,9 +70,9 @@ function nextSortOrder(status) {
70
70
  .get(status);
71
71
  return row.max_sort + 1;
72
72
  }
73
- function normalizeCompletedAt(status, existingCompletedAt) {
73
+ function normalizeCompletedAt(status, existingCompletedAt, overrideCompletedAt) {
74
74
  if (status === "done") {
75
- return existingCompletedAt ?? new Date().toISOString();
75
+ return overrideCompletedAt ?? existingCompletedAt ?? new Date().toISOString();
76
76
  }
77
77
  return null;
78
78
  }
@@ -203,10 +203,32 @@ function updateTaskRecord(current, input, activity) {
203
203
  const movedColumns = nextStatus !== current.status;
204
204
  const nextSort = input.sortOrder ??
205
205
  (movedColumns ? nextSortOrder(nextStatus) : current.sortOrder);
206
+ const completionRequirement = nextStatus === "done"
207
+ ? getTaskCompletionRequirement(current, current.userId ?? undefined)
208
+ : null;
209
+ const applyCompletionWorkLogAdjustment = (desiredTodaySeconds, currentTodayCreditedSeconds) => {
210
+ const deltaMinutes = Math.round((desiredTodaySeconds - currentTodayCreditedSeconds) / 60);
211
+ if (deltaMinutes === 0) {
212
+ return;
213
+ }
214
+ const appliedDeltaMinutes = deltaMinutes;
215
+ createWorkAdjustment({
216
+ entityType: "task",
217
+ entityId: current.id,
218
+ deltaMinutes: appliedDeltaMinutes,
219
+ appliedDeltaMinutes,
220
+ note: desiredTodaySeconds <= 0
221
+ ? "Completion log cleared for today"
222
+ : "Completion log adjusted for today"
223
+ }, {
224
+ actor: activity?.actor ?? null,
225
+ source: activity?.source ?? "ui"
226
+ });
227
+ };
206
228
  if (current.status !== "done" &&
207
229
  nextStatus === "done" &&
208
- input.resolutionKind !== "split") {
209
- const completionRequirement = getTaskCompletionRequirement(current, current.userId ?? undefined);
230
+ input.resolutionKind !== "split" &&
231
+ completionRequirement) {
210
232
  if (input.enforceTodayWorkLog === true &&
211
233
  completionRequirement.requiresWorkLog &&
212
234
  input.completedTodayWorkSeconds === undefined) {
@@ -215,21 +237,18 @@ function updateTaskRecord(current, input, activity) {
215
237
  todayCreditedSeconds: completionRequirement.todayCreditedSeconds
216
238
  });
217
239
  }
218
- if ((input.completedTodayWorkSeconds ?? 0) > 0) {
219
- const appliedDeltaMinutes = Math.max(1, Math.round((input.completedTodayWorkSeconds ?? 0) / 60));
220
- createWorkAdjustment({
221
- entityType: "task",
222
- entityId: current.id,
223
- deltaMinutes: appliedDeltaMinutes,
224
- appliedDeltaMinutes,
225
- note: "Completion log for today"
226
- }, {
227
- actor: activity?.actor ?? null,
228
- source: activity?.source ?? "ui"
229
- });
240
+ if (input.completedTodayWorkSeconds !== undefined) {
241
+ const desiredTodaySeconds = Math.max(0, input.completedTodayWorkSeconds);
242
+ applyCompletionWorkLogAdjustment(desiredTodaySeconds, completionRequirement.todayCreditedSeconds);
230
243
  }
231
244
  }
232
- const completedAt = normalizeCompletedAt(nextStatus, current.completedAt);
245
+ else if (nextStatus === "done" &&
246
+ input.completedTodayWorkSeconds !== undefined &&
247
+ completionRequirement) {
248
+ const desiredTodaySeconds = Math.max(0, input.completedTodayWorkSeconds);
249
+ applyCompletionWorkLogAdjustment(desiredTodaySeconds, completionRequirement.todayCreditedSeconds);
250
+ }
251
+ const completedAt = normalizeCompletedAt(nextStatus, current.completedAt, input.completedAt);
233
252
  const updatedAt = new Date().toISOString();
234
253
  getDatabase()
235
254
  .prepare(`UPDATE tasks