toolnexus 0.9.5 → 0.10.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/dist/client.d.ts CHANGED
@@ -184,10 +184,14 @@ export interface RunResult {
184
184
  usage: Usage;
185
185
  /** The model used. */
186
186
  model: string;
187
- /** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10). */
188
- status: "done" | "pending";
187
+ /** "done" normally; "pending" iff a tool suspended and no waitFor was configured (§10);
188
+ * "incomplete" iff a limit stopped the run loudly (§7D) — a maxTurns stop with no final
189
+ * text is never a silent "done". */
190
+ status: "done" | "pending" | "incomplete";
189
191
  /** The unresolved suspension — present iff status === "pending" (§10). */
190
192
  pending?: Request;
193
+ /** The limit that stopped the run (e.g. "maxTurns") — present iff status === "incomplete". */
194
+ limit?: string;
191
195
  }
192
196
  /** Events yielded by client.stream(). */
193
197
  export type StreamEvent = {
@@ -242,7 +246,8 @@ export declare class Client {
242
246
  metrics(): string;
243
247
  /** Feed a semantic metric event to the built-in registry and the optional onMetric sink. */
244
248
  private emit;
245
- /** Emit the terminal `run` metric event and build the RunResult. */
249
+ /** Emit the terminal `run` metric event and build the RunResult. `exhausted` marks a
250
+ * maxTurns loop exit — with no final text that is a LOUD "incomplete", never "done" (§7D). */
246
251
  private endRun;
247
252
  /** Emit a `run` error metric event (once, on a thrown run). */
248
253
  private emitRunError;
package/dist/client.js CHANGED
@@ -213,10 +213,11 @@ export class Client {
213
213
  this.registry.record(ev);
214
214
  this.opts.onMetric?.(ev);
215
215
  }
216
- /** Emit the terminal `run` metric event and build the RunResult. */
217
- endRun(runStart, text, messages, toolCalls, turns, usage) {
216
+ /** Emit the terminal `run` metric event and build the RunResult. `exhausted` marks a
217
+ * maxTurns loop exit — with no final text that is a LOUD "incomplete", never "done" (§7D). */
218
+ endRun(runStart, text, messages, toolCalls, turns, usage, exhausted = false) {
218
219
  this.emit({ event: "run", model: this.opts.model, turns, toolCalls: toolCalls.length, totalTokens: usage.totalTokens, ms: Date.now() - runStart });
219
- return this.result(text, messages, toolCalls, turns, usage);
220
+ return this.result(text, messages, toolCalls, turns, usage, exhausted);
220
221
  }
221
222
  /** Emit a `run` error metric event (once, on a thrown run). */
222
223
  emitRunError(runStart, toolCalls, turns, usage, e) {
@@ -436,15 +437,19 @@ export class Client {
436
437
  return this.pendingRun(runStart, s.halted, messages, toolCalls, turns, usage);
437
438
  }
438
439
  }
439
- return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage);
440
+ return this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true);
440
441
  }
441
442
  catch (e) {
442
443
  this.emitRunError(runStart, toolCalls, turns, usage, e);
443
444
  throw e;
444
445
  }
445
446
  }
446
- result(text, messages, toolCalls, turns, usage) {
447
- return { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: "done" };
447
+ result(text, messages, toolCalls, turns, usage, exhausted = false) {
448
+ const incomplete = exhausted && text === "";
449
+ const r = { text, messages, toolCalls, toolCallCount: toolCalls.length, turns, usage, model: this.opts.model, status: incomplete ? "incomplete" : "done" };
450
+ if (incomplete)
451
+ r.limit = "maxTurns";
452
+ return r;
448
453
  }
449
454
  /** §10: a run halted because a tool suspended and no `waitFor` was configured. */
450
455
  pendingRun(runStart, request, messages, toolCalls, turns, usage) {
@@ -545,7 +550,7 @@ export class Client {
545
550
  if (halted)
546
551
  return this.pendingRun(runStart, halted, messages, toolCalls, turns, usage);
547
552
  }
548
- return this.endRun(runStart, "", messages, toolCalls, turns, usage);
553
+ return this.endRun(runStart, "", messages, toolCalls, turns, usage, true);
549
554
  }
550
555
  catch (e) {
551
556
  this.emitRunError(runStart, toolCalls, turns, usage, e);
@@ -655,7 +660,7 @@ export class Client {
655
660
  yield { type: "tool_result", id: c.id, name: c.name, output: result.output, isError: result.isError };
656
661
  }
657
662
  }
658
- yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage) };
663
+ yield { type: "done", result: this.endRun(runStart, lastText(messages), messages, toolCalls, turns, usage, true) };
659
664
  }
660
665
  catch (e) {
661
666
  this.emitRunError(runStart, toolCalls, turns, usage, e);
@@ -764,7 +769,7 @@ export class Client {
764
769
  }
765
770
  messages.push({ role: "user", content: results });
766
771
  }
767
- yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage) };
772
+ yield { type: "done", result: this.endRun(runStart, "", messages, toolCalls, turns, usage, true) };
768
773
  }
769
774
  catch (e) {
770
775
  this.emitRunError(runStart, toolCalls, turns, usage, e);
package/dist/index.d.ts CHANGED
@@ -10,3 +10,4 @@ export * from "./mcpserve.js";
10
10
  export * from "./adapters.js";
11
11
  export * from "./toolkit.js";
12
12
  export * from "./client.js";
13
+ export * as agents from "./agents/index.js";
package/dist/index.js CHANGED
@@ -10,3 +10,4 @@ export * from "./mcpserve.js";
10
10
  export * from "./adapters.js";
11
11
  export * from "./toolkit.js";
12
12
  export * from "./client.js";
13
+ export * as agents from "./agents/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolnexus",
3
- "version": "0.9.5",
3
+ "version": "0.10.0",
4
4
  "description": "Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "build": "tsc -p tsconfig.json",
20
- "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts",
20
+ "test": "npm run build && node --experimental-strip-types --test test/unit.test.ts test/agents.test.ts test/home.test.ts test/compaction.test.ts",
21
21
  "example": "node --experimental-strip-types examples/basic.ts"
22
22
  },
23
23
  "keywords": [
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Level-1 subagent surface — SPEC.md §7D.
3
+ *
4
+ * One new noun: `agent(name, spec)`. An Agent is (identity/system prompt × a
5
+ * filtered toolkit view × the shipped client loop), and — the §7A/§7B axiom
6
+ * completed locally — an Agent IS a Tool: `asTool()` bridges it into the classic
7
+ * API's `extraTools`; `run()` executes one-shot against a private runtime.
8
+ * Everything compiles down to the six host verbs in runtime.ts.
9
+ */
10
+ import fs from "node:fs"
11
+ import { defineTool } from "../native.js"
12
+ import type { Answer, Request, Tool } from "../types.js"
13
+ import { AgentRuntime, isVerbError, type AgentDef, type Budget, type Handle, type RuntimeOptions, type TaskResult } from "./runtime.js"
14
+
15
+ /** The declarative agent spec (§7D Level-1 surface). */
16
+ export interface AgentSpec {
17
+ /** Required routing description — what a delegating model sees. */
18
+ does: string
19
+ /** The toolkit VIEW for this agent (scoping is the security model). */
20
+ uses?: { tools?: Tool[] }
21
+ /** Identity: inline soul text (becomes the system prompt). */
22
+ soul?: string
23
+ /** Path form of `soul` — read when the registry is built. */
24
+ soulFile?: string
25
+ /** Declaring agents here IS the subagent wiring: they become the `task`
26
+ * tool's only reachable targets. No team ⇒ no `task` tool. */
27
+ team?: Agent[]
28
+ budget?: Budget
29
+ /** Model id; default "inherit" (the runtime's `llm.model`). */
30
+ model?: string
31
+ /** §10 interpreter authority for this agent's subtree. */
32
+ waitFor?: (request: Request) => Promise<Answer>
33
+ onSpawn?: AgentDef["onSpawn"]
34
+ onClose?: AgentDef["onClose"]
35
+ }
36
+
37
+ /** Runtime options for a one-shot `run`/`asTool` (the registry is derived). */
38
+ export type AgentRunOptions = Omit<RuntimeOptions, "registry">
39
+
40
+ /** A one-shot run's result, with the runtime attached for durable resume. */
41
+ export interface AgentRunResult extends TaskResult {
42
+ /** The runtime that ran this agent — call `runtime.resume(answer)` after a
43
+ * `status:"pending"` result to continue from the checkpoint. */
44
+ runtime: AgentRuntime
45
+ }
46
+
47
+ export class Agent {
48
+ constructor(readonly name: string, readonly spec: AgentSpec) {}
49
+
50
+ /**
51
+ * Collect this agent plus its transitive team graph into a runtime registry —
52
+ * the registry IS the reachable graph; unreachable agents are not present.
53
+ */
54
+ registry(acc: Record<string, AgentDef> = {}): Record<string, AgentDef> {
55
+ if (acc[this.name]) return acc
56
+ let soul = this.spec.soul ?? ""
57
+ if (this.spec.soulFile) soul = fs.readFileSync(this.spec.soulFile, "utf8")
58
+ acc[this.name] = {
59
+ name: this.name,
60
+ does: this.spec.does,
61
+ soul,
62
+ model: this.spec.model ?? "inherit",
63
+ tools: this.spec.uses?.tools,
64
+ team: (this.spec.team ?? []).map((a) => a.name),
65
+ budget: this.spec.budget,
66
+ waitFor: this.spec.waitFor,
67
+ onSpawn: this.spec.onSpawn,
68
+ onClose: this.spec.onClose,
69
+ }
70
+ for (const t of this.spec.team ?? []) t.registry(acc)
71
+ return acc
72
+ }
73
+
74
+ /** Build a runtime scoped to this agent's reachable team graph. */
75
+ createRuntime(opts: AgentRunOptions = {}): AgentRuntime {
76
+ return new AgentRuntime({ ...opts, registry: this.registry() })
77
+ }
78
+
79
+ /**
80
+ * Level 1: one-shot. Builds a runtime, runs this agent to completion, and tears
81
+ * the tree down — unless the run suspends durably (`status:"pending"`), in which
82
+ * case the tree stays parked and `result.runtime.resume(answer)` continues it.
83
+ */
84
+ async run(prompt: string, opts: AgentRunOptions = {}): Promise<AgentRunResult> {
85
+ const runtime = this.createRuntime(opts)
86
+ const h = runtime.spawn(runtime.root, this.name)
87
+ if (isVerbError(h)) {
88
+ return { text: h.error, isError: true, status: "error", turns: 0, totalTokens: 0, runtime }
89
+ }
90
+ const woke = runtime.wake(h, prompt)
91
+ if (!woke.ok) {
92
+ return { ...(await runtime.wait(h as Handle)), runtime }
93
+ }
94
+ const r = await runtime.wait(h as Handle)
95
+ if (r.status !== "pending") await runtime.close(runtime.root)
96
+ return { ...r, runtime }
97
+ }
98
+
99
+ /**
100
+ * The bridge: an Agent IS a Tool. Drop the result into the classic API's
101
+ * `extraTools`; the agent's own loop runs in isolation and the classic run
102
+ * receives one tool result carrying only the final text + metadata
103
+ * `{agent, turns, totalTokens}`.
104
+ */
105
+ asTool(opts: AgentRunOptions = {}): Tool {
106
+ return defineTool({
107
+ name: this.name,
108
+ description: this.spec.does,
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: { prompt: { type: "string", description: "The task for this agent" } },
112
+ required: ["prompt"],
113
+ },
114
+ run: async (args: Record<string, unknown>) => {
115
+ const r = await this.run(String(args.prompt), opts)
116
+ return {
117
+ output: r.text,
118
+ isError: r.isError,
119
+ metadata: { agent: this.name, turns: r.turns, totalTokens: r.totalTokens },
120
+ }
121
+ },
122
+ })
123
+ }
124
+ }
125
+
126
+ /** The one new noun: define an agent. */
127
+ export function agent(name: string, spec: AgentSpec): Agent {
128
+ return new Agent(name, spec)
129
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Context compaction (SPEC.md §7F) — keep a long-lived agent under budget.
3
+ *
4
+ * A long-running agent grows its transcript until it overflows the model's window.
5
+ * `compactor(opts)` returns a **`beforeLLM` hook** (§8) that summarizes the older
6
+ * transcript and keeps a recent tail. It rides the existing seam: the client loop
7
+ * applies a `beforeLLM` message rewrite by *replacing* the working transcript, and
8
+ * that array flows on into `RunResult.messages` and the `ConversationStore`. So
9
+ * compaction is a pure `messages → messages` helper and adds **no loop behavior** —
10
+ * it is the canonical use of the §8 `beforeLLM` hook.
11
+ *
12
+ * Invariants (SPEC §7F):
13
+ * 1. At/below `maxTokens` the hook is a **no-op** — it returns nothing and the run
14
+ * is byte-identical to a run with no compactor.
15
+ * 2. **Tool-pair safety** — the retained tail always begins at a `user` turn, so no
16
+ * `tool` message is ever orphaned from the `assistant` carrying its `tool_call_id`.
17
+ * 3. The **leading system prompt** (identity / soul / skills) is preserved verbatim;
18
+ * only the body between it and the tail is summarized.
19
+ *
20
+ * import { agents } from "toolnexus"
21
+ * const client = createClient({
22
+ * …,
23
+ * hooks: { beforeLLM: agents.compactor({ maxTokens: 120_000, summarize }) },
24
+ * })
25
+ */
26
+
27
+ /**
28
+ * A chat message in the working transcript. Structural and provider-neutral — only the
29
+ * fields compaction reasons about are named; anything else on the message is carried
30
+ * through untouched.
31
+ */
32
+ export interface Message {
33
+ role: string
34
+ content?: unknown
35
+ tool_calls?: unknown[]
36
+ tool_call_id?: string
37
+ [key: string]: unknown
38
+ }
39
+
40
+ /**
41
+ * Cheap, deterministic token estimate: `ceil(chars/4)` over each message's JSON
42
+ * serialization, summed. This is an *estimator*, not a tokenizer — exactness is the
43
+ * host's call. Override via `CompactorOptions.countTokens` for a real tokenizer.
44
+ */
45
+ export function estimateTokens(messages: Message[]): number {
46
+ let total = 0
47
+ for (const m of messages) total += Math.ceil(JSON.stringify(m).length / 4)
48
+ return total
49
+ }
50
+
51
+ /** Options for {@link compactor}. */
52
+ export interface CompactorOptions {
53
+ /** Compact only when the transcript estimate exceeds this; at/below ⇒ no-op. */
54
+ maxTokens: number
55
+ /** Keep at least this many tokens of the most recent tail. Default `maxTokens/2`. */
56
+ keepTail?: number
57
+ /** Produce a summary of the older messages. MAY call an LLM (async). Required. */
58
+ summarize: (older: Message[]) => Promise<string> | string
59
+ /** Token estimator; default `ceil(chars/4)` summed over messages. */
60
+ countTokens?: (messages: Message[]) => number
61
+ /**
62
+ * When set, inject a pre-compact system reminder telling the model to persist
63
+ * durable facts via the §7E `memory` tool before the head is summarized. Off by
64
+ * default. Composes with the memory builtin.
65
+ */
66
+ flushToMemory?: boolean
67
+ }
68
+
69
+ /** The event a `beforeLLM` hook receives (§8). */
70
+ export interface BeforeLLMEvent {
71
+ messages: Message[]
72
+ tools: unknown[]
73
+ model: string
74
+ turn: number
75
+ }
76
+
77
+ /** The system reminder injected when `flushToMemory` is set. */
78
+ const FLUSH_TO_MEMORY_REMINDER =
79
+ "Before continuing: if anything from earlier is worth keeping, save it with the memory tool now — the earlier transcript is about to be summarized."
80
+
81
+ /** Prefix on the summary system message; hosts/tests key off this to detect a compaction. */
82
+ const SUMMARY_PREFIX = "[Summary of earlier conversation]\n"
83
+
84
+ /**
85
+ * Build a `beforeLLM` hook (§8) that compacts the transcript once it exceeds
86
+ * `maxTokens`. Below budget it returns nothing (a byte-identical no-op); above it, it
87
+ * replaces the transcript with `[leading system prompt, summary system message,
88
+ * (flush reminder?), …recent tail]`, splitting at a clean `user` boundary so tool
89
+ * groups are never broken.
90
+ */
91
+ export function compactor(
92
+ opts: CompactorOptions,
93
+ ): (ev: BeforeLLMEvent) => Promise<{ messages: Message[] } | undefined> {
94
+ const count = opts.countTokens ?? estimateTokens
95
+ const keepTail = opts.keepTail ?? Math.floor(opts.maxTokens / 2)
96
+
97
+ return async (ev: BeforeLLMEvent): Promise<{ messages: Message[] } | undefined> => {
98
+ const msgs = ev.messages
99
+ if (count(msgs) <= opts.maxTokens) return undefined // under budget → no-op
100
+
101
+ // Preserve a leading system prompt verbatim (identity / soul / skills — never summarized).
102
+ const headEnd = msgs[0]?.role === "system" ? 1 : 0
103
+ const system = msgs.slice(0, headEnd)
104
+
105
+ const split = findTailStart(msgs, headEnd, keepTail, count)
106
+ if (split <= headEnd) return undefined // nothing safely compactible
107
+
108
+ const older = msgs.slice(headEnd, split)
109
+ if (older.length === 0) return undefined
110
+ const tail = msgs.slice(split)
111
+
112
+ const flushNote: Message[] = opts.flushToMemory
113
+ ? [{ role: "system", content: FLUSH_TO_MEMORY_REMINDER }]
114
+ : []
115
+
116
+ const summary = await opts.summarize(older)
117
+ const summaryMsg: Message = { role: "system", content: `${SUMMARY_PREFIX}${summary}` }
118
+
119
+ return { messages: [...system, summaryMsg, ...flushNote, ...tail] }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Locate the index where the retained tail begins. Tool-pair safety requires the tail
125
+ * to start at a `user` turn: the largest user-boundary tail that still fits `keepTail`.
126
+ * If no user boundary fits within `keepTail`, fall back to the most recent user turn
127
+ * (favoring safety over size). Returns `msgs.length` (no tail) only when there is no
128
+ * user turn after the head at all.
129
+ */
130
+ function findTailStart(
131
+ msgs: Message[],
132
+ headEnd: number,
133
+ keepTail: number,
134
+ count: (messages: Message[]) => number,
135
+ ): number {
136
+ let split = msgs.length
137
+ for (let i = msgs.length - 1; i > headEnd; i--) {
138
+ if (count(msgs.slice(i)) > keepTail) break
139
+ if (msgs[i].role === "user") split = i
140
+ }
141
+ if (split !== msgs.length) return split
142
+
143
+ // No user boundary fit within keepTail — extend back to the most recent user turn.
144
+ for (let i = msgs.length - 1; i > headEnd; i--) {
145
+ if (msgs[i].role === "user") return i
146
+ }
147
+ return msgs.length
148
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Agent home — the persona surface over the §7D runtime (SPEC.md §7E).
3
+ *
4
+ * A persona is an identity that lives in **files**, keeps durable **memory** it can
5
+ * edit, and runs on a **heartbeat** so it can act unprompted. All three ride shipped
6
+ * seams — the composed soul is just a system prompt, the memory tool is a plain
7
+ * `Tool`, and the heartbeat is `post`+`wake` on the runtime's injectable clock — so
8
+ * this layer adds *no* new runtime behavior. It is convention (a directory layout)
9
+ * and composition (recipes over the six verbs), not new machinery.
10
+ *
11
+ * import { agents } from "toolnexus"
12
+ * const ava = agents.fromDir("./personas/ava")
13
+ * const started = agents.startAgent(ava, { fetch, clock }, { everyMs: 30 * 60_000 })
14
+ */
15
+ import fs from "node:fs"
16
+ import path from "node:path"
17
+ import { defineTool } from "../native.js"
18
+ import type { Tool } from "../types.js"
19
+ import { agent, Agent, type AgentRunOptions } from "./agent.js"
20
+ import { AgentRuntime, isVerbError } from "./runtime.js"
21
+
22
+ /**
23
+ * Bootstrap discovery order (§7E): identity first, durable memory last. Each present
24
+ * file is injected into the soul as a `## <filename>` section, in exactly this order.
25
+ */
26
+ export const BOOTSTRAP_ORDER = [
27
+ "AGENTS.md",
28
+ "SOUL.md",
29
+ "IDENTITY.md",
30
+ "USER.md",
31
+ "TOOLS.md",
32
+ "HEARTBEAT.md",
33
+ "MEMORY.md",
34
+ ] as const
35
+
36
+ /** Per-file bootstrap cap (§7E): a larger file is injected truncated, disk untouched. */
37
+ export const MAX_FILE_BYTES = 2 * 1024 * 1024
38
+
39
+ /** Notice appended to a bootstrap file truncated at {@link MAX_FILE_BYTES}. */
40
+ export const TRUNCATION_NOTICE = "\n[truncated: exceeds 2 MB bootstrap cap]"
41
+
42
+ /** The silent-no-op sentinel: a heartbeat reply containing this surfaces nothing. */
43
+ export const HEARTBEAT_OK = "HEARTBEAT_OK"
44
+
45
+ /** The prompt a heartbeat wakes the persona with. Contains `HEARTBEAT_OK` so the
46
+ * model knows the silent-reply contract, and the word "Heartbeat" so a HEARTBEAT.md
47
+ * that keys off it can recognise the trigger. */
48
+ export const HEARTBEAT_PROMPT =
49
+ `Heartbeat. Read your HEARTBEAT.md section and follow it. ` +
50
+ `If nothing needs attention, reply ${HEARTBEAT_OK}.`
51
+
52
+ /** Read a bootstrap file with the byte cap applied; `undefined` if it is absent. */
53
+ function readCapped(file: string): string | undefined {
54
+ let buf: Buffer
55
+ try {
56
+ buf = fs.readFileSync(file)
57
+ } catch {
58
+ return undefined // absent files are skipped (§7E)
59
+ }
60
+ if (buf.byteLength > MAX_FILE_BYTES) {
61
+ return buf.subarray(0, MAX_FILE_BYTES).toString("utf8") + TRUNCATION_NOTICE
62
+ }
63
+ return buf.toString("utf8")
64
+ }
65
+
66
+ /**
67
+ * Compose the bootstrap files present in `dir` into one soul string — the frozen
68
+ * snapshot injected as the system prompt for the whole run. Only present files
69
+ * appear, always in {@link BOOTSTRAP_ORDER}, each as a `## <filename>` section.
70
+ */
71
+ export function composeSoul(dir: string): { soul: string; found: string[] } {
72
+ const sections: string[] = []
73
+ const found: string[] = []
74
+ for (const file of BOOTSTRAP_ORDER) {
75
+ const body = readCapped(path.join(dir, file))
76
+ if (body !== undefined) {
77
+ sections.push(`## ${file}\n\n${body.trim()}`)
78
+ found.push(file)
79
+ }
80
+ }
81
+ return { soul: sections.join("\n\n"), found }
82
+ }
83
+
84
+ /** Which memory file an action targets. `self` → MEMORY.md, `user` → USER.md. */
85
+ type MemoryTarget = "self" | "user"
86
+
87
+ function memoryFile(dir: string, target: unknown): string {
88
+ return path.join(dir, target === "user" ? "USER.md" : "MEMORY.md")
89
+ }
90
+
91
+ /**
92
+ * The `memory` builtin (§7E, file-backed, opt-in). One tool, three actions over
93
+ * `MEMORY.md` (the agent's own notes) and `USER.md` (its model of the user):
94
+ *
95
+ * - `add` — append an entry
96
+ * - `replace` — swap an existing substring for `with`
97
+ * - `remove` — delete an existing substring
98
+ *
99
+ * Every action writes to **disk**. It does **not** touch the live session's system
100
+ * prompt: the frozen-snapshot rule means the edit loads only at the START of the
101
+ * next session (keeping a long-lived persona cache-stable). A `replace`/`remove`
102
+ * whose substring is absent is a loud `isError` — never a silent no-op.
103
+ */
104
+ export function memoryTool(dir: string): Tool {
105
+ return defineTool({
106
+ 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.",
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: {
115
+ action: { type: "string", enum: ["add", "replace", "remove"] },
116
+ target: { type: "string", enum: ["self", "user"], description: "self=MEMORY.md (default), user=USER.md" },
117
+ text: { type: "string", description: "For add: the entry. For replace/remove: the existing text." },
118
+ with: { type: "string", description: "For replace: the replacement text." },
119
+ },
120
+ required: ["action", "text"],
121
+ },
122
+ run: (args: Record<string, unknown>) => {
123
+ const target = (args.target as MemoryTarget) ?? "self"
124
+ 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)
145
+ return `ok (${String(args.action)} → ${path.basename(file)}); loads next session`
146
+ },
147
+ })
148
+ }
149
+
150
+ /** Options for {@link fromDir}. */
151
+ export interface FromDirOptions {
152
+ /** Routing description a delegating model sees; default derived from the dir. */
153
+ does?: string
154
+ /** Agent name; default the directory's basename. */
155
+ name?: string
156
+ /** Model id; default `"inherit"` (the runtime's `llm.model`). */
157
+ model?: string
158
+ /** Extra tools beyond the memory builtin. */
159
+ tools?: Tool[]
160
+ /** Set `false` to omit the memory tool (a read-only persona). */
161
+ memory?: boolean
162
+ }
163
+
164
+ /**
165
+ * The directory IS the agent (§7E). Discovers the bootstrap files in `dir`, composes
166
+ * them into a frozen soul snapshot, and (unless `memory:false`) wires a `memory` tool
167
+ * over the same directory. Returns a plain {@link Agent} — `run`/`asTool`/`startAgent`
168
+ * all apply.
169
+ */
170
+ export function fromDir(dir: string, opts: FromDirOptions = {}): Agent {
171
+ const { soul } = composeSoul(dir)
172
+ const tools = [...(opts.tools ?? [])]
173
+ if (opts.memory !== false) tools.push(memoryTool(dir))
174
+ return agent(opts.name ?? path.basename(dir), {
175
+ does: opts.does ?? `persona agent from ${dir}`,
176
+ soul,
177
+ model: opts.model ?? "inherit",
178
+ uses: { tools },
179
+ })
180
+ }
181
+
182
+ /** Heartbeat configuration for {@link startAgent}. */
183
+ export interface HeartbeatOptions {
184
+ /** Interval between beats, in ms (measured on the runtime's clock). */
185
+ everyMs: number
186
+ /** Called for each SUBSTANTIVE beat — a `HEARTBEAT_OK` reply is stripped and never
187
+ * surfaces here. */
188
+ onBeat?: (text: string) => void
189
+ }
190
+
191
+ /** A running persona: the live runtime, the collected substantive beats, and `stop`. */
192
+ export interface StartedAgent {
193
+ /** The runtime the persona runs on — expose so a host can `post`/`wake` it (channels). */
194
+ runtime: AgentRuntime
195
+ /** Substantive beats collected so far (HEARTBEAT_OK replies excluded). */
196
+ beats: string[]
197
+ /** Cancel the heartbeat and close the tree gracefully. */
198
+ stop: () => Promise<void>
199
+ }
200
+
201
+ /**
202
+ * Give a persona its own clock (§7E). On each interval it `post`s a tick to its own
203
+ * inbox (the unsolicited rail — ticks **coalesce**, so a slow beat can't pile up) and,
204
+ * when idle, `wake`s it with {@link HEARTBEAT_PROMPT}. A reply containing
205
+ * {@link HEARTBEAT_OK} is treated as silent (no `onBeat`); only a substantive reply
206
+ * surfaces. All timing goes through the runtime's injectable clock, so a virtual-clock
207
+ * test is deterministic.
208
+ *
209
+ * Inbound channels are the host's job: deliver external events by calling
210
+ * `started.runtime.post`/`wake` on the persona's handle.
211
+ */
212
+ export function startAgent(a: Agent, run: AgentRunOptions, hb: HeartbeatOptions): StartedAgent {
213
+ const runtime = a.createRuntime(run)
214
+ const handle = runtime.spawn(runtime.root, a.name)
215
+ if (isVerbError(handle)) throw new Error(handle.error)
216
+
217
+ const beats: string[] = []
218
+ let stopped = false
219
+ let cancel: (() => void) | undefined
220
+
221
+ const beat = (): void => {
222
+ if (stopped) return
223
+ // Unsolicited rail: the tick lands in the inbox (coalesces); a wake — only when
224
+ // idle — drains the whole inbox as one turn.
225
+ runtime.post(handle, { from: "clock", channel: "timer", text: "tick" })
226
+ if (handle.state === "idle") {
227
+ const woke = runtime.wake(handle, HEARTBEAT_PROMPT)
228
+ if (woke.ok) {
229
+ 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
+ }
234
+ })
235
+ }
236
+ }
237
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs) // self-reschedule → one live timer
238
+ }
239
+ cancel = runtime.clock.setTimeout(beat, hb.everyMs)
240
+
241
+ return {
242
+ runtime,
243
+ beats,
244
+ stop: async () => {
245
+ stopped = true
246
+ cancel?.()
247
+ await runtime.close(runtime.root)
248
+ },
249
+ }
250
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Sub-agents & agent runtime (SPEC.md §7D), exported as the `agents` namespace:
3
+ *
4
+ * import { agents } from "toolnexus"
5
+ * const explore = agents.agent("explore", { does: "read-only research" })
6
+ */
7
+ export * from "./runtime.js"
8
+ export * from "./agent.js"
9
+ export * from "./home.js"
10
+ export * from "./compaction.js"