@agentproto/runtime 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.
@@ -0,0 +1,120 @@
1
+ import { ConversationStore } from './conversations.js';
2
+
3
+ /**
4
+ * In-process event bus for the runtime gateway. Anything the daemon
5
+ * does that an external observer might want to watch (heartbeat fired,
6
+ * conversation turn appended, boot completed, …) flows through here.
7
+ *
8
+ * The HTTP server's `/events` SSE stream subscribes to it and forwards
9
+ * each event as a `data: <json>\n\n` frame. Tests and embedded usage
10
+ * subscribe directly via `events.on()`.
11
+ */
12
+ interface BootEvent {
13
+ type: "boot";
14
+ at: string;
15
+ workspace: string;
16
+ registered: readonly string[];
17
+ }
18
+ interface HeartbeatFiredEvent {
19
+ type: "heartbeat-fired";
20
+ at: string;
21
+ agent: string;
22
+ conversationId: string;
23
+ prompt: string;
24
+ reply: string;
25
+ durationMs: number;
26
+ }
27
+ interface HeartbeatErrorEvent {
28
+ type: "heartbeat-error";
29
+ at: string;
30
+ agent?: string;
31
+ error: string;
32
+ }
33
+ interface ConvTurnEvent {
34
+ type: "conv-turn-appended";
35
+ at: string;
36
+ conversationId: string;
37
+ role: "user" | "assistant";
38
+ contentPreview: string;
39
+ }
40
+ /**
41
+ * One log line from a supervised tunnel provider (cloudflared today).
42
+ * Surfaced verbatim so users can debug "why didn't my remote URL come
43
+ * up" by tailing /events.
44
+ */
45
+ interface RemoteLogEvent {
46
+ type: "remote-log";
47
+ at: string;
48
+ line: string;
49
+ }
50
+ type RuntimeEvent = BootEvent | HeartbeatFiredEvent | HeartbeatErrorEvent | ConvTurnEvent | RemoteLogEvent;
51
+ interface RuntimeEvents {
52
+ on<E extends RuntimeEvent["type"]>(type: E, handler: (ev: Extract<RuntimeEvent, {
53
+ type: E;
54
+ }>) => void): () => void;
55
+ onAny(handler: (ev: RuntimeEvent) => void): () => void;
56
+ emit(ev: RuntimeEvent): void;
57
+ }
58
+
59
+ /**
60
+ * HEARTBEAT.md autonomy loop.
61
+ *
62
+ * A workspace can ship a `HEARTBEAT.md` at its root. Frontmatter:
63
+ *
64
+ * schema: heartbeat/v1
65
+ * agent: <id> (matches `<workspace>/.agents/<id>/AGENT.md`)
66
+ * every: "60s" (Ns | Nm | Nh | Nd; cron support TODO)
67
+ * enabled: true (default true; set false to register-but-skip)
68
+ * conversation: heartbeat-{date} (templated id; defaults to per-day)
69
+ *
70
+ * Body = the prompt body the agent receives each tick. Reread fresh
71
+ * on every tick so editing HEARTBEAT.md takes effect without restart.
72
+ *
73
+ * Ticks resolve the agent via the injected `buildAgent` callback,
74
+ * call `agent.generate(prompt)`, and append the prompt + reply to a
75
+ * conversation (default: daily-rolling `heartbeat-YYYY-MM-DD`).
76
+ *
77
+ * When the file is missing, frontmatter is invalid, `enabled: false`,
78
+ * or the resolved agent has no usable model — the runner skips the
79
+ * tick silently and emits a `heartbeat-error` event. The daemon
80
+ * stays up.
81
+ */
82
+
83
+ interface HeartbeatAgent {
84
+ generate(prompt: string): Promise<{
85
+ text: string;
86
+ }>;
87
+ }
88
+ interface BuildHeartbeatAgent {
89
+ (id: string): Promise<HeartbeatAgent | null>;
90
+ }
91
+ interface HeartbeatRunnerOptions {
92
+ workspace: string;
93
+ conversations: ConversationStore;
94
+ events: RuntimeEvents;
95
+ /** Resolves a heartbeat-runnable agent from its workspace id. */
96
+ buildAgent: BuildHeartbeatAgent;
97
+ /** Filename inside the workspace root. Default `HEARTBEAT.md`. */
98
+ filename?: string;
99
+ /** Skip ticks while this returns false. Default: always true. */
100
+ shouldFire?: () => boolean;
101
+ /** Override clock for tests. */
102
+ now?: () => Date;
103
+ }
104
+ interface HeartbeatRunner {
105
+ /** Start the timer loop. Idempotent. */
106
+ start(): void;
107
+ /** Stop the timer loop. Pending ticks finish out. */
108
+ stop(): void;
109
+ /** Force-fire one tick now (used by `POST /heartbeat/tick`). */
110
+ fireNow(): Promise<void>;
111
+ }
112
+ declare function startHeartbeat(opts: HeartbeatRunnerOptions): HeartbeatRunner;
113
+ /**
114
+ * Parse `Ns | Nm | Nh | Nd` into milliseconds. Returns `null` if the
115
+ * string doesn't match. (AIP-41 supports more units, but the daemon's
116
+ * heartbeat sticks to coarse durations to keep ticks observable.)
117
+ */
118
+ declare function parseDuration(input: string): number | null;
119
+
120
+ export { type BuildHeartbeatAgent as B, type HeartbeatAgent as H, type RuntimeEvent as R, type HeartbeatRunner as a, type RuntimeEvents as b, type HeartbeatRunnerOptions as c, parseDuration as p, startHeartbeat as s };
@@ -0,0 +1,2 @@
1
+ import './conversations.js';
2
+ export { B as BuildHeartbeatAgent, H as HeartbeatAgent, a as HeartbeatRunner, c as HeartbeatRunnerOptions, p as parseDuration, s as startHeartbeat } from './heartbeat-COGpMrJS.js';
@@ -0,0 +1,185 @@
1
+ import matter from 'gray-matter';
2
+ import { existsSync } from 'fs';
3
+ import { readFile } from 'fs/promises';
4
+ import { join } from 'path';
5
+
6
+ /**
7
+ * @agentproto/runtime v0.1.0-alpha
8
+ * Long-running gateway: MCP server + HTTP transport + HEARTBEAT autonomy + conversation persistence over a workspace dir.
9
+ */
10
+
11
+ function startHeartbeat(opts) {
12
+ const filename = opts.filename ?? "HEARTBEAT.md";
13
+ const path = join(opts.workspace, filename);
14
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
15
+ let timer = null;
16
+ let currentEveryMs = null;
17
+ let firing = false;
18
+ async function load() {
19
+ if (!existsSync(path)) return null;
20
+ let source;
21
+ try {
22
+ source = await readFile(path, "utf8");
23
+ } catch {
24
+ return null;
25
+ }
26
+ const parsed = matter(source);
27
+ const fm = parsed.data;
28
+ const agent = typeof fm.agent === "string" ? fm.agent : null;
29
+ if (!agent) return null;
30
+ const enabled = fm.enabled === void 0 ? true : Boolean(fm.enabled);
31
+ const everyRaw = typeof fm.every === "string" ? fm.every : "60s";
32
+ const everyMs = parseDuration(everyRaw);
33
+ if (!everyMs) return null;
34
+ const conversationTemplate = typeof fm.conversation === "string" ? fm.conversation : "heartbeat-{date}";
35
+ return {
36
+ agent,
37
+ everyMs,
38
+ enabled,
39
+ conversationTemplate,
40
+ body: parsed.content.trim()
41
+ };
42
+ }
43
+ async function tick() {
44
+ if (firing) return;
45
+ if (opts.shouldFire && !opts.shouldFire()) return;
46
+ firing = true;
47
+ let agentId;
48
+ try {
49
+ const hb = await load();
50
+ if (!hb || !hb.enabled) return;
51
+ agentId = hb.agent;
52
+ const agent = await opts.buildAgent(hb.agent);
53
+ if (!agent) {
54
+ opts.events.emit({
55
+ type: "heartbeat-error",
56
+ at: now().toISOString(),
57
+ agent: hb.agent,
58
+ error: `buildAgent('${hb.agent}') returned null`
59
+ });
60
+ return;
61
+ }
62
+ if (!hb.body) {
63
+ opts.events.emit({
64
+ type: "heartbeat-error",
65
+ at: now().toISOString(),
66
+ agent: hb.agent,
67
+ error: "HEARTBEAT.md body is empty"
68
+ });
69
+ return;
70
+ }
71
+ const conversationId = expandTemplate(hb.conversationTemplate, now());
72
+ await opts.conversations.open(conversationId, { agent: hb.agent });
73
+ const startedAt = now();
74
+ await opts.conversations.appendTurn(
75
+ conversationId,
76
+ "user",
77
+ hb.body,
78
+ { at: startedAt.toISOString(), attribution: "heartbeat" }
79
+ );
80
+ opts.events.emit({
81
+ type: "conv-turn-appended",
82
+ at: startedAt.toISOString(),
83
+ conversationId,
84
+ role: "user",
85
+ contentPreview: preview(hb.body)
86
+ });
87
+ const t0 = Date.now();
88
+ const reply = await agent.generate(hb.body);
89
+ const durationMs = Date.now() - t0;
90
+ const finishedAt = now();
91
+ await opts.conversations.appendTurn(
92
+ conversationId,
93
+ "assistant",
94
+ reply.text,
95
+ { at: finishedAt.toISOString(), attribution: hb.agent }
96
+ );
97
+ opts.events.emit({
98
+ type: "conv-turn-appended",
99
+ at: finishedAt.toISOString(),
100
+ conversationId,
101
+ role: "assistant",
102
+ contentPreview: preview(reply.text)
103
+ });
104
+ opts.events.emit({
105
+ type: "heartbeat-fired",
106
+ at: finishedAt.toISOString(),
107
+ agent: hb.agent,
108
+ conversationId,
109
+ prompt: hb.body,
110
+ reply: reply.text,
111
+ durationMs
112
+ });
113
+ if (currentEveryMs !== null && currentEveryMs !== hb.everyMs) {
114
+ reschedule(hb.everyMs);
115
+ }
116
+ } catch (err) {
117
+ opts.events.emit({
118
+ type: "heartbeat-error",
119
+ at: now().toISOString(),
120
+ agent: agentId,
121
+ error: err instanceof Error ? err.message : String(err)
122
+ });
123
+ } finally {
124
+ firing = false;
125
+ }
126
+ }
127
+ function reschedule(everyMs) {
128
+ if (timer) clearInterval(timer);
129
+ currentEveryMs = everyMs;
130
+ timer = setInterval(() => {
131
+ void tick();
132
+ }, everyMs);
133
+ }
134
+ return {
135
+ start() {
136
+ if (timer) return;
137
+ void load().then((hb) => {
138
+ const everyMs = hb?.everyMs ?? 6e4;
139
+ reschedule(everyMs);
140
+ });
141
+ },
142
+ stop() {
143
+ if (timer) {
144
+ clearInterval(timer);
145
+ timer = null;
146
+ currentEveryMs = null;
147
+ }
148
+ },
149
+ async fireNow() {
150
+ await tick();
151
+ }
152
+ };
153
+ }
154
+ var DURATION_RE = /^(\d+)\s*(s|m|h|d)$/;
155
+ function parseDuration(input) {
156
+ const trimmed = input.trim();
157
+ const match = trimmed.match(DURATION_RE);
158
+ if (!match) return null;
159
+ const n = Number(match[1]);
160
+ if (!Number.isFinite(n) || n <= 0) return null;
161
+ switch (match[2]) {
162
+ case "s":
163
+ return n * 1e3;
164
+ case "m":
165
+ return n * 6e4;
166
+ case "h":
167
+ return n * 36e5;
168
+ case "d":
169
+ return n * 864e5;
170
+ default:
171
+ return null;
172
+ }
173
+ }
174
+ function expandTemplate(template, when) {
175
+ const date = when.toISOString().slice(0, 10);
176
+ return template.replace(/\{date\}/g, date);
177
+ }
178
+ function preview(text, max = 120) {
179
+ const flat = text.replace(/\s+/g, " ").trim();
180
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
181
+ }
182
+
183
+ export { parseDuration, startHeartbeat };
184
+ //# sourceMappingURL=heartbeat.mjs.map
185
+ //# sourceMappingURL=heartbeat.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/heartbeat.ts"],"names":[],"mappings":"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","file":"heartbeat.mjs","sourcesContent":["/**\n * HEARTBEAT.md autonomy loop.\n *\n * A workspace can ship a `HEARTBEAT.md` at its root. Frontmatter:\n *\n * schema: heartbeat/v1\n * agent: <id> (matches `<workspace>/.agents/<id>/AGENT.md`)\n * every: \"60s\" (Ns | Nm | Nh | Nd; cron support TODO)\n * enabled: true (default true; set false to register-but-skip)\n * conversation: heartbeat-{date} (templated id; defaults to per-day)\n *\n * Body = the prompt body the agent receives each tick. Reread fresh\n * on every tick so editing HEARTBEAT.md takes effect without restart.\n *\n * Ticks resolve the agent via the injected `buildAgent` callback,\n * call `agent.generate(prompt)`, and append the prompt + reply to a\n * conversation (default: daily-rolling `heartbeat-YYYY-MM-DD`).\n *\n * When the file is missing, frontmatter is invalid, `enabled: false`,\n * or the resolved agent has no usable model — the runner skips the\n * tick silently and emits a `heartbeat-error` event. The daemon\n * stays up.\n */\n\nimport matter from \"gray-matter\"\nimport { existsSync } from \"node:fs\"\nimport { readFile } from \"node:fs/promises\"\nimport { join } from \"node:path\"\nimport type { ConversationStore } from \"./conversations.js\"\nimport type { RuntimeEvents } from \"./events.js\"\n\nexport interface HeartbeatAgent {\n generate(prompt: string): Promise<{ text: string }>\n}\n\nexport interface BuildHeartbeatAgent {\n (id: string): Promise<HeartbeatAgent | null>\n}\n\nexport interface HeartbeatRunnerOptions {\n workspace: string\n conversations: ConversationStore\n events: RuntimeEvents\n /** Resolves a heartbeat-runnable agent from its workspace id. */\n buildAgent: BuildHeartbeatAgent\n /** Filename inside the workspace root. Default `HEARTBEAT.md`. */\n filename?: string\n /** Skip ticks while this returns false. Default: always true. */\n shouldFire?: () => boolean\n /** Override clock for tests. */\n now?: () => Date\n}\n\nexport interface HeartbeatRunner {\n /** Start the timer loop. Idempotent. */\n start(): void\n /** Stop the timer loop. Pending ticks finish out. */\n stop(): void\n /** Force-fire one tick now (used by `POST /heartbeat/tick`). */\n fireNow(): Promise<void>\n}\n\ninterface ParsedHeartbeat {\n agent: string\n everyMs: number\n enabled: boolean\n conversationTemplate: string\n body: string\n}\n\nexport function startHeartbeat(opts: HeartbeatRunnerOptions): HeartbeatRunner {\n const filename = opts.filename ?? \"HEARTBEAT.md\"\n const path = join(opts.workspace, filename)\n const now = opts.now ?? (() => new Date())\n\n let timer: NodeJS.Timeout | null = null\n let currentEveryMs: number | null = null\n let firing = false\n\n async function load(): Promise<ParsedHeartbeat | null> {\n if (!existsSync(path)) return null\n let source: string\n try {\n source = await readFile(path, \"utf8\")\n } catch {\n return null\n }\n const parsed = matter(source)\n const fm = parsed.data as Record<string, unknown>\n const agent = typeof fm.agent === \"string\" ? fm.agent : null\n if (!agent) return null\n const enabled = fm.enabled === undefined ? true : Boolean(fm.enabled)\n const everyRaw = typeof fm.every === \"string\" ? fm.every : \"60s\"\n const everyMs = parseDuration(everyRaw)\n if (!everyMs) return null\n const conversationTemplate =\n typeof fm.conversation === \"string\"\n ? fm.conversation\n : \"heartbeat-{date}\"\n return {\n agent,\n everyMs,\n enabled,\n conversationTemplate,\n body: parsed.content.trim(),\n }\n }\n\n async function tick(): Promise<void> {\n if (firing) return\n if (opts.shouldFire && !opts.shouldFire()) return\n firing = true\n let agentId: string | undefined\n try {\n const hb = await load()\n if (!hb || !hb.enabled) return\n agentId = hb.agent\n const agent = await opts.buildAgent(hb.agent)\n if (!agent) {\n opts.events.emit({\n type: \"heartbeat-error\",\n at: now().toISOString(),\n agent: hb.agent,\n error: `buildAgent('${hb.agent}') returned null`,\n })\n return\n }\n if (!hb.body) {\n opts.events.emit({\n type: \"heartbeat-error\",\n at: now().toISOString(),\n agent: hb.agent,\n error: \"HEARTBEAT.md body is empty\",\n })\n return\n }\n\n const conversationId = expandTemplate(hb.conversationTemplate, now())\n await opts.conversations.open(conversationId, { agent: hb.agent })\n\n const startedAt = now()\n await opts.conversations.appendTurn(\n conversationId,\n \"user\",\n hb.body,\n { at: startedAt.toISOString(), attribution: \"heartbeat\" },\n )\n opts.events.emit({\n type: \"conv-turn-appended\",\n at: startedAt.toISOString(),\n conversationId,\n role: \"user\",\n contentPreview: preview(hb.body),\n })\n\n const t0 = Date.now()\n const reply = await agent.generate(hb.body)\n const durationMs = Date.now() - t0\n\n const finishedAt = now()\n await opts.conversations.appendTurn(\n conversationId,\n \"assistant\",\n reply.text,\n { at: finishedAt.toISOString(), attribution: hb.agent },\n )\n opts.events.emit({\n type: \"conv-turn-appended\",\n at: finishedAt.toISOString(),\n conversationId,\n role: \"assistant\",\n contentPreview: preview(reply.text),\n })\n opts.events.emit({\n type: \"heartbeat-fired\",\n at: finishedAt.toISOString(),\n agent: hb.agent,\n conversationId,\n prompt: hb.body,\n reply: reply.text,\n durationMs,\n })\n\n // Adapt the timer if `every` changed since we started.\n if (currentEveryMs !== null && currentEveryMs !== hb.everyMs) {\n reschedule(hb.everyMs)\n }\n } catch (err) {\n opts.events.emit({\n type: \"heartbeat-error\",\n at: now().toISOString(),\n agent: agentId,\n error: err instanceof Error ? err.message : String(err),\n })\n } finally {\n firing = false\n }\n }\n\n function reschedule(everyMs: number): void {\n if (timer) clearInterval(timer)\n currentEveryMs = everyMs\n timer = setInterval(() => {\n void tick()\n }, everyMs)\n }\n\n return {\n start() {\n if (timer) return\n // Bootstrap interval from the file (or fall back to 60s if\n // unreadable — first real tick will refresh from disk).\n void load().then((hb) => {\n const everyMs = hb?.everyMs ?? 60_000\n reschedule(everyMs)\n })\n },\n stop() {\n if (timer) {\n clearInterval(timer)\n timer = null\n currentEveryMs = null\n }\n },\n async fireNow() {\n await tick()\n },\n }\n}\n\n// ── helpers ──────────────────────────────────────────────────────────\n\nconst DURATION_RE = /^(\\d+)\\s*(s|m|h|d)$/\n\n/**\n * Parse `Ns | Nm | Nh | Nd` into milliseconds. Returns `null` if the\n * string doesn't match. (AIP-41 supports more units, but the daemon's\n * heartbeat sticks to coarse durations to keep ticks observable.)\n */\nexport function parseDuration(input: string): number | null {\n const trimmed = input.trim()\n const match = trimmed.match(DURATION_RE)\n if (!match) return null\n const n = Number(match[1])\n if (!Number.isFinite(n) || n <= 0) return null\n switch (match[2]) {\n case \"s\":\n return n * 1_000\n case \"m\":\n return n * 60_000\n case \"h\":\n return n * 3_600_000\n case \"d\":\n return n * 86_400_000\n default:\n return null\n }\n}\n\n/**\n * Replace `{date}` with `YYYY-MM-DD` (UTC) in conversation templates.\n * Other tokens pass through unchanged — extend later if needed.\n */\nfunction expandTemplate(template: string, when: Date): string {\n const date = when.toISOString().slice(0, 10)\n return template.replace(/\\{date\\}/g, date)\n}\n\nfunction preview(text: string, max = 120): string {\n const flat = text.replace(/\\s+/g, \" \").trim()\n return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat\n}\n"]}