forge-openclaw-plugin 0.2.26 → 0.2.28

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 (109) hide show
  1. package/README.md +60 -3
  2. package/dist/assets/{board-ta0rUHOf.js → board-DPFvZf-D.js} +2 -2
  3. package/dist/assets/{board-ta0rUHOf.js.map → board-DPFvZf-D.js.map} +1 -1
  4. package/dist/assets/index-Auw3JrdE.css +1 -0
  5. package/dist/assets/index-D1H7myQH.js +85 -0
  6. package/dist/assets/index-D1H7myQH.js.map +1 -0
  7. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js +2 -0
  8. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js.map +1 -0
  9. package/dist/assets/{motion-fBKPB6yw.js → motion-Bvwc85ch.js} +2 -2
  10. package/dist/assets/{motion-fBKPB6yw.js.map → motion-Bvwc85ch.js.map} +1 -1
  11. package/dist/assets/{table-C-IGTQni.js → table-FJQTJvUR.js} +2 -2
  12. package/dist/assets/{table-C-IGTQni.js.map → table-FJQTJvUR.js.map} +1 -1
  13. package/dist/assets/{ui-DInOpaYF.js → ui-GXFcgvSw.js} +2 -2
  14. package/dist/assets/{ui-DInOpaYF.js.map → ui-GXFcgvSw.js.map} +1 -1
  15. package/dist/assets/vendor-Cwf49UMz.js +1247 -0
  16. package/dist/assets/vendor-Cwf49UMz.js.map +1 -0
  17. package/dist/index.html +7 -7
  18. package/dist/openclaw/local-runtime.js +16 -0
  19. package/dist/openclaw/routes.d.ts +27 -0
  20. package/dist/openclaw/routes.js +16 -12
  21. package/dist/server/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
  22. package/dist/server/server/migrations/038_data_management_settings.sql +11 -0
  23. package/dist/server/server/migrations/039_life_force_and_action_points.sql +114 -0
  24. package/dist/server/server/migrations/040_screen_time_domain.sql +89 -0
  25. package/dist/server/server/migrations/041_companion_source_states.sql +21 -0
  26. package/dist/server/server/migrations/042_movement_boxes.sql +47 -0
  27. package/dist/server/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
  28. package/dist/server/server/src/app.js +1900 -91
  29. package/dist/server/server/src/connectors/box-registry.js +44 -9
  30. package/dist/server/server/src/data-management-types.js +107 -0
  31. package/dist/server/server/src/db.js +68 -4
  32. package/dist/server/server/src/demo-data.js +2 -2
  33. package/dist/server/server/src/health.js +702 -18
  34. package/dist/server/server/src/managers/platform/llm-manager.js +7 -4
  35. package/dist/server/server/src/managers/platform/mock-workbench-provider.js +149 -0
  36. package/dist/server/server/src/managers/platform/secrets-manager.js +18 -1
  37. package/dist/server/server/src/managers/runtime.js +9 -0
  38. package/dist/server/server/src/movement.js +1971 -112
  39. package/dist/server/server/src/openapi.js +1390 -105
  40. package/dist/server/server/src/psyche-types.js +9 -1
  41. package/dist/server/server/src/repositories/activity-events.js +8 -0
  42. package/dist/server/server/src/repositories/ai-connectors.js +522 -74
  43. package/dist/server/server/src/repositories/calendar.js +151 -0
  44. package/dist/server/server/src/repositories/habits.js +37 -1
  45. package/dist/server/server/src/repositories/model-settings.js +13 -3
  46. package/dist/server/server/src/repositories/notes.js +3 -0
  47. package/dist/server/server/src/repositories/settings.js +380 -18
  48. package/dist/server/server/src/repositories/tasks.js +170 -10
  49. package/dist/server/server/src/runtime-data-root.js +82 -0
  50. package/dist/server/server/src/screen-time.js +802 -0
  51. package/dist/server/server/src/services/data-management.js +788 -0
  52. package/dist/server/server/src/services/entity-crud.js +205 -2
  53. package/dist/server/server/src/services/knowledge-graph.js +1455 -0
  54. package/dist/server/server/src/services/life-force-model.js +217 -0
  55. package/dist/server/server/src/services/life-force.js +2506 -0
  56. package/dist/server/server/src/services/psyche-observation-calendar.js +383 -16
  57. package/dist/server/server/src/types.js +307 -14
  58. package/dist/server/server/src/web.js +228 -13
  59. package/dist/server/src/components/customization/utility-widgets.js +136 -27
  60. package/dist/server/src/components/ui/info-tooltip.js +25 -0
  61. package/dist/server/src/components/workbench-boxes/calendar/calendar-boxes.js +78 -0
  62. package/dist/server/src/components/workbench-boxes/goals/goals-boxes.js +62 -0
  63. package/dist/server/src/components/workbench-boxes/habits/habits-boxes.js +62 -0
  64. package/dist/server/src/components/workbench-boxes/health/health-boxes.js +63 -8
  65. package/dist/server/src/components/workbench-boxes/insights/insights-boxes.js +50 -0
  66. package/dist/server/src/components/workbench-boxes/kanban/kanban-boxes.js +62 -54
  67. package/dist/server/src/components/workbench-boxes/movement/movement-boxes.js +18 -8
  68. package/dist/server/src/components/workbench-boxes/notes/notes-boxes.js +56 -38
  69. package/dist/server/src/components/workbench-boxes/overview/overview-boxes.js +65 -0
  70. package/dist/server/src/components/workbench-boxes/preferences/preferences-boxes.js +78 -0
  71. package/dist/server/src/components/workbench-boxes/projects/projects-boxes.js +35 -30
  72. package/dist/server/src/components/workbench-boxes/psyche/psyche-boxes.js +88 -0
  73. package/dist/server/src/components/workbench-boxes/questionnaires/questionnaires-boxes.js +61 -0
  74. package/dist/server/src/components/workbench-boxes/review/review-boxes.js +53 -0
  75. package/dist/server/src/components/workbench-boxes/shared/define-workbench-box.js +3 -1
  76. package/dist/server/src/components/workbench-boxes/shared/generic-node-view.js +39 -3
  77. package/dist/server/src/components/workbench-boxes/strategies/strategies-boxes.js +62 -0
  78. package/dist/server/src/components/workbench-boxes/tasks/tasks-boxes.js +76 -0
  79. package/dist/server/src/components/workbench-boxes/today/today-boxes.js +47 -32
  80. package/dist/server/src/components/workbench-boxes/wiki/wiki-boxes.js +60 -0
  81. package/dist/server/src/lib/api.js +280 -21
  82. package/dist/server/src/lib/data-management-types.js +1 -0
  83. package/dist/server/src/lib/entity-visuals.js +279 -0
  84. package/dist/server/src/lib/knowledge-graph-types.js +276 -0
  85. package/dist/server/src/lib/knowledge-graph.js +470 -0
  86. package/dist/server/src/lib/schemas.js +4 -0
  87. package/dist/server/src/lib/snapshot-normalizer.js +45 -1
  88. package/dist/server/src/lib/workbench/contracts.js +229 -0
  89. package/dist/server/src/lib/workbench/nodes.js +200 -0
  90. package/dist/server/src/lib/workbench/registry.js +52 -5
  91. package/dist/server/src/lib/workbench/runtime.js +254 -38
  92. package/dist/server/src/lib/workbench/tool-catalog.js +68 -0
  93. package/openclaw.plugin.json +1 -1
  94. package/package.json +1 -1
  95. package/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
  96. package/server/migrations/038_data_management_settings.sql +11 -0
  97. package/server/migrations/039_life_force_and_action_points.sql +114 -0
  98. package/server/migrations/040_screen_time_domain.sql +89 -0
  99. package/server/migrations/041_companion_source_states.sql +21 -0
  100. package/server/migrations/042_movement_boxes.sql +47 -0
  101. package/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
  102. package/skills/forge-openclaw/SKILL.md +41 -11
  103. package/skills/forge-openclaw/entity_conversation_playbooks.md +448 -34
  104. package/skills/forge-openclaw/psyche_entity_playbooks.md +170 -17
  105. package/dist/assets/index-Ro0ZF_az.css +0 -1
  106. package/dist/assets/index-ytlpSj23.js +0 -79
  107. package/dist/assets/index-ytlpSj23.js.map +0 -1
  108. package/dist/assets/vendor-lE3tZJcC.js +0 -876
  109. package/dist/assets/vendor-lE3tZJcC.js.map +0 -1
@@ -0,0 +1,2506 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDatabase, runInTransaction } from "../db.js";
3
+ import { getDefaultUser, getUserById } from "../repositories/users.js";
4
+ import { actionProfileSchema, fatigueSignalCreateSchema, lifeForcePayloadSchema, lifeForceProfilePatchSchema, lifeForceTemplateUpdateSchema } from "../types.js";
5
+ import { LIFE_FORCE_BASELINE_DAILY_AP, buildDefaultTaskActionProfile, buildTaskActionPointSummary, buildTaskSplitSuggestion, clamp, computeActionCostModifier, computeLifeForceLevelMultiplier, computeStatCostModifier, interpolateCurveRate, normalizeCurveToBudget, resolveBandTotalCostAp, resolveTaskExpectedDurationSeconds } from "./life-force-model.js";
6
+ import { computeWorkTime } from "./work-time.js";
7
+ const DEFAULT_TEMPLATE_POINTS = [
8
+ { minuteOfDay: 0, rateApPerHour: 0 },
9
+ { minuteOfDay: 7 * 60, rateApPerHour: 0 },
10
+ { minuteOfDay: 8 * 60, rateApPerHour: 8 },
11
+ { minuteOfDay: 10 * 60, rateApPerHour: 13 },
12
+ { minuteOfDay: 13 * 60, rateApPerHour: 9 },
13
+ { minuteOfDay: 14 * 60, rateApPerHour: 11 },
14
+ { minuteOfDay: 19 * 60, rateApPerHour: 7 },
15
+ { minuteOfDay: 23 * 60, rateApPerHour: 0 },
16
+ { minuteOfDay: 24 * 60, rateApPerHour: 0 }
17
+ ];
18
+ const LIFE_FORCE_STAT_LABELS = {
19
+ life_force: "Life Force",
20
+ activation: "Activation",
21
+ focus: "Focus",
22
+ vigor: "Vigor",
23
+ composure: "Composure",
24
+ flow: "Flow"
25
+ };
26
+ const CALENDAR_ACTIVITY_PRESETS = {
27
+ deep_work: {
28
+ title: "Deep work",
29
+ mode: "container",
30
+ sustainRateApPerHour: 14,
31
+ demandWeights: {
32
+ activation: 0.1,
33
+ focus: 0.55,
34
+ vigor: 0.05,
35
+ composure: 0.05,
36
+ flow: 0.25
37
+ },
38
+ doubleCountPolicy: "container_only",
39
+ costBand: "light",
40
+ recoveryEffect: 0,
41
+ metadata: {
42
+ physicalIntensity: 0.15,
43
+ cognitiveDemand: 0.9,
44
+ socialLoad: 0.1,
45
+ switchingLoad: 0.2
46
+ }
47
+ },
48
+ admin: {
49
+ title: "Admin and coordination",
50
+ mode: "container",
51
+ sustainRateApPerHour: 9,
52
+ demandWeights: {
53
+ activation: 0.15,
54
+ focus: 0.3,
55
+ vigor: 0.05,
56
+ composure: 0.1,
57
+ flow: 0.4
58
+ },
59
+ doubleCountPolicy: "container_only",
60
+ costBand: "light",
61
+ recoveryEffect: 0,
62
+ metadata: {
63
+ physicalIntensity: 0.1,
64
+ cognitiveDemand: 0.45,
65
+ socialLoad: 0.25,
66
+ switchingLoad: 0.7
67
+ }
68
+ },
69
+ maintenance: {
70
+ title: "Maintenance and light activity",
71
+ mode: "container",
72
+ sustainRateApPerHour: 6,
73
+ demandWeights: {
74
+ activation: 0.15,
75
+ focus: 0.2,
76
+ vigor: 0.1,
77
+ composure: 0.05,
78
+ flow: 0.5
79
+ },
80
+ doubleCountPolicy: "container_only",
81
+ costBand: "light",
82
+ recoveryEffect: 0,
83
+ metadata: {
84
+ physicalIntensity: 0.2,
85
+ cognitiveDemand: 0.25,
86
+ socialLoad: 0.1,
87
+ switchingLoad: 0.5
88
+ }
89
+ },
90
+ meeting: {
91
+ title: "Meeting or social commitment",
92
+ mode: "container",
93
+ sustainRateApPerHour: 13,
94
+ demandWeights: {
95
+ activation: 0.05,
96
+ focus: 0.25,
97
+ vigor: 0.05,
98
+ composure: 0.45,
99
+ flow: 0.2
100
+ },
101
+ doubleCountPolicy: "container_only",
102
+ costBand: "standard",
103
+ recoveryEffect: 0,
104
+ metadata: {
105
+ physicalIntensity: 0.1,
106
+ cognitiveDemand: 0.45,
107
+ socialLoad: 0.85,
108
+ switchingLoad: 0.35
109
+ }
110
+ },
111
+ recovery_break: {
112
+ title: "Rest and recovery",
113
+ mode: "recovery",
114
+ sustainRateApPerHour: 3,
115
+ demandWeights: {
116
+ activation: 0.05,
117
+ focus: 0.05,
118
+ vigor: 0.15,
119
+ composure: 0.15,
120
+ flow: 0.1
121
+ },
122
+ doubleCountPolicy: "container_only",
123
+ costBand: "tiny",
124
+ recoveryEffect: 4,
125
+ metadata: {
126
+ physicalIntensity: 0.1,
127
+ cognitiveDemand: 0.05,
128
+ socialLoad: 0.1,
129
+ switchingLoad: 0.05
130
+ }
131
+ },
132
+ holiday_leisure: {
133
+ title: "Holiday or leisure time",
134
+ mode: "container",
135
+ sustainRateApPerHour: 4,
136
+ demandWeights: {
137
+ activation: 0.05,
138
+ focus: 0.05,
139
+ vigor: 0.15,
140
+ composure: 0.25,
141
+ flow: 0.1
142
+ },
143
+ doubleCountPolicy: "container_only",
144
+ costBand: "tiny",
145
+ recoveryEffect: 2,
146
+ metadata: {
147
+ physicalIntensity: 0.15,
148
+ cognitiveDemand: 0.1,
149
+ socialLoad: 0.35,
150
+ switchingLoad: 0.1
151
+ }
152
+ },
153
+ light_context: {
154
+ title: "Light context",
155
+ mode: "container",
156
+ sustainRateApPerHour: 2,
157
+ demandWeights: {
158
+ activation: 0.05,
159
+ focus: 0.1,
160
+ vigor: 0.05,
161
+ composure: 0.15,
162
+ flow: 0.15
163
+ },
164
+ doubleCountPolicy: "container_only",
165
+ costBand: "tiny",
166
+ recoveryEffect: 0,
167
+ metadata: {
168
+ physicalIntensity: 0.05,
169
+ cognitiveDemand: 0.15,
170
+ socialLoad: 0.2,
171
+ switchingLoad: 0.15
172
+ }
173
+ }
174
+ };
175
+ function buildStatLevels(profile) {
176
+ return {
177
+ life_force: profile.life_force_level,
178
+ activation: profile.activation_level,
179
+ focus: profile.focus_level,
180
+ vigor: profile.vigor_level,
181
+ composure: profile.composure_level,
182
+ flow: profile.flow_level
183
+ };
184
+ }
185
+ function buildEffectiveProfile(profile, lifeForceProfile) {
186
+ const costModifier = computeActionCostModifier(profile.demandWeights, buildStatLevels(lifeForceProfile));
187
+ return {
188
+ ...profile,
189
+ startupAp: Number((profile.startupAp * costModifier).toFixed(4)),
190
+ totalCostAp: Number((profile.totalCostAp * costModifier).toFixed(4)),
191
+ sustainRateApPerHour: Number((profile.sustainRateApPerHour * costModifier).toFixed(4)),
192
+ metadata: {
193
+ ...profile.metadata,
194
+ costModifier
195
+ }
196
+ };
197
+ }
198
+ function rateToTotalAp(rateApPerHour, durationSeconds) {
199
+ return Number(((durationSeconds / 3600) * rateApPerHour).toFixed(4));
200
+ }
201
+ function overlapsWindow(leftStartIso, leftEndIso, rightStartIso, rightEndIso) {
202
+ return (Date.parse(leftStartIso) < Date.parse(rightEndIso) &&
203
+ Date.parse(leftEndIso) > Date.parse(rightStartIso));
204
+ }
205
+ function clipWindowToRange(window, range) {
206
+ const startMs = Math.max(Date.parse(window.startAt), Date.parse(range.startAt));
207
+ const endMs = Math.min(Date.parse(window.endAt), Date.parse(range.endAt));
208
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
209
+ return null;
210
+ }
211
+ return { startMs, endMs };
212
+ }
213
+ function computeUncoveredSeconds(window, blockingWindows) {
214
+ const clippedBlocks = blockingWindows
215
+ .map((candidate) => clipWindowToRange(candidate, window))
216
+ .filter((candidate) => candidate !== null)
217
+ .sort((left, right) => left.startMs - right.startMs);
218
+ const totalSeconds = Math.max(0, Math.floor((Date.parse(window.endAt) - Date.parse(window.startAt)) / 1000));
219
+ if (totalSeconds <= 0 || clippedBlocks.length === 0) {
220
+ return totalSeconds;
221
+ }
222
+ let coveredMs = 0;
223
+ let activeStartMs = clippedBlocks[0].startMs;
224
+ let activeEndMs = clippedBlocks[0].endMs;
225
+ for (const block of clippedBlocks.slice(1)) {
226
+ if (block.startMs <= activeEndMs) {
227
+ activeEndMs = Math.max(activeEndMs, block.endMs);
228
+ continue;
229
+ }
230
+ coveredMs += activeEndMs - activeStartMs;
231
+ activeStartMs = block.startMs;
232
+ activeEndMs = block.endMs;
233
+ }
234
+ coveredMs += activeEndMs - activeStartMs;
235
+ return Math.max(0, totalSeconds - Math.floor(coveredMs / 1000));
236
+ }
237
+ function nowIso() {
238
+ return new Date().toISOString();
239
+ }
240
+ function toDateKey(date) {
241
+ return date.toISOString().slice(0, 10);
242
+ }
243
+ function startOfUtcDay(date) {
244
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
245
+ }
246
+ function buildDayRange(date) {
247
+ const start = startOfUtcDay(date);
248
+ const end = new Date(start);
249
+ end.setUTCDate(end.getUTCDate() + 1);
250
+ return {
251
+ startMs: start.getTime(),
252
+ endMs: end.getTime(),
253
+ dateKey: toDateKey(start),
254
+ from: start.toISOString(),
255
+ to: end.toISOString()
256
+ };
257
+ }
258
+ function parseCurvePoints(raw) {
259
+ try {
260
+ const parsed = JSON.parse(raw);
261
+ if (!Array.isArray(parsed)) {
262
+ return [];
263
+ }
264
+ return parsed
265
+ .filter((value) => !!value &&
266
+ typeof value === "object" &&
267
+ typeof value.minuteOfDay === "number" &&
268
+ typeof value.rateApPerHour === "number")
269
+ .map((point) => ({
270
+ minuteOfDay: Math.round(point.minuteOfDay),
271
+ rateApPerHour: point.rateApPerHour,
272
+ locked: point.locked
273
+ }))
274
+ .sort((left, right) => left.minuteOfDay - right.minuteOfDay);
275
+ }
276
+ catch {
277
+ return [];
278
+ }
279
+ }
280
+ function defaultTemplatePoints() {
281
+ return normalizeCurveToBudget(DEFAULT_TEMPLATE_POINTS, LIFE_FORCE_BASELINE_DAILY_AP);
282
+ }
283
+ function resolveWorkBlockPresetKey(kind) {
284
+ if (kind === "main_activity") {
285
+ return "deep_work";
286
+ }
287
+ if (kind === "secondary_activity") {
288
+ return "admin";
289
+ }
290
+ if (kind === "third_activity" || kind === "custom") {
291
+ return "maintenance";
292
+ }
293
+ if (kind === "holiday") {
294
+ return "holiday_leisure";
295
+ }
296
+ return "recovery_break";
297
+ }
298
+ function resolveCalendarEventPresetKey(input) {
299
+ const searchable = `${input.title} ${input.eventType ?? ""}`.toLowerCase();
300
+ if (searchable.includes("meeting") || searchable.includes("call") || searchable.includes("interview")) {
301
+ return "meeting";
302
+ }
303
+ if (searchable.includes("deep work") || searchable.includes("focus")) {
304
+ return "deep_work";
305
+ }
306
+ if (searchable.includes("admin") || searchable.includes("email") || searchable.includes("inbox")) {
307
+ return "admin";
308
+ }
309
+ if (searchable.includes("lunch") ||
310
+ searchable.includes("break") ||
311
+ searchable.includes("rest")) {
312
+ return "recovery_break";
313
+ }
314
+ if (searchable.includes("holiday") || searchable.includes("vacation")) {
315
+ return "holiday_leisure";
316
+ }
317
+ return input.availability === "busy" ? "meeting" : "light_context";
318
+ }
319
+ function buildCalendarActivityProfile(input) {
320
+ const durationSeconds = Math.max(1, input.expectedDurationSeconds);
321
+ const presetDefinition = input.activityPresetKey === "task_inherited"
322
+ ? null
323
+ : CALENDAR_ACTIVITY_PRESETS[input.activityPresetKey];
324
+ const inheritedProfile = input.fallbackProfile;
325
+ const baseProfile = presetDefinition === null
326
+ ? inheritedProfile
327
+ : actionProfileSchema.parse({
328
+ id: `profile_${input.entityType}_${input.entityId}`,
329
+ profileKey: `${String(input.entityType)}_${input.entityId}`,
330
+ title: input.title || presetDefinition.title,
331
+ entityType: input.entityType,
332
+ mode: presetDefinition.mode,
333
+ startupAp: 0,
334
+ totalCostAp: rateToTotalAp(presetDefinition.sustainRateApPerHour, durationSeconds),
335
+ expectedDurationSeconds: durationSeconds,
336
+ sustainRateApPerHour: presetDefinition.sustainRateApPerHour,
337
+ demandWeights: presetDefinition.demandWeights,
338
+ doubleCountPolicy: presetDefinition.doubleCountPolicy,
339
+ sourceMethod: input.sourceMethod ?? "inferred",
340
+ costBand: presetDefinition.costBand,
341
+ recoveryEffect: presetDefinition.recoveryEffect,
342
+ metadata: {
343
+ activityPresetKey: input.activityPresetKey,
344
+ ...presetDefinition.metadata,
345
+ ...(input.metadata ?? {})
346
+ },
347
+ createdAt: nowIso(),
348
+ updatedAt: nowIso()
349
+ });
350
+ const base = baseProfile ??
351
+ actionProfileSchema.parse({
352
+ id: `profile_${input.entityType}_${input.entityId}`,
353
+ profileKey: `${String(input.entityType)}_${input.entityId}`,
354
+ title: input.title || "Activity",
355
+ entityType: input.entityType,
356
+ mode: "container",
357
+ startupAp: 0,
358
+ totalCostAp: rateToTotalAp(8, durationSeconds),
359
+ expectedDurationSeconds: durationSeconds,
360
+ sustainRateApPerHour: 8,
361
+ demandWeights: {
362
+ activation: 0.1,
363
+ focus: 0.3,
364
+ vigor: 0.1,
365
+ composure: 0.1,
366
+ flow: 0.4
367
+ },
368
+ doubleCountPolicy: "container_only",
369
+ sourceMethod: input.sourceMethod ?? "inferred",
370
+ costBand: "light",
371
+ recoveryEffect: 0,
372
+ metadata: {
373
+ activityPresetKey: "maintenance",
374
+ ...(input.metadata ?? {})
375
+ },
376
+ createdAt: nowIso(),
377
+ updatedAt: nowIso()
378
+ });
379
+ const sustainRateApPerHour = input.customSustainRateApPerHour ?? base.sustainRateApPerHour;
380
+ return actionProfileSchema.parse({
381
+ ...base,
382
+ id: `profile_${input.entityType}_${input.entityId}`,
383
+ profileKey: `${String(input.entityType)}_${input.entityId}`,
384
+ title: input.title || base.title,
385
+ entityType: input.entityType,
386
+ expectedDurationSeconds: durationSeconds,
387
+ totalCostAp: rateToTotalAp(sustainRateApPerHour, durationSeconds),
388
+ sustainRateApPerHour,
389
+ sourceMethod: input.customSustainRateApPerHour !== null &&
390
+ input.customSustainRateApPerHour !== undefined
391
+ ? "manual"
392
+ : input.sourceMethod ?? base.sourceMethod,
393
+ metadata: {
394
+ ...base.metadata,
395
+ activityPresetKey: input.activityPresetKey,
396
+ customSustainRateApPerHour: input.customSustainRateApPerHour ?? null,
397
+ ...(input.metadata ?? {})
398
+ },
399
+ updatedAt: nowIso()
400
+ });
401
+ }
402
+ function seededActionProfiles() {
403
+ const now = nowIso();
404
+ const taskDefault = buildDefaultTaskActionProfile({});
405
+ return [
406
+ taskDefault,
407
+ actionProfileSchema.parse({
408
+ id: "profile_note_quick",
409
+ profileKey: "note_quick",
410
+ title: "Quick note",
411
+ entityType: "note",
412
+ mode: "impulse",
413
+ startupAp: 1,
414
+ totalCostAp: 1,
415
+ expectedDurationSeconds: null,
416
+ sustainRateApPerHour: 0,
417
+ demandWeights: {
418
+ activation: 0.15,
419
+ focus: 0.5,
420
+ vigor: 0,
421
+ composure: 0,
422
+ flow: 0.35
423
+ },
424
+ doubleCountPolicy: "primary_only",
425
+ sourceMethod: "seeded",
426
+ costBand: "tiny",
427
+ recoveryEffect: 0,
428
+ metadata: {},
429
+ createdAt: now,
430
+ updatedAt: now
431
+ }),
432
+ actionProfileSchema.parse({
433
+ id: "profile_habit_default",
434
+ profileKey: "habit_default",
435
+ title: "Habit check-in",
436
+ entityType: "habit",
437
+ mode: "impulse",
438
+ startupAp: 3,
439
+ totalCostAp: 3,
440
+ expectedDurationSeconds: null,
441
+ sustainRateApPerHour: 0,
442
+ demandWeights: {
443
+ activation: 0.4,
444
+ focus: 0.1,
445
+ vigor: 0.15,
446
+ composure: 0.05,
447
+ flow: 0.3
448
+ },
449
+ doubleCountPolicy: "primary_only",
450
+ sourceMethod: "seeded",
451
+ costBand: "light",
452
+ recoveryEffect: 0,
453
+ metadata: {},
454
+ createdAt: now,
455
+ updatedAt: now
456
+ }),
457
+ actionProfileSchema.parse({
458
+ id: "profile_workout_default",
459
+ profileKey: "workout_default",
460
+ title: "Workout",
461
+ entityType: "workout_session",
462
+ mode: "rate",
463
+ startupAp: 1,
464
+ totalCostAp: 24,
465
+ expectedDurationSeconds: 3_600,
466
+ sustainRateApPerHour: 24,
467
+ demandWeights: {
468
+ activation: 0.1,
469
+ focus: 0.05,
470
+ vigor: 0.75,
471
+ composure: 0,
472
+ flow: 0.1
473
+ },
474
+ doubleCountPolicy: "primary_only",
475
+ sourceMethod: "seeded",
476
+ costBand: "standard",
477
+ recoveryEffect: 0,
478
+ metadata: {},
479
+ createdAt: now,
480
+ updatedAt: now
481
+ }),
482
+ actionProfileSchema.parse({
483
+ id: "profile_calendar_default",
484
+ profileKey: "calendar_event_default",
485
+ title: "Calendar event",
486
+ entityType: "calendar_event",
487
+ mode: "container",
488
+ startupAp: 0,
489
+ totalCostAp: 12,
490
+ expectedDurationSeconds: 3_600,
491
+ sustainRateApPerHour: 12,
492
+ demandWeights: {
493
+ activation: 0.05,
494
+ focus: 0.35,
495
+ vigor: 0.05,
496
+ composure: 0.35,
497
+ flow: 0.2
498
+ },
499
+ doubleCountPolicy: "container_only",
500
+ sourceMethod: "seeded",
501
+ costBand: "light",
502
+ recoveryEffect: 0,
503
+ metadata: {},
504
+ createdAt: now,
505
+ updatedAt: now
506
+ }),
507
+ actionProfileSchema.parse({
508
+ id: "profile_recovery_break",
509
+ profileKey: "recovery_break",
510
+ title: "Recovery break",
511
+ entityType: "system",
512
+ mode: "recovery",
513
+ startupAp: 0,
514
+ totalCostAp: 0,
515
+ expectedDurationSeconds: null,
516
+ sustainRateApPerHour: 0,
517
+ demandWeights: {
518
+ activation: 0,
519
+ focus: 0,
520
+ vigor: 0,
521
+ composure: 0,
522
+ flow: 0
523
+ },
524
+ doubleCountPolicy: "primary_only",
525
+ sourceMethod: "seeded",
526
+ costBand: "tiny",
527
+ recoveryEffect: 6,
528
+ metadata: {},
529
+ createdAt: now,
530
+ updatedAt: now
531
+ }),
532
+ actionProfileSchema.parse({
533
+ id: "profile_wake_start",
534
+ profileKey: "wake_start",
535
+ title: "Get out of bed",
536
+ entityType: "sleep_session",
537
+ mode: "impulse",
538
+ startupAp: 5,
539
+ totalCostAp: 5,
540
+ expectedDurationSeconds: null,
541
+ sustainRateApPerHour: 0,
542
+ demandWeights: {
543
+ activation: 0.75,
544
+ focus: 0.05,
545
+ vigor: 0.1,
546
+ composure: 0,
547
+ flow: 0.1
548
+ },
549
+ doubleCountPolicy: "primary_only",
550
+ sourceMethod: "seeded",
551
+ costBand: "light",
552
+ recoveryEffect: 0,
553
+ metadata: {},
554
+ createdAt: now,
555
+ updatedAt: now
556
+ }),
557
+ actionProfileSchema.parse({
558
+ id: "profile_timebox_default",
559
+ profileKey: "task_timebox_default",
560
+ title: "Task timebox",
561
+ entityType: "task_timebox",
562
+ mode: "container",
563
+ startupAp: 0,
564
+ totalCostAp: 12,
565
+ expectedDurationSeconds: 3_600,
566
+ sustainRateApPerHour: 12,
567
+ demandWeights: {
568
+ activation: 0.15,
569
+ focus: 0.45,
570
+ vigor: 0.05,
571
+ composure: 0.05,
572
+ flow: 0.3
573
+ },
574
+ doubleCountPolicy: "container_only",
575
+ sourceMethod: "seeded",
576
+ costBand: "light",
577
+ recoveryEffect: 0,
578
+ metadata: {},
579
+ createdAt: now,
580
+ updatedAt: now
581
+ }),
582
+ actionProfileSchema.parse({
583
+ id: "profile_work_block_main",
584
+ profileKey: "work_block_main",
585
+ title: "Main activity block",
586
+ entityType: "work_block",
587
+ mode: "container",
588
+ startupAp: 0,
589
+ totalCostAp: 14,
590
+ expectedDurationSeconds: 3_600,
591
+ sustainRateApPerHour: 14,
592
+ demandWeights: {
593
+ activation: 0.1,
594
+ focus: 0.5,
595
+ vigor: 0.1,
596
+ composure: 0.05,
597
+ flow: 0.25
598
+ },
599
+ doubleCountPolicy: "container_only",
600
+ sourceMethod: "seeded",
601
+ costBand: "light",
602
+ recoveryEffect: 0,
603
+ metadata: { kind: "main_activity" },
604
+ createdAt: now,
605
+ updatedAt: now
606
+ }),
607
+ actionProfileSchema.parse({
608
+ id: "profile_work_block_secondary",
609
+ profileKey: "work_block_secondary",
610
+ title: "Secondary activity block",
611
+ entityType: "work_block",
612
+ mode: "container",
613
+ startupAp: 0,
614
+ totalCostAp: 9,
615
+ expectedDurationSeconds: 3_600,
616
+ sustainRateApPerHour: 9,
617
+ demandWeights: {
618
+ activation: 0.15,
619
+ focus: 0.3,
620
+ vigor: 0.1,
621
+ composure: 0.05,
622
+ flow: 0.4
623
+ },
624
+ doubleCountPolicy: "container_only",
625
+ sourceMethod: "seeded",
626
+ costBand: "light",
627
+ recoveryEffect: 0,
628
+ metadata: { kind: "secondary_activity" },
629
+ createdAt: now,
630
+ updatedAt: now
631
+ }),
632
+ actionProfileSchema.parse({
633
+ id: "profile_work_block_third",
634
+ profileKey: "work_block_third",
635
+ title: "Third activity block",
636
+ entityType: "work_block",
637
+ mode: "container",
638
+ startupAp: 0,
639
+ totalCostAp: 6,
640
+ expectedDurationSeconds: 3_600,
641
+ sustainRateApPerHour: 6,
642
+ demandWeights: {
643
+ activation: 0.15,
644
+ focus: 0.2,
645
+ vigor: 0.1,
646
+ composure: 0.05,
647
+ flow: 0.5
648
+ },
649
+ doubleCountPolicy: "container_only",
650
+ sourceMethod: "seeded",
651
+ costBand: "light",
652
+ recoveryEffect: 0,
653
+ metadata: { kind: "third_activity" },
654
+ createdAt: now,
655
+ updatedAt: now
656
+ }),
657
+ actionProfileSchema.parse({
658
+ id: "profile_work_block_rest",
659
+ profileKey: "work_block_rest",
660
+ title: "Rest block",
661
+ entityType: "work_block",
662
+ mode: "recovery",
663
+ startupAp: 0,
664
+ totalCostAp: 3,
665
+ expectedDurationSeconds: 3_600,
666
+ sustainRateApPerHour: 3,
667
+ demandWeights: {
668
+ activation: 0.05,
669
+ focus: 0.05,
670
+ vigor: 0.15,
671
+ composure: 0.15,
672
+ flow: 0.1
673
+ },
674
+ doubleCountPolicy: "container_only",
675
+ sourceMethod: "seeded",
676
+ costBand: "tiny",
677
+ recoveryEffect: 4,
678
+ metadata: { kind: "rest", activityPresetKey: "recovery_break" },
679
+ createdAt: now,
680
+ updatedAt: now
681
+ }),
682
+ actionProfileSchema.parse({
683
+ id: "profile_work_block_holiday",
684
+ profileKey: "work_block_holiday",
685
+ title: "Holiday block",
686
+ entityType: "work_block",
687
+ mode: "container",
688
+ startupAp: 0,
689
+ totalCostAp: 4,
690
+ expectedDurationSeconds: 3_600,
691
+ sustainRateApPerHour: 4,
692
+ demandWeights: {
693
+ activation: 0.05,
694
+ focus: 0.05,
695
+ vigor: 0.15,
696
+ composure: 0.25,
697
+ flow: 0.1
698
+ },
699
+ doubleCountPolicy: "container_only",
700
+ sourceMethod: "seeded",
701
+ costBand: "tiny",
702
+ recoveryEffect: 2,
703
+ metadata: { kind: "holiday", activityPresetKey: "holiday_leisure" },
704
+ createdAt: now,
705
+ updatedAt: now
706
+ }),
707
+ actionProfileSchema.parse({
708
+ id: "profile_movement_trip",
709
+ profileKey: "movement_trip_default",
710
+ title: "Movement trip",
711
+ entityType: "movement_trip",
712
+ mode: "rate",
713
+ startupAp: 0,
714
+ totalCostAp: 8,
715
+ expectedDurationSeconds: 3_600,
716
+ sustainRateApPerHour: 8,
717
+ demandWeights: {
718
+ activation: 0.05,
719
+ focus: 0.15,
720
+ vigor: 0.55,
721
+ composure: 0,
722
+ flow: 0.25
723
+ },
724
+ doubleCountPolicy: "primary_only",
725
+ sourceMethod: "seeded",
726
+ costBand: "light",
727
+ recoveryEffect: 0,
728
+ metadata: {},
729
+ createdAt: now,
730
+ updatedAt: now
731
+ })
732
+ ];
733
+ }
734
+ function mapTemplateProfileRow(row) {
735
+ return actionProfileSchema.parse({
736
+ id: row.id,
737
+ profileKey: row.profile_key,
738
+ entityType: row.entity_type,
739
+ title: row.title,
740
+ createdAt: row.created_at,
741
+ updatedAt: row.updated_at,
742
+ ...JSON.parse(row.profile_json)
743
+ });
744
+ }
745
+ function mapEntityProfileRow(row, fallback) {
746
+ return actionProfileSchema.parse({
747
+ id: row.id,
748
+ profileKey: fallback.profileKey,
749
+ title: fallback.title,
750
+ entityType: fallback.entityType,
751
+ createdAt: row.created_at,
752
+ updatedAt: row.updated_at,
753
+ ...JSON.parse(row.profile_json)
754
+ });
755
+ }
756
+ function readEntityActionProfileRow(entityType, entityId) {
757
+ return getDatabase()
758
+ .prepare(`SELECT id, entity_type, entity_id, profile_json, created_at, updated_at
759
+ FROM entity_action_profiles
760
+ WHERE entity_type = ? AND entity_id = ?`)
761
+ .get(entityType, entityId);
762
+ }
763
+ export function readEntityActionProfile(entityType, entityId, fallback) {
764
+ const row = readEntityActionProfileRow(entityType, entityId);
765
+ return row ? mapEntityProfileRow(row, fallback) : null;
766
+ }
767
+ function ensureActionProfileTemplates() {
768
+ const database = getDatabase();
769
+ const insert = database.prepare(`INSERT OR IGNORE INTO action_profile_templates (
770
+ id,
771
+ profile_key,
772
+ entity_type,
773
+ title,
774
+ profile_json,
775
+ created_at,
776
+ updated_at
777
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`);
778
+ for (const profile of seededActionProfiles()) {
779
+ insert.run(profile.id, profile.profileKey, profile.entityType, profile.title, JSON.stringify({
780
+ mode: profile.mode,
781
+ startupAp: profile.startupAp,
782
+ totalCostAp: profile.totalCostAp,
783
+ expectedDurationSeconds: profile.expectedDurationSeconds,
784
+ sustainRateApPerHour: profile.sustainRateApPerHour,
785
+ demandWeights: profile.demandWeights,
786
+ doubleCountPolicy: profile.doubleCountPolicy,
787
+ sourceMethod: profile.sourceMethod,
788
+ costBand: profile.costBand,
789
+ recoveryEffect: profile.recoveryEffect,
790
+ metadata: profile.metadata
791
+ }), profile.createdAt, profile.updatedAt);
792
+ }
793
+ }
794
+ export function upsertTaskActionProfile(input) {
795
+ const now = nowIso();
796
+ const profile = buildDefaultTaskActionProfile({
797
+ id: `profile_task_${input.taskId}`,
798
+ profileKey: `task_${input.taskId}`,
799
+ title: input.title || "Task",
800
+ expectedDurationSeconds: input.plannedDurationSeconds,
801
+ totalCostAp: input.totalCostAp ?? resolveBandTotalCostAp(input.actionCostBand ?? "standard"),
802
+ costBand: input.actionCostBand ?? "standard",
803
+ sourceMethod: "manual"
804
+ });
805
+ getDatabase()
806
+ .prepare(`INSERT INTO entity_action_profiles (
807
+ id,
808
+ entity_type,
809
+ entity_id,
810
+ profile_json,
811
+ created_at,
812
+ updated_at
813
+ ) VALUES (?, 'task', ?, ?, ?, ?)
814
+ ON CONFLICT(entity_type, entity_id) DO UPDATE SET
815
+ profile_json = excluded.profile_json,
816
+ updated_at = excluded.updated_at`)
817
+ .run(profile.id, input.taskId, JSON.stringify({
818
+ profileKey: profile.profileKey,
819
+ title: profile.title,
820
+ entityType: profile.entityType,
821
+ mode: profile.mode,
822
+ startupAp: profile.startupAp,
823
+ totalCostAp: profile.totalCostAp,
824
+ expectedDurationSeconds: profile.expectedDurationSeconds,
825
+ sustainRateApPerHour: profile.sustainRateApPerHour,
826
+ demandWeights: profile.demandWeights,
827
+ doubleCountPolicy: profile.doubleCountPolicy,
828
+ sourceMethod: profile.sourceMethod,
829
+ costBand: profile.costBand,
830
+ recoveryEffect: profile.recoveryEffect,
831
+ metadata: profile.metadata
832
+ }), now, now);
833
+ }
834
+ export function upsertEntityActionProfile(input) {
835
+ const now = nowIso();
836
+ getDatabase()
837
+ .prepare(`INSERT INTO entity_action_profiles (
838
+ id,
839
+ entity_type,
840
+ entity_id,
841
+ profile_json,
842
+ created_at,
843
+ updated_at
844
+ ) VALUES (?, ?, ?, ?, ?, ?)
845
+ ON CONFLICT(entity_type, entity_id) DO UPDATE SET
846
+ profile_json = excluded.profile_json,
847
+ updated_at = excluded.updated_at`)
848
+ .run(input.profile.id, input.entityType, input.entityId, JSON.stringify({
849
+ mode: input.profile.mode,
850
+ startupAp: input.profile.startupAp,
851
+ totalCostAp: input.profile.totalCostAp,
852
+ expectedDurationSeconds: input.profile.expectedDurationSeconds,
853
+ sustainRateApPerHour: input.profile.sustainRateApPerHour,
854
+ demandWeights: input.profile.demandWeights,
855
+ doubleCountPolicy: input.profile.doubleCountPolicy,
856
+ sourceMethod: input.profile.sourceMethod,
857
+ costBand: input.profile.costBand,
858
+ recoveryEffect: input.profile.recoveryEffect,
859
+ metadata: input.profile.metadata
860
+ }), now, now);
861
+ }
862
+ export function buildWorkBlockTemplateActionProfile(input) {
863
+ const durationMinutes = input.endMinute > input.startMinute
864
+ ? input.endMinute - input.startMinute
865
+ : 24 * 60 - input.startMinute + input.endMinute;
866
+ const activityPresetKey = input.activityPresetKey ??
867
+ resolveWorkBlockPresetKey(input.kind);
868
+ return buildCalendarActivityProfile({
869
+ entityType: "work_block_template",
870
+ entityId: input.templateId,
871
+ title: input.title,
872
+ expectedDurationSeconds: durationMinutes * 60,
873
+ activityPresetKey,
874
+ customSustainRateApPerHour: input.customSustainRateApPerHour,
875
+ sourceMethod: input.customSustainRateApPerHour !== null &&
876
+ input.customSustainRateApPerHour !== undefined
877
+ ? "manual"
878
+ : "inferred",
879
+ metadata: {
880
+ kind: input.kind
881
+ }
882
+ });
883
+ }
884
+ export function buildCalendarEventActionProfile(input) {
885
+ const activityPresetKey = input.activityPresetKey ??
886
+ resolveCalendarEventPresetKey({
887
+ title: input.title,
888
+ eventType: input.eventType,
889
+ availability: input.availability
890
+ });
891
+ return buildCalendarActivityProfile({
892
+ entityType: "calendar_event",
893
+ entityId: input.eventId,
894
+ title: input.title,
895
+ expectedDurationSeconds: Math.max(60, Math.floor((Date.parse(input.endAt) - Date.parse(input.startAt)) / 1000)),
896
+ activityPresetKey,
897
+ customSustainRateApPerHour: input.customSustainRateApPerHour,
898
+ sourceMethod: input.customSustainRateApPerHour !== null &&
899
+ input.customSustainRateApPerHour !== undefined
900
+ ? "manual"
901
+ : "inferred",
902
+ metadata: {
903
+ availability: input.availability,
904
+ eventType: input.eventType ?? ""
905
+ }
906
+ });
907
+ }
908
+ function ensureLifeForceProfile(userId) {
909
+ const database = getDatabase();
910
+ const existing = database
911
+ .prepare(`SELECT *
912
+ FROM life_force_profiles
913
+ WHERE user_id = ?`)
914
+ .get(userId);
915
+ if (existing) {
916
+ return existing;
917
+ }
918
+ const now = nowIso();
919
+ database
920
+ .prepare(`INSERT INTO life_force_profiles (
921
+ user_id,
922
+ base_daily_ap,
923
+ readiness_multiplier,
924
+ life_force_level,
925
+ activation_level,
926
+ focus_level,
927
+ vigor_level,
928
+ composure_level,
929
+ flow_level,
930
+ created_at,
931
+ updated_at
932
+ ) VALUES (?, 200, 1.0, 1, 1, 1, 1, 1, 1, ?, ?)`)
933
+ .run(userId, now, now);
934
+ return database
935
+ .prepare(`SELECT *
936
+ FROM life_force_profiles
937
+ WHERE user_id = ?`)
938
+ .get(userId);
939
+ }
940
+ function ensureWeekdayTemplates(userId) {
941
+ const database = getDatabase();
942
+ const insert = database.prepare(`INSERT OR IGNORE INTO life_force_weekday_templates (
943
+ id,
944
+ user_id,
945
+ weekday,
946
+ baseline_daily_ap,
947
+ points_json,
948
+ created_at,
949
+ updated_at
950
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`);
951
+ const now = nowIso();
952
+ const defaultPoints = JSON.stringify(defaultTemplatePoints());
953
+ for (let weekday = 0; weekday < 7; weekday += 1) {
954
+ insert.run(`lf_template_${userId}_${weekday}`, userId, weekday, LIFE_FORCE_BASELINE_DAILY_AP, defaultPoints, now, now);
955
+ }
956
+ }
957
+ function readWeekdayTemplate(userId, weekday) {
958
+ ensureWeekdayTemplates(userId);
959
+ return getDatabase()
960
+ .prepare(`SELECT *
961
+ FROM life_force_weekday_templates
962
+ WHERE user_id = ? AND weekday = ?`)
963
+ .get(userId, weekday);
964
+ }
965
+ function readTaskRunRows(range, userId) {
966
+ return getDatabase()
967
+ .prepare(`SELECT
968
+ task_runs.id,
969
+ task_runs.task_id,
970
+ task_runs.actor,
971
+ task_runs.status,
972
+ task_runs.is_current,
973
+ task_runs.claimed_at,
974
+ task_runs.heartbeat_at,
975
+ task_runs.lease_expires_at,
976
+ task_runs.completed_at,
977
+ task_runs.released_at,
978
+ task_runs.timed_out_at,
979
+ task_runs.updated_at,
980
+ tasks.title AS task_title,
981
+ task_runs.planned_duration_seconds,
982
+ tasks.planned_duration_seconds AS task_expected_duration_seconds
983
+ FROM task_runs
984
+ INNER JOIN tasks ON tasks.id = task_runs.task_id
985
+ INNER JOIN entity_owners
986
+ ON entity_owners.entity_type = 'task'
987
+ AND entity_owners.entity_id = tasks.id
988
+ AND entity_owners.role = 'owner'
989
+ WHERE entity_owners.user_id = ?
990
+ AND task_runs.claimed_at < ?
991
+ AND COALESCE(task_runs.completed_at, task_runs.released_at, task_runs.timed_out_at, task_runs.updated_at, task_runs.lease_expires_at, task_runs.heartbeat_at) >= ?`)
992
+ .all(userId, range.to, range.from);
993
+ }
994
+ function terminalRunMs(row, now) {
995
+ if (row.status === "active") {
996
+ return Math.max(Date.parse(row.claimed_at), Math.min(now.getTime(), Date.parse(row.lease_expires_at)));
997
+ }
998
+ const terminal = row.completed_at ??
999
+ row.released_at ??
1000
+ row.timed_out_at ??
1001
+ row.updated_at ??
1002
+ row.lease_expires_at ??
1003
+ row.heartbeat_at;
1004
+ return Math.max(Date.parse(row.claimed_at), Date.parse(terminal));
1005
+ }
1006
+ function overlapSeconds(range, row, now) {
1007
+ const start = Math.max(range.startMs, Date.parse(row.claimed_at));
1008
+ const end = Math.min(range.endMs, terminalRunMs(row, now));
1009
+ return Math.max(0, Math.floor((end - start) / 1000));
1010
+ }
1011
+ function readStatXpByKey(userId) {
1012
+ const rows = getDatabase()
1013
+ .prepare(`SELECT stat_key, COALESCE(SUM(delta_xp), 0) AS xp
1014
+ FROM stat_xp_events
1015
+ WHERE user_id = ?
1016
+ GROUP BY stat_key`)
1017
+ .all(userId);
1018
+ return new Map(rows.map((row) => [row.stat_key, row.xp]));
1019
+ }
1020
+ function buildStats(profile, userId) {
1021
+ const xpByKey = readStatXpByKey(userId);
1022
+ const levelByKey = buildStatLevels(profile);
1023
+ return Object.keys(levelByKey).map((key) => {
1024
+ const level = levelByKey[key];
1025
+ return {
1026
+ key,
1027
+ label: LIFE_FORCE_STAT_LABELS[key],
1028
+ level,
1029
+ xp: xpByKey.get(key) ?? 0,
1030
+ xpToNextLevel: level * 100,
1031
+ costModifier: key === "life_force"
1032
+ ? Number(computeLifeForceLevelMultiplier(level).toFixed(3))
1033
+ : Number(computeStatCostModifier(level).toFixed(3))
1034
+ };
1035
+ });
1036
+ }
1037
+ function computeLifeForceMultiplier(profile) {
1038
+ return computeLifeForceLevelMultiplier(profile.life_force_level);
1039
+ }
1040
+ function readPrimarySleepSessionForDate(userId, date) {
1041
+ const range = buildDayRange(date);
1042
+ const lookback = new Date(range.startMs - 18 * 60 * 60 * 1000).toISOString();
1043
+ try {
1044
+ return getDatabase()
1045
+ .prepare(`SELECT id, started_at, ended_at, asleep_seconds, sleep_score
1046
+ FROM health_sleep_sessions
1047
+ WHERE user_id = ?
1048
+ AND ended_at >= ?
1049
+ AND ended_at < ?
1050
+ ORDER BY asleep_seconds DESC, ended_at DESC
1051
+ LIMIT 1`)
1052
+ .get(userId, lookback, range.to);
1053
+ }
1054
+ catch {
1055
+ return undefined;
1056
+ }
1057
+ }
1058
+ function computeSleepRecoveryMultiplier(userId, date) {
1059
+ const session = readPrimarySleepSessionForDate(userId, date);
1060
+ if (!session) {
1061
+ return 1;
1062
+ }
1063
+ const sleepHours = session.asleep_seconds / 3600;
1064
+ const durationFactor = clamp(0.82 + ((sleepHours - 4.5) / 4.5) * 0.22, 0.85, 1.1);
1065
+ const scoreFactor = session.sleep_score === null
1066
+ ? 1
1067
+ : clamp(0.92 + (session.sleep_score / 100) * 0.16, 0.9, 1.08);
1068
+ return Number((durationFactor * scoreFactor).toFixed(3));
1069
+ }
1070
+ function computeFatigueDebtCarry(userId, date) {
1071
+ const previous = new Date(date);
1072
+ previous.setUTCDate(previous.getUTCDate() - 1);
1073
+ const previousKey = toDateKey(previous);
1074
+ const snapshot = getDatabase()
1075
+ .prepare(`SELECT daily_budget_ap
1076
+ FROM life_force_day_snapshots
1077
+ WHERE user_id = ? AND date_key = ?`)
1078
+ .get(userId, previousKey);
1079
+ if (!snapshot) {
1080
+ return 0;
1081
+ }
1082
+ const spent = getDatabase()
1083
+ .prepare(`SELECT COALESCE(SUM(total_ap), 0) AS total_ap
1084
+ FROM ap_ledger_events
1085
+ WHERE user_id = ? AND date_key = ?`)
1086
+ .get(userId, previousKey);
1087
+ return Math.max(0, Number((spent.total_ap - snapshot.daily_budget_ap).toFixed(2)));
1088
+ }
1089
+ function getOrCreateDaySnapshot(userId, date) {
1090
+ const range = buildDayRange(date);
1091
+ const existing = getDatabase()
1092
+ .prepare(`SELECT *
1093
+ FROM life_force_day_snapshots
1094
+ WHERE user_id = ? AND date_key = ?`)
1095
+ .get(userId, range.dateKey);
1096
+ if (existing) {
1097
+ return existing;
1098
+ }
1099
+ const profile = ensureLifeForceProfile(userId);
1100
+ const template = readWeekdayTemplate(userId, date.getUTCDay());
1101
+ const sleepRecoveryMultiplier = computeSleepRecoveryMultiplier(userId, date);
1102
+ const fatigueDebtCarry = computeFatigueDebtCarry(userId, date);
1103
+ const readinessMultiplier = profile.readiness_multiplier;
1104
+ const dailyBudgetAp = Math.max(40, Math.round(profile.base_daily_ap *
1105
+ computeLifeForceMultiplier(profile) *
1106
+ sleepRecoveryMultiplier *
1107
+ readinessMultiplier) - fatigueDebtCarry);
1108
+ const points = normalizeCurveToBudget(parseCurvePoints(template.points_json), dailyBudgetAp);
1109
+ const now = nowIso();
1110
+ const id = `lf_day_${userId}_${range.dateKey}`;
1111
+ getDatabase()
1112
+ .prepare(`INSERT INTO life_force_day_snapshots (
1113
+ id,
1114
+ user_id,
1115
+ date_key,
1116
+ daily_budget_ap,
1117
+ sleep_recovery_multiplier,
1118
+ readiness_multiplier,
1119
+ fatigue_debt_carry,
1120
+ points_json,
1121
+ created_at,
1122
+ updated_at
1123
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1124
+ .run(id, userId, range.dateKey, dailyBudgetAp, sleepRecoveryMultiplier, readinessMultiplier, fatigueDebtCarry, JSON.stringify(points), now, now);
1125
+ return getDatabase()
1126
+ .prepare(`SELECT *
1127
+ FROM life_force_day_snapshots
1128
+ WHERE id = ?`)
1129
+ .get(id);
1130
+ }
1131
+ export function resolveTaskActionProfile(task, lifeForceProfile) {
1132
+ const row = readEntityActionProfileRow("task", task.id);
1133
+ const baseProfile = !row
1134
+ ? buildDefaultTaskActionProfile({
1135
+ id: `profile_task_${task.id}`,
1136
+ expectedDurationSeconds: task.plannedDurationSeconds
1137
+ })
1138
+ : mapEntityProfileRow(row, {
1139
+ profileKey: `task_${task.id}`,
1140
+ title: "Task",
1141
+ entityType: "task"
1142
+ });
1143
+ const profile = {
1144
+ ...baseProfile,
1145
+ expectedDurationSeconds: resolveTaskExpectedDurationSeconds(baseProfile.expectedDurationSeconds ?? task.plannedDurationSeconds)
1146
+ };
1147
+ return lifeForceProfile ? buildEffectiveProfile(profile, lifeForceProfile) : profile;
1148
+ }
1149
+ export function buildTaskTimeboxActionProfile(input) {
1150
+ const durationSeconds = Math.max(60, Math.floor((Date.parse(input.endsAt) - Date.parse(input.startsAt)) / 1000));
1151
+ const fallbackTaskProfile = resolveTaskActionProfile({
1152
+ id: input.taskId,
1153
+ plannedDurationSeconds: input.taskPlannedDurationSeconds ?? null
1154
+ });
1155
+ const activityPresetKey = input.activityPresetKey ??
1156
+ "task_inherited";
1157
+ return buildCalendarActivityProfile({
1158
+ entityType: "task_timebox",
1159
+ entityId: input.timeboxId,
1160
+ title: input.title,
1161
+ expectedDurationSeconds: durationSeconds,
1162
+ activityPresetKey,
1163
+ customSustainRateApPerHour: input.customSustainRateApPerHour,
1164
+ sourceMethod: input.customSustainRateApPerHour !== null &&
1165
+ input.customSustainRateApPerHour !== undefined
1166
+ ? "manual"
1167
+ : activityPresetKey === "task_inherited"
1168
+ ? "inferred"
1169
+ : "manual",
1170
+ fallbackProfile: fallbackTaskProfile,
1171
+ metadata: {
1172
+ taskId: input.taskId
1173
+ }
1174
+ });
1175
+ }
1176
+ function readTodayAdjustmentRows(userId, range) {
1177
+ return getDatabase()
1178
+ .prepare(`SELECT
1179
+ work_adjustments.id,
1180
+ work_adjustments.entity_type,
1181
+ work_adjustments.entity_id,
1182
+ work_adjustments.applied_delta_minutes,
1183
+ work_adjustments.note,
1184
+ work_adjustments.created_at,
1185
+ tasks.planned_duration_seconds
1186
+ FROM work_adjustments
1187
+ LEFT JOIN tasks
1188
+ ON work_adjustments.entity_type = 'task'
1189
+ AND tasks.id = work_adjustments.entity_id
1190
+ INNER JOIN entity_owners
1191
+ ON entity_owners.entity_type = work_adjustments.entity_type
1192
+ AND entity_owners.entity_id = work_adjustments.entity_id
1193
+ AND entity_owners.role = 'owner'
1194
+ WHERE entity_owners.user_id = ?
1195
+ AND work_adjustments.created_at >= ?
1196
+ AND work_adjustments.created_at < ?`)
1197
+ .all(userId, range.from, range.to);
1198
+ }
1199
+ function readTodayAdjustmentApByTaskId(userId, range, lifeForceProfile) {
1200
+ const rows = readTodayAdjustmentRows(userId, range);
1201
+ const totals = new Map();
1202
+ for (const row of rows) {
1203
+ if (row.entity_type !== "task") {
1204
+ continue;
1205
+ }
1206
+ const profile = resolveTaskActionProfile({
1207
+ id: row.entity_id,
1208
+ plannedDurationSeconds: row.planned_duration_seconds
1209
+ }, lifeForceProfile);
1210
+ const deltaAp = rateToTotalAp(profile.sustainRateApPerHour, row.applied_delta_minutes * 60);
1211
+ totals.set(row.entity_id, (totals.get(row.entity_id) ?? 0) + deltaAp);
1212
+ }
1213
+ return totals;
1214
+ }
1215
+ function readTodayAdjustmentSecondsByTaskId(userId, range) {
1216
+ const rows = readTodayAdjustmentRows(userId, range);
1217
+ const totals = new Map();
1218
+ for (const row of rows) {
1219
+ if (row.entity_type !== "task") {
1220
+ continue;
1221
+ }
1222
+ totals.set(row.entity_id, (totals.get(row.entity_id) ?? 0) + row.applied_delta_minutes * 60);
1223
+ }
1224
+ return totals;
1225
+ }
1226
+ function readActiveTaskRunProjectionRows(taskId) {
1227
+ return getDatabase()
1228
+ .prepare(`SELECT
1229
+ id,
1230
+ task_id,
1231
+ timer_mode,
1232
+ planned_duration_seconds,
1233
+ claimed_at,
1234
+ lease_expires_at,
1235
+ status
1236
+ FROM task_runs
1237
+ WHERE task_id = ?
1238
+ AND status = 'active'`)
1239
+ .all(taskId);
1240
+ }
1241
+ function computeProjectedRemainingSeconds(row, now) {
1242
+ if (row.timer_mode !== "planned" || row.planned_duration_seconds === null) {
1243
+ return 0;
1244
+ }
1245
+ const endMs = Math.min(now.getTime(), Date.parse(row.lease_expires_at));
1246
+ const elapsedWallSeconds = Math.max(0, Math.floor((endMs - Date.parse(row.claimed_at)) / 1000));
1247
+ return Math.max(0, row.planned_duration_seconds - elapsedWallSeconds);
1248
+ }
1249
+ function buildTaskLifeForceRuntime(task, userId, now = new Date(), lifeForceProfile = ensureLifeForceProfile(userId)) {
1250
+ const range = buildDayRange(now);
1251
+ const profile = resolveTaskActionProfile(task, lifeForceProfile);
1252
+ const todayRunSeconds = readTaskRunRows(range, userId)
1253
+ .filter((row) => row.task_id === task.id)
1254
+ .reduce((sum, row) => sum + overlapSeconds(range, row, now), 0);
1255
+ const todayAdjustmentSeconds = readTodayAdjustmentSecondsByTaskId(userId, range).get(task.id) ?? 0;
1256
+ const todayCreditedSeconds = todayRunSeconds + todayAdjustmentSeconds;
1257
+ const spentTodayAp = (todayCreditedSeconds / 3600) * profile.sustainRateApPerHour;
1258
+ const spentTotalAp = (task.time.totalCreditedSeconds / 3600) * profile.sustainRateApPerHour;
1259
+ const projectedTotalSeconds = task.time.totalCreditedSeconds +
1260
+ readActiveTaskRunProjectionRows(task.id).reduce((sum, row) => sum + computeProjectedRemainingSeconds(row, now), 0);
1261
+ return {
1262
+ taskId: task.id,
1263
+ profile,
1264
+ todayRunSeconds,
1265
+ todayAdjustmentSeconds,
1266
+ todayCreditedSeconds,
1267
+ spentTodayAp,
1268
+ spentTotalAp,
1269
+ projectedTotalSeconds
1270
+ };
1271
+ }
1272
+ function readTaskRunWindowsByTaskId(userId, range, now) {
1273
+ const windows = new Map();
1274
+ for (const row of readTaskRunRows(range, userId)) {
1275
+ const startMs = Math.max(range.startMs, Date.parse(row.claimed_at));
1276
+ const endMs = Math.min(range.endMs, terminalRunMs(row, now));
1277
+ if (endMs <= startMs) {
1278
+ continue;
1279
+ }
1280
+ const list = windows.get(row.task_id) ?? [];
1281
+ list.push({ startMs, endMs });
1282
+ windows.set(row.task_id, list);
1283
+ }
1284
+ return windows;
1285
+ }
1286
+ function buildWorkAdjustmentContributions(userId, range, lifeForceProfile) {
1287
+ return readTodayAdjustmentRows(userId, range)
1288
+ .filter((row) => row.entity_type === "task")
1289
+ .map((row) => {
1290
+ const profile = resolveTaskActionProfile({
1291
+ id: row.entity_id,
1292
+ plannedDurationSeconds: row.planned_duration_seconds
1293
+ }, lifeForceProfile);
1294
+ const totalAp = rateToTotalAp(profile.sustainRateApPerHour, row.applied_delta_minutes * 60);
1295
+ return {
1296
+ entityType: "task",
1297
+ entityId: row.entity_id,
1298
+ eventKind: "work_adjustment",
1299
+ sourceKind: "work_adjustment",
1300
+ totalAp,
1301
+ rateApPerHour: null,
1302
+ title: row.note?.trim() || "Manual work adjustment",
1303
+ why: "Manual time adjustments count toward today's Action Point spend.",
1304
+ startsAt: row.created_at,
1305
+ endsAt: row.created_at,
1306
+ role: "background",
1307
+ metadata: {
1308
+ adjustmentId: row.id,
1309
+ appliedDeltaMinutes: row.applied_delta_minutes
1310
+ }
1311
+ };
1312
+ });
1313
+ }
1314
+ function buildTaskRunContributions(userId, range, now, lifeForceProfile) {
1315
+ const contributions = [];
1316
+ const totalsByTaskId = new Map();
1317
+ const activeDrains = [];
1318
+ for (const row of readTaskRunRows(range, userId)) {
1319
+ const seconds = overlapSeconds(range, row, now);
1320
+ if (seconds <= 0) {
1321
+ continue;
1322
+ }
1323
+ const profile = resolveTaskActionProfile({
1324
+ id: row.task_id,
1325
+ plannedDurationSeconds: row.task_expected_duration_seconds ?? row.planned_duration_seconds
1326
+ }, lifeForceProfile);
1327
+ const totalAp = rateToTotalAp(profile.sustainRateApPerHour, seconds);
1328
+ const startsAt = new Date(Math.max(range.startMs, Date.parse(row.claimed_at))).toISOString();
1329
+ const endsAt = new Date(Math.min(range.endMs, terminalRunMs(row, now))).toISOString();
1330
+ const contribution = {
1331
+ entityType: "task",
1332
+ entityId: row.task_id,
1333
+ eventKind: "task_run",
1334
+ sourceKind: "task_run",
1335
+ totalAp,
1336
+ rateApPerHour: profile.sustainRateApPerHour,
1337
+ title: row.task_title,
1338
+ why: "Active timed work consumes Action Points proportionally to actual time worked today.",
1339
+ startsAt,
1340
+ endsAt,
1341
+ role: row.is_current === 1 ? "primary" : "secondary",
1342
+ metadata: { taskRunId: row.id }
1343
+ };
1344
+ contributions.push(contribution);
1345
+ const existing = totalsByTaskId.get(row.task_id) ?? { todayAp: 0, totalAp: 0 };
1346
+ existing.todayAp += totalAp;
1347
+ existing.totalAp += totalAp;
1348
+ totalsByTaskId.set(row.task_id, existing);
1349
+ if (row.status === "active" && Date.parse(row.lease_expires_at) > now.getTime()) {
1350
+ activeDrains.push({
1351
+ ...contribution,
1352
+ totalAp: 0
1353
+ });
1354
+ }
1355
+ }
1356
+ return { contributions, totalsByTaskId, activeDrains };
1357
+ }
1358
+ function buildNoteContributions(userId, range, now, lifeForceProfile) {
1359
+ try {
1360
+ const noteProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "note_quick"), lifeForceProfile);
1361
+ const taskRunWindowsByTaskId = readTaskRunWindowsByTaskId(userId, range, now);
1362
+ const rows = getDatabase()
1363
+ .prepare(`SELECT
1364
+ notes.id,
1365
+ notes.title,
1366
+ notes.created_at,
1367
+ GROUP_CONCAT(
1368
+ CASE
1369
+ WHEN note_links.entity_type = 'task' THEN note_links.entity_id
1370
+ ELSE NULL
1371
+ END
1372
+ ) AS linked_task_ids
1373
+ FROM notes
1374
+ LEFT JOIN note_links ON note_links.note_id = notes.id
1375
+ INNER JOIN entity_owners
1376
+ ON entity_owners.entity_type = 'note'
1377
+ AND entity_owners.entity_id = notes.id
1378
+ AND entity_owners.role = 'owner'
1379
+ WHERE entity_owners.user_id = ?
1380
+ AND notes.created_at >= ?
1381
+ AND notes.created_at < ?
1382
+ GROUP BY notes.id, notes.title, notes.created_at`)
1383
+ .all(userId, range.from, range.to);
1384
+ return rows
1385
+ .filter((row) => {
1386
+ const createdAtMs = Date.parse(row.created_at);
1387
+ const linkedTaskIds = (row.linked_task_ids ?? "")
1388
+ .split(",")
1389
+ .map((entry) => entry.trim())
1390
+ .filter(Boolean);
1391
+ return !linkedTaskIds.some((taskId) => (taskRunWindowsByTaskId.get(taskId) ?? []).some((window) => createdAtMs >= window.startMs && createdAtMs <= window.endMs));
1392
+ })
1393
+ .map((row) => ({
1394
+ entityType: "note",
1395
+ entityId: row.id,
1396
+ eventKind: "note_created",
1397
+ sourceKind: "note",
1398
+ totalAp: noteProfile.totalCostAp,
1399
+ rateApPerHour: null,
1400
+ title: row.title || "Note",
1401
+ why: "Standalone capture takes a small impulse of activation and focus.",
1402
+ startsAt: row.created_at,
1403
+ endsAt: row.created_at,
1404
+ role: "background"
1405
+ }));
1406
+ }
1407
+ catch {
1408
+ return [];
1409
+ }
1410
+ }
1411
+ function buildHabitContributions(userId, range, lifeForceProfile) {
1412
+ try {
1413
+ const habitProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "habit_default"), lifeForceProfile);
1414
+ const rows = getDatabase()
1415
+ .prepare(`SELECT
1416
+ habits.id,
1417
+ habits.title,
1418
+ habit_check_ins.created_at,
1419
+ health_workout_sessions.id AS generated_workout_id
1420
+ FROM habit_check_ins
1421
+ INNER JOIN habits ON habits.id = habit_check_ins.habit_id
1422
+ LEFT JOIN health_workout_sessions
1423
+ ON health_workout_sessions.generated_from_check_in_id = habit_check_ins.id
1424
+ INNER JOIN entity_owners
1425
+ ON entity_owners.entity_type = 'habit'
1426
+ AND entity_owners.entity_id = habits.id
1427
+ AND entity_owners.role = 'owner'
1428
+ WHERE entity_owners.user_id = ?
1429
+ AND habit_check_ins.created_at >= ?
1430
+ AND habit_check_ins.created_at < ?`)
1431
+ .all(userId, range.from, range.to);
1432
+ return rows
1433
+ .filter((row) => row.generated_workout_id === null)
1434
+ .map((row) => ({
1435
+ entityType: "habit",
1436
+ entityId: row.id,
1437
+ eventKind: "habit_check_in",
1438
+ sourceKind: "habit",
1439
+ totalAp: habitProfile.totalCostAp,
1440
+ rateApPerHour: null,
1441
+ title: row.title,
1442
+ why: "Habit execution still costs activation even when the action is short.",
1443
+ startsAt: row.created_at,
1444
+ endsAt: row.created_at,
1445
+ role: "background"
1446
+ }));
1447
+ }
1448
+ catch {
1449
+ return [];
1450
+ }
1451
+ }
1452
+ function buildWorkoutContributions(userId, range, now, lifeForceProfile) {
1453
+ let rows = [];
1454
+ try {
1455
+ rows = getDatabase()
1456
+ .prepare(`SELECT id, workout_type, started_at, ended_at, duration_seconds, subjective_effort
1457
+ FROM health_workout_sessions
1458
+ WHERE user_id = ?
1459
+ AND started_at < ?
1460
+ AND ended_at >= ?`)
1461
+ .all(userId, range.to, range.from);
1462
+ }
1463
+ catch {
1464
+ rows = [];
1465
+ }
1466
+ const contributions = [];
1467
+ const activeDrains = [];
1468
+ const workoutProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "workout_default"), lifeForceProfile);
1469
+ for (const row of rows) {
1470
+ const startMs = Math.max(range.startMs, Date.parse(row.started_at));
1471
+ const endMs = Math.min(range.endMs, Date.parse(row.ended_at));
1472
+ const seconds = Math.max(0, Math.floor((endMs - startMs) / 1000));
1473
+ if (seconds <= 0) {
1474
+ continue;
1475
+ }
1476
+ const effortMultiplier = row.subjective_effort
1477
+ ? clamp(row.subjective_effort / 6, 0.8, 1.6)
1478
+ : 1;
1479
+ const rateApPerHour = Number((workoutProfile.sustainRateApPerHour * effortMultiplier).toFixed(4));
1480
+ const contribution = {
1481
+ entityType: "workout_session",
1482
+ entityId: row.id,
1483
+ eventKind: "workout_session",
1484
+ sourceKind: "workout",
1485
+ totalAp: rateToTotalAp(rateApPerHour, seconds),
1486
+ rateApPerHour,
1487
+ title: row.workout_type,
1488
+ why: "Workout sessions consume real physical capacity and should affect current load.",
1489
+ startsAt: new Date(startMs).toISOString(),
1490
+ endsAt: new Date(endMs).toISOString(),
1491
+ role: "secondary"
1492
+ };
1493
+ contributions.push(contribution);
1494
+ if (Date.parse(row.started_at) <= now.getTime() && Date.parse(row.ended_at) > now.getTime()) {
1495
+ activeDrains.push({ ...contribution, totalAp: 0 });
1496
+ }
1497
+ }
1498
+ return { contributions, activeDrains };
1499
+ }
1500
+ function buildWakeImpulseContributions(userId, range, lifeForceProfile) {
1501
+ const primarySleep = readPrimarySleepSessionForDate(userId, new Date(range.startMs));
1502
+ if (!primarySleep) {
1503
+ return [];
1504
+ }
1505
+ const endedAtMs = Date.parse(primarySleep.ended_at);
1506
+ if (endedAtMs < range.startMs || endedAtMs >= range.endMs) {
1507
+ return [];
1508
+ }
1509
+ const wakeProfile = buildEffectiveProfile(seededActionProfiles().find((entry) => entry.profileKey === "wake_start"), lifeForceProfile);
1510
+ return [
1511
+ {
1512
+ entityType: "sleep_session",
1513
+ entityId: primarySleep.id,
1514
+ eventKind: "wake_start",
1515
+ sourceKind: "wake",
1516
+ totalAp: wakeProfile.totalCostAp,
1517
+ rateApPerHour: null,
1518
+ title: "Get out of bed",
1519
+ why: "Starting the day takes real activation and should count as an Action Point impulse.",
1520
+ startsAt: primarySleep.ended_at,
1521
+ endsAt: primarySleep.ended_at,
1522
+ role: "background"
1523
+ }
1524
+ ];
1525
+ }
1526
+ function buildMovementTripProfile(trip) {
1527
+ const baseProfile = seededActionProfiles().find((entry) => entry.profileKey === "movement_trip_default");
1528
+ const expectedMet = trip.expected_met ?? 2;
1529
+ const baseRateApPerHour = clamp(expectedMet * 4, 4, 22);
1530
+ const lowerTitle = `${trip.activity_type} ${trip.travel_mode}`.toLowerCase();
1531
+ const vigor = lowerTitle.includes("walk") || lowerTitle.includes("run") || lowerTitle.includes("bike")
1532
+ ? 0.65
1533
+ : lowerTitle.includes("drive") || lowerTitle.includes("train")
1534
+ ? 0.2
1535
+ : 0.45;
1536
+ const focus = lowerTitle.includes("drive") ? 0.35 : 0.15;
1537
+ const flow = 1 - vigor - focus;
1538
+ return actionProfileSchema.parse({
1539
+ ...baseProfile,
1540
+ id: `${baseProfile.id}_${trip.travel_mode}_${trip.activity_type || "travel"}`,
1541
+ profileKey: `${baseProfile.profileKey}_${trip.travel_mode}_${trip.activity_type || "travel"}`,
1542
+ title: trip.activity_type?.trim() || trip.travel_mode || "Movement trip",
1543
+ sustainRateApPerHour: Number(baseRateApPerHour.toFixed(4)),
1544
+ totalCostAp: Number(baseRateApPerHour.toFixed(4)),
1545
+ demandWeights: {
1546
+ activation: 0.05,
1547
+ focus,
1548
+ vigor,
1549
+ composure: 0,
1550
+ flow: Math.max(0.05, Number(flow.toFixed(3)))
1551
+ }
1552
+ });
1553
+ }
1554
+ function buildMovementTripContributions(userId, range, now, lifeForceProfile) {
1555
+ let rows = [];
1556
+ try {
1557
+ rows = getDatabase()
1558
+ .prepare(`SELECT
1559
+ id,
1560
+ label,
1561
+ status,
1562
+ travel_mode,
1563
+ activity_type,
1564
+ started_at,
1565
+ ended_at,
1566
+ moving_seconds,
1567
+ idle_seconds,
1568
+ expected_met,
1569
+ distance_meters
1570
+ FROM movement_trips
1571
+ WHERE user_id = ?
1572
+ AND started_at < ?
1573
+ AND ended_at >= ?`)
1574
+ .all(userId, range.to, range.from);
1575
+ }
1576
+ catch {
1577
+ rows = [];
1578
+ }
1579
+ const contributions = [];
1580
+ const activeDrains = [];
1581
+ for (const row of rows) {
1582
+ const seconds = Math.max(0, Math.floor((Math.min(range.endMs, Date.parse(row.ended_at)) -
1583
+ Math.max(range.startMs, Date.parse(row.started_at))) /
1584
+ 1000));
1585
+ if (seconds <= 0) {
1586
+ continue;
1587
+ }
1588
+ const profile = buildEffectiveProfile(buildMovementTripProfile(row), lifeForceProfile);
1589
+ const contribution = {
1590
+ entityType: "movement_trip",
1591
+ entityId: row.id,
1592
+ eventKind: "movement_trip",
1593
+ sourceKind: "movement",
1594
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, seconds),
1595
+ rateApPerHour: profile.sustainRateApPerHour,
1596
+ title: row.label || row.activity_type || row.travel_mode || "Movement trip",
1597
+ why: "Movement and commuting consume current capacity through physical effort, attention, and switching overhead.",
1598
+ startsAt: new Date(Math.max(range.startMs, Date.parse(row.started_at))).toISOString(),
1599
+ endsAt: new Date(Math.min(range.endMs, Date.parse(row.ended_at))).toISOString(),
1600
+ role: "secondary"
1601
+ };
1602
+ contributions.push(contribution);
1603
+ if (Date.parse(row.started_at) <= now.getTime() && Date.parse(row.ended_at) > now.getTime()) {
1604
+ activeDrains.push({ ...contribution, totalAp: 0 });
1605
+ }
1606
+ }
1607
+ return { contributions, activeDrains };
1608
+ }
1609
+ function readTaskTimeboxLifeForceRows(userId, range) {
1610
+ try {
1611
+ return getDatabase()
1612
+ .prepare(`SELECT
1613
+ task_timeboxes.id,
1614
+ task_timeboxes.task_id,
1615
+ task_timeboxes.linked_task_run_id,
1616
+ task_timeboxes.status,
1617
+ task_timeboxes.source,
1618
+ task_timeboxes.title,
1619
+ task_timeboxes.starts_at,
1620
+ task_timeboxes.ends_at,
1621
+ tasks.planned_duration_seconds AS task_planned_duration_seconds
1622
+ FROM task_timeboxes
1623
+ INNER JOIN tasks ON tasks.id = task_timeboxes.task_id
1624
+ INNER JOIN entity_owners
1625
+ ON entity_owners.entity_type = 'task_timebox'
1626
+ AND entity_owners.entity_id = task_timeboxes.id
1627
+ AND entity_owners.role = 'owner'
1628
+ WHERE entity_owners.user_id = ?
1629
+ AND task_timeboxes.ends_at > ?
1630
+ AND task_timeboxes.starts_at < ?
1631
+ ORDER BY task_timeboxes.starts_at ASC`)
1632
+ .all(userId, range.from, range.to);
1633
+ }
1634
+ catch {
1635
+ return [];
1636
+ }
1637
+ }
1638
+ function readWorkBlockTemplateLifeForceRows(userId) {
1639
+ try {
1640
+ return getDatabase()
1641
+ .prepare(`SELECT
1642
+ work_block_templates.id,
1643
+ work_block_templates.title,
1644
+ work_block_templates.kind,
1645
+ work_block_templates.color,
1646
+ work_block_templates.weekdays_json,
1647
+ work_block_templates.start_minute,
1648
+ work_block_templates.end_minute,
1649
+ work_block_templates.starts_on,
1650
+ work_block_templates.ends_on,
1651
+ work_block_templates.blocking_state,
1652
+ work_block_templates.created_at,
1653
+ work_block_templates.updated_at
1654
+ FROM work_block_templates
1655
+ INNER JOIN entity_owners
1656
+ ON entity_owners.entity_type = 'work_block_template'
1657
+ AND entity_owners.entity_id = work_block_templates.id
1658
+ AND entity_owners.role = 'owner'
1659
+ WHERE entity_owners.user_id = ?`)
1660
+ .all(userId);
1661
+ }
1662
+ catch {
1663
+ return [];
1664
+ }
1665
+ }
1666
+ function parseWeekdaysJson(raw) {
1667
+ try {
1668
+ const parsed = JSON.parse(raw);
1669
+ return Array.isArray(parsed)
1670
+ ? parsed.filter((entry) => Number.isInteger(entry))
1671
+ : [];
1672
+ }
1673
+ catch {
1674
+ return [];
1675
+ }
1676
+ }
1677
+ function deriveTodayWorkBlocks(userId, range) {
1678
+ const dayDate = new Date(range.startMs);
1679
+ const dayKey = range.dateKey;
1680
+ return readWorkBlockTemplateLifeForceRows(userId)
1681
+ .filter((template) => {
1682
+ if (template.starts_on && dayKey < template.starts_on) {
1683
+ return false;
1684
+ }
1685
+ if (template.ends_on && dayKey > template.ends_on) {
1686
+ return false;
1687
+ }
1688
+ return parseWeekdaysJson(template.weekdays_json).includes(dayDate.getUTCDay());
1689
+ })
1690
+ .map((template) => {
1691
+ const startAt = new Date(range.startMs + template.start_minute * 60_000).toISOString();
1692
+ const endAt = new Date(range.startMs + template.end_minute * 60_000).toISOString();
1693
+ return {
1694
+ ...template,
1695
+ instance_id: `wbinst_${template.id}_${dayKey}`,
1696
+ start_at: startAt,
1697
+ end_at: endAt
1698
+ };
1699
+ });
1700
+ }
1701
+ function buildWorkBlockProfile(input) {
1702
+ const storedProfile = readEntityActionProfile("work_block_template", input.templateId, {
1703
+ profileKey: `work_block_template_${input.templateId}`,
1704
+ title: input.title,
1705
+ entityType: "work_block_template"
1706
+ });
1707
+ if (storedProfile) {
1708
+ return storedProfile;
1709
+ }
1710
+ const startMinute = new Date(input.startAt).getUTCHours() * 60 + new Date(input.startAt).getUTCMinutes();
1711
+ const endMinute = new Date(input.endAt).getUTCHours() * 60 + new Date(input.endAt).getUTCMinutes();
1712
+ return buildWorkBlockTemplateActionProfile({
1713
+ templateId: input.templateId,
1714
+ title: input.title,
1715
+ kind: input.kind,
1716
+ startMinute,
1717
+ endMinute
1718
+ });
1719
+ }
1720
+ function buildTimeboxAndWorkBlockDrains(userId, range, now, lifeForceProfile, activeTaskRunTaskIds, actualSourceWindows) {
1721
+ const actualContributions = [];
1722
+ const plannedDrains = [];
1723
+ const activeDrains = [];
1724
+ const timeboxWindows = [];
1725
+ const workBlockWindows = [];
1726
+ const timeboxes = readTaskTimeboxLifeForceRows(userId, range);
1727
+ for (const row of timeboxes) {
1728
+ if (row.linked_task_run_id || row.status === "cancelled") {
1729
+ continue;
1730
+ }
1731
+ timeboxWindows.push({
1732
+ startAt: row.starts_at,
1733
+ endAt: row.ends_at
1734
+ });
1735
+ const storedProfile = readEntityActionProfile("task_timebox", row.id, {
1736
+ profileKey: `task_timebox_${row.id}`,
1737
+ title: row.title,
1738
+ entityType: "task_timebox"
1739
+ });
1740
+ const profile = lifeForceProfile
1741
+ ? buildEffectiveProfile(storedProfile ??
1742
+ buildTaskTimeboxActionProfile({
1743
+ timeboxId: row.id,
1744
+ title: row.title,
1745
+ taskId: row.task_id,
1746
+ taskPlannedDurationSeconds: row.task_planned_duration_seconds,
1747
+ startsAt: row.starts_at,
1748
+ endsAt: row.ends_at
1749
+ }), lifeForceProfile)
1750
+ : storedProfile ??
1751
+ buildTaskTimeboxActionProfile({
1752
+ timeboxId: row.id,
1753
+ title: row.title,
1754
+ taskId: row.task_id,
1755
+ taskPlannedDurationSeconds: row.task_planned_duration_seconds,
1756
+ startsAt: row.starts_at,
1757
+ endsAt: row.ends_at
1758
+ });
1759
+ const elapsedWindow = {
1760
+ startAt: row.starts_at,
1761
+ endAt: new Date(Math.min(now.getTime(), Date.parse(row.ends_at))).toISOString()
1762
+ };
1763
+ const elapsedSeconds = computeUncoveredSeconds(elapsedWindow, actualSourceWindows);
1764
+ if (elapsedSeconds > 0) {
1765
+ actualContributions.push({
1766
+ entityType: "task_timebox",
1767
+ entityId: row.id,
1768
+ eventKind: "task_timebox_actual",
1769
+ sourceKind: "task_timebox",
1770
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, elapsedSeconds),
1771
+ rateApPerHour: profile.sustainRateApPerHour,
1772
+ title: row.title,
1773
+ why: "Elapsed timeboxes count toward today's Action Point spend when no richer timed source covered that window.",
1774
+ startsAt: row.starts_at,
1775
+ endsAt: elapsedWindow.endAt,
1776
+ role: "background"
1777
+ });
1778
+ }
1779
+ const remainingStartMs = Math.max(now.getTime(), Date.parse(row.starts_at));
1780
+ const remainingEndMs = Math.min(range.endMs, Date.parse(row.ends_at));
1781
+ const remainingSeconds = Math.max(0, Math.floor((remainingEndMs - remainingStartMs) / 1000));
1782
+ if (remainingSeconds > 0) {
1783
+ plannedDrains.push({
1784
+ entityType: "task_timebox",
1785
+ entityId: row.id,
1786
+ eventKind: "task_timebox_plan",
1787
+ sourceKind: "task_timebox",
1788
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, remainingSeconds),
1789
+ rateApPerHour: profile.sustainRateApPerHour,
1790
+ title: row.title,
1791
+ why: "Planned task timeboxes forecast how much Action Point throughput is still booked today.",
1792
+ startsAt: row.starts_at,
1793
+ endsAt: row.ends_at,
1794
+ role: "secondary"
1795
+ });
1796
+ }
1797
+ if (Date.parse(row.starts_at) <= now.getTime() &&
1798
+ Date.parse(row.ends_at) > now.getTime() &&
1799
+ !activeTaskRunTaskIds.has(row.task_id) &&
1800
+ !actualSourceWindows.some((window) => overlapsWindow(row.starts_at, row.ends_at, window.startAt, window.endAt))) {
1801
+ activeDrains.push({
1802
+ entityType: "task_timebox",
1803
+ entityId: row.id,
1804
+ eventKind: "task_timebox_context",
1805
+ sourceKind: "task_timebox",
1806
+ totalAp: 0,
1807
+ rateApPerHour: profile.sustainRateApPerHour,
1808
+ title: row.title,
1809
+ why: "An active timebox still occupies current capacity even before live work logging starts.",
1810
+ startsAt: row.starts_at,
1811
+ endsAt: row.ends_at,
1812
+ role: "background"
1813
+ });
1814
+ }
1815
+ }
1816
+ const workBlocks = deriveTodayWorkBlocks(userId, range);
1817
+ for (const block of workBlocks) {
1818
+ workBlockWindows.push({
1819
+ startAt: block.start_at,
1820
+ endAt: block.end_at
1821
+ });
1822
+ const overlapsTimebox = timeboxes.some((timebox) => timebox.status !== "cancelled" &&
1823
+ overlapsWindow(block.start_at, block.end_at, timebox.starts_at, timebox.ends_at));
1824
+ const profile = buildEffectiveProfile(buildWorkBlockProfile({
1825
+ templateId: block.id,
1826
+ title: block.title,
1827
+ kind: block.kind,
1828
+ startAt: block.start_at,
1829
+ endAt: block.end_at
1830
+ }), lifeForceProfile);
1831
+ const elapsedWindow = {
1832
+ startAt: block.start_at,
1833
+ endAt: new Date(Math.min(now.getTime(), Date.parse(block.end_at))).toISOString()
1834
+ };
1835
+ const elapsedSeconds = computeUncoveredSeconds(elapsedWindow, [
1836
+ ...actualSourceWindows,
1837
+ ...timeboxWindows
1838
+ ]);
1839
+ if (elapsedSeconds > 0 && !overlapsTimebox) {
1840
+ actualContributions.push({
1841
+ entityType: "work_block",
1842
+ entityId: block.instance_id,
1843
+ eventKind: "work_block_actual",
1844
+ sourceKind: "work_block",
1845
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, elapsedSeconds),
1846
+ rateApPerHour: profile.sustainRateApPerHour,
1847
+ title: block.title,
1848
+ why: "Elapsed work blocks count toward today's spend when no specific task run or timebox covered that work window.",
1849
+ startsAt: block.start_at,
1850
+ endsAt: elapsedWindow.endAt,
1851
+ role: "background",
1852
+ metadata: {
1853
+ templateId: block.id,
1854
+ kind: block.kind
1855
+ }
1856
+ });
1857
+ }
1858
+ const remainingStartMs = Math.max(now.getTime(), Date.parse(block.start_at));
1859
+ const remainingEndMs = Math.min(range.endMs, Date.parse(block.end_at));
1860
+ const remainingSeconds = Math.max(0, Math.floor((remainingEndMs - remainingStartMs) / 1000));
1861
+ if (remainingSeconds > 0 && !overlapsTimebox) {
1862
+ plannedDrains.push({
1863
+ entityType: "work_block",
1864
+ entityId: block.instance_id,
1865
+ eventKind: "work_block_plan",
1866
+ sourceKind: "work_block",
1867
+ totalAp: rateToTotalAp(profile.sustainRateApPerHour, remainingSeconds),
1868
+ rateApPerHour: profile.sustainRateApPerHour,
1869
+ title: block.title,
1870
+ why: "Work blocks act as planning containers and forecast background load when no richer task plan exists.",
1871
+ startsAt: block.start_at,
1872
+ endsAt: block.end_at,
1873
+ role: "background",
1874
+ metadata: {
1875
+ templateId: block.id,
1876
+ kind: block.kind
1877
+ }
1878
+ });
1879
+ }
1880
+ if (Date.parse(block.start_at) <= now.getTime() &&
1881
+ Date.parse(block.end_at) > now.getTime() &&
1882
+ !overlapsTimebox &&
1883
+ activeTaskRunTaskIds.size === 0 &&
1884
+ !actualSourceWindows.some((window) => overlapsWindow(block.start_at, block.end_at, window.startAt, window.endAt))) {
1885
+ activeDrains.push({
1886
+ entityType: "work_block",
1887
+ entityId: block.instance_id,
1888
+ eventKind: "work_block_context",
1889
+ sourceKind: "work_block",
1890
+ totalAp: 0,
1891
+ rateApPerHour: profile.sustainRateApPerHour,
1892
+ title: block.title,
1893
+ why: "The current work block still claims capacity as a container for focused effort.",
1894
+ startsAt: block.start_at,
1895
+ endsAt: block.end_at,
1896
+ role: "background",
1897
+ metadata: {
1898
+ templateId: block.id,
1899
+ kind: block.kind
1900
+ }
1901
+ });
1902
+ }
1903
+ }
1904
+ return {
1905
+ actualContributions,
1906
+ plannedDrains,
1907
+ activeDrains,
1908
+ timeboxWindows,
1909
+ workBlockWindows
1910
+ };
1911
+ }
1912
+ function buildCalendarDrains(userId, now, range, lifeForceProfile, blockingWindows) {
1913
+ const actualContributions = [];
1914
+ const nowIsoValue = now.toISOString();
1915
+ const activeDrains = [];
1916
+ const plannedDrains = [];
1917
+ try {
1918
+ const rows = getDatabase()
1919
+ .prepare(`SELECT
1920
+ forge_events.id,
1921
+ forge_events.title,
1922
+ forge_events.start_at,
1923
+ forge_events.end_at,
1924
+ forge_events.availability,
1925
+ forge_events.event_type,
1926
+ COUNT(forge_event_links.id) AS link_count
1927
+ FROM forge_events
1928
+ LEFT JOIN forge_event_links
1929
+ ON forge_event_links.forge_event_id = forge_events.id
1930
+ WHERE forge_events.deleted_at IS NULL
1931
+ AND forge_events.end_at > ?
1932
+ AND forge_events.start_at < ?
1933
+ GROUP BY
1934
+ forge_events.id,
1935
+ forge_events.title,
1936
+ forge_events.start_at,
1937
+ forge_events.end_at,
1938
+ forge_events.availability,
1939
+ forge_events.event_type`)
1940
+ .all(range.from, range.to);
1941
+ for (const row of rows) {
1942
+ const calendarProfile = buildEffectiveProfile(readEntityActionProfile("calendar_event", row.id, {
1943
+ profileKey: `calendar_event_${row.id}`,
1944
+ title: row.title,
1945
+ entityType: "calendar_event"
1946
+ }) ??
1947
+ buildCalendarEventActionProfile({
1948
+ eventId: row.id,
1949
+ title: row.title,
1950
+ eventType: row.event_type,
1951
+ availability: row.availability,
1952
+ startAt: row.start_at,
1953
+ endAt: row.end_at
1954
+ }), lifeForceProfile);
1955
+ const overlapsBlockingWindow = blockingWindows.some((window) => overlapsWindow(row.start_at, row.end_at, window.startAt, window.endAt));
1956
+ const elapsedWindow = {
1957
+ startAt: row.start_at,
1958
+ endAt: new Date(Math.min(now.getTime(), Date.parse(row.end_at))).toISOString()
1959
+ };
1960
+ const elapsedSeconds = computeUncoveredSeconds(elapsedWindow, blockingWindows);
1961
+ if (elapsedSeconds > 0 && row.link_count === 0) {
1962
+ actualContributions.push({
1963
+ entityType: "calendar_event",
1964
+ entityId: row.id,
1965
+ eventKind: "calendar_event_actual",
1966
+ sourceKind: "calendar",
1967
+ totalAp: rateToTotalAp(calendarProfile.sustainRateApPerHour, elapsedSeconds),
1968
+ rateApPerHour: calendarProfile.sustainRateApPerHour,
1969
+ title: row.title,
1970
+ why: "Busy calendar events debit today's AP when they were real containers and nothing richer occupied the same window.",
1971
+ startsAt: row.start_at,
1972
+ endsAt: elapsedWindow.endAt,
1973
+ role: "background"
1974
+ });
1975
+ }
1976
+ const remainingStartMs = Math.max(now.getTime(), Date.parse(row.start_at));
1977
+ const remainingEndMs = Math.min(range.endMs, Date.parse(row.end_at));
1978
+ const remainingSeconds = Math.max(0, Math.floor((remainingEndMs - remainingStartMs) / 1000));
1979
+ if (remainingSeconds > 0 && row.link_count === 0) {
1980
+ plannedDrains.push({
1981
+ entityType: "calendar_event",
1982
+ entityId: row.id,
1983
+ eventKind: "calendar_event_plan",
1984
+ sourceKind: "calendar",
1985
+ totalAp: rateToTotalAp(calendarProfile.sustainRateApPerHour, remainingSeconds),
1986
+ rateApPerHour: calendarProfile.sustainRateApPerHour,
1987
+ title: row.title,
1988
+ why: "Busy calendar events reserve attention and social bandwidth even before deeper work is linked to them.",
1989
+ startsAt: row.start_at,
1990
+ endsAt: row.end_at,
1991
+ role: "background"
1992
+ });
1993
+ }
1994
+ if (row.start_at <= nowIsoValue &&
1995
+ row.end_at > nowIsoValue &&
1996
+ !overlapsBlockingWindow) {
1997
+ activeDrains.push({
1998
+ entityType: "calendar_event",
1999
+ entityId: row.id,
2000
+ eventKind: "calendar_context",
2001
+ sourceKind: "calendar",
2002
+ totalAp: 0,
2003
+ rateApPerHour: calendarProfile.sustainRateApPerHour,
2004
+ title: row.title,
2005
+ why: "Calendar context occupies mental and social capacity even before task work is logged.",
2006
+ startsAt: row.start_at,
2007
+ endsAt: row.end_at,
2008
+ role: "background"
2009
+ });
2010
+ }
2011
+ }
2012
+ }
2013
+ catch {
2014
+ return { actualContributions, activeDrains, plannedDrains };
2015
+ }
2016
+ return { actualContributions, activeDrains, plannedDrains };
2017
+ }
2018
+ function syncApLedger(userId, range, contributions) {
2019
+ runInTransaction(() => {
2020
+ const database = getDatabase();
2021
+ database
2022
+ .prepare(`DELETE FROM ap_ledger_events
2023
+ WHERE user_id = ? AND date_key = ?`)
2024
+ .run(userId, range.dateKey);
2025
+ const insert = database.prepare(`INSERT INTO ap_ledger_events (
2026
+ id,
2027
+ user_id,
2028
+ date_key,
2029
+ entity_type,
2030
+ entity_id,
2031
+ event_kind,
2032
+ source_kind,
2033
+ starts_at,
2034
+ ends_at,
2035
+ total_ap,
2036
+ rate_ap_per_hour,
2037
+ metadata_json,
2038
+ created_at
2039
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2040
+ const createdAt = nowIso();
2041
+ for (const contribution of contributions) {
2042
+ insert.run(`ap_${randomUUID().replaceAll("-", "").slice(0, 12)}`, userId, range.dateKey, contribution.entityType, contribution.entityId, contribution.eventKind, contribution.sourceKind, contribution.startsAt, contribution.endsAt, Number(contribution.totalAp.toFixed(4)), contribution.rateApPerHour, JSON.stringify({
2043
+ title: contribution.title,
2044
+ why: contribution.why,
2045
+ role: contribution.role,
2046
+ ...(contribution.metadata ?? {})
2047
+ }), createdAt);
2048
+ }
2049
+ });
2050
+ }
2051
+ function syncStatXpEvents(userId, dateKey, contributions) {
2052
+ const totals = new Map();
2053
+ for (const contribution of contributions) {
2054
+ if (contribution.totalAp <= 0) {
2055
+ continue;
2056
+ }
2057
+ const weights = contribution.profile?.demandWeights ?? {
2058
+ activation: 0.2,
2059
+ focus: 0.25,
2060
+ vigor: 0.2,
2061
+ composure: 0.15,
2062
+ flow: 0.2
2063
+ };
2064
+ for (const [key, weight] of Object.entries(weights)) {
2065
+ totals.set(key, Number(((totals.get(key) ?? 0) + contribution.totalAp * weight).toFixed(4)));
2066
+ }
2067
+ totals.set("life_force", Number(((totals.get("life_force") ?? 0) + contribution.totalAp * 0.35).toFixed(4)));
2068
+ }
2069
+ runInTransaction(() => {
2070
+ const database = getDatabase();
2071
+ database
2072
+ .prepare(`DELETE FROM stat_xp_events
2073
+ WHERE user_id = ?
2074
+ AND json_extract(metadata_json, '$.dateKey') = ?`)
2075
+ .run(userId, dateKey);
2076
+ const insert = database.prepare(`INSERT INTO stat_xp_events (
2077
+ id,
2078
+ user_id,
2079
+ stat_key,
2080
+ delta_xp,
2081
+ entity_type,
2082
+ entity_id,
2083
+ metadata_json,
2084
+ created_at
2085
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
2086
+ const createdAt = nowIso();
2087
+ for (const [statKey, deltaXp] of totals.entries()) {
2088
+ insert.run(`statxp_${userId}_${dateKey}_${statKey}`, userId, statKey, deltaXp, "system", dateKey, JSON.stringify({
2089
+ source: "life_force_daily_rollup",
2090
+ dateKey
2091
+ }), createdAt);
2092
+ }
2093
+ });
2094
+ }
2095
+ function readTodayLedger(userId, dateKey) {
2096
+ return getDatabase()
2097
+ .prepare(`SELECT *
2098
+ FROM ap_ledger_events
2099
+ WHERE user_id = ? AND date_key = ?
2100
+ ORDER BY created_at ASC`)
2101
+ .all(userId, dateKey);
2102
+ }
2103
+ function readTodayFatigueSignals(userId, dateKey) {
2104
+ return getDatabase()
2105
+ .prepare(`SELECT signal_type, delta
2106
+ FROM fatigue_signals
2107
+ WHERE user_id = ? AND date_key = ?`)
2108
+ .all(userId, dateKey);
2109
+ }
2110
+ function buildWarnings(input) {
2111
+ const warnings = [];
2112
+ if (input.isOverloadedNow) {
2113
+ warnings.push({
2114
+ id: "lf_overload",
2115
+ tone: "danger",
2116
+ title: "You are overloaded right now",
2117
+ detail: "Current concurrent work is draining more than the available instant capacity."
2118
+ });
2119
+ }
2120
+ if (input.spentTodayAp > input.dailyBudgetAp) {
2121
+ warnings.push({
2122
+ id: "lf_overspent",
2123
+ tone: "warning",
2124
+ title: "Daily AP is in debt",
2125
+ detail: "Today has already exceeded the calibrated Action Point budget."
2126
+ });
2127
+ }
2128
+ if (input.topTaskIdsNeedingSplit.length > 0) {
2129
+ warnings.push({
2130
+ id: "lf_split",
2131
+ tone: "info",
2132
+ title: "A task wants to be split",
2133
+ detail: "One or more tasks have grown beyond a healthy expected duration."
2134
+ });
2135
+ }
2136
+ if (warnings.length === 0) {
2137
+ warnings.push({
2138
+ id: "lf_stable",
2139
+ tone: "success",
2140
+ title: "Life Force is stable",
2141
+ detail: "Today is still inside a healthy capacity band."
2142
+ });
2143
+ }
2144
+ return warnings;
2145
+ }
2146
+ export function resolveLifeForceUser(userIds) {
2147
+ if (userIds && userIds.length > 0) {
2148
+ return getUserById(userIds[0]) ?? getDefaultUser();
2149
+ }
2150
+ return getDefaultUser();
2151
+ }
2152
+ export function buildLifeForcePayload(now = new Date(), userIds) {
2153
+ ensureActionProfileTemplates();
2154
+ const user = resolveLifeForceUser(userIds);
2155
+ const profile = ensureLifeForceProfile(user.id);
2156
+ const snapshot = getOrCreateDaySnapshot(user.id, now);
2157
+ const range = buildDayRange(now);
2158
+ const taskRuns = buildTaskRunContributions(user.id, range, now, profile);
2159
+ const notes = buildNoteContributions(user.id, range, now, profile);
2160
+ const habits = buildHabitContributions(user.id, range, profile);
2161
+ const workouts = buildWorkoutContributions(user.id, range, now, profile);
2162
+ const movement = buildMovementTripContributions(user.id, range, now, profile);
2163
+ const wakeImpulses = buildWakeImpulseContributions(user.id, range, profile);
2164
+ const adjustments = buildWorkAdjustmentContributions(user.id, range, profile);
2165
+ const actualSourceWindows = [
2166
+ ...taskRuns.contributions,
2167
+ ...workouts.contributions,
2168
+ ...movement.contributions
2169
+ ]
2170
+ .filter((entry) => Boolean(entry.startsAt && entry.endsAt))
2171
+ .map((entry) => ({
2172
+ startAt: entry.startsAt,
2173
+ endAt: entry.endsAt
2174
+ }));
2175
+ const activeTaskRunTaskIds = new Set(taskRuns.activeDrains.map((entry) => entry.entityId));
2176
+ const plannedContainers = buildTimeboxAndWorkBlockDrains(user.id, range, now, profile, activeTaskRunTaskIds, actualSourceWindows);
2177
+ const calendarBlockingWindows = [
2178
+ ...actualSourceWindows,
2179
+ ...plannedContainers.timeboxWindows,
2180
+ ...plannedContainers.workBlockWindows
2181
+ ];
2182
+ const calendarDrains = buildCalendarDrains(user.id, now, range, profile, calendarBlockingWindows);
2183
+ const contributions = [
2184
+ ...taskRuns.contributions,
2185
+ ...adjustments,
2186
+ ...notes,
2187
+ ...habits,
2188
+ ...workouts.contributions,
2189
+ ...movement.contributions,
2190
+ ...wakeImpulses,
2191
+ ...plannedContainers.actualContributions,
2192
+ ...calendarDrains.actualContributions
2193
+ ];
2194
+ const seededProfilesByKey = new Map(seededActionProfiles().map((entry) => [entry.profileKey, entry]));
2195
+ const taskDurationRows = getDatabase()
2196
+ .prepare(`SELECT id, planned_duration_seconds
2197
+ FROM tasks`)
2198
+ .all();
2199
+ const taskDurationById = new Map(taskDurationRows.map((row) => [row.id, row.planned_duration_seconds]));
2200
+ const profileLookup = new Map();
2201
+ for (const contribution of contributions) {
2202
+ if (contribution.entityType === "task") {
2203
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, resolveTaskActionProfile({
2204
+ id: contribution.entityId,
2205
+ plannedDurationSeconds: taskDurationById.get(contribution.entityId) ?? null
2206
+ }, profile));
2207
+ continue;
2208
+ }
2209
+ if (contribution.entityType === "note") {
2210
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("note_quick") ?? seededActionProfiles()[0], profile));
2211
+ continue;
2212
+ }
2213
+ if (contribution.entityType === "habit") {
2214
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("habit_default") ?? seededActionProfiles()[0], profile));
2215
+ continue;
2216
+ }
2217
+ if (contribution.entityType === "workout_session") {
2218
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("workout_default") ?? seededActionProfiles()[0], profile));
2219
+ continue;
2220
+ }
2221
+ if (contribution.entityType === "movement_trip") {
2222
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("movement_trip_default") ?? seededActionProfiles()[0], profile));
2223
+ continue;
2224
+ }
2225
+ if (contribution.entityType === "task_timebox") {
2226
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(readEntityActionProfile("task_timebox", contribution.entityId, {
2227
+ profileKey: `task_timebox_${contribution.entityId}`,
2228
+ title: contribution.title,
2229
+ entityType: "task_timebox"
2230
+ }) ??
2231
+ (seededProfilesByKey.get("task_timebox_default") ?? seededActionProfiles()[0]), profile));
2232
+ continue;
2233
+ }
2234
+ if (contribution.entityType === "work_block") {
2235
+ const templateId = typeof contribution.metadata?.templateId === "string"
2236
+ ? contribution.metadata.templateId
2237
+ : contribution.entityId;
2238
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(readEntityActionProfile("work_block_template", templateId, {
2239
+ profileKey: `work_block_template_${templateId}`,
2240
+ title: contribution.title,
2241
+ entityType: "work_block_template"
2242
+ }) ??
2243
+ (seededProfilesByKey.get("work_block_main") ?? seededActionProfiles()[0]), profile));
2244
+ continue;
2245
+ }
2246
+ if (contribution.entityType === "calendar_event") {
2247
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(readEntityActionProfile("calendar_event", contribution.entityId, {
2248
+ profileKey: `calendar_event_${contribution.entityId}`,
2249
+ title: contribution.title,
2250
+ entityType: "calendar_event"
2251
+ }) ??
2252
+ (seededProfilesByKey.get("calendar_event_default") ?? seededActionProfiles()[0]), profile));
2253
+ continue;
2254
+ }
2255
+ if (contribution.eventKind === "wake_start") {
2256
+ profileLookup.set(`${contribution.entityType}:${contribution.entityId}`, buildEffectiveProfile(seededProfilesByKey.get("wake_start") ?? seededActionProfiles()[0], profile));
2257
+ }
2258
+ }
2259
+ const adjustmentApByTaskId = readTodayAdjustmentApByTaskId(user.id, range, profile);
2260
+ for (const [taskId, adjustmentAp] of adjustmentApByTaskId.entries()) {
2261
+ const existing = taskRuns.totalsByTaskId.get(taskId) ?? { todayAp: 0, totalAp: 0 };
2262
+ existing.todayAp += adjustmentAp;
2263
+ existing.totalAp += adjustmentAp;
2264
+ taskRuns.totalsByTaskId.set(taskId, existing);
2265
+ }
2266
+ syncApLedger(user.id, range, contributions);
2267
+ syncStatXpEvents(user.id, range.dateKey, contributions.map((contribution) => ({
2268
+ ...contribution,
2269
+ profile: profileLookup.get(`${contribution.entityType}:${contribution.entityId}`) ?? null
2270
+ })));
2271
+ const ledger = readTodayLedger(user.id, range.dateKey);
2272
+ const spentTodayAp = ledger.reduce((sum, row) => sum + row.total_ap, 0);
2273
+ const currentCurve = parseCurvePoints(snapshot.points_json);
2274
+ const minuteOfDay = now.getUTCHours() * 60 + now.getUTCMinutes();
2275
+ const instantCapacityApPerHour = interpolateCurveRate(currentCurve, minuteOfDay);
2276
+ const activeSourceWindows = [
2277
+ ...taskRuns.activeDrains,
2278
+ ...workouts.activeDrains,
2279
+ ...movement.activeDrains,
2280
+ ...plannedContainers.activeDrains
2281
+ ].map((entry) => ({
2282
+ startAt: entry.startsAt ?? now.toISOString(),
2283
+ endAt: entry.endsAt ?? now.toISOString()
2284
+ }));
2285
+ const activeDrains = [
2286
+ ...taskRuns.activeDrains,
2287
+ ...workouts.activeDrains,
2288
+ ...movement.activeDrains,
2289
+ ...plannedContainers.activeDrains,
2290
+ ...calendarDrains.activeDrains
2291
+ ]
2292
+ .sort((left, right) => (right.rateApPerHour ?? 0) - (left.rateApPerHour ?? 0))
2293
+ .map((entry, index) => ({
2294
+ id: `${entry.sourceKind}:${entry.entityId}`,
2295
+ sourceType: entry.entityType,
2296
+ sourceId: entry.entityId,
2297
+ title: entry.title,
2298
+ role: index === 0 ? "primary" : entry.role,
2299
+ apPerHour: Number((entry.rateApPerHour ?? 0).toFixed(2)),
2300
+ instantAp: Number((entry.totalAp ?? 0).toFixed(2)),
2301
+ why: entry.why,
2302
+ startedAt: entry.startsAt,
2303
+ endsAt: entry.endsAt
2304
+ }));
2305
+ const plannedDrains = [
2306
+ ...plannedContainers.plannedDrains,
2307
+ ...calendarDrains.plannedDrains
2308
+ ]
2309
+ .sort((left, right) => Date.parse(left.startsAt ?? now.toISOString()) - Date.parse(right.startsAt ?? now.toISOString()))
2310
+ .map((entry) => ({
2311
+ id: `${entry.sourceKind}:${entry.entityId}`,
2312
+ sourceType: entry.entityType,
2313
+ sourceId: entry.entityId,
2314
+ title: entry.title,
2315
+ role: entry.role,
2316
+ apPerHour: Number((entry.rateApPerHour ?? 0).toFixed(2)),
2317
+ instantAp: Number((entry.totalAp ?? 0).toFixed(2)),
2318
+ why: entry.why,
2319
+ startedAt: entry.startsAt,
2320
+ endsAt: entry.endsAt
2321
+ }));
2322
+ const sortedRates = activeDrains
2323
+ .map((entry) => entry.apPerHour)
2324
+ .sort((left, right) => right - left);
2325
+ const currentDrainApPerHour = (sortedRates[0] ?? 0) +
2326
+ (sortedRates[1] ?? 0) * 0.6 +
2327
+ (sortedRates[2] ?? 0) * 0.35 +
2328
+ sortedRates.slice(3).reduce((sum, value) => sum + value * 0.2, 0);
2329
+ const fatigueFromSignals = readTodayFatigueSignals(user.id, range.dateKey).reduce((sum, signal) => sum + signal.delta, 0);
2330
+ const fatigueBufferApPerHour = Math.max(0, Number((fatigueFromSignals + Math.max(0, activeDrains.length - 1) * 1.5).toFixed(2)));
2331
+ const rawInstantFreeApPerHour = Number((instantCapacityApPerHour - currentDrainApPerHour - fatigueBufferApPerHour).toFixed(2));
2332
+ const instantFreeApPerHour = Math.max(0, rawInstantFreeApPerHour);
2333
+ const overloadApPerHour = Math.max(0, Number((-rawInstantFreeApPerHour).toFixed(2)));
2334
+ const remainingAp = Number((snapshot.daily_budget_ap - spentTodayAp).toFixed(2));
2335
+ const plannedRemainingAp = Number(plannedDrains.reduce((sum, entry) => sum + entry.instantAp, 0).toFixed(2));
2336
+ const forecastAp = Number((spentTodayAp + plannedRemainingAp).toFixed(2));
2337
+ const targetBandMinAp = Number((snapshot.daily_budget_ap * 0.85).toFixed(2));
2338
+ const targetBandMaxAp = Number(snapshot.daily_budget_ap.toFixed(2));
2339
+ const workTime = computeWorkTime(now);
2340
+ const topTaskIdsNeedingSplit = getDatabase()
2341
+ .prepare(`SELECT tasks.id, tasks.planned_duration_seconds
2342
+ FROM tasks
2343
+ INNER JOIN entity_owners
2344
+ ON entity_owners.entity_type = 'task'
2345
+ AND entity_owners.entity_id = tasks.id
2346
+ AND entity_owners.role = 'owner'
2347
+ WHERE entity_owners.user_id = ?`)
2348
+ .all(user.id)
2349
+ .map((row) => row)
2350
+ .filter((row) => {
2351
+ const time = workTime.taskSummaries.get(row.id);
2352
+ return buildTaskSplitSuggestion({
2353
+ plannedDurationSeconds: row.planned_duration_seconds,
2354
+ totalTrackedSeconds: time?.totalCreditedSeconds ?? 0,
2355
+ projectedTotalSeconds: (time?.totalCreditedSeconds ?? 0) +
2356
+ readActiveTaskRunProjectionRows(row.id).reduce((sum, activeRow) => sum + computeProjectedRemainingSeconds(activeRow, now), 0)
2357
+ }).shouldSplit;
2358
+ })
2359
+ .map((row) => row.id)
2360
+ .slice(0, 3);
2361
+ return lifeForcePayloadSchema.parse({
2362
+ userId: user.id,
2363
+ dateKey: range.dateKey,
2364
+ baselineDailyAp: profile.base_daily_ap,
2365
+ dailyBudgetAp: Number(snapshot.daily_budget_ap.toFixed(2)),
2366
+ spentTodayAp: Number(spentTodayAp.toFixed(2)),
2367
+ remainingAp,
2368
+ forecastAp,
2369
+ plannedRemainingAp,
2370
+ targetBandMinAp,
2371
+ targetBandMaxAp,
2372
+ instantCapacityApPerHour: Number(instantCapacityApPerHour.toFixed(2)),
2373
+ instantFreeApPerHour,
2374
+ overloadApPerHour,
2375
+ currentDrainApPerHour: Number(currentDrainApPerHour.toFixed(2)),
2376
+ fatigueBufferApPerHour,
2377
+ sleepRecoveryMultiplier: snapshot.sleep_recovery_multiplier,
2378
+ readinessMultiplier: snapshot.readiness_multiplier,
2379
+ fatigueDebtCarry: snapshot.fatigue_debt_carry,
2380
+ stats: buildStats(profile, user.id),
2381
+ currentCurve: currentCurve.map((point) => ({
2382
+ ...point,
2383
+ locked: point.minuteOfDay <= minuteOfDay
2384
+ })),
2385
+ activeDrains,
2386
+ plannedDrains,
2387
+ warnings: buildWarnings({
2388
+ spentTodayAp,
2389
+ dailyBudgetAp: snapshot.daily_budget_ap,
2390
+ isOverloadedNow: rawInstantFreeApPerHour < 0,
2391
+ topTaskIdsNeedingSplit
2392
+ }),
2393
+ recommendations: [
2394
+ instantFreeApPerHour <= 0
2395
+ ? "Reduce overlap or take a recovery action before starting something new."
2396
+ : instantCapacityApPerHour > currentDrainApPerHour + 4
2397
+ ? "This is a good moment for deep work."
2398
+ : "Favor lower-friction admin or recovery until headroom increases."
2399
+ ],
2400
+ topTaskIdsNeedingSplit,
2401
+ updatedAt: now.toISOString()
2402
+ });
2403
+ }
2404
+ export function buildTaskLifeForceFields(task, userId) {
2405
+ const effectiveUserId = userId ?? task.userId ?? getDefaultUser().id;
2406
+ const runtime = buildTaskLifeForceRuntime(task, effectiveUserId);
2407
+ return {
2408
+ actionPointSummary: buildTaskActionPointSummary({
2409
+ plannedDurationSeconds: task.plannedDurationSeconds,
2410
+ totalCostAp: runtime.profile.totalCostAp,
2411
+ spentTodayAp: runtime.spentTodayAp,
2412
+ spentTotalAp: runtime.spentTotalAp
2413
+ }),
2414
+ splitSuggestion: buildTaskSplitSuggestion({
2415
+ plannedDurationSeconds: task.plannedDurationSeconds,
2416
+ totalTrackedSeconds: task.time.totalCreditedSeconds,
2417
+ projectedTotalSeconds: runtime.projectedTotalSeconds
2418
+ })
2419
+ };
2420
+ }
2421
+ export function getTaskCompletionRequirement(task, userId) {
2422
+ const effectiveUserId = userId ?? task.userId ?? getDefaultUser().id;
2423
+ const runtime = buildTaskLifeForceRuntime(task, effectiveUserId);
2424
+ return {
2425
+ todayCreditedSeconds: runtime.todayCreditedSeconds,
2426
+ requiresWorkLog: runtime.todayCreditedSeconds <= 0
2427
+ };
2428
+ }
2429
+ export function updateLifeForceProfile(userId, patch) {
2430
+ const parsed = lifeForceProfilePatchSchema.parse(patch);
2431
+ const current = ensureLifeForceProfile(userId);
2432
+ const next = {
2433
+ base_daily_ap: parsed.baseDailyAp ?? current.base_daily_ap,
2434
+ readiness_multiplier: parsed.readinessMultiplier ?? current.readiness_multiplier,
2435
+ life_force_level: parsed.stats?.life_force ?? current.life_force_level,
2436
+ activation_level: parsed.stats?.activation ?? current.activation_level,
2437
+ focus_level: parsed.stats?.focus ?? current.focus_level,
2438
+ vigor_level: parsed.stats?.vigor ?? current.vigor_level,
2439
+ composure_level: parsed.stats?.composure ?? current.composure_level,
2440
+ flow_level: parsed.stats?.flow ?? current.flow_level
2441
+ };
2442
+ getDatabase()
2443
+ .prepare(`UPDATE life_force_profiles
2444
+ SET base_daily_ap = ?,
2445
+ readiness_multiplier = ?,
2446
+ life_force_level = ?,
2447
+ activation_level = ?,
2448
+ focus_level = ?,
2449
+ vigor_level = ?,
2450
+ composure_level = ?,
2451
+ flow_level = ?,
2452
+ updated_at = ?
2453
+ WHERE user_id = ?`)
2454
+ .run(next.base_daily_ap, next.readiness_multiplier, next.life_force_level, next.activation_level, next.focus_level, next.vigor_level, next.composure_level, next.flow_level, nowIso(), userId);
2455
+ const todayKey = toDateKey(new Date());
2456
+ getDatabase()
2457
+ .prepare(`DELETE FROM life_force_day_snapshots
2458
+ WHERE user_id = ? AND date_key = ?`)
2459
+ .run(userId, todayKey);
2460
+ return buildLifeForcePayload(new Date(), [userId]);
2461
+ }
2462
+ export function updateLifeForceTemplate(userId, weekday, input) {
2463
+ const parsed = lifeForceTemplateUpdateSchema.parse(input);
2464
+ ensureWeekdayTemplates(userId);
2465
+ const normalized = normalizeCurveToBudget([...parsed.points].sort((left, right) => left.minuteOfDay - right.minuteOfDay), LIFE_FORCE_BASELINE_DAILY_AP);
2466
+ getDatabase()
2467
+ .prepare(`UPDATE life_force_weekday_templates
2468
+ SET points_json = ?, updated_at = ?
2469
+ WHERE user_id = ? AND weekday = ?`)
2470
+ .run(JSON.stringify(normalized), nowIso(), userId, weekday);
2471
+ return normalized;
2472
+ }
2473
+ export function createFatigueSignal(userId, input) {
2474
+ const parsed = fatigueSignalCreateSchema.parse(input);
2475
+ const observedAt = parsed.observedAt ?? nowIso();
2476
+ const dateKey = observedAt.slice(0, 10);
2477
+ const delta = parsed.signalType === "tired" ? 4 : -4;
2478
+ getDatabase()
2479
+ .prepare(`INSERT INTO fatigue_signals (
2480
+ id,
2481
+ user_id,
2482
+ date_key,
2483
+ signal_type,
2484
+ observed_at,
2485
+ note,
2486
+ delta,
2487
+ created_at
2488
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
2489
+ .run(`fatigue_${randomUUID().replaceAll("-", "").slice(0, 12)}`, userId, dateKey, parsed.signalType, observedAt, parsed.note ?? "", delta, nowIso());
2490
+ return buildLifeForcePayload(new Date(observedAt), [userId]);
2491
+ }
2492
+ export function listLifeForceTemplates(userId) {
2493
+ ensureWeekdayTemplates(userId);
2494
+ return getDatabase()
2495
+ .prepare(`SELECT *
2496
+ FROM life_force_weekday_templates
2497
+ WHERE user_id = ?
2498
+ ORDER BY weekday ASC`)
2499
+ .all(userId)
2500
+ .map((row) => row)
2501
+ .map((row) => ({
2502
+ weekday: row.weekday,
2503
+ baselineDailyAp: row.baseline_daily_ap,
2504
+ points: parseCurvePoints(row.points_json)
2505
+ }));
2506
+ }