@runravel/ravel 0.1.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/CHANGELOG.md +34 -0
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/bin/ravel.mjs +6 -0
- package/examples/acme/agent.md +14 -0
- package/examples/acme/growth/agent.md +19 -0
- package/examples/acme/growth/copywriter/agent.md +13 -0
- package/examples/acme/growth/copywriter/tools.json +11 -0
- package/examples/acme/growth/researcher/agent.md +11 -0
- package/examples/acme/processes/prospect-outreach.process.md +24 -0
- package/examples/harbor/agent.md +31 -0
- package/examples/harbor/processes/new-client-quote.process.md +31 -0
- package/examples/harbor/processes/resolve-ticket-batch.process.md +33 -0
- package/examples/harbor/sales/agent.md +29 -0
- package/examples/harbor/sales/sdr/agent.md +24 -0
- package/examples/harbor/sales/solutions/agent.md +29 -0
- package/examples/harbor/sales/solutions/tools.json +16 -0
- package/examples/harbor/support/agent.md +31 -0
- package/examples/harbor/support/kb-writer/agent.md +25 -0
- package/examples/harbor/support/kb-writer/tools.json +10 -0
- package/examples/harbor/support/qa-reviewer/agent.md +23 -0
- package/examples/harbor/support/tools.json +16 -0
- package/examples/harbor/support/triage/agent.md +24 -0
- package/examples/harbor/support/triage/tools.json +10 -0
- package/examples/plugin-demo/agent.md +15 -0
- package/examples/plugin-demo/processes/jot.process.md +21 -0
- package/examples/plugin-demo/scribe/agent.md +15 -0
- package/examples/plugin-demo/scribe/plugin.ts +53 -0
- package/examples/plugin-demo/scribe/tools.json +9 -0
- package/package.json +65 -0
- package/src/cli/main.ts +428 -0
- package/src/control-plane/registry.ts +294 -0
- package/src/control-plane/watcher.ts +132 -0
- package/src/domain/ids.ts +17 -0
- package/src/domain/pricing.ts +51 -0
- package/src/domain/types.ts +168 -0
- package/src/index.ts +35 -0
- package/src/memory/genericTools.ts +276 -0
- package/src/memory/kv.ts +52 -0
- package/src/memory/store.ts +76 -0
- package/src/messaging/bus.ts +143 -0
- package/src/messaging/inbox.ts +119 -0
- package/src/orchestrator/orchestrator.ts +270 -0
- package/src/orchestrator/planner.ts +218 -0
- package/src/platform/app.ts +287 -0
- package/src/plugins/loader.ts +86 -0
- package/src/plugins/server.ts +41 -0
- package/src/plugins/types.ts +96 -0
- package/src/runtime/agent.ts +488 -0
- package/src/runtime/engine.ts +84 -0
- package/src/runtime/fakeEngine.ts +75 -0
- package/src/runtime/lifecycle.ts +85 -0
- package/src/runtime/officeActions.ts +58 -0
- package/src/runtime/officeTools.ts +42 -0
- package/src/runtime/sdkEngine.ts +213 -0
- package/src/schemas/agent.ts +47 -0
- package/src/schemas/common.ts +52 -0
- package/src/schemas/frontmatter.ts +36 -0
- package/src/schemas/process.ts +55 -0
- package/src/schemas/tools.ts +83 -0
- package/src/secrets/store.ts +131 -0
- package/src/service/scheduler.ts +377 -0
- package/src/service/server.ts +554 -0
- package/src/trust/approval.ts +180 -0
- package/src/trust/audit.ts +195 -0
- package/src/trust/budget.ts +64 -0
- package/src/trust/emittingAudit.ts +32 -0
- package/src/trust/executor.ts +73 -0
- package/src/trust/killswitch.ts +73 -0
- package/src/trust/observability.ts +97 -0
- package/src/trust/proposals.ts +114 -0
- package/ui/dist/assets/index-C6CxDaPS.js +44 -0
- package/ui/dist/assets/index-CD-lhs0Z.css +1 -0
- package/ui/dist/index.html +13 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseAgentSpec, type AgentSpec } from "../schemas/agent.js";
|
|
4
|
+
import { parseToolsConfig, EMPTY_TOOLS_CONFIG, type ToolsConfig } from "../schemas/tools.js";
|
|
5
|
+
import { parseProcessSpec, type ProcessSpec } from "../schemas/process.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The compiled, immutable representation of one agent folder.
|
|
9
|
+
*
|
|
10
|
+
* Folder position defines the *escalation/permission tree* (parent ← manager,
|
|
11
|
+
* children → direct reports), not the execution flow. Execution is driven by
|
|
12
|
+
* processes and the orchestrator. `id` is a stable, path-derived identifier so
|
|
13
|
+
* the same folder keeps the same id across recompiles.
|
|
14
|
+
*/
|
|
15
|
+
export interface RegistryNode {
|
|
16
|
+
/** Stable id derived from the path relative to the org root (POSIX slashes). Root is "". */
|
|
17
|
+
id: string;
|
|
18
|
+
/** Absolute path to the agent folder. */
|
|
19
|
+
dir: string;
|
|
20
|
+
spec: AgentSpec;
|
|
21
|
+
tools: ToolsConfig;
|
|
22
|
+
parentId: string | null;
|
|
23
|
+
childIds: string[];
|
|
24
|
+
/** Processes authored at this node's `processes/` directory. */
|
|
25
|
+
processes: ProcessSpec[];
|
|
26
|
+
/**
|
|
27
|
+
* Root-relative path to this node's `plugin.ts`, if present. Compile only
|
|
28
|
+
* RECORDS the path — it never imports team code. The platform loads it later.
|
|
29
|
+
*/
|
|
30
|
+
pluginPath?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A process plus the node id that owns its execution. */
|
|
34
|
+
export interface RegistryProcess {
|
|
35
|
+
spec: ProcessSpec;
|
|
36
|
+
/** Node that authored the process file. */
|
|
37
|
+
definedAtNodeId: string;
|
|
38
|
+
/** Node resolved from `spec.owner` that will decompose & dispatch it. */
|
|
39
|
+
ownerNodeId: string;
|
|
40
|
+
/** The process file's path, relative to the org root (for the config editor). */
|
|
41
|
+
path: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Diagnostic {
|
|
45
|
+
/** Path (file or dir) the problem relates to, relative to the org root. */
|
|
46
|
+
where: string;
|
|
47
|
+
message: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* An immutable snapshot of the whole org. The watcher builds a new snapshot on
|
|
52
|
+
* every change; the lifecycle manager diffs snapshots to drain/spawn agents.
|
|
53
|
+
* Editing a folder never mutates a running agent directly — it produces a new
|
|
54
|
+
* snapshot that is swapped in deliberately.
|
|
55
|
+
*/
|
|
56
|
+
export interface RegistrySnapshot {
|
|
57
|
+
/** Monotonic version assigned by the compiler caller. */
|
|
58
|
+
version: number;
|
|
59
|
+
/** Absolute org root path. */
|
|
60
|
+
root: string;
|
|
61
|
+
rootId: string;
|
|
62
|
+
nodes: ReadonlyMap<string, RegistryNode>;
|
|
63
|
+
/** All processes across the org, with owners resolved. */
|
|
64
|
+
processes: readonly RegistryProcess[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CompileResult {
|
|
68
|
+
ok: boolean;
|
|
69
|
+
snapshot: RegistrySnapshot | null;
|
|
70
|
+
diagnostics: Diagnostic[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const AGENT_FILE = "agent.md";
|
|
74
|
+
const TOOLS_FILE = "tools.json";
|
|
75
|
+
const PROCESS_DIR = "processes";
|
|
76
|
+
const PROCESS_SUFFIX = ".process.md";
|
|
77
|
+
const PLUGIN_FILE = "plugin.ts";
|
|
78
|
+
|
|
79
|
+
function toNodeId(root: string, dir: string): string {
|
|
80
|
+
const rel = path.relative(root, dir);
|
|
81
|
+
return rel.split(path.sep).join("/"); // "" for root
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function describeError(err: unknown): string {
|
|
85
|
+
if (err && typeof err === "object" && "issues" in err) {
|
|
86
|
+
// ZodError — flatten to a compact, readable list.
|
|
87
|
+
const issues = (err as { issues: Array<{ path: (string | number)[]; message: string }> }).issues;
|
|
88
|
+
return issues
|
|
89
|
+
.map((i) => (i.path.length ? `${i.path.join(".")}: ${i.message}` : i.message))
|
|
90
|
+
.join("; ");
|
|
91
|
+
}
|
|
92
|
+
return err instanceof Error ? err.message : String(err);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Is this directory an agent node? (contains an agent.md) */
|
|
96
|
+
async function hasAgentFile(dir: string): Promise<boolean> {
|
|
97
|
+
try {
|
|
98
|
+
await fs.access(path.join(dir, AGENT_FILE));
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ScanContext {
|
|
106
|
+
root: string;
|
|
107
|
+
nodes: Map<string, RegistryNode>;
|
|
108
|
+
diagnostics: Diagnostic[];
|
|
109
|
+
/** definedAt + spec, owner resolved in a second pass once all nodes exist. */
|
|
110
|
+
pendingProcesses: Array<{ spec: ProcessSpec; definedAtNodeId: string; where: string }>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readProcesses(
|
|
114
|
+
dir: string,
|
|
115
|
+
ctx: ScanContext,
|
|
116
|
+
nodeId: string,
|
|
117
|
+
): Promise<ProcessSpec[]> {
|
|
118
|
+
const procDir = path.join(dir, PROCESS_DIR);
|
|
119
|
+
let entries: string[];
|
|
120
|
+
try {
|
|
121
|
+
entries = (await fs.readdir(procDir)).filter((f) => f.endsWith(PROCESS_SUFFIX));
|
|
122
|
+
} catch {
|
|
123
|
+
return []; // no processes/ dir is fine
|
|
124
|
+
}
|
|
125
|
+
entries.sort(); // deterministic ordering — stable cache keys downstream
|
|
126
|
+
const result: ProcessSpec[] = [];
|
|
127
|
+
for (const file of entries) {
|
|
128
|
+
const full = path.join(procDir, file);
|
|
129
|
+
const rel = toNodeId(ctx.root, full);
|
|
130
|
+
try {
|
|
131
|
+
const spec = parseProcessSpec(await fs.readFile(full, "utf8"));
|
|
132
|
+
result.push(spec);
|
|
133
|
+
ctx.pendingProcesses.push({ spec, definedAtNodeId: nodeId, where: rel });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
ctx.diagnostics.push({ where: rel, message: describeError(err) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function scanNode(
|
|
142
|
+
dir: string,
|
|
143
|
+
parentId: string | null,
|
|
144
|
+
ctx: ScanContext,
|
|
145
|
+
): Promise<string | null> {
|
|
146
|
+
const id = toNodeId(ctx.root, dir);
|
|
147
|
+
const relDir = id || ".";
|
|
148
|
+
|
|
149
|
+
// Parse agent.md
|
|
150
|
+
let spec: AgentSpec;
|
|
151
|
+
try {
|
|
152
|
+
spec = parseAgentSpec(await fs.readFile(path.join(dir, AGENT_FILE), "utf8"));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
ctx.diagnostics.push({ where: path.join(relDir, AGENT_FILE), message: describeError(err) });
|
|
155
|
+
return null; // node invalid; do not register
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Parse tools.json (optional)
|
|
159
|
+
let tools: ToolsConfig = EMPTY_TOOLS_CONFIG;
|
|
160
|
+
try {
|
|
161
|
+
const raw = await fs.readFile(path.join(dir, TOOLS_FILE), "utf8");
|
|
162
|
+
tools = parseToolsConfig(raw);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
165
|
+
ctx.diagnostics.push({ where: path.join(relDir, TOOLS_FILE), message: describeError(err) });
|
|
166
|
+
// Tools invalid is a hard error for the node — running with the wrong
|
|
167
|
+
// permission surface is unsafe. Drop the node.
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const processes = await readProcesses(dir, ctx, id);
|
|
173
|
+
|
|
174
|
+
// Detect an optional team plugin (record its path only; never import at compile).
|
|
175
|
+
let pluginPath: string | undefined;
|
|
176
|
+
try {
|
|
177
|
+
await fs.access(path.join(dir, PLUGIN_FILE));
|
|
178
|
+
pluginPath = toNodeId(ctx.root, path.join(dir, PLUGIN_FILE)); // root-relative
|
|
179
|
+
} catch {
|
|
180
|
+
/* no plugin.ts — fine */
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Recurse into child folders that contain an agent.md.
|
|
184
|
+
const childIds: string[] = [];
|
|
185
|
+
const dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
186
|
+
const childDirs = dirents
|
|
187
|
+
.filter((d) => d.isDirectory() && d.name !== PROCESS_DIR && !d.name.startsWith("."))
|
|
188
|
+
.map((d) => path.join(dir, d.name))
|
|
189
|
+
.sort(); // deterministic
|
|
190
|
+
|
|
191
|
+
// Register this node before recursing so children can reference parentId.
|
|
192
|
+
ctx.nodes.set(id, { id, dir, spec, tools, parentId, childIds, processes, ...(pluginPath ? { pluginPath } : {}) });
|
|
193
|
+
|
|
194
|
+
for (const child of childDirs) {
|
|
195
|
+
if (await hasAgentFile(child)) {
|
|
196
|
+
const childId = await scanNode(child, id, ctx);
|
|
197
|
+
if (childId !== null) childIds.push(childId);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return id;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve a process's `owner` role string to a node id.
|
|
205
|
+
* Resolution order (first match wins, case-insensitive): node id, role, name.
|
|
206
|
+
* The owner must be the defining node or one of its descendants — a process
|
|
207
|
+
* cannot dispatch work to an unrelated branch of the org.
|
|
208
|
+
*/
|
|
209
|
+
function resolveOwner(
|
|
210
|
+
ownerRef: string,
|
|
211
|
+
definedAtNodeId: string,
|
|
212
|
+
nodes: Map<string, RegistryNode>,
|
|
213
|
+
): { ok: true; nodeId: string } | { ok: false; reason: string } {
|
|
214
|
+
const needle = ownerRef.trim().toLowerCase();
|
|
215
|
+
const inScope = (id: string): boolean =>
|
|
216
|
+
id === definedAtNodeId ||
|
|
217
|
+
id.startsWith(definedAtNodeId === "" ? "" : `${definedAtNodeId}/`);
|
|
218
|
+
|
|
219
|
+
const matches: string[] = [];
|
|
220
|
+
for (const node of nodes.values()) {
|
|
221
|
+
if (!inScope(node.id)) continue;
|
|
222
|
+
const candidates = [node.id, node.spec.role ?? "", node.spec.name].map((s) =>
|
|
223
|
+
s.toLowerCase(),
|
|
224
|
+
);
|
|
225
|
+
if (candidates.includes(needle)) matches.push(node.id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (matches.length === 0) {
|
|
229
|
+
return { ok: false, reason: `owner "${ownerRef}" did not resolve to any agent in scope` };
|
|
230
|
+
}
|
|
231
|
+
if (matches.length > 1) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
reason: `owner "${ownerRef}" is ambiguous (matched ${matches.join(", ")})`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, nodeId: matches[0]! };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Compile an org folder tree into an immutable RegistrySnapshot.
|
|
242
|
+
*
|
|
243
|
+
* On any validation error the result is `ok: false` with diagnostics and a
|
|
244
|
+
* null snapshot — the caller keeps the last-good snapshot live. The `version`
|
|
245
|
+
* is supplied by the caller (the watcher owns the monotonic counter).
|
|
246
|
+
*/
|
|
247
|
+
export async function compileRegistry(root: string, version: number): Promise<CompileResult> {
|
|
248
|
+
const absRoot = path.resolve(root);
|
|
249
|
+
const diagnostics: Diagnostic[] = [];
|
|
250
|
+
|
|
251
|
+
if (!(await hasAgentFile(absRoot))) {
|
|
252
|
+
diagnostics.push({
|
|
253
|
+
where: ".",
|
|
254
|
+
message: `org root has no ${AGENT_FILE} — the root folder must define the top-level agent`,
|
|
255
|
+
});
|
|
256
|
+
return { ok: false, snapshot: null, diagnostics };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const ctx: ScanContext = { root: absRoot, nodes: new Map(), diagnostics, pendingProcesses: [] };
|
|
260
|
+
const rootId = await scanNode(absRoot, null, ctx);
|
|
261
|
+
|
|
262
|
+
if (rootId === null) {
|
|
263
|
+
return { ok: false, snapshot: null, diagnostics };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Second pass: resolve process owners now that all nodes exist.
|
|
267
|
+
const processes: RegistryProcess[] = [];
|
|
268
|
+
for (const pending of ctx.pendingProcesses) {
|
|
269
|
+
const resolved = resolveOwner(pending.spec.owner, pending.definedAtNodeId, ctx.nodes);
|
|
270
|
+
if (!resolved.ok) {
|
|
271
|
+
diagnostics.push({ where: pending.where, message: resolved.reason });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
processes.push({
|
|
275
|
+
spec: pending.spec,
|
|
276
|
+
definedAtNodeId: pending.definedAtNodeId,
|
|
277
|
+
ownerNodeId: resolved.nodeId,
|
|
278
|
+
path: pending.where,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (diagnostics.length > 0) {
|
|
283
|
+
return { ok: false, snapshot: null, diagnostics };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const snapshot: RegistrySnapshot = {
|
|
287
|
+
version,
|
|
288
|
+
root: absRoot,
|
|
289
|
+
rootId,
|
|
290
|
+
nodes: ctx.nodes,
|
|
291
|
+
processes,
|
|
292
|
+
};
|
|
293
|
+
return { ok: true, snapshot, diagnostics: [] };
|
|
294
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import chokidar, { type FSWatcher, type ChokidarOptions } from "chokidar";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { compileRegistry, type CompileResult, type RegistrySnapshot, type Diagnostic } from "./registry.js";
|
|
4
|
+
|
|
5
|
+
export interface WatcherEvents {
|
|
6
|
+
/** A new valid snapshot was compiled and is now current. */
|
|
7
|
+
snapshot: (snapshot: RegistrySnapshot) => void;
|
|
8
|
+
/** A compile failed; the previous snapshot (if any) stays current. */
|
|
9
|
+
invalid: (diagnostics: Diagnostic[], lastGood: RegistrySnapshot | null) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Watches an org folder and compiles it into versioned snapshots on change.
|
|
14
|
+
*
|
|
15
|
+
* Design choices that follow from the architecture:
|
|
16
|
+
* - The folder is the *authoring source of truth*; this watcher is the
|
|
17
|
+
* compile step that turns it into a runtime artifact. Nothing mutates a
|
|
18
|
+
* running agent directly.
|
|
19
|
+
* - Versions are monotonic and owned here, so downstream (lifecycle) can diff.
|
|
20
|
+
* - A failed compile never clobbers the last-good snapshot — invalid trees are
|
|
21
|
+
* surfaced as diagnostics and the running org keeps going.
|
|
22
|
+
* - Changes are debounced so a multi-file save produces one recompile.
|
|
23
|
+
*/
|
|
24
|
+
export class RegistryWatcher extends EventEmitter {
|
|
25
|
+
private readonly root: string;
|
|
26
|
+
private readonly debounceMs: number;
|
|
27
|
+
private readonly watchOptions: Partial<ChokidarOptions>;
|
|
28
|
+
private version = 0;
|
|
29
|
+
private lastGood: RegistrySnapshot | null = null;
|
|
30
|
+
private watcher: FSWatcher | null = null;
|
|
31
|
+
private timer: NodeJS.Timeout | null = null;
|
|
32
|
+
private compiling = false;
|
|
33
|
+
private pending = false;
|
|
34
|
+
|
|
35
|
+
constructor(root: string, opts: { debounceMs?: number; watchOptions?: Partial<ChokidarOptions> } = {}) {
|
|
36
|
+
super();
|
|
37
|
+
this.root = root;
|
|
38
|
+
this.debounceMs = opts.debounceMs ?? 150;
|
|
39
|
+
// Polling is more reliable than fsevents/inotify under heavy parallel load
|
|
40
|
+
// (e.g. test workers) and across network/container filesystems. Callers can
|
|
41
|
+
// opt into it; the default uses the native backend for efficiency.
|
|
42
|
+
this.watchOptions = opts.watchOptions ?? {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Compile once without starting the watcher. Returns the result. */
|
|
46
|
+
async compileOnce(): Promise<CompileResult> {
|
|
47
|
+
const result = await compileRegistry(this.root, this.version + 1);
|
|
48
|
+
if (result.ok && result.snapshot) {
|
|
49
|
+
this.version = result.snapshot.version;
|
|
50
|
+
this.lastGood = result.snapshot;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Current last-good snapshot, or null if nothing has compiled cleanly yet. */
|
|
56
|
+
current(): RegistrySnapshot | null {
|
|
57
|
+
return this.lastGood;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Start watching. Performs an initial compile, then recompiles on changes. */
|
|
61
|
+
async start(): Promise<CompileResult> {
|
|
62
|
+
const initial = await this.compileOnce();
|
|
63
|
+
this.emitResult(initial);
|
|
64
|
+
|
|
65
|
+
this.watcher = chokidar.watch(this.root, {
|
|
66
|
+
ignoreInitial: true,
|
|
67
|
+
ignored: (p: string) => /(^|[/\\])(node_modules|\.git|\.businessos)([/\\]|$)/.test(p),
|
|
68
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
69
|
+
...this.watchOptions,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const schedule = () => this.scheduleRecompile();
|
|
73
|
+
this.watcher.on("add", schedule);
|
|
74
|
+
this.watcher.on("change", schedule);
|
|
75
|
+
this.watcher.on("unlink", schedule);
|
|
76
|
+
this.watcher.on("addDir", schedule);
|
|
77
|
+
this.watcher.on("unlinkDir", schedule);
|
|
78
|
+
|
|
79
|
+
// Wait until the initial scan completes so the watch is actually armed
|
|
80
|
+
// before we return — otherwise a change made immediately after start() can
|
|
81
|
+
// race the watcher's setup and be missed.
|
|
82
|
+
await new Promise<void>((resolve) => {
|
|
83
|
+
this.watcher!.once("ready", () => resolve());
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return initial;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async stop(): Promise<void> {
|
|
90
|
+
if (this.timer) clearTimeout(this.timer);
|
|
91
|
+
this.timer = null;
|
|
92
|
+
await this.watcher?.close();
|
|
93
|
+
this.watcher = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private scheduleRecompile(): void {
|
|
97
|
+
if (this.timer) clearTimeout(this.timer);
|
|
98
|
+
this.timer = setTimeout(() => void this.recompile(), this.debounceMs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async recompile(): Promise<void> {
|
|
102
|
+
// Serialize compiles; coalesce changes that arrive mid-compile into one
|
|
103
|
+
// follow-up pass so we never miss the latest state.
|
|
104
|
+
if (this.compiling) {
|
|
105
|
+
this.pending = true;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.compiling = true;
|
|
109
|
+
try {
|
|
110
|
+
const result = await compileRegistry(this.root, this.version + 1);
|
|
111
|
+
if (result.ok && result.snapshot) {
|
|
112
|
+
this.version = result.snapshot.version;
|
|
113
|
+
this.lastGood = result.snapshot;
|
|
114
|
+
}
|
|
115
|
+
this.emitResult(result);
|
|
116
|
+
} finally {
|
|
117
|
+
this.compiling = false;
|
|
118
|
+
if (this.pending) {
|
|
119
|
+
this.pending = false;
|
|
120
|
+
this.scheduleRecompile();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private emitResult(result: CompileResult): void {
|
|
126
|
+
if (result.ok && result.snapshot) {
|
|
127
|
+
this.emit("snapshot", result.snapshot);
|
|
128
|
+
} else {
|
|
129
|
+
this.emit("invalid", result.diagnostics, this.lastGood);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Short, prefixed, sortable-ish id. Prefix communicates the kind at a glance. */
|
|
4
|
+
export function newId(prefix: string): string {
|
|
5
|
+
return `${prefix}_${randomUUID().slice(0, 12)}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Injectable clock so time-dependent logic stays testable. */
|
|
9
|
+
export interface Clock {
|
|
10
|
+
now(): number;
|
|
11
|
+
iso(): string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const systemClock: Clock = {
|
|
15
|
+
now: () => Date.now(),
|
|
16
|
+
iso: () => new Date().toISOString(),
|
|
17
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Usage } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Per-model pricing in USD per million tokens (input, output). */
|
|
4
|
+
const PRICING: Record<string, { input: number; output: number }> = {
|
|
5
|
+
"claude-opus-4-8": { input: 5, output: 25 },
|
|
6
|
+
"claude-opus-4-7": { input: 5, output: 25 },
|
|
7
|
+
"claude-sonnet-4-6": { input: 3, output: 15 },
|
|
8
|
+
"claude-haiku-4-5": { input: 1, output: 5 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Fallback pricing for unknown model ids — assume opus-tier so we never undercount spend. */
|
|
12
|
+
const FALLBACK = { input: 5, output: 25 };
|
|
13
|
+
|
|
14
|
+
/** Cache multipliers relative to the input rate (5-minute TTL). */
|
|
15
|
+
const CACHE_READ_MULT = 0.1;
|
|
16
|
+
const CACHE_WRITE_MULT = 1.25;
|
|
17
|
+
|
|
18
|
+
/** Cache-aware cost estimate in USD. */
|
|
19
|
+
export function estimateUsd(
|
|
20
|
+
model: string,
|
|
21
|
+
inputTokens: number,
|
|
22
|
+
outputTokens: number,
|
|
23
|
+
cacheReadTokens = 0,
|
|
24
|
+
cacheCreationTokens = 0,
|
|
25
|
+
): number {
|
|
26
|
+
const p = PRICING[model] ?? FALLBACK;
|
|
27
|
+
return (
|
|
28
|
+
(inputTokens * p.input +
|
|
29
|
+
outputTokens * p.output +
|
|
30
|
+
cacheReadTokens * p.input * CACHE_READ_MULT +
|
|
31
|
+
cacheCreationTokens * p.input * CACHE_WRITE_MULT) /
|
|
32
|
+
1_000_000
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build a cache-aware Usage with cost filled in from the model's pricing. */
|
|
37
|
+
export function usageFor(
|
|
38
|
+
model: string,
|
|
39
|
+
inputTokens: number,
|
|
40
|
+
outputTokens: number,
|
|
41
|
+
cacheReadTokens = 0,
|
|
42
|
+
cacheCreationTokens = 0,
|
|
43
|
+
): Usage {
|
|
44
|
+
return {
|
|
45
|
+
inputTokens,
|
|
46
|
+
outputTokens,
|
|
47
|
+
cacheReadTokens,
|
|
48
|
+
cacheCreationTokens,
|
|
49
|
+
usd: estimateUsd(model, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core runtime domain types shared across the engine, orchestrator, messaging,
|
|
3
|
+
* and trust layers. These are deliberately serializable (plain data) so they
|
|
4
|
+
* can be logged to the audit trail and replayed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Budget } from "../schemas/common.js";
|
|
8
|
+
|
|
9
|
+
/** Token/cost accounting for a single model interaction or an aggregate. */
|
|
10
|
+
export interface Usage {
|
|
11
|
+
/** Uncached input tokens (full price). */
|
|
12
|
+
inputTokens: number;
|
|
13
|
+
outputTokens: number;
|
|
14
|
+
/** Tokens served from the prompt cache (~0.1× input price). */
|
|
15
|
+
cacheReadTokens: number;
|
|
16
|
+
/** Tokens written to the prompt cache (~1.25× input price, 5-min TTL). */
|
|
17
|
+
cacheCreationTokens: number;
|
|
18
|
+
/** Estimated cost in USD, cache-aware. */
|
|
19
|
+
usd: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function emptyUsage(): Usage {
|
|
23
|
+
return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, usd: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function addUsage(a: Usage, b: Usage): Usage {
|
|
27
|
+
return {
|
|
28
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
29
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
30
|
+
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
|
|
31
|
+
cacheCreationTokens: a.cacheCreationTokens + b.cacheCreationTokens,
|
|
32
|
+
usd: a.usd + b.usd,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Total tokens that hit the model this turn, cached or not. */
|
|
37
|
+
export function totalTokens(u: Usage): number {
|
|
38
|
+
return u.inputTokens + u.outputTokens + u.cacheReadTokens + u.cacheCreationTokens;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A structured handoff from a manager to a worker. Dispatch is via contracts,
|
|
43
|
+
* not free-form chat — this is the unit of orchestrated work. A task ends when
|
|
44
|
+
* its definition-of-done is met, its budget is exhausted, or it escalates.
|
|
45
|
+
*/
|
|
46
|
+
export interface TaskContract {
|
|
47
|
+
id: string;
|
|
48
|
+
/** Node id of the agent expected to execute this task. */
|
|
49
|
+
assigneeNodeId: string;
|
|
50
|
+
/** Node id (or "owner") that issued the contract. */
|
|
51
|
+
issuerNodeId: string;
|
|
52
|
+
/** What to accomplish. */
|
|
53
|
+
goal: string;
|
|
54
|
+
/** Inputs/context the worker needs. */
|
|
55
|
+
inputs: Record<string, unknown>;
|
|
56
|
+
/** Checkable completion criteria. */
|
|
57
|
+
definitionOfDone: string;
|
|
58
|
+
/** Hard ceiling for this task. */
|
|
59
|
+
budget: Budget;
|
|
60
|
+
/** Optional ISO deadline. */
|
|
61
|
+
deadline?: string;
|
|
62
|
+
/** Process run this task belongs to, if any. */
|
|
63
|
+
runId?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Root of the shared per-run workspace. When set, this becomes the agent's
|
|
66
|
+
* working directory for the task, giving it `shared/` (team-shared files for
|
|
67
|
+
* the run) and a private `<nodeId>/` subfolder. Source files are staged into
|
|
68
|
+
* `shared/` once by the orchestrator, so agents hand off artifacts by writing
|
|
69
|
+
* to `shared/` rather than re-passing them through prompts.
|
|
70
|
+
*/
|
|
71
|
+
workspaceRoot?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Absolute host paths of source files to stage into the assignee's working
|
|
74
|
+
* directory (legacy / standalone path; runs stage into the shared workspace).
|
|
75
|
+
*/
|
|
76
|
+
files?: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type TaskStatus =
|
|
80
|
+
| "completed"
|
|
81
|
+
| "budget_exhausted"
|
|
82
|
+
| "escalated"
|
|
83
|
+
| "failed"
|
|
84
|
+
| "aborted"
|
|
85
|
+
/** Finished, but one or more consequential actions are awaiting human approval. */
|
|
86
|
+
| "deferred";
|
|
87
|
+
|
|
88
|
+
/** The structured result a worker returns for a task contract. */
|
|
89
|
+
export interface TaskResult {
|
|
90
|
+
contractId: string;
|
|
91
|
+
status: TaskStatus;
|
|
92
|
+
/** Worker's summary / answer. */
|
|
93
|
+
summary: string;
|
|
94
|
+
/** Artifacts produced (file paths, data), keyed by name. */
|
|
95
|
+
artifacts: Record<string, unknown>;
|
|
96
|
+
/** Follow-ups the worker surfaced (e.g. needs decision, blocked on X). */
|
|
97
|
+
followUps: string[];
|
|
98
|
+
usage: Usage;
|
|
99
|
+
/** Proposals queued for human approval during this task (deferred actions). */
|
|
100
|
+
pendingProposalIds?: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Direction of an inter-agent message relative to the sender. */
|
|
104
|
+
export type MessageDirection = "down" | "up" | "sideways";
|
|
105
|
+
|
|
106
|
+
/** A message between agents, delivered via durable per-agent inboxes. */
|
|
107
|
+
export interface AgentMessage {
|
|
108
|
+
id: string;
|
|
109
|
+
fromNodeId: string;
|
|
110
|
+
toNodeId: string;
|
|
111
|
+
direction: MessageDirection;
|
|
112
|
+
/** "task" (a contract), "result", or "note" (free-form, rate-limited). */
|
|
113
|
+
kind: "task" | "result" | "note";
|
|
114
|
+
subject: string;
|
|
115
|
+
body: string;
|
|
116
|
+
/** Higher = more urgent; used for inbox prioritization into context. */
|
|
117
|
+
priority: number;
|
|
118
|
+
/** ISO timestamp set at enqueue. */
|
|
119
|
+
enqueuedAt: string;
|
|
120
|
+
/** Idempotency: redelivery carries the same id. */
|
|
121
|
+
payload?: Record<string, unknown>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Decision returned by the human-in-the-loop approval gate. */
|
|
125
|
+
export type PermissionDecision = "allow" | "deny";
|
|
126
|
+
|
|
127
|
+
/** A pending request for a human to approve a consequential action. */
|
|
128
|
+
export interface ApprovalRequest {
|
|
129
|
+
id: string;
|
|
130
|
+
nodeId: string;
|
|
131
|
+
toolName: string;
|
|
132
|
+
input: unknown;
|
|
133
|
+
/** Why the agent wants to do this (model-provided rationale, if any). */
|
|
134
|
+
rationale?: string;
|
|
135
|
+
requestedAt: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type ProposalStatus = "pending" | "approved" | "rejected" | "executed" | "failed";
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A consequential action an agent wanted to take, recorded for asynchronous
|
|
142
|
+
* human approval rather than blocking the run. The agent proposes; the run
|
|
143
|
+
* completes; a human approves on their own schedule; an executor then performs
|
|
144
|
+
* the action deterministically (no second model call). This is what makes the
|
|
145
|
+
* platform able to run continuously without a human in the critical path.
|
|
146
|
+
*/
|
|
147
|
+
export interface Proposal {
|
|
148
|
+
id: string;
|
|
149
|
+
runId?: string;
|
|
150
|
+
nodeId: string;
|
|
151
|
+
/** Team-memory scope key (manager + direct reports share it). For executor team writes. */
|
|
152
|
+
managerNodeId?: string;
|
|
153
|
+
/** Bare action/tool name (e.g. "deliver_to_client"). */
|
|
154
|
+
action: string;
|
|
155
|
+
input: unknown;
|
|
156
|
+
/** Model-provided rationale, if any. */
|
|
157
|
+
rationale?: string;
|
|
158
|
+
/** Workspace root the executor should act in (matches the agent's cwd). */
|
|
159
|
+
cwd: string;
|
|
160
|
+
createdAt: string;
|
|
161
|
+
status: ProposalStatus;
|
|
162
|
+
decidedAt?: string;
|
|
163
|
+
executedAt?: string;
|
|
164
|
+
/** Executor result on success. */
|
|
165
|
+
result?: unknown;
|
|
166
|
+
error?: string;
|
|
167
|
+
}
|
|
168
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ravel — public API surface.
|
|
3
|
+
*
|
|
4
|
+
* The stable entry point for consumers: **team plugins** import the memory,
|
|
5
|
+
* plugin, and helper types from here instead of deep paths; a **hosting platform**
|
|
6
|
+
* imports `App` and the engine to embed the runtime. Deep `src/...` imports are not
|
|
7
|
+
* part of the contract and may change between versions.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// --- platform / runtime ------------------------------------------------------
|
|
11
|
+
export { App } from "./platform/app.js";
|
|
12
|
+
export type { AppOptions } from "./platform/app.js";
|
|
13
|
+
export { SdkEngine, assembleMcpServers } from "./runtime/sdkEngine.js";
|
|
14
|
+
export { FakeEngine } from "./runtime/fakeEngine.js";
|
|
15
|
+
export type { AgentEngine, EngineRequest, EngineResult, ToolContext, EngineToolUse } from "./runtime/engine.js";
|
|
16
|
+
|
|
17
|
+
// --- memory ------------------------------------------------------------------
|
|
18
|
+
export { MemoryStore } from "./memory/store.js";
|
|
19
|
+
export type { MemoryScope } from "./memory/store.js";
|
|
20
|
+
export { withLock, lockKey, readJson, json } from "./memory/kv.js";
|
|
21
|
+
export { queueAppend, queueClear, resolveScope, buildGenericMemoryServer, GENERIC_MEMORY_TOOL_NAMES } from "./memory/genericTools.js";
|
|
22
|
+
|
|
23
|
+
// --- plugins (what a team's plugin.ts builds against) ------------------------
|
|
24
|
+
export { definePlugin, isPluginDefinition } from "./plugins/types.js";
|
|
25
|
+
export type {
|
|
26
|
+
PluginDefinition,
|
|
27
|
+
PluginTool,
|
|
28
|
+
PluginAction,
|
|
29
|
+
PluginToolCtx,
|
|
30
|
+
PluginActionCtx,
|
|
31
|
+
PluginActionResult,
|
|
32
|
+
} from "./plugins/types.js";
|
|
33
|
+
|
|
34
|
+
// --- trust (executor action contract for gated plugin actions) --------------
|
|
35
|
+
export type { ActionHandler, ActionContext, ActionResult } from "./trust/executor.js";
|