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.
- package/README.md +31 -1
- package/dist/agents/agent.d.ts +60 -0
- package/dist/agents/agent.js +99 -0
- package/dist/agents/compaction.d.ts +77 -0
- package/dist/agents/compaction.js +96 -0
- package/dist/agents/home.d.ts +90 -0
- package/dist/agents/home.js +203 -0
- package/dist/agents/index.d.ts +10 -0
- package/dist/agents/index.js +10 -0
- package/dist/agents/runtime.d.ts +357 -0
- package/dist/agents/runtime.js +733 -0
- package/dist/client.d.ts +28 -4
- package/dist/client.js +26 -12
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/toolkit.d.ts +7 -0
- package/dist/toolkit.js +13 -3
- package/package.json +2 -2
- package/src/agents/agent.ts +129 -0
- package/src/agents/compaction.ts +148 -0
- package/src/agents/home.ts +250 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/runtime.ts +890 -0
- package/src/client.ts +50 -14
- package/src/index.ts +1 -0
- package/src/toolkit.ts +19 -2
|
@@ -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"
|