@directive-run/knowledge 1.19.0 → 1.19.2

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,97 @@ 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 construct BOTH the adapter AND the
193
+ > module inside the same factory function** so the adapter's
194
+ > `events` callbacks close over the same factory-local `publishRef`
195
+ > as the source's `attach`. Sharing an adapter across factory calls
196
+ > re-introduces the cross-contamination because the adapter's
197
+ > `events.onConnect` was bound at construction time to whichever
198
+ > factory's `publishRef` was in scope first.
199
+
180
200
  ```ts
181
201
  import { createSystem, createModule, t } from "@directive-run/core";
182
202
  import type { SourcePublish } from "@directive-run/core";
183
203
  import { createMCPAdapter } from "@directive-run/ai/mcp";
184
204
 
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
- });
205
+ // Factory closure each call yields a fresh `(adapter, module)`
206
+ // pair with its own `publishRef`. Multi-tenant safe; SSR / Vitest
207
+ // hot-reload safe; one tenant's MCP traffic can never leak into
208
+ // another tenant's facts.
209
+ function makeOrchestrator() {
210
+ let publishRef: SourcePublish | null = null;
199
211
 
200
- const orchestrator = createModule("orchestrator", {
201
- schema: {
202
- facts: {
203
- mcp: t.object<{
204
- servers: Record<string, "connected" | "disconnected">;
205
- }>(),
206
- },
212
+ const adapter = createMCPAdapter({
213
+ servers: [/* ... */],
207
214
  events: {
208
- MCP_SERVER_CONNECTED: { name: t.string() },
209
- MCP_SERVER_DISCONNECTED: { name: t.string() },
210
- },
211
- derivations: {
212
- allServersHealthy: t.boolean(),
215
+ onConnect: (name) => publishRef?.("MCP_SERVER_CONNECTED", { name }),
216
+ onDisconnect: (name) =>
217
+ publishRef?.("MCP_SERVER_DISCONNECTED", { name }),
213
218
  },
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
- };
219
+ });
220
+
221
+ const orchestrator = createModule("orchestrator", {
222
+ schema: {
223
+ facts: {
224
+ mcp: t.object<{
225
+ servers: Record<string, "connected" | "disconnected">;
226
+ }>(),
227
+ },
228
+ events: {
229
+ MCP_SERVER_CONNECTED: { name: t.string() },
230
+ MCP_SERVER_DISCONNECTED: { name: t.string() },
231
+ },
232
+ derivations: {
233
+ allServersHealthy: t.boolean(),
234
+ },
224
235
  },
225
- MCP_SERVER_DISCONNECTED: (f, p) => {
226
- f.mcp = {
227
- ...f.mcp,
228
- servers: { ...f.mcp.servers, [p.name]: "disconnected" },
229
- };
236
+ init: (f) => {
237
+ f.mcp = { servers: {} };
230
238
  },
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();
239
+ events: {
240
+ MCP_SERVER_CONNECTED: (f, p) => {
241
+ f.mcp = {
242
+ ...f.mcp,
243
+ servers: { ...f.mcp.servers, [p.name]: "connected" },
244
+ };
245
+ },
246
+ MCP_SERVER_DISCONNECTED: (f, p) => {
247
+ f.mcp = {
248
+ ...f.mcp,
249
+ servers: { ...f.mcp.servers, [p.name]: "disconnected" },
246
250
  };
247
251
  },
248
252
  },
249
- },
250
- });
253
+ derive: {
254
+ allServersHealthy: (facts) =>
255
+ Object.values(facts.mcp.servers).every((s) => s === "connected"),
256
+ },
257
+ sources: {
258
+ mcpLifecycle: {
259
+ attach: (publish) => {
260
+ publishRef = publish;
261
+ // Kick the adapter's connection lifecycle once the source is
262
+ // attached so the first `onConnect` flows into our publish.
263
+ void adapter.connect();
264
+ return async () => {
265
+ publishRef = null;
266
+ await adapter.disconnect();
267
+ };
268
+ },
269
+ },
270
+ },
271
+ });
272
+
273
+ return { orchestrator, adapter };
274
+ }
275
+
276
+ // Per-tenant / per-request usage:
277
+ const { orchestrator, adapter } = makeOrchestrator();
278
+ const system = createSystem({ module: orchestrator });
251
279
  ```
252
280
 
253
281
  Constraints can now gate agent execution on
@@ -344,7 +372,8 @@ exports. One install, optional peerDependencies per vendor.
344
372
  |---|---|---|
345
373
  | `@directive-run/sources/supabase` | `sourceFromSupabaseChannel()` | Supabase realtime channel |
346
374
  | `@directive-run/sources/cloudflare` | `sourceFromDOAlarm()` | Cloudflare Durable Object alarm |
347
- | `@directive-run/sources/websocket` | `sourceFromWebSocket()` | (future RFC) raw WebSocket |
375
+ | `@directive-run/sources/cloudflare` | `sourceFromWebSocketMessage()` | Cloudflare Durable Object WebSocket message stream |
376
+ | `@directive-run/sources/websocket` | `sourceFromWebSocket()` | (future RFC) raw browser / Node WebSocket |
348
377
  | `@directive-run/sources/sentry` | `sourceFromSentryHook()` | (future RFC) Sentry production-error stream |
349
378
 
350
379
  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.2",
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.2",
57
+ "@directive-run/ai": "1.19.2"
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",