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.
@@ -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
- See also: §5 (Claude Bridge), §14.4 (Dangerous Operations)
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.33",
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": ["telegram", "claude", "ai", "assistant", "knowledge-graph"],
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 ENV_PATH = path.join(PROJECT_ROOT, '.env');
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
- // Load .env from project root
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;
@@ -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;
@@ -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 = config.ENV_PATH || DEFAULT_ENV_PATH;
109
+ this._envPath = ENV_PATH;
111
110
  }
112
111
 
113
112
  /**