@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +202 -0
  3. package/README.md +68 -0
  4. package/bin/ravel.mjs +6 -0
  5. package/examples/acme/agent.md +14 -0
  6. package/examples/acme/growth/agent.md +19 -0
  7. package/examples/acme/growth/copywriter/agent.md +13 -0
  8. package/examples/acme/growth/copywriter/tools.json +11 -0
  9. package/examples/acme/growth/researcher/agent.md +11 -0
  10. package/examples/acme/processes/prospect-outreach.process.md +24 -0
  11. package/examples/harbor/agent.md +31 -0
  12. package/examples/harbor/processes/new-client-quote.process.md +31 -0
  13. package/examples/harbor/processes/resolve-ticket-batch.process.md +33 -0
  14. package/examples/harbor/sales/agent.md +29 -0
  15. package/examples/harbor/sales/sdr/agent.md +24 -0
  16. package/examples/harbor/sales/solutions/agent.md +29 -0
  17. package/examples/harbor/sales/solutions/tools.json +16 -0
  18. package/examples/harbor/support/agent.md +31 -0
  19. package/examples/harbor/support/kb-writer/agent.md +25 -0
  20. package/examples/harbor/support/kb-writer/tools.json +10 -0
  21. package/examples/harbor/support/qa-reviewer/agent.md +23 -0
  22. package/examples/harbor/support/tools.json +16 -0
  23. package/examples/harbor/support/triage/agent.md +24 -0
  24. package/examples/harbor/support/triage/tools.json +10 -0
  25. package/examples/plugin-demo/agent.md +15 -0
  26. package/examples/plugin-demo/processes/jot.process.md +21 -0
  27. package/examples/plugin-demo/scribe/agent.md +15 -0
  28. package/examples/plugin-demo/scribe/plugin.ts +53 -0
  29. package/examples/plugin-demo/scribe/tools.json +9 -0
  30. package/package.json +65 -0
  31. package/src/cli/main.ts +428 -0
  32. package/src/control-plane/registry.ts +294 -0
  33. package/src/control-plane/watcher.ts +132 -0
  34. package/src/domain/ids.ts +17 -0
  35. package/src/domain/pricing.ts +51 -0
  36. package/src/domain/types.ts +168 -0
  37. package/src/index.ts +35 -0
  38. package/src/memory/genericTools.ts +276 -0
  39. package/src/memory/kv.ts +52 -0
  40. package/src/memory/store.ts +76 -0
  41. package/src/messaging/bus.ts +143 -0
  42. package/src/messaging/inbox.ts +119 -0
  43. package/src/orchestrator/orchestrator.ts +270 -0
  44. package/src/orchestrator/planner.ts +218 -0
  45. package/src/platform/app.ts +287 -0
  46. package/src/plugins/loader.ts +86 -0
  47. package/src/plugins/server.ts +41 -0
  48. package/src/plugins/types.ts +96 -0
  49. package/src/runtime/agent.ts +488 -0
  50. package/src/runtime/engine.ts +84 -0
  51. package/src/runtime/fakeEngine.ts +75 -0
  52. package/src/runtime/lifecycle.ts +85 -0
  53. package/src/runtime/officeActions.ts +58 -0
  54. package/src/runtime/officeTools.ts +42 -0
  55. package/src/runtime/sdkEngine.ts +213 -0
  56. package/src/schemas/agent.ts +47 -0
  57. package/src/schemas/common.ts +52 -0
  58. package/src/schemas/frontmatter.ts +36 -0
  59. package/src/schemas/process.ts +55 -0
  60. package/src/schemas/tools.ts +83 -0
  61. package/src/secrets/store.ts +131 -0
  62. package/src/service/scheduler.ts +377 -0
  63. package/src/service/server.ts +554 -0
  64. package/src/trust/approval.ts +180 -0
  65. package/src/trust/audit.ts +195 -0
  66. package/src/trust/budget.ts +64 -0
  67. package/src/trust/emittingAudit.ts +32 -0
  68. package/src/trust/executor.ts +73 -0
  69. package/src/trust/killswitch.ts +73 -0
  70. package/src/trust/observability.ts +97 -0
  71. package/src/trust/proposals.ts +114 -0
  72. package/ui/dist/assets/index-C6CxDaPS.js +44 -0
  73. package/ui/dist/assets/index-CD-lhs0Z.css +1 -0
  74. package/ui/dist/index.html +13 -0
@@ -0,0 +1,276 @@
1
+ import { z } from "zod";
2
+ import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
3
+ import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
4
+ import { MemoryStore, type MemoryScope } from "./store.js";
5
+ import { withLock, lockKey, readJson, json } from "./kv.js";
6
+
7
+ /**
8
+ * Platform-level, domain-agnostic memory tools over the generic `MemoryStore`.
9
+ * Three shapes cover essentially any team's durable state:
10
+ * - **text** — a freeform string at a key.
11
+ * - **json** — a structured value (object/array/scalar) at a key, with merge.
12
+ * - **queue** — an append-only list with optional dedup-by-field, a cap, and
13
+ * clear-by-field. (Watchlists, signal logs, candidate backlogs,
14
+ * etc. are all queues; cursor maps are json.)
15
+ *
16
+ * All tools are **team-scoped by default**; pass `scope` to target the agent's
17
+ * private memory or the org-wide store. Org *writes* are gated: they require the
18
+ * agent to be granted `mem_allow_org_write`.
19
+ */
20
+
21
+ const DEFAULT_LIST_LIMIT = 50;
22
+ const MAX_LIST_LIMIT = 500;
23
+
24
+ const ScopeArg = z.enum(["agent", "team", "org"]).optional();
25
+
26
+ interface MemCtx {
27
+ nodeId: string;
28
+ managerNodeId: string;
29
+ memory: MemoryStore;
30
+ }
31
+
32
+ /** Resolve the optional `scope` arg to a concrete MemoryScope (default: team). */
33
+ function resolveScope(arg: string | undefined, ctx: MemCtx): MemoryScope {
34
+ if (arg === "agent") return { kind: "agent", nodeId: ctx.nodeId };
35
+ if (arg === "org") return { kind: "org" };
36
+ return { kind: "team", managerNodeId: ctx.managerNodeId };
37
+ }
38
+
39
+ /** Guard org writes behind an explicit grant; returns an error result if blocked. */
40
+ function orgWriteBlocked(scope: MemoryScope, allowOrgWrite: boolean): { ok: false; error: string } | null {
41
+ if (scope.kind === "org" && !allowOrgWrite) {
42
+ return { ok: false, error: "org-scope writes require the `mem_allow_org_write` grant" };
43
+ }
44
+ return null;
45
+ }
46
+
47
+ // --- queue operations --------------------------------------------------------
48
+
49
+ /** A value's dedup identity: the named field (stringified) or the whole item. */
50
+ function itemKey(item: unknown, dedupBy?: string): string {
51
+ if (dedupBy && item && typeof item === "object" && dedupBy in (item as Record<string, unknown>)) {
52
+ return `f:${String((item as Record<string, unknown>)[dedupBy])}`;
53
+ }
54
+ return `v:${JSON.stringify(item)}`;
55
+ }
56
+
57
+ async function queueAppend(
58
+ memory: MemoryStore,
59
+ scope: MemoryScope,
60
+ key: string,
61
+ items: unknown[],
62
+ opts: { dedupBy?: string; cap?: number; prepend?: boolean; allowOrgWrite: boolean },
63
+ ): Promise<{ ok: boolean; appended?: number; skipped?: number; count?: number; error?: string }> {
64
+ const blocked = orgWriteBlocked(scope, opts.allowOrgWrite);
65
+ if (blocked) return blocked;
66
+ return withLock(lockKey(scope, key), async () => {
67
+ const list = await readJson<unknown[]>(memory, scope, key, []);
68
+ const seen = new Set(list.map((it) => itemKey(it, opts.dedupBy)));
69
+ const fresh: unknown[] = [];
70
+ let skipped = 0;
71
+ for (const it of items) {
72
+ const k = itemKey(it, opts.dedupBy);
73
+ if (seen.has(k)) {
74
+ skipped += 1;
75
+ continue;
76
+ }
77
+ seen.add(k);
78
+ fresh.push(it);
79
+ }
80
+ let next = opts.prepend ? [...fresh, ...list] : [...list, ...fresh];
81
+ if (opts.cap !== undefined && next.length > opts.cap) {
82
+ // Keep the most recent `cap`: tail when appending, head when prepending.
83
+ next = opts.prepend ? next.slice(0, opts.cap) : next.slice(next.length - opts.cap);
84
+ }
85
+ if (fresh.length || next.length !== list.length) await memory.set(scope, key, JSON.stringify(next), { allowOrgWrite: opts.allowOrgWrite });
86
+ return { ok: true, appended: fresh.length, skipped, count: next.length };
87
+ });
88
+ }
89
+
90
+ async function queueClear(
91
+ memory: MemoryStore,
92
+ scope: MemoryScope,
93
+ key: string,
94
+ by: string,
95
+ values: string[],
96
+ allowOrgWrite: boolean,
97
+ ): Promise<{ ok: boolean; cleared?: number; count?: number; error?: string }> {
98
+ const blocked = orgWriteBlocked(scope, allowOrgWrite);
99
+ if (blocked) return blocked;
100
+ return withLock(lockKey(scope, key), async () => {
101
+ const list = await readJson<Record<string, unknown>[]>(memory, scope, key, []);
102
+ const drop = new Set(values.map((v) => String(v)));
103
+ const kept = list.filter((it) => !(it && typeof it === "object" && drop.has(String(it[by]))));
104
+ await memory.set(scope, key, JSON.stringify(kept), { allowOrgWrite });
105
+ return { ok: true, cleared: list.length - kept.length, count: kept.length };
106
+ });
107
+ }
108
+
109
+ // --- MCP server --------------------------------------------------------------
110
+
111
+ export const GENERIC_MEMORY_TOOL_NAMES = [
112
+ "mem_text_get",
113
+ "mem_text_set",
114
+ "mem_keys",
115
+ "mem_json_get",
116
+ "mem_json_set",
117
+ "mem_json_merge",
118
+ "mem_queue_append",
119
+ "mem_queue_list",
120
+ "mem_queue_clear",
121
+ // Not a tool itself — a capability grant that unlocks org-scope writes.
122
+ "mem_allow_org_write",
123
+ ];
124
+
125
+ /**
126
+ * Build the generic memory MCP server (`mem`) for the tools an agent was granted.
127
+ * Scoped to the agent (private), its team (default), or org (reads open; writes
128
+ * gated by `mem_allow_org_write`).
129
+ */
130
+ export function buildGenericMemoryServer(grantedNames: string[], ctx: MemCtx): McpServerConfig | null {
131
+ const granted = new Set(grantedNames);
132
+ const allowOrgWrite = granted.has("mem_allow_org_write");
133
+ const tools = [];
134
+
135
+ if (granted.has("mem_text_get")) {
136
+ tools.push(
137
+ tool("mem_text_get", "Read a freeform text value by key (team scope by default).", { key: z.string(), scope: ScopeArg }, async (a) =>
138
+ json({ value: await ctx.memory.get(resolveScope(a["scope"] as string | undefined, ctx), String(a["key"])) }),
139
+ ),
140
+ );
141
+ }
142
+ if (granted.has("mem_text_set")) {
143
+ tools.push(
144
+ tool(
145
+ "mem_text_set",
146
+ "Write a freeform text value at a key.",
147
+ { key: z.string(), value: z.string(), scope: ScopeArg },
148
+ async (a) => {
149
+ const scope = resolveScope(a["scope"] as string | undefined, ctx);
150
+ const blocked = orgWriteBlocked(scope, allowOrgWrite);
151
+ if (blocked) return json(blocked);
152
+ await ctx.memory.set(scope, String(a["key"]), String(a["value"]), { allowOrgWrite });
153
+ return json({ ok: true });
154
+ },
155
+ ),
156
+ );
157
+ }
158
+ if (granted.has("mem_keys")) {
159
+ tools.push(
160
+ tool("mem_keys", "List the keys stored in a scope.", { scope: ScopeArg }, async (a) =>
161
+ json({ keys: await ctx.memory.list(resolveScope(a["scope"] as string | undefined, ctx)) }),
162
+ ),
163
+ );
164
+ }
165
+ if (granted.has("mem_json_get")) {
166
+ tools.push(
167
+ tool("mem_json_get", "Read a JSON value by key.", { key: z.string(), scope: ScopeArg }, async (a) =>
168
+ json({ value: await readJson(ctx.memory, resolveScope(a["scope"] as string | undefined, ctx), String(a["key"]), null) }),
169
+ ),
170
+ );
171
+ }
172
+ if (granted.has("mem_json_set")) {
173
+ tools.push(
174
+ tool(
175
+ "mem_json_set",
176
+ "Write a JSON value (object/array/scalar) at a key.",
177
+ { key: z.string(), value: z.unknown(), scope: ScopeArg },
178
+ async (a) => {
179
+ const scope = resolveScope(a["scope"] as string | undefined, ctx);
180
+ const blocked = orgWriteBlocked(scope, allowOrgWrite);
181
+ if (blocked) return json(blocked);
182
+ await ctx.memory.set(scope, String(a["key"]), JSON.stringify(a["value"] ?? null), { allowOrgWrite });
183
+ return json({ ok: true });
184
+ },
185
+ ),
186
+ );
187
+ }
188
+ if (granted.has("mem_json_merge")) {
189
+ tools.push(
190
+ tool(
191
+ "mem_json_merge",
192
+ "Shallow-merge a patch object into the JSON object stored at a key (creates it if absent).",
193
+ { key: z.string(), patch: z.record(z.unknown()), scope: ScopeArg },
194
+ async (a) => {
195
+ const scope = resolveScope(a["scope"] as string | undefined, ctx);
196
+ const blocked = orgWriteBlocked(scope, allowOrgWrite);
197
+ if (blocked) return json(blocked);
198
+ const key = String(a["key"]);
199
+ const result = await withLock(lockKey(scope, key), async () => {
200
+ const cur = await readJson<Record<string, unknown>>(ctx.memory, scope, key, {});
201
+ Object.assign(cur, (a["patch"] as Record<string, unknown>) ?? {});
202
+ await ctx.memory.set(scope, key, JSON.stringify(cur), { allowOrgWrite });
203
+ return cur;
204
+ });
205
+ return json({ ok: true, value: result });
206
+ },
207
+ ),
208
+ );
209
+ }
210
+ if (granted.has("mem_queue_append")) {
211
+ tools.push(
212
+ tool(
213
+ "mem_queue_append",
214
+ "Append items to a list at a key in ONE call. `dedupBy` skips items whose named " +
215
+ "field already exists; `cap` keeps only the most recent N; `prepend` adds to the front.",
216
+ {
217
+ key: z.string(),
218
+ items: z.array(z.unknown()),
219
+ dedupBy: z.string().optional(),
220
+ cap: z.number().optional(),
221
+ prepend: z.boolean().optional(),
222
+ scope: ScopeArg,
223
+ },
224
+ async (a) =>
225
+ json(
226
+ await queueAppend(ctx.memory, resolveScope(a["scope"] as string | undefined, ctx), String(a["key"]), (a["items"] as unknown[]) ?? [], {
227
+ ...(a["dedupBy"] !== undefined ? { dedupBy: String(a["dedupBy"]) } : {}),
228
+ ...(a["cap"] !== undefined ? { cap: Number(a["cap"]) } : {}),
229
+ ...(a["prepend"] !== undefined ? { prepend: Boolean(a["prepend"]) } : {}),
230
+ allowOrgWrite,
231
+ }),
232
+ ),
233
+ ),
234
+ );
235
+ }
236
+ if (granted.has("mem_queue_list")) {
237
+ tools.push(
238
+ tool(
239
+ "mem_queue_list",
240
+ "List a queue's items, bounded. Returns {total, returned, items} so you process a manageable batch.",
241
+ { key: z.string(), limit: z.number().optional(), scope: ScopeArg },
242
+ async (a) => {
243
+ const all = await readJson<unknown[]>(ctx.memory, resolveScope(a["scope"] as string | undefined, ctx), String(a["key"]), []);
244
+ const limit = Math.min(Math.max(Number(a["limit"] ?? DEFAULT_LIST_LIMIT), 1), MAX_LIST_LIMIT);
245
+ return json({ total: all.length, returned: Math.min(limit, all.length), items: all.slice(0, limit) });
246
+ },
247
+ ),
248
+ );
249
+ }
250
+ if (granted.has("mem_queue_clear")) {
251
+ tools.push(
252
+ tool(
253
+ "mem_queue_clear",
254
+ "Remove items from a queue whose `by` field matches one of `values`, in one call.",
255
+ { key: z.string(), by: z.string(), values: z.array(z.string()), scope: ScopeArg },
256
+ async (a) =>
257
+ json(
258
+ await queueClear(
259
+ ctx.memory,
260
+ resolveScope(a["scope"] as string | undefined, ctx),
261
+ String(a["key"]),
262
+ String(a["by"]),
263
+ (a["values"] as string[]) ?? [],
264
+ allowOrgWrite,
265
+ ),
266
+ ),
267
+ ),
268
+ );
269
+ }
270
+
271
+ if (tools.length === 0) return null;
272
+ return createSdkMcpServer({ name: "mem", version: "1.0.0", tools });
273
+ }
274
+
275
+ // Exported for unit tests and reuse by in-tree/plugin tools.
276
+ export { queueAppend, queueClear, resolveScope };
@@ -0,0 +1,52 @@
1
+ import { MemoryStore, type MemoryScope } from "./store.js";
2
+
3
+ /**
4
+ * Shared, domain-agnostic helpers over the generic `MemoryStore`. Used by both
5
+ * the platform's generic memory tools (`genericTools.ts`) and any in-tree/plugin
6
+ * tools that keep durable per-team state.
7
+ *
8
+ * `MemoryStore.set` is a whole-file overwrite with no locking, so every
9
+ * read-modify-write goes through a per-(scope,key) async mutex to avoid lost
10
+ * updates when agents write concurrently.
11
+ */
12
+
13
+ // --- per-key serialization (mutex) ------------------------------------------
14
+
15
+ const chains = new Map<string, Promise<unknown>>();
16
+
17
+ /** Serialize async work per key so concurrent read-modify-writes don't clobber. */
18
+ export function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
19
+ const prev = chains.get(key) ?? Promise.resolve();
20
+ const result = prev.then(fn, fn);
21
+ // Swallow errors on the stored chain so one failure doesn't wedge the lock.
22
+ chains.set(
23
+ key,
24
+ result.then(
25
+ () => undefined,
26
+ () => undefined,
27
+ ),
28
+ );
29
+ return result;
30
+ }
31
+
32
+ /** A stable lock key for a (scope, key) pair. */
33
+ export function lockKey(scope: MemoryScope, key: string): string {
34
+ const s = scope.kind === "team" ? `team:${scope.managerNodeId}` : scope.kind === "agent" ? `agent:${scope.nodeId}` : "org";
35
+ return `${s}#${key}`;
36
+ }
37
+
38
+ /** Read a JSON-serialized value from memory, falling back on missing/corrupt. */
39
+ export async function readJson<T>(memory: MemoryStore, scope: MemoryScope, key: string, fallback: T): Promise<T> {
40
+ const raw = await memory.get(scope, key);
41
+ if (!raw) return fallback;
42
+ try {
43
+ return JSON.parse(raw) as T;
44
+ } catch {
45
+ return fallback;
46
+ }
47
+ }
48
+
49
+ /** Wrap any value as an MCP tool text result (`{content:[{type:"text",...}]}`). */
50
+ export function json(value: unknown) {
51
+ return { content: [{ type: "text" as const, text: JSON.stringify(value) }] };
52
+ }
@@ -0,0 +1,76 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Where a piece of memory lives. The hierarchy keeps agents from re-learning
6
+ * everything and contradicting each other:
7
+ * - `agent`: private to one agent (alongside its working dir).
8
+ * - `team`: shared among the direct reports of one manager.
9
+ * - `org`: company-wide facts/policies/glossary — read widely, written rarely.
10
+ */
11
+ export type MemoryScope =
12
+ | { kind: "agent"; nodeId: string }
13
+ | { kind: "team"; managerNodeId: string }
14
+ | { kind: "org" };
15
+
16
+ function sanitize(id: string): string {
17
+ return id === "" ? "_root" : id.replace(/\//g, "__");
18
+ }
19
+
20
+ /**
21
+ * File-backed memory keyed by scope + name. Values are plain text (markdown).
22
+ * `org` writes are gated: callers must pass `{ allowOrgWrite: true }` so a
23
+ * worker can't casually rewrite company-wide facts.
24
+ */
25
+ export class MemoryStore {
26
+ constructor(private readonly root: string) {}
27
+
28
+ private dir(scope: MemoryScope): string {
29
+ switch (scope.kind) {
30
+ case "agent":
31
+ return path.join(this.root, "agent", sanitize(scope.nodeId));
32
+ case "team":
33
+ return path.join(this.root, "team", sanitize(scope.managerNodeId));
34
+ case "org":
35
+ return path.join(this.root, "org");
36
+ }
37
+ }
38
+
39
+ private file(scope: MemoryScope, key: string): string {
40
+ const safeKey = key.replace(/[^a-zA-Z0-9._-]/g, "_");
41
+ return path.join(this.dir(scope), `${safeKey}.md`);
42
+ }
43
+
44
+ async get(scope: MemoryScope, key: string): Promise<string | null> {
45
+ try {
46
+ return await fs.readFile(this.file(scope, key), "utf8");
47
+ } catch (err) {
48
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ async set(
54
+ scope: MemoryScope,
55
+ key: string,
56
+ value: string,
57
+ opts: { allowOrgWrite?: boolean } = {},
58
+ ): Promise<void> {
59
+ if (scope.kind === "org" && !opts.allowOrgWrite) {
60
+ throw new Error("org memory is write-gated; pass { allowOrgWrite: true }");
61
+ }
62
+ const file = this.file(scope, key);
63
+ await fs.mkdir(path.dirname(file), { recursive: true });
64
+ await fs.writeFile(file, value, "utf8");
65
+ }
66
+
67
+ async list(scope: MemoryScope): Promise<string[]> {
68
+ try {
69
+ const entries = await fs.readdir(this.dir(scope));
70
+ return entries.filter((e) => e.endsWith(".md")).map((e) => e.slice(0, -3)).sort();
71
+ } catch (err) {
72
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
73
+ throw err;
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,143 @@
1
+ import path from "node:path";
2
+ import type { RegistryNode, RegistrySnapshot } from "../control-plane/registry.js";
3
+ import type { AgentMessage, MessageDirection } from "../domain/types.js";
4
+ import { newId, systemClock, type Clock } from "../domain/ids.js";
5
+ import type { AuditSink } from "../trust/audit.js";
6
+ import { Inbox, InboxFullError } from "./inbox.js";
7
+
8
+ export interface MessageBusDeps {
9
+ audit: AuditSink;
10
+ /** Directory for durable inbox files; omit for in-memory inboxes. */
11
+ messagesDir?: string;
12
+ clock?: Clock;
13
+ inboxCap?: number;
14
+ }
15
+
16
+ export interface DeadLetter {
17
+ message: AgentMessage;
18
+ reason: string;
19
+ }
20
+
21
+ type Relation = MessageDirection | "invalid";
22
+
23
+ /**
24
+ * Routes messages between agents through durable per-agent inboxes.
25
+ *
26
+ * Communication is constrained to the org topology: an agent may message DOWN
27
+ * (a direct report), UP (its manager), or SIDEWAYS (a peer under the same
28
+ * manager). Anything else is dead-lettered. When a recipient's inbox is full,
29
+ * the bus escalates an overflow note to that agent's manager rather than
30
+ * silently dropping work — backpressure that surfaces, not hides.
31
+ */
32
+ export class MessageBus {
33
+ private nodes: ReadonlyMap<string, RegistryNode> = new Map();
34
+ private readonly inboxes = new Map<string, Inbox>();
35
+ readonly deadLetters: DeadLetter[] = [];
36
+ private readonly clock: Clock;
37
+
38
+ constructor(private readonly deps: MessageBusDeps) {
39
+ this.clock = deps.clock ?? systemClock;
40
+ }
41
+
42
+ /** Update the topology the bus validates against (called on each new snapshot). */
43
+ updateTopology(snapshot: RegistrySnapshot): void {
44
+ this.nodes = snapshot.nodes;
45
+ }
46
+
47
+ inbox(nodeId: string): Inbox {
48
+ let box = this.inboxes.get(nodeId);
49
+ if (!box) {
50
+ box = new Inbox(nodeId, {
51
+ ...(this.deps.inboxCap !== undefined ? { cap: this.deps.inboxCap } : {}),
52
+ ...(this.deps.messagesDir
53
+ ? { filePath: path.join(this.deps.messagesDir, `${nodeId === "" ? "_root" : nodeId.replace(/\//g, "__")}.json`) }
54
+ : {}),
55
+ });
56
+ this.inboxes.set(nodeId, box);
57
+ }
58
+ return box;
59
+ }
60
+
61
+ /** Build a message with id/timestamp filled in. */
62
+ compose(fields: Omit<AgentMessage, "id" | "enqueuedAt">): AgentMessage {
63
+ return { ...fields, id: newId("msg"), enqueuedAt: this.clock.iso() };
64
+ }
65
+
66
+ private relation(from: string, to: string): Relation {
67
+ const fromNode = this.nodes.get(from);
68
+ const toNode = this.nodes.get(to);
69
+ if (!fromNode || !toNode) return "invalid";
70
+ if (fromNode.parentId === to) return "up";
71
+ if (toNode.parentId === from) return "down";
72
+ if (fromNode.parentId !== null && fromNode.parentId === toNode.parentId) return "sideways";
73
+ return "invalid";
74
+ }
75
+
76
+ private async deadLetter(message: AgentMessage, reason: string): Promise<void> {
77
+ this.deadLetters.push({ message, reason });
78
+ await this.deps.audit.append("message.deadletter", {
79
+ nodeId: message.toNodeId,
80
+ data: { messageId: message.id, from: message.fromNodeId, reason },
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Deliver a message. Returns the outcome so callers can react. Idempotent on
86
+ * message id (duplicate redelivery is a successful no-op).
87
+ */
88
+ async send(message: AgentMessage): Promise<"delivered" | "duplicate" | "deadletter" | "escalated"> {
89
+ const relation = this.relation(message.fromNodeId, message.toNodeId);
90
+ if (relation === "invalid") {
91
+ await this.deadLetter(message, "invalid_route");
92
+ return "deadletter";
93
+ }
94
+ if (relation !== message.direction) {
95
+ await this.deadLetter(message, `direction_mismatch (declared ${message.direction}, actual ${relation})`);
96
+ return "deadletter";
97
+ }
98
+
99
+ try {
100
+ const inserted = await this.inbox(message.toNodeId).enqueue(message);
101
+ if (!inserted) return "duplicate";
102
+ await this.deps.audit.append("message.delivered", {
103
+ nodeId: message.toNodeId,
104
+ data: { messageId: message.id, from: message.fromNodeId, kind: message.kind },
105
+ });
106
+ return "delivered";
107
+ } catch (err) {
108
+ if (!(err instanceof InboxFullError)) throw err;
109
+ return this.escalateOverflow(message);
110
+ }
111
+ }
112
+
113
+ /** On a full inbox, send an overflow note up to the recipient's manager. */
114
+ private async escalateOverflow(message: AgentMessage): Promise<"escalated" | "deadletter"> {
115
+ const recipient = this.nodes.get(message.toNodeId);
116
+ const managerId = recipient?.parentId ?? null;
117
+ if (managerId === null) {
118
+ await this.deadLetter(message, "inbox_full_no_manager");
119
+ return "deadletter";
120
+ }
121
+ const note = this.compose({
122
+ fromNodeId: message.toNodeId,
123
+ toNodeId: managerId,
124
+ direction: "up",
125
+ kind: "note",
126
+ subject: `Inbox overflow for ${message.toNodeId}`,
127
+ body: `Inbox is full; a message from ${message.fromNodeId} ("${message.subject}") could not be delivered and needs attention.`,
128
+ priority: 10,
129
+ });
130
+ await this.deps.audit.append("message.backpressure", {
131
+ nodeId: message.toNodeId,
132
+ data: { droppedMessageId: message.id, escalatedTo: managerId },
133
+ });
134
+ // Best-effort escalation; if the manager is also full, dead-letter the note.
135
+ try {
136
+ await this.inbox(managerId).enqueue(note);
137
+ } catch {
138
+ await this.deadLetter(note, "manager_inbox_full");
139
+ }
140
+ await this.deadLetter(message, "inbox_full_escalated");
141
+ return "escalated";
142
+ }
143
+ }
@@ -0,0 +1,119 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import type { AgentMessage } from "../domain/types.js";
4
+
5
+ /**
6
+ * A durable, per-agent message queue. Delivery is at-least-once with
7
+ * idempotency by message id (a redelivered message with a known id is ignored).
8
+ * Messages are ordered by priority (desc) then enqueue time (asc) so the most
9
+ * urgent surface first when injected into the agent's context.
10
+ *
11
+ * Persistence is a simple rewrite-on-change of the pending set — queues are
12
+ * small (an agent that accumulates hundreds of messages is a design smell the
13
+ * backpressure cap is meant to catch).
14
+ */
15
+ export class Inbox {
16
+ private pending: AgentMessage[] = [];
17
+ private readonly seen = new Set<string>();
18
+
19
+ constructor(
20
+ readonly nodeId: string,
21
+ private readonly opts: { cap?: number; filePath?: string } = {},
22
+ ) {}
23
+
24
+ get cap(): number {
25
+ return this.opts.cap ?? 100;
26
+ }
27
+
28
+ size(): number {
29
+ return this.pending.length;
30
+ }
31
+
32
+ isFull(): boolean {
33
+ return this.pending.length >= this.cap;
34
+ }
35
+
36
+ /** Load persisted messages from disk, if a file path was configured. */
37
+ async load(): Promise<void> {
38
+ if (!this.opts.filePath) return;
39
+ try {
40
+ const raw = await fs.readFile(this.opts.filePath, "utf8");
41
+ const parsed = JSON.parse(raw) as AgentMessage[];
42
+ this.pending = parsed;
43
+ for (const m of parsed) this.seen.add(m.id);
44
+ } catch (err) {
45
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Enqueue a message. Returns false if it was a duplicate (already seen) — the
51
+ * caller can treat that as a successful no-op (idempotent redelivery).
52
+ * Throws if the inbox is full so the bus can apply backpressure.
53
+ */
54
+ async enqueue(message: AgentMessage): Promise<boolean> {
55
+ if (this.seen.has(message.id)) return false;
56
+ if (this.isFull()) throw new InboxFullError(this.nodeId);
57
+ this.seen.add(message.id);
58
+ this.pending.push(message);
59
+ this.sort();
60
+ await this.persist();
61
+ return true;
62
+ }
63
+
64
+ /** Remove and return up to `n` highest-priority messages. */
65
+ async dequeue(n = 1): Promise<AgentMessage[]> {
66
+ const taken = this.pending.splice(0, n);
67
+ if (taken.length) await this.persist();
68
+ return taken;
69
+ }
70
+
71
+ /** Non-destructive view, highest priority first. */
72
+ peek(): readonly AgentMessage[] {
73
+ return this.pending;
74
+ }
75
+
76
+ private sort(): void {
77
+ this.pending.sort((a, b) => b.priority - a.priority || a.enqueuedAt.localeCompare(b.enqueuedAt));
78
+ }
79
+
80
+ private async persist(): Promise<void> {
81
+ if (!this.opts.filePath) return;
82
+ await fs.mkdir(path.dirname(this.opts.filePath), { recursive: true });
83
+ await fs.writeFile(this.opts.filePath, JSON.stringify(this.pending), "utf8");
84
+ }
85
+ }
86
+
87
+ export class InboxFullError extends Error {
88
+ constructor(readonly nodeId: string) {
89
+ super(`inbox for ${nodeId} is full`);
90
+ this.name = "InboxFullError";
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Turn a set of inbox messages into a bounded, prioritized context block for an
96
+ * agent's prompt. Messages are NEVER raw-replayed wholesale — they are ranked
97
+ * by priority and truncated to `maxChars`, with an explicit note about any that
98
+ * were omitted, so a flooded inbox can't blow the context window.
99
+ */
100
+ export function summarizeInbox(messages: readonly AgentMessage[], maxChars = 4000): string {
101
+ if (messages.length === 0) return "(no new messages)";
102
+ const sorted = [...messages].sort(
103
+ (a, b) => b.priority - a.priority || a.enqueuedAt.localeCompare(b.enqueuedAt),
104
+ );
105
+ const lines: string[] = [];
106
+ let used = 0;
107
+ let included = 0;
108
+ for (const m of sorted) {
109
+ const body = m.body.length > 280 ? `${m.body.slice(0, 277)}...` : m.body;
110
+ const line = `- [${m.kind} ${m.direction} from ${m.fromNodeId}] ${m.subject}: ${body}`;
111
+ if (used + line.length > maxChars && included > 0) break;
112
+ lines.push(line);
113
+ used += line.length + 1;
114
+ included += 1;
115
+ }
116
+ const omitted = sorted.length - included;
117
+ if (omitted > 0) lines.push(`- (+${omitted} more lower-priority message(s) omitted)`);
118
+ return lines.join("\n");
119
+ }