@fungi.computer/mycelium 0.2.3 → 0.3.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/README.md CHANGED
@@ -10,9 +10,10 @@ when the run ends. You can also execute a known operation without a model.
10
10
  npm install @fungi.computer/mycelium
11
11
  ```
12
12
 
13
- The package ships ESM only and depends on Zod, Effect, `acorn`,
14
- [Cairn](../cairn/README.md), [Forage](../forage/README.md), and
15
- [Mule](../mule/README.md) at runtime, all installed automatically. The root
13
+ The package ships ESM only. Runtime dependencies include Zod, Effect, `acorn`,
14
+ Cloudflare Code Mode, the MCP server SDK, [Cairn](../cairn/README.md),
15
+ [Forage](../forage/README.md), [Mule](../mule/README.md) and
16
+ [Puffball](../puffball/README.md), all installed automatically. The root
16
17
  entrypoint is the Promise host facade. `@fungi.computer/mycelium/effect`
17
18
  supplies the scoped Effect API. Both produce an Effect-native Mule tool. The
18
19
  `./sandbox-guest`, `./sandbox-wire`, `./sandbox-failure`, `./sandbox-boundary`,
@@ -223,17 +224,21 @@ Preserve that evidence without sending host diagnostics to the guest.
223
224
  ### Sandbox admission
224
225
 
225
226
  The host-only `execution` options accept `sandboxCapacity` (a positive safe
226
- integer) and `admissionWaitMs` (an integer from 1 to 900,000 milliseconds,
227
- default 5,000). Guests still submit only `code`. An omitted capacity derives
228
- from available cores, clamped to 2..32; hosts without a portable core
229
- observation use 2. Node captures its affinity-aware core count once per runtime
230
- through `node:os.availableParallelism()`, behind a platform-conditional module
231
- outside the Workers import graph. Explicit valid capacity overrides bypass
232
- observation and are not clamped, including values above 32.
227
+ integer) and `admissionWaitMs` (an integer from 1 to 900,000 milliseconds). By
228
+ default, admission waits for the invocation's `timeoutMs` budget (180,000
229
+ milliseconds when omitted), rather than failing after five seconds. An explicit
230
+ host wait remains bounded by the original invocation deadline. Guests still
231
+ submit only `code`. An omitted capacity derives from available cores, clamped to
232
+ 2..32; hosts without a portable core observation use 2. Node captures its
233
+ affinity-aware core count once per runtime through
234
+ `node:os.availableParallelism()`, behind a platform-conditional module outside
235
+ the Workers import graph. Explicit valid capacity overrides bypass observation
236
+ and are not clamped, including values above 32.
233
237
 
234
238
  `normalizeSandboxAdmissionPolicy(options, availableCores?)` validates and
235
- resolves these host facts. It does not reserve a slot or start a sandbox. Both
236
- public runtime facades validate and capture the policy when the runtime opens.
239
+ resolves these host facts, using 180,000 milliseconds when no wait is supplied.
240
+ It does not reserve a slot or start a sandbox. Both public runtime facades
241
+ validate and capture the policy when the runtime opens.
237
242
 
238
243
  Admission belongs to the existing runtime-wide backing-work tracker, not each
239
244
  lease, group, or revision. A full pool waits until a slot opens or the earlier
package/dist/effect.d.ts CHANGED
@@ -10,6 +10,7 @@ import type { CapabilitySession, CapabilitySource, ModuleRegistration, Sandbox a
10
10
  import type { AccessOptions, LabelledTool } from "./access.js";
11
11
  export type { CapabilitySession, CapabilitySource, ModuleRegistration, SandboxBinding, SandboxRequest, };
12
12
  export type { ExecutionOptions } from "./execute-tool.js";
13
+ export { forageDiscoveryInstructions } from "./execute-tool.js";
13
14
  export type { Access, AccessContext, AccessLabel, AccessOptions, AccessRefusal, AccessRefusalReason, ConfirmRequest, Grant, Grants, LabelledTool, ToolPolicy, } from "./access.js";
14
15
  /**
15
16
  * Effect-native host seam for sandbox execution.
package/dist/effect.js CHANGED
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
3
3
  import * as Scope from "effect/Scope";
4
4
  import {} from "./sandbox-failure.js";
5
5
  import { openCoreRuntime } from "./runtime.js";
6
+ export { forageDiscoveryInstructions } from "./execute-tool.js";
6
7
  /**
7
8
  * Adapt a Promise-shaped sandbox to the Effect seam. This is Mycelium's one
8
9
  * Promise sandbox conversion: interruption waits for the host Promise to
@@ -7,6 +7,8 @@ import { type BackingWorkGroup, type SerializedSandboxValue } from "./internal/e
7
7
  import { type MyceliumError } from "./errors.js";
8
8
  import type { EffectSandbox } from "./runtime.js";
9
9
  import type { SandboxAdmissionOptions } from "./sandbox-admission.js";
10
+ /** Shared discovery scope for hosts offering Mycelium's execute tool. */
11
+ export declare const forageDiscoveryInstructions = "Inside execute, call forage.search, forage.list or forage.describe to discover operations and inspect their inputs. forage is available only there, not as a separate tool.";
10
12
  /** Host execution limits plus the reserved, host-only admission contract. */
11
13
  export type ExecutionOptions = Readonly<{
12
14
  /** Total execution deadline in milliseconds. */
@@ -14,7 +14,10 @@ import { OutputCollector } from "./output-collector.js";
14
14
  import { InvalidOperationInput } from "./errors.js";
15
15
  /** The one outer Mule input authority for the Mycelium execute door. */
16
16
  const ExecuteInputSchema = Schema.Struct({ code: Schema.String });
17
- const executeDescription = 'Run async JavaScript in the sandbox. Each call is a fresh sandbox, not a REPL: nothing survives between calls, so redefine helpers or persist state to a file. Never shadow injected module names with local bindings. Find operations with await forage.search({ query }) using one short keyword such as read or shell, or page them all with forage.list({limit:50}), optionally by namespace, until nextCursor is null. Inspect input with await forage.describe({ path }) before calling; describe known paths directly. Call an operation as await env.<path>({ ... }) with the exact dotted path, for example await env.computers.local.exec({ command: "pwd" }); a bare computers.local.exec is not defined. Put multiline shell commands in a template literal. Code is JavaScript first, so double each backslash the shell needs (`\\\\(` passes `\\(`; `\\(` becomes `(`). For file edits, prefer Files operations when available. Explicitly return the final value. Before treating a shell or process result as success, check status, exitCode and stderr.';
17
+ /** Shared discovery scope for hosts offering Mycelium's execute tool. */
18
+ export const forageDiscoveryInstructions = "Inside execute, call forage.search, forage.list or forage.describe to discover operations and inspect their inputs. forage is available only there, not as a separate tool.";
19
+ const executeDescription = `${forageDiscoveryInstructions} ` +
20
+ 'Run async JavaScript in the sandbox. Each call is a fresh sandbox, not a REPL: nothing survives between calls, so redefine helpers or persist state to a file. Never shadow injected module names with local bindings. Find operations with await forage.search({ query }) using one short keyword such as read or shell, or page them all with forage.list({limit:50}), optionally by namespace, until nextCursor is null. Inspect input with await forage.describe({ path }) before calling; describe known paths directly. Call an operation as await env.<path>({ ... }) with the exact dotted path, for example await env.computers.local.exec({ command: "pwd" }); a bare computers.local.exec is not defined. Put multiline shell commands in a template literal. Code is JavaScript first, so double each backslash the shell needs (`\\\\(` passes `\\(`; `\\(` becomes `(`). For file edits, prefer Files operations when available. Explicitly return the final value. Before treating a shell or process result as success, check status, exitCode and stderr.';
18
21
  /** One bound operation: its labels are checked against the host's grants first. */
19
22
  function guardedInvoke(entry, context, access, sessionId) {
20
23
  const label = {
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { sandboxFailure, isSandboxFailure, sandboxOperatorValue, } from "./sandb
2
2
  import type { ForageCatalog } from "@fungi.computer/forage";
3
3
  export { normalizeSandboxAdmissionPolicy, type SandboxAdmissionOptions, type SandboxAdmissionPolicy, } from "./sandbox-admission.js";
4
4
  import type { MuleTool } from "@fungi.computer/mule/effect";
5
- import type { Sandbox, MyceliumJsonValue } from "./sandbox.js";
5
+ import type { Sandbox } from "./sandbox.js";
6
6
  export type { MyceliumJsonValue, SandboxValue, SandboxBinding, SandboxRequest, Sandbox, } from "./sandbox.js";
7
7
  import type * as Schema from "effect/Schema";
8
8
  import type { OperationSchema } from "./operation-schema.js";
@@ -143,13 +143,13 @@ export type MyceliumApi = Readonly<{
143
143
  /**
144
144
  * Declare one schema-described operation behind the execute door. An Effect
145
145
  * output schema encodes the result to JSON; otherwise the result must
146
- * already be a JSON value.
146
+ * already be a JSON value, which may be readonly.
147
147
  */
148
148
  operation: {
149
149
  <I, O>(definition: OperationDefinition<I, O> & {
150
150
  readonly output: Schema.Codec<O, unknown>;
151
151
  }): OperationRegistration<I, O>;
152
- <I, O extends MyceliumJsonValue>(definition: OperationDefinition<I, O>): OperationRegistration<I, O>;
152
+ <I, O extends Schema.Json>(definition: OperationDefinition<I, O>): OperationRegistration<I, O>;
153
153
  };
154
154
  /** Open the Promise-shaped facade over Mycelium's Effect runtime. */
155
155
  make: (options: MyceliumOptions) => Promise<Runtime>;
@@ -32,12 +32,13 @@ export function normalizeExecutionPolicy(value) {
32
32
  if (!parsed.success) {
33
33
  throw new MyceliumDefinitionError("Execution policy is invalid");
34
34
  }
35
+ const timeoutMs = parsed.data?.timeoutMs ?? 180_000;
35
36
  return Object.freeze({
36
- timeoutMs: parsed.data?.timeoutMs ?? 180_000,
37
+ timeoutMs,
37
38
  abortGraceMs: parsed.data?.abortGraceMs ?? 5_000,
38
39
  ...normalizeSandboxAdmissionPolicy({
39
40
  sandboxCapacity: parsed.data?.sandboxCapacity,
40
- admissionWaitMs: parsed.data?.admissionWaitMs,
41
+ admissionWaitMs: parsed.data?.admissionWaitMs ?? timeoutMs,
41
42
  }, parsed.data?.sandboxCapacity === undefined
42
43
  ? availableHostCores()
43
44
  : undefined),
@@ -2,6 +2,8 @@ import * as Effect from "effect/Effect";
2
2
  import * as Queue from "effect/Queue";
3
3
  import * as Stream from "effect/Stream";
4
4
  const capacity = 8192;
5
+ // Bound temporary decoded text and raw context even for a single huge write.
6
+ const decodeCapacity = capacity * 4;
5
7
  const encoder = new TextEncoder();
6
8
  // fatal: false is the existing default; Cloudflare's type requires the field.
7
9
  const decoder = () => new TextDecoder("utf-8", { ignoreBOM: true, fatal: false });
@@ -9,11 +11,11 @@ const decoder = () => new TextDecoder("utf-8", { ignoreBOM: true, fatal: false }
9
11
  export class OutputCollector {
10
12
  wake;
11
13
  #producers = {
12
- stdout: { decoder: decoder(), pendingBytes: 0 },
13
- stderr: { decoder: decoder(), pendingBytes: 0 },
14
+ stdout: { decoder: decoder(), pending: new Uint8Array(0) },
15
+ stderr: { decoder: decoder(), pending: new Uint8Array(0) },
14
16
  };
15
17
  #probe = decoder();
16
- // One bounded ring of complete decoder emissions. No UTF-8 slicing/parser.
18
+ // One bounded ring of decoded chunks. TextDecoder owns every UTF-8 boundary.
17
19
  #parts = Array.from({ length: capacity }, () => undefined);
18
20
  #head = 0;
19
21
  #size = 0;
@@ -35,28 +37,34 @@ export class OutputCollector {
35
37
  return;
36
38
  const producer = this.#producers[stream];
37
39
  this.#totalBytes += bytes.length;
38
- // Byte-sized decoder inputs let each retained emission carry its exact raw
39
- // cost, even when malformed input expands to replacement characters.
40
- for (let i = 0; i < bytes.length; i++) {
41
- const byte = bytes.subarray(i, i + 1);
42
- producer.pendingBytes++;
43
- const text = producer.decoder.decode(byte, { stream: true });
44
- if (text.length === 0)
45
- continue;
46
- // A decoder may emit a replacement for a prior incomplete sequence while
47
- // buffering the current byte as the start of the next scalar. Ask the
48
- // maintained decoder, rather than duplicating UTF-8 byte grammar.
49
- const pending = producer.pendingBytes > 1 &&
50
- this.#probe.decode(byte, { stream: true }).length === 0
51
- ? 1
52
- : 0;
53
- this.#probe.decode();
54
- this.#append(text, producer.pendingBytes - pending);
55
- producer.pendingBytes = pending;
40
+ for (let offset = 0; offset < bytes.length; offset += decodeCapacity) {
41
+ const chunk = bytes.subarray(offset, offset + decodeCapacity);
42
+ const text = producer.decoder.decode(chunk, { stream: true });
43
+ const raw = new Uint8Array(producer.pending.length + chunk.length);
44
+ raw.set(producer.pending);
45
+ raw.set(chunk, producer.pending.length);
46
+ const pending = this.#pendingLength(raw);
47
+ producer.pending = raw.slice(raw.length - pending);
48
+ this.#append(text, raw.length - pending, raw);
56
49
  }
57
50
  this.#dirty = true;
58
51
  Queue.offerUnsafe(this.wake, undefined);
59
52
  }
53
+ #decodePrefix(raw, length) {
54
+ const text = this.#probe.decode(raw.subarray(0, length), { stream: true });
55
+ this.#probe.decode();
56
+ return text;
57
+ }
58
+ #pendingLength(raw) {
59
+ // UTF-8 has at most three incomplete bytes. Ask the maintained decoder
60
+ // which suffix is still incomplete; do not inspect UTF-8 byte patterns.
61
+ for (let length = 1; length <= Math.min(3, raw.length); length++) {
62
+ const suffix = raw.subarray(raw.length - length);
63
+ if (this.#decodePrefix(suffix, suffix.length).length === 0)
64
+ return length;
65
+ }
66
+ return 0;
67
+ }
60
68
  #evict() {
61
69
  const part = this.#parts[this.#head];
62
70
  if (part === undefined)
@@ -68,11 +76,56 @@ export class OutputCollector {
68
76
  this.#newlines -= part.newlines;
69
77
  this.#droppedBytes += part.rawBytes;
70
78
  }
71
- #append(text, rawBytes) {
79
+ #trimHead(part, textBytes, newlines) {
80
+ const raw = part.raw;
81
+ if (raw === null) {
82
+ this.#evict();
83
+ return;
84
+ }
85
+ // Find the first decoder emission that pays both clipping debts. A native
86
+ // decode can emit replacement + ASCII together, or emit a replacement
87
+ // while buffering the next scalar. Keep those same emission boundaries.
88
+ const enough = (length) => {
89
+ const prefix = this.#decodePrefix(raw, length);
90
+ return (encoder.encode(prefix).length >= textBytes &&
91
+ prefix.split("\n").length - 1 >= newlines);
92
+ };
93
+ let high = Math.min(raw.length, Math.max(4, textBytes));
94
+ while (high < raw.length && !enough(high))
95
+ high = Math.min(raw.length, high * 2);
96
+ let low = 0;
97
+ while (low < high) {
98
+ const middle = Math.floor((low + high) / 2);
99
+ if (enough(middle))
100
+ high = middle;
101
+ else
102
+ low = middle + 1;
103
+ }
104
+ const prefix = this.#decodePrefix(raw, low);
105
+ if (prefix.length === part.text.length) {
106
+ this.#evict();
107
+ return;
108
+ }
109
+ const cut = low - this.#pendingLength(raw.subarray(0, low));
110
+ const removedBytes = encoder.encode(prefix).length;
111
+ const removedLines = prefix.split("\n").length - 1;
112
+ part.raw = raw.slice(cut);
113
+ // Decode the bounded remainder anew so a sliced string cannot retain the
114
+ // larger temporary chunk. raw.slice also releases its original buffer.
115
+ part.text = this.#decodePrefix(part.raw, part.raw.length);
116
+ part.rawBytes -= cut;
117
+ part.textBytes -= removedBytes;
118
+ part.newlines -= removedLines;
119
+ this.#textBytes -= removedBytes;
120
+ this.#newlines -= removedLines;
121
+ this.#droppedBytes += cut;
122
+ }
123
+ #append(text, rawBytes, raw) {
72
124
  if (text.length === 0)
73
125
  return;
74
126
  const part = {
75
127
  text,
128
+ raw,
76
129
  rawBytes,
77
130
  textBytes: encoder.encode(text).length,
78
131
  newlines: text.split("\n").length - 1,
@@ -83,8 +136,17 @@ export class OutputCollector {
83
136
  this.#size++;
84
137
  this.#textBytes += part.textBytes;
85
138
  this.#newlines += part.newlines;
86
- while (this.#textBytes > capacity || this.#newlines >= 200)
87
- this.#evict();
139
+ while (this.#textBytes > capacity || this.#newlines >= 200) {
140
+ const head = this.#parts[this.#head];
141
+ if (head === undefined)
142
+ throw new Error("Missing output ring entry");
143
+ const bytesToDrop = Math.max(0, this.#textBytes - capacity);
144
+ const linesToDrop = Math.max(0, this.#newlines - 199);
145
+ if (head.textBytes <= bytesToDrop || head.newlines < linesToDrop)
146
+ this.#evict();
147
+ else
148
+ this.#trimHead(head, bytesToDrop, linesToDrop);
149
+ }
88
150
  }
89
151
  snapshot() {
90
152
  let tail = "";
@@ -121,8 +183,8 @@ export class OutputCollector {
121
183
  this.#closed = true;
122
184
  for (const producer of [this.#producers.stdout, this.#producers.stderr]) {
123
185
  const text = producer.decoder.decode();
124
- this.#append(text, producer.pendingBytes);
125
- producer.pendingBytes = 0;
186
+ this.#append(text, producer.pending.length, null);
187
+ producer.pending = new Uint8Array(0);
126
188
  if (text.length > 0)
127
189
  this.#dirty = true;
128
190
  }
@@ -139,7 +201,7 @@ export class OutputCollector {
139
201
  this.#dirty = false;
140
202
  for (const producer of [this.#producers.stdout, this.#producers.stderr]) {
141
203
  producer.decoder.decode();
142
- producer.pendingBytes = 0;
204
+ producer.pending = new Uint8Array(0);
143
205
  }
144
206
  }
145
207
  }
package/dist/runtime.js CHANGED
@@ -50,12 +50,16 @@ export function openCoreRuntime(options) {
50
50
  onFailure: options.onSandboxFailure,
51
51
  execution,
52
52
  });
53
- const staticSurface = yield* Effect.try({
54
- try: () => compileSurface(options.modules),
55
- catch: (cause) => cause instanceof MyceliumDefinitionError
56
- ? cause
57
- : new MyceliumDefinitionError("Capability compilation failed"),
58
- });
53
+ // A source-only runtime has no static definitions to validate. Its empty
54
+ // surface would be discarded; acquisition compiles the captured modules.
55
+ const staticSurface = options.modules.length === 0 && options.sources.length > 0
56
+ ? undefined
57
+ : yield* Effect.try({
58
+ try: () => compileSurface(options.modules),
59
+ catch: (cause) => cause instanceof MyceliumDefinitionError
60
+ ? cause
61
+ : new MyceliumDefinitionError("Capability compilation failed"),
62
+ });
59
63
  const tools = yield* Effect.try({
60
64
  try: () => Object.freeze(options.tools.map((tool) => guardTool(tool, options.access, {
61
65
  sessionId: options.session?.sessionId ?? null,
@@ -67,7 +71,7 @@ export function openCoreRuntime(options) {
67
71
  let installed;
68
72
  const liveRevisions = new Set();
69
73
  let closed = false;
70
- if (options.sources.length === 0) {
74
+ if (options.sources.length === 0 && staticSurface !== undefined) {
71
75
  const candidate = yield* acquireSourceCandidate({
72
76
  ...options,
73
77
  sandbox,
@@ -16,6 +16,6 @@ export function normalizeSandboxAdmissionPolicy(options, availableCores) {
16
16
  }
17
17
  return Object.freeze({
18
18
  capacity: parsed.data.sandboxCapacity ?? Math.min(32, Math.max(2, cores.data)),
19
- waitMs: parsed.data.admissionWaitMs ?? 5_000,
19
+ waitMs: parsed.data.admissionWaitMs ?? 180_000,
20
20
  });
21
21
  }
@@ -77,6 +77,17 @@ type Runtime = Readonly<{
77
77
 
78
78
  Warm Effect lifecycle for acquiring scope-owned run leases.
79
79
 
80
+ ## Variables
81
+
82
+ ### forageDiscoveryInstructions
83
+
84
+ ```ts
85
+ const forageDiscoveryInstructions: "Inside execute, call forage.search, forage.list or forage.describe to discover operations and inspect their inputs. forage is available only there, not as a separate tool." =
86
+ "Inside execute, call forage.search, forage.list or forage.describe to discover operations and inspect their inputs. forage is available only there, not as a separate tool.";
87
+ ```
88
+
89
+ Shared discovery scope for hosts offering Mycelium's execute tool.
90
+
80
91
  ## Functions
81
92
 
82
93
  ### fromTypedSandbox()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/mycelium",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "author": "Levi <levi@technomancy.dev>",
5
5
  "description": "Effect-native scoped capability lifecycle with a Zod-authored execute door",
6
6
  "type": "module",
@@ -79,9 +79,9 @@
79
79
  ],
80
80
  "dependencies": {
81
81
  "@fungi.computer/cairn": "0.1.0",
82
- "@fungi.computer/forage": "0.2.0",
82
+ "@fungi.computer/forage": "0.2.2",
83
83
  "@fungi.computer/mule": "0.2.1",
84
- "@fungi.computer/puffball": "0.2.0",
84
+ "@fungi.computer/puffball": "0.4.0",
85
85
  "effect": "4.0.2",
86
86
  "zod": "4.4.3",
87
87
  "acorn": "8.18.0",