@gonzih/screen-time-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +343 -0
- package/dist/config.js +31 -0
- package/dist/db.js +148 -0
- package/dist/gamification.js +80 -0
- package/dist/index.js +237 -0
- package/dist/limits.js +182 -0
- package/dist/reports.js +113 -0
- package/package.json +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Maksim Soltan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# screen-time-mcp
|
|
2
|
+
|
|
3
|
+
An MCP (Model Context Protocol) server that enforces AI usage limits for children. Set daily time caps, quiet hours, weekly budgets, cool-down periods between sessions, gamified streaks, and receive weekly parent reports via Telegram — all without modifying the AI assistant itself.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Daily time caps** — hard stop when the child's daily AI minutes are used up
|
|
8
|
+
- **Per-session limits** — cap individual sessions to encourage breaks between focused work
|
|
9
|
+
- **Weekly budget** — soft warning when the weekly total is reached
|
|
10
|
+
- **Quiet hours** — block AI access at night and in the morning (fully configurable, supports ranges that cross midnight)
|
|
11
|
+
- **Cool-down periods** — require a break between sessions so children step away from the screen
|
|
12
|
+
- **Parent PIN overrides** — grant temporary extra time without changing permanent settings
|
|
13
|
+
- **Gamified streaks** — track consecutive days within limit and celebrate milestones
|
|
14
|
+
- **Banked minutes** — optionally carry unused daily minutes forward as a bonus
|
|
15
|
+
- **Weekly reports** — auto-generate and send a parent summary via Telegram
|
|
16
|
+
- **Daily summaries** — optional end-of-day Telegram digest for parents
|
|
17
|
+
- **Child-friendly messages** — warm, encouraging block messages (never punitive)
|
|
18
|
+
- **Multi-profile support** — run separate profiles for multiple children
|
|
19
|
+
- **SQLite storage** — lightweight, local, no external database required
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### From npm
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g @gonzih/screen-time-mcp
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### From source
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/gonzih/screen-time-mcp
|
|
33
|
+
cd screen-time-mcp
|
|
34
|
+
npm install
|
|
35
|
+
npm run build
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
Copy `.env.example` to `.env` and edit to match your family's needs:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cp .env.example .env
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Environment Variables
|
|
47
|
+
|
|
48
|
+
| Variable | Default | Description |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `SCREEN_TIME_PROFILE_ID` | `default` | Child profile identifier (use separate IDs per child) |
|
|
51
|
+
| `SCREEN_TIME_CHILD_NAME` | `Child` | Child's name, used in reports and messages |
|
|
52
|
+
| `SCREEN_TIME_DB` | `~/.screen-time-mcp/usage.sqlite` | Path to the SQLite database file |
|
|
53
|
+
| `DAILY_LIMIT_MINS` | `45` | Daily AI time cap in minutes |
|
|
54
|
+
| `SESSION_LIMIT_MINS` | `30` | Per-session cap in minutes |
|
|
55
|
+
| `WEEKLY_BUDGET_MINS` | `240` | Weekly total budget in minutes (4 hours) |
|
|
56
|
+
| `COOL_DOWN_MINS` | `30` | Required break between sessions in minutes |
|
|
57
|
+
| `PARENT_OVERRIDE_PIN` | `1234` | PIN required to grant temporary extra time |
|
|
58
|
+
| `QUIET_HOURS` | `21:00-07:00` | Comma-separated time ranges; ranges crossing midnight supported |
|
|
59
|
+
| `TELEGRAM_BOT_TOKEN` | _(none)_ | Telegram bot token for parent reports |
|
|
60
|
+
| `TELEGRAM_PARENT_CHAT_ID` | _(none)_ | Parent's Telegram chat ID |
|
|
61
|
+
| `REPORT_DAY` | `sunday` | Day of week to send the weekly report |
|
|
62
|
+
| `DAILY_REPORT` | `false` | Set to `true` to send a Telegram summary after each day |
|
|
63
|
+
| `BANK_UNUSED_TIME` | `false` | Set to `true` to carry unused daily minutes forward |
|
|
64
|
+
|
|
65
|
+
### Quiet Hours format
|
|
66
|
+
|
|
67
|
+
Quiet hours are comma-separated `HH:MM-HH:MM` ranges. Ranges that cross midnight are fully supported:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
# Night + morning blocks
|
|
71
|
+
QUIET_HOURS=21:00-07:00,07:00-08:00
|
|
72
|
+
|
|
73
|
+
# Night block only
|
|
74
|
+
QUIET_HOURS=22:00-07:00
|
|
75
|
+
|
|
76
|
+
# Multiple blocks
|
|
77
|
+
QUIET_HOURS=21:00-07:00,12:00-13:00
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Setting up Telegram reports
|
|
81
|
+
|
|
82
|
+
1. Message [@BotFather](https://t.me/botfather) on Telegram and create a new bot. Copy the bot token.
|
|
83
|
+
2. Start a conversation with your new bot, then visit `https://api.telegram.org/bot<TOKEN>/getUpdates` to find your chat ID.
|
|
84
|
+
3. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_PARENT_CHAT_ID` in your `.env`.
|
|
85
|
+
|
|
86
|
+
## Claude Desktop Integration
|
|
87
|
+
|
|
88
|
+
Add the server to your Claude Desktop configuration at `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"screen-time-mcp": {
|
|
94
|
+
"command": "screen-time-mcp",
|
|
95
|
+
"env": {
|
|
96
|
+
"SCREEN_TIME_PROFILE_ID": "alex",
|
|
97
|
+
"SCREEN_TIME_CHILD_NAME": "Alex",
|
|
98
|
+
"DAILY_LIMIT_MINS": "45",
|
|
99
|
+
"SESSION_LIMIT_MINS": "30",
|
|
100
|
+
"WEEKLY_BUDGET_MINS": "240",
|
|
101
|
+
"COOL_DOWN_MINS": "30",
|
|
102
|
+
"PARENT_OVERRIDE_PIN": "9876",
|
|
103
|
+
"QUIET_HOURS": "21:00-07:00,07:00-08:00",
|
|
104
|
+
"TELEGRAM_BOT_TOKEN": "your-bot-token",
|
|
105
|
+
"TELEGRAM_PARENT_CHAT_ID": "your-chat-id",
|
|
106
|
+
"DAILY_REPORT": "true",
|
|
107
|
+
"BANK_UNUSED_TIME": "true"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If running from source, use the full path instead:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"mcpServers": {
|
|
119
|
+
"screen-time-mcp": {
|
|
120
|
+
"command": "node",
|
|
121
|
+
"args": ["/path/to/screen-time-mcp/dist/index.js"],
|
|
122
|
+
"env": {
|
|
123
|
+
"SCREEN_TIME_PROFILE_ID": "alex"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Multi-child setup
|
|
131
|
+
|
|
132
|
+
Run one server instance per child, each with a distinct profile ID:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"mcpServers": {
|
|
137
|
+
"screen-time-alex": {
|
|
138
|
+
"command": "screen-time-mcp",
|
|
139
|
+
"env": {
|
|
140
|
+
"SCREEN_TIME_PROFILE_ID": "alex",
|
|
141
|
+
"SCREEN_TIME_CHILD_NAME": "Alex",
|
|
142
|
+
"DAILY_LIMIT_MINS": "45"
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
"screen-time-jamie": {
|
|
146
|
+
"command": "screen-time-mcp",
|
|
147
|
+
"env": {
|
|
148
|
+
"SCREEN_TIME_PROFILE_ID": "jamie",
|
|
149
|
+
"SCREEN_TIME_CHILD_NAME": "Jamie",
|
|
150
|
+
"DAILY_LIMIT_MINS": "60"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## MCP Tools Reference
|
|
158
|
+
|
|
159
|
+
The AI assistant is expected to call these tools automatically according to the session flow described in `SKILL.md`.
|
|
160
|
+
|
|
161
|
+
### Child-facing tools
|
|
162
|
+
|
|
163
|
+
| Tool | Parameters | Description |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `start_session` | `profileId: string` | Call at conversation start. Returns `allowed`, `sessionId`, and time remaining. If `allowed` is false, show the block message and stop. |
|
|
166
|
+
| `check_limits` | `profileId: string`, `sessionId: string` | Call every message. Returns `withinLimits`, current usage, and any `warning` to relay to the child. |
|
|
167
|
+
| `end_session` | `profileId: string`, `sessionId: string`, `messageCount?: number` | Call at conversation end. Updates daily usage, returns streak info and a closing message for the child. |
|
|
168
|
+
|
|
169
|
+
### Parent-facing tools (require PIN where noted)
|
|
170
|
+
|
|
171
|
+
| Tool | Parameters | Description |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `get_usage` | `profileId: string`, `days?: number` | View daily usage history, totals, and streak data. |
|
|
174
|
+
| `update_limits` | `profileId: string`, `settings: object` | Update `dailyLimitMins`, `sessionLimitMins`, `weeklyBudgetMins`, or `coolDownMins` at runtime. Changes are in-memory only. |
|
|
175
|
+
| `override_limits` | `profileId: string`, `pin: string`, `extraMinutes: number` | Grant a temporary extension to today's daily limit. Requires parent PIN. |
|
|
176
|
+
| `send_report` | `profileId: string` | Generate the weekly report and send it via Telegram if configured. |
|
|
177
|
+
|
|
178
|
+
### Session flow
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
Conversation starts
|
|
182
|
+
│
|
|
183
|
+
▼
|
|
184
|
+
start_session(profileId)
|
|
185
|
+
│
|
|
186
|
+
├── allowed: false → show block message, end conversation
|
|
187
|
+
│
|
|
188
|
+
└── allowed: true → store sessionId
|
|
189
|
+
│
|
|
190
|
+
▼ (every message)
|
|
191
|
+
check_limits(profileId, sessionId)
|
|
192
|
+
│
|
|
193
|
+
├── withinLimits: false → relay warning, wrap up gracefully
|
|
194
|
+
├── warning present → mention naturally in reply
|
|
195
|
+
└── all good → continue
|
|
196
|
+
│
|
|
197
|
+
▼ (conversation ends)
|
|
198
|
+
end_session(profileId, sessionId, messageCount)
|
|
199
|
+
│
|
|
200
|
+
└── share streakMessage with child if present
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## How It Works
|
|
204
|
+
|
|
205
|
+
### Block evaluation order
|
|
206
|
+
|
|
207
|
+
Limits are checked in this priority order:
|
|
208
|
+
|
|
209
|
+
1. **Quiet hours** — if the current time falls in a configured quiet range, block immediately with a tailored morning or night message
|
|
210
|
+
2. **Daily cap** — if today's accumulated minutes (including the current session) meet or exceed the daily limit, block
|
|
211
|
+
3. **Weekly budget** — if the weekly total is reached, allow but show a soft warning
|
|
212
|
+
4. **Cool-down period** — at session start only, check if enough time has passed since the last session ended
|
|
213
|
+
5. **5/10-minute warnings** — if the daily remaining time is under 10 minutes, emit a countdown warning
|
|
214
|
+
|
|
215
|
+
### Gamification
|
|
216
|
+
|
|
217
|
+
- A streak increments each day the child stays within the daily limit
|
|
218
|
+
- Streaks reset to 0 if the limit is exceeded
|
|
219
|
+
- Milestone messages trigger at day 1 (new streak), day 3, and day 7+
|
|
220
|
+
- If `BANK_UNUSED_TIME=true`, unused daily minutes accumulate as banked minutes that a parent can manually grant as a bonus session
|
|
221
|
+
|
|
222
|
+
### Parent reports
|
|
223
|
+
|
|
224
|
+
When `TELEGRAM_BOT_TOKEN` and `TELEGRAM_PARENT_CHAT_ID` are set:
|
|
225
|
+
|
|
226
|
+
- **Weekly report** — generated on the configured `REPORT_DAY` (or on demand via `send_report`). Includes total time, days within limit, session count, average session length, most active day, and a qualitative insight.
|
|
227
|
+
- **Daily summary** — if `DAILY_REPORT=true`, a brief digest is sent via Telegram after each session ends.
|
|
228
|
+
|
|
229
|
+
## Healthy Screen Time Guidelines (AAP)
|
|
230
|
+
|
|
231
|
+
The American Academy of Pediatrics recommends the following for digital media use:
|
|
232
|
+
|
|
233
|
+
**Ages 6–12**
|
|
234
|
+
- Set consistent daily limits on screen time, including AI tools
|
|
235
|
+
- Prioritize sleep (9–12 hours/night for ages 6–12) — AI time should end well before bedtime
|
|
236
|
+
- Ensure physical activity (60 minutes/day) is not displaced by screen time
|
|
237
|
+
- Use media together when possible; ask children what they're doing with AI
|
|
238
|
+
|
|
239
|
+
**Teens (13–17)**
|
|
240
|
+
- Negotiate consistent limits collaboratively — teens are more likely to follow rules they helped set
|
|
241
|
+
- Encourage awareness of how much time is spent, not just enforcement
|
|
242
|
+
- Keep devices and AI assistants out of the bedroom at night
|
|
243
|
+
|
|
244
|
+
**For parents**
|
|
245
|
+
- Model healthy use yourself — children notice how adults interact with technology
|
|
246
|
+
- Treat AI assistants as a tool to be used intentionally, not a constant companion
|
|
247
|
+
- Review weekly reports together with your child as a conversation starter, not a punishment
|
|
248
|
+
|
|
249
|
+
The default settings in this server (45 min/day, 30 min cool-down, 21:00 quiet hours) are calibrated for the 6–12 age range. Adjust for older children as appropriate.
|
|
250
|
+
|
|
251
|
+
## Suite Integration
|
|
252
|
+
|
|
253
|
+
`screen-time-mcp` is designed to work alongside a family of child-safety MCP servers:
|
|
254
|
+
|
|
255
|
+
### parental-control-mcp
|
|
256
|
+
|
|
257
|
+
Filters content and topics the AI is allowed to discuss. While `screen-time-mcp` controls *when* and *how long* a child can use AI, `parental-control-mcp` controls *what* they can discuss. Use both together for comprehensive guardrails.
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"mcpServers": {
|
|
262
|
+
"screen-time-mcp": { "command": "screen-time-mcp", "env": { ... } },
|
|
263
|
+
"parental-control-mcp": { "command": "parental-control-mcp", "env": { ... } }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### attachment-guard-mcp
|
|
269
|
+
|
|
270
|
+
Monitors for signs of unhealthy emotional attachment to the AI assistant — excessive dependency, replacing human relationships, or distress when access is limited. Complements `screen-time-mcp` by addressing the quality of AI use alongside the quantity.
|
|
271
|
+
|
|
272
|
+
### ai-mood-journal-mcp
|
|
273
|
+
|
|
274
|
+
Lets children optionally log how they're feeling after an AI session. Pairs well with `end_session` — after the streak message, invite the child to record a quick mood check-in. Parents can review mood trends alongside usage data from `get_usage`.
|
|
275
|
+
|
|
276
|
+
### Recommended combined configuration
|
|
277
|
+
|
|
278
|
+
```json
|
|
279
|
+
{
|
|
280
|
+
"mcpServers": {
|
|
281
|
+
"screen-time-mcp": {
|
|
282
|
+
"command": "screen-time-mcp",
|
|
283
|
+
"env": {
|
|
284
|
+
"SCREEN_TIME_PROFILE_ID": "alex",
|
|
285
|
+
"DAILY_LIMIT_MINS": "45",
|
|
286
|
+
"BANK_UNUSED_TIME": "true",
|
|
287
|
+
"TELEGRAM_BOT_TOKEN": "...",
|
|
288
|
+
"TELEGRAM_PARENT_CHAT_ID": "..."
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
"parental-control-mcp": {
|
|
292
|
+
"command": "parental-control-mcp",
|
|
293
|
+
"env": { "PROFILE_ID": "alex" }
|
|
294
|
+
},
|
|
295
|
+
"attachment-guard-mcp": {
|
|
296
|
+
"command": "attachment-guard-mcp",
|
|
297
|
+
"env": { "PROFILE_ID": "alex" }
|
|
298
|
+
},
|
|
299
|
+
"ai-mood-journal-mcp": {
|
|
300
|
+
"command": "ai-mood-journal-mcp",
|
|
301
|
+
"env": { "PROFILE_ID": "alex" }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## Development
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
# Run in development mode (no build step)
|
|
311
|
+
npm run dev
|
|
312
|
+
|
|
313
|
+
# Build TypeScript
|
|
314
|
+
npm run build
|
|
315
|
+
|
|
316
|
+
# Run the built server
|
|
317
|
+
npm start
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The SQLite database is created automatically at the configured path on first run. No migrations are needed — the schema is applied idempotently via `CREATE TABLE IF NOT EXISTS`.
|
|
321
|
+
|
|
322
|
+
## Publishing to npm
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
# Build first
|
|
326
|
+
npm run build
|
|
327
|
+
|
|
328
|
+
# Verify the dist/ output looks correct
|
|
329
|
+
ls dist/
|
|
330
|
+
|
|
331
|
+
# Dry-run to see what will be published
|
|
332
|
+
npm publish --dry-run
|
|
333
|
+
|
|
334
|
+
# Publish (requires npm login)
|
|
335
|
+
npm login
|
|
336
|
+
npm publish --access public
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The `files` field in `package.json` ensures only `dist/` is included in the published package. The `bin` entry makes `screen-time-mcp` available as a global CLI command after install.
|
|
340
|
+
|
|
341
|
+
## License
|
|
342
|
+
|
|
343
|
+
MIT — see [LICENSE](LICENSE) for details. Author: Maksim Soltan.
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
function parseQuietHours(raw) {
|
|
4
|
+
if (!raw)
|
|
5
|
+
return [];
|
|
6
|
+
return raw.split(',').map(range => {
|
|
7
|
+
const [start, end] = range.trim().split('-');
|
|
8
|
+
return { start, end };
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
const dbPath = process.env.SCREEN_TIME_DB
|
|
13
|
+
? process.env.SCREEN_TIME_DB.replace(/^~/, homedir())
|
|
14
|
+
: join(homedir(), '.screen-time-mcp', 'usage.sqlite');
|
|
15
|
+
return {
|
|
16
|
+
profileId: process.env.SCREEN_TIME_PROFILE_ID || 'default',
|
|
17
|
+
childName: process.env.SCREEN_TIME_CHILD_NAME || 'Child',
|
|
18
|
+
dbPath,
|
|
19
|
+
dailyLimitMins: Number(process.env.DAILY_LIMIT_MINS || 45),
|
|
20
|
+
sessionLimitMins: Number(process.env.SESSION_LIMIT_MINS || 30),
|
|
21
|
+
weeklyBudgetMins: Number(process.env.WEEKLY_BUDGET_MINS || 240),
|
|
22
|
+
coolDownMins: Number(process.env.COOL_DOWN_MINS || 30),
|
|
23
|
+
parentOverridePin: process.env.PARENT_OVERRIDE_PIN || '1234',
|
|
24
|
+
quietHours: parseQuietHours(process.env.QUIET_HOURS || '21:00-07:00'),
|
|
25
|
+
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || null,
|
|
26
|
+
telegramParentChatId: process.env.TELEGRAM_PARENT_CHAT_ID || null,
|
|
27
|
+
reportDay: (process.env.REPORT_DAY || 'sunday').toLowerCase(),
|
|
28
|
+
dailyReport: process.env.DAILY_REPORT === 'true',
|
|
29
|
+
bankUnusedTime: process.env.BANK_UNUSED_TIME === 'true',
|
|
30
|
+
};
|
|
31
|
+
}
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { mkdirSync } from 'fs';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
let _db = null;
|
|
5
|
+
export function getDb(dbPath) {
|
|
6
|
+
if (_db)
|
|
7
|
+
return _db;
|
|
8
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
9
|
+
_db = new Database(dbPath);
|
|
10
|
+
_db.pragma('journal_mode = WAL');
|
|
11
|
+
initSchema(_db);
|
|
12
|
+
return _db;
|
|
13
|
+
}
|
|
14
|
+
function initSchema(db) {
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
profile_id TEXT NOT NULL,
|
|
19
|
+
started_at TEXT NOT NULL,
|
|
20
|
+
ended_at TEXT,
|
|
21
|
+
duration_mins REAL,
|
|
22
|
+
messages_exchanged INTEGER DEFAULT 0
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS daily_usage (
|
|
26
|
+
profile_id TEXT,
|
|
27
|
+
date TEXT,
|
|
28
|
+
total_mins REAL DEFAULT 0,
|
|
29
|
+
session_count INTEGER DEFAULT 0,
|
|
30
|
+
PRIMARY KEY (profile_id, date)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS weekly_reports (
|
|
34
|
+
profile_id TEXT,
|
|
35
|
+
week_start TEXT,
|
|
36
|
+
total_mins REAL,
|
|
37
|
+
days_active INTEGER,
|
|
38
|
+
avg_session_mins REAL,
|
|
39
|
+
longest_session_mins REAL,
|
|
40
|
+
report_text TEXT,
|
|
41
|
+
sent_at TEXT,
|
|
42
|
+
PRIMARY KEY (profile_id, week_start)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS overrides (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
profile_id TEXT NOT NULL,
|
|
48
|
+
granted_at TEXT NOT NULL,
|
|
49
|
+
expires_at TEXT NOT NULL,
|
|
50
|
+
extra_minutes REAL NOT NULL
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS streaks (
|
|
54
|
+
profile_id TEXT PRIMARY KEY,
|
|
55
|
+
current_streak INTEGER DEFAULT 0,
|
|
56
|
+
last_streak_date TEXT,
|
|
57
|
+
longest_streak INTEGER DEFAULT 0,
|
|
58
|
+
banked_mins REAL DEFAULT 0
|
|
59
|
+
);
|
|
60
|
+
`);
|
|
61
|
+
}
|
|
62
|
+
export function insertSession(db, session) {
|
|
63
|
+
db.prepare(`
|
|
64
|
+
INSERT INTO sessions (id, profile_id, started_at, messages_exchanged)
|
|
65
|
+
VALUES (?, ?, ?, 0)
|
|
66
|
+
`).run(session.id, session.profile_id, session.started_at);
|
|
67
|
+
}
|
|
68
|
+
export function getSession(db, sessionId) {
|
|
69
|
+
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
|
|
70
|
+
}
|
|
71
|
+
export function endSession(db, sessionId, endedAt, durationMins, messageCount) {
|
|
72
|
+
db.prepare(`
|
|
73
|
+
UPDATE sessions SET ended_at = ?, duration_mins = ?, messages_exchanged = ? WHERE id = ?
|
|
74
|
+
`).run(endedAt, durationMins, messageCount, sessionId);
|
|
75
|
+
}
|
|
76
|
+
export function getLastEndedSession(db, profileId) {
|
|
77
|
+
return db.prepare(`
|
|
78
|
+
SELECT * FROM sessions WHERE profile_id = ? AND ended_at IS NOT NULL ORDER BY ended_at DESC LIMIT 1
|
|
79
|
+
`).get(profileId);
|
|
80
|
+
}
|
|
81
|
+
export function getActiveSession(db, profileId) {
|
|
82
|
+
return db.prepare(`
|
|
83
|
+
SELECT * FROM sessions WHERE profile_id = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1
|
|
84
|
+
`).get(profileId);
|
|
85
|
+
}
|
|
86
|
+
export function getDailyUsage(db, profileId, date) {
|
|
87
|
+
const row = db.prepare('SELECT * FROM daily_usage WHERE profile_id = ? AND date = ?').get(profileId, date);
|
|
88
|
+
return row || { profile_id: profileId, date, total_mins: 0, session_count: 0 };
|
|
89
|
+
}
|
|
90
|
+
export function upsertDailyUsage(db, profileId, date, addMins, addSessions) {
|
|
91
|
+
db.prepare(`
|
|
92
|
+
INSERT INTO daily_usage (profile_id, date, total_mins, session_count)
|
|
93
|
+
VALUES (?, ?, ?, ?)
|
|
94
|
+
ON CONFLICT(profile_id, date) DO UPDATE SET
|
|
95
|
+
total_mins = total_mins + excluded.total_mins,
|
|
96
|
+
session_count = session_count + excluded.session_count
|
|
97
|
+
`).run(profileId, date, addMins, addSessions);
|
|
98
|
+
}
|
|
99
|
+
export function getUsageRange(db, profileId, fromDate, toDate) {
|
|
100
|
+
return db.prepare(`
|
|
101
|
+
SELECT * FROM daily_usage WHERE profile_id = ? AND date >= ? AND date <= ? ORDER BY date ASC
|
|
102
|
+
`).all(profileId, fromDate, toDate);
|
|
103
|
+
}
|
|
104
|
+
export function getWeeklyTotal(db, profileId, weekStart, weekEnd) {
|
|
105
|
+
const row = db.prepare(`
|
|
106
|
+
SELECT COALESCE(SUM(total_mins), 0) as total FROM daily_usage
|
|
107
|
+
WHERE profile_id = ? AND date >= ? AND date <= ?
|
|
108
|
+
`).get(profileId, weekStart, weekEnd);
|
|
109
|
+
return row.total;
|
|
110
|
+
}
|
|
111
|
+
export function insertOverride(db, override) {
|
|
112
|
+
db.prepare(`
|
|
113
|
+
INSERT INTO overrides (id, profile_id, granted_at, expires_at, extra_minutes)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?)
|
|
115
|
+
`).run(override.id, override.profile_id, override.granted_at, override.expires_at, override.extra_minutes);
|
|
116
|
+
}
|
|
117
|
+
export function getActiveOverride(db, profileId, now) {
|
|
118
|
+
return db.prepare(`
|
|
119
|
+
SELECT * FROM overrides WHERE profile_id = ? AND expires_at > ? ORDER BY expires_at DESC LIMIT 1
|
|
120
|
+
`).get(profileId, now);
|
|
121
|
+
}
|
|
122
|
+
export function getStreak(db, profileId) {
|
|
123
|
+
const row = db.prepare('SELECT * FROM streaks WHERE profile_id = ?').get(profileId);
|
|
124
|
+
return row || { profile_id: profileId, current_streak: 0, last_streak_date: null, longest_streak: 0, banked_mins: 0 };
|
|
125
|
+
}
|
|
126
|
+
export function upsertStreak(db, streak) {
|
|
127
|
+
db.prepare(`
|
|
128
|
+
INSERT INTO streaks (profile_id, current_streak, last_streak_date, longest_streak, banked_mins)
|
|
129
|
+
VALUES (?, ?, ?, ?, ?)
|
|
130
|
+
ON CONFLICT(profile_id) DO UPDATE SET
|
|
131
|
+
current_streak = excluded.current_streak,
|
|
132
|
+
last_streak_date = excluded.last_streak_date,
|
|
133
|
+
longest_streak = excluded.longest_streak,
|
|
134
|
+
banked_mins = excluded.banked_mins
|
|
135
|
+
`).run(streak.profile_id, streak.current_streak, streak.last_streak_date, streak.longest_streak, streak.banked_mins);
|
|
136
|
+
}
|
|
137
|
+
export function insertWeeklyReport(db, report) {
|
|
138
|
+
db.prepare(`
|
|
139
|
+
INSERT OR REPLACE INTO weekly_reports
|
|
140
|
+
(profile_id, week_start, total_mins, days_active, avg_session_mins, longest_session_mins, report_text, sent_at)
|
|
141
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
142
|
+
`).run(report.profile_id, report.week_start, report.total_mins, report.days_active, report.avg_session_mins, report.longest_session_mins, report.report_text, report.sent_at);
|
|
143
|
+
}
|
|
144
|
+
export function getSessionsForWeek(db, profileId, weekStart, weekEnd) {
|
|
145
|
+
return db.prepare(`
|
|
146
|
+
SELECT * FROM sessions WHERE profile_id = ? AND started_at >= ? AND started_at <= ? AND ended_at IS NOT NULL
|
|
147
|
+
`).all(profileId, weekStart, weekEnd);
|
|
148
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { getStreak, upsertStreak } from './db.js';
|
|
2
|
+
function yesterday(dateStr) {
|
|
3
|
+
const d = new Date(dateStr);
|
|
4
|
+
d.setDate(d.getDate() - 1);
|
|
5
|
+
return d.toISOString().split('T')[0];
|
|
6
|
+
}
|
|
7
|
+
export function updateStreak(db, config, profileId, today, dailyMinsUsed, dailyLimit) {
|
|
8
|
+
const streak = getStreak(db, profileId);
|
|
9
|
+
const withinLimit = dailyMinsUsed <= dailyLimit;
|
|
10
|
+
let newStreak = streak.current_streak;
|
|
11
|
+
let bankedMins = streak.banked_mins;
|
|
12
|
+
if (withinLimit && streak.last_streak_date) {
|
|
13
|
+
const expectedYesterday = yesterday(today);
|
|
14
|
+
if (streak.last_streak_date === expectedYesterday) {
|
|
15
|
+
// Continuing streak
|
|
16
|
+
newStreak = streak.current_streak + 1;
|
|
17
|
+
}
|
|
18
|
+
else if (streak.last_streak_date === today) {
|
|
19
|
+
// Already updated today
|
|
20
|
+
newStreak = streak.current_streak;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
// Broke streak
|
|
24
|
+
newStreak = 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else if (withinLimit) {
|
|
28
|
+
newStreak = 1;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Over limit, reset streak
|
|
32
|
+
newStreak = 0;
|
|
33
|
+
}
|
|
34
|
+
// Bank unused time if configured
|
|
35
|
+
if (config.bankUnusedTime && withinLimit) {
|
|
36
|
+
const saved = dailyLimit - dailyMinsUsed;
|
|
37
|
+
if (saved > 0) {
|
|
38
|
+
bankedMins = streak.banked_mins + saved;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const longestStreak = Math.max(streak.longest_streak, newStreak);
|
|
42
|
+
const updated = {
|
|
43
|
+
profile_id: profileId,
|
|
44
|
+
current_streak: newStreak,
|
|
45
|
+
last_streak_date: today,
|
|
46
|
+
longest_streak: longestStreak,
|
|
47
|
+
banked_mins: bankedMins,
|
|
48
|
+
};
|
|
49
|
+
upsertStreak(db, updated);
|
|
50
|
+
let streakMessage;
|
|
51
|
+
if (newStreak >= 7) {
|
|
52
|
+
streakMessage = `🔥 ${newStreak} days in a row of staying within your limit! You're absolutely crushing it!`;
|
|
53
|
+
}
|
|
54
|
+
else if (newStreak >= 3) {
|
|
55
|
+
streakMessage = `🔥 Day ${newStreak} in a row of staying within your limit! Keep it up!`;
|
|
56
|
+
}
|
|
57
|
+
else if (newStreak === 1 && streak.current_streak === 0) {
|
|
58
|
+
streakMessage = `✨ Great job staying within your limit today! That's how streaks start!`;
|
|
59
|
+
}
|
|
60
|
+
return { currentStreak: newStreak, longestStreak, streakMessage, bankedMins };
|
|
61
|
+
}
|
|
62
|
+
export function getChildWeeklySummary(totalMins, streak, dailyLimit) {
|
|
63
|
+
const hours = Math.floor(totalMins / 60);
|
|
64
|
+
const mins = Math.round(totalMins % 60);
|
|
65
|
+
const timeStr = hours > 0 ? `${hours}h ${mins}min` : `${mins}min`;
|
|
66
|
+
let summary = `📊 This week you used AI for ${timeStr}.`;
|
|
67
|
+
if (streak.current_streak >= 5) {
|
|
68
|
+
summary += ` You're on a ${streak.current_streak}-day streak of staying within your limit — that's amazing self-regulation! 🏆`;
|
|
69
|
+
}
|
|
70
|
+
else if (streak.current_streak >= 3) {
|
|
71
|
+
summary += ` You're on a ${streak.current_streak}-day streak! 🔥 You're getting really good at wrapping up on time.`;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
summary += ` You're getting the hang of managing your AI time!`;
|
|
75
|
+
}
|
|
76
|
+
if (streak.banked_mins > 0) {
|
|
77
|
+
summary += ` You've banked ${Math.round(streak.banked_mins)} saved minutes for a future bonus day! 🌟`;
|
|
78
|
+
}
|
|
79
|
+
return summary;
|
|
80
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
import { loadConfig } from './config.js';
|
|
7
|
+
import { getDb, insertSession, endSession, getDailyUsage, getUsageRange, upsertDailyUsage, insertOverride, getStreak } from './db.js';
|
|
8
|
+
import { checkLimits, checkSessionCap } from './limits.js';
|
|
9
|
+
import { updateStreak } from './gamification.js';
|
|
10
|
+
import { sendWeeklyReport, buildDailySummary, sendTelegram } from './reports.js';
|
|
11
|
+
const config = loadConfig();
|
|
12
|
+
const db = getDb(config.dbPath);
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: 'screen-time-mcp',
|
|
15
|
+
version: '0.1.0',
|
|
16
|
+
});
|
|
17
|
+
// ─── start_session ───────────────────────────────────────────────────────────
|
|
18
|
+
server.tool('start_session', 'Call at the start of every AI session. Checks if the session is allowed based on configured limits.', {
|
|
19
|
+
profileId: z.string().describe('Child profile ID'),
|
|
20
|
+
}, async ({ profileId }) => {
|
|
21
|
+
const limitCheck = checkLimits(db, config, profileId);
|
|
22
|
+
if (!limitCheck.allowed) {
|
|
23
|
+
return {
|
|
24
|
+
content: [{
|
|
25
|
+
type: 'text',
|
|
26
|
+
text: JSON.stringify({
|
|
27
|
+
allowed: false,
|
|
28
|
+
reason: limitCheck.reason,
|
|
29
|
+
message: limitCheck.message,
|
|
30
|
+
minutesUsedToday: limitCheck.minutesUsedToday,
|
|
31
|
+
minutesRemainingToday: limitCheck.minutesRemainingToday,
|
|
32
|
+
}),
|
|
33
|
+
}],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const sessionId = randomUUID();
|
|
37
|
+
const now = new Date().toISOString();
|
|
38
|
+
insertSession(db, { id: sessionId, profile_id: profileId, started_at: now });
|
|
39
|
+
return {
|
|
40
|
+
content: [{
|
|
41
|
+
type: 'text',
|
|
42
|
+
text: JSON.stringify({
|
|
43
|
+
allowed: true,
|
|
44
|
+
sessionId,
|
|
45
|
+
minutesRemainingToday: limitCheck.minutesRemainingToday,
|
|
46
|
+
minutesUsedToday: limitCheck.minutesUsedToday,
|
|
47
|
+
weeklyMinsRemaining: limitCheck.weeklyMinsRemaining,
|
|
48
|
+
warningMessage: limitCheck.warning,
|
|
49
|
+
}),
|
|
50
|
+
}],
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
// ─── check_limits ─────────────────────────────────────────────────────────────
|
|
54
|
+
server.tool('check_limits', 'Call periodically (every message) and at session end to check if limits are still within bounds.', {
|
|
55
|
+
profileId: z.string().describe('Child profile ID'),
|
|
56
|
+
sessionId: z.string().describe('Active session ID from start_session'),
|
|
57
|
+
}, async ({ profileId, sessionId }) => {
|
|
58
|
+
const limitCheck = checkLimits(db, config, profileId, sessionId);
|
|
59
|
+
const sessionCheck = checkSessionCap(db, config, sessionId);
|
|
60
|
+
const withinLimits = limitCheck.allowed && !sessionCheck.overCap;
|
|
61
|
+
let warning = limitCheck.warning;
|
|
62
|
+
if (sessionCheck.overCap) {
|
|
63
|
+
warning = sessionCheck.message;
|
|
64
|
+
}
|
|
65
|
+
if (!limitCheck.allowed) {
|
|
66
|
+
warning = limitCheck.message;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
content: [{
|
|
70
|
+
type: 'text',
|
|
71
|
+
text: JSON.stringify({
|
|
72
|
+
withinLimits,
|
|
73
|
+
minutesUsedToday: limitCheck.minutesUsedToday,
|
|
74
|
+
minutesRemaining: limitCheck.minutesRemainingToday,
|
|
75
|
+
weeklyMinsUsed: limitCheck.weeklyMinsUsed,
|
|
76
|
+
weeklyMinsRemaining: limitCheck.weeklyMinsRemaining,
|
|
77
|
+
sessionDurationMins: sessionCheck.durationMins,
|
|
78
|
+
warning,
|
|
79
|
+
}),
|
|
80
|
+
}],
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
// ─── end_session ──────────────────────────────────────────────────────────────
|
|
84
|
+
server.tool('end_session', 'Call when the session ends. Records duration, updates daily usage, and returns streak info.', {
|
|
85
|
+
profileId: z.string().describe('Child profile ID'),
|
|
86
|
+
sessionId: z.string().describe('Session ID from start_session'),
|
|
87
|
+
messageCount: z.number().optional().describe('Number of messages exchanged'),
|
|
88
|
+
}, async ({ profileId, sessionId, messageCount }) => {
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
const today = new Date().toISOString().split('T')[0];
|
|
91
|
+
const sessionCheck = checkSessionCap(db, config, sessionId);
|
|
92
|
+
const durationMins = sessionCheck.durationMins;
|
|
93
|
+
endSession(db, sessionId, now, durationMins, messageCount || 0);
|
|
94
|
+
upsertDailyUsage(db, profileId, today, durationMins, 1);
|
|
95
|
+
const dailyUsage = getDailyUsage(db, profileId, today);
|
|
96
|
+
const streakResult = updateStreak(db, config, profileId, today, dailyUsage.total_mins, config.dailyLimitMins);
|
|
97
|
+
// Optional daily Telegram summary
|
|
98
|
+
if (config.dailyReport && config.telegramBotToken && config.telegramParentChatId) {
|
|
99
|
+
const summary = buildDailySummary(config, config.childName, dailyUsage.total_mins, dailyUsage.session_count);
|
|
100
|
+
sendTelegram(config.telegramBotToken, config.telegramParentChatId, summary).catch(() => { });
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
content: [{
|
|
104
|
+
type: 'text',
|
|
105
|
+
text: JSON.stringify({
|
|
106
|
+
sessionDuration_mins: Math.round(durationMins * 10) / 10,
|
|
107
|
+
totalToday_mins: Math.round(dailyUsage.total_mins * 10) / 10,
|
|
108
|
+
streak: streakResult.currentStreak,
|
|
109
|
+
streakMessage: streakResult.streakMessage,
|
|
110
|
+
bankedMins: streakResult.bankedMins,
|
|
111
|
+
childSummary: streakResult.streakMessage || `Session done! ${Math.round(durationMins)} minutes today.`,
|
|
112
|
+
}),
|
|
113
|
+
}],
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
// ─── get_usage ────────────────────────────────────────────────────────────────
|
|
117
|
+
server.tool('get_usage', 'Parent tool: get usage stats for a profile over recent days.', {
|
|
118
|
+
profileId: z.string().describe('Child profile ID'),
|
|
119
|
+
days: z.number().optional().describe('Number of days to look back (default: 7)'),
|
|
120
|
+
}, async ({ profileId, days = 7 }) => {
|
|
121
|
+
const today = new Date();
|
|
122
|
+
const from = new Date(today);
|
|
123
|
+
from.setDate(today.getDate() - days + 1);
|
|
124
|
+
const fromDate = from.toISOString().split('T')[0];
|
|
125
|
+
const toDate = today.toISOString().split('T')[0];
|
|
126
|
+
const entries = getUsageRange(db, profileId, fromDate, toDate);
|
|
127
|
+
const totalMins = entries.reduce((sum, e) => sum + e.total_mins, 0);
|
|
128
|
+
const avgDailyMins = entries.length > 0 ? totalMins / days : 0;
|
|
129
|
+
const streak = getStreak(db, profileId);
|
|
130
|
+
return {
|
|
131
|
+
content: [{
|
|
132
|
+
type: 'text',
|
|
133
|
+
text: JSON.stringify({
|
|
134
|
+
entries,
|
|
135
|
+
totalMins: Math.round(totalMins * 10) / 10,
|
|
136
|
+
avgDailyMins: Math.round(avgDailyMins * 10) / 10,
|
|
137
|
+
currentStreak: streak.current_streak,
|
|
138
|
+
longestStreak: streak.longest_streak,
|
|
139
|
+
bankedMins: streak.banked_mins,
|
|
140
|
+
}),
|
|
141
|
+
}],
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
// ─── update_limits ────────────────────────────────────────────────────────────
|
|
145
|
+
server.tool('update_limits', 'Parent tool: update limit settings for a profile at runtime.', {
|
|
146
|
+
profileId: z.string().describe('Child profile ID'),
|
|
147
|
+
settings: z.object({
|
|
148
|
+
dailyLimitMins: z.number().optional(),
|
|
149
|
+
sessionLimitMins: z.number().optional(),
|
|
150
|
+
weeklyBudgetMins: z.number().optional(),
|
|
151
|
+
coolDownMins: z.number().optional(),
|
|
152
|
+
}).describe('Settings to update (only provided fields are updated)'),
|
|
153
|
+
}, async ({ profileId, settings }) => {
|
|
154
|
+
// Runtime config mutation (persists for current process)
|
|
155
|
+
if (settings.dailyLimitMins !== undefined)
|
|
156
|
+
config.dailyLimitMins = settings.dailyLimitMins;
|
|
157
|
+
if (settings.sessionLimitMins !== undefined)
|
|
158
|
+
config.sessionLimitMins = settings.sessionLimitMins;
|
|
159
|
+
if (settings.weeklyBudgetMins !== undefined)
|
|
160
|
+
config.weeklyBudgetMins = settings.weeklyBudgetMins;
|
|
161
|
+
if (settings.coolDownMins !== undefined)
|
|
162
|
+
config.coolDownMins = settings.coolDownMins;
|
|
163
|
+
return {
|
|
164
|
+
content: [{
|
|
165
|
+
type: 'text',
|
|
166
|
+
text: JSON.stringify({
|
|
167
|
+
updated: true,
|
|
168
|
+
profileId,
|
|
169
|
+
currentLimits: {
|
|
170
|
+
dailyLimitMins: config.dailyLimitMins,
|
|
171
|
+
sessionLimitMins: config.sessionLimitMins,
|
|
172
|
+
weeklyBudgetMins: config.weeklyBudgetMins,
|
|
173
|
+
coolDownMins: config.coolDownMins,
|
|
174
|
+
},
|
|
175
|
+
note: 'Changes are in-memory only. Update your .env file to persist across restarts.',
|
|
176
|
+
}),
|
|
177
|
+
}],
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
// ─── override_limits ──────────────────────────────────────────────────────────
|
|
181
|
+
server.tool('override_limits', 'Parent tool: temporarily extend daily limit with a PIN.', {
|
|
182
|
+
profileId: z.string().describe('Child profile ID'),
|
|
183
|
+
pin: z.string().describe('Parent PIN'),
|
|
184
|
+
extraMinutes: z.number().describe('How many extra minutes to grant'),
|
|
185
|
+
}, async ({ profileId, pin, extraMinutes }) => {
|
|
186
|
+
if (pin !== config.parentOverridePin) {
|
|
187
|
+
return {
|
|
188
|
+
content: [{
|
|
189
|
+
type: 'text',
|
|
190
|
+
text: JSON.stringify({ granted: false, reason: 'Invalid PIN' }),
|
|
191
|
+
}],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const now = new Date();
|
|
195
|
+
const expiresAt = new Date(now.getTime() + extraMinutes * 60 * 1000).toISOString();
|
|
196
|
+
insertOverride(db, {
|
|
197
|
+
id: randomUUID(),
|
|
198
|
+
profile_id: profileId,
|
|
199
|
+
granted_at: now.toISOString(),
|
|
200
|
+
expires_at: expiresAt,
|
|
201
|
+
extra_minutes: extraMinutes,
|
|
202
|
+
});
|
|
203
|
+
return {
|
|
204
|
+
content: [{
|
|
205
|
+
type: 'text',
|
|
206
|
+
text: JSON.stringify({
|
|
207
|
+
granted: true,
|
|
208
|
+
extraMinutes,
|
|
209
|
+
expiresAt,
|
|
210
|
+
message: `Override granted! ${extraMinutes} extra minutes until ${new Date(expiresAt).toLocaleTimeString()}.`,
|
|
211
|
+
}),
|
|
212
|
+
}],
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
// ─── send_report ──────────────────────────────────────────────────────────────
|
|
216
|
+
server.tool('send_report', 'Parent tool: generate and send the weekly usage report via Telegram.', {
|
|
217
|
+
profileId: z.string().describe('Child profile ID'),
|
|
218
|
+
}, async ({ profileId }) => {
|
|
219
|
+
const { reportText, sent } = await sendWeeklyReport(db, config, profileId);
|
|
220
|
+
return {
|
|
221
|
+
content: [{
|
|
222
|
+
type: 'text',
|
|
223
|
+
text: JSON.stringify({
|
|
224
|
+
sent,
|
|
225
|
+
reportText,
|
|
226
|
+
telegramConfigured: !!(config.telegramBotToken && config.telegramParentChatId),
|
|
227
|
+
}),
|
|
228
|
+
}],
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
// ─── Start server ─────────────────────────────────────────────────────────────
|
|
232
|
+
async function main() {
|
|
233
|
+
const transport = new StdioServerTransport();
|
|
234
|
+
await server.connect(transport);
|
|
235
|
+
console.error('screen-time-mcp running on stdio');
|
|
236
|
+
}
|
|
237
|
+
main().catch(console.error);
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { getDailyUsage, getWeeklyTotal, getLastEndedSession, getActiveOverride, getSession } from './db.js';
|
|
2
|
+
function toMinutes(timeStr) {
|
|
3
|
+
const [h, m] = timeStr.split(':').map(Number);
|
|
4
|
+
return h * 60 + m;
|
|
5
|
+
}
|
|
6
|
+
function nowMinutes() {
|
|
7
|
+
const now = new Date();
|
|
8
|
+
return now.getHours() * 60 + now.getMinutes();
|
|
9
|
+
}
|
|
10
|
+
function isInQuietHour(qh) {
|
|
11
|
+
const nowMins = nowMinutes();
|
|
12
|
+
const start = toMinutes(qh.start);
|
|
13
|
+
const end = toMinutes(qh.end);
|
|
14
|
+
if (start > end) {
|
|
15
|
+
// crosses midnight: e.g. 21:00 - 07:00
|
|
16
|
+
return nowMins >= start || nowMins < end;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
return nowMins >= start && nowMins < end;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function isMorningQuietHour(qh) {
|
|
23
|
+
// Morning quiet hours don't cross midnight
|
|
24
|
+
const start = toMinutes(qh.start);
|
|
25
|
+
const end = toMinutes(qh.end);
|
|
26
|
+
return start < end && start < 12 * 60;
|
|
27
|
+
}
|
|
28
|
+
function getActiveQuietHour(quietHours) {
|
|
29
|
+
for (const qh of quietHours) {
|
|
30
|
+
if (isInQuietHour(qh))
|
|
31
|
+
return qh;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
function getTodayDate() {
|
|
36
|
+
return new Date().toISOString().split('T')[0];
|
|
37
|
+
}
|
|
38
|
+
function getWeekBounds() {
|
|
39
|
+
const now = new Date();
|
|
40
|
+
const day = now.getDay(); // 0=Sun
|
|
41
|
+
const diffToMonday = (day + 6) % 7;
|
|
42
|
+
const monday = new Date(now);
|
|
43
|
+
monday.setDate(now.getDate() - diffToMonday);
|
|
44
|
+
const sunday = new Date(monday);
|
|
45
|
+
sunday.setDate(monday.getDate() + 6);
|
|
46
|
+
return {
|
|
47
|
+
weekStart: monday.toISOString().split('T')[0],
|
|
48
|
+
weekEnd: sunday.toISOString().split('T')[0],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function checkLimits(db, config, profileId, sessionId) {
|
|
52
|
+
const now = new Date().toISOString();
|
|
53
|
+
const today = getTodayDate();
|
|
54
|
+
const { weekStart, weekEnd } = getWeekBounds();
|
|
55
|
+
const dailyUsage = getDailyUsage(db, profileId, today);
|
|
56
|
+
const weeklyUsed = getWeeklyTotal(db, profileId, weekStart, weekEnd);
|
|
57
|
+
// Add current session time if checking mid-session
|
|
58
|
+
let currentSessionMins = 0;
|
|
59
|
+
if (sessionId) {
|
|
60
|
+
const session = getSession(db, sessionId);
|
|
61
|
+
if (session && !session.ended_at) {
|
|
62
|
+
const startedAt = new Date(session.started_at);
|
|
63
|
+
currentSessionMins = (Date.now() - startedAt.getTime()) / 60000;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const totalTodayMins = dailyUsage.total_mins + currentSessionMins;
|
|
67
|
+
const totalWeeklyMins = weeklyUsed + currentSessionMins;
|
|
68
|
+
// Check for active override
|
|
69
|
+
const override = getActiveOverride(db, profileId, now);
|
|
70
|
+
const overrideBonus = override ? override.extra_minutes : 0;
|
|
71
|
+
const effectiveDailyLimit = config.dailyLimitMins + overrideBonus;
|
|
72
|
+
const minutesUsedToday = totalTodayMins;
|
|
73
|
+
const minutesRemainingToday = Math.max(0, effectiveDailyLimit - totalTodayMins);
|
|
74
|
+
const weeklyMinsUsed = totalWeeklyMins;
|
|
75
|
+
const weeklyMinsRemaining = Math.max(0, config.weeklyBudgetMins - totalWeeklyMins);
|
|
76
|
+
// 1. Quiet hours check
|
|
77
|
+
const activeQH = getActiveQuietHour(config.quietHours);
|
|
78
|
+
if (activeQH) {
|
|
79
|
+
const isMorning = isMorningQuietHour(activeQH);
|
|
80
|
+
if (isMorning) {
|
|
81
|
+
return {
|
|
82
|
+
allowed: false,
|
|
83
|
+
reason: 'quiet_hours_morning',
|
|
84
|
+
message: `Good morning! AI time starts at ${activeQH.end} today. Go have breakfast 🥞`,
|
|
85
|
+
minutesUsedToday,
|
|
86
|
+
minutesRemainingToday,
|
|
87
|
+
weeklyMinsUsed,
|
|
88
|
+
weeklyMinsRemaining,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
return {
|
|
93
|
+
allowed: false,
|
|
94
|
+
reason: 'quiet_hours_night',
|
|
95
|
+
message: `It's getting late! AI time is done for tonight. Sleep is when your brain locks in everything you learned today 🧠`,
|
|
96
|
+
minutesUsedToday,
|
|
97
|
+
minutesRemainingToday,
|
|
98
|
+
weeklyMinsUsed,
|
|
99
|
+
weeklyMinsRemaining,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 2. Daily cap check
|
|
104
|
+
if (totalTodayMins >= effectiveDailyLimit) {
|
|
105
|
+
return {
|
|
106
|
+
allowed: false,
|
|
107
|
+
reason: 'daily_cap',
|
|
108
|
+
message: `We've hit your daily AI time for today! 🌙 Your brain has done a lot today — rest is how it grows. See you tomorrow!`,
|
|
109
|
+
minutesUsedToday,
|
|
110
|
+
minutesRemainingToday: 0,
|
|
111
|
+
weeklyMinsUsed,
|
|
112
|
+
weeklyMinsRemaining,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// 3. Weekly budget check (soft block — still allowed but warn)
|
|
116
|
+
if (totalWeeklyMins >= config.weeklyBudgetMins) {
|
|
117
|
+
return {
|
|
118
|
+
allowed: true, // soft block: allowed but warned
|
|
119
|
+
warning: `You've used your full weekly AI budget (${config.weeklyBudgetMins} min). Try to keep it brief — maybe just one quick question? 🌟`,
|
|
120
|
+
minutesUsedToday,
|
|
121
|
+
minutesRemainingToday,
|
|
122
|
+
weeklyMinsUsed,
|
|
123
|
+
weeklyMinsRemaining: 0,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// 4. Cool-down check (only at session start, not mid-session)
|
|
127
|
+
if (!sessionId) {
|
|
128
|
+
const lastSession = getLastEndedSession(db, profileId);
|
|
129
|
+
if (lastSession && lastSession.ended_at) {
|
|
130
|
+
const lastEndMs = new Date(lastSession.ended_at).getTime();
|
|
131
|
+
const minsAgo = (Date.now() - lastEndMs) / 60000;
|
|
132
|
+
if (minsAgo < config.coolDownMins) {
|
|
133
|
+
const minsLeft = Math.ceil(config.coolDownMins - minsAgo);
|
|
134
|
+
return {
|
|
135
|
+
allowed: false,
|
|
136
|
+
reason: 'cool_down',
|
|
137
|
+
message: `Your brain needs a short break! ☕ AI time resumes in ${minsLeft} minute${minsLeft !== 1 ? 's' : ''}. Go stretch, grab a snack, or look out the window for a bit.`,
|
|
138
|
+
minutesUsedToday,
|
|
139
|
+
minutesRemainingToday,
|
|
140
|
+
weeklyMinsUsed,
|
|
141
|
+
weeklyMinsRemaining,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// 5. 5-minute warning before daily cutoff
|
|
147
|
+
let warning;
|
|
148
|
+
if (minutesRemainingToday <= 5 && minutesRemainingToday > 0) {
|
|
149
|
+
warning = `Just so you know — we have about ${Math.ceil(minutesRemainingToday)} minute${minutesRemainingToday <= 1 ? '' : 's'} left for today. Anything important you want to wrap up? ⏰`;
|
|
150
|
+
}
|
|
151
|
+
else if (minutesRemainingToday <= 10) {
|
|
152
|
+
warning = `About ${Math.ceil(minutesRemainingToday)} minutes of AI time left today. Start wrapping up! 🕐`;
|
|
153
|
+
}
|
|
154
|
+
// Session cap warning (if mid-session)
|
|
155
|
+
if (sessionId && currentSessionMins >= config.sessionLimitMins - 5 && currentSessionMins < config.sessionLimitMins) {
|
|
156
|
+
const sessionMinsLeft = config.sessionLimitMins - currentSessionMins;
|
|
157
|
+
warning = warning || `This session is running long — about ${Math.ceil(sessionMinsLeft)} minute${sessionMinsLeft <= 1 ? '' : 's'} left in this session. Let's start wrapping up! 🎯`;
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
allowed: true,
|
|
161
|
+
minutesUsedToday,
|
|
162
|
+
minutesRemainingToday,
|
|
163
|
+
weeklyMinsUsed,
|
|
164
|
+
weeklyMinsRemaining,
|
|
165
|
+
warning,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export function checkSessionCap(db, config, sessionId) {
|
|
169
|
+
const session = getSession(db, sessionId);
|
|
170
|
+
if (!session)
|
|
171
|
+
return { overCap: false, durationMins: 0 };
|
|
172
|
+
const startedAt = new Date(session.started_at);
|
|
173
|
+
const durationMins = (Date.now() - startedAt.getTime()) / 60000;
|
|
174
|
+
if (durationMins >= config.sessionLimitMins) {
|
|
175
|
+
return {
|
|
176
|
+
overCap: true,
|
|
177
|
+
durationMins,
|
|
178
|
+
message: `This session has reached the ${config.sessionLimitMins}-minute limit! 🌟 You've done great work. Time to take a break and let it sink in. Come back in ${config.coolDownMins} minutes!`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { overCap: false, durationMins };
|
|
182
|
+
}
|
package/dist/reports.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { getUsageRange, getSessionsForWeek, insertWeeklyReport } from './db.js';
|
|
2
|
+
function formatMins(mins) {
|
|
3
|
+
const h = Math.floor(mins / 60);
|
|
4
|
+
const m = Math.round(mins % 60);
|
|
5
|
+
return h > 0 ? `${h}h ${m}min` : `${m}min`;
|
|
6
|
+
}
|
|
7
|
+
function getWeekBounds(weekStart) {
|
|
8
|
+
if (weekStart) {
|
|
9
|
+
const end = new Date(weekStart);
|
|
10
|
+
end.setDate(end.getDate() + 6);
|
|
11
|
+
return { weekStart, weekEnd: end.toISOString().split('T')[0] };
|
|
12
|
+
}
|
|
13
|
+
const now = new Date();
|
|
14
|
+
const day = now.getDay();
|
|
15
|
+
const diffToMonday = (day + 6) % 7;
|
|
16
|
+
const monday = new Date(now);
|
|
17
|
+
monday.setDate(now.getDate() - diffToMonday);
|
|
18
|
+
const sunday = new Date(monday);
|
|
19
|
+
sunday.setDate(monday.getDate() + 6);
|
|
20
|
+
return {
|
|
21
|
+
weekStart: monday.toISOString().split('T')[0],
|
|
22
|
+
weekEnd: sunday.toISOString().split('T')[0],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function buildWeeklyReport(db, config, profileId, childName) {
|
|
26
|
+
const { weekStart, weekEnd } = getWeekBounds();
|
|
27
|
+
const entries = getUsageRange(db, profileId, weekStart, weekEnd);
|
|
28
|
+
const sessions = getSessionsForWeek(db, profileId, weekStart + 'T00:00:00.000Z', weekEnd + 'T23:59:59.999Z');
|
|
29
|
+
const totalMins = entries.reduce((sum, e) => sum + e.total_mins, 0);
|
|
30
|
+
const daysActive = entries.filter(e => e.total_mins > 0).length;
|
|
31
|
+
const sessionCount = sessions.length;
|
|
32
|
+
const avgSessionMins = sessionCount > 0 ? totalMins / sessionCount : 0;
|
|
33
|
+
const longestSession = sessions.reduce((max, s) => Math.max(max, s.duration_mins || 0), 0);
|
|
34
|
+
const daysWithinLimit = entries.filter(e => e.total_mins <= config.dailyLimitMins).length;
|
|
35
|
+
// Find most active day
|
|
36
|
+
const mostActiveEntry = entries.reduce((max, e) => e.total_mins > (max?.total_mins || 0) ? e : max, entries[0]);
|
|
37
|
+
// Week range display
|
|
38
|
+
const start = new Date(weekStart);
|
|
39
|
+
const end = new Date(weekEnd);
|
|
40
|
+
const months = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
41
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
42
|
+
const weekRange = `${months[start.getMonth()]} ${start.getDate()}–${end.getDate()}`;
|
|
43
|
+
let report = `📅 ${childName}'s AI week — ${weekRange}\n\n`;
|
|
44
|
+
report += `Total time: ${formatMins(totalMins)} (daily avg: ${formatMins(totalMins / 7)})\n`;
|
|
45
|
+
report += `Daily limit: ${config.dailyLimitMins} min — stayed within limit ${daysWithinLimit}/7 days ${daysWithinLimit >= 6 ? '✅' : daysWithinLimit >= 4 ? '🆗' : '⚠️'}\n`;
|
|
46
|
+
report += `Sessions: ${sessionCount} total, avg ${formatMins(avgSessionMins)} each\n\n`;
|
|
47
|
+
if (mostActiveEntry) {
|
|
48
|
+
const overBy = mostActiveEntry.total_mins - config.dailyLimitMins;
|
|
49
|
+
const dayName = new Date(mostActiveEntry.date).toLocaleDateString('en-US', { weekday: 'long' });
|
|
50
|
+
if (overBy > 0) {
|
|
51
|
+
report += `Most active: ${dayName} (${formatMins(mostActiveEntry.total_mins)} — went over by ${formatMins(overBy)})\n`;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
report += `Most active: ${dayName} (${formatMins(mostActiveEntry.total_mins)})\n`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
report += `\nThis week's insight: ${childName} is averaging ${formatMins(avgSessionMins)} per session across ${sessionCount} sessions. `;
|
|
58
|
+
if (avgSessionMins > 20) {
|
|
59
|
+
report += `Longer sessions suggest focused work — great for deep learning! Consider short breaks between sessions.\n`;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
report += `Short sessions suggest good self-regulation habits!\n`;
|
|
63
|
+
}
|
|
64
|
+
const weeklyMinsDown = 0; // Would need prior week data for comparison
|
|
65
|
+
report += `\nWeekly budget: ${formatMins(totalMins)} of ${formatMins(config.weeklyBudgetMins)} used (${Math.round((totalMins / config.weeklyBudgetMins) * 100)}%)`;
|
|
66
|
+
return report;
|
|
67
|
+
}
|
|
68
|
+
export function buildDailySummary(config, childName, totalMins, sessionCount) {
|
|
69
|
+
const withinLimit = totalMins <= config.dailyLimitMins;
|
|
70
|
+
const status = withinLimit ? '✅ Within limit' : `⚠️ Over by ${formatMins(totalMins - config.dailyLimitMins)}`;
|
|
71
|
+
return `📊 ${childName}'s AI time today: ${formatMins(totalMins)}/${formatMins(config.dailyLimitMins)}. ${sessionCount} session${sessionCount !== 1 ? 's' : ''}. ${status}`;
|
|
72
|
+
}
|
|
73
|
+
export async function sendTelegram(botToken, chatId, text) {
|
|
74
|
+
try {
|
|
75
|
+
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
|
76
|
+
const response = await fetch(url, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' }),
|
|
80
|
+
});
|
|
81
|
+
return response.ok;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function sendWeeklyReport(db, config, profileId) {
|
|
88
|
+
const reportText = buildWeeklyReport(db, config, profileId, config.childName);
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
let sent = false;
|
|
91
|
+
if (config.telegramBotToken && config.telegramParentChatId) {
|
|
92
|
+
sent = await sendTelegram(config.telegramBotToken, config.telegramParentChatId, reportText);
|
|
93
|
+
}
|
|
94
|
+
const { weekStart, weekEnd } = getWeekBounds();
|
|
95
|
+
const entries = getUsageRange(db, profileId, weekStart, weekEnd);
|
|
96
|
+
const sessions = getSessionsForWeek(db, profileId, weekStart + 'T00:00:00.000Z', weekEnd + 'T23:59:59.999Z');
|
|
97
|
+
const totalMins = entries.reduce((sum, e) => sum + e.total_mins, 0);
|
|
98
|
+
const daysActive = entries.filter(e => e.total_mins > 0).length;
|
|
99
|
+
const sessionCount = sessions.length;
|
|
100
|
+
const avgSessionMins = sessionCount > 0 ? totalMins / sessionCount : 0;
|
|
101
|
+
const longestSession = sessions.reduce((max, s) => Math.max(max, s.duration_mins || 0), 0);
|
|
102
|
+
insertWeeklyReport(db, {
|
|
103
|
+
profile_id: profileId,
|
|
104
|
+
week_start: weekStart,
|
|
105
|
+
total_mins: totalMins,
|
|
106
|
+
days_active: daysActive,
|
|
107
|
+
avg_session_mins: avgSessionMins,
|
|
108
|
+
longest_session_mins: longestSession,
|
|
109
|
+
report_text: reportText,
|
|
110
|
+
sent_at: sent ? now : null,
|
|
111
|
+
});
|
|
112
|
+
return { reportText, sent };
|
|
113
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gonzih/screen-time-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server enforcing AI usage limits for children — daily caps, quiet hours, weekly budgets, cool-down periods, gamified streaks, parent override",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Gonzih",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"screen-time-mcp": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/index.ts",
|
|
15
|
+
"start": "node dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
19
|
+
"better-sqlite3": "^9.4.3"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/better-sqlite3": "^7.6.8",
|
|
23
|
+
"@types/node": "^20.0.0",
|
|
24
|
+
"tsx": "^4.7.0",
|
|
25
|
+
"typescript": "^5.4.0"
|
|
26
|
+
},
|
|
27
|
+
"files": ["dist"]
|
|
28
|
+
}
|