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 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`
@@ -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 };
@@ -0,0 +1,13 @@
1
+ {
2
+ "logs": {
3
+ "dir": "./logs",
4
+ "file": "heartbeat.log"
5
+ },
6
+ "scheduler": {
7
+ "jobsDir": "./scheduler/jobs",
8
+ "defaultWorkdir": "."
9
+ },
10
+ "taskManager": {
11
+ "file": "./task-manager/tasks.json"
12
+ }
13
+ }
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;