@directive-run/knowledge 1.19.0 → 1.19.1

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/ai/ai-sources.md CHANGED
@@ -54,7 +54,11 @@ gate execution on `facts.<lifecycle>.healthy` derivations.
54
54
  - **MCP server connect/disconnect** → see "MCP Lifecycle" below.
55
55
  - **Health checks** → poll inside the source's `attach`; publish
56
56
  `health_changed` events as the upstream service responds.
57
- - **WebSocket open / close** → `sourceFromWebSocket()` (sources/websocket).
57
+ - **WebSocket open / close (Cloudflare DO)** → `sourceFromWebSocketMessage()`
58
+ (`@directive-run/sources/cloudflare`). For raw Node/browser WebSocket
59
+ bridges, declare a source whose `attach` wires `addEventListener` and
60
+ publishes `MESSAGE` / `CLOSE` / `ERROR` events (a generic
61
+ `sourceFromWebSocket()` helper is queued for a follow-up RFC).
58
62
 
59
63
  ### "I want my agent's facts to update from a live external feed"
60
64
 
@@ -151,10 +155,14 @@ Two new chunk variants land on the stream:
151
155
  `runStream({ liveContext })` is the dedicated subject of
152
156
  [RFC 0005](../../docs/rfcs/0005-live-context-agent.md). Today's
153
157
  implementation is **231 LOC in `agent-orchestrator.ts`** — under the
154
- RFC's 300-LOC scope guard. The `mode: "restart"` automatic
155
- re-invocation is reserved for a follow-up minor; today both `mode`
156
- values produce identical behavior (the chunk-emission contract +
157
- abort wiring; consumers re-prompt via a fresh `runStream` call).
158
+ RFC's 300-LOC scope guard. Behavior is **abort-and-emit**: when
159
+ `interruptWhen` returns `true`, the orchestrator aborts the in-flight
160
+ LLM run and emits an `interrupted` chunk with the partial output; the
161
+ caller resumes by issuing a fresh `runStream` against the still-live
162
+ subscription (or fully tears down via `result.abort()`). Automatic
163
+ re-invocation (the "restart" semantic the original RFC drafted) is
164
+ reserved for a follow-up RFC + field — the original `mode` field was
165
+ removed before release because the impl never read it.
158
166
 
159
167
  **Security companion (mandatory when watched facts may carry PII):**
160
168
  wire `createFactPIIGuardrail` on the same Directive system. Without
@@ -177,77 +185,95 @@ holder. The holder pattern lets you construct the adapter and the
177
185
  source in either order — the source's mount/unmount drives which
178
186
  publish closure (if any) receives lifecycle events.
179
187
 
188
+ > **Multi-tenant safety.** If you import the module that owns the
189
+ > holder from two places (e.g., a Worker that mounts one Directive
190
+ > system per tenant DO), a module-level `let publishRef` would be
191
+ > SHARED — the second tenant's `attach` would silently overwrite the
192
+ > first tenant's pipe. **Always declare the holder + adapter inside a
193
+ > factory function** so each call yields an isolated closure pair.
194
+ > The recipe below uses `makeOrchestrator(adapter)` for exactly this
195
+ > reason; if you create the adapter outside the factory, pass it in
196
+ > per call too.
197
+
180
198
  ```ts
181
199
  import { createSystem, createModule, t } from "@directive-run/core";
182
200
  import type { SourcePublish } from "@directive-run/core";
183
201
  import { createMCPAdapter } from "@directive-run/ai/mcp";
184
202
 
185
- // Holder that the source's `attach` populates. The adapter's events
186
- // forward through this when the source is detached (`attach`'s
187
- // unsubscribe runs), the holder goes null and the adapter callbacks
188
- // no-op. This guarantees post-stop lifecycle events from the MCP
189
- // transport can't write into a torn-down system.
190
- let publishRef: SourcePublish | null = null;
191
-
192
- const adapter = createMCPAdapter({
193
- servers: [/* ... */],
194
- events: {
195
- onConnect: (name) => publishRef?.("MCP_SERVER_CONNECTED", { name }),
196
- onDisconnect: (name) => publishRef?.("MCP_SERVER_DISCONNECTED", { name }),
197
- },
198
- });
203
+ // Factory closure each call yields a fresh `(adapter, module)`
204
+ // pair with its own `publishRef`. Multi-tenant safe; SSR / Vitest
205
+ // hot-reload safe; one tenant's MCP traffic can never leak into
206
+ // another tenant's facts.
207
+ function makeOrchestrator() {
208
+ let publishRef: SourcePublish | null = null;
199
209
 
200
- const orchestrator = createModule("orchestrator", {
201
- schema: {
202
- facts: {
203
- mcp: t.object<{
204
- servers: Record<string, "connected" | "disconnected">;
205
- }>(),
206
- },
210
+ const adapter = createMCPAdapter({
211
+ servers: [/* ... */],
207
212
  events: {
208
- MCP_SERVER_CONNECTED: { name: t.string() },
209
- MCP_SERVER_DISCONNECTED: { name: t.string() },
210
- },
211
- derivations: {
212
- allServersHealthy: t.boolean(),
213
+ onConnect: (name) => publishRef?.("MCP_SERVER_CONNECTED", { name }),
214
+ onDisconnect: (name) =>
215
+ publishRef?.("MCP_SERVER_DISCONNECTED", { name }),
213
216
  },
214
- },
215
- init: (f) => {
216
- f.mcp = { servers: {} };
217
- },
218
- events: {
219
- MCP_SERVER_CONNECTED: (f, p) => {
220
- f.mcp = {
221
- ...f.mcp,
222
- servers: { ...f.mcp.servers, [p.name]: "connected" },
223
- };
217
+ });
218
+
219
+ const orchestrator = createModule("orchestrator", {
220
+ schema: {
221
+ facts: {
222
+ mcp: t.object<{
223
+ servers: Record<string, "connected" | "disconnected">;
224
+ }>(),
225
+ },
226
+ events: {
227
+ MCP_SERVER_CONNECTED: { name: t.string() },
228
+ MCP_SERVER_DISCONNECTED: { name: t.string() },
229
+ },
230
+ derivations: {
231
+ allServersHealthy: t.boolean(),
232
+ },
224
233
  },
225
- MCP_SERVER_DISCONNECTED: (f, p) => {
226
- f.mcp = {
227
- ...f.mcp,
228
- servers: { ...f.mcp.servers, [p.name]: "disconnected" },
229
- };
234
+ init: (f) => {
235
+ f.mcp = { servers: {} };
230
236
  },
231
- },
232
- derive: {
233
- allServersHealthy: (facts) =>
234
- Object.values(facts.mcp.servers).every((s) => s === "connected"),
235
- },
236
- sources: {
237
- mcpLifecycle: {
238
- attach: (publish) => {
239
- publishRef = publish;
240
- // Kick the adapter's connection lifecycle once the source is
241
- // attached so the first `onConnect` flows into our publish.
242
- void adapter.connect();
243
- return async () => {
244
- publishRef = null;
245
- await adapter.disconnect();
237
+ events: {
238
+ MCP_SERVER_CONNECTED: (f, p) => {
239
+ f.mcp = {
240
+ ...f.mcp,
241
+ servers: { ...f.mcp.servers, [p.name]: "connected" },
242
+ };
243
+ },
244
+ MCP_SERVER_DISCONNECTED: (f, p) => {
245
+ f.mcp = {
246
+ ...f.mcp,
247
+ servers: { ...f.mcp.servers, [p.name]: "disconnected" },
246
248
  };
247
249
  },
248
250
  },
249
- },
250
- });
251
+ derive: {
252
+ allServersHealthy: (facts) =>
253
+ Object.values(facts.mcp.servers).every((s) => s === "connected"),
254
+ },
255
+ sources: {
256
+ mcpLifecycle: {
257
+ attach: (publish) => {
258
+ publishRef = publish;
259
+ // Kick the adapter's connection lifecycle once the source is
260
+ // attached so the first `onConnect` flows into our publish.
261
+ void adapter.connect();
262
+ return async () => {
263
+ publishRef = null;
264
+ await adapter.disconnect();
265
+ };
266
+ },
267
+ },
268
+ },
269
+ });
270
+
271
+ return { orchestrator, adapter };
272
+ }
273
+
274
+ // Per-tenant / per-request usage:
275
+ const { orchestrator, adapter } = makeOrchestrator();
276
+ const system = createSystem({ module: orchestrator });
251
277
  ```
252
278
 
253
279
  Constraints can now gate agent execution on
@@ -344,7 +370,8 @@ exports. One install, optional peerDependencies per vendor.
344
370
  |---|---|---|
345
371
  | `@directive-run/sources/supabase` | `sourceFromSupabaseChannel()` | Supabase realtime channel |
346
372
  | `@directive-run/sources/cloudflare` | `sourceFromDOAlarm()` | Cloudflare Durable Object alarm |
347
- | `@directive-run/sources/websocket` | `sourceFromWebSocket()` | (future RFC) raw WebSocket |
373
+ | `@directive-run/sources/cloudflare` | `sourceFromWebSocketMessage()` | Cloudflare Durable Object WebSocket message stream |
374
+ | `@directive-run/sources/websocket` | `sourceFromWebSocket()` | (future RFC) raw browser / Node WebSocket |
348
375
  | `@directive-run/sources/sentry` | `sourceFromSentryHook()` | (future RFC) Sentry production-error stream |
349
376
 
350
377
  Install only the umbrella; the vendor peerDeps are optional and pull in
package/core/sources.md CHANGED
@@ -274,20 +274,38 @@ flushes return Promises) work with both sync and async teardown:
274
274
  wall-clock cutoff so the runtime can evict the isolate even if
275
275
  some sources hang.
276
276
 
277
+ Use the **holder + closure** bridge pattern (the same shape the MCP
278
+ recipe in `ai-sources.md` documents) so the `attach` and `onEvict`
279
+ sibling closures share the channel handle:
280
+
277
281
  ```typescript
282
+ // Holder shared between `attach` and `onEvict`. Both closures live
283
+ // inside the same `sources` object; declaring the channel above them
284
+ // keeps the handle reachable from both.
285
+ let channel: RealtimeChannel | null = null;
286
+
278
287
  sources: {
279
288
  channel: {
280
289
  attach: (publish) => {
281
- const ch = supabase.channel("game").subscribe();
282
- ch.on("postgres_changes", { event: "UPDATE" }, (p) =>
290
+ channel = supabase.channel("game");
291
+ channel.on("postgres_changes", { event: "UPDATE" }, (p) =>
283
292
  publish("ROW_UPDATED", p),
284
293
  );
285
- return () => ch.unsubscribe(); // returns a Promise
294
+ channel.subscribe();
295
+ // Per RFC 0009, the unsubscribe MAY return a Promise; awaiting
296
+ // it on `system.stopAsync()` ensures the broker has dropped the
297
+ // subscription before the next start cycle re-attaches.
298
+ return async () => {
299
+ if (channel) await supabase.removeChannel(channel);
300
+ channel = null;
301
+ };
286
302
  },
287
303
  onEvict: async () => {
288
304
  // Cloudflare DO hibernate signal: close the channel actively
289
305
  // so the broker drops the subscription before the isolate dies.
290
- await supabase.removeChannel(ch);
306
+ // The unsubscribe will also run, but onEvict fires first and
307
+ // bounds the pre-hibernation work to a deadline.
308
+ if (channel) await supabase.removeChannel(channel);
291
309
  },
292
310
  },
293
311
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directive-run/knowledge",
3
- "version": "1.19.0",
3
+ "version": "1.19.1",
4
4
  "description": "Knowledge files, examples, and validation for Directive — the constraint-driven TypeScript runtime.",
5
5
  "license": "(MIT OR Apache-2.0)",
6
6
  "author": "Jason Comes",
@@ -53,8 +53,8 @@
53
53
  "tsx": "^4.19.2",
54
54
  "typescript": "^5.7.2",
55
55
  "vitest": "^3.0.0",
56
- "@directive-run/core": "1.19.0",
57
- "@directive-run/ai": "1.19.0"
56
+ "@directive-run/core": "1.19.1",
57
+ "@directive-run/ai": "1.19.1"
58
58
  },
59
59
  "scripts": {
60
60
  "build": "tsx scripts/generate-api-skeleton.ts && tsx scripts/generate-sitemap.ts && tsx scripts/extract-examples.ts && tsup",