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/memory/index.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { type Plugin, tool } from "@opencode-ai/plugin";
|
|
3
|
+
import { getLogFilePath, getLogsDir, loadConfig } from "../config";
|
|
4
|
+
|
|
5
|
+
function ensureDir(dir: string): void {
|
|
6
|
+
if (!fs.existsSync(dir)) {
|
|
7
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isoNow(): string {
|
|
12
|
+
return new Date().toISOString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function initLogger(projectDir?: string): void {
|
|
16
|
+
loadConfig(projectDir);
|
|
17
|
+
ensureDir(getLogsDir());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function appendLog(line: string): void {
|
|
21
|
+
ensureDir(getLogsDir());
|
|
22
|
+
const entry = `[${isoNow()}] ${line}\n`;
|
|
23
|
+
fs.appendFileSync(getLogFilePath(), entry);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function writeLog(input: {
|
|
27
|
+
program: string;
|
|
28
|
+
taskId: string;
|
|
29
|
+
message: string;
|
|
30
|
+
}): string {
|
|
31
|
+
const tagged = `[${input.program}][${input.taskId}] ${input.message}`;
|
|
32
|
+
appendLog(tagged);
|
|
33
|
+
return `Logged: ${tagged}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LOG_ENTRY_PREFIX =
|
|
37
|
+
/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\]\s/;
|
|
38
|
+
|
|
39
|
+
function readAllEntries(limit?: number): string[] {
|
|
40
|
+
const path = getLogFilePath();
|
|
41
|
+
if (!fs.existsSync(path)) return [];
|
|
42
|
+
|
|
43
|
+
const raw = fs.readFileSync(path, "utf-8");
|
|
44
|
+
if (raw.trim().length === 0) return [];
|
|
45
|
+
|
|
46
|
+
const lines = raw.split(/\r?\n/);
|
|
47
|
+
const entries: string[] = [];
|
|
48
|
+
let current: string[] = [];
|
|
49
|
+
|
|
50
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
51
|
+
const line = lines[index];
|
|
52
|
+
|
|
53
|
+
// Ignore terminal newline from file ending; preserve intentional blank lines inside entries.
|
|
54
|
+
if (index === lines.length - 1 && line.length === 0) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (line.length === 0 && current.length === 0) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (LOG_ENTRY_PREFIX.test(line)) {
|
|
63
|
+
if (current.length > 0) {
|
|
64
|
+
entries.push(current.join("\n"));
|
|
65
|
+
}
|
|
66
|
+
current = [line];
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Continuation of a multiline log entry.
|
|
71
|
+
if (current.length > 0) {
|
|
72
|
+
current.push(line);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Backward compatibility for malformed/legacy lines without timestamp.
|
|
77
|
+
current = [line];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (current.length > 0) {
|
|
81
|
+
entries.push(current.join("\n"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (limit && limit > 0) {
|
|
85
|
+
return entries.slice(-limit);
|
|
86
|
+
}
|
|
87
|
+
return entries;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function readAllLogs(limit?: number): string[] {
|
|
91
|
+
return readAllEntries(limit);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function searchMemory(input: {
|
|
95
|
+
program: string;
|
|
96
|
+
taskId?: string;
|
|
97
|
+
limit?: number;
|
|
98
|
+
}): string[] {
|
|
99
|
+
const programTag = `[${input.program}]`;
|
|
100
|
+
const taskTag = input.taskId ? `[${input.program}][${input.taskId}]` : null;
|
|
101
|
+
const entries = readAllEntries();
|
|
102
|
+
|
|
103
|
+
const matches = entries.filter((entry) => {
|
|
104
|
+
if (taskTag) return entry.includes(taskTag);
|
|
105
|
+
return entry.includes(programTag);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (input.limit && input.limit > 0) {
|
|
109
|
+
return matches.slice(-input.limit);
|
|
110
|
+
}
|
|
111
|
+
return matches;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function readProgram(input: {
|
|
115
|
+
program: string;
|
|
116
|
+
limit?: number;
|
|
117
|
+
}): string[] {
|
|
118
|
+
return searchMemory(input);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function readProgramTask(input: {
|
|
122
|
+
program: string;
|
|
123
|
+
taskId: string;
|
|
124
|
+
limit?: number;
|
|
125
|
+
}): string[] {
|
|
126
|
+
return searchMemory(input);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function grepLogs(input: { pattern: string; limit?: number }): string[] {
|
|
130
|
+
const regex = new RegExp(input.pattern);
|
|
131
|
+
const entries = readAllEntries();
|
|
132
|
+
const matches = entries.filter((entry) => regex.test(entry));
|
|
133
|
+
|
|
134
|
+
if (input.limit && input.limit > 0) {
|
|
135
|
+
return matches.slice(-input.limit);
|
|
136
|
+
}
|
|
137
|
+
return matches;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function tailLogs(lines = 50): string[] {
|
|
141
|
+
return readAllLogs(lines);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function getLogInfo(): {
|
|
145
|
+
path: string;
|
|
146
|
+
exists: boolean;
|
|
147
|
+
sizeBytes: number;
|
|
148
|
+
lineCount: number;
|
|
149
|
+
} {
|
|
150
|
+
const path = getLogFilePath();
|
|
151
|
+
const exists = fs.existsSync(path);
|
|
152
|
+
|
|
153
|
+
if (!exists) {
|
|
154
|
+
return {
|
|
155
|
+
path,
|
|
156
|
+
exists: false,
|
|
157
|
+
sizeBytes: 0,
|
|
158
|
+
lineCount: 0,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const stats = fs.statSync(path);
|
|
163
|
+
const lineCount = readAllLogs().length;
|
|
164
|
+
return {
|
|
165
|
+
path,
|
|
166
|
+
exists: true,
|
|
167
|
+
sizeBytes: stats.size,
|
|
168
|
+
lineCount,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function linesToText(lines: string[]): string {
|
|
173
|
+
if (lines.length === 0) return "No matching log entries found.";
|
|
174
|
+
return lines.join("\n\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const MemoryPlugin: Plugin = async (ctx) => {
|
|
178
|
+
initLogger(ctx.directory);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
tool: {
|
|
182
|
+
memory_write: tool({
|
|
183
|
+
description: "Write a tagged log line as [program][taskId] message.",
|
|
184
|
+
args: {
|
|
185
|
+
program: tool.schema.string().describe("Program name"),
|
|
186
|
+
taskId: tool.schema.string().describe("Task identifier"),
|
|
187
|
+
message: tool.schema.string().describe("Message content"),
|
|
188
|
+
},
|
|
189
|
+
async execute(args) {
|
|
190
|
+
return writeLog(args);
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
|
|
194
|
+
write_log: tool({
|
|
195
|
+
description:
|
|
196
|
+
"Alias for memory_write. Writes tagged memory lines into the shared heartbeat log.",
|
|
197
|
+
args: {
|
|
198
|
+
program: tool.schema.string().describe("Program name"),
|
|
199
|
+
taskId: tool.schema.string().describe("Task identifier"),
|
|
200
|
+
message: tool.schema.string().describe("Message content"),
|
|
201
|
+
},
|
|
202
|
+
async execute(args) {
|
|
203
|
+
return writeLog(args);
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
|
|
207
|
+
search_memory: tool({
|
|
208
|
+
description:
|
|
209
|
+
"Search memory by [program] and optional [taskId]. Use this on boot to restore previous cycle context.",
|
|
210
|
+
args: {
|
|
211
|
+
program: tool.schema.string().describe("Program name"),
|
|
212
|
+
taskId: tool.schema
|
|
213
|
+
.string()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe(
|
|
216
|
+
"Optional task identifier for exact [program][taskId] lookup",
|
|
217
|
+
),
|
|
218
|
+
limit: tool.schema
|
|
219
|
+
.number()
|
|
220
|
+
.optional()
|
|
221
|
+
.describe("Return only the most recent N matching lines"),
|
|
222
|
+
},
|
|
223
|
+
async execute(args) {
|
|
224
|
+
const lines = searchMemory(args);
|
|
225
|
+
return linesToText(lines);
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
|
|
229
|
+
read_program: tool({
|
|
230
|
+
description: "Read all log lines containing [program].",
|
|
231
|
+
args: {
|
|
232
|
+
program: tool.schema.string().describe("Program name"),
|
|
233
|
+
limit: tool.schema.number().optional().describe("Optional max lines"),
|
|
234
|
+
},
|
|
235
|
+
async execute(args) {
|
|
236
|
+
return linesToText(readProgram(args));
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
|
|
240
|
+
read_program_task: tool({
|
|
241
|
+
description: "Read all log lines containing [program][taskId].",
|
|
242
|
+
args: {
|
|
243
|
+
program: tool.schema.string().describe("Program name"),
|
|
244
|
+
taskId: tool.schema.string().describe("Task identifier"),
|
|
245
|
+
limit: tool.schema.number().optional().describe("Optional max lines"),
|
|
246
|
+
},
|
|
247
|
+
async execute(args) {
|
|
248
|
+
return linesToText(readProgramTask(args));
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
251
|
+
|
|
252
|
+
memory_grep: tool({
|
|
253
|
+
description:
|
|
254
|
+
"Regex grep across the shared heartbeat log file. Useful for free-form memory queries.",
|
|
255
|
+
args: {
|
|
256
|
+
pattern: tool.schema.string().describe("JavaScript regex pattern"),
|
|
257
|
+
limit: tool.schema.number().optional().describe("Optional max lines"),
|
|
258
|
+
},
|
|
259
|
+
async execute(args) {
|
|
260
|
+
try {
|
|
261
|
+
return linesToText(grepLogs(args));
|
|
262
|
+
} catch {
|
|
263
|
+
return `Invalid regex pattern: ${args.pattern}`;
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
|
|
268
|
+
grep_logs: tool({
|
|
269
|
+
description: "Alias for memory_grep.",
|
|
270
|
+
args: {
|
|
271
|
+
pattern: tool.schema.string().describe("JavaScript regex pattern"),
|
|
272
|
+
limit: tool.schema.number().optional().describe("Optional max lines"),
|
|
273
|
+
},
|
|
274
|
+
async execute(args) {
|
|
275
|
+
try {
|
|
276
|
+
return linesToText(grepLogs(args));
|
|
277
|
+
} catch {
|
|
278
|
+
return `Invalid regex pattern: ${args.pattern}`;
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
|
|
283
|
+
tail_logs: tool({
|
|
284
|
+
description: "Return recent lines from the shared heartbeat log.",
|
|
285
|
+
args: {
|
|
286
|
+
lines: tool.schema
|
|
287
|
+
.number()
|
|
288
|
+
.optional()
|
|
289
|
+
.describe("Number of lines to return"),
|
|
290
|
+
},
|
|
291
|
+
async execute(args) {
|
|
292
|
+
return linesToText(tailLogs(args.lines ?? 50));
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
|
|
296
|
+
log_info: tool({
|
|
297
|
+
description: "Get shared log file path and stats.",
|
|
298
|
+
args: {},
|
|
299
|
+
async execute() {
|
|
300
|
+
return JSON.stringify(getLogInfo(), null, 2);
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
export { getLogFilePath };
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "heartbeat-opencode-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Heartbeat Runtime",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"module": "./index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.ts",
|
|
13
|
+
"config",
|
|
14
|
+
"memory",
|
|
15
|
+
"scheduler",
|
|
16
|
+
"task-manager",
|
|
17
|
+
"heartbeat.config.json",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"keywords": [
|
|
21
|
+
"opencode",
|
|
22
|
+
"plugin",
|
|
23
|
+
"scheduler",
|
|
24
|
+
"memory",
|
|
25
|
+
"cron"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "bunx tsc",
|
|
33
|
+
"prepublishOnly": "bun run typecheck"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/bun": "latest"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"typescript": "^5.0.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@opencode-ai/plugin": "^1.2.9"
|
|
43
|
+
}
|
|
44
|
+
}
|