heartbeat-opencode-plugin 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/README.md +198 -0
- package/config/index.ts +409 -0
- package/heartbeat.config.json +13 -0
- package/index.ts +71 -0
- package/memory/index.ts +307 -0
- package/package.json +44 -0
- package/scheduler/index.ts +966 -0
- package/task-manager/index.ts +459 -0
package/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Heartbeat OpenCode Plugin
|
|
2
|
+
|
|
3
|
+
Heartbeat is a plugin stack for OpenCode with three modules:
|
|
4
|
+
|
|
5
|
+
- `scheduler`: human-managed cron jobs (including `opencode run`)
|
|
6
|
+
- `task-manager`: structured task state (`[program][taskId]`)
|
|
7
|
+
- `memory`: shared append-only log + grep/search retrieval
|
|
8
|
+
|
|
9
|
+
The design model is:
|
|
10
|
+
|
|
11
|
+
- LLM = compute cycle
|
|
12
|
+
- Scheduler = clock/launcher
|
|
13
|
+
- Task manager + memory = state across cycles
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
### Components
|
|
18
|
+
|
|
19
|
+
1. Scheduler (`/Users/pragneshbarik/Projects/heartbeat/scheduler/index.ts`)
|
|
20
|
+
- Stores job definitions as JSON files.
|
|
21
|
+
- Runs jobs manually or on schedule (launchd on macOS).
|
|
22
|
+
- Streams all emitted stdout/stderr lines to one shared log file.
|
|
23
|
+
|
|
24
|
+
2. Task Manager (`/Users/pragneshbarik/Projects/heartbeat/task-manager/index.ts`)
|
|
25
|
+
- Stores tasks in a JSON file.
|
|
26
|
+
- Task key is `[program][taskId]`.
|
|
27
|
+
- Supports `parentTaskId` for task hierarchies.
|
|
28
|
+
- Tracks status, priority, timestamps, and optional references.
|
|
29
|
+
|
|
30
|
+
3. Memory (`/Users/pragneshbarik/Projects/heartbeat/memory/index.ts`)
|
|
31
|
+
- Appends timestamped entries to one shared file.
|
|
32
|
+
- Supports:
|
|
33
|
+
- tag lookup by `[program]`
|
|
34
|
+
- exact lookup by `[program][taskId]`
|
|
35
|
+
- regex grep across all entries
|
|
36
|
+
- Preserves multiline entries as a single memory record.
|
|
37
|
+
|
|
38
|
+
4. Unified Plugin Entry (`/Users/pragneshbarik/Projects/heartbeat/index.ts`)
|
|
39
|
+
- Merges tools from scheduler + task-manager + memory.
|
|
40
|
+
- Adds `heartbeat_boot_context` helper:
|
|
41
|
+
- current task snapshot
|
|
42
|
+
- recent task memory
|
|
43
|
+
- queue slices (pending/running/blocked/raised)
|
|
44
|
+
|
|
45
|
+
### Data Flow
|
|
46
|
+
|
|
47
|
+
1. Human creates a scheduler job.
|
|
48
|
+
2. Scheduler executes command or `opencode run <prompt>`.
|
|
49
|
+
3. Runtime output is streamed into the shared log.
|
|
50
|
+
4. Next cycle can query memory and task state, then continue.
|
|
51
|
+
|
|
52
|
+
## Config Model
|
|
53
|
+
|
|
54
|
+
`launchd` prefix and Bun path are fixed in code (not configurable):
|
|
55
|
+
|
|
56
|
+
- launchd label prefix: `com.heartbeat.job`
|
|
57
|
+
- bun path: `/opt/homebrew/bin/bun`
|
|
58
|
+
|
|
59
|
+
Configurable settings:
|
|
60
|
+
|
|
61
|
+
- logs directory/file
|
|
62
|
+
- jobs directory
|
|
63
|
+
- default job workdir
|
|
64
|
+
- task store file
|
|
65
|
+
|
|
66
|
+
### Config precedence (lowest -> highest)
|
|
67
|
+
|
|
68
|
+
1. Built-in defaults (in code)
|
|
69
|
+
2. Global plugin config:
|
|
70
|
+
- `~/.config/opencode/heartbeat.jsonc`
|
|
71
|
+
3. Legacy fallback files:
|
|
72
|
+
- `heartbeat.config.json`
|
|
73
|
+
- `.opencode/heartbeat.config.json`
|
|
74
|
+
4. Workspace plugin config:
|
|
75
|
+
- `.opencode/heartbeat.jsonc`
|
|
76
|
+
5. Environment override:
|
|
77
|
+
- `HEARTBEAT_CONFIG_PATH`
|
|
78
|
+
- legacy alias: `HEARTBEAT_CONFIG`
|
|
79
|
+
|
|
80
|
+
## Setup
|
|
81
|
+
|
|
82
|
+
### 1. Install package in workspace
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm install heartbeat-opencode-plugin
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 2. Register plugin in OpenCode config
|
|
89
|
+
|
|
90
|
+
Create or edit `opencode.json`:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"$schema": "https://opencode.ai/config.json",
|
|
95
|
+
"plugin": ["heartbeat-opencode-plugin"]
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 3. Add Heartbeat workspace config
|
|
100
|
+
|
|
101
|
+
Create `.opencode/heartbeat.jsonc`:
|
|
102
|
+
|
|
103
|
+
```jsonc
|
|
104
|
+
{
|
|
105
|
+
"logs": {
|
|
106
|
+
"dir": "./.opencode/heartbeat/logs",
|
|
107
|
+
"file": "memory.log"
|
|
108
|
+
},
|
|
109
|
+
"scheduler": {
|
|
110
|
+
"jobsDir": "./.opencode/heartbeat/jobs",
|
|
111
|
+
"defaultWorkdir": "."
|
|
112
|
+
},
|
|
113
|
+
"taskManager": {
|
|
114
|
+
"file": "./.opencode/heartbeat/tasks.json"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Quick Start
|
|
120
|
+
|
|
121
|
+
1. Create a task:
|
|
122
|
+
- `task_create(program, taskId, description, parentTaskId?, priority?, status?, references?)`
|
|
123
|
+
|
|
124
|
+
2. Create a job:
|
|
125
|
+
- `scheduler_create_opencode_job(...)` or `scheduler_create_command_job(...)`
|
|
126
|
+
|
|
127
|
+
3. Test run once:
|
|
128
|
+
- `scheduler_run_job(jobId)`
|
|
129
|
+
|
|
130
|
+
4. Read memory:
|
|
131
|
+
- `search_memory(program, taskId?)`
|
|
132
|
+
- `memory_grep(pattern)`
|
|
133
|
+
|
|
134
|
+
5. Mark done:
|
|
135
|
+
- `task_mark_done(program, taskId)`
|
|
136
|
+
|
|
137
|
+
## Tool Surface
|
|
138
|
+
|
|
139
|
+
Scheduler tools:
|
|
140
|
+
|
|
141
|
+
- `scheduler_create_opencode_job`
|
|
142
|
+
- `scheduler_create_command_job`
|
|
143
|
+
- `scheduler_list_jobs`
|
|
144
|
+
- `scheduler_get_job`
|
|
145
|
+
- `scheduler_update_job`
|
|
146
|
+
- `scheduler_run_job`
|
|
147
|
+
- `scheduler_install_job` (macOS)
|
|
148
|
+
- `scheduler_uninstall_job` (macOS)
|
|
149
|
+
- `scheduler_delete_job`
|
|
150
|
+
- `scheduler_info`
|
|
151
|
+
|
|
152
|
+
Task tools:
|
|
153
|
+
|
|
154
|
+
- `task_create`
|
|
155
|
+
- `task_list`
|
|
156
|
+
- `task_get`
|
|
157
|
+
- `task_update`
|
|
158
|
+
- `task_mark_done`
|
|
159
|
+
- `task_delete`
|
|
160
|
+
- `task_store_info`
|
|
161
|
+
|
|
162
|
+
Memory tools:
|
|
163
|
+
|
|
164
|
+
- `memory_write` / `write_log`
|
|
165
|
+
- `search_memory`
|
|
166
|
+
- `read_program`
|
|
167
|
+
- `read_program_task`
|
|
168
|
+
- `memory_grep` / `grep_logs`
|
|
169
|
+
- `tail_logs`
|
|
170
|
+
- `log_info`
|
|
171
|
+
|
|
172
|
+
Helper:
|
|
173
|
+
|
|
174
|
+
- `heartbeat_boot_context`
|
|
175
|
+
|
|
176
|
+
## Local Development
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
bun install
|
|
180
|
+
bun run typecheck
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Publish To npm
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
bun run typecheck
|
|
187
|
+
NPM_CONFIG_CACHE=/tmp/heartbeat-npm-cache npm pack --dry-run
|
|
188
|
+
npm login
|
|
189
|
+
npm publish --access public
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Example Files
|
|
193
|
+
|
|
194
|
+
- `/Users/pragneshbarik/Projects/heartbeat/examples/opencode.json`
|
|
195
|
+
- `/Users/pragneshbarik/Projects/heartbeat/examples/.opencode/heartbeat.jsonc`
|
|
196
|
+
- `/Users/pragneshbarik/Projects/heartbeat/examples/scheduler/jobs/daily-standup.json`
|
|
197
|
+
- `/Users/pragneshbarik/Projects/heartbeat/examples/scheduler/jobs/research-cycle.json`
|
|
198
|
+
- `/Users/pragneshbarik/Projects/heartbeat/examples/task-manager/tasks.json`
|
package/config/index.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat Configuration
|
|
3
|
+
*
|
|
4
|
+
* Central configuration for the heartbeat LLM OS.
|
|
5
|
+
* Supports plugin-style overrides from:
|
|
6
|
+
* - ~/.config/opencode/heartbeat.jsonc
|
|
7
|
+
* - .opencode/heartbeat.jsonc (workspace OpenCode folder)
|
|
8
|
+
* - heartbeat.config.json (legacy project-local file)
|
|
9
|
+
* - env override: HEARTBEAT_CONFIG_PATH (or legacy HEARTBEAT_CONFIG)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Types
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
export interface HeartbeatConfig {
|
|
21
|
+
/** Logging configuration */
|
|
22
|
+
logs: {
|
|
23
|
+
/** Directory for log files (relative to project root) */
|
|
24
|
+
dir: string;
|
|
25
|
+
/** Log file name */
|
|
26
|
+
file: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Scheduler configuration */
|
|
30
|
+
scheduler: {
|
|
31
|
+
/** Directory for job JSON files (relative to project root) */
|
|
32
|
+
jobsDir: string;
|
|
33
|
+
/** Default working directory for jobs (absolute or relative to project root) */
|
|
34
|
+
defaultWorkdir: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Task manager configuration */
|
|
38
|
+
taskManager: {
|
|
39
|
+
/** Task JSON store file (absolute or relative to project root) */
|
|
40
|
+
file: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Defaults
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
const DEFAULT_CONFIG: HeartbeatConfig = {
|
|
49
|
+
logs: {
|
|
50
|
+
dir: "./logs",
|
|
51
|
+
file: "heartbeat.log",
|
|
52
|
+
},
|
|
53
|
+
scheduler: {
|
|
54
|
+
jobsDir: "./scheduler/jobs",
|
|
55
|
+
defaultWorkdir: ".",
|
|
56
|
+
},
|
|
57
|
+
taskManager: {
|
|
58
|
+
file: "./task-manager/tasks.json",
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const LAUNCHD_LABEL_PREFIX = "com.heartbeat.job";
|
|
63
|
+
const FIXED_BUN_PATH = "/opt/homebrew/bin/bun";
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Config Loading
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
const CONFIG_FILENAME = "heartbeat.config.json";
|
|
70
|
+
const HEARTBEAT_JSONC = "heartbeat.jsonc";
|
|
71
|
+
const OPENCODE_DIR = ".opencode";
|
|
72
|
+
const HEARTBEAT_ENV_CONFIG_PATH = "HEARTBEAT_CONFIG_PATH";
|
|
73
|
+
const HEARTBEAT_ENV_CONFIG_LEGACY = "HEARTBEAT_CONFIG";
|
|
74
|
+
const OPENCODE_GLOBAL_DIR = path.join(homedir(), ".config", "opencode");
|
|
75
|
+
|
|
76
|
+
let loadedConfig: HeartbeatConfig | null = null;
|
|
77
|
+
let configDir: string = process.cwd();
|
|
78
|
+
|
|
79
|
+
function cloneDefaultConfig(): HeartbeatConfig {
|
|
80
|
+
return {
|
|
81
|
+
logs: { ...DEFAULT_CONFIG.logs },
|
|
82
|
+
scheduler: { ...DEFAULT_CONFIG.scheduler },
|
|
83
|
+
taskManager: { ...DEFAULT_CONFIG.taskManager },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Deep merge two objects
|
|
89
|
+
*/
|
|
90
|
+
function deepMerge(
|
|
91
|
+
target: HeartbeatConfig,
|
|
92
|
+
source: Partial<HeartbeatConfig>
|
|
93
|
+
): HeartbeatConfig {
|
|
94
|
+
return {
|
|
95
|
+
logs: {
|
|
96
|
+
...target.logs,
|
|
97
|
+
...source.logs,
|
|
98
|
+
},
|
|
99
|
+
scheduler: {
|
|
100
|
+
...target.scheduler,
|
|
101
|
+
...source.scheduler,
|
|
102
|
+
},
|
|
103
|
+
taskManager: {
|
|
104
|
+
...target.taskManager,
|
|
105
|
+
...source.taskManager,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function stripJsonComments(raw: string): string {
|
|
111
|
+
let result = "";
|
|
112
|
+
let inString = false;
|
|
113
|
+
let stringQuote: '"' | "'" | null = null;
|
|
114
|
+
let escaped = false;
|
|
115
|
+
let inLineComment = false;
|
|
116
|
+
let inBlockComment = false;
|
|
117
|
+
|
|
118
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
119
|
+
const char = raw[i];
|
|
120
|
+
const next = raw[i + 1];
|
|
121
|
+
|
|
122
|
+
if (inLineComment) {
|
|
123
|
+
if (char === "\n") {
|
|
124
|
+
inLineComment = false;
|
|
125
|
+
result += "\n";
|
|
126
|
+
} else {
|
|
127
|
+
result += " ";
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (inBlockComment) {
|
|
133
|
+
if (char === "*" && next === "/") {
|
|
134
|
+
inBlockComment = false;
|
|
135
|
+
result += " ";
|
|
136
|
+
i += 1;
|
|
137
|
+
} else {
|
|
138
|
+
result += char === "\n" ? "\n" : " ";
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (inString) {
|
|
144
|
+
result += char;
|
|
145
|
+
if (escaped) {
|
|
146
|
+
escaped = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (char === "\\") {
|
|
151
|
+
escaped = true;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (stringQuote && char === stringQuote) {
|
|
156
|
+
inString = false;
|
|
157
|
+
stringQuote = null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if ((char === '"' || char === "'") && !inString) {
|
|
164
|
+
inString = true;
|
|
165
|
+
stringQuote = char;
|
|
166
|
+
result += char;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (char === "/" && next === "/") {
|
|
171
|
+
inLineComment = true;
|
|
172
|
+
result += " ";
|
|
173
|
+
i += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (char === "/" && next === "*") {
|
|
178
|
+
inBlockComment = true;
|
|
179
|
+
result += " ";
|
|
180
|
+
i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
result += char;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseJsonMaybeJsonc(raw: string, filePath: string): unknown {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(raw);
|
|
193
|
+
} catch {
|
|
194
|
+
try {
|
|
195
|
+
return JSON.parse(stripJsonComments(raw));
|
|
196
|
+
} catch (error) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Failed to parse JSON/JSONC at ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function readJsonFile(filePath: string): unknown {
|
|
205
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
206
|
+
return parseJsonMaybeJsonc(content, filePath);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readHeartbeatConfigFile(filePath: string): Partial<HeartbeatConfig> | null {
|
|
210
|
+
if (!fs.existsSync(filePath)) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const parsed = readJsonFile(filePath);
|
|
215
|
+
if (!parsed || typeof parsed !== "object") {
|
|
216
|
+
throw new Error(`Config file must be an object: ${filePath}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return parsed as Partial<HeartbeatConfig>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveOptionalPath(input: string): string {
|
|
223
|
+
if (path.isAbsolute(input)) {
|
|
224
|
+
return input;
|
|
225
|
+
}
|
|
226
|
+
return path.resolve(configDir, input);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function mergeHeartbeatConfig(
|
|
230
|
+
base: HeartbeatConfig,
|
|
231
|
+
override: Partial<HeartbeatConfig> | null,
|
|
232
|
+
): HeartbeatConfig {
|
|
233
|
+
if (!override) {
|
|
234
|
+
return base;
|
|
235
|
+
}
|
|
236
|
+
return deepMerge(base, override);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Load configuration using plugin-style config files.
|
|
241
|
+
* Falls back to defaults when no overrides are found.
|
|
242
|
+
*/
|
|
243
|
+
export function loadConfig(projectDir?: string): HeartbeatConfig {
|
|
244
|
+
if (projectDir) {
|
|
245
|
+
configDir = projectDir;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let config = cloneDefaultConfig();
|
|
249
|
+
const warnings: string[] = [];
|
|
250
|
+
|
|
251
|
+
const mergeHeartbeatFile = (filePath: string): void => {
|
|
252
|
+
try {
|
|
253
|
+
config = mergeHeartbeatConfig(config, readHeartbeatConfigFile(filePath));
|
|
254
|
+
} catch (error) {
|
|
255
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Global plugin config (shared defaults across workspaces).
|
|
260
|
+
mergeHeartbeatFile(path.join(OPENCODE_GLOBAL_DIR, HEARTBEAT_JSONC));
|
|
261
|
+
|
|
262
|
+
// Legacy files kept for backward compatibility.
|
|
263
|
+
mergeHeartbeatFile(path.join(configDir, CONFIG_FILENAME));
|
|
264
|
+
mergeHeartbeatFile(path.join(configDir, OPENCODE_DIR, CONFIG_FILENAME));
|
|
265
|
+
|
|
266
|
+
// Preferred workspace-local plugin config.
|
|
267
|
+
mergeHeartbeatFile(path.join(configDir, OPENCODE_DIR, HEARTBEAT_JSONC));
|
|
268
|
+
|
|
269
|
+
// Explicit Heartbeat override file (highest precedence).
|
|
270
|
+
const explicitHeartbeatConfig =
|
|
271
|
+
process.env[HEARTBEAT_ENV_CONFIG_PATH]?.trim() ??
|
|
272
|
+
process.env[HEARTBEAT_ENV_CONFIG_LEGACY]?.trim();
|
|
273
|
+
if (explicitHeartbeatConfig) {
|
|
274
|
+
mergeHeartbeatFile(resolveOptionalPath(explicitHeartbeatConfig));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
loadedConfig = config;
|
|
278
|
+
|
|
279
|
+
for (const warning of warnings) {
|
|
280
|
+
console.warn(`Warning: ${warning}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return config;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Get the current configuration
|
|
288
|
+
* Loads from file if not already loaded
|
|
289
|
+
*/
|
|
290
|
+
export function getConfig(): HeartbeatConfig {
|
|
291
|
+
if (loadedConfig === null) {
|
|
292
|
+
loadedConfig = loadConfig();
|
|
293
|
+
}
|
|
294
|
+
return loadedConfig;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Reset configuration (useful for testing)
|
|
299
|
+
*/
|
|
300
|
+
export function resetConfig(): void {
|
|
301
|
+
loadedConfig = null;
|
|
302
|
+
configDir = process.cwd();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Get the project directory
|
|
307
|
+
*/
|
|
308
|
+
export function getProjectDir(): string {
|
|
309
|
+
return configDir;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ============================================================================
|
|
313
|
+
// Path Resolvers
|
|
314
|
+
// ============================================================================
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Resolve a path relative to project directory
|
|
318
|
+
*/
|
|
319
|
+
export function resolvePath(relativePath: string): string {
|
|
320
|
+
if (path.isAbsolute(relativePath)) {
|
|
321
|
+
return relativePath;
|
|
322
|
+
}
|
|
323
|
+
return path.resolve(configDir, relativePath);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Get the full log file path
|
|
328
|
+
*/
|
|
329
|
+
export function getLogFilePath(): string {
|
|
330
|
+
const config = getConfig();
|
|
331
|
+
return path.join(resolvePath(config.logs.dir), config.logs.file);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Get the full logs directory path
|
|
336
|
+
*/
|
|
337
|
+
export function getLogsDir(): string {
|
|
338
|
+
const config = getConfig();
|
|
339
|
+
return resolvePath(config.logs.dir);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Get the full jobs directory path
|
|
344
|
+
*/
|
|
345
|
+
export function getJobsDir(): string {
|
|
346
|
+
const config = getConfig();
|
|
347
|
+
return resolvePath(config.scheduler.jobsDir);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Get the default working directory for jobs
|
|
352
|
+
*/
|
|
353
|
+
export function getDefaultWorkdir(): string {
|
|
354
|
+
const config = getConfig();
|
|
355
|
+
return resolvePath(config.scheduler.defaultWorkdir);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get the full task store file path
|
|
360
|
+
*/
|
|
361
|
+
export function getTasksFilePath(): string {
|
|
362
|
+
const config = getConfig();
|
|
363
|
+
return resolvePath(config.taskManager.file);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Get launchd label for a job
|
|
368
|
+
*/
|
|
369
|
+
export function getLaunchdLabel(jobId: string): string {
|
|
370
|
+
return `${LAUNCHD_LABEL_PREFIX}.${jobId}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Get bun executable path
|
|
375
|
+
*/
|
|
376
|
+
export function getBunPath(): string {
|
|
377
|
+
return FIXED_BUN_PATH;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// Config File Generator
|
|
382
|
+
// ============================================================================
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Generate a default config file
|
|
386
|
+
*/
|
|
387
|
+
export function generateConfigFile(projectDir?: string): string {
|
|
388
|
+
const dir = projectDir || configDir;
|
|
389
|
+
const configPath = path.join(dir, CONFIG_FILENAME);
|
|
390
|
+
|
|
391
|
+
const configContent = JSON.stringify(cloneDefaultConfig(), null, 2);
|
|
392
|
+
fs.writeFileSync(configPath, configContent);
|
|
393
|
+
|
|
394
|
+
return configPath;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Check if config file exists
|
|
399
|
+
*/
|
|
400
|
+
export function configFileExists(projectDir?: string): boolean {
|
|
401
|
+
const dir = projectDir || configDir;
|
|
402
|
+
return fs.existsSync(path.join(dir, CONFIG_FILENAME));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ============================================================================
|
|
406
|
+
// Exports
|
|
407
|
+
// ============================================================================
|
|
408
|
+
|
|
409
|
+
export { DEFAULT_CONFIG, CONFIG_FILENAME };
|
package/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type Plugin, tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { MemoryPlugin, searchMemory } from "./memory";
|
|
3
|
+
import { SchedulerPlugin } from "./scheduler";
|
|
4
|
+
import { TaskManagerPlugin, getTask, listTasks } from "./task-manager";
|
|
5
|
+
|
|
6
|
+
type PluginOutput = Awaited<ReturnType<Plugin>>;
|
|
7
|
+
|
|
8
|
+
function mergeTools(outputs: PluginOutput[]): Record<string, unknown> {
|
|
9
|
+
const toolMap: Record<string, unknown> = {};
|
|
10
|
+
for (const output of outputs) {
|
|
11
|
+
if (output?.tool) {
|
|
12
|
+
Object.assign(toolMap, output.tool);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return toolMap;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const HeartbeatPlugin: Plugin = async (ctx) => {
|
|
19
|
+
const [memory, scheduler, taskManager] = await Promise.all([
|
|
20
|
+
MemoryPlugin(ctx),
|
|
21
|
+
SchedulerPlugin(ctx),
|
|
22
|
+
TaskManagerPlugin(ctx),
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const mergedTools = mergeTools([memory, scheduler, taskManager]);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
tool: {
|
|
29
|
+
...mergedTools,
|
|
30
|
+
|
|
31
|
+
heartbeat_boot_context: tool({
|
|
32
|
+
description:
|
|
33
|
+
"Boot helper for a CPU cycle. Returns task state + prior memory for [program][taskId].",
|
|
34
|
+
args: {
|
|
35
|
+
program: tool.schema.string().describe("Program name"),
|
|
36
|
+
taskId: tool.schema.string().describe("Task identifier"),
|
|
37
|
+
memoryLimit: tool.schema
|
|
38
|
+
.number()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("How many recent memory lines to include (default 30)"),
|
|
41
|
+
},
|
|
42
|
+
async execute(args) {
|
|
43
|
+
const memoryLimit =
|
|
44
|
+
typeof args.memoryLimit === "number" && Number.isFinite(args.memoryLimit)
|
|
45
|
+
? args.memoryLimit
|
|
46
|
+
: 30;
|
|
47
|
+
|
|
48
|
+
const snapshot = {
|
|
49
|
+
task: getTask(args.program, args.taskId) ?? null,
|
|
50
|
+
memory: searchMemory({
|
|
51
|
+
program: args.program,
|
|
52
|
+
taskId: args.taskId,
|
|
53
|
+
limit: memoryLimit,
|
|
54
|
+
}),
|
|
55
|
+
queue: {
|
|
56
|
+
pending: listTasks({ program: args.program, status: "pending" }),
|
|
57
|
+
running: listTasks({ program: args.program, status: "running" }),
|
|
58
|
+
blocked: listTasks({ program: args.program, status: "blocked" }),
|
|
59
|
+
raised: listTasks({ program: args.program, status: "raised" }),
|
|
60
|
+
},
|
|
61
|
+
tagFormat: `[${args.program}][${args.taskId}]`,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return JSON.stringify(snapshot, null, 2);
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export default HeartbeatPlugin;
|