toolnexus 0.27.0 → 0.29.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 +61 -17
- package/dist/acp.d.ts +6 -0
- package/dist/acp.js +38 -16
- package/dist/agents/home.d.ts +85 -4
- package/dist/agents/home.js +347 -52
- package/dist/classifier.d.ts +11 -3
- package/dist/classifier.js +120 -3
- package/dist/client.d.ts +112 -5
- package/dist/client.js +397 -82
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +13 -1
- package/dist/media.d.ts +465 -54
- package/dist/media.js +1819 -230
- package/dist/nodefetch.d.ts +1 -0
- package/dist/nodefetch.js +156 -0
- package/dist/transcript_search.d.ts +27 -0
- package/dist/transcript_search.js +165 -0
- package/dist/wire.d.ts +11 -1
- package/dist/wire.js +20 -3
- package/package.json +2 -2
- package/src/acp.ts +42 -15
- package/src/agents/home.ts +381 -53
- package/src/classifier.ts +117 -6
- package/src/client.ts +380 -64
- package/src/index.ts +1 -0
- package/src/mcp.ts +18 -1
- package/src/media.ts +2034 -271
- package/src/nodefetch.ts +143 -0
- package/src/transcript_search.ts +172 -0
- package/src/wire.ts +19 -2
package/dist/agents/home.js
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
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 { agent } from "./agent.js";
|
|
19
|
-
import { isVerbError } from "./runtime.js";
|
|
20
|
+
import { AgentRuntime, isVerbError } from "./runtime.js";
|
|
21
|
+
import { transcriptSearchTool } from "../transcript_search.js";
|
|
20
22
|
/**
|
|
21
23
|
* Bootstrap discovery order (§7E): identity first, durable memory last. Each present
|
|
22
24
|
* file is injected into the soul as a `## <filename>` section, in exactly this order.
|
|
@@ -55,16 +57,71 @@ function readCapped(file) {
|
|
|
55
57
|
}
|
|
56
58
|
return buf.toString("utf8");
|
|
57
59
|
}
|
|
60
|
+
/** The preset index cap (§7E "Memory hierarchy"): Claude Code's 200 lines / 25 KB. */
|
|
61
|
+
export const MEMORY_INDEX_CAP_PRESET = { maxLines: 200, maxBytes: 25600 };
|
|
62
|
+
function resolveIndexCap(c) {
|
|
63
|
+
if (c === undefined || c === false)
|
|
64
|
+
return undefined;
|
|
65
|
+
const v = c === true ? {} : c;
|
|
66
|
+
const maxLines = v.maxLines ?? MEMORY_INDEX_CAP_PRESET.maxLines;
|
|
67
|
+
const maxBytes = v.maxBytes ?? MEMORY_INDEX_CAP_PRESET.maxBytes;
|
|
68
|
+
if (!Number.isInteger(maxLines) || maxLines < 1 || !Number.isInteger(maxBytes) || maxBytes < 1) {
|
|
69
|
+
throw new Error("memoryIndexCap: maxLines and maxBytes must be integers >= 1");
|
|
70
|
+
}
|
|
71
|
+
return { maxLines, maxBytes };
|
|
72
|
+
}
|
|
73
|
+
/** The exact notice appended to a capped `MEMORY.md` (§7E "Memory hierarchy"). */
|
|
74
|
+
export function memoryIndexNotice(maxLines, maxBytes, notes) {
|
|
75
|
+
const tail = notes ? `; move detail into notes (memory target "note:<slug>").` : ` (keep entries short).`;
|
|
76
|
+
return `\n\n[MEMORY.md index truncated at ${maxLines} lines / ${maxBytes} bytes. Keep MEMORY.md a short index${tail}]`;
|
|
77
|
+
}
|
|
78
|
+
/** Cut a UTF-8 string at the last character boundary whose byte length is <= `max`. */
|
|
79
|
+
function cutUtf8(s, max) {
|
|
80
|
+
let out = "";
|
|
81
|
+
let bytes = 0;
|
|
82
|
+
for (const ch of s) {
|
|
83
|
+
const n = Buffer.byteLength(ch);
|
|
84
|
+
if (bytes + n > max)
|
|
85
|
+
break;
|
|
86
|
+
out += ch;
|
|
87
|
+
bytes += n;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Apply the index cap to `MEMORY.md`'s (already 2 MB-capped) content. Lines are split on
|
|
93
|
+
* "\n"; one trailing "\n" does not start a line. Within both bounds ⇒ returned unchanged.
|
|
94
|
+
* Otherwise keep the first `maxLines` lines, drop trailing whole lines until the "\n"-joined
|
|
95
|
+
* UTF-8 length is <= `maxBytes` (a lone over-long first line is cut at a character
|
|
96
|
+
* boundary), and append {@link memoryIndexNotice}.
|
|
97
|
+
*/
|
|
98
|
+
export function applyIndexCap(content, cap, notes = false) {
|
|
99
|
+
const lines = content.split("\n");
|
|
100
|
+
if (content.endsWith("\n"))
|
|
101
|
+
lines.pop();
|
|
102
|
+
if (lines.length <= cap.maxLines && Buffer.byteLength(lines.join("\n")) <= cap.maxBytes)
|
|
103
|
+
return content;
|
|
104
|
+
const kept = lines.slice(0, cap.maxLines);
|
|
105
|
+
while (kept.length > 1 && Buffer.byteLength(kept.join("\n")) > cap.maxBytes)
|
|
106
|
+
kept.pop();
|
|
107
|
+
let text = kept.join("\n");
|
|
108
|
+
if (Buffer.byteLength(text) > cap.maxBytes)
|
|
109
|
+
text = cutUtf8(text, cap.maxBytes);
|
|
110
|
+
return text + memoryIndexNotice(cap.maxLines, cap.maxBytes, notes);
|
|
111
|
+
}
|
|
58
112
|
/**
|
|
59
113
|
* Compose the bootstrap files present in `dir` into one soul string — the frozen
|
|
60
114
|
* snapshot injected as the system prompt for the whole run. Only present files
|
|
61
115
|
* appear, always in {@link BOOTSTRAP_ORDER}, each as a `## <filename>` section.
|
|
62
116
|
*/
|
|
63
|
-
export function composeSoul(dir) {
|
|
117
|
+
export function composeSoul(dir, opts = {}) {
|
|
64
118
|
const sections = [];
|
|
65
119
|
const found = [];
|
|
120
|
+
const cap = resolveIndexCap(opts.memoryIndexCap);
|
|
66
121
|
for (const file of BOOTSTRAP_ORDER) {
|
|
67
|
-
|
|
122
|
+
let body = readCapped(path.join(dir, file));
|
|
123
|
+
if (body !== undefined && cap && file === "MEMORY.md")
|
|
124
|
+
body = applyIndexCap(body, cap, opts.notes === true);
|
|
68
125
|
if (body !== undefined) {
|
|
69
126
|
sections.push(`## ${file}\n\n${body.trim()}`);
|
|
70
127
|
found.push(file);
|
|
@@ -75,26 +132,98 @@ export function composeSoul(dir) {
|
|
|
75
132
|
function memoryFile(dir, target) {
|
|
76
133
|
return path.join(dir, target === "user" ? "USER.md" : "MEMORY.md");
|
|
77
134
|
}
|
|
135
|
+
/** A valid note slug (§7E "Memory hierarchy"): lowercase, no path separators, no `..`. */
|
|
136
|
+
export const NOTE_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
137
|
+
const MEMORY_DESCRIPTION = "Persist durable memory. action=add appends an entry; replace swaps an " +
|
|
138
|
+
"existing substring (with); remove deletes one. target=self (MEMORY.md, " +
|
|
139
|
+
"default) or user (USER.md). Writes persist to disk and load at the START of " +
|
|
140
|
+
"your NEXT session — they do NOT change your current context.";
|
|
141
|
+
/** The notes-on description (§7E): says what memory is FOR, not rules about what to record. */
|
|
142
|
+
export const MEMORY_NOTES_DESCRIPTION = "Persist durable memory. MEMORY.md is a short index loaded at the start of every " +
|
|
143
|
+
"session; put detail in notes (target note:<slug>, stored in memory/<slug>.md, never " +
|
|
144
|
+
"loaded automatically) and point to them from the index. Record what will change a " +
|
|
145
|
+
"future decision. action=add appends an entry; replace swaps the first occurrence of " +
|
|
146
|
+
"an existing substring (with); remove deletes it; read returns a file; list lists the " +
|
|
147
|
+
"notes. target=self (MEMORY.md, default), user (USER.md) or note:<slug> (slug: " +
|
|
148
|
+
"lowercase letters, digits, '.', '_', '-'). Writes persist to disk and load at the " +
|
|
149
|
+
"START of your NEXT session — they do NOT change your current context.";
|
|
150
|
+
/**
|
|
151
|
+
* Atomic replace (§7E write safety): temp file in the same dir, flushed (fsync), renamed
|
|
152
|
+
* over the target; an existing file's permission bits are kept.
|
|
153
|
+
*/
|
|
154
|
+
function atomicWrite(file, data) {
|
|
155
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
156
|
+
const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
157
|
+
try {
|
|
158
|
+
const fd = fs.openSync(tmp, "w"); // new file: 0666 & ~umask
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(fd, data);
|
|
161
|
+
fs.fsyncSync(fd);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
fs.closeSync(fd);
|
|
165
|
+
}
|
|
166
|
+
// Keep an existing file's permission bits (the rename would otherwise give it the temp's).
|
|
167
|
+
if (process.platform !== "win32" && fs.existsSync(file)) {
|
|
168
|
+
fs.chmodSync(tmp, fs.statSync(file).mode & 0o777);
|
|
169
|
+
}
|
|
170
|
+
fs.renameSync(tmp, file);
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
fs.rmSync(tmp, { force: true });
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Read-modify-write over the FULL file (never the capped view). In JS the whole RMW is
|
|
178
|
+
* synchronous, so it cannot interleave with another call in this runtime — the per-path
|
|
179
|
+
* serialization §7E requires holds by construction. */
|
|
180
|
+
function rmw(file, action, text, withText) {
|
|
181
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
182
|
+
let next;
|
|
183
|
+
switch (action) {
|
|
184
|
+
case "add":
|
|
185
|
+
next = (current.trimEnd() + `\n- ${text}\n`).trimStart();
|
|
186
|
+
break;
|
|
187
|
+
case "replace":
|
|
188
|
+
if (!current.includes(text))
|
|
189
|
+
return { output: `not found: ${text}`, isError: true };
|
|
190
|
+
// First occurrence only, inserted LITERALLY (§7E): a function replacement, because a
|
|
191
|
+
// string one would expand `$&`, `$1`, `$$` … in the model's text.
|
|
192
|
+
next = current.replace(text, () => String(withText ?? ""));
|
|
193
|
+
break;
|
|
194
|
+
case "remove":
|
|
195
|
+
if (!current.includes(text))
|
|
196
|
+
return { output: `not found: ${text}`, isError: true };
|
|
197
|
+
next = current.replace(text, () => ""); // first occurrence only (§7E)
|
|
198
|
+
break;
|
|
199
|
+
default:
|
|
200
|
+
return { output: `unknown action: ${String(action)}`, isError: true };
|
|
201
|
+
}
|
|
202
|
+
atomicWrite(file, next);
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
78
205
|
/**
|
|
79
206
|
* The `memory` builtin (§7E, file-backed, opt-in). One tool, three actions over
|
|
80
207
|
* `MEMORY.md` (the agent's own notes) and `USER.md` (its model of the user):
|
|
81
208
|
*
|
|
82
209
|
* - `add` — append an entry
|
|
83
|
-
* - `replace` — swap an existing substring for `with`
|
|
84
|
-
* - `remove` — delete an existing substring
|
|
210
|
+
* - `replace` — swap the first occurrence of an existing substring for `with`
|
|
211
|
+
* - `remove` — delete the first occurrence of an existing substring
|
|
212
|
+
*
|
|
213
|
+
* With `{notes: true}` it also takes `target: "note:<slug>"` (`<dir>/memory/<slug>.md`,
|
|
214
|
+
* never injected into the soul) and two read actions, `read` and `list`.
|
|
85
215
|
*
|
|
86
216
|
* Every action writes to **disk**. It does **not** touch the live session's system
|
|
87
217
|
* prompt: the frozen-snapshot rule means the edit loads only at the START of the
|
|
88
218
|
* next session (keeping a long-lived persona cache-stable). A `replace`/`remove`
|
|
89
219
|
* whose substring is absent is a loud `isError` — never a silent no-op.
|
|
90
220
|
*/
|
|
91
|
-
export function memoryTool(dir) {
|
|
221
|
+
export function memoryTool(dir, opts = {}) {
|
|
222
|
+
if (opts.notes === true)
|
|
223
|
+
return notesMemoryTool(dir);
|
|
92
224
|
return defineTool({
|
|
93
225
|
name: "memory",
|
|
94
|
-
description:
|
|
95
|
-
"existing substring (with); remove deletes one. target=self (MEMORY.md, " +
|
|
96
|
-
"default) or user (USER.md). Writes persist to disk and load at the START of " +
|
|
97
|
-
"your NEXT session — they do NOT change your current context.",
|
|
226
|
+
description: MEMORY_DESCRIPTION,
|
|
98
227
|
inputSchema: {
|
|
99
228
|
type: "object",
|
|
100
229
|
properties: {
|
|
@@ -108,32 +237,103 @@ export function memoryTool(dir) {
|
|
|
108
237
|
run: (args) => {
|
|
109
238
|
const target = args.target ?? "self";
|
|
110
239
|
const file = memoryFile(dir, target);
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
switch (args.action) {
|
|
115
|
-
case "add":
|
|
116
|
-
next = (current.trimEnd() + `\n- ${text}\n`).trimStart();
|
|
117
|
-
break;
|
|
118
|
-
case "replace":
|
|
119
|
-
if (!current.includes(text))
|
|
120
|
-
return { output: `not found: ${text}`, isError: true };
|
|
121
|
-
next = current.replace(text, String(args.with ?? ""));
|
|
122
|
-
break;
|
|
123
|
-
case "remove":
|
|
124
|
-
if (!current.includes(text))
|
|
125
|
-
return { output: `not found: ${text}`, isError: true };
|
|
126
|
-
next = current.replace(text, "");
|
|
127
|
-
break;
|
|
128
|
-
default:
|
|
129
|
-
return { output: `unknown action: ${String(args.action)}`, isError: true };
|
|
130
|
-
}
|
|
131
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
132
|
-
fs.writeFileSync(file, next);
|
|
240
|
+
const err = rmw(file, args.action, String(args.text), args.with);
|
|
241
|
+
if (err)
|
|
242
|
+
return err;
|
|
133
243
|
return `ok (${String(args.action)} → ${path.basename(file)}); loads next session`;
|
|
134
244
|
},
|
|
135
245
|
});
|
|
136
246
|
}
|
|
247
|
+
function notesMemoryTool(dir) {
|
|
248
|
+
const notesDir = path.join(dir, "memory");
|
|
249
|
+
return defineTool({
|
|
250
|
+
name: "memory",
|
|
251
|
+
description: MEMORY_NOTES_DESCRIPTION,
|
|
252
|
+
inputSchema: {
|
|
253
|
+
type: "object",
|
|
254
|
+
properties: {
|
|
255
|
+
action: { type: "string", enum: ["add", "replace", "remove", "read", "list"] },
|
|
256
|
+
target: {
|
|
257
|
+
type: "string",
|
|
258
|
+
description: "self=MEMORY.md (default), user=USER.md, note:<slug>=memory/<slug>.md",
|
|
259
|
+
},
|
|
260
|
+
text: { type: "string", description: "For add: the entry. For replace/remove: the existing text." },
|
|
261
|
+
with: { type: "string", description: "For replace: the replacement text." },
|
|
262
|
+
},
|
|
263
|
+
required: ["action"],
|
|
264
|
+
},
|
|
265
|
+
run: (args) => {
|
|
266
|
+
const action = args.action;
|
|
267
|
+
if (action === "list")
|
|
268
|
+
return listNotes(notesDir);
|
|
269
|
+
const target = args.target === undefined ? "self" : String(args.target);
|
|
270
|
+
let file;
|
|
271
|
+
let label;
|
|
272
|
+
if (target === "self" || target === "user") {
|
|
273
|
+
file = memoryFile(dir, target);
|
|
274
|
+
label = path.basename(file);
|
|
275
|
+
}
|
|
276
|
+
else if (target.startsWith("note:")) {
|
|
277
|
+
const slug = target.slice(5);
|
|
278
|
+
if (!NOTE_SLUG_RE.test(slug) || slug.includes("..")) {
|
|
279
|
+
return { output: `invalid note slug: ${slug}`, isError: true };
|
|
280
|
+
}
|
|
281
|
+
file = path.join(notesDir, `${slug}.md`);
|
|
282
|
+
label = `memory/${slug}.md`;
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
return { output: `unknown target: ${target}`, isError: true };
|
|
286
|
+
}
|
|
287
|
+
if (action === "read") {
|
|
288
|
+
const body = readCapped(file);
|
|
289
|
+
if (body === undefined)
|
|
290
|
+
return { output: `not found: ${target}`, isError: true };
|
|
291
|
+
return body;
|
|
292
|
+
}
|
|
293
|
+
if (action !== "add" && action !== "replace" && action !== "remove") {
|
|
294
|
+
return { output: `unknown action: ${String(action)}`, isError: true };
|
|
295
|
+
}
|
|
296
|
+
if (typeof args.text !== "string")
|
|
297
|
+
return { output: "text is required", isError: true };
|
|
298
|
+
const err = rmw(file, action, args.text, args.with);
|
|
299
|
+
if (err)
|
|
300
|
+
return err;
|
|
301
|
+
return label.startsWith("memory/") ? `ok (${action} → ${label})` : `ok (${action} → ${label}); loads next session`;
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/** `list` output (§7E): `<slug>\t<bytes>` per note, slug-ascending (explicit sort). */
|
|
306
|
+
function listNotes(notesDir) {
|
|
307
|
+
let names;
|
|
308
|
+
try {
|
|
309
|
+
names = fs.readdirSync(notesDir);
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
names = [];
|
|
313
|
+
}
|
|
314
|
+
const rows = [];
|
|
315
|
+
for (const name of names) {
|
|
316
|
+
if (!name.endsWith(".md"))
|
|
317
|
+
continue;
|
|
318
|
+
const slug = name.slice(0, -3);
|
|
319
|
+
if (!NOTE_SLUG_RE.test(slug) || slug.includes(".."))
|
|
320
|
+
continue;
|
|
321
|
+
// lstat: a symlink (dangling or not) is not a regular file and is skipped (§7E).
|
|
322
|
+
let st;
|
|
323
|
+
try {
|
|
324
|
+
st = fs.lstatSync(path.join(notesDir, name));
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (st.isFile())
|
|
330
|
+
rows.push([slug, st.size]);
|
|
331
|
+
}
|
|
332
|
+
rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
333
|
+
return rows.length === 0 ? "(no notes)" : rows.map(([s, n]) => `${s}\t${n}`).join("\n");
|
|
334
|
+
}
|
|
335
|
+
/** Dream config attached by `fromDir(…, {dream})`, read by {@link startAgent}. */
|
|
336
|
+
const DREAMS = new WeakMap();
|
|
137
337
|
/**
|
|
138
338
|
* The directory IS the agent (§7E). Discovers the bootstrap files in `dir`, composes
|
|
139
339
|
* them into a frozen soul snapshot, and (unless `memory:false`) wires a `memory` tool
|
|
@@ -141,16 +341,83 @@ export function memoryTool(dir) {
|
|
|
141
341
|
* all apply.
|
|
142
342
|
*/
|
|
143
343
|
export function fromDir(dir, opts = {}) {
|
|
144
|
-
const { soul } = composeSoul(dir);
|
|
344
|
+
const { soul } = composeSoul(dir, { memoryIndexCap: opts.memoryIndexCap, notes: opts.notes });
|
|
145
345
|
const tools = [...(opts.tools ?? [])];
|
|
146
346
|
if (opts.memory !== false)
|
|
147
|
-
tools.push(memoryTool(dir));
|
|
148
|
-
|
|
347
|
+
tools.push(memoryTool(dir, { notes: opts.notes }));
|
|
348
|
+
const name = opts.name ?? path.basename(dir);
|
|
349
|
+
const persona = agent(name, {
|
|
149
350
|
does: opts.does ?? `persona agent from ${dir}`,
|
|
150
351
|
soul,
|
|
151
352
|
model: opts.model ?? "inherit",
|
|
152
353
|
uses: { tools },
|
|
153
354
|
});
|
|
355
|
+
if (opts.dream !== undefined) {
|
|
356
|
+
const { everyMs, store, instructions } = opts.dream;
|
|
357
|
+
if (!(Number.isFinite(everyMs) && everyMs > 0))
|
|
358
|
+
throw new Error("fromDir: dream.everyMs must be > 0");
|
|
359
|
+
DREAMS.set(persona, { dreamer: dreamer(dir, { store, instructions, name: `${name}-dreamer` }), everyMs });
|
|
360
|
+
}
|
|
361
|
+
return persona;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* What a dream pass is FOR and the order it works in (§7E "Dreaming"). Byte-identical in
|
|
365
|
+
* every port. It becomes the dreamer's `## HEARTBEAT.md` section, so the ordinary
|
|
366
|
+
* {@link HEARTBEAT_PROMPT} wake drives a pass and `HEARTBEAT_OK` keeps an idle pass silent.
|
|
367
|
+
*/
|
|
368
|
+
export const DREAM_PROMPT = [
|
|
369
|
+
"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.",
|
|
370
|
+
"Work in this order:",
|
|
371
|
+
"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).",
|
|
372
|
+
"2. Demote: move detail out of MEMORY.md into a note and leave a one-line [[slug]] pointer, so the index stays short.",
|
|
373
|
+
"3. Merge: fold duplicate or conflicting notes into one.",
|
|
374
|
+
"4. Repair links: point every [[slug]] at an existing note or create it; give every note an index line.",
|
|
375
|
+
"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.",
|
|
376
|
+
"If nothing needs changing, write nothing and reply HEARTBEAT_OK.",
|
|
377
|
+
].join("\n");
|
|
378
|
+
/**
|
|
379
|
+
* The built-in dreamer preset (§7E "Dreaming"): a plain {@link Agent} whose tools are
|
|
380
|
+
* EXACTLY `memory` (notes on, over `dir`) and `transcript_search` (over `store`) — never
|
|
381
|
+
* the persona's shell/file tools. Its soul is one `## HEARTBEAT.md` section holding
|
|
382
|
+
* {@link DREAM_PROMPT}, then `DREAM.md` from `dir` (trimmed, if non-empty), then
|
|
383
|
+
* `instructions` (trimmed, if non-empty), joined by blank lines. Schedule it with
|
|
384
|
+
* {@link startAgent} or run one pass with `run(HEARTBEAT_PROMPT, …)`.
|
|
385
|
+
*/
|
|
386
|
+
export function dreamer(dir, opts) {
|
|
387
|
+
if (!opts || !opts.store)
|
|
388
|
+
throw new Error("dreamer: store is required");
|
|
389
|
+
const search = transcriptSearchTool(opts.store);
|
|
390
|
+
const parts = [DREAM_PROMPT];
|
|
391
|
+
const dreamMd = readCapped(path.join(dir, "DREAM.md"))?.trim();
|
|
392
|
+
if (dreamMd)
|
|
393
|
+
parts.push(dreamMd);
|
|
394
|
+
const extra = opts.instructions?.trim();
|
|
395
|
+
if (extra)
|
|
396
|
+
parts.push(extra);
|
|
397
|
+
return agent(opts.name ?? `${path.basename(dir)}-dreamer`, {
|
|
398
|
+
does: `dreams over the memory in ${dir}`,
|
|
399
|
+
soul: `## HEARTBEAT.md\n\n${parts.join("\n\n")}`,
|
|
400
|
+
model: opts.model ?? "inherit",
|
|
401
|
+
uses: { tools: [dreamGuard(memoryTool(dir, { notes: true })), search] },
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The dream guard (§7E "Dreaming"): the dreamer's `memory` tool refuses every action on
|
|
406
|
+
* `USER.md` and `replace`/`remove` on `note:log`, before anything is read or written, so a
|
|
407
|
+
* dream never touches the user model and its audit trail is append-only.
|
|
408
|
+
*/
|
|
409
|
+
function dreamGuard(mem) {
|
|
410
|
+
return {
|
|
411
|
+
...mem,
|
|
412
|
+
execute: async (args, ctx) => {
|
|
413
|
+
if (args.target === "user")
|
|
414
|
+
return { output: "dreamer: USER.md is off-limits", isError: true };
|
|
415
|
+
if (args.target === "note:log" && (args.action === "replace" || args.action === "remove")) {
|
|
416
|
+
return { output: "dreamer: memory/log.md is append-only", isError: true };
|
|
417
|
+
}
|
|
418
|
+
return mem.execute(args, ctx);
|
|
419
|
+
},
|
|
420
|
+
};
|
|
154
421
|
}
|
|
155
422
|
/**
|
|
156
423
|
* Give a persona its own clock (§7E). On each interval it `post`s a tick to its own
|
|
@@ -164,15 +431,53 @@ export function fromDir(dir, opts = {}) {
|
|
|
164
431
|
* `started.runtime.post`/`wake` on the persona's handle.
|
|
165
432
|
*/
|
|
166
433
|
export function startAgent(a, run, hb) {
|
|
167
|
-
const
|
|
434
|
+
const dream = DREAMS.get(a);
|
|
435
|
+
if (dream && Object.prototype.hasOwnProperty.call(a.registry(), dream.dreamer.name)) {
|
|
436
|
+
throw new Error(`startAgent: dreamer name collision: ${dream.dreamer.name} is already registered`);
|
|
437
|
+
}
|
|
438
|
+
const runtime = dream
|
|
439
|
+
? new AgentRuntime({ ...run, registry: { ...dream.dreamer.registry(), ...a.registry() } })
|
|
440
|
+
: a.createRuntime(run);
|
|
168
441
|
const handle = runtime.spawn(runtime.root, a.name);
|
|
169
442
|
if (isVerbError(handle))
|
|
170
443
|
throw new Error(handle.error);
|
|
171
444
|
const beats = [];
|
|
445
|
+
const cancels = [];
|
|
172
446
|
let stopped = false;
|
|
447
|
+
cancels.push(heartbeat(runtime, handle, hb.everyMs, () => stopped, (text) => {
|
|
448
|
+
beats.push(text);
|
|
449
|
+
hb.onBeat?.(text);
|
|
450
|
+
}));
|
|
451
|
+
if (dream) {
|
|
452
|
+
// fromDir(…, {dream}): the dreamer runs on the SAME runtime on its own interval. Its
|
|
453
|
+
// replies are housekeeping — they never reach onBeat; what it did is in memory/log.md.
|
|
454
|
+
const dh = runtime.spawn(runtime.root, dream.dreamer.name);
|
|
455
|
+
if (isVerbError(dh)) {
|
|
456
|
+
// Never leak the persona's heartbeat when the dreamer cannot start.
|
|
457
|
+
stopped = true;
|
|
458
|
+
for (const c of cancels)
|
|
459
|
+
c();
|
|
460
|
+
void runtime.close(runtime.root);
|
|
461
|
+
throw new Error(dh.error);
|
|
462
|
+
}
|
|
463
|
+
cancels.push(heartbeat(runtime, dh, dream.everyMs, () => stopped, () => { }));
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
runtime,
|
|
467
|
+
beats,
|
|
468
|
+
stop: async () => {
|
|
469
|
+
stopped = true;
|
|
470
|
+
for (const c of cancels)
|
|
471
|
+
c();
|
|
472
|
+
await runtime.close(runtime.root);
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/** One self-rescheduling heartbeat on `handle`; returns its cancel. */
|
|
477
|
+
function heartbeat(runtime, handle, everyMs, isStopped, onText) {
|
|
173
478
|
let cancel;
|
|
174
479
|
const beat = () => {
|
|
175
|
-
if (
|
|
480
|
+
if (isStopped())
|
|
176
481
|
return;
|
|
177
482
|
// Unsolicited rail: the tick lands in the inbox (coalesces); a wake — only when
|
|
178
483
|
// idle — drains the whole inbox as one turn.
|
|
@@ -181,25 +486,15 @@ export function startAgent(a, run, hb) {
|
|
|
181
486
|
const woke = runtime.wake(handle, HEARTBEAT_PROMPT);
|
|
182
487
|
if (woke.ok) {
|
|
183
488
|
void runtime.wait(handle).then((r) => {
|
|
184
|
-
if (!r.isError && !r.text.includes(HEARTBEAT_OK))
|
|
185
|
-
|
|
186
|
-
hb.onBeat?.(r.text);
|
|
187
|
-
}
|
|
489
|
+
if (!r.isError && !r.text.includes(HEARTBEAT_OK))
|
|
490
|
+
onText(r.text);
|
|
188
491
|
});
|
|
189
492
|
}
|
|
190
493
|
}
|
|
191
494
|
// keepAlive:false — a heartbeat is housekeeping and must never be the reason
|
|
192
495
|
// a host process stays up.
|
|
193
|
-
cancel = runtime.clock.setTimeout(beat,
|
|
194
|
-
};
|
|
195
|
-
cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false });
|
|
196
|
-
return {
|
|
197
|
-
runtime,
|
|
198
|
-
beats,
|
|
199
|
-
stop: async () => {
|
|
200
|
-
stopped = true;
|
|
201
|
-
cancel?.();
|
|
202
|
-
await runtime.close(runtime.root);
|
|
203
|
-
},
|
|
496
|
+
cancel = runtime.clock.setTimeout(beat, everyMs, { keepAlive: false }); // self-reschedule → one live timer
|
|
204
497
|
};
|
|
498
|
+
cancel = runtime.clock.setTimeout(beat, everyMs, { keepAlive: false });
|
|
499
|
+
return () => cancel?.();
|
|
205
500
|
}
|
package/dist/classifier.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export declare const DEFAULT_BATCH_CONCURRENCY = 16;
|
|
|
16
16
|
* `typesafe/jev-1.14`, and mixing the two is issue #91. `custom` is a self-hosted System One
|
|
17
17
|
* endpoint and requires `baseUrl` + `supports` (SPEC §8B "Backends, images, cache and validation").
|
|
18
18
|
*/
|
|
19
|
-
export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "custom";
|
|
19
|
+
export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "openai" | "custom";
|
|
20
20
|
/** What a backend+model can take. DATA: a fail-fast for what the table says, never a claim about
|
|
21
21
|
* the vendor. Override per classifier with `ClassifierOptions.supports`. */
|
|
22
22
|
export interface ClassifierSupports {
|
|
@@ -28,8 +28,9 @@ export interface ClassifierBackendPreset {
|
|
|
28
28
|
baseUrl: string;
|
|
29
29
|
model: string;
|
|
30
30
|
apiKeyEnv: string;
|
|
31
|
-
/** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope.
|
|
32
|
-
|
|
31
|
+
/** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope.
|
|
32
|
+
* `decisions`: POST {baseUrl}/decisions, OpenAI's Decisions API (#157). */
|
|
33
|
+
wire: "systemone" | "cloudflare" | "decisions";
|
|
33
34
|
/** Send `TypeSafe-Danger-Zone-Images: true` when a request carries images. */
|
|
34
35
|
imagesHeader: boolean;
|
|
35
36
|
/** The default support for a model on this backend; absent on `custom`. */
|
|
@@ -537,3 +538,10 @@ export declare function thresholdBand(answer: DecisionAnswer | RawAnswer, t?: Th
|
|
|
537
538
|
* joint calibration across a split is unproven, so the host decides to split and merges.
|
|
538
539
|
*/
|
|
539
540
|
export declare function chunkQuestions<Q extends AnyQuestion>(questions: Record<string, Q>, size: number): Record<string, Q>[];
|
|
541
|
+
/** The Decisions API's per-request question cap. */
|
|
542
|
+
export declare const MAX_DECISIONS_QUESTIONS = 400;
|
|
543
|
+
/**
|
|
544
|
+
* Reads a Decisions API reply back into a System One-shaped body (canonical JSON), so decode,
|
|
545
|
+
* validation and the cache are the one path. `calibrated` is false. Exported for conformance tests.
|
|
546
|
+
*/
|
|
547
|
+
export declare function decisionsReply(reply: unknown, questions: Record<string, AnyQuestion>): string;
|