toolnexus 0.9.4 → 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.
@@ -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"