toolnexus 0.26.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.
- package/README.md +6 -2
- package/dist/acp.d.ts +16 -2
- package/dist/acp.js +74 -21
- package/dist/agents/home.d.ts +85 -4
- package/dist/agents/home.js +347 -52
- package/dist/classifier.d.ts +21 -3
- package/dist/classifier.js +175 -9
- package/dist/client.d.ts +109 -0
- package/dist/client.js +380 -75
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +29 -7
- package/dist/mcp2026/source.d.ts +5 -0
- package/dist/mcp2026/source.js +11 -2
- package/dist/mcp2026/stdio.d.ts +43 -0
- package/dist/mcp2026/stdio.js +144 -20
- package/dist/mcphandler.js +2 -4
- package/dist/mcpserve.js +2 -1
- package/dist/nodefetch.d.ts +1 -0
- package/dist/nodefetch.js +156 -0
- package/dist/serve.js +5 -1
- package/dist/skill.d.ts +14 -0
- package/dist/skill.js +33 -2
- package/dist/transcript_search.d.ts +27 -0
- package/dist/transcript_search.js +165 -0
- package/package.json +2 -2
- package/src/acp.ts +101 -22
- package/src/agents/home.ts +381 -53
- package/src/classifier.ts +168 -12
- package/src/client.ts +368 -57
- package/src/index.ts +1 -0
- package/src/mcp.ts +36 -7
- package/src/mcp2026/source.ts +14 -4
- package/src/mcp2026/stdio.ts +148 -23
- package/src/mcphandler.ts +2 -3
- package/src/mcpserve.ts +2 -1
- package/src/nodefetch.ts +143 -0
- package/src/serve.ts +6 -2
- package/src/skill.ts +51 -2
- package/src/transcript_search.ts +172 -0
package/src/agents/home.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
}
|