@stratusagent/agents 0.11.4 → 0.11.6

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.
Files changed (53) hide show
  1. package/dist/define.d.ts +22 -0
  2. package/dist/define.d.ts.map +1 -0
  3. package/dist/define.js +64 -0
  4. package/dist/define.js.map +1 -0
  5. package/dist/frontmatter.d.ts +65 -0
  6. package/dist/frontmatter.d.ts.map +1 -0
  7. package/dist/frontmatter.js +260 -0
  8. package/dist/frontmatter.js.map +1 -0
  9. package/dist/identity.d.ts +139 -0
  10. package/dist/identity.d.ts.map +1 -0
  11. package/dist/identity.js +259 -0
  12. package/dist/identity.js.map +1 -0
  13. package/dist/index.d.ts +11 -654
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +16 -1641
  16. package/dist/index.js.map +1 -1
  17. package/dist/router.d.ts +18 -0
  18. package/dist/router.d.ts.map +1 -0
  19. package/dist/router.js +44 -0
  20. package/dist/router.js.map +1 -0
  21. package/dist/schedules.d.ts +163 -0
  22. package/dist/schedules.d.ts.map +1 -0
  23. package/dist/schedules.js +215 -0
  24. package/dist/schedules.js.map +1 -0
  25. package/dist/skills.d.ts +135 -0
  26. package/dist/skills.d.ts.map +1 -0
  27. package/dist/skills.js +167 -0
  28. package/dist/skills.js.map +1 -0
  29. package/dist/soul.d.ts +44 -0
  30. package/dist/soul.d.ts.map +1 -0
  31. package/dist/soul.js +98 -0
  32. package/dist/soul.js.map +1 -0
  33. package/dist/tools/delegate.d.ts +93 -0
  34. package/dist/tools/delegate.d.ts.map +1 -0
  35. package/dist/tools/delegate.js +223 -0
  36. package/dist/tools/delegate.js.map +1 -0
  37. package/dist/tools/gateway-only.d.ts +19 -0
  38. package/dist/tools/gateway-only.d.ts.map +1 -0
  39. package/dist/tools/gateway-only.js +29 -0
  40. package/dist/tools/gateway-only.js.map +1 -0
  41. package/dist/tools/memory.d.ts +51 -0
  42. package/dist/tools/memory.d.ts.map +1 -0
  43. package/dist/tools/memory.js +306 -0
  44. package/dist/tools/memory.js.map +1 -0
  45. package/dist/tools/message.d.ts +26 -0
  46. package/dist/tools/message.d.ts.map +1 -0
  47. package/dist/tools/message.js +46 -0
  48. package/dist/tools/message.js.map +1 -0
  49. package/dist/tools/schedule.d.ts +20 -0
  50. package/dist/tools/schedule.d.ts.map +1 -0
  51. package/dist/tools/schedule.js +146 -0
  52. package/dist/tools/schedule.js.map +1 -0
  53. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,1642 +1,17 @@
1
- import { isListensMode, LISTENS_MODES, AgentRegistry, isTaintedTrust, leastTrusted, memoryEntryTrust, SENDER_TRUST_METADATA_KEY, sessionTaintedBy, sessionTrustOf, sessionWriteTrust, } from '@stratusagent/core';
2
- // First names only — agents should feel like a teammate you call by name.
3
- // Uniqueness comes from the generated id suffix, never from the name.
4
- const AGENT_NAMES = [
5
- 'Ada', 'Amara', 'Arlo', 'Asha', 'August', 'Beatrix', 'Caleb', 'Camila',
6
- 'Dara', 'Devon', 'Eleni', 'Elio', 'Esme', 'Felix', 'Freya', 'Gideon',
7
- 'Hana', 'Hugo', 'Imani', 'Ines', 'Jasper', 'Juno', 'Kai', 'Kira',
8
- 'Leandro', 'Lucia', 'Mabel', 'Mateo', 'Nadia', 'Nico', 'Odessa', 'Otis',
9
- 'Priya', 'Quinn', 'Rafael', 'Romy', 'Sana', 'Silas', 'Tamsin', 'Theo',
10
- 'Uma', 'Vera', 'Wesley', 'Xiomara', 'Yusuf', 'Zadie',
11
- 'Alba', 'Bruno', 'Celia', 'Dashiell', 'Edie', 'Ferris', 'Greta', 'Hollis',
12
- 'Ida', 'Jules', 'Koa', 'Lior', 'Marisol', 'Nyla', 'Oren', 'Paloma',
13
- 'Reza', 'Sunny', 'Tobias', 'Vada', 'Wren', 'Yara', 'Zeke',
14
- ];
15
- /**
16
- * The one house style every Stratus agent avatar is drawn in. Surfaces
17
- * (CLI, web, macOS) render this style; agents differ by their name-derived
18
- * hue and palette, so the team looks cohesive while each member is
19
- * recognizable.
20
- */
21
- export const AVATAR_STYLE = 'stratus';
22
- /** Deterministic 32-bit hash so the same seed always yields the same identity. */
23
- const hashSeed = (seed) => {
24
- let hash = 2166136261;
25
- for (let index = 0; index < seed.length; index += 1) {
26
- hash ^= seed.charCodeAt(index);
27
- hash = Math.imul(hash, 16777619);
28
- }
29
- return hash >>> 0;
30
- };
31
- const hslToHex = (hue, saturation, lightness) => {
32
- const s = saturation / 100;
33
- const l = lightness / 100;
34
- const k = (n) => (n + hue / 30) % 12;
35
- const a = s * Math.min(l, 1 - l);
36
- const channel = (n) => {
37
- const value = l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
38
- return Math.round(value * 255).toString(16).padStart(2, '0');
39
- };
40
- return `#${channel(0)}${channel(8)}${channel(4)}`;
41
- };
42
- /**
43
- * Generate a human-ish first name. Deterministic when a seed is supplied;
44
- * random otherwise.
45
- */
46
- export const generateAgentName = (seed) => {
47
- const value = seed !== undefined
48
- ? hashSeed(seed)
49
- : Math.floor(Math.random() * 0xffffffff);
50
- return AGENT_NAMES[value % AGENT_NAMES.length] ?? 'Quinn';
51
- };
52
- /**
53
- * Derive an avatar theme from a name: a stable hue and a small palette in
54
- * the shared Stratus house style. Consumers (CLI, web, macOS) draw the
55
- * actual image from this so the agent looks the same on every surface.
56
- */
57
- export const generateAvatarTheme = (name) => {
58
- const value = hashSeed(name);
59
- const hue = value % 360;
60
- return {
61
- seed: name,
62
- hue,
63
- palette: [
64
- hslToHex(hue, 70, 55),
65
- hslToHex((hue + 30) % 360, 65, 70),
66
- hslToHex((hue + 180) % 360, 60, 45),
67
- ],
68
- style: AVATAR_STYLE,
69
- };
70
- };
71
- const slugify = (name) => name
72
- .toLowerCase()
73
- .replace(/[^a-z0-9]+/g, '-')
74
- .replace(/^-+|-+$/g, '') || 'agent';
75
- /**
76
- * The shape an id is *minted* in: lowercase alphanumerics and internal
77
- * hyphens, starting with an alphanumeric. Everything `slugify` produces
78
- * matches it, so every id this module derives does.
79
- *
80
- * Not what validation enforces. A hand-written `id:` predates any shape
81
- * rule and `Ava_1` was as valid as `ava` — see `isValidAgentId`.
82
- */
83
- export const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
84
- /**
85
- * How long an id we *build* is allowed to get. A construction bound, not a
86
- * validation rule — `isValidAgentId` deliberately does not check it.
87
- *
88
- * Every id derived from a long name predates this bound, and enforcing it
89
- * retroactively drops working agents off the roster, while trimming them
90
- * silently re-keys their sessions, memory, and credentials to an id nobody
91
- * has ever used. So the bound applies where an id is minted fresh and
92
- * nothing is keyed to it yet, and nowhere else. File systems still have
93
- * limits, and an id long enough to hit them fails at the join with a name
94
- * that says so.
95
- */
96
- export const MAX_AGENT_ID_LENGTH = 64;
97
- /**
98
- * What an id may never be, whatever else it is: anything that stops being
99
- * a single, addressable path segment. A separator escapes the directory,
100
- * a leading dot hides the file or walks up out of it (`..`), and a control
101
- * character is not typeable back.
102
- */
103
- const PATH_UNSAFE_ID = /[/\\]|[\u0000-\u001f\u007f]/;
104
- /**
105
- * The safety half of every id rule here, shared because agent ids and
106
- * session ids answer it identically and a second copy would drift from the
107
- * first the next time one of them learned something.
108
- *
109
- * Safety only: what an id may never be. Shape — the slug pattern, a length
110
- * bound — is per-kind and stays with the kind, because the reasons differ.
111
- */
112
- const isAddressableId = (id) => id.length > 0
113
- // Invisible leading or trailing space cannot be typed back reliably, so
114
- // an id that is not its own trimmed self is a mistake, not a legacy.
115
- && id === id.trim()
116
- && !id.startsWith('.')
117
- && !PATH_UNSAFE_ID.test(id)
118
- // An id keys plain objects too — `credentials.channels.slack[id]` among
119
- // them — where an inherited name is not a free slot. `toString` reads as
120
- // already connected with nothing stored, and `__proto__` assigns through
121
- // to the prototype, so the write lands nowhere and `JSON.stringify` drops
122
- // it. `in {}` names exactly that set, and names it by the property it
123
- // has rather than by a list to keep in step.
124
- && !(id in {});
125
- /**
126
- * Whether `id` is safe to key an agent's resources and paths by.
127
- *
128
- * Ids are not labels. They key sessions, memory, credentials, Slack channel
129
- * tokens, and every per-agent path on disk — and an explicit frontmatter
130
- * `id` is untrusted input: a soul file travels with a repository, and in
131
- * the hosted profile it comes from a tenant. `id: ../../escape` reaches
132
- * every one of those joins intact, so it is stopped once here rather than
133
- * defended at each of them.
134
- *
135
- * **Unsafe is the rule, not un-sluglike.** Nothing before this checked an
136
- * explicit id at all, so `Ava_1`, `team.alpha`, and `AVA` are all out there
137
- * keying real sessions and real credentials. Holding them to the shape ids
138
- * are minted in would not make anything safer — none of them can leave
139
- * their directory — and `loadRosterSouls` degrades a soul that will not
140
- * parse to a warning, so the only thing it would accomplish is dropping
141
- * those agents off the roster on upgrade, quietly, while their data stays
142
- * behind under the old id. Reserving the slug shape for ids nobody has
143
- * chosen yet costs nothing; imposing it on ids people are already using
144
- * costs them their agent.
145
- *
146
- * Rejected, never sanitized: rewriting `../../escape` into `escape` hands
147
- * back an agent nobody asked for, keyed to resources nobody named.
148
- */
149
- // `*` is the delegate wildcard (`delegates: ['*']`), so an agent with that
150
- // id could never be delegated to alone — reserved rather than ambiguous.
151
- // Agent ids only: a session id is a broader address, and the wildcard means
152
- // nothing there.
153
- export const isValidAgentId = (id) => isAddressableId(id) && id !== '*';
154
- /**
155
- * Whether a `delegates` entry is one the soul can hold: any agent id, or
156
- * the wildcard — the same rule as {@link isValidAgentId}, so every agent a
157
- * roster can hold can be named as a target. Exported so the control API
158
- * refuses a bad entry as the client's error rather than discovering it in
159
- * the soul round-trip.
160
- */
161
- export const isValidDelegateEntry = (entry) => entry === '*' || isValidAgentId(entry);
162
- /**
163
- * How much of a new session id is the *client's* to spend, on top of the
164
- * agent id it addresses.
165
- *
166
- * Every id in circulation is a colon-joined address that embeds the agent
167
- * id — `web:<agentId>:<uuid>` from the dashboard, a channel key of
168
- * `<channel>:<agentId>:<team>:<conversation>:<thread>`, with a bare UUID
169
- * from the CLI the one exception. So a flat cap is the wrong shape: agent
170
- * ids are deliberately unbounded (see {@link isValidAgentId}), and a flat
171
- * 200 refused every dashboard conversation for an agent whose id ran past
172
- * 159 characters — an agent that stays on the roster and simply stops being
173
- * chattable, which is the quiet-breakage failure the unbounded rule exists
174
- * to avoid.
175
- *
176
- * Budgeting the composition instead keeps the bound meaningful without
177
- * capping the agent id through the back door. It cannot be gamed to widen
178
- * itself: the allowance comes from an agent id already checked against the
179
- * roster, not from the caller's own string.
180
- */
181
- export const MAX_SESSION_ID_LENGTH = 200;
182
- /**
183
- * Ids that are a client's bug rendered as text, not an address anyone chose.
184
- *
185
- * Each is what JavaScript prints when an id was never computed — an
186
- * unresolved variable, a null field, arithmetic that went sideways, an
187
- * object concatenated into a template. The dashboard shipped the first one:
188
- * `/sessions/undefined/messages`, accepted, and a durable conversation
189
- * literally named `undefined`.
190
- *
191
- * A blocklist rather than a pattern, because these are safe, well-formed
192
- * strings — nothing about their *shape* is wrong, which is exactly why no
193
- * shape rule catches them. Kept to the strings the language itself
194
- * produces: guessing at what else looks like a mistake would start refusing
195
- * ids people meant.
196
- */
197
- const STRINGIFIED_NOTHING = new Set(['undefined', 'null', 'NaN', '[object Object]']);
198
- /**
199
- * Whether `id` may open a *new* conversation.
200
- *
201
- * Session ids are client-minted and a `POST` to an unknown one creates it,
202
- * so this is the only thing standing between an HTTP caller and a durable
203
- * row. `undefined`, `null`, and a bare space have all reached the session
204
- * table through that door — the dashboard shipped the first of them.
205
- *
206
- * **Checked where an id is minted, never where one is used.** An id already
207
- * in the store addresses a real conversation, whatever shape it is, and
208
- * re-validating on read would lock someone out of their own history to
209
- * enforce a rule that postdates it. Same reasoning as
210
- * {@link MAX_AGENT_ID_LENGTH}, which is why the length bound lives here and
211
- * not in {@link isValidAgentId}.
212
- *
213
- * `agentId` is the agent the id addresses, when the caller knows it — every
214
- * client convention embeds it, so its length is part of the budget rather
215
- * than something the bound gets to cap. The allowance applies only to an id
216
- * that actually contains it, since those are the characters being paid for;
217
- * a bare UUID and an unrelated string are both held to
218
- * {@link MAX_SESSION_ID_LENGTH} alone, as they are when `agentId` is
219
- * omitted.
220
- *
221
- * Shape is deliberately not enforced. Every id above is a legitimate
222
- * address, and no pattern admitting all of them excludes `undefined` — so
223
- * the safety rule, a bound, and {@link STRINGIFIED_NOTHING} are what catch
224
- * the real mistakes. Rejected, never sanitized: trimming an id hands the
225
- * caller back a conversation at an address it never asked for.
226
- */
227
- export const isValidSessionId = (id, agentId) => isAddressableId(id)
228
- // The allowance is for an id that actually *spends* those characters on
229
- // the agent id. Granting it merely because the request names a long-id
230
- // agent would let that agent's existence buy an unrelated caller 300 more
231
- // characters of junk — the durable-garbage case the bound is here to stop.
232
- && id.length <= MAX_SESSION_ID_LENGTH
233
- + (agentId !== undefined && id.includes(agentId) ? agentId.length : 0)
234
- && !STRINGIFIED_NOTHING.has(id);
235
- /**
236
- * A freshly minted id, bounded — `base` trimmed so that appending `suffix`
237
- * still fits, with any hyphen left dangling by the trim removed.
238
- *
239
- * Only for ids nothing is keyed to yet: a generated agent's
240
- * name-plus-suffix, and `agent new` retrying a filename collision. Both mint
241
- * an id in the same breath as the agent, so trimming takes nothing away.
242
- * Shared rather than re-derived so the two do not each own half of the bound
243
- * and drift. An id derived from a name someone chose does *not* come through
244
- * here — that name may already have an agent behind it.
245
- */
246
- export const agentIdWithSuffix = (base, suffix) => {
247
- const tail = suffix ? `-${suffix}` : '';
248
- const room = Math.max(1, MAX_AGENT_ID_LENGTH - tail.length);
249
- return `${base.slice(0, room).replace(/-+$/, '') || 'agent'}${tail}`;
250
- };
251
- // Ids for generated names carry a short unique suffix: the name pool is
252
- // small, and two agents that draw the same name must still be two people —
253
- // memory and access scopes are keyed by id. Deterministic when seeded.
254
- const generatedIdSuffix = (seed) => {
255
- const value = seed !== undefined
256
- ? hashSeed(`${seed}:id`)
257
- : Math.floor(Math.random() * 0xffffffff);
258
- return value.toString(36).padStart(4, '0').slice(0, 4);
259
- };
260
- /**
261
- * One-call agent creation. Everything is optional: with no input you get a
262
- * fresh identity with a human-ish name and a matching avatar theme.
263
- * Explicitly named agents keep a clean, predictable slug id.
264
- */
265
- export const defineAgent = (input = {}) => {
266
- const nameWasGenerated = input.name === undefined;
267
- const name = input.name ?? generateAgentName(input.seed);
268
- // Only an explicit id is checked: a derived one comes out of slugify,
269
- // which cannot produce anything unsafe.
270
- if (input.id === '*') {
271
- // The wildcard was an accepted id until `delegates` gave it a meaning,
272
- // so a soul written before then can still carry it. The path-safety
273
- // message would be wrong for it — `*` breaks none of those rules — and
274
- // the roster reports this error with the file's path, so name the real
275
- // reason and the fix rather than leaving the operator to guess why an
276
- // agent that loaded yesterday is skipped today.
277
- throw new Error('Invalid agent id: "*". * is the delegates wildcard (delegates: [\'*\'] means any agent on the roster), '
278
- + 'so no agent may have it as an id — give this agent another id. Its sessions, memory, and credentials '
279
- + 'are keyed by the old id and stay on disk.');
280
- }
281
- if (input.id !== undefined && !isValidAgentId(input.id)) {
282
- throw new Error(`Invalid agent id: ${JSON.stringify(input.id)}. An id becomes a path segment, so it may not start with `
283
- + 'a dot or contain a slash, a backslash, a control character, or leading or trailing whitespace — '
284
- + 'it keys files and credentials, not just labels.');
285
- }
286
- // Each entry is an id or the wildcard. Refused here, not in the tool: a
287
- // list holding a value no agent can have would be a grant to nobody
288
- // that read as a grant.
289
- for (const entry of input.delegates ?? []) {
290
- if (!isValidDelegateEntry(entry)) {
291
- throw new Error(`Invalid delegates entry: ${JSON.stringify(entry)}. Each entry is an agent id, or * for any agent on the roster.`);
292
- }
293
- }
294
- // A chosen name's slug is used whole. It is not this function's to
295
- // shorten: the same name has resolved to the same id in every release,
296
- // and two long names that differ only past the bound are two agents,
297
- // not one roster-refusing collision.
298
- const derived = nameWasGenerated
299
- ? agentIdWithSuffix(slugify(name), generatedIdSuffix(input.seed))
300
- : slugify(name);
301
- return {
302
- // Derived ids are safe by construction with one exception: `constructor`
303
- // is a perfectly ordinary slug and an Object.prototype key, so the name
304
- // "Constructor" reaches a rule that only ran on explicit ids. Checked
305
- // rather than special-cased, so what this returns is *an id that
306
- // validates* — not one that passes the cases anyone thought of. The
307
- // suffix is seeded by the slug, so the name still answers the same way
308
- // every time.
309
- id: input.id ?? (isValidAgentId(derived)
310
- ? derived
311
- : agentIdWithSuffix(derived, generatedIdSuffix(derived))),
312
- name,
313
- ...(input.instructions ? { instructions: input.instructions } : {}),
314
- avatar: input.avatar ?? generateAvatarTheme(name),
315
- ...(input.tools ? { tools: input.tools } : {}),
316
- ...(input.skills ? { skills: input.skills } : {}),
317
- ...(input.credentials ? { credentials: input.credentials } : {}),
318
- ...(input.delegates ? { delegates: input.delegates } : {}),
319
- ...(input.listens ? { listens: input.listens } : {}),
320
- };
321
- };
322
- /**
323
- * Whether `agent` may delegate to the agent with id `targetId`. Exact ids
324
- * or `*`; no namespace globs, because agent ids have no namespaces. Omitted
325
- * is nobody — see `AgentDefinition.delegates`.
326
- */
327
- export const isDelegateAllowed = (agent, targetId) => (agent.delegates ?? []).some((entry) => entry === '*' || entry === targetId);
328
- const SOUL_SCALAR_KEYS = ['name', 'id', 'provider', 'model', 'listens'];
329
- const SOUL_LIST_KEYS = ['tools', 'skills', 'credentials', 'delegates'];
330
- /**
331
- * The entries of an inline list, `[a, 'b,c', "d"]`, split on the commas
332
- * that are outside quotes. Splitting on every comma and unquoting after
333
- * turned `['foo,bar']` into the two entries `'foo` and `bar'` — for a
334
- * `delegates` list, a grant to two agents nobody meant and none to the
335
- * one they did, from a soul that loaded without complaint.
336
- */
337
- const splitInlineList = (inline) => {
338
- const entries = [];
339
- let current = '';
340
- let quote;
341
- for (const character of inline) {
342
- // A quote opens an entry only at the entry's start (after any leading
343
- // space): an apostrophe inside an unquoted id — o'brien — is part of
344
- // the id, and reading it as an opening quote would swallow every comma
345
- // after it into one entry no agent has.
346
- if (quote === undefined && (character === '"' || character === "'") && current.trim().length === 0) {
347
- quote = character;
348
- }
349
- else if (character === quote) {
350
- quote = undefined;
351
- }
352
- else if (character === ',' && quote === undefined) {
353
- entries.push(current);
354
- current = '';
355
- continue;
356
- }
357
- current += character;
358
- }
359
- // A quote left open would have swallowed every comma after it into one
360
- // entry no agent has — for a delegates list, a typo that changes who is
361
- // authorized while the soul loads without complaint. Refused instead.
362
- if (quote !== undefined) {
363
- return undefined;
364
- }
365
- entries.push(current);
366
- return entries;
367
- };
368
- /**
369
- * The soul edit that grants one delegate, as the refusal suggests it. The
370
- * inline form, `delegates: [a, "b, c"]`, quoted when the inline parser
371
- * would otherwise split or unquote the id, in whichever quote it does not
372
- * contain — and the block form when the id holds both quotes, since no
373
- * inline spelling of `a"',b` reads back as one entry, while a block line
374
- * is never split and is unquoted only for matching outer quotes, which
375
- * {@link writeScalar} guards. Rendered this carefully because
376
- * `delegates: [foo,bar]` is a grant to two agents and a refusal of the
377
- * one the message was about.
378
- */
379
- const suggestedDelegatesEdit = (value) => {
380
- if (!/[,"'\s]/.test(value) && unquote(value) === value) {
381
- return `delegates: [${value}]`;
382
- }
383
- if (!value.includes('"')) {
384
- return `delegates: ["${value}"]`;
385
- }
386
- if (!value.includes("'")) {
387
- return `delegates: ['${value}']`;
388
- }
389
- return `a delegates: list with the line "- ${writeScalar(value)}"`;
390
- };
391
- const unquote = (value) => {
392
- const trimmed = value.trim();
393
- if (trimmed.length >= 2 &&
394
- ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
395
- (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
396
- return trimmed.slice(1, -1);
397
- }
398
- return trimmed;
399
- };
400
- // A deliberately tiny frontmatter dialect: `key: value` scalars and block
401
- // lists of `- item` lines. Enough for souls and skills, no YAML dependency.
402
- // Tolerant mode (see FrontmatterShape) additionally reads the block-scalar
403
- // forms (`description: >-` and friends) and plain multi-line scalars, and
404
- // skips unknown keys with their nested blocks.
405
- const parseFrontmatterLines = (lines, shape) => {
406
- const scalars = {};
407
- const lists = {};
408
- const maps = {};
409
- const unknownKeys = [];
410
- const tolerant = shape.tolerant ?? false;
411
- let currentList;
412
- let currentMap;
413
- // Tolerant-mode line context: an unknown key whose indented block is
414
- // being skipped, or a known scalar whose value continues on indented
415
- // lines (a YAML block scalar, or a plain scalar that wraps). Inside
416
- // either, blank lines and lines starting with # are content or skipped
417
- // block — never the comments the top level treats them as — so both are
418
- // handled before the comment filter below.
419
- let skippingUnknownBlock = false;
420
- let continuation;
421
- const isIndented = (line) => /^\s/.test(line);
422
- for (const rawLine of lines) {
423
- const line = rawLine.trimEnd();
424
- const blank = line.trim().length === 0;
425
- if (tolerant && skippingUnknownBlock && (blank || isIndented(line))) {
426
- continue;
427
- }
428
- if (tolerant && continuation) {
429
- if (blank) {
430
- // A blank line is a line break in a literal block and a paragraph
431
- // break in a folded or plain one — never the end of the value.
432
- if (continuation.literal) {
433
- const existing = scalars[continuation.key] ?? '';
434
- if (existing.length > 0) {
435
- scalars[continuation.key] = `${existing}\n`;
436
- }
437
- }
438
- else {
439
- continuation.pendingBreak = true;
440
- }
441
- continue;
442
- }
443
- if (isIndented(line)) {
444
- const existing = scalars[continuation.key] ?? '';
445
- const separator = continuation.literal || continuation.pendingBreak ? '\n' : ' ';
446
- continuation.pendingBreak = false;
447
- // The raw content, not unquote(): inside a block scalar a quote or
448
- // a # is text, and YAML agrees.
449
- const piece = line.trim();
450
- scalars[continuation.key] = existing.length > 0 ? `${existing}${separator}${piece}` : piece;
451
- continue;
452
- }
453
- // A non-indented line ends the value; fall through to read it.
454
- }
455
- if (blank || line.trim().startsWith('#')) {
456
- continue;
457
- }
458
- if (currentMap && isIndented(line)) {
459
- // One level of `key: value` pairs, every value a string — the
460
- // spec's shape for `metadata`. Anything deeper is not a string
461
- // value, and refusing it beats reading the wrong half of it.
462
- // Any string is a key — quoted (`"vendor/key": v`), or bare up to
463
- // the first colon — since the spec types the map as string to
464
- // string and says nothing about what a key looks like.
465
- const pair = /^\s+(?:"([^"]*)"|'([^']*)'|([^\s"'#][^:]*?))\s*:\s*(.*)$/.exec(line);
466
- const mapKey = pair?.[1] ?? pair?.[2] ?? pair?.[3]?.trim();
467
- if (!pair || mapKey === undefined || mapKey.length === 0 || pair[4] === undefined || unquote(pair[4]).length === 0) {
468
- throw new Error(`${capitalize(shape.kind)} frontmatter metadata entries must be "key: value" strings, one per line: "${line.trim()}"`);
469
- }
470
- currentMap[mapKey] = unquote(pair[4]);
471
- continue;
472
- }
473
- const listItem = /^\s+-\s*(.*)$/.exec(line);
474
- if (listItem) {
475
- if (!currentList) {
476
- throw new Error(`${capitalize(shape.kind)} frontmatter has a list item outside a list: "${line.trim()}"`);
477
- }
478
- const item = unquote(listItem[1] ?? '');
479
- if (item.length > 0) {
480
- currentList.push(item);
481
- }
482
- continue;
483
- }
484
- const entry = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line);
485
- if (!entry) {
486
- throw new Error(`${capitalize(shape.kind)} frontmatter line is not "key: value": "${line.trim()}"`);
487
- }
488
- const key = entry[1] ?? '';
489
- const value = entry[2] ?? '';
490
- currentList = undefined;
491
- currentMap = undefined;
492
- skippingUnknownBlock = false;
493
- continuation = undefined;
494
- if (tolerant && shape.mapKeys?.includes(key)) {
495
- const inline = value.trim();
496
- if (inline.length > 0 && inline !== '{}') {
497
- throw new Error(`${capitalize(shape.kind)} frontmatter "${key}" must be a block of indented "key: value" lines: "${inline}"`);
498
- }
499
- const map = {};
500
- maps[key] = map;
501
- currentMap = map;
502
- continue;
503
- }
504
- if (shape.listKeys.includes(key)) {
505
- const list = [];
506
- lists[key] = list;
507
- const inline = value.trim();
508
- if (inline.length > 0) {
509
- // Inline form: tools: [a, b]
510
- const match = /^\[(.*)\]$/.exec(inline);
511
- if (!match) {
512
- throw new Error(`${capitalize(shape.kind)} frontmatter list "${key}" must be a block list or [a, b]: "${inline}"`);
513
- }
514
- const items = splitInlineList(match[1] ?? '');
515
- if (items === undefined) {
516
- throw new Error(`${capitalize(shape.kind)} frontmatter list "${key}" has an unterminated quote: "${inline}"`);
517
- }
518
- for (const item of items) {
519
- const cleaned = unquote(item);
520
- if (cleaned.length > 0) {
521
- list.push(cleaned);
522
- }
523
- }
524
- }
525
- else {
526
- currentList = list;
527
- }
528
- continue;
529
- }
530
- if (!shape.scalarKeys.includes(key)) {
531
- if (tolerant) {
532
- // The key and anything nested under it belong to some other host.
533
- // Remembered, not refused: a validator reports them.
534
- unknownKeys.push(key);
535
- skippingUnknownBlock = true;
536
- continue;
537
- }
538
- throw new Error(`Unknown ${shape.kind} frontmatter key: "${key}". Supported keys: ${[...shape.scalarKeys, ...shape.listKeys].join(', ')}.`);
539
- }
540
- const inline = value.trim();
541
- // YAML block-scalar headers: `>` folds continuation lines with spaces,
542
- // `|` keeps their line breaks; either may carry an indentation digit
543
- // and a chomping sign in either order, and a trailing comment. Only
544
- // meaningful in tolerant mode — the strict dialect never wrote them.
545
- if (tolerant && /^[>|](?:[1-9][+-]?|[+-][1-9]?)?(?:\s+#.*)?$/.test(inline)) {
546
- scalars[key] = '';
547
- continuation = { key, literal: inline.startsWith('|'), pendingBreak: false };
548
- continue;
549
- }
550
- const scalar = unquote(value);
551
- if (scalar.length === 0) {
552
- if (tolerant) {
553
- // May be a plain scalar continuing on indented lines; empty stays
554
- // empty and is dropped below if nothing follows.
555
- scalars[key] = '';
556
- continuation = { key, literal: false, pendingBreak: false };
557
- continue;
558
- }
559
- throw new Error(`${capitalize(shape.kind)} frontmatter key "${key}" has no value.`);
560
- }
561
- scalars[key] = scalar;
562
- if (tolerant) {
563
- continuation = { key, literal: false, pendingBreak: false };
564
- }
565
- }
566
- // A tolerant block scalar that never got a body reads as absent, not as
567
- // an empty string that would satisfy a required-field check.
568
- for (const [key, value] of Object.entries(scalars)) {
569
- if (value !== undefined && value.length === 0) {
570
- delete scalars[key];
571
- }
572
- }
573
- return { scalars, lists, maps, unknownKeys };
574
- };
575
- const capitalize = (word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
576
- /**
577
- * Split a markdown document into its `---`-fenced frontmatter lines (if
578
- * any) and the body after them. Shared by souls and skills — one reading
579
- * of what the fences mean, wherever the dialect appears.
580
- */
581
- const extractFrontmatter = (source, kind) => {
582
- const normalized = source.replace(/\r\n/g, '\n');
583
- const opener = /^---[ \t]*\n/.exec(normalized);
584
- if (!opener) {
585
- return { lines: undefined, body: normalized };
586
- }
587
- const closer = /\n---[ \t]*(\n|$)/.exec(normalized.slice(opener[0].length - 1));
588
- if (!closer || closer.index === undefined) {
589
- throw new Error(`${capitalize(kind)} frontmatter opened with --- but never closed.`);
590
- }
591
- const frontmatterEnd = opener[0].length - 1 + closer.index;
592
- return {
593
- lines: normalized.slice(opener[0].length, frontmatterEnd).split('\n'),
594
- body: normalized.slice(frontmatterEnd + closer[0].length),
595
- };
596
- };
597
- /**
598
- * Parse a soul file (markdown with optional frontmatter) into an agent
599
- * definition. The body becomes the agent's instructions; a missing name is
600
- * generated, so the smallest possible soul is just prose.
601
- */
602
- export const parseSoul = (source, options = {}) => {
603
- const { lines, body } = extractFrontmatter(source, 'soul');
604
- const { scalars, lists } = lines
605
- ? parseFrontmatterLines(lines, { kind: 'soul', scalarKeys: SOUL_SCALAR_KEYS, listKeys: SOUL_LIST_KEYS })
606
- : { scalars: {}, lists: {}, maps: {}, unknownKeys: [] };
607
- const instructions = body.trim();
608
- // Strict, like every soul key: a misspelled mode would otherwise load as
609
- // the default, and an agent that answers every reply when it was meant
610
- // to judge is the failure this file is read to prevent.
611
- if (scalars.listens !== undefined && !isListensMode(scalars.listens)) {
612
- throw new Error(`Soul frontmatter listens: ${JSON.stringify(scalars.listens)} is not a listening mode; use one of ${LISTENS_MODES.join(', ')}.`);
613
- }
614
- const agent = defineAgent({
615
- ...(scalars.name ? { name: scalars.name } : {}),
616
- ...(scalars.id ? { id: scalars.id } : {}),
617
- ...(instructions ? { instructions } : {}),
618
- ...(lists.tools ? { tools: lists.tools } : {}),
619
- ...(lists.skills ? { skills: lists.skills } : {}),
620
- ...(lists.credentials ? { credentials: lists.credentials } : {}),
621
- ...(lists.delegates ? { delegates: lists.delegates } : {}),
622
- ...(isListensMode(scalars.listens) ? { listens: scalars.listens } : {}),
623
- ...(options.seed !== undefined ? { seed: options.seed } : {}),
624
- });
625
- return {
626
- agent,
627
- ...(scalars.provider ? { provider: scalars.provider } : {}),
628
- ...(scalars.model ? { model: scalars.model } : {}),
629
- };
630
- };
631
- /**
632
- * Render an agent definition as a soul file, ready to save and edit. The
633
- * inverse of parseSoul for round-tripping `stratus agent new` output.
634
- */
635
- /**
636
- * A scalar or list entry as the soul writer emits it. The parser strips
637
- * one layer of matching quotes from every value, so a value that begins
638
- * and ends with the same quote — an agent id like `'bea'`, which
639
- * {@link isValidAgentId} allows — would come back as `bea`: for a
640
- * `delegates` entry that is a permission changed by an unrelated edit,
641
- * since the control API renders a soul through here on every field edit.
642
- * Such a value is wrapped in the other quote, which the parser strips
643
- * back off; so is one the parser's trim would alter.
644
- */
645
- const writeScalar = (value) => unquote(value) === value ? value : (value.startsWith('"') ? `'${value}'` : `"${value}"`);
646
- export const formatSoul = (soul) => {
647
- const lines = ['---', `name: ${writeScalar(soul.agent.name)}`, `id: ${writeScalar(soul.agent.id)}`];
648
- if (soul.provider) {
649
- lines.push(`provider: ${soul.provider}`);
650
- }
651
- if (soul.model) {
652
- lines.push(`model: ${soul.model}`);
653
- }
654
- if (soul.agent.listens) {
655
- lines.push(`listens: ${soul.agent.listens}`);
656
- }
657
- for (const [key, values] of [
658
- ['tools', soul.agent.tools],
659
- ['skills', soul.agent.skills],
660
- ['credentials', soul.agent.credentials],
661
- ['delegates', soul.agent.delegates],
662
- ]) {
663
- if (values && values.length > 0) {
664
- lines.push(`${key}:`);
665
- for (const value of values) {
666
- lines.push(` - ${writeScalar(value)}`);
667
- }
668
- }
669
- }
670
- lines.push('---', '');
671
- lines.push(soul.agent.instructions ?? 'Describe who this agent is and how they talk.');
672
- lines.push('');
673
- return lines.join('\n');
674
- };
675
- // ---- skills ----------------------------------------------------------------
676
- /**
677
- * The shape a skill id is written in — the Agent Skills spec's rule for
678
- * `name`, which is also the directory name: lowercase letters and digits
679
- * in hyphen-separated runs (`web-research`), at most 64 characters, no
680
- * leading, trailing, or doubled hyphen. One pattern for the manifest's
681
- * declared ids, the operator directory's folder names, and install-time
682
- * validation — two copies would drift into two answers to what a valid
683
- * id is.
684
- */
685
- export const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
686
- /** The spec's ceiling on `name`, and so on a skill id. */
687
- export const SKILL_ID_MAX_LENGTH = 64;
688
- export const isValidSkillId = (id) => id.length <= SKILL_ID_MAX_LENGTH && SKILL_ID_PATTERN.test(id);
689
- // The rule before the spec's: any lowercase kebab-ish run, doubled and
690
- // trailing hyphens and all, no length cap. What a directory or manifest
691
- // id that loaded yesterday was checked against.
692
- const LEGACY_SKILL_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
693
- /**
694
- * Whether an id that is already on this machine — a directory under
695
- * `~/.stratus/skills/`, a plugin manifest's declared id — is served.
696
- * Wider than `isValidSkillId` on purpose: the spec's rule arrived after
697
- * some of these were written, and tightening the *loader* would silently
698
- * drop an enabled procedure from an agent on upgrade. Installing and
699
- * validating use the strict rule; loading uses this one, and warns.
700
- */
701
- export const isLoadableSkillId = (id) => LEGACY_SKILL_ID_PATTERN.test(id);
702
- /** What a skill id refusal says, everywhere one is refused. */
703
- export const SKILL_ID_RULE = 'Skill ids are lowercase letters, digits, and single hyphens (web-research), at most 64 characters.';
704
- /** The spec's ceiling on `description`. */
705
- export const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
706
- /** The spec's ceiling on `compatibility`. */
707
- export const SKILL_COMPATIBILITY_MAX_LENGTH = 500;
708
- // The frontmatter keys the Agent Skills spec defines. Everything else at
709
- // the top level belongs to some other host — read past, and reported by
710
- // `validateSkillDocument`. `allowed-tools` is read so it is not reported,
711
- // and then deliberately unused: it is another host's pre-approval list,
712
- // and here the two gates (a trusted config, a soul's allowlist) are the
713
- // only things that grant a tool.
714
- const SKILL_SPEC_KEYS = ['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools'];
715
- // `version` and `requires` at the top level are the pre-spec Stratus form;
716
- // the spec keeps host extensions under `metadata:`, and that is where a
717
- // skill written today puts them. The old form still reads, so an installed
718
- // skill keeps working — and validation says which form it found.
719
- const SKILL_SCALAR_KEYS = ['name', 'description', 'license', 'compatibility', 'allowed-tools', 'version'];
720
- const SKILL_LIST_KEYS = ['requires'];
721
- const SKILL_MAP_KEYS = ['metadata'];
722
- const SKILL_LEGACY_KEYS = ['version', 'requires'];
723
- /**
724
- * Parse a `SKILL.md` — the same frontmatter dialect souls use (`key: value`
725
- * scalars, block lists), plus the spec's `metadata:` map. The body is the
726
- * procedure itself, markdown, untouched.
727
- *
728
- * `description` is required: it is the only thing the model sees before
729
- * deciding to load the body, and a skill without one is unreachable by the
730
- * mechanism that makes skills cheap. Everything the spec constrains beyond
731
- * presence — the name rule, the length ceilings — is
732
- * `validateSkillDocument`'s, so that loading what is already installed
733
- * stays lenient while installing is strict.
734
- */
735
- export const parseSkillDocument = (source) => {
736
- const { lines, body } = extractFrontmatter(source, 'skill');
737
- if (!lines) {
738
- throw new Error('Skill file has no frontmatter. A SKILL.md starts with --- and needs at least a description.');
739
- }
740
- const { scalars, lists, maps, unknownKeys } = parseFrontmatterLines(lines, {
741
- kind: 'skill',
742
- scalarKeys: SKILL_SCALAR_KEYS,
743
- listKeys: SKILL_LIST_KEYS,
744
- mapKeys: SKILL_MAP_KEYS,
745
- // Skills travel the wider agent-skills ecosystem (skills.sh and the
746
- // registries behind it), whose frontmatter carries fields other hosts
747
- // own. Those are metadata to skip, not defects to refuse — see
748
- // FrontmatterShape.tolerant, and note souls do NOT set this.
749
- tolerant: true,
750
- });
751
- const description = scalars.description;
752
- if (!description) {
753
- throw new Error('Skill frontmatter has no "description". The description is what an agent routes on — say when to reach for this skill.');
754
- }
755
- const metadata = maps.metadata;
756
- const version = metadata?.version ?? scalars.version;
757
- const requires = metadata?.requires !== undefined
758
- ? metadata.requires.split(/\s+/).filter((entry) => entry.length > 0)
759
- : lists.requires;
760
- // The legacy keys are read, and still not the spec's: a validator names
761
- // them so a skill written here can be moved to the form that ports.
762
- const legacy = SKILL_LEGACY_KEYS.filter((key) => (key === 'requires' ? lists.requires : scalars[key]) !== undefined);
763
- return {
764
- ...(scalars.name ? { name: scalars.name } : {}),
765
- description,
766
- ...(scalars.license ? { license: scalars.license } : {}),
767
- ...(scalars.compatibility ? { compatibility: scalars.compatibility } : {}),
768
- ...(metadata ? { metadata } : {}),
769
- ...(version ? { version } : {}),
770
- ...(requires && requires.length > 0 ? { requires } : {}),
771
- unknownKeys: [...legacy, ...unknownKeys.filter((key) => !SKILL_SPEC_KEYS.includes(key))],
772
- body: body.trim(),
773
- };
774
- };
775
- const characterCount = (text) => Array.from(text).length;
776
- /**
777
- * Check a parsed `SKILL.md` against the Agent Skills spec — the checks
778
- * `skills-ref validate` runs, so a skill that passes here passes there:
779
- * `name` present, shaped like an id, equal to the directory name;
780
- * `description` and `compatibility` under their ceilings. Errors are what
781
- * refuses an install. Top-level keys the spec does not define are
782
- * warnings, not errors: the reference validator refuses them, but the
783
- * ecosystem's skills carry other hosts' fields routinely, and refusing a
784
- * skill over a field that changes nothing here fails the whole point of
785
- * conforming. The legacy Stratus keys get their own warning, naming the
786
- * `metadata:` form that ports.
787
- *
788
- * Loading never calls this — an installed skill keeps loading whatever
789
- * the spec says today. Installing always does.
790
- */
791
- export const validateSkillDocument = (document, options = {}) => {
792
- const errors = [];
793
- const warnings = [];
794
- if (document.name === undefined) {
795
- const suggestion = options.suggestedName ?? options.directoryName;
796
- errors.push(`frontmatter has no "name". The Agent Skills spec requires one, equal to the directory name${suggestion !== undefined ? ` — add: name: ${suggestion}` : ''}.`);
797
- }
798
- else if (!isValidSkillId(document.name)) {
799
- errors.push(`name ${JSON.stringify(document.name)} is not a skill id. ${SKILL_ID_RULE}`);
800
- }
801
- else if (options.directoryName !== undefined && document.name !== options.directoryName) {
802
- errors.push(`name ${JSON.stringify(document.name)} does not match the directory name ${JSON.stringify(options.directoryName)}. The spec requires the two to agree — rename one.`);
803
- }
804
- // Characters, as the spec and its Python reference count them — code
805
- // points, not the UTF-16 units `.length` reports, which would count an
806
- // emoji twice and refuse a description the reference validator passes.
807
- const descriptionLength = characterCount(document.description);
808
- if (descriptionLength > SKILL_DESCRIPTION_MAX_LENGTH) {
809
- errors.push(`description is ${descriptionLength} characters, past the spec's ceiling of ${SKILL_DESCRIPTION_MAX_LENGTH}. Say when to reach for the skill, and move the rest into the body.`);
810
- }
811
- const compatibilityLength = document.compatibility !== undefined ? characterCount(document.compatibility) : 0;
812
- if (compatibilityLength > SKILL_COMPATIBILITY_MAX_LENGTH) {
813
- errors.push(`compatibility is ${compatibilityLength} characters, past the spec's ceiling of ${SKILL_COMPATIBILITY_MAX_LENGTH}.`);
814
- }
815
- const legacy = document.unknownKeys.filter((key) => SKILL_LEGACY_KEYS.includes(key));
816
- const foreign = document.unknownKeys.filter((key) => !SKILL_LEGACY_KEYS.includes(key));
817
- for (const key of legacy) {
818
- warnings.push(key === 'requires'
819
- ? 'top-level "requires" is the pre-spec Stratus form, and the reference validator refuses it. Write it as metadata.requires, one space-separated string: "browser.* fs.read".'
820
- : 'top-level "version" is the pre-spec Stratus form, and the reference validator refuses it. Write it as metadata.version.');
821
- }
822
- if (foreign.length > 0) {
823
- warnings.push(`frontmatter key${foreign.length > 1 ? 's' : ''} outside the Agent Skills spec: ${foreign.map((key) => JSON.stringify(key)).join(', ')}. Another host's; ignored here, and the reference validator refuses ${foreign.length > 1 ? 'them' : 'it'}.`);
824
- }
825
- return { errors, warnings };
826
- };
827
- /**
828
- * A `Skill` whose body stays on disk until somebody asks. The identity
829
- * comes from the load-time parse (which is also what validated the file);
830
- * `load()` re-reads rather than closing over the body, so an
831
- * enabled-but-unused skill costs its description line and nothing else —
832
- * in the prompt and in memory.
833
- */
834
- export const createLazySkill = ({ id, document, read }) => ({
835
- id,
836
- name: document.name ?? id,
837
- description: document.description,
838
- ...(document.requires ? { requires: document.requires } : {}),
839
- load: async () => parseSkillDocument(await read()).body,
840
- });
841
- export const MEMORY_TOOL_NAME = 'memory.remember';
842
- /**
843
- * A tool that lets an agent write to its own long-term memory. Entries are
844
- * keyed by the session's agent id, so what one agent learns stays exclusively
845
- * that agent's knowledge — and follows it to every channel and thread.
846
- */
847
- export const createRememberTool = (store) => ({
848
- name: MEMORY_TOOL_NAME,
849
- description: 'Save a fact to your long-term memory so you can recall it in future conversations on any channel.',
850
- // The agent's own memory, keyed to the agent and read by nobody else.
851
- risk: 'safe',
852
- parameters: {
853
- type: 'object',
854
- properties: {
855
- fact: { type: 'string', description: 'The fact to remember, phrased so it makes sense later without context.' },
856
- },
857
- required: ['fact'],
858
- },
859
- async execute(input, session) {
860
- const fact = typeof input.fact === 'string' ? input.fact.trim() : '';
861
- if (!fact) {
862
- throw new Error('memory.remember requires a non-empty "fact" string.');
863
- }
864
- // Per session, not per fact: the runner knows this session has seen
865
- // untrusted content and cannot know which words of the fact came from
866
- // it, so the write carries the least trusted label the session holds.
867
- // Coarse and over-marking — the safe direction.
868
- const taintedBy = sessionTaintedBy(session);
869
- const origin = { sessionId: session.id, ...(taintedBy !== undefined ? { taintedBy } : {}) };
870
- const entry = await store.append(session.agent.id, fact, { sessionId: session.id }, {
871
- trust: sessionWriteTrust(session),
872
- origin,
873
- });
874
- return { remembered: true, id: entry.id, trust: memoryEntryTrust(entry) };
875
- },
876
- });
877
- export const RECALL_TOOL_NAME = 'memory.recall';
878
- /**
879
- * Searches the agent's own long-term memory. `safe` by the same rule as
880
- * `memory.remember`: reading what this agent already knows. The query is
881
- * literal text — the store does whatever escaping its backend needs, so no
882
- * query a model writes is ever a syntax error; matching nothing is a normal
883
- * result, because an agent that has learned nothing yet is the ordinary
884
- * starting state, not a broken tool.
885
- */
886
- export const createRecallTool = (store) => ({
887
- name: RECALL_TOOL_NAME,
888
- description: 'Search your long-term memory for facts you have remembered. Only your most recent memories appear in your prompt automatically — use this to find everything older.',
889
- risk: 'safe',
890
- parameters: {
891
- type: 'object',
892
- properties: {
893
- query: { type: 'string', description: 'What to look for, as plain words. Every word must appear in a fact for it to match.' },
894
- limit: { type: 'number', description: 'Maximum facts to return. Optional; the store bounds it either way.' },
895
- },
896
- required: ['query'],
897
- },
898
- async execute(input, session, context) {
899
- if (typeof input.query !== 'string') {
900
- throw new Error('memory.recall requires a "query" string.');
901
- }
902
- const limit = typeof input.limit === 'number' ? input.limit : undefined;
903
- const result = await store.search(session.agent.id, input.query, limit);
904
- // What enters the context taints the session, recalled as much as
905
- // injected: an `external` entry surfacing here is a stranger's text
906
- // in the prompt, and marking the call is what stops the store from
907
- // laundering its own contents through a fresh session.
908
- const lowest = leastTrusted(...result.entries.map(memoryEntryTrust));
909
- if (isTaintedTrust(lowest)) {
910
- context?.markTrust?.(lowest);
911
- }
912
- return {
913
- results: result.entries.map((entry) => ({
914
- id: entry.id,
915
- content: entry.content,
916
- createdAt: entry.createdAt,
917
- trust: memoryEntryTrust(entry),
918
- })),
919
- truncated: result.truncated,
920
- };
921
- },
922
- });
923
- export const FORGET_TOOL_NAME = 'memory.forget';
924
- /**
925
- * Retires one of the agent's own memories, by id. Tombstoned rather than
926
- * deleted — which is what makes `safe` defensible for it: the entry stops
927
- * being live but does not stop existing, so an operator can still see what
928
- * an agent chose to drop.
929
- */
930
- export const createForgetTool = (store) => ({
931
- name: FORGET_TOOL_NAME,
932
- description: 'Forget one of your remembered facts by its id (from memory.recall or memory.remember). It stops informing future conversations.',
933
- risk: 'safe',
934
- parameters: {
935
- type: 'object',
936
- properties: {
937
- id: { type: 'string', description: 'The id of the memory entry to forget.' },
938
- },
939
- required: ['id'],
940
- },
941
- async execute(input, session) {
942
- const id = typeof input.id === 'string' ? input.id.trim() : '';
943
- if (!id) {
944
- throw new Error('memory.forget requires a non-empty "id" string.');
945
- }
946
- const forgotten = await store.forget(session.agent.id, id);
947
- if (!forgotten) {
948
- throw new Error(`No live memory entry with id ${id} belongs to this agent — nothing was forgotten.`);
949
- }
950
- return { forgotten: true, id };
951
- },
952
- });
953
- export const DELEGATE_TOOL_NAME = 'agent.delegate';
954
- /**
955
- * Metadata a delegated sub-session carries: which agent delegated, and the
956
- * session at the root of the chain. Written by `agent.delegate` and read by
957
- * the gateway's restart sweep, which is why the keys are named here rather
958
- * than spelled twice — a sub-session's parent is a turn in some other
959
- * process's memory, and after a restart nothing can consume its reply.
960
- */
961
- export const DELEGATED_BY_METADATA_KEY = 'delegatedBy';
962
- export const ROOT_SESSION_ID_METADATA_KEY = 'rootSessionId';
963
- export const DELEGATION_DEPTH_METADATA_KEY = 'delegationDepth';
964
- /**
965
- * The segment `agent.delegate` mints into every sub-session's id —
966
- * `<parent>:delegate:<agent>:<depth>:<suffix>`. The second half of what
967
- * identifies a sub-session, beside the metadata above: the gateway refuses
968
- * the metadata keys at its public door, but rows written before it did
969
- * cannot be told apart by their metadata alone, and a caller who wrote
970
- * `delegatedBy` onto a session of its own would not also have minted an
971
- * id in this shape.
972
- */
973
- export const DELEGATED_SESSION_ID_MARKER = ':delegate:';
974
- /**
975
- * Whether this session is a delegated sub-session — one whose reply is
976
- * consumed by a parent turn's `agent.delegate` call and by nothing else.
977
- * Both halves are required: the metadata `agent.delegate` writes, and the
978
- * id shape it mints.
979
- */
980
- export const isDelegatedSession = (session) => typeof session.metadata?.[DELEGATED_BY_METADATA_KEY] === 'string'
981
- && session.id.includes(DELEGATED_SESSION_ID_MARKER);
982
- /**
983
- * The id of the session whose `agent.delegate` call started this one, read
984
- * off the id shape above — the text before the last marker, since a nested
985
- * sub-session embeds its parent's id whole. Undefined for an id that was
986
- * not minted that way.
987
- */
988
- export const delegatingSessionIdOf = (sessionId) => {
989
- const at = sessionId.lastIndexOf(DELEGATED_SESSION_ID_MARKER);
990
- return at > 0 ? sessionId.slice(0, at) : undefined;
991
- };
992
- /**
993
- * The id of the `agent.delegate` call in `parent` that is still awaiting
994
- * `child`, or undefined when none is.
995
- *
996
- * The one thing about a delegation nothing outside the runner can forge:
997
- * a caller can name a session in the sub-session id shape and write the
998
- * metadata keys, but only the runner writes assistant tool calls, and only
999
- * a delegation in flight leaves one with no result behind it. Matched to
1000
- * the child and not merely counted, because a parent can be inside one
1001
- * delegation while an earlier sub-session of its — continued from outside
1002
- * under a version that kept the markers — is parked on something of its
1003
- * own. The call names its target and its prompt, and `agent.delegate`
1004
- * made the prompt the child's first user message. Read against the
1005
- * transcript order so a re-used call id cannot make a later call look
1006
- * answered.
1007
- */
1008
- export const outstandingDelegationFor = (parent, child) => {
1009
- const firstMessage = child.messages.find((message) => message.role === 'user')?.content;
1010
- if (firstMessage === undefined) {
1011
- return undefined;
1012
- }
1013
- const answered = new Map();
1014
- for (const message of parent.messages) {
1015
- if (message.role === 'tool' && message.toolResult) {
1016
- answered.set(message.toolResult.callId, (answered.get(message.toolResult.callId) ?? 0) + 1);
1017
- }
1018
- }
1019
- const seen = new Map();
1020
- for (const message of parent.messages) {
1021
- if (message.role !== 'assistant' || !message.toolCalls) {
1022
- continue;
1023
- }
1024
- for (const call of message.toolCalls) {
1025
- const occurrence = (seen.get(call.id) ?? 0) + 1;
1026
- seen.set(call.id, occurrence);
1027
- if (call.toolName !== DELEGATE_TOOL_NAME || occurrence <= (answered.get(call.id) ?? 0)) {
1028
- continue;
1029
- }
1030
- const target = typeof call.input.agent === 'string' ? call.input.agent : '';
1031
- const prompt = typeof call.input.prompt === 'string' ? call.input.prompt.trim() : '';
1032
- if ((target === child.agent.id || target === child.agent.name) && prompt === firstMessage) {
1033
- return call.id;
1034
- }
1035
- }
1036
- }
1037
- return undefined;
1038
- };
1039
- /**
1040
- * `metadata` with the delegation markers removed — what a sub-session
1041
- * carries once it has been addressed from outside. The delegation ended
1042
- * with the turn that awaited it; a conversation somebody continues on the
1043
- * same id afterwards is an ordinary one, and must not be closed as an
1044
- * orphan by a later restart.
1045
- */
1046
- export const withoutDelegation = (metadata) => {
1047
- const { [DELEGATED_BY_METADATA_KEY]: _delegatedBy, [ROOT_SESSION_ID_METADATA_KEY]: _root, [DELEGATION_DEPTH_METADATA_KEY]: _depth, ...rest } = metadata;
1048
- return rest;
1049
- };
1050
- const DEFAULT_MAX_DELEGATION_DEPTH = 3;
1051
- /**
1052
- * The orchestrator primitive: a tool that runs another agent in its own
1053
- * sub-session (with that agent's memory, tool allowlist, and credentials)
1054
- * and returns its final reply as the tool result.
1055
- */
1056
- export const createDelegateTool = ({ registry, runner, dispatch, maxDepth = DEFAULT_MAX_DELEGATION_DEPTH, }) => {
1057
- const runDelegated = dispatch ?? ((input) => runner.run(input));
1058
- // Sub-session ids must be unique even when one orchestrator delegates to
1059
- // the same target repeatedly, and must stay unique across tool rebuilds
1060
- // sharing one SessionStore — so a counter alone is not enough.
1061
- let delegationCount = 0;
1062
- const uniqueSuffix = () => `${delegationCount}-${Math.random().toString(36).slice(2, 10)}`;
1063
- return {
1064
- name: DELEGATE_TOOL_NAME,
1065
- description: 'Delegate a task to another agent by name and get their reply back.',
1066
- // Safe, and the call is arguable enough to record why. Delegation spends
1067
- // provider tokens and starts work as another agent, which reads like
1068
- // `gated` — but the money argument proves too much: the turn that decides
1069
- // to delegate was itself an unapproved provider call, so gating on spend
1070
- // gates the conversation. What delegation does NOT do is act outside
1071
- // Stratus. It stays in the fleet, the delegate's own tool calls face the
1072
- // policy again under the delegate's allowlist, and maxDepth bounds the
1073
- // chain.
1074
- //
1075
- // The practical half: `gated` here means a headless daemon refuses every
1076
- // delegation, and headless is what every installed service runs. That
1077
- // would remove the orchestrator pattern from the product until remote
1078
- // approval exists — a feature removal wearing a safety hat, with no way
1079
- // for an operator to say yes. Revisit when a human can actually be asked.
1080
- risk: 'safe',
1081
- parameters: {
1082
- type: 'object',
1083
- properties: {
1084
- agent: { type: 'string', description: 'Name or id of the agent to delegate to.' },
1085
- prompt: { type: 'string', description: 'The task or question for that agent.' },
1086
- },
1087
- required: ['agent', 'prompt'],
1088
- },
1089
- async execute(input, session, context) {
1090
- const targetRef = typeof input.agent === 'string' ? input.agent : '';
1091
- const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
1092
- if (!targetRef || !prompt) {
1093
- throw new Error('agent.delegate requires "agent" and "prompt" strings.');
1094
- }
1095
- const depth = typeof session.metadata?.[DELEGATION_DEPTH_METADATA_KEY] === 'number'
1096
- ? session.metadata[DELEGATION_DEPTH_METADATA_KEY]
1097
- : 0;
1098
- if (depth >= maxDepth) {
1099
- throw new Error(`Delegation depth limit reached (${maxDepth}).`);
1100
- }
1101
- const target = registry.get(targetRef) ?? registry.getByName(targetRef);
1102
- if (!target) {
1103
- throw new Error(`Agent not found: ${targetRef}`);
1104
- }
1105
- if (target.id === session.agent.id) {
1106
- throw new Error('An agent cannot delegate to itself.');
1107
- }
1108
- // Judged on the session's frozen copy of the agent, as the tool
1109
- // allowlist is: the target is model-chosen input, and without this
1110
- // list any agent holding this tool could run a turn as any other —
1111
- // under the other's tools, credentials, and memory — which is the
1112
- // lateral move a prompt-injected agent would take.
1113
- if (!isDelegateAllowed(session.agent, target.id)) {
1114
- throw new Error(`Agent ${session.agent.id} may not delegate to ${target.id}. `
1115
- + `Add ${suggestedDelegatesEdit(target.id)} to its soul, or delegates: ['*'] for any agent on the roster.`);
1116
- }
1117
- delegationCount += 1;
1118
- let result;
1119
- try {
1120
- result = await runDelegated({
1121
- sessionId: `${session.id}${DELEGATED_SESSION_ID_MARKER}${target.id}:${depth + 1}:${uniqueSuffix()}`,
1122
- agent: target,
1123
- userMessage: prompt,
1124
- metadata: {
1125
- [DELEGATION_DEPTH_METADATA_KEY]: depth + 1,
1126
- [DELEGATED_BY_METADATA_KEY]: session.agent.id,
1127
- [ROOT_SESSION_ID_METADATA_KEY]: typeof session.metadata?.[ROOT_SESSION_ID_METADATA_KEY] === 'string'
1128
- ? session.metadata[ROOT_SESSION_ID_METADATA_KEY]
1129
- : session.id,
1130
- // Outbound: the prompt is the parent's text, and the parent is its
1131
- // "sender" — a tainted parent must not hand attacker text to a
1132
- // teammate whose session has seen nothing. At most `agent`: the
1133
- // prompt is something an agent wrote, whoever it was talking to.
1134
- [SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(session),
1135
- },
1136
- // A cancelled parent turn cancels the delegated run with it —
1137
- // otherwise the parent cannot settle until the target gives up.
1138
- ...(context?.signal ? { signal: context.signal } : {}),
1139
- });
1140
- }
1141
- catch (error) {
1142
- // A failed run has no session to read a label from here, and its
1143
- // error text is the target's — a provider quoting the request it
1144
- // refused, which is the target's prompt and injected memory, whatever
1145
- // label those carried. The parent sees that text as a tool error, so
1146
- // the call is marked at the label for provenance nobody can vouch
1147
- // for before it is rethrown. The parent's own cancellation is the
1148
- // exception: that error is this process's sentence, not the target's.
1149
- if (!context?.signal?.aborted) {
1150
- context?.markTrust?.('unknown');
1151
- }
1152
- throw error;
1153
- }
1154
- // Inbound: the reply carries the target session's label, whatever it
1155
- // is — not only `external`. A target whose own injected memory was
1156
- // `unknown` replies from content the parent never saw and cannot
1157
- // assess; the parent takes the lower of its own label and this one,
1158
- // by the same ordering as everything else.
1159
- const targetTrust = sessionTrustOf(result);
1160
- context?.markTrust?.(targetTrust);
1161
- const reply = [...result.messages]
1162
- .reverse()
1163
- .find((message) => message.role === 'assistant' && message.content.length > 0);
1164
- return {
1165
- agent: target.name,
1166
- reply: reply?.content ?? '(no reply)',
1167
- sessionId: result.id,
1168
- trust: targetTrust,
1169
- };
1170
- },
1171
- };
1172
- };
1173
- /**
1174
- * Route inbound work (a channel name, a message, a thread key) to an agent.
1175
- * The Slack transport will feed channel/mention strings through this so the
1176
- * same person consistently answers in the same places.
1177
- */
1178
- export const createAgentRouter = (rules, fallback) => {
1179
- // Global/sticky regexes mutate lastIndex on test(). Clone them (so the
1180
- // caller's regex is never touched) keeping all flags — sticky stays
1181
- // anchored — and reset lastIndex before every test for stable results.
1182
- const normalized = rules.map((rule) => ({
1183
- ...rule,
1184
- match: rule.match instanceof RegExp
1185
- ? new RegExp(rule.match.source, rule.match.flags)
1186
- : rule.match,
1187
- }));
1188
- return {
1189
- route(input) {
1190
- for (const rule of normalized) {
1191
- let matched;
1192
- if (rule.match instanceof RegExp) {
1193
- rule.match.lastIndex = 0;
1194
- matched = rule.match.test(input);
1195
- }
1196
- else {
1197
- matched = rule.match(input);
1198
- }
1199
- if (matched) {
1200
- return rule.agent;
1201
- }
1202
- }
1203
- return fallback;
1204
- },
1205
- };
1206
- };
1207
- /** Register a set of agents and get the registry back. */
1208
- export const createAgentTeam = (agents) => {
1209
- const registry = new AgentRegistry();
1210
- for (const agent of agents) {
1211
- registry.register(agent);
1212
- }
1213
- return registry;
1214
- };
1215
- /**
1216
- * The one string form of a destination, `<channel>:<native id>`, used
1217
- * everywhere a destination is *compared*: `Tool.destinationFor`, the
1218
- * permission policy's pre-authorization check, and the schedule row it is
1219
- * checked against. One canonicalization, or two writers of `slack:C01` and
1220
- * `SLACK:C01` would disagree about the same channel.
1221
- */
1222
- export const canonicalDestination = (destination) => `${destination.channel.trim().toLowerCase()}:${destination.to.trim()}`;
1223
- /** `every` strings: a positive integer and one unit — `90s`, `10m`, `2h`, `1d`. */
1224
- export const parseInterval = (text) => {
1225
- const match = /^(\d+)(s|m|h|d)$/.exec(text.trim());
1226
- if (!match) {
1227
- return undefined;
1228
- }
1229
- const amount = Number(match[1]);
1230
- if (!Number.isSafeInteger(amount) || amount <= 0) {
1231
- return undefined;
1232
- }
1233
- const unit = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[match[2]];
1234
- return amount * unit;
1235
- };
1236
- const parseCronField = (field, min, max, label) => {
1237
- const values = new Set();
1238
- for (const part of field.split(',')) {
1239
- const stepMatch = /^([^/]+)(?:\/(\d+))?$/.exec(part);
1240
- if (!stepMatch || part.length === 0) {
1241
- throw new Error(`Invalid cron ${label} field: ${field}`);
1242
- }
1243
- const [, rangeText, stepText] = stepMatch;
1244
- const step = stepText === undefined ? 1 : Number(stepText);
1245
- if (!Number.isSafeInteger(step) || step < 1) {
1246
- throw new Error(`Invalid cron ${label} step: ${part}`);
1247
- }
1248
- let low;
1249
- let high;
1250
- if (rangeText === '*') {
1251
- low = min;
1252
- high = max;
1253
- }
1254
- else {
1255
- const rangeMatch = /^(\d+)(?:-(\d+))?$/.exec(rangeText);
1256
- if (!rangeMatch) {
1257
- throw new Error(`Invalid cron ${label} field: ${field} (numbers only — names are not supported)`);
1258
- }
1259
- low = Number(rangeMatch[1]);
1260
- high = rangeMatch[2] === undefined ? low : Number(rangeMatch[2]);
1261
- // A bare value with a step (`5/15`) reads to the end of the range,
1262
- // matching vixie cron.
1263
- if (rangeMatch[2] === undefined && stepText !== undefined) {
1264
- high = max;
1265
- }
1266
- }
1267
- if (low < min || high > max || low > high) {
1268
- throw new Error(`Cron ${label} value out of range ${min}-${max}: ${part}`);
1269
- }
1270
- for (let value = low; value <= high; value += step) {
1271
- values.add(value);
1272
- }
1273
- }
1274
- return values;
1275
- };
1276
- /**
1277
- * Five fields — minute, hour, day-of-month, month, day-of-week — with `*`,
1278
- * lists, ranges, and steps. Numbers only; day-of-week 0-7 with both 0 and 7
1279
- * as Sunday. Evaluated in the daemon's local time, which is what "every
1280
- * morning at 7" means to the person whose machine it is.
1281
- */
1282
- export const parseCronExpression = (expression) => {
1283
- const fields = expression.trim().split(/\s+/);
1284
- if (fields.length !== 5) {
1285
- throw new Error(`A cron expression has five fields (minute hour day-of-month month day-of-week); got ${fields.length} in "${expression}".`);
1286
- }
1287
- const [minute, hour, dom, month, dow] = fields;
1288
- const daysOfWeek = parseCronField(dow, 0, 7, 'day-of-week');
1289
- if (daysOfWeek.has(7)) {
1290
- daysOfWeek.delete(7);
1291
- daysOfWeek.add(0);
1292
- }
1293
- return {
1294
- minutes: parseCronField(minute, 0, 59, 'minute'),
1295
- hours: parseCronField(hour, 0, 23, 'hour'),
1296
- daysOfMonth: parseCronField(dom, 1, 31, 'day-of-month'),
1297
- months: parseCronField(month, 1, 12, 'month'),
1298
- daysOfWeek,
1299
- domRestricted: dom !== '*',
1300
- dowRestricted: dow !== '*',
1301
- };
1302
- };
1303
- /** How far a cron search looks before concluding the expression never fires. */
1304
- const CRON_SEARCH_DAYS = 366 * 4;
1305
- const nextCronFire = (fields, after) => {
1306
- // Strictly after `after`, at minute granularity.
1307
- const start = new Date(after.getTime());
1308
- start.setSeconds(0, 0);
1309
- start.setMinutes(start.getMinutes() + 1);
1310
- const dayMatches = (date) => {
1311
- if (!fields.months.has(date.getMonth() + 1)) {
1312
- return false;
1313
- }
1314
- const domMatch = fields.daysOfMonth.has(date.getDate());
1315
- const dowMatch = fields.daysOfWeek.has(date.getDay());
1316
- if (fields.domRestricted && fields.dowRestricted) {
1317
- return domMatch || dowMatch;
1318
- }
1319
- return fields.domRestricted ? domMatch : fields.dowRestricted ? dowMatch : true;
1320
- };
1321
- const hours = [...fields.hours].sort((a, b) => a - b);
1322
- const minutes = [...fields.minutes].sort((a, b) => a - b);
1323
- const day = new Date(start.getFullYear(), start.getMonth(), start.getDate());
1324
- for (let offset = 0; offset < CRON_SEARCH_DAYS; offset += 1) {
1325
- if (dayMatches(day)) {
1326
- for (const hour of hours) {
1327
- for (const minute of minutes) {
1328
- const candidate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, minute);
1329
- if (candidate >= start && candidate > after) {
1330
- return candidate;
1331
- }
1332
- }
1333
- }
1334
- }
1335
- day.setDate(day.getDate() + 1);
1336
- }
1337
- // Reachable: `0 0 31 2 *` matches no real date. Creation treats this as
1338
- // a refusal, not a schedule that quietly never runs.
1339
- return undefined;
1340
- };
1341
- /**
1342
- * The slot after `after`, or undefined when there is none — a one-shot
1343
- * already past, or a cron expression that matches no real date. One
1344
- * implementation, because the scheduler advancing a fired slot and the
1345
- * creation path computing the first one must never disagree.
1346
- */
1347
- export const nextFireAfter = (cadence, after) => {
1348
- switch (cadence.kind) {
1349
- case 'every':
1350
- return new Date(after.getTime() + cadence.intervalMs);
1351
- case 'cron':
1352
- return nextCronFire(parseCronExpression(cadence.expression), after);
1353
- case 'at': {
1354
- const at = new Date(cadence.at);
1355
- if (Number.isNaN(at.getTime())) {
1356
- return undefined;
1357
- }
1358
- return at.getTime() > after.getTime() ? at : undefined;
1359
- }
1360
- default:
1361
- return undefined;
1362
- }
1363
- };
1364
- /** One line for a human: `every 30m`, `cron 0 7 * * *`, `once at 2026-…`. */
1365
- export const describeCadence = (cadence) => {
1366
- switch (cadence.kind) {
1367
- case 'every': {
1368
- const units = [[86_400_000, 'd'], [3_600_000, 'h'], [60_000, 'm'], [1_000, 's']];
1369
- for (const [ms, suffix] of units) {
1370
- if (cadence.intervalMs % ms === 0) {
1371
- return `every ${cadence.intervalMs / ms}${suffix}`;
1372
- }
1373
- }
1374
- return `every ${cadence.intervalMs}ms`;
1375
- }
1376
- case 'cron':
1377
- return `cron ${cadence.expression}`;
1378
- case 'at':
1379
- return `once at ${cadence.at}`;
1380
- default:
1381
- return 'unknown';
1382
- }
1383
- };
1384
- /**
1385
- * Metadata a firing's session carries. The schedule id is what the
1386
- * permission carve-out resolves the approved destination from, and
1387
- * `scheduled: true` is what lets a renderer say a turn arrived from a
1388
- * schedule rather than a person.
1389
- */
1390
- export const SCHEDULE_ID_METADATA_KEY = 'scheduleId';
1391
- export const SCHEDULED_TURN_METADATA_KEY = 'scheduled';
1392
- // ---- schedule tools ---------------------------------------------------------
1393
- export const SCHEDULE_EVERY_TOOL_NAME = 'schedule.every';
1394
- export const SCHEDULE_AT_TOOL_NAME = 'schedule.at';
1395
- export const SCHEDULE_LIST_TOOL_NAME = 'schedule.list';
1396
- export const SCHEDULE_CANCEL_TOOL_NAME = 'schedule.cancel';
1397
- export const MESSAGE_SEND_TOOL_NAME = 'message.send';
1398
- /**
1399
- * The tools only a running gateway registers. Each needs the dispatcher,
1400
- * the store, or the channels, and nothing else in the tree has them — so a
1401
- * soul naming one is correct while a host without them cannot call it,
1402
- * which is a different thing from a name that does not exist. `stratus
1403
- * run` reads this to say which it is rather than reporting a daemon soul's
1404
- * `schedule.*` as a typo.
1405
- *
1406
- * It lives here, with the names, rather than in `@stratusagent/gateway`
1407
- * where the registrations are: the CLI imports the gateway only through
1408
- * `await import`, because `bin.ts` has to load the CLI to print the Node
1409
- * version message and the gateway pulls in `node:sqlite` at module scope.
1410
- * A static import for a list of strings would have made the floor guard
1411
- * fail with the exact builtin-module error it exists to replace. The
1412
- * gateway's own test pins this set against what it actually registers,
1413
- * which holds the two together better than proximity would.
1414
- */
1415
- export const GATEWAY_ONLY_TOOL_NAMES = [
1416
- SCHEDULE_EVERY_TOOL_NAME,
1417
- SCHEDULE_AT_TOOL_NAME,
1418
- SCHEDULE_LIST_TOOL_NAME,
1419
- SCHEDULE_CANCEL_TOOL_NAME,
1420
- MESSAGE_SEND_TOOL_NAME,
1421
- DELEGATE_TOOL_NAME,
1422
- ];
1423
- const parseDestinationInput = (raw) => {
1424
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1425
- return undefined;
1426
- }
1427
- const channel = raw.channel;
1428
- const to = raw.to;
1429
- if (typeof channel !== 'string' || channel.trim().length === 0
1430
- || typeof to !== 'string' || to.trim().length === 0) {
1431
- return undefined;
1432
- }
1433
- return { channel: channel.trim().toLowerCase(), to: to.trim() };
1434
- };
1435
- const DESTINATION_PARAMETER = {
1436
- type: 'object',
1437
- description: 'Where firings report. Omit for a schedule that does not speak — its sends will need approval like any other. The channel kind plus the channel-native conversation id (for Slack, a channel id like C0123456789 that your app is a member of).',
1438
- properties: {
1439
- channel: { type: 'string', description: "Channel kind, e.g. 'slack'." },
1440
- to: { type: 'string', description: 'Channel-native conversation id.' },
1441
- },
1442
- required: ['channel', 'to'],
1443
- };
1444
- /**
1445
- * One schedule as a surface shows it — the CLI table, the control API's
1446
- * listing, and the tool results all render this one projection, so the
1447
- * operator's view and the agent's cannot drift.
1448
- */
1449
- export const describeSchedule = (record) => ({
1450
- id: record.id,
1451
- agentId: record.agentId,
1452
- cadence: describeCadence(record.cadence),
1453
- prompt: record.prompt,
1454
- trust: record.trust ?? 'unknown',
1455
- ...(record.destination ? { destination: canonicalDestination(record.destination) } : {}),
1456
- ...(record.nextFireAt ? { nextFireAt: record.nextFireAt } : {}),
1457
- ...(record.lastFiredAt ? { lastFiredAt: record.lastFiredAt } : {}),
1458
- createdAt: record.createdAt,
1459
- });
1460
- /**
1461
- * The four `schedule.*` tools over one handle.
1462
- *
1463
- * Risk is split per tool, not per toolset: creating a schedule spends
1464
- * future money unattended and (with a destination) mints a standing
1465
- * permission to speak, so `schedule.every` and `schedule.at` are `gated` —
1466
- * the approval of THAT call is the human decision the whole step leans on.
1467
- * `schedule.list` is a read, and `schedule.cancel` only ever narrows
1468
- * authority — it is the reversal the risk note in the spec names — so both
1469
- * are `safe`: a headless agent that set a bad schedule must be able to
1470
- * undo it without waiting for the human whose absence is the problem.
1471
- */
1472
- export const createScheduleTools = (scheduler) => {
1473
- const create = async (session, cadence, input) => {
1474
- const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
1475
- if (!prompt) {
1476
- throw new Error('A schedule needs a non-empty "prompt" — the instruction each firing runs.');
1477
- }
1478
- if (input.destination !== undefined && parseDestinationInput(input.destination) === undefined) {
1479
- throw new Error('"destination" must be { channel, to } with non-empty strings, or omitted.');
1480
- }
1481
- const destination = parseDestinationInput(input.destination);
1482
- const record = await scheduler.create({
1483
- agentId: session.agent.id,
1484
- cadence,
1485
- prompt,
1486
- ...(destination ? { destination } : {}),
1487
- createdBy: session.id,
1488
- trust: sessionWriteTrust(session),
1489
- });
1490
- return { created: true, schedule: describeSchedule(record) };
1491
- };
1492
- return [
1493
- {
1494
- name: SCHEDULE_EVERY_TOOL_NAME,
1495
- description: 'Set a recurring schedule for yourself: an interval ("30m", "1d") or a five-field cron expression, the prompt each firing runs, and optionally the destination your reports are pre-authorized to post to. Creating a schedule needs human approval once; its firings then run unattended.',
1496
- risk: 'gated',
1497
- parameters: {
1498
- type: 'object',
1499
- properties: {
1500
- every: { type: 'string', description: 'Interval like "90s", "30m", "2h", "1d". Exactly one of "every" or "cron".' },
1501
- cron: { type: 'string', description: 'Five-field cron expression (minute hour day-of-month month day-of-week), local time. Exactly one of "every" or "cron".' },
1502
- prompt: { type: 'string', description: 'The instruction each firing runs, phrased to stand alone.' },
1503
- destination: DESTINATION_PARAMETER,
1504
- },
1505
- required: ['prompt'],
1506
- },
1507
- async execute(input, session) {
1508
- const every = typeof input.every === 'string' ? input.every.trim() : '';
1509
- const cron = typeof input.cron === 'string' ? input.cron.trim() : '';
1510
- if ((every === '') === (cron === '')) {
1511
- throw new Error('Pass exactly one of "every" (an interval) or "cron" (a cron expression).');
1512
- }
1513
- if (every) {
1514
- const intervalMs = parseInterval(every);
1515
- if (intervalMs === undefined) {
1516
- throw new Error(`Not an interval: "${every}". Use a positive integer and one unit — "90s", "30m", "2h", "1d".`);
1517
- }
1518
- return create(session, { kind: 'every', intervalMs }, input);
1519
- }
1520
- parseCronExpression(cron); // Refuse a bad expression here, with its own message.
1521
- return create(session, { kind: 'cron', expression: cron }, input);
1522
- },
1523
- },
1524
- {
1525
- name: SCHEDULE_AT_TOOL_NAME,
1526
- description: 'Schedule a one-shot run of a prompt at a future time (ISO-8601). Needs human approval once; the firing then runs unattended.',
1527
- risk: 'gated',
1528
- parameters: {
1529
- type: 'object',
1530
- properties: {
1531
- at: { type: 'string', description: 'When to fire, ISO-8601 (e.g. 2026-09-01T07:00:00). Must be in the future.' },
1532
- prompt: { type: 'string', description: 'The instruction the firing runs, phrased to stand alone.' },
1533
- destination: DESTINATION_PARAMETER,
1534
- },
1535
- required: ['at', 'prompt'],
1536
- },
1537
- async execute(input, session) {
1538
- const at = typeof input.at === 'string' ? input.at.trim() : '';
1539
- const parsed = new Date(at);
1540
- if (!at || Number.isNaN(parsed.getTime())) {
1541
- throw new Error(`Not a timestamp: "${at}". Use ISO-8601, e.g. 2026-09-01T07:00:00.`);
1542
- }
1543
- if (parsed.getTime() <= Date.now()) {
1544
- throw new Error(`${at} is in the past — a one-shot schedule must name a future time.`);
1545
- }
1546
- return create(session, { kind: 'at', at: parsed.toISOString() }, input);
1547
- },
1548
- },
1549
- {
1550
- name: SCHEDULE_LIST_TOOL_NAME,
1551
- description: 'List your own schedules: cadence, prompt, destination, and when each fires next.',
1552
- // A read of state this agent itself created.
1553
- risk: 'safe',
1554
- parameters: { type: 'object', properties: {} },
1555
- async execute(_input, session, context) {
1556
- const records = await scheduler.list(session.agent.id);
1557
- // Each prompt was written by the session that set the schedule, at
1558
- // that session's label — and a schedule from before labels existed
1559
- // has none. Listing puts those prompts in front of the model, so the
1560
- // call is marked at the lowest of them, as `memory.recall` marks a
1561
- // hit: a fresh session must not restate a tainted prompt as its own.
1562
- const lowest = leastTrusted(...records.map((record) => record.trust ?? 'unknown'));
1563
- if (records.length > 0 && isTaintedTrust(lowest)) {
1564
- context?.markTrust?.(lowest);
1565
- }
1566
- return { schedules: records.map(describeSchedule) };
1567
- },
1568
- },
1569
- {
1570
- name: SCHEDULE_CANCEL_TOOL_NAME,
1571
- description: 'Cancel one of your schedules by id. Cancelling also revokes the pre-authorized destination that was approved with it.',
1572
- // Cancel only ever NARROWS authority — it destroys a human-minted
1573
- // grant — and it is the reversal that keeps schedule creation short
1574
- // of `dangerous`. Gating it would leave a headless agent unable to
1575
- // undo its own schedule for want of the human whose absence is the
1576
- // point of headless.
1577
- risk: 'safe',
1578
- parameters: {
1579
- type: 'object',
1580
- properties: {
1581
- id: { type: 'string', description: 'The schedule id, from schedule.list or the creation result.' },
1582
- },
1583
- required: ['id'],
1584
- },
1585
- async execute(input, session) {
1586
- const id = typeof input.id === 'string' ? input.id.trim() : '';
1587
- if (!id) {
1588
- throw new Error('schedule.cancel requires a non-empty "id".');
1589
- }
1590
- const cancelled = await scheduler.cancel(session.agent.id, id);
1591
- if (!cancelled) {
1592
- throw new Error(`No schedule of yours has id ${id}. schedule.list shows what exists.`);
1593
- }
1594
- return { cancelled: true, id };
1595
- },
1596
- },
1597
- ];
1598
- };
1599
- /**
1600
- * Post to a channel or DM outside the current conversation — what makes a
1601
- * scheduled turn observable.
1602
- *
1603
- * `gated`, and deliberately not `safe` the way `agent.delegate` is: the
1604
- * delegate stays inside the fleet, under every per-agent allowlist and the
1605
- * same approval policy, while this speaks to people who did not ask. The
1606
- * unattended path is the schedule carve-out — `destinationFor` names where
1607
- * this call would speak, and the policy allows it exactly when the firing's
1608
- * schedule was approved with that destination.
1609
- */
1610
- export const createMessageSendTool = (send) => ({
1611
- name: MESSAGE_SEND_TOOL_NAME,
1612
- description: 'Send a message to a channel or DM you are not currently talking in. Scheduled turns may post to their schedule\'s approved destination without asking; anywhere else needs approval.',
1613
- risk: 'gated',
1614
- parameters: {
1615
- type: 'object',
1616
- properties: {
1617
- destination: {
1618
- ...DESTINATION_PARAMETER,
1619
- description: 'Where to post: the channel kind plus the channel-native conversation id.',
1620
- },
1621
- text: { type: 'string', description: 'The message text.' },
1622
- },
1623
- required: ['destination', 'text'],
1624
- },
1625
- destinationFor(input) {
1626
- const destination = parseDestinationInput(input.destination);
1627
- return destination ? canonicalDestination(destination) : undefined;
1628
- },
1629
- async execute(input, session) {
1630
- const destination = parseDestinationInput(input.destination);
1631
- if (!destination) {
1632
- throw new Error('message.send requires "destination" as { channel, to } with non-empty strings.');
1633
- }
1634
- const text = typeof input.text === 'string' ? input.text.trim() : '';
1635
- if (!text) {
1636
- throw new Error('message.send requires a non-empty "text".');
1637
- }
1638
- await send({ agentId: session.agent.id, destination, text });
1639
- return { sent: true, destination: canonicalDestination(destination) };
1640
- },
1641
- });
1
+ // The package's one public surface: every export below is re-exported by
2
+ // name from the module that owns it. What an agent *is* — its identity,
3
+ // its soul file, its skills — sits at the top level; `tools/` holds the
4
+ // tool factories an agent is given (memory, delegation, schedules,
5
+ // message.send), each with the tool names it owns.
6
+ export { AVATAR_STYLE, generateAgentName, generateAvatarTheme, AGENT_ID_PATTERN, MAX_AGENT_ID_LENGTH, isValidAgentId, isValidDelegateEntry, MAX_SESSION_ID_LENGTH, isValidSessionId, agentIdWithSuffix, } from "./identity.js";
7
+ export { defineAgent } from "./define.js";
8
+ export { isDelegateAllowed, parseSoul, formatSoul, } from "./soul.js";
9
+ export { SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, isLoadableSkillId, SKILL_ID_RULE, SKILL_DESCRIPTION_MAX_LENGTH, SKILL_COMPATIBILITY_MAX_LENGTH, parseSkillDocument, validateSkillDocument, createLazySkill, } from "./skills.js";
10
+ export { canonicalDestination, parseInterval, parseCronExpression, nextFireAfter, describeCadence, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, describeSchedule, } from "./schedules.js";
11
+ export { createAgentRouter, createAgentTeam, } from "./router.js";
12
+ export { MEMORY_TOOL_NAME, createRememberTool, RECALL_TOOL_NAME, createRecallTool, FORGET_TOOL_NAME, createForgetTool, PIN_TOOL_NAME, createPinTool, } from "./tools/memory.js";
13
+ export { DELEGATE_TOOL_NAME, DELEGATED_BY_METADATA_KEY, ROOT_SESSION_ID_METADATA_KEY, DELEGATION_DEPTH_METADATA_KEY, DELEGATED_SESSION_ID_MARKER, isDelegatedSession, delegatingSessionIdOf, outstandingDelegationFor, withoutDelegation, createDelegateTool, } from "./tools/delegate.js";
14
+ export { SCHEDULE_EVERY_TOOL_NAME, SCHEDULE_AT_TOOL_NAME, SCHEDULE_LIST_TOOL_NAME, SCHEDULE_CANCEL_TOOL_NAME, createScheduleTools, } from "./tools/schedule.js";
15
+ export { MESSAGE_SEND_TOOL_NAME, createMessageSendTool, } from "./tools/message.js";
16
+ export { GATEWAY_ONLY_TOOL_NAMES } from "./tools/gateway-only.js";
1642
17
  //# sourceMappingURL=index.js.map