2ndbrain 2026.1.33 → 2026.1.35
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/.claude/settings.local.json +6 -1
- package/db/migrations/002_scheduled_tasks.sql +25 -0
- package/doc/SPEC.md +79 -1
- package/package.json +9 -2
- package/skills/project-manage/SKILL.md +80 -0
- package/skills/scheduler/SKILL.md +207 -0
- package/src/config.js +16 -3
- package/src/hooks/lifecycle.js +5 -0
- package/src/index.js +20 -0
- package/src/scheduler/worker.js +320 -0
- package/src/telegram/commands.js +17 -1
- package/src/web/server.js +2 -3
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\\\\migrations\"\" 2>/dev/null || echo \"No migrations directory \")",
|
|
15
15
|
"Bash(node:*)",
|
|
16
16
|
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\src\\\\web\"\" 2>/dev/null || echo \"web directory not found \")",
|
|
17
|
-
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\"\" 2>/dev/null || echo \"Directory not accessible \")"
|
|
17
|
+
"Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\"\" 2>/dev/null || echo \"Directory not accessible \")",
|
|
18
|
+
"Bash(npm install:*)",
|
|
19
|
+
"mcp__dude__get_project_context",
|
|
20
|
+
"mcp__dude__create_project",
|
|
21
|
+
"mcp__dude__create_issue",
|
|
22
|
+
"mcp__dude__update_issue"
|
|
18
23
|
]
|
|
19
24
|
}
|
|
20
25
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- 002_scheduled_tasks.sql
|
|
2
|
+
-- Scheduled tasks table for the /schedule feature (cron-based task execution)
|
|
3
|
+
|
|
4
|
+
CREATE TABLE scheduled_tasks (
|
|
5
|
+
id SERIAL PRIMARY KEY,
|
|
6
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
7
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
8
|
+
chat_id TEXT NOT NULL, -- Telegram chat ID for delivering results
|
|
9
|
+
cron_expression TEXT NOT NULL, -- Standard 5-field cron: min hour dom month dow
|
|
10
|
+
task_prompt TEXT NOT NULL, -- Prompt sent to Claude when the task fires
|
|
11
|
+
description TEXT NOT NULL DEFAULT '', -- Human-readable description
|
|
12
|
+
timezone TEXT NOT NULL DEFAULT 'UTC', -- IANA timezone (e.g. 'America/New_York')
|
|
13
|
+
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
14
|
+
last_run_at TIMESTAMPTZ,
|
|
15
|
+
next_run_at TIMESTAMPTZ,
|
|
16
|
+
error_count INTEGER NOT NULL DEFAULT 0, -- Consecutive error count for backoff
|
|
17
|
+
last_error TEXT -- Last error message, if any
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX idx_scheduled_tasks_next_run
|
|
21
|
+
ON scheduled_tasks (next_run_at)
|
|
22
|
+
WHERE enabled = TRUE;
|
|
23
|
+
|
|
24
|
+
CREATE INDEX idx_scheduled_tasks_chat_id
|
|
25
|
+
ON scheduled_tasks (chat_id);
|
package/doc/SPEC.md
CHANGED
|
@@ -103,6 +103,7 @@ See also: §4 (Command Router), §8 (Attachment Store), §14 (Security Model),
|
|
|
103
103
|
|
|
104
104
|
Parses incoming messages and routes them:
|
|
105
105
|
- Messages prefixed with `/` are dispatched to slash command handlers
|
|
106
|
+
- Pass-through commands are forwarded to the Claude Bridge where the corresponding skill handles them (§12)
|
|
106
107
|
- All other messages are forwarded to the Claude Bridge (§5)
|
|
107
108
|
|
|
108
109
|
### Slash Commands
|
|
@@ -115,11 +116,17 @@ Parses incoming messages and routes them:
|
|
|
115
116
|
| `/restart` | none | Process restart | Confirmation prompt; on confirm, restart process |
|
|
116
117
|
| `/reboot` | none | OS reboot | Confirmation prompt; on confirm, `sudo reboot` |
|
|
117
118
|
| `/new` | none | Start new conversation session | Confirmation; old session preserved in logs |
|
|
119
|
+
| `/project` | optional description | Manage projects, specs, issues | Pass-through to Claude (§12, `project-manage` skill) |
|
|
120
|
+
| `/journal` | optional text | Record or search journal entries | Pass-through to Claude (§12, `journal` skill) |
|
|
121
|
+
| `/knowledge` | optional text | Manage knowledge graph | Pass-through to Claude (§12, `knowledge` skill) |
|
|
122
|
+
| `/schedule` | optional description | Create, list, or manage scheduled tasks | Pass-through to Claude (§12, `scheduler` skill) |
|
|
118
123
|
| `/help` | none | List available commands | Command list with descriptions |
|
|
119
124
|
|
|
120
125
|
`/restart` and `/reboot` require explicit confirmation reply before execution (see §14.4). `/new` starts a fresh Claude session; the previous session remains accessible in logs and search.
|
|
121
126
|
|
|
122
|
-
|
|
127
|
+
**Pass-through commands** (`/project`, `/journal`, `/knowledge`, `/schedule`) are recognized by the command router but not handled locally. Instead, the full message text is forwarded to the Claude Bridge as a conversational message. The corresponding Claude skill (§12) activates on the `/command` prefix and performs the database operations. Pass-through commands go through the normal message flow including rate limiting, conversation persistence, and lifecycle hooks.
|
|
128
|
+
|
|
129
|
+
See also: §5 (Claude Bridge), §12 (Claude Skills), §14.4 (Dangerous Operations)
|
|
123
130
|
|
|
124
131
|
|
|
125
132
|
## 5. Claude Bridge
|
|
@@ -315,6 +322,31 @@ Structured operational logs are written to the `system_logs` table with level (`
|
|
|
315
322
|
|
|
316
323
|
See also: §9 (Web Admin Server), §15 (Error Handling), §17.1 (`system_logs` table), §18 (rate limit and log variables)
|
|
317
324
|
|
|
325
|
+
### 10.2 Scheduler Worker
|
|
326
|
+
|
|
327
|
+
Background service that executes scheduled tasks defined in the `scheduled_tasks` table (§17.7).
|
|
328
|
+
|
|
329
|
+
| Property | Value |
|
|
330
|
+
|----------|-------|
|
|
331
|
+
| Poll interval | 60 seconds |
|
|
332
|
+
| Max tasks per tick | 3 |
|
|
333
|
+
| Auto-disable threshold | 5 consecutive errors |
|
|
334
|
+
| Pattern | `EmbeddingWorker` (setTimeout-based polling loop) |
|
|
335
|
+
|
|
336
|
+
**Execution flow:**
|
|
337
|
+
1. Poll `scheduled_tasks` for rows where `enabled = TRUE AND next_run_at <= NOW()`
|
|
338
|
+
2. Check `ClaudeBridge.isActive()` -- skip if a user message is being processed
|
|
339
|
+
3. Acquire Claude rate limiter slot
|
|
340
|
+
4. Invoke `claude-cli` with the task's `task_prompt` (fresh session per execution)
|
|
341
|
+
5. Deliver the response to the user's Telegram chat, prefixed with `[Scheduled: <description>]`
|
|
342
|
+
6. Compute `next_run_at` using `node-cron` and update the row
|
|
343
|
+
|
|
344
|
+
**Startup recovery:** On service start, recomputes `next_run_at` for all enabled tasks with NULL or past values. Tasks missed during downtime skip ahead to the next scheduled time (no retroactive execution).
|
|
345
|
+
|
|
346
|
+
**Error handling:** Consecutive failures increment `error_count`. After 5 failures, the task is auto-disabled and the user is notified via Telegram. Re-enabling via `/schedule` resets the error count.
|
|
347
|
+
|
|
348
|
+
See also: §12.2 (`scheduler` skill), §17.7 (`scheduled_tasks` table)
|
|
349
|
+
|
|
318
350
|
|
|
319
351
|
## 11. Knowledge Platform
|
|
320
352
|
|
|
@@ -403,6 +435,7 @@ $DATA_DIR/claude-runtime/.claude/skills/
|
|
|
403
435
|
knowledge/SKILL.md
|
|
404
436
|
project-manage/SKILL.md
|
|
405
437
|
recall/SKILL.md
|
|
438
|
+
scheduler/SKILL.md
|
|
406
439
|
system-ops/SKILL.md
|
|
407
440
|
```
|
|
408
441
|
|
|
@@ -507,6 +540,26 @@ Each skill is defined as a `SKILL.md` file containing natural-language instructi
|
|
|
507
540
|
|
|
508
541
|
**Restrictions:** This skill provides read-only system access only. It does NOT support restart, reboot, shutdown, or any mutating operations. Those remain exclusive to slash commands with confirmation prompts (§4).
|
|
509
542
|
|
|
543
|
+
#### `scheduler` -- Scheduled Task Management
|
|
544
|
+
|
|
545
|
+
| Property | Value |
|
|
546
|
+
|----------|-------|
|
|
547
|
+
| Name | `scheduler` |
|
|
548
|
+
| Description | Create, list, update, and delete scheduled tasks. Parse natural language schedules into cron expressions and persist them for automatic execution. |
|
|
549
|
+
| Invocation | User sends message with `/schedule` preamble, or automatic (Claude detects scheduling intent: "every morning remind me to...", "schedule a daily check on...") |
|
|
550
|
+
| Allowed tools | PostgreSQL MCP (`mcp__pg__query`) |
|
|
551
|
+
| Tables | `scheduled_tasks` |
|
|
552
|
+
|
|
553
|
+
**Behaviors:**
|
|
554
|
+
- **Create task:** Parse natural language into a 5-field cron expression and task prompt. `INSERT INTO scheduled_tasks (chat_id, cron_expression, task_prompt, description, timezone) VALUES ($1, $2, $3, $4, $5)`. The `chat_id` is obtained from the system prompt context.
|
|
555
|
+
- **List tasks:** `SELECT * FROM scheduled_tasks WHERE chat_id = $1 ORDER BY created_at DESC`
|
|
556
|
+
- **Update task:** `UPDATE scheduled_tasks SET cron_expression = $1, task_prompt = $2, description = $3, next_run_at = NULL, updated_at = NOW() WHERE id = $4 AND chat_id = $5`. Setting `next_run_at = NULL` forces the scheduler worker to recompute it.
|
|
557
|
+
- **Enable/disable:** `UPDATE scheduled_tasks SET enabled = $1, error_count = 0, updated_at = NOW() WHERE id = $2 AND chat_id = $3`
|
|
558
|
+
- **Delete task:** `DELETE FROM scheduled_tasks WHERE id = $1 AND chat_id = $2`
|
|
559
|
+
- **Search tasks:** `SELECT * FROM scheduled_tasks WHERE chat_id = $1 AND (description ILIKE $2 OR task_prompt ILIKE $2)`
|
|
560
|
+
|
|
561
|
+
**Execution model:** The skill only manages task definitions in the database. Actual execution is performed by the `SchedulerWorker` background service (§10.2), which polls the `scheduled_tasks` table every 60 seconds and invokes `claude-cli` for tasks whose `next_run_at` has passed. After 5 consecutive execution failures, a task is auto-disabled and the user is notified.
|
|
562
|
+
|
|
510
563
|
|
|
511
564
|
## 13. Claude Hooks
|
|
512
565
|
|
|
@@ -864,6 +917,31 @@ CREATE INDEX idx_embeddings_vector
|
|
|
864
917
|
ON embeddings USING hnsw (vector vector_cosine_ops);
|
|
865
918
|
```
|
|
866
919
|
|
|
920
|
+
### 17.7 Scheduled Tasks
|
|
921
|
+
|
|
922
|
+
```sql
|
|
923
|
+
CREATE TABLE scheduled_tasks (
|
|
924
|
+
id SERIAL PRIMARY KEY,
|
|
925
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
926
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
927
|
+
chat_id TEXT NOT NULL, -- Telegram chat ID for delivering results
|
|
928
|
+
cron_expression TEXT NOT NULL, -- Standard 5-field cron: min hour dom month dow
|
|
929
|
+
task_prompt TEXT NOT NULL, -- Prompt sent to Claude when the task fires
|
|
930
|
+
description TEXT NOT NULL DEFAULT '', -- Human-readable description
|
|
931
|
+
timezone TEXT NOT NULL DEFAULT 'UTC', -- IANA timezone (e.g. 'America/New_York')
|
|
932
|
+
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
933
|
+
last_run_at TIMESTAMPTZ,
|
|
934
|
+
next_run_at TIMESTAMPTZ, -- Pre-computed by SchedulerWorker
|
|
935
|
+
error_count INTEGER NOT NULL DEFAULT 0, -- Consecutive error count for backoff
|
|
936
|
+
last_error TEXT -- Last error message, if any
|
|
937
|
+
);
|
|
938
|
+
|
|
939
|
+
CREATE INDEX idx_scheduled_tasks_next_run
|
|
940
|
+
ON scheduled_tasks (next_run_at) WHERE enabled = TRUE;
|
|
941
|
+
CREATE INDEX idx_scheduled_tasks_chat_id
|
|
942
|
+
ON scheduled_tasks (chat_id);
|
|
943
|
+
```
|
|
944
|
+
|
|
867
945
|
|
|
868
946
|
## 18. Configuration Reference
|
|
869
947
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "2ndbrain",
|
|
3
|
-
"version": "2026.1.
|
|
3
|
+
"version": "2026.1.35",
|
|
4
4
|
"description": "Always-on Node.js service bridging Telegram messaging to Claude AI with knowledge graph, journal, project management, and semantic search.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
"type": "git",
|
|
16
16
|
"url": "git+https://github.com/fingerskier/2ndbrain.git"
|
|
17
17
|
},
|
|
18
|
-
"keywords": [
|
|
18
|
+
"keywords": [
|
|
19
|
+
"telegram",
|
|
20
|
+
"claude",
|
|
21
|
+
"ai",
|
|
22
|
+
"assistant",
|
|
23
|
+
"knowledge-graph"
|
|
24
|
+
],
|
|
19
25
|
"author": "",
|
|
20
26
|
"license": "MIT",
|
|
21
27
|
"bugs": {
|
|
@@ -25,6 +31,7 @@
|
|
|
25
31
|
"dependencies": {
|
|
26
32
|
"dotenv": "^16.4.7",
|
|
27
33
|
"express": "^4.21.2",
|
|
34
|
+
"node-cron": "^4.2.1",
|
|
28
35
|
"open": "^10.1.0",
|
|
29
36
|
"pg": "^8.13.1"
|
|
30
37
|
},
|
|
@@ -57,6 +57,86 @@ No other tools are permitted for this skill.
|
|
|
57
57
|
|
|
58
58
|
## Operations
|
|
59
59
|
|
|
60
|
+
### List All Projects (No Arguments)
|
|
61
|
+
|
|
62
|
+
When the user sends `/project` with no additional text, list all projects with status:
|
|
63
|
+
|
|
64
|
+
```sql
|
|
65
|
+
SELECT
|
|
66
|
+
p.id,
|
|
67
|
+
p.name,
|
|
68
|
+
p.created_at,
|
|
69
|
+
COUNT(i.id) FILTER (WHERE NOT i.completed) AS open_issues,
|
|
70
|
+
COUNT(i.id) FILTER (WHERE i.completed) AS closed_issues,
|
|
71
|
+
COUNT(DISTINCT s.id) AS spec_count
|
|
72
|
+
FROM projects p
|
|
73
|
+
LEFT JOIN issues i ON i.project_id = p.id
|
|
74
|
+
LEFT JOIN specifications s ON s.project_id = p.id
|
|
75
|
+
GROUP BY p.id, p.name, p.created_at
|
|
76
|
+
ORDER BY p.updated_at DESC;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Present as a concise listing. If no projects exist, respond: "No projects found. Send `/project <description>` to create one."
|
|
80
|
+
|
|
81
|
+
### Parse and Scaffold from Description
|
|
82
|
+
|
|
83
|
+
When the user sends `/project` followed by a natural language description, parse it and create the appropriate database records:
|
|
84
|
+
|
|
85
|
+
1. **Extract the project name** from the description. Use the most prominent noun phrase or explicit project name.
|
|
86
|
+
|
|
87
|
+
2. **Check for existing project** (case-insensitive):
|
|
88
|
+
|
|
89
|
+
```sql
|
|
90
|
+
SELECT id, name FROM projects WHERE name ILIKE '%project name%';
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- If a match exists, use the existing project ID (do not create a duplicate).
|
|
94
|
+
- If multiple matches, list them and ask the user to clarify.
|
|
95
|
+
- If no match, create a new project.
|
|
96
|
+
|
|
97
|
+
3. **Extract specifications** -- any requirements, constraints, architecture decisions, API contracts, or design notes from the description. Insert each:
|
|
98
|
+
|
|
99
|
+
```sql
|
|
100
|
+
INSERT INTO specifications (project_id, note)
|
|
101
|
+
VALUES (<project_id>, 'extracted specification')
|
|
102
|
+
RETURNING id, note;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Use `parent_id` when specifications have a natural parent-child hierarchy.
|
|
106
|
+
|
|
107
|
+
4. **Extract issues/tasks** -- any action items, features to build, bugs to fix, or work items. Insert each:
|
|
108
|
+
|
|
109
|
+
```sql
|
|
110
|
+
INSERT INTO issues (project_id, note)
|
|
111
|
+
VALUES (<project_id>, 'extracted task or issue')
|
|
112
|
+
RETURNING id, note;
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Use `parent_id` when tasks have a natural hierarchy.
|
|
116
|
+
|
|
117
|
+
5. **Upsert behavior:** Before inserting a specification or issue, check if one with very similar text already exists for this project:
|
|
118
|
+
|
|
119
|
+
```sql
|
|
120
|
+
SELECT id, note FROM specifications WHERE project_id = <project_id> AND note ILIKE '%key phrase%';
|
|
121
|
+
SELECT id, note FROM issues WHERE project_id = <project_id> AND note ILIKE '%key phrase%';
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
If a match exists, skip it (note as "already exists" in the summary). Do not delete existing records.
|
|
125
|
+
|
|
126
|
+
6. **Respond with a summary** of everything created or found:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
Project: <name> (id: <id>) [created | existing]
|
|
130
|
+
|
|
131
|
+
Specifications:
|
|
132
|
+
- <spec note> (id: <id>)
|
|
133
|
+
- <spec note> (id: <id>) [already exists]
|
|
134
|
+
|
|
135
|
+
Issues:
|
|
136
|
+
- <issue note> (id: <id>)
|
|
137
|
+
- <issue note> (id: <id>) [already exists]
|
|
138
|
+
```
|
|
139
|
+
|
|
60
140
|
### Create a Project
|
|
61
141
|
|
|
62
142
|
When the user wants to start tracking a new project.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Skill: scheduler
|
|
2
|
+
|
|
3
|
+
## Description
|
|
4
|
+
|
|
5
|
+
Create, list, update, and delete scheduled tasks. Use this skill when the user wants to set up recurring actions that run on a cron schedule. The user describes the task and schedule in natural language; you parse it into a cron expression and a task prompt, then persist it to the database. A background worker executes each task at its scheduled time by sending the prompt to Claude and delivering the response to the user's Telegram chat.
|
|
6
|
+
|
|
7
|
+
## When to Activate
|
|
8
|
+
|
|
9
|
+
Activate this skill when any of the following conditions are met:
|
|
10
|
+
|
|
11
|
+
- The user's message begins with `/schedule`
|
|
12
|
+
- The user wants to set up a recurring task (e.g., "every morning remind me to...", "schedule a daily check on...", "run this every Monday")
|
|
13
|
+
- The user asks about their scheduled tasks (e.g., "what's scheduled", "list my schedules", "show my cron jobs")
|
|
14
|
+
- The user wants to modify or cancel a scheduled task (e.g., "stop the daily reminder", "change the schedule to weekly", "disable schedule #3")
|
|
15
|
+
|
|
16
|
+
## Available Tools
|
|
17
|
+
|
|
18
|
+
- `mcp__pg__query` -- Execute SQL queries against the PostgreSQL database.
|
|
19
|
+
|
|
20
|
+
No other tools are permitted for this skill.
|
|
21
|
+
|
|
22
|
+
## Context
|
|
23
|
+
|
|
24
|
+
The current Telegram `chat_id` is provided in the system prompt as `Current Telegram chat_id: <value>`. You MUST use this value when inserting or querying scheduled tasks to scope operations to the current user's chat.
|
|
25
|
+
|
|
26
|
+
## Database Tables
|
|
27
|
+
|
|
28
|
+
### `scheduled_tasks`
|
|
29
|
+
|
|
30
|
+
| Column | Type | Description |
|
|
31
|
+
|--------|------|-------------|
|
|
32
|
+
| `id` | SERIAL PRIMARY KEY | Auto-incrementing identifier |
|
|
33
|
+
| `created_at` | TIMESTAMPTZ | Timestamp of creation |
|
|
34
|
+
| `updated_at` | TIMESTAMPTZ | Timestamp of last update |
|
|
35
|
+
| `chat_id` | TEXT NOT NULL | Telegram chat ID (from system prompt) |
|
|
36
|
+
| `cron_expression` | TEXT NOT NULL | Standard 5-field cron: minute hour day-of-month month day-of-week |
|
|
37
|
+
| `task_prompt` | TEXT NOT NULL | The prompt sent to Claude when the task fires |
|
|
38
|
+
| `description` | TEXT NOT NULL DEFAULT '' | Human-readable description of the task |
|
|
39
|
+
| `timezone` | TEXT NOT NULL DEFAULT 'UTC' | IANA timezone for cron evaluation |
|
|
40
|
+
| `enabled` | BOOLEAN NOT NULL DEFAULT TRUE | Whether the task is active |
|
|
41
|
+
| `last_run_at` | TIMESTAMPTZ | When the task last executed |
|
|
42
|
+
| `next_run_at` | TIMESTAMPTZ | Pre-computed next execution time |
|
|
43
|
+
| `error_count` | INTEGER NOT NULL DEFAULT 0 | Consecutive error count |
|
|
44
|
+
| `last_error` | TEXT | Most recent error message |
|
|
45
|
+
|
|
46
|
+
## Cron Expression Reference
|
|
47
|
+
|
|
48
|
+
Standard 5-field cron format: `minute hour day-of-month month day-of-week`
|
|
49
|
+
|
|
50
|
+
| Field | Values | Special Characters |
|
|
51
|
+
|-------|--------|--------------------|
|
|
52
|
+
| Minute | 0-59 | `*` `,` `-` `/` |
|
|
53
|
+
| Hour | 0-23 | `*` `,` `-` `/` |
|
|
54
|
+
| Day of Month | 1-31 | `*` `,` `-` `/` |
|
|
55
|
+
| Month | 1-12 | `*` `,` `-` `/` |
|
|
56
|
+
| Day of Week | 0-6 (0=Sunday) | `*` `,` `-` `/` |
|
|
57
|
+
|
|
58
|
+
### Common Patterns
|
|
59
|
+
|
|
60
|
+
| Natural Language | Cron Expression |
|
|
61
|
+
|-----------------|-----------------|
|
|
62
|
+
| every day at 8am | `0 8 * * *` |
|
|
63
|
+
| every weekday at 9am | `0 9 * * 1-5` |
|
|
64
|
+
| every Monday at 10am | `0 10 * * 1` |
|
|
65
|
+
| every hour | `0 * * * *` |
|
|
66
|
+
| every 30 minutes | `*/30 * * * *` |
|
|
67
|
+
| every 15 minutes | `*/15 * * * *` |
|
|
68
|
+
| first of every month at noon | `0 12 1 * *` |
|
|
69
|
+
| every Sunday at 6pm | `0 18 * * 0` |
|
|
70
|
+
| twice a day (8am and 8pm) | `0 8,20 * * *` |
|
|
71
|
+
| every weekday at 5pm | `0 17 * * 1-5` |
|
|
72
|
+
| every 6 hours | `0 */6 * * *` |
|
|
73
|
+
|
|
74
|
+
## Operations
|
|
75
|
+
|
|
76
|
+
### List All Scheduled Tasks (No Arguments)
|
|
77
|
+
|
|
78
|
+
When the user sends `/schedule` with no additional text, list all their tasks:
|
|
79
|
+
|
|
80
|
+
```sql
|
|
81
|
+
SELECT id, description, cron_expression, timezone, enabled,
|
|
82
|
+
last_run_at, next_run_at, error_count, last_error
|
|
83
|
+
FROM scheduled_tasks
|
|
84
|
+
WHERE chat_id = '<chat_id>'
|
|
85
|
+
ORDER BY created_at DESC;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Present as a concise listing showing: ID, description, schedule (human-readable interpretation of the cron), enabled status, and next run time. If no tasks exist, respond: "No scheduled tasks found. Send `/schedule <description>` to create one."
|
|
89
|
+
|
|
90
|
+
Example format:
|
|
91
|
+
```
|
|
92
|
+
Your Scheduled Tasks:
|
|
93
|
+
|
|
94
|
+
#1 - Check project status
|
|
95
|
+
Schedule: Every day at 8:00 AM (UTC)
|
|
96
|
+
Status: Enabled
|
|
97
|
+
Next run: 2026-02-01 08:00 UTC
|
|
98
|
+
|
|
99
|
+
#2 - Weekly team summary
|
|
100
|
+
Schedule: Every Monday at 9:00 AM (America/New_York)
|
|
101
|
+
Status: Disabled (5 errors)
|
|
102
|
+
Last error: Claude subprocess timed out
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Create a Scheduled Task
|
|
106
|
+
|
|
107
|
+
When the user sends `/schedule` followed by a natural language description, parse it:
|
|
108
|
+
|
|
109
|
+
1. **Extract the schedule** from the natural language and convert to a 5-field cron expression.
|
|
110
|
+
2. **Extract the task prompt** -- this is what Claude will be asked to do when the task fires. Formulate it as a clear, actionable prompt. For example, if the user says "remind me to check my project status", the task prompt should be something like: "Check my project status and give me a summary of open issues across all projects."
|
|
111
|
+
3. **Extract or infer the timezone** -- if the user mentions a timezone, use it. Otherwise default to UTC. If unclear, ask.
|
|
112
|
+
4. **Write a human-readable description** summarizing the task.
|
|
113
|
+
|
|
114
|
+
```sql
|
|
115
|
+
INSERT INTO scheduled_tasks (chat_id, cron_expression, task_prompt, description, timezone)
|
|
116
|
+
VALUES ('<chat_id>', '<cron>', '<task_prompt>', '<description>', '<timezone>')
|
|
117
|
+
RETURNING id, cron_expression, task_prompt, description, timezone;
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
After creating, confirm with the user:
|
|
121
|
+
```
|
|
122
|
+
Scheduled task created:
|
|
123
|
+
#<id> - <description>
|
|
124
|
+
Schedule: <human-readable cron interpretation> (<timezone>)
|
|
125
|
+
Prompt: "<task_prompt>"
|
|
126
|
+
|
|
127
|
+
The task will first run at <next computed time>. The background scheduler picks up new tasks within 60 seconds.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Important:** Do NOT set `next_run_at` yourself. The background scheduler worker computes and sets `next_run_at` automatically when it detects new tasks with a NULL `next_run_at`.
|
|
131
|
+
|
|
132
|
+
### Update a Scheduled Task
|
|
133
|
+
|
|
134
|
+
When the user wants to change the schedule, prompt, or description of an existing task.
|
|
135
|
+
|
|
136
|
+
First, verify the task belongs to this chat:
|
|
137
|
+
|
|
138
|
+
```sql
|
|
139
|
+
SELECT id, description, cron_expression, task_prompt, timezone
|
|
140
|
+
FROM scheduled_tasks
|
|
141
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then update the requested fields:
|
|
145
|
+
|
|
146
|
+
```sql
|
|
147
|
+
UPDATE scheduled_tasks
|
|
148
|
+
SET cron_expression = '<new_cron>',
|
|
149
|
+
task_prompt = '<new_prompt>',
|
|
150
|
+
description = '<new_description>',
|
|
151
|
+
timezone = '<new_timezone>',
|
|
152
|
+
next_run_at = NULL,
|
|
153
|
+
updated_at = NOW()
|
|
154
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Setting `next_run_at = NULL` forces the scheduler worker to recompute it on the next poll.
|
|
158
|
+
|
|
159
|
+
### Enable / Disable a Task
|
|
160
|
+
|
|
161
|
+
```sql
|
|
162
|
+
UPDATE scheduled_tasks
|
|
163
|
+
SET enabled = TRUE, error_count = 0, last_error = NULL,
|
|
164
|
+
next_run_at = NULL, updated_at = NOW()
|
|
165
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```sql
|
|
169
|
+
UPDATE scheduled_tasks
|
|
170
|
+
SET enabled = FALSE, updated_at = NOW()
|
|
171
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
When re-enabling, reset `error_count` and `last_error`, and set `next_run_at = NULL` so the scheduler recomputes it.
|
|
175
|
+
|
|
176
|
+
### Delete a Scheduled Task
|
|
177
|
+
|
|
178
|
+
When the user wants to permanently remove a task.
|
|
179
|
+
|
|
180
|
+
```sql
|
|
181
|
+
DELETE FROM scheduled_tasks
|
|
182
|
+
WHERE id = <task_id> AND chat_id = '<chat_id>';
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Confirm deletion to the user. If the task ID does not exist or does not belong to this chat, say so.
|
|
186
|
+
|
|
187
|
+
### Search Scheduled Tasks
|
|
188
|
+
|
|
189
|
+
```sql
|
|
190
|
+
SELECT id, description, cron_expression, timezone, enabled
|
|
191
|
+
FROM scheduled_tasks
|
|
192
|
+
WHERE chat_id = '<chat_id>'
|
|
193
|
+
AND (description ILIKE '%' || '<search_term>' || '%'
|
|
194
|
+
OR task_prompt ILIKE '%' || '<search_term>' || '%')
|
|
195
|
+
ORDER BY created_at DESC;
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Restrictions and Notes
|
|
199
|
+
|
|
200
|
+
- Always use `mcp__pg__query` for database operations. Do not use any other tool.
|
|
201
|
+
- Always scope queries with `chat_id = '<chat_id>'` using the chat ID from the system prompt. Never access another chat's tasks.
|
|
202
|
+
- When the user refers to a task by number (e.g., "disable #3" or "delete task 3"), use that as the `id`.
|
|
203
|
+
- If the user's schedule description is ambiguous (e.g., "every morning" -- what time exactly?), ask for clarification rather than guessing.
|
|
204
|
+
- The cron expression must be valid 5-field format. Do not use 6-field (with seconds) or non-standard extensions.
|
|
205
|
+
- When listing tasks, always translate the cron expression into human-readable language (e.g., `0 8 * * 1-5` = "Every weekday at 8:00 AM").
|
|
206
|
+
- Task prompts should be self-contained and actionable -- the Claude instance executing them will not have the original conversation context. Write prompts that make sense in isolation.
|
|
207
|
+
- Do not set or modify `next_run_at`, `last_run_at`, `error_count`, or `last_error` when creating or updating tasks. These are managed by the scheduler worker.
|
package/src/config.js
CHANGED
|
@@ -7,9 +7,22 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
8
|
const __dirname = path.dirname(__filename);
|
|
9
9
|
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
10
|
-
const
|
|
10
|
+
const SETTINGS_DIR = path.join(os.homedir(), '.2ndbrain');
|
|
11
|
+
const ENV_PATH = path.join(SETTINGS_DIR, '.env');
|
|
12
|
+
const LEGACY_ENV_PATH = path.join(PROJECT_ROOT, '.env');
|
|
11
13
|
|
|
12
|
-
//
|
|
14
|
+
// Migrate legacy .env from package root to stable user directory
|
|
15
|
+
if (!fs.existsSync(ENV_PATH) && fs.existsSync(LEGACY_ENV_PATH)) {
|
|
16
|
+
try {
|
|
17
|
+
fs.mkdirSync(SETTINGS_DIR, { recursive: true });
|
|
18
|
+
fs.copyFileSync(LEGACY_ENV_PATH, ENV_PATH);
|
|
19
|
+
} catch { /* fall through to first-run */ }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Ensure settings directory exists
|
|
23
|
+
try { fs.mkdirSync(SETTINGS_DIR, { recursive: true }); } catch { /* ignore */ }
|
|
24
|
+
|
|
25
|
+
// Load .env from stable user directory
|
|
13
26
|
dotenvConfig({ path: ENV_PATH });
|
|
14
27
|
|
|
15
28
|
const env = process.env;
|
|
@@ -99,5 +112,5 @@ function isFirstRun() {
|
|
|
99
112
|
return !valid;
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
export { config, validateConfig, isFirstRun, PROJECT_ROOT, ENV_PATH };
|
|
115
|
+
export { config, validateConfig, isFirstRun, PROJECT_ROOT, ENV_PATH, SETTINGS_DIR };
|
|
103
116
|
export default config;
|
package/src/hooks/lifecycle.js
CHANGED
|
@@ -239,6 +239,11 @@ class LifecycleHooks extends EventEmitter {
|
|
|
239
239
|
ctx.systemPrompt = dateContext;
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Inject chat_id for skills that need it (e.g., scheduler)
|
|
243
|
+
if (ctx.chatId) {
|
|
244
|
+
ctx.systemPrompt += `\nCurrent Telegram chat_id: ${ctx.chatId}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
242
247
|
// Assemble conversation context from recent history if db is available
|
|
243
248
|
if (db && ctx.includeHistory !== false) {
|
|
244
249
|
try {
|
package/src/index.js
CHANGED
|
@@ -34,6 +34,7 @@ import { createEmbedTool } from './mcp/embed-server.js';
|
|
|
34
34
|
import { AttachmentStore } from './attachments/store.js';
|
|
35
35
|
import { EmbeddingsEngine } from './embeddings/engine.js';
|
|
36
36
|
import { EmbeddingWorker } from './embeddings/worker.js';
|
|
37
|
+
import { SchedulerWorker } from './scheduler/worker.js';
|
|
37
38
|
import { WebServer } from './web/server.js';
|
|
38
39
|
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
@@ -44,6 +45,7 @@ const startTime = Date.now();
|
|
|
44
45
|
let bot = null;
|
|
45
46
|
let webServer = null;
|
|
46
47
|
let embeddingWorker = null;
|
|
48
|
+
let schedulerWorker = null;
|
|
47
49
|
let embedTool = null;
|
|
48
50
|
let shuttingDown = false;
|
|
49
51
|
|
|
@@ -178,6 +180,7 @@ async function handleMessage(message, deps) {
|
|
|
178
180
|
message: text,
|
|
179
181
|
systemPrompt: '',
|
|
180
182
|
sessionId: conversationManager.currentSessionId,
|
|
183
|
+
chatId,
|
|
181
184
|
});
|
|
182
185
|
|
|
183
186
|
if (preResult.aborted) {
|
|
@@ -296,6 +299,10 @@ async function shutdown(signal) {
|
|
|
296
299
|
try { embeddingWorker.stop(); } catch { /* ignore */ }
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
if (schedulerWorker) {
|
|
303
|
+
try { schedulerWorker.stop(); } catch { /* ignore */ }
|
|
304
|
+
}
|
|
305
|
+
|
|
299
306
|
if (embedTool?.server) {
|
|
300
307
|
try { embedTool.server.close(); } catch { /* ignore */ }
|
|
301
308
|
}
|
|
@@ -516,6 +523,19 @@ async function main() {
|
|
|
516
523
|
embeddingWorker.start();
|
|
517
524
|
}
|
|
518
525
|
|
|
526
|
+
// Start background scheduler worker
|
|
527
|
+
if (dbReady && bot) {
|
|
528
|
+
schedulerWorker = new SchedulerWorker({
|
|
529
|
+
db: { query },
|
|
530
|
+
config,
|
|
531
|
+
logger,
|
|
532
|
+
claudeBridge,
|
|
533
|
+
bot,
|
|
534
|
+
rateLimiters,
|
|
535
|
+
});
|
|
536
|
+
schedulerWorker.start();
|
|
537
|
+
}
|
|
538
|
+
|
|
519
539
|
// Step 8: Set signal handlers
|
|
520
540
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
521
541
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import cron from 'node-cron';
|
|
2
|
+
|
|
3
|
+
/** Milliseconds between processing iterations (1 minute). */
|
|
4
|
+
const POLL_INTERVAL_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
/** Maximum tasks to execute per poll cycle. */
|
|
7
|
+
const MAX_TASKS_PER_TICK = 3;
|
|
8
|
+
|
|
9
|
+
/** Consecutive errors before auto-disabling a task. */
|
|
10
|
+
const MAX_CONSECUTIVE_ERRORS = 5;
|
|
11
|
+
|
|
12
|
+
/** Maximum minutes to scan forward when computing next run (366 days). */
|
|
13
|
+
const MAX_SCAN_MINUTES = 366 * 24 * 60;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compute the next Date after `from` that matches the cron expression.
|
|
17
|
+
*
|
|
18
|
+
* Uses node-cron's internal timeMatcher to check each minute. Scans up
|
|
19
|
+
* to ~366 days forward before giving up.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} cronExpression - 5-field cron expression
|
|
22
|
+
* @param {Date} from - Start searching from this date (exclusive)
|
|
23
|
+
* @param {string} [timezone='UTC'] - IANA timezone for evaluation
|
|
24
|
+
* @returns {Date|null} Next matching date, or null if none found
|
|
25
|
+
*/
|
|
26
|
+
function nextCronDate(cronExpression, from, timezone = 'UTC') {
|
|
27
|
+
// Create a temporary task just to access the timeMatcher
|
|
28
|
+
const task = cron.schedule(cronExpression, () => {}, {
|
|
29
|
+
scheduled: false,
|
|
30
|
+
timezone,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const matcher = task.timeMatcher;
|
|
34
|
+
|
|
35
|
+
// Start from the next whole minute after `from`
|
|
36
|
+
const candidate = new Date(from.getTime());
|
|
37
|
+
candidate.setSeconds(0, 0);
|
|
38
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < MAX_SCAN_MINUTES; i++) {
|
|
41
|
+
if (matcher.match(candidate)) {
|
|
42
|
+
return new Date(candidate.getTime());
|
|
43
|
+
}
|
|
44
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Background scheduler worker -- periodically checks for scheduled tasks
|
|
52
|
+
* whose next_run_at has passed, executes them via the Claude bridge,
|
|
53
|
+
* and delivers results to the user's Telegram chat.
|
|
54
|
+
*
|
|
55
|
+
* Follows the EmbeddingWorker pattern (§11.4): setTimeout-based polling
|
|
56
|
+
* loop with overlap guard.
|
|
57
|
+
*/
|
|
58
|
+
class SchedulerWorker {
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} deps
|
|
61
|
+
* @param {object} deps.db - Database query interface ({ query(sql, params) })
|
|
62
|
+
* @param {object} deps.config - Application configuration
|
|
63
|
+
* @param {object} deps.logger - Logger instance
|
|
64
|
+
* @param {import('../claude/bridge.js').ClaudeBridge} deps.claudeBridge - Claude CLI bridge
|
|
65
|
+
* @param {import('../telegram/bot.js').TelegramBot} deps.bot - Telegram bot instance
|
|
66
|
+
* @param {object} deps.rateLimiters - { claude: RateLimiter, ... }
|
|
67
|
+
*/
|
|
68
|
+
constructor({ db, config, logger, claudeBridge, bot, rateLimiters }) {
|
|
69
|
+
this.db = db;
|
|
70
|
+
this.config = config;
|
|
71
|
+
this.logger = logger;
|
|
72
|
+
this.claudeBridge = claudeBridge;
|
|
73
|
+
this.bot = bot;
|
|
74
|
+
this.rateLimiters = rateLimiters;
|
|
75
|
+
|
|
76
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
77
|
+
this._timer = null;
|
|
78
|
+
|
|
79
|
+
/** Whether the worker loop is active. */
|
|
80
|
+
this._running = false;
|
|
81
|
+
|
|
82
|
+
/** Guard to prevent overlapping iterations. */
|
|
83
|
+
this._processing = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Start the periodic scheduler worker loop.
|
|
88
|
+
* Initializes next_run_at for any tasks that need it, then begins polling.
|
|
89
|
+
*/
|
|
90
|
+
async start() {
|
|
91
|
+
if (this._running) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this._running = true;
|
|
96
|
+
this.logger.info('scheduler', 'Starting background scheduler worker.');
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await this._initializeNextRuns();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
this.logger.error('scheduler', `Failed to initialize next runs: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this._scheduleNext();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Stop the worker loop gracefully. Any in-flight iteration will finish
|
|
109
|
+
* before the loop fully halts.
|
|
110
|
+
*/
|
|
111
|
+
stop() {
|
|
112
|
+
this._running = false;
|
|
113
|
+
|
|
114
|
+
if (this._timer !== null) {
|
|
115
|
+
clearTimeout(this._timer);
|
|
116
|
+
this._timer = null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.logger.info('scheduler', 'Scheduler worker stopped.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Internal
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* On startup, compute next_run_at for tasks that have NULL or past values.
|
|
128
|
+
* Tasks missed during downtime skip ahead to the next valid time (no
|
|
129
|
+
* retroactive execution).
|
|
130
|
+
*/
|
|
131
|
+
async _initializeNextRuns() {
|
|
132
|
+
const result = await this.db.query(
|
|
133
|
+
`SELECT id, cron_expression, timezone
|
|
134
|
+
FROM scheduled_tasks
|
|
135
|
+
WHERE enabled = TRUE
|
|
136
|
+
AND (next_run_at IS NULL OR next_run_at < NOW())`,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (result.rows.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.logger.info('scheduler', `Recomputing next_run_at for ${result.rows.length} task(s).`);
|
|
144
|
+
|
|
145
|
+
for (const row of result.rows) {
|
|
146
|
+
try {
|
|
147
|
+
if (!cron.validate(row.cron_expression)) {
|
|
148
|
+
this.logger.warn('scheduler', `Invalid cron expression for task ${row.id}: "${row.cron_expression}"; skipping.`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const nextRun = nextCronDate(row.cron_expression, new Date(), row.timezone);
|
|
153
|
+
if (nextRun) {
|
|
154
|
+
await this.db.query(
|
|
155
|
+
'UPDATE scheduled_tasks SET next_run_at = $1, updated_at = NOW() WHERE id = $2',
|
|
156
|
+
[nextRun.toISOString(), row.id],
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
this.logger.warn('scheduler', `Failed to compute next_run_at for task ${row.id}: ${err.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Schedule the next processing iteration after POLL_INTERVAL_MS.
|
|
167
|
+
*/
|
|
168
|
+
_scheduleNext() {
|
|
169
|
+
if (!this._running) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
this._timer = setTimeout(async () => {
|
|
174
|
+
this._timer = null;
|
|
175
|
+
|
|
176
|
+
// Skip if the previous iteration is still running
|
|
177
|
+
if (this._processing) {
|
|
178
|
+
this._scheduleNext();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Skip if Claude is busy with a user message
|
|
183
|
+
if (this.claudeBridge.isActive()) {
|
|
184
|
+
this.logger.debug('scheduler', 'Skipping tick: Claude subprocess is active.');
|
|
185
|
+
this._scheduleNext();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
this._processing = true;
|
|
191
|
+
await this._processQueue();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.logger.error('scheduler', `Unexpected error in worker loop: ${err.message}`);
|
|
194
|
+
} finally {
|
|
195
|
+
this._processing = false;
|
|
196
|
+
this._scheduleNext();
|
|
197
|
+
}
|
|
198
|
+
}, POLL_INTERVAL_MS);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Fetch and execute tasks whose next_run_at has passed.
|
|
203
|
+
*/
|
|
204
|
+
async _processQueue() {
|
|
205
|
+
const result = await this.db.query(
|
|
206
|
+
`SELECT id, chat_id, cron_expression, task_prompt, description, timezone, error_count
|
|
207
|
+
FROM scheduled_tasks
|
|
208
|
+
WHERE enabled = TRUE
|
|
209
|
+
AND next_run_at IS NOT NULL
|
|
210
|
+
AND next_run_at <= NOW()
|
|
211
|
+
ORDER BY next_run_at ASC
|
|
212
|
+
LIMIT $1`,
|
|
213
|
+
[MAX_TASKS_PER_TICK],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (result.rows.length === 0) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.logger.info('scheduler', `Processing ${result.rows.length} scheduled task(s).`);
|
|
221
|
+
|
|
222
|
+
for (const row of result.rows) {
|
|
223
|
+
// Re-check if Claude became active between tasks
|
|
224
|
+
if (this.claudeBridge.isActive()) {
|
|
225
|
+
this.logger.debug('scheduler', 'Pausing task execution: Claude became active.');
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await this._executeTask(row);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
this.logger.error('scheduler', `Failed to execute task ${row.id}: ${err.message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Execute a single scheduled task: invoke Claude, send the response,
|
|
239
|
+
* and update next_run_at.
|
|
240
|
+
*
|
|
241
|
+
* @param {object} row - Database row from scheduled_tasks
|
|
242
|
+
*/
|
|
243
|
+
async _executeTask(row) {
|
|
244
|
+
const { id, chat_id, cron_expression, task_prompt, description, timezone, error_count } = row;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
// Respect the Claude rate limiter
|
|
248
|
+
await this.rateLimiters.claude.acquire();
|
|
249
|
+
|
|
250
|
+
// Show typing indicator
|
|
251
|
+
await this.bot.sendTyping(chat_id);
|
|
252
|
+
|
|
253
|
+
// Invoke Claude with a fresh session
|
|
254
|
+
const systemPrompt = `You are executing a scheduled task. Task description: ${description}. Provide a helpful, concise response.`;
|
|
255
|
+
const result = await this.claudeBridge.invoke(task_prompt, null, systemPrompt);
|
|
256
|
+
|
|
257
|
+
// Deliver the response
|
|
258
|
+
const responseText = result.text || 'Scheduled task completed (no output).';
|
|
259
|
+
const header = `[Scheduled: ${description}]\n\n`;
|
|
260
|
+
await this.bot.sendMessage(chat_id, header + responseText, {
|
|
261
|
+
parse_mode: undefined,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Compute the next run time
|
|
265
|
+
const nextRun = nextCronDate(cron_expression, new Date(), timezone);
|
|
266
|
+
|
|
267
|
+
await this.db.query(
|
|
268
|
+
`UPDATE scheduled_tasks
|
|
269
|
+
SET last_run_at = NOW(),
|
|
270
|
+
next_run_at = $1,
|
|
271
|
+
error_count = 0,
|
|
272
|
+
last_error = NULL,
|
|
273
|
+
updated_at = NOW()
|
|
274
|
+
WHERE id = $2`,
|
|
275
|
+
[nextRun ? nextRun.toISOString() : null, id],
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
this.logger.info('scheduler', `Task ${id} ("${description}") executed. Next run: ${nextRun?.toISOString() || 'none'}`);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// Record the error and possibly auto-disable
|
|
281
|
+
const newErrorCount = error_count + 1;
|
|
282
|
+
const shouldDisable = newErrorCount >= MAX_CONSECUTIVE_ERRORS;
|
|
283
|
+
|
|
284
|
+
// Still compute next_run_at so a re-enabled task doesn't fire immediately
|
|
285
|
+
let nextRun = null;
|
|
286
|
+
try {
|
|
287
|
+
nextRun = nextCronDate(cron_expression, new Date(), timezone);
|
|
288
|
+
} catch { /* ignore parse errors during error handling */ }
|
|
289
|
+
|
|
290
|
+
await this.db.query(
|
|
291
|
+
`UPDATE scheduled_tasks
|
|
292
|
+
SET error_count = $1,
|
|
293
|
+
last_error = $2,
|
|
294
|
+
enabled = $3,
|
|
295
|
+
next_run_at = $4,
|
|
296
|
+
updated_at = NOW()
|
|
297
|
+
WHERE id = $5`,
|
|
298
|
+
[newErrorCount, err.message, !shouldDisable, nextRun ? nextRun.toISOString() : null, id],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (shouldDisable) {
|
|
302
|
+
this.logger.warn('scheduler', `Task ${id} auto-disabled after ${MAX_CONSECUTIVE_ERRORS} consecutive errors.`);
|
|
303
|
+
|
|
304
|
+
// Notify the user
|
|
305
|
+
try {
|
|
306
|
+
await this.bot.sendMessage(
|
|
307
|
+
chat_id,
|
|
308
|
+
`Your scheduled task "${description}" has been disabled after ${MAX_CONSECUTIVE_ERRORS} consecutive failures.\n\nLast error: ${err.message}\n\nUse /schedule to re-enable it.`,
|
|
309
|
+
{ parse_mode: undefined },
|
|
310
|
+
);
|
|
311
|
+
} catch { /* best-effort notification */ }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export { SchedulerWorker, nextCronDate };
|
|
320
|
+
export default SchedulerWorker;
|
package/src/telegram/commands.js
CHANGED
|
@@ -4,6 +4,13 @@ import { escapeMarkdownV2 } from './bot.js';
|
|
|
4
4
|
/** How long a confirmation remains valid (ms). */
|
|
5
5
|
const CONFIRMATION_TTL_MS = 60_000;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Commands that are forwarded to Claude instead of handled by the router.
|
|
9
|
+
* The corresponding Claude skill activates on the `/command` prefix.
|
|
10
|
+
* @type {Set<string>}
|
|
11
|
+
*/
|
|
12
|
+
const PASSTHROUGH_COMMANDS = new Set(['/project', '/journal', '/knowledge', '/schedule']);
|
|
13
|
+
|
|
7
14
|
/**
|
|
8
15
|
* Descriptions shown by the /help command.
|
|
9
16
|
* @type {Record<string, string>}
|
|
@@ -15,6 +22,10 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
15
22
|
'/restart': 'Restart the service process',
|
|
16
23
|
'/reboot': 'Reboot the host operating system',
|
|
17
24
|
'/new': 'Start a new conversation session',
|
|
25
|
+
'/project': 'Manage projects, specs, and issues (handled by Claude)',
|
|
26
|
+
'/journal': 'Record and search journal entries (handled by Claude)',
|
|
27
|
+
'/knowledge': 'Manage knowledge graph (handled by Claude)',
|
|
28
|
+
'/schedule': 'Create, list, or manage scheduled tasks (handled by Claude)',
|
|
18
29
|
'/help': 'List available commands',
|
|
19
30
|
};
|
|
20
31
|
|
|
@@ -91,6 +102,11 @@ class CommandRouter {
|
|
|
91
102
|
// Extract the command name (everything up to the first space or @)
|
|
92
103
|
const command = trimmed.split(/[\s@]/)[0].toLowerCase();
|
|
93
104
|
|
|
105
|
+
// Skill-managed commands pass through to Claude
|
|
106
|
+
if (PASSTHROUGH_COMMANDS.has(command)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
const replyOpts = { reply_to_message_id: messageId, parse_mode: undefined };
|
|
95
111
|
|
|
96
112
|
try {
|
|
@@ -436,5 +452,5 @@ class CommandRouter {
|
|
|
436
452
|
}
|
|
437
453
|
}
|
|
438
454
|
|
|
439
|
-
export { CommandRouter, COMMAND_DESCRIPTIONS };
|
|
455
|
+
export { CommandRouter, COMMAND_DESCRIPTIONS, PASSTHROUGH_COMMANDS };
|
|
440
456
|
export default CommandRouter;
|
package/src/web/server.js
CHANGED
|
@@ -4,11 +4,10 @@ import fs from 'node:fs';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { migrate, getMigrationFiles, ensureMigrationsTable } from '../db/migrate.js';
|
|
7
|
+
import { ENV_PATH } from '../config.js';
|
|
7
8
|
|
|
8
|
-
// Resolve project root (two directories up from src/web/)
|
|
9
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
10
|
const __dirname = path.dirname(__filename);
|
|
11
|
-
const DEFAULT_ENV_PATH = path.resolve(__dirname, '..', '..', '.env');
|
|
12
11
|
|
|
13
12
|
// ---------------------------------------------------------------------------
|
|
14
13
|
// Settings field definitions -- drives both the form UI and save logic
|
|
@@ -107,7 +106,7 @@ class WebServer {
|
|
|
107
106
|
this._logger = logger;
|
|
108
107
|
this._server = null;
|
|
109
108
|
this._app = null;
|
|
110
|
-
this._envPath =
|
|
109
|
+
this._envPath = ENV_PATH;
|
|
111
110
|
}
|
|
112
111
|
|
|
113
112
|
/**
|