toolnexus 0.27.0 → 0.28.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.
@@ -14,10 +14,13 @@
14
14
  */
15
15
  import fs from "node:fs"
16
16
  import path from "node:path"
17
+ import { randomBytes } from "node:crypto"
17
18
  import { defineTool } from "../native.js"
18
19
  import type { Tool } from "../types.js"
19
20
  import { agent, Agent, type AgentRunOptions } from "./agent.js"
20
- import { AgentRuntime, isVerbError } from "./runtime.js"
21
+ import { AgentRuntime, isVerbError, type Handle } from "./runtime.js"
22
+ import type { ConversationStore } from "../client.js"
23
+ import { transcriptSearchTool } from "../transcript_search.js"
21
24
 
22
25
  /**
23
26
  * Bootstrap discovery order (§7E): identity first, durable memory last. Each present
@@ -63,16 +66,80 @@ function readCapped(file: string): string | undefined {
63
66
  return buf.toString("utf8")
64
67
  }
65
68
 
69
+ /** The preset index cap (§7E "Memory hierarchy"): Claude Code's 200 lines / 25 KB. */
70
+ export const MEMORY_INDEX_CAP_PRESET = { maxLines: 200, maxBytes: 25600 } as const
71
+
72
+ /** `memoryIndexCap` option: `true` ⇒ the preset; an object's missing field ⇒ the preset's. */
73
+ export type MemoryIndexCap = boolean | { maxLines?: number; maxBytes?: number }
74
+
75
+ /** Options for {@link composeSoul}. Absent ⇒ byte-identical to the pre-hierarchy soul. */
76
+ export interface ComposeSoulOptions {
77
+ /** Second, smaller injection cap for `MEMORY.md` only (whole lines). Off by default. */
78
+ memoryIndexCap?: MemoryIndexCap
79
+ /** Notes tier on — selects the notice wording that points at `note:<slug>`. */
80
+ notes?: boolean
81
+ }
82
+
83
+ function resolveIndexCap(c: MemoryIndexCap | undefined): { maxLines: number; maxBytes: number } | undefined {
84
+ if (c === undefined || c === false) return undefined
85
+ const v = c === true ? {} : c
86
+ const maxLines = v.maxLines ?? MEMORY_INDEX_CAP_PRESET.maxLines
87
+ const maxBytes = v.maxBytes ?? MEMORY_INDEX_CAP_PRESET.maxBytes
88
+ if (!Number.isInteger(maxLines) || maxLines < 1 || !Number.isInteger(maxBytes) || maxBytes < 1) {
89
+ throw new Error("memoryIndexCap: maxLines and maxBytes must be integers >= 1")
90
+ }
91
+ return { maxLines, maxBytes }
92
+ }
93
+
94
+ /** The exact notice appended to a capped `MEMORY.md` (§7E "Memory hierarchy"). */
95
+ export function memoryIndexNotice(maxLines: number, maxBytes: number, notes: boolean): string {
96
+ const tail = notes ? `; move detail into notes (memory target "note:<slug>").` : ` (keep entries short).`
97
+ return `\n\n[MEMORY.md index truncated at ${maxLines} lines / ${maxBytes} bytes. Keep MEMORY.md a short index${tail}]`
98
+ }
99
+
100
+ /** Cut a UTF-8 string at the last character boundary whose byte length is <= `max`. */
101
+ function cutUtf8(s: string, max: number): string {
102
+ let out = ""
103
+ let bytes = 0
104
+ for (const ch of s) {
105
+ const n = Buffer.byteLength(ch)
106
+ if (bytes + n > max) break
107
+ out += ch
108
+ bytes += n
109
+ }
110
+ return out
111
+ }
112
+
113
+ /**
114
+ * Apply the index cap to `MEMORY.md`'s (already 2 MB-capped) content. Lines are split on
115
+ * "\n"; one trailing "\n" does not start a line. Within both bounds ⇒ returned unchanged.
116
+ * Otherwise keep the first `maxLines` lines, drop trailing whole lines until the "\n"-joined
117
+ * UTF-8 length is <= `maxBytes` (a lone over-long first line is cut at a character
118
+ * boundary), and append {@link memoryIndexNotice}.
119
+ */
120
+ export function applyIndexCap(content: string, cap: { maxLines: number; maxBytes: number }, notes = false): string {
121
+ const lines = content.split("\n")
122
+ if (content.endsWith("\n")) lines.pop()
123
+ if (lines.length <= cap.maxLines && Buffer.byteLength(lines.join("\n")) <= cap.maxBytes) return content
124
+ const kept = lines.slice(0, cap.maxLines)
125
+ while (kept.length > 1 && Buffer.byteLength(kept.join("\n")) > cap.maxBytes) kept.pop()
126
+ let text = kept.join("\n")
127
+ if (Buffer.byteLength(text) > cap.maxBytes) text = cutUtf8(text, cap.maxBytes)
128
+ return text + memoryIndexNotice(cap.maxLines, cap.maxBytes, notes)
129
+ }
130
+
66
131
  /**
67
132
  * Compose the bootstrap files present in `dir` into one soul string — the frozen
68
133
  * snapshot injected as the system prompt for the whole run. Only present files
69
134
  * appear, always in {@link BOOTSTRAP_ORDER}, each as a `## <filename>` section.
70
135
  */
71
- export function composeSoul(dir: string): { soul: string; found: string[] } {
136
+ export function composeSoul(dir: string, opts: ComposeSoulOptions = {}): { soul: string; found: string[] } {
72
137
  const sections: string[] = []
73
138
  const found: string[] = []
139
+ const cap = resolveIndexCap(opts.memoryIndexCap)
74
140
  for (const file of BOOTSTRAP_ORDER) {
75
- const body = readCapped(path.join(dir, file))
141
+ let body = readCapped(path.join(dir, file))
142
+ if (body !== undefined && cap && file === "MEMORY.md") body = applyIndexCap(body, cap, opts.notes === true)
76
143
  if (body !== undefined) {
77
144
  sections.push(`## ${file}\n\n${body.trim()}`)
78
145
  found.push(file)
@@ -88,27 +155,107 @@ function memoryFile(dir: string, target: unknown): string {
88
155
  return path.join(dir, target === "user" ? "USER.md" : "MEMORY.md")
89
156
  }
90
157
 
158
+ /** A valid note slug (§7E "Memory hierarchy"): lowercase, no path separators, no `..`. */
159
+ export const NOTE_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/
160
+
161
+ /** Options for {@link memoryTool}. */
162
+ export interface MemoryToolOptions {
163
+ /** Turn on the notes tier: `note:<slug>` targets plus `read`/`list`. Off ⇒ the tool is
164
+ * byte-identical to the pre-hierarchy tool (schema, description, behavior). */
165
+ notes?: boolean
166
+ }
167
+
168
+ const MEMORY_DESCRIPTION =
169
+ "Persist durable memory. action=add appends an entry; replace swaps an " +
170
+ "existing substring (with); remove deletes one. target=self (MEMORY.md, " +
171
+ "default) or user (USER.md). Writes persist to disk and load at the START of " +
172
+ "your NEXT session — they do NOT change your current context."
173
+
174
+ /** The notes-on description (§7E): says what memory is FOR, not rules about what to record. */
175
+ export const MEMORY_NOTES_DESCRIPTION =
176
+ "Persist durable memory. MEMORY.md is a short index loaded at the start of every " +
177
+ "session; put detail in notes (target note:<slug>, stored in memory/<slug>.md, never " +
178
+ "loaded automatically) and point to them from the index. Record what will change a " +
179
+ "future decision. action=add appends an entry; replace swaps the first occurrence of " +
180
+ "an existing substring (with); remove deletes it; read returns a file; list lists the " +
181
+ "notes. target=self (MEMORY.md, default), user (USER.md) or note:<slug> (slug: " +
182
+ "lowercase letters, digits, '.', '_', '-'). Writes persist to disk and load at the " +
183
+ "START of your NEXT session — they do NOT change your current context."
184
+
185
+ /**
186
+ * Atomic replace (§7E write safety): temp file in the same dir, flushed (fsync), renamed
187
+ * over the target; an existing file's permission bits are kept.
188
+ */
189
+ function atomicWrite(file: string, data: string): void {
190
+ fs.mkdirSync(path.dirname(file), { recursive: true })
191
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
192
+ try {
193
+ const fd = fs.openSync(tmp, "w") // new file: 0666 & ~umask
194
+ try {
195
+ fs.writeFileSync(fd, data)
196
+ fs.fsyncSync(fd)
197
+ } finally {
198
+ fs.closeSync(fd)
199
+ }
200
+ // Keep an existing file's permission bits (the rename would otherwise give it the temp's).
201
+ if (process.platform !== "win32" && fs.existsSync(file)) {
202
+ fs.chmodSync(tmp, fs.statSync(file).mode & 0o777)
203
+ }
204
+ fs.renameSync(tmp, file)
205
+ } catch (e) {
206
+ fs.rmSync(tmp, { force: true })
207
+ throw e
208
+ }
209
+ }
210
+
211
+ /** Read-modify-write over the FULL file (never the capped view). In JS the whole RMW is
212
+ * synchronous, so it cannot interleave with another call in this runtime — the per-path
213
+ * serialization §7E requires holds by construction. */
214
+ function rmw(file: string, action: unknown, text: string, withText: unknown): { output: string; isError: true } | undefined {
215
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : ""
216
+ let next: string
217
+ switch (action) {
218
+ case "add":
219
+ next = (current.trimEnd() + `\n- ${text}\n`).trimStart()
220
+ break
221
+ case "replace":
222
+ if (!current.includes(text)) return { output: `not found: ${text}`, isError: true }
223
+ // First occurrence only, inserted LITERALLY (§7E): a function replacement, because a
224
+ // string one would expand `$&`, `$1`, `$$` … in the model's text.
225
+ next = current.replace(text, () => String(withText ?? ""))
226
+ break
227
+ case "remove":
228
+ if (!current.includes(text)) return { output: `not found: ${text}`, isError: true }
229
+ next = current.replace(text, () => "") // first occurrence only (§7E)
230
+ break
231
+ default:
232
+ return { output: `unknown action: ${String(action)}`, isError: true }
233
+ }
234
+ atomicWrite(file, next)
235
+ return undefined
236
+ }
237
+
91
238
  /**
92
239
  * The `memory` builtin (§7E, file-backed, opt-in). One tool, three actions over
93
240
  * `MEMORY.md` (the agent's own notes) and `USER.md` (its model of the user):
94
241
  *
95
242
  * - `add` — append an entry
96
- * - `replace` — swap an existing substring for `with`
97
- * - `remove` — delete an existing substring
243
+ * - `replace` — swap the first occurrence of an existing substring for `with`
244
+ * - `remove` — delete the first occurrence of an existing substring
245
+ *
246
+ * With `{notes: true}` it also takes `target: "note:<slug>"` (`<dir>/memory/<slug>.md`,
247
+ * never injected into the soul) and two read actions, `read` and `list`.
98
248
  *
99
249
  * Every action writes to **disk**. It does **not** touch the live session's system
100
250
  * prompt: the frozen-snapshot rule means the edit loads only at the START of the
101
251
  * next session (keeping a long-lived persona cache-stable). A `replace`/`remove`
102
252
  * whose substring is absent is a loud `isError` — never a silent no-op.
103
253
  */
104
- export function memoryTool(dir: string): Tool {
254
+ export function memoryTool(dir: string, opts: MemoryToolOptions = {}): Tool {
255
+ if (opts.notes === true) return notesMemoryTool(dir)
105
256
  return defineTool({
106
257
  name: "memory",
107
- description:
108
- "Persist durable memory. action=add appends an entry; replace swaps an " +
109
- "existing substring (with); remove deletes one. target=self (MEMORY.md, " +
110
- "default) or user (USER.md). Writes persist to disk and load at the START of " +
111
- "your NEXT session — they do NOT change your current context.",
258
+ description: MEMORY_DESCRIPTION,
112
259
  inputSchema: {
113
260
  type: "object",
114
261
  properties: {
@@ -122,31 +269,92 @@ export function memoryTool(dir: string): Tool {
122
269
  run: (args: Record<string, unknown>) => {
123
270
  const target = (args.target as MemoryTarget) ?? "self"
124
271
  const file = memoryFile(dir, target)
125
- const text = String(args.text)
126
- const current = readCapped(file) ?? ""
127
- let next: string
128
- switch (args.action) {
129
- case "add":
130
- next = (current.trimEnd() + `\n- ${text}\n`).trimStart()
131
- break
132
- case "replace":
133
- if (!current.includes(text)) return { output: `not found: ${text}`, isError: true }
134
- next = current.replace(text, String(args.with ?? ""))
135
- break
136
- case "remove":
137
- if (!current.includes(text)) return { output: `not found: ${text}`, isError: true }
138
- next = current.replace(text, "")
139
- break
140
- default:
141
- return { output: `unknown action: ${String(args.action)}`, isError: true }
142
- }
143
- fs.mkdirSync(path.dirname(file), { recursive: true })
144
- fs.writeFileSync(file, next)
272
+ const err = rmw(file, args.action, String(args.text), args.with)
273
+ if (err) return err
145
274
  return `ok (${String(args.action)} → ${path.basename(file)}); loads next session`
146
275
  },
147
276
  })
148
277
  }
149
278
 
279
+ function notesMemoryTool(dir: string): Tool {
280
+ const notesDir = path.join(dir, "memory")
281
+ return defineTool({
282
+ name: "memory",
283
+ description: MEMORY_NOTES_DESCRIPTION,
284
+ inputSchema: {
285
+ type: "object",
286
+ properties: {
287
+ action: { type: "string", enum: ["add", "replace", "remove", "read", "list"] },
288
+ target: {
289
+ type: "string",
290
+ description: "self=MEMORY.md (default), user=USER.md, note:<slug>=memory/<slug>.md",
291
+ },
292
+ text: { type: "string", description: "For add: the entry. For replace/remove: the existing text." },
293
+ with: { type: "string", description: "For replace: the replacement text." },
294
+ },
295
+ required: ["action"],
296
+ },
297
+ run: (args: Record<string, unknown>) => {
298
+ const action = args.action
299
+ if (action === "list") return listNotes(notesDir)
300
+ const target = args.target === undefined ? "self" : String(args.target)
301
+ let file: string
302
+ let label: string
303
+ if (target === "self" || target === "user") {
304
+ file = memoryFile(dir, target)
305
+ label = path.basename(file)
306
+ } else if (target.startsWith("note:")) {
307
+ const slug = target.slice(5)
308
+ if (!NOTE_SLUG_RE.test(slug) || slug.includes("..")) {
309
+ return { output: `invalid note slug: ${slug}`, isError: true }
310
+ }
311
+ file = path.join(notesDir, `${slug}.md`)
312
+ label = `memory/${slug}.md`
313
+ } else {
314
+ return { output: `unknown target: ${target}`, isError: true }
315
+ }
316
+ if (action === "read") {
317
+ const body = readCapped(file)
318
+ if (body === undefined) return { output: `not found: ${target}`, isError: true }
319
+ return body
320
+ }
321
+ if (action !== "add" && action !== "replace" && action !== "remove") {
322
+ return { output: `unknown action: ${String(action)}`, isError: true }
323
+ }
324
+ if (typeof args.text !== "string") return { output: "text is required", isError: true }
325
+ const err = rmw(file, action, args.text, args.with)
326
+ if (err) return err
327
+ return label.startsWith("memory/") ? `ok (${action} → ${label})` : `ok (${action} → ${label}); loads next session`
328
+ },
329
+ })
330
+ }
331
+
332
+ /** `list` output (§7E): `<slug>\t<bytes>` per note, slug-ascending (explicit sort). */
333
+ function listNotes(notesDir: string): string {
334
+ let names: string[]
335
+ try {
336
+ names = fs.readdirSync(notesDir)
337
+ } catch {
338
+ names = []
339
+ }
340
+ const rows: Array<[string, number]> = []
341
+ for (const name of names) {
342
+ if (!name.endsWith(".md")) continue
343
+ const slug = name.slice(0, -3)
344
+ if (!NOTE_SLUG_RE.test(slug) || slug.includes("..")) continue
345
+ // lstat: a symlink (dangling or not) is not a regular file and is skipped (§7E).
346
+ let st: fs.Stats
347
+ try {
348
+ st = fs.lstatSync(path.join(notesDir, name))
349
+ } catch {
350
+ continue
351
+ }
352
+ if (st.isFile()) rows.push([slug, st.size])
353
+ }
354
+ rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
355
+ return rows.length === 0 ? "(no notes)" : rows.map(([s, n]) => `${s}\t${n}`).join("\n")
356
+ }
357
+
150
358
  /** Options for {@link fromDir}. */
151
359
  export interface FromDirOptions {
152
360
  /** Routing description a delegating model sees; default derived from the dir. */
@@ -159,8 +367,18 @@ export interface FromDirOptions {
159
367
  tools?: Tool[]
160
368
  /** Set `false` to omit the memory tool (a read-only persona). */
161
369
  memory?: boolean
370
+ /** Cap the injected `MEMORY.md` (whole lines; `true` ⇒ 200 lines / 25600 bytes). Off by default. */
371
+ memoryIndexCap?: MemoryIndexCap
372
+ /** Turn on the notes tier of the memory tool (`note:<slug>`, `read`, `list`). Off by default. */
373
+ notes?: boolean
374
+ /** Dream on a schedule: when this persona is started with {@link startAgent}, a
375
+ * {@link dreamer} over the same dir runs on the same runtime every `everyMs`. */
376
+ dream?: { everyMs: number; store: ConversationStore; instructions?: string }
162
377
  }
163
378
 
379
+ /** Dream config attached by `fromDir(…, {dream})`, read by {@link startAgent}. */
380
+ const DREAMS = new WeakMap<Agent, { dreamer: Agent; everyMs: number }>()
381
+
164
382
  /**
165
383
  * The directory IS the agent (§7E). Discovers the bootstrap files in `dir`, composes
166
384
  * them into a frozen soul snapshot, and (unless `memory:false`) wires a `memory` tool
@@ -168,15 +386,92 @@ export interface FromDirOptions {
168
386
  * all apply.
169
387
  */
170
388
  export function fromDir(dir: string, opts: FromDirOptions = {}): Agent {
171
- const { soul } = composeSoul(dir)
389
+ const { soul } = composeSoul(dir, { memoryIndexCap: opts.memoryIndexCap, notes: opts.notes })
172
390
  const tools = [...(opts.tools ?? [])]
173
- if (opts.memory !== false) tools.push(memoryTool(dir))
174
- return agent(opts.name ?? path.basename(dir), {
391
+ if (opts.memory !== false) tools.push(memoryTool(dir, { notes: opts.notes }))
392
+ const name = opts.name ?? path.basename(dir)
393
+ const persona = agent(name, {
175
394
  does: opts.does ?? `persona agent from ${dir}`,
176
395
  soul,
177
396
  model: opts.model ?? "inherit",
178
397
  uses: { tools },
179
398
  })
399
+ if (opts.dream !== undefined) {
400
+ const { everyMs, store, instructions } = opts.dream
401
+ if (!(Number.isFinite(everyMs) && everyMs > 0)) throw new Error("fromDir: dream.everyMs must be > 0")
402
+ DREAMS.set(persona, { dreamer: dreamer(dir, { store, instructions, name: `${name}-dreamer` }), everyMs })
403
+ }
404
+ return persona
405
+ }
406
+
407
+ /**
408
+ * What a dream pass is FOR and the order it works in (§7E "Dreaming"). Byte-identical in
409
+ * every port. It becomes the dreamer's `## HEARTBEAT.md` section, so the ordinary
410
+ * {@link HEARTBEAT_PROMPT} wake drives a pass and `HEARTBEAT_OK` keeps an idle pass silent.
411
+ */
412
+ export const DREAM_PROMPT = [
413
+ "You are dreaming: an offline pass over your own memory. Memory exists so that a future session decides better. MEMORY.md is a short index loaded at the start of every session; notes (memory target \"note:<slug>\") hold the detail; the transcripts (transcript_search) are the ground truth of what actually happened.",
414
+ "Work in this order:",
415
+ "1. Verify: read MEMORY.md and the notes it points to (memory read, memory list) and check each claim against the transcripts. Fix or remove a claim only when it is contradicted by the transcripts; a claim they do not mention stays (transcripts may be pruned).",
416
+ "2. Demote: move detail out of MEMORY.md into a note and leave a one-line [[slug]] pointer, so the index stays short.",
417
+ "3. Merge: fold duplicate or conflicting notes into one.",
418
+ "4. Repair links: point every [[slug]] at an existing note or create it; give every note an index line.",
419
+ "5. Log: if you changed anything, append exactly one entry with memory add, target \"note:log\", text starting \"dream: \" and naming every change and why.",
420
+ "If nothing needs changing, write nothing and reply HEARTBEAT_OK.",
421
+ ].join("\n")
422
+
423
+ /** Options for {@link dreamer}. */
424
+ export interface DreamerOptions {
425
+ /** The transcripts to verify memory against — must implement `list()`. Required. */
426
+ store: ConversationStore
427
+ /** Host prose appended after {@link DREAM_PROMPT} (and after `DREAM.md`, if present). */
428
+ instructions?: string
429
+ /** Agent name; default `<basename(dir)>-dreamer`. */
430
+ name?: string
431
+ /** Model id; default `"inherit"` — the persona's client (use a cheap model there). */
432
+ model?: string
433
+ }
434
+
435
+ /**
436
+ * The built-in dreamer preset (§7E "Dreaming"): a plain {@link Agent} whose tools are
437
+ * EXACTLY `memory` (notes on, over `dir`) and `transcript_search` (over `store`) — never
438
+ * the persona's shell/file tools. Its soul is one `## HEARTBEAT.md` section holding
439
+ * {@link DREAM_PROMPT}, then `DREAM.md` from `dir` (trimmed, if non-empty), then
440
+ * `instructions` (trimmed, if non-empty), joined by blank lines. Schedule it with
441
+ * {@link startAgent} or run one pass with `run(HEARTBEAT_PROMPT, …)`.
442
+ */
443
+ export function dreamer(dir: string, opts: DreamerOptions): Agent {
444
+ if (!opts || !opts.store) throw new Error("dreamer: store is required")
445
+ const search = transcriptSearchTool(opts.store)
446
+ const parts = [DREAM_PROMPT]
447
+ const dreamMd = readCapped(path.join(dir, "DREAM.md"))?.trim()
448
+ if (dreamMd) parts.push(dreamMd)
449
+ const extra = opts.instructions?.trim()
450
+ if (extra) parts.push(extra)
451
+ return agent(opts.name ?? `${path.basename(dir)}-dreamer`, {
452
+ does: `dreams over the memory in ${dir}`,
453
+ soul: `## HEARTBEAT.md\n\n${parts.join("\n\n")}`,
454
+ model: opts.model ?? "inherit",
455
+ uses: { tools: [dreamGuard(memoryTool(dir, { notes: true })), search] },
456
+ })
457
+ }
458
+
459
+ /**
460
+ * The dream guard (§7E "Dreaming"): the dreamer's `memory` tool refuses every action on
461
+ * `USER.md` and `replace`/`remove` on `note:log`, before anything is read or written, so a
462
+ * dream never touches the user model and its audit trail is append-only.
463
+ */
464
+ function dreamGuard(mem: Tool): Tool {
465
+ return {
466
+ ...mem,
467
+ execute: async (args, ctx) => {
468
+ if (args.target === "user") return { output: "dreamer: USER.md is off-limits", isError: true }
469
+ if (args.target === "note:log" && (args.action === "replace" || args.action === "remove")) {
470
+ return { output: "dreamer: memory/log.md is append-only", isError: true }
471
+ }
472
+ return mem.execute(args, ctx)
473
+ },
474
+ }
180
475
  }
181
476
 
182
477
  /** Heartbeat configuration for {@link startAgent}. */
@@ -210,16 +505,61 @@ export interface StartedAgent {
210
505
  * `started.runtime.post`/`wake` on the persona's handle.
211
506
  */
212
507
  export function startAgent(a: Agent, run: AgentRunOptions, hb: HeartbeatOptions): StartedAgent {
213
- const runtime = a.createRuntime(run)
508
+ const dream = DREAMS.get(a)
509
+ if (dream && Object.prototype.hasOwnProperty.call(a.registry(), dream.dreamer.name)) {
510
+ throw new Error(`startAgent: dreamer name collision: ${dream.dreamer.name} is already registered`)
511
+ }
512
+ const runtime = dream
513
+ ? new AgentRuntime({ ...run, registry: { ...dream.dreamer.registry(), ...a.registry() } })
514
+ : a.createRuntime(run)
214
515
  const handle = runtime.spawn(runtime.root, a.name)
215
516
  if (isVerbError(handle)) throw new Error(handle.error)
216
517
 
217
518
  const beats: string[] = []
519
+ const cancels: Array<() => void> = []
218
520
  let stopped = false
219
- let cancel: (() => void) | undefined
521
+ cancels.push(
522
+ heartbeat(runtime, handle, hb.everyMs, () => stopped, (text) => {
523
+ beats.push(text)
524
+ hb.onBeat?.(text)
525
+ }),
526
+ )
527
+ if (dream) {
528
+ // fromDir(…, {dream}): the dreamer runs on the SAME runtime on its own interval. Its
529
+ // replies are housekeeping — they never reach onBeat; what it did is in memory/log.md.
530
+ const dh = runtime.spawn(runtime.root, dream.dreamer.name)
531
+ if (isVerbError(dh)) {
532
+ // Never leak the persona's heartbeat when the dreamer cannot start.
533
+ stopped = true
534
+ for (const c of cancels) c()
535
+ void runtime.close(runtime.root)
536
+ throw new Error(dh.error)
537
+ }
538
+ cancels.push(heartbeat(runtime, dh, dream.everyMs, () => stopped, () => {}))
539
+ }
540
+
541
+ return {
542
+ runtime,
543
+ beats,
544
+ stop: async () => {
545
+ stopped = true
546
+ for (const c of cancels) c()
547
+ await runtime.close(runtime.root)
548
+ },
549
+ }
550
+ }
220
551
 
552
+ /** One self-rescheduling heartbeat on `handle`; returns its cancel. */
553
+ function heartbeat(
554
+ runtime: AgentRuntime,
555
+ handle: Handle,
556
+ everyMs: number,
557
+ isStopped: () => boolean,
558
+ onText: (text: string) => void,
559
+ ): () => void {
560
+ let cancel: (() => void) | undefined
221
561
  const beat = (): void => {
222
- if (stopped) return
562
+ if (isStopped()) return
223
563
  // Unsolicited rail: the tick lands in the inbox (coalesces); a wake — only when
224
564
  // idle — drains the whole inbox as one turn.
225
565
  runtime.post(handle, { from: "clock", channel: "timer", text: "tick" })
@@ -227,26 +567,14 @@ export function startAgent(a: Agent, run: AgentRunOptions, hb: HeartbeatOptions)
227
567
  const woke = runtime.wake(handle, HEARTBEAT_PROMPT)
228
568
  if (woke.ok) {
229
569
  void runtime.wait(handle).then((r) => {
230
- if (!r.isError && !r.text.includes(HEARTBEAT_OK)) {
231
- beats.push(r.text)
232
- hb.onBeat?.(r.text)
233
- }
570
+ if (!r.isError && !r.text.includes(HEARTBEAT_OK)) onText(r.text)
234
571
  })
235
572
  }
236
573
  }
237
574
  // keepAlive:false — a heartbeat is housekeeping and must never be the reason
238
575
  // a host process stays up.
239
- cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false }) // self-reschedule → one live timer
240
- }
241
- cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false })
242
-
243
- return {
244
- runtime,
245
- beats,
246
- stop: async () => {
247
- stopped = true
248
- cancel?.()
249
- await runtime.close(runtime.root)
250
- },
576
+ cancel = runtime.clock.setTimeout(beat, everyMs, { keepAlive: false }) // self-reschedule → one live timer
251
577
  }
578
+ cancel = runtime.clock.setTimeout(beat, everyMs, { keepAlive: false })
579
+ return () => cancel?.()
252
580
  }