forge-openclaw-plugin 0.2.13 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/assets/{board-C_m78kvK.js → board-2KevHCI0.js} +2 -2
- package/dist/assets/{board-C_m78kvK.js.map → board-2KevHCI0.js.map} +1 -1
- package/dist/assets/index-CDYW4WDH.js +36 -0
- package/dist/assets/index-CDYW4WDH.js.map +1 -0
- package/dist/assets/index-yroQr6YZ.css +1 -0
- package/dist/assets/{motion-CpZvZumD.js → motion-q19HPmWs.js} +2 -2
- package/dist/assets/{motion-CpZvZumD.js.map → motion-q19HPmWs.js.map} +1 -1
- package/dist/assets/{table-DtyXTw03.js → table-BDMHBY4a.js} +2 -2
- package/dist/assets/{table-DtyXTw03.js.map → table-BDMHBY4a.js.map} +1 -1
- package/dist/assets/{ui-BXbpiKyS.js → ui-CQ_AsFs8.js} +2 -2
- package/dist/assets/{ui-BXbpiKyS.js.map → ui-CQ_AsFs8.js.map} +1 -1
- package/dist/assets/{vendor-QBH6qVEe.js → vendor-5HifrnRK.js} +90 -75
- package/dist/assets/{vendor-QBH6qVEe.js.map → vendor-5HifrnRK.js.map} +1 -1
- package/dist/assets/{viz-w-IMeueL.js → viz-CQzkRnTu.js} +2 -2
- package/dist/assets/{viz-w-IMeueL.js.map → viz-CQzkRnTu.js.map} +1 -1
- package/dist/index.html +8 -8
- package/dist/openclaw/api-client.d.ts +1 -0
- package/dist/openclaw/local-runtime.js +243 -15
- package/dist/openclaw/plugin-entry-shared.js +45 -4
- package/dist/openclaw/tools.js +15 -0
- package/dist/server/app.js +129 -11
- package/dist/server/openapi.js +181 -4
- package/dist/server/repositories/habits.js +358 -0
- package/dist/server/repositories/rewards.js +62 -0
- package/dist/server/services/context.js +16 -6
- package/dist/server/services/dashboard.js +6 -3
- package/dist/server/services/entity-crud.js +23 -1
- package/dist/server/services/gamification.js +66 -18
- package/dist/server/services/insights.js +2 -1
- package/dist/server/services/reviews.js +2 -1
- package/dist/server/types.js +140 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/003_habits.sql +30 -0
- package/server/migrations/004_habit_links.sql +8 -0
- package/server/migrations/005_habit_psyche_links.sql +24 -0
- package/skills/forge-openclaw/SKILL.md +16 -2
- package/skills/forge-openclaw/cron_jobs.md +395 -0
- package/dist/assets/index-BWtLtXwb.js +0 -36
- package/dist/assets/index-BWtLtXwb.js.map +0 -1
- package/dist/assets/index-Dp5GXY_z.css +0 -1
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDatabase, runInTransaction } from "../db.js";
|
|
3
|
+
import { HttpError } from "../errors.js";
|
|
4
|
+
import { getGoalById } from "./goals.js";
|
|
5
|
+
import { getProjectById } from "./projects.js";
|
|
6
|
+
import { getBehaviorById, getBehaviorPatternById, getBeliefEntryById, getModeProfileById, getPsycheValueById, getTriggerReportById } from "./psyche.js";
|
|
7
|
+
import { getTaskById } from "./tasks.js";
|
|
8
|
+
import { recordActivityEvent } from "./activity-events.js";
|
|
9
|
+
import { recordHabitCheckInReward } from "./rewards.js";
|
|
10
|
+
import { createHabitCheckInSchema, createHabitSchema, habitCheckInSchema, habitSchema, updateHabitSchema } from "../types.js";
|
|
11
|
+
function todayKey(now = new Date()) {
|
|
12
|
+
return now.toISOString().slice(0, 10);
|
|
13
|
+
}
|
|
14
|
+
function parseWeekDays(raw) {
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
return Array.isArray(parsed) ? parsed.filter((value) => Number.isInteger(value) && value >= 0 && value <= 6) : [];
|
|
17
|
+
}
|
|
18
|
+
function parseIdList(raw) {
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
|
|
21
|
+
}
|
|
22
|
+
function uniqueIds(values) {
|
|
23
|
+
return [...new Set(values.filter((value) => typeof value === "string" && value.trim().length > 0))];
|
|
24
|
+
}
|
|
25
|
+
function normalizeLinkedBehaviorIds(input) {
|
|
26
|
+
const fromArray = Array.isArray(input.linkedBehaviorIds) ? input.linkedBehaviorIds : [];
|
|
27
|
+
return uniqueIds([...fromArray, input.linkedBehaviorId ?? null]);
|
|
28
|
+
}
|
|
29
|
+
function validateExistingIds(ids, getById, code, label) {
|
|
30
|
+
for (const id of ids) {
|
|
31
|
+
if (!getById(id)) {
|
|
32
|
+
throw new HttpError(404, code, `${label} ${id} does not exist`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function mapCheckIn(row) {
|
|
37
|
+
return habitCheckInSchema.parse({
|
|
38
|
+
id: row.id,
|
|
39
|
+
habitId: row.habit_id,
|
|
40
|
+
dateKey: row.date_key,
|
|
41
|
+
status: row.status,
|
|
42
|
+
note: row.note,
|
|
43
|
+
deltaXp: row.delta_xp,
|
|
44
|
+
createdAt: row.created_at,
|
|
45
|
+
updatedAt: row.updated_at
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function listCheckInsForHabit(habitId, limit = 14) {
|
|
49
|
+
const rows = getDatabase()
|
|
50
|
+
.prepare(`SELECT id, habit_id, date_key, status, note, delta_xp, created_at, updated_at
|
|
51
|
+
FROM habit_check_ins
|
|
52
|
+
WHERE habit_id = ?
|
|
53
|
+
ORDER BY date_key DESC, created_at DESC
|
|
54
|
+
LIMIT ?`)
|
|
55
|
+
.all(habitId, limit);
|
|
56
|
+
return rows.map(mapCheckIn);
|
|
57
|
+
}
|
|
58
|
+
function isAligned(habit, checkIn) {
|
|
59
|
+
return (habit.polarity === "positive" && checkIn.status === "done") || (habit.polarity === "negative" && checkIn.status === "missed");
|
|
60
|
+
}
|
|
61
|
+
function calculateCompletionRate(habit, checkIns) {
|
|
62
|
+
if (checkIns.length === 0) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
const aligned = checkIns.filter((checkIn) => isAligned(habit, checkIn)).length;
|
|
66
|
+
return Math.round((aligned / checkIns.length) * 100);
|
|
67
|
+
}
|
|
68
|
+
function calculateStreak(habit, checkIns) {
|
|
69
|
+
let streak = 0;
|
|
70
|
+
for (const checkIn of checkIns) {
|
|
71
|
+
if (!isAligned(habit, checkIn)) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
streak += 1;
|
|
75
|
+
}
|
|
76
|
+
return streak;
|
|
77
|
+
}
|
|
78
|
+
function isHabitDueToday(habit, latestCheckIn, now = new Date()) {
|
|
79
|
+
if (habit.status !== "active") {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const key = todayKey(now);
|
|
83
|
+
if (latestCheckIn?.dateKey === key) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
if (habit.frequency === "daily") {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return habit.weekDays.includes(now.getUTCDay());
|
|
90
|
+
}
|
|
91
|
+
function mapHabit(row, checkIns = listCheckInsForHabit(row.id)) {
|
|
92
|
+
const latestCheckIn = checkIns[0] ?? null;
|
|
93
|
+
const linkedBehaviorIds = normalizeLinkedBehaviorIds({
|
|
94
|
+
linkedBehaviorIds: parseIdList(row.linked_behavior_ids_json),
|
|
95
|
+
linkedBehaviorId: row.linked_behavior_id
|
|
96
|
+
});
|
|
97
|
+
const linkedBehaviors = linkedBehaviorIds
|
|
98
|
+
.map((behaviorId) => getBehaviorById(behaviorId))
|
|
99
|
+
.filter((behavior) => behavior !== undefined);
|
|
100
|
+
const draft = {
|
|
101
|
+
id: row.id,
|
|
102
|
+
title: row.title,
|
|
103
|
+
description: row.description,
|
|
104
|
+
status: row.status,
|
|
105
|
+
polarity: row.polarity,
|
|
106
|
+
frequency: row.frequency,
|
|
107
|
+
targetCount: row.target_count,
|
|
108
|
+
weekDays: parseWeekDays(row.week_days_json),
|
|
109
|
+
linkedGoalIds: parseIdList(row.linked_goal_ids_json),
|
|
110
|
+
linkedProjectIds: parseIdList(row.linked_project_ids_json),
|
|
111
|
+
linkedTaskIds: parseIdList(row.linked_task_ids_json),
|
|
112
|
+
linkedValueIds: parseIdList(row.linked_value_ids_json),
|
|
113
|
+
linkedPatternIds: parseIdList(row.linked_pattern_ids_json),
|
|
114
|
+
linkedBehaviorIds,
|
|
115
|
+
linkedBeliefIds: parseIdList(row.linked_belief_ids_json),
|
|
116
|
+
linkedModeIds: parseIdList(row.linked_mode_ids_json),
|
|
117
|
+
linkedReportIds: parseIdList(row.linked_report_ids_json),
|
|
118
|
+
linkedBehaviorId: linkedBehaviorIds[0] ?? null,
|
|
119
|
+
linkedBehaviorTitle: linkedBehaviors[0]?.title ?? null,
|
|
120
|
+
linkedBehaviorTitles: linkedBehaviors.map((behavior) => behavior.title),
|
|
121
|
+
rewardXp: row.reward_xp,
|
|
122
|
+
penaltyXp: row.penalty_xp,
|
|
123
|
+
createdAt: row.created_at,
|
|
124
|
+
updatedAt: row.updated_at,
|
|
125
|
+
lastCheckInAt: latestCheckIn?.createdAt ?? null,
|
|
126
|
+
lastCheckInStatus: latestCheckIn?.status ?? null,
|
|
127
|
+
streakCount: calculateStreak({ polarity: row.polarity }, checkIns),
|
|
128
|
+
completionRate: calculateCompletionRate({ polarity: row.polarity }, checkIns),
|
|
129
|
+
dueToday: false,
|
|
130
|
+
checkIns
|
|
131
|
+
};
|
|
132
|
+
draft.dueToday = isHabitDueToday({ status: draft.status, frequency: draft.frequency, weekDays: draft.weekDays }, latestCheckIn);
|
|
133
|
+
return habitSchema.parse(draft);
|
|
134
|
+
}
|
|
135
|
+
function getHabitRow(habitId) {
|
|
136
|
+
return getDatabase()
|
|
137
|
+
.prepare(`SELECT
|
|
138
|
+
id, title, description, status, polarity, frequency, target_count, week_days_json,
|
|
139
|
+
linked_goal_ids_json, linked_project_ids_json, linked_task_ids_json,
|
|
140
|
+
linked_value_ids_json, linked_pattern_ids_json, linked_behavior_ids_json,
|
|
141
|
+
linked_belief_ids_json, linked_mode_ids_json, linked_report_ids_json,
|
|
142
|
+
linked_behavior_id, reward_xp, penalty_xp, created_at, updated_at
|
|
143
|
+
FROM habits
|
|
144
|
+
WHERE id = ?`)
|
|
145
|
+
.get(habitId);
|
|
146
|
+
}
|
|
147
|
+
export function listHabits(filters = {}) {
|
|
148
|
+
const parsed = filters;
|
|
149
|
+
const whereClauses = [];
|
|
150
|
+
const params = [];
|
|
151
|
+
if (parsed.status) {
|
|
152
|
+
whereClauses.push("status = ?");
|
|
153
|
+
params.push(parsed.status);
|
|
154
|
+
}
|
|
155
|
+
if (parsed.polarity) {
|
|
156
|
+
whereClauses.push("polarity = ?");
|
|
157
|
+
params.push(parsed.polarity);
|
|
158
|
+
}
|
|
159
|
+
const limitSql = parsed.limit ? "LIMIT ?" : "";
|
|
160
|
+
if (parsed.limit) {
|
|
161
|
+
params.push(parsed.limit);
|
|
162
|
+
}
|
|
163
|
+
const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
|
|
164
|
+
const rows = getDatabase()
|
|
165
|
+
.prepare(`SELECT
|
|
166
|
+
id, title, description, status, polarity, frequency, target_count, week_days_json,
|
|
167
|
+
linked_goal_ids_json, linked_project_ids_json, linked_task_ids_json,
|
|
168
|
+
linked_value_ids_json, linked_pattern_ids_json, linked_behavior_ids_json,
|
|
169
|
+
linked_belief_ids_json, linked_mode_ids_json, linked_report_ids_json,
|
|
170
|
+
linked_behavior_id, reward_xp, penalty_xp, created_at, updated_at
|
|
171
|
+
FROM habits
|
|
172
|
+
${whereSql}
|
|
173
|
+
ORDER BY
|
|
174
|
+
CASE status WHEN 'active' THEN 0 WHEN 'paused' THEN 1 ELSE 2 END,
|
|
175
|
+
updated_at DESC
|
|
176
|
+
${limitSql}`)
|
|
177
|
+
.all(...params);
|
|
178
|
+
const habits = rows.map((row) => mapHabit(row));
|
|
179
|
+
return parsed.dueToday ? habits.filter((habit) => habit.dueToday) : habits;
|
|
180
|
+
}
|
|
181
|
+
export function getHabitById(habitId) {
|
|
182
|
+
const row = getHabitRow(habitId);
|
|
183
|
+
return row ? mapHabit(row) : undefined;
|
|
184
|
+
}
|
|
185
|
+
export function createHabit(input, activity) {
|
|
186
|
+
const parsed = createHabitSchema.parse(input);
|
|
187
|
+
const linkedBehaviorIds = normalizeLinkedBehaviorIds(parsed);
|
|
188
|
+
validateExistingIds(parsed.linkedGoalIds, getGoalById, "goal_not_found", "Goal");
|
|
189
|
+
validateExistingIds(parsed.linkedProjectIds, getProjectById, "project_not_found", "Project");
|
|
190
|
+
validateExistingIds(parsed.linkedTaskIds, getTaskById, "task_not_found", "Task");
|
|
191
|
+
validateExistingIds(parsed.linkedValueIds, getPsycheValueById, "value_not_found", "Value");
|
|
192
|
+
validateExistingIds(parsed.linkedPatternIds, getBehaviorPatternById, "pattern_not_found", "Pattern");
|
|
193
|
+
validateExistingIds(linkedBehaviorIds, getBehaviorById, "behavior_not_found", "Behavior");
|
|
194
|
+
validateExistingIds(parsed.linkedBeliefIds, getBeliefEntryById, "belief_not_found", "Belief");
|
|
195
|
+
validateExistingIds(parsed.linkedModeIds, getModeProfileById, "mode_not_found", "Mode");
|
|
196
|
+
validateExistingIds(parsed.linkedReportIds, getTriggerReportById, "report_not_found", "Report");
|
|
197
|
+
return runInTransaction(() => {
|
|
198
|
+
const now = new Date().toISOString();
|
|
199
|
+
const id = `habit_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
200
|
+
getDatabase()
|
|
201
|
+
.prepare(`INSERT INTO habits (
|
|
202
|
+
id, title, description, status, polarity, frequency, target_count, week_days_json,
|
|
203
|
+
linked_goal_ids_json, linked_project_ids_json, linked_task_ids_json,
|
|
204
|
+
linked_value_ids_json, linked_pattern_ids_json, linked_behavior_ids_json,
|
|
205
|
+
linked_belief_ids_json, linked_mode_ids_json, linked_report_ids_json,
|
|
206
|
+
linked_behavior_id, reward_xp, penalty_xp, created_at, updated_at
|
|
207
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
208
|
+
.run(id, parsed.title, parsed.description, parsed.status, parsed.polarity, parsed.frequency, parsed.targetCount, JSON.stringify(parsed.weekDays), JSON.stringify(parsed.linkedGoalIds), JSON.stringify(parsed.linkedProjectIds), JSON.stringify(parsed.linkedTaskIds), JSON.stringify(parsed.linkedValueIds), JSON.stringify(parsed.linkedPatternIds), JSON.stringify(linkedBehaviorIds), JSON.stringify(parsed.linkedBeliefIds), JSON.stringify(parsed.linkedModeIds), JSON.stringify(parsed.linkedReportIds), linkedBehaviorIds[0] ?? null, parsed.rewardXp, parsed.penaltyXp, now, now);
|
|
209
|
+
const habit = getHabitById(id);
|
|
210
|
+
if (activity) {
|
|
211
|
+
recordActivityEvent({
|
|
212
|
+
entityType: "habit",
|
|
213
|
+
entityId: habit.id,
|
|
214
|
+
eventType: "habit_created",
|
|
215
|
+
title: `Habit created: ${habit.title}`,
|
|
216
|
+
description: `${habit.frequency === "daily" ? "Daily" : "Weekly"} ${habit.polarity} habit added to Forge.`,
|
|
217
|
+
actor: activity.actor ?? null,
|
|
218
|
+
source: activity.source,
|
|
219
|
+
metadata: {
|
|
220
|
+
polarity: habit.polarity,
|
|
221
|
+
frequency: habit.frequency,
|
|
222
|
+
targetCount: habit.targetCount
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return habit;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
export function updateHabit(habitId, input, activity) {
|
|
230
|
+
const current = getHabitById(habitId);
|
|
231
|
+
if (!current) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const parsed = updateHabitSchema.parse(input);
|
|
235
|
+
const nextLinkedBehaviorIds = parsed.linkedBehaviorIds !== undefined || parsed.linkedBehaviorId !== undefined
|
|
236
|
+
? normalizeLinkedBehaviorIds({
|
|
237
|
+
linkedBehaviorIds: parsed.linkedBehaviorIds ?? current.linkedBehaviorIds,
|
|
238
|
+
linkedBehaviorId: parsed.linkedBehaviorId === undefined
|
|
239
|
+
? current.linkedBehaviorId
|
|
240
|
+
: parsed.linkedBehaviorId
|
|
241
|
+
})
|
|
242
|
+
: current.linkedBehaviorIds;
|
|
243
|
+
validateExistingIds(parsed.linkedGoalIds ?? current.linkedGoalIds, getGoalById, "goal_not_found", "Goal");
|
|
244
|
+
validateExistingIds(parsed.linkedProjectIds ?? current.linkedProjectIds, getProjectById, "project_not_found", "Project");
|
|
245
|
+
validateExistingIds(parsed.linkedTaskIds ?? current.linkedTaskIds, getTaskById, "task_not_found", "Task");
|
|
246
|
+
validateExistingIds(parsed.linkedValueIds ?? current.linkedValueIds, getPsycheValueById, "value_not_found", "Value");
|
|
247
|
+
validateExistingIds(parsed.linkedPatternIds ?? current.linkedPatternIds, getBehaviorPatternById, "pattern_not_found", "Pattern");
|
|
248
|
+
validateExistingIds(nextLinkedBehaviorIds, getBehaviorById, "behavior_not_found", "Behavior");
|
|
249
|
+
validateExistingIds(parsed.linkedBeliefIds ?? current.linkedBeliefIds, getBeliefEntryById, "belief_not_found", "Belief");
|
|
250
|
+
validateExistingIds(parsed.linkedModeIds ?? current.linkedModeIds, getModeProfileById, "mode_not_found", "Mode");
|
|
251
|
+
validateExistingIds(parsed.linkedReportIds ?? current.linkedReportIds, getTriggerReportById, "report_not_found", "Report");
|
|
252
|
+
return runInTransaction(() => {
|
|
253
|
+
const updatedAt = new Date().toISOString();
|
|
254
|
+
getDatabase()
|
|
255
|
+
.prepare(`UPDATE habits
|
|
256
|
+
SET title = ?, description = ?, status = ?, polarity = ?, frequency = ?, target_count = ?,
|
|
257
|
+
week_days_json = ?, linked_goal_ids_json = ?, linked_project_ids_json = ?, linked_task_ids_json = ?,
|
|
258
|
+
linked_value_ids_json = ?, linked_pattern_ids_json = ?, linked_behavior_ids_json = ?,
|
|
259
|
+
linked_belief_ids_json = ?, linked_mode_ids_json = ?, linked_report_ids_json = ?,
|
|
260
|
+
linked_behavior_id = ?, reward_xp = ?, penalty_xp = ?, updated_at = ?
|
|
261
|
+
WHERE id = ?`)
|
|
262
|
+
.run(parsed.title ?? current.title, parsed.description ?? current.description, parsed.status ?? current.status, parsed.polarity ?? current.polarity, parsed.frequency ?? current.frequency, parsed.targetCount ?? current.targetCount, JSON.stringify(parsed.weekDays ?? current.weekDays), JSON.stringify(parsed.linkedGoalIds ?? current.linkedGoalIds), JSON.stringify(parsed.linkedProjectIds ?? current.linkedProjectIds), JSON.stringify(parsed.linkedTaskIds ?? current.linkedTaskIds), JSON.stringify(parsed.linkedValueIds ?? current.linkedValueIds), JSON.stringify(parsed.linkedPatternIds ?? current.linkedPatternIds), JSON.stringify(nextLinkedBehaviorIds), JSON.stringify(parsed.linkedBeliefIds ?? current.linkedBeliefIds), JSON.stringify(parsed.linkedModeIds ?? current.linkedModeIds), JSON.stringify(parsed.linkedReportIds ?? current.linkedReportIds), nextLinkedBehaviorIds[0] ?? null, parsed.rewardXp ?? current.rewardXp, parsed.penaltyXp ?? current.penaltyXp, updatedAt, habitId);
|
|
263
|
+
const habit = getHabitById(habitId);
|
|
264
|
+
if (activity) {
|
|
265
|
+
recordActivityEvent({
|
|
266
|
+
entityType: "habit",
|
|
267
|
+
entityId: habit.id,
|
|
268
|
+
eventType: "habit_updated",
|
|
269
|
+
title: `Habit updated: ${habit.title}`,
|
|
270
|
+
description: "Habit settings and recurrence were updated.",
|
|
271
|
+
actor: activity.actor ?? null,
|
|
272
|
+
source: activity.source,
|
|
273
|
+
metadata: {
|
|
274
|
+
polarity: habit.polarity,
|
|
275
|
+
frequency: habit.frequency,
|
|
276
|
+
targetCount: habit.targetCount
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return habit;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
export function deleteHabit(habitId, activity) {
|
|
284
|
+
const current = getHabitById(habitId);
|
|
285
|
+
if (!current) {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
return runInTransaction(() => {
|
|
289
|
+
getDatabase().prepare(`DELETE FROM habits WHERE id = ?`).run(habitId);
|
|
290
|
+
if (activity) {
|
|
291
|
+
recordActivityEvent({
|
|
292
|
+
entityType: "habit",
|
|
293
|
+
entityId: current.id,
|
|
294
|
+
eventType: "habit_deleted",
|
|
295
|
+
title: `Habit deleted: ${current.title}`,
|
|
296
|
+
description: "Habit removed from Forge.",
|
|
297
|
+
actor: activity.actor ?? null,
|
|
298
|
+
source: activity.source,
|
|
299
|
+
metadata: {
|
|
300
|
+
polarity: current.polarity,
|
|
301
|
+
frequency: current.frequency
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return current;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
export function createHabitCheckIn(habitId, input, activity) {
|
|
309
|
+
const habit = getHabitById(habitId);
|
|
310
|
+
if (!habit) {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const parsed = createHabitCheckInSchema.parse(input);
|
|
314
|
+
return runInTransaction(() => {
|
|
315
|
+
const existing = getDatabase()
|
|
316
|
+
.prepare(`SELECT id, habit_id, date_key, status, note, delta_xp, created_at, updated_at
|
|
317
|
+
FROM habit_check_ins
|
|
318
|
+
WHERE habit_id = ? AND date_key = ?`)
|
|
319
|
+
.get(habitId, parsed.dateKey);
|
|
320
|
+
const reward = recordHabitCheckInReward(habit, parsed.status, parsed.dateKey, activity ?? { source: "ui", actor: null });
|
|
321
|
+
const now = new Date().toISOString();
|
|
322
|
+
if (existing) {
|
|
323
|
+
getDatabase()
|
|
324
|
+
.prepare(`UPDATE habit_check_ins
|
|
325
|
+
SET status = ?, note = ?, delta_xp = ?, updated_at = ?
|
|
326
|
+
WHERE id = ?`)
|
|
327
|
+
.run(parsed.status, parsed.note, reward.deltaXp, now, existing.id);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
getDatabase()
|
|
331
|
+
.prepare(`INSERT INTO habit_check_ins (id, habit_id, date_key, status, note, delta_xp, created_at, updated_at)
|
|
332
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
333
|
+
.run(`hci_${randomUUID().replaceAll("-", "").slice(0, 10)}`, habitId, parsed.dateKey, parsed.status, parsed.note, reward.deltaXp, now, now);
|
|
334
|
+
}
|
|
335
|
+
recordActivityEvent({
|
|
336
|
+
entityType: "habit",
|
|
337
|
+
entityId: habit.id,
|
|
338
|
+
eventType: parsed.status === "done" ? "habit_done" : "habit_missed",
|
|
339
|
+
title: `${parsed.status === "done" ? "Habit completed" : "Habit missed"}: ${habit.title}`,
|
|
340
|
+
description: habit.polarity === "positive"
|
|
341
|
+
? parsed.status === "done"
|
|
342
|
+
? "Positive habit logged as completed."
|
|
343
|
+
: "Positive habit logged as missed."
|
|
344
|
+
: parsed.status === "done"
|
|
345
|
+
? "Negative habit logged as performed."
|
|
346
|
+
: "Negative habit logged as resisted.",
|
|
347
|
+
actor: activity?.actor ?? null,
|
|
348
|
+
source: activity?.source ?? "ui",
|
|
349
|
+
metadata: {
|
|
350
|
+
dateKey: parsed.dateKey,
|
|
351
|
+
status: parsed.status,
|
|
352
|
+
polarity: habit.polarity,
|
|
353
|
+
deltaXp: reward.deltaXp
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
return getHabitById(habitId);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
@@ -43,6 +43,22 @@ const DEFAULT_RULES = [
|
|
|
43
43
|
description: "Reward a concrete decision to apply a useful insight.",
|
|
44
44
|
config: { fixedXp: 15 }
|
|
45
45
|
},
|
|
46
|
+
{
|
|
47
|
+
id: "reward_rule_habit_aligned",
|
|
48
|
+
family: "consistency",
|
|
49
|
+
code: "habit_aligned",
|
|
50
|
+
title: "Habit alignment",
|
|
51
|
+
description: "Award XP when a habit outcome matches the intended direction.",
|
|
52
|
+
config: { award: "habit.rewardXp" }
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "reward_rule_habit_misaligned",
|
|
56
|
+
family: "recovery",
|
|
57
|
+
code: "habit_misaligned",
|
|
58
|
+
title: "Habit miss",
|
|
59
|
+
description: "Apply a small XP penalty when a habit outcome moves against the intended direction.",
|
|
60
|
+
config: { penalty: "habit.penaltyXp" }
|
|
61
|
+
},
|
|
46
62
|
{
|
|
47
63
|
id: "reward_rule_psyche_reflection_capture",
|
|
48
64
|
family: "alignment",
|
|
@@ -633,6 +649,52 @@ export function recordSessionEvent(input, activity, now = new Date()) {
|
|
|
633
649
|
}, now);
|
|
634
650
|
return { sessionEvent, rewardEvent };
|
|
635
651
|
}
|
|
652
|
+
export function recordHabitCheckInReward(habit, status, dateKey, activity) {
|
|
653
|
+
ensureDefaultRewardRules();
|
|
654
|
+
const aligned = (habit.polarity === "positive" && status === "done") ||
|
|
655
|
+
(habit.polarity === "negative" && status === "missed");
|
|
656
|
+
const rule = getRuleByCode(aligned ? "habit_aligned" : "habit_misaligned");
|
|
657
|
+
const deltaXp = aligned ? habit.rewardXp : -Math.abs(habit.penaltyXp);
|
|
658
|
+
const actionLabel = habit.polarity === "positive"
|
|
659
|
+
? status === "done"
|
|
660
|
+
? "completed"
|
|
661
|
+
: "missed"
|
|
662
|
+
: status === "done"
|
|
663
|
+
? "performed"
|
|
664
|
+
: "resisted";
|
|
665
|
+
const eventLog = recordEventLog({
|
|
666
|
+
eventKind: aligned ? "reward.habit_aligned" : "reward.habit_misaligned",
|
|
667
|
+
entityType: "habit",
|
|
668
|
+
entityId: habit.id,
|
|
669
|
+
actor: activity.actor ?? null,
|
|
670
|
+
source: activity.source,
|
|
671
|
+
metadata: {
|
|
672
|
+
habitId: habit.id,
|
|
673
|
+
status,
|
|
674
|
+
polarity: habit.polarity,
|
|
675
|
+
dateKey,
|
|
676
|
+
deltaXp
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
return insertLedgerEvent({
|
|
680
|
+
ruleId: rule?.id ?? null,
|
|
681
|
+
eventLogId: eventLog.id,
|
|
682
|
+
entityType: "habit",
|
|
683
|
+
entityId: habit.id,
|
|
684
|
+
actor: activity.actor ?? null,
|
|
685
|
+
source: activity.source,
|
|
686
|
+
deltaXp,
|
|
687
|
+
reasonTitle: aligned ? `${habit.title} aligned` : `${habit.title} slipped`,
|
|
688
|
+
reasonSummary: `Habit ${actionLabel} on ${dateKey}.`,
|
|
689
|
+
reversibleGroup: `habit:${habit.id}:${dateKey}`,
|
|
690
|
+
metadata: {
|
|
691
|
+
habitId: habit.id,
|
|
692
|
+
status,
|
|
693
|
+
polarity: habit.polarity,
|
|
694
|
+
dateKey
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
}
|
|
636
698
|
export function listSessionEvents(limit = 50) {
|
|
637
699
|
const rows = getDatabase()
|
|
638
700
|
.prepare(`SELECT id, session_id, event_type, actor, source, metrics_json, created_at
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { listActivityEvents } from "../repositories/activity-events.js";
|
|
2
2
|
import { listGoals } from "../repositories/goals.js";
|
|
3
|
+
import { listHabits } from "../repositories/habits.js";
|
|
4
|
+
import { listRewardLedger } from "../repositories/rewards.js";
|
|
3
5
|
import { listTags, listTagsByIds } from "../repositories/tags.js";
|
|
4
6
|
import { listTasks } from "../repositories/tasks.js";
|
|
5
7
|
import { getDashboard } from "./dashboard.js";
|
|
@@ -109,6 +111,7 @@ export function getOverviewContext(now = new Date()) {
|
|
|
109
111
|
const dashboard = getDashboard();
|
|
110
112
|
const focusTasks = dashboard.tasks.filter((task) => task.status === "focus" || task.status === "in_progress").length;
|
|
111
113
|
const overdueTasks = dashboard.tasks.filter((task) => task.status !== "done" && task.dueDate !== null && task.dueDate < now.toISOString().slice(0, 10)).length;
|
|
114
|
+
const dueHabits = dashboard.habits.filter((habit) => habit.dueToday).slice(0, 6);
|
|
112
115
|
return overviewContextSchema.parse({
|
|
113
116
|
generatedAt: now.toISOString(),
|
|
114
117
|
strategicHeader: {
|
|
@@ -124,8 +127,9 @@ export function getOverviewContext(now = new Date()) {
|
|
|
124
127
|
projects: dashboard.projects.slice(0, 5),
|
|
125
128
|
activeGoals: dashboard.goals.filter((goal) => goal.status === "active").slice(0, 6),
|
|
126
129
|
topTasks: sortStrategicTasks(dashboard.tasks.filter((task) => task.status !== "done")).slice(0, 6),
|
|
130
|
+
dueHabits,
|
|
127
131
|
recentEvidence: listActivityEvents({ limit: 12 }),
|
|
128
|
-
achievements: buildAchievementSignals(listGoals(), listTasks(), now),
|
|
132
|
+
achievements: buildAchievementSignals(listGoals(), listTasks(), listHabits(), now),
|
|
129
133
|
domainBalance: buildDomainBalance(listGoals(), listTasks()),
|
|
130
134
|
neglectedGoals: buildNeglectedGoals(listGoals(), listTasks(), now)
|
|
131
135
|
});
|
|
@@ -133,10 +137,12 @@ export function getOverviewContext(now = new Date()) {
|
|
|
133
137
|
export function getTodayContext(now = new Date()) {
|
|
134
138
|
const goals = listGoals();
|
|
135
139
|
const tasks = listTasks();
|
|
136
|
-
const
|
|
140
|
+
const habits = listHabits();
|
|
141
|
+
const gamification = buildGamificationProfile(goals, tasks, habits, now);
|
|
137
142
|
const inProgressTasks = sortStrategicTasks(tasks.filter((task) => task.status === "in_progress")).slice(0, 4);
|
|
138
143
|
const readyTasks = sortStrategicTasks(tasks.filter((task) => task.status === "focus" || task.status === "backlog")).slice(0, 4);
|
|
139
144
|
const deferredTasks = sortStrategicTasks(tasks.filter((task) => task.status === "blocked")).slice(0, 4);
|
|
145
|
+
const dueHabits = habits.filter((habit) => habit.dueToday).slice(0, 6);
|
|
140
146
|
const completedTasks = [...tasks]
|
|
141
147
|
.filter((task) => task.status === "done" && task.completedAt !== null)
|
|
142
148
|
.sort((left, right) => Date.parse(right.completedAt ?? "") - Date.parse(left.completedAt ?? ""))
|
|
@@ -185,13 +191,17 @@ export function getTodayContext(now = new Date()) {
|
|
|
185
191
|
completed: false
|
|
186
192
|
}
|
|
187
193
|
],
|
|
188
|
-
|
|
194
|
+
dueHabits,
|
|
195
|
+
milestoneRewards: buildMilestoneRewards(goals, tasks, habits, now),
|
|
196
|
+
recentHabitRewards: listRewardLedger({ entityType: "habit", limit: 8 }),
|
|
189
197
|
momentum: {
|
|
190
198
|
streakDays: gamification.streakDays,
|
|
191
199
|
momentumScore: gamification.momentumScore,
|
|
192
|
-
recoveryHint:
|
|
193
|
-
?
|
|
194
|
-
:
|
|
200
|
+
recoveryHint: dueHabits.length > 0
|
|
201
|
+
? `${dueHabits.length} habit${dueHabits.length === 1 ? "" : "s"} still need a check-in today. Closing one will keep momentum honest.`
|
|
202
|
+
: overdueCount > 0
|
|
203
|
+
? `Clear ${overdueCount} overdue task${overdueCount === 1 ? "" : "s"} to keep momentum from decaying.`
|
|
204
|
+
: "No overdue drag right now. Preserve the rhythm with one decisive completion."
|
|
195
205
|
}
|
|
196
206
|
});
|
|
197
207
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { listGoals } from "../repositories/goals.js";
|
|
2
2
|
import { listActivityEvents } from "../repositories/activity-events.js";
|
|
3
|
+
import { listHabits } from "../repositories/habits.js";
|
|
3
4
|
import { buildNotesSummaryByEntity } from "../repositories/notes.js";
|
|
4
5
|
import { listTagsByIds, listTags } from "../repositories/tags.js";
|
|
5
6
|
import { listTasks } from "../repositories/tasks.js";
|
|
@@ -116,6 +117,7 @@ function buildGoalSummary(tasks, goalId) {
|
|
|
116
117
|
export function getDashboard() {
|
|
117
118
|
const goals = listGoals();
|
|
118
119
|
const tasks = listTasks();
|
|
120
|
+
const habits = listHabits();
|
|
119
121
|
const tags = listTags();
|
|
120
122
|
const now = new Date();
|
|
121
123
|
const weekStart = startOfWeek(now).toISOString();
|
|
@@ -150,9 +152,9 @@ export function getDashboard() {
|
|
|
150
152
|
const suggestedTags = tags.filter((tag) => ["value", "execution"].includes(tag.kind)).slice(0, 6);
|
|
151
153
|
const owners = [...new Set(tasks.map((task) => task.owner).filter(Boolean))].sort((left, right) => left.localeCompare(right));
|
|
152
154
|
const executionBuckets = buildExecutionBuckets(tasks, todayIso, weekEndIso);
|
|
153
|
-
const gamification = buildGamificationProfile(goals, tasks, now);
|
|
154
|
-
const achievements = buildAchievementSignals(goals, tasks, now);
|
|
155
|
-
const milestoneRewards = buildMilestoneRewards(goals, tasks, now);
|
|
155
|
+
const gamification = buildGamificationProfile(goals, tasks, habits, now);
|
|
156
|
+
const achievements = buildAchievementSignals(goals, tasks, habits, now);
|
|
157
|
+
const milestoneRewards = buildMilestoneRewards(goals, tasks, habits, now);
|
|
156
158
|
const recentActivity = listActivityEvents({ limit: 12 });
|
|
157
159
|
const notesSummaryByEntity = buildNotesSummaryByEntity();
|
|
158
160
|
return dashboardPayloadSchema.parse({
|
|
@@ -160,6 +162,7 @@ export function getDashboard() {
|
|
|
160
162
|
goals: goalCards,
|
|
161
163
|
projects,
|
|
162
164
|
tasks,
|
|
165
|
+
habits,
|
|
163
166
|
tags,
|
|
164
167
|
suggestedTags,
|
|
165
168
|
owners,
|
|
@@ -4,11 +4,12 @@ import { createNote, deleteNote, getNoteById, listNotes, unlinkNotesForEntity, u
|
|
|
4
4
|
import { createBehaviorPatternSchema, createBehaviorSchema, createBeliefEntrySchema, createEmotionDefinitionSchema, createEventTypeSchema, createModeGuideSessionSchema, createModeProfileSchema, createPsycheValueSchema, createTriggerReportSchema, updateBehaviorPatternSchema, updateBehaviorSchema, updateBeliefEntrySchema, updateEmotionDefinitionSchema, updateEventTypeSchema, updateModeGuideSessionSchema, updateModeProfileSchema, updatePsycheValueSchema, updateTriggerReportSchema } from "../psyche-types.js";
|
|
5
5
|
import { buildSettingsBinPayload, cascadeSoftDeleteAnchoredCollaboration, clearDeletedEntityRecord, getDeletedEntityRecord, listDeletedEntities, restoreAnchoredCollaboration, restoreDeletedEntityRecord, upsertDeletedEntityRecord } from "../repositories/deleted-entities.js";
|
|
6
6
|
import { createGoal, deleteGoal, getGoalById, listGoals, updateGoal } from "../repositories/goals.js";
|
|
7
|
+
import { createHabit, deleteHabit, getHabitById, listHabits, updateHabit } from "../repositories/habits.js";
|
|
7
8
|
import { createBehavior, createBehaviorPattern, createBeliefEntry, createEmotionDefinition, createEventType, createModeGuideSession, createModeProfile, createPsycheValue, createTriggerReport, deleteBehavior, deleteBehaviorPattern, deleteBeliefEntry, deleteEmotionDefinition, deleteEventType, deleteModeGuideSession, deleteModeProfile, deletePsycheValue, deleteTriggerReport, getBehaviorById, getBehaviorPatternById, getBeliefEntryById, getEmotionDefinitionById, getEventTypeById, getModeGuideSessionById, getModeProfileById, getPsycheValueById, getTriggerReportById, listBehaviors, listBehaviorPatterns, listBeliefEntries, listEmotionDefinitions, listEventTypes, listModeGuideSessions, listModeProfiles, listPsycheValues, listTriggerReports, updateBehavior, updateBehaviorPattern, updateBeliefEntry, updateEmotionDefinition, updateEventType, updateModeGuideSession, updateModeProfile, updatePsycheValue, updateTriggerReport } from "../repositories/psyche.js";
|
|
8
9
|
import { createProject, deleteProject, getProjectById, listProjects, updateProject } from "../repositories/projects.js";
|
|
9
10
|
import { createTag, deleteTag, getTagById, listTags, updateTag } from "../repositories/tags.js";
|
|
10
11
|
import { createTask, deleteTask, getTaskById, listTasks, updateTask } from "../repositories/tasks.js";
|
|
11
|
-
import { createGoalSchema, createInsightSchema, createNoteSchema, createProjectSchema, createTagSchema, createTaskSchema, updateGoalSchema, updateInsightSchema, updateNoteSchema, updateProjectSchema, updateTagSchema, updateTaskSchema } from "../types.js";
|
|
12
|
+
import { createGoalSchema, createHabitSchema, createInsightSchema, createNoteSchema, createProjectSchema, createTagSchema, createTaskSchema, updateGoalSchema, updateHabitSchema, updateInsightSchema, updateNoteSchema, updateProjectSchema, updateTagSchema, updateTaskSchema } from "../types.js";
|
|
12
13
|
class AtomicBatchRollback extends Error {
|
|
13
14
|
index;
|
|
14
15
|
code;
|
|
@@ -49,6 +50,15 @@ const CRUD_ENTITY_CAPABILITIES = {
|
|
|
49
50
|
update: (id, patch, context) => updateTask(id, patch, context),
|
|
50
51
|
hardDelete: (id, context) => deleteTask(id, context)
|
|
51
52
|
},
|
|
53
|
+
habit: {
|
|
54
|
+
entityType: "habit",
|
|
55
|
+
routeBase: "/api/v1/habits",
|
|
56
|
+
list: () => listHabits(),
|
|
57
|
+
get: (id) => getHabitById(id),
|
|
58
|
+
create: (data, context) => createHabit(data, context),
|
|
59
|
+
update: (id, patch, context) => updateHabit(id, patch, context),
|
|
60
|
+
hardDelete: (id, context) => deleteHabit(id, context)
|
|
61
|
+
},
|
|
52
62
|
tag: {
|
|
53
63
|
entityType: "tag",
|
|
54
64
|
routeBase: "/api/v1/tags",
|
|
@@ -174,6 +184,7 @@ const CREATE_ENTITY_SCHEMAS = {
|
|
|
174
184
|
goal: createGoalSchema,
|
|
175
185
|
project: createProjectSchema,
|
|
176
186
|
task: createTaskSchema,
|
|
187
|
+
habit: createHabitSchema,
|
|
177
188
|
tag: createTagSchema,
|
|
178
189
|
note: createNoteSchema,
|
|
179
190
|
insight: createInsightSchema,
|
|
@@ -191,6 +202,7 @@ const UPDATE_ENTITY_SCHEMAS = {
|
|
|
191
202
|
goal: updateGoalSchema,
|
|
192
203
|
project: updateProjectSchema,
|
|
193
204
|
task: updateTaskSchema,
|
|
205
|
+
habit: updateHabitSchema,
|
|
194
206
|
tag: updateTagSchema,
|
|
195
207
|
note: updateNoteSchema,
|
|
196
208
|
insight: updateInsightSchema,
|
|
@@ -312,6 +324,16 @@ function matchesLinkedTo(entityType, entity, linkedTo) {
|
|
|
312
324
|
return linkedTo.entityType === "goal" && entity.goalId === linkedTo.id;
|
|
313
325
|
case "task":
|
|
314
326
|
return (linkedTo.entityType === "goal" && entity.goalId === linkedTo.id) || (linkedTo.entityType === "project" && entity.projectId === linkedTo.id);
|
|
327
|
+
case "habit":
|
|
328
|
+
return ((linkedTo.entityType === "goal" && Array.isArray(entity.linkedGoalIds) && entity.linkedGoalIds.includes(linkedTo.id)) ||
|
|
329
|
+
(linkedTo.entityType === "project" && Array.isArray(entity.linkedProjectIds) && entity.linkedProjectIds.includes(linkedTo.id)) ||
|
|
330
|
+
(linkedTo.entityType === "task" && Array.isArray(entity.linkedTaskIds) && entity.linkedTaskIds.includes(linkedTo.id)) ||
|
|
331
|
+
(linkedTo.entityType === "psyche_value" && Array.isArray(entity.linkedValueIds) && entity.linkedValueIds.includes(linkedTo.id)) ||
|
|
332
|
+
(linkedTo.entityType === "behavior_pattern" && Array.isArray(entity.linkedPatternIds) && entity.linkedPatternIds.includes(linkedTo.id)) ||
|
|
333
|
+
(linkedTo.entityType === "behavior" && Array.isArray(entity.linkedBehaviorIds) && entity.linkedBehaviorIds.includes(linkedTo.id)) ||
|
|
334
|
+
(linkedTo.entityType === "belief_entry" && Array.isArray(entity.linkedBeliefIds) && entity.linkedBeliefIds.includes(linkedTo.id)) ||
|
|
335
|
+
(linkedTo.entityType === "mode_profile" && Array.isArray(entity.linkedModeIds) && entity.linkedModeIds.includes(linkedTo.id)) ||
|
|
336
|
+
(linkedTo.entityType === "trigger_report" && Array.isArray(entity.linkedReportIds) && entity.linkedReportIds.includes(linkedTo.id)));
|
|
315
337
|
case "note":
|
|
316
338
|
return (Array.isArray(entity.links) &&
|
|
317
339
|
entity.links.some((link) => typeof link === "object" &&
|