@fungi.computer/truffle 0.4.0 → 0.5.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.
@@ -17,12 +17,13 @@ import { z } from "zod";
17
17
  import * as Schema from "effect/Schema";
18
18
  import { selectionSchema } from "./private-local-service-discovery.js";
19
19
  import { createShiitakeHttpRoutes } from "@fungi.computer/shiitake/http";
20
+ import { questionContract } from "@fungi.computer/shiitake-extensions/question";
20
21
  import { listPrivateLocalAgentStores, LocalStoreSchemaError, readPrivateLocalAgentProfile, readPrivateLocalSessionSummaries, isArchivedStore, unreadableStoreReason, } from "./private-local-session-discovery.js";
21
22
  import { createPrivateLocalSessionRequestId, openPrivateLocalRuntimeHost, resolvePrivateLocalCreateStore, } from "./private-local-runtime-host.js";
22
23
  import { parsePrivateLocalExecPath } from "./private-local-exec-path.js";
23
24
  import { createLocalMeshLinks, localMeshLinkKey, } from "./private-local-mesh-links.js";
24
25
  import { agentMailAddressSchema, machineAgentRequestSchema, machineDirectorySchema, } from "@fungi.computer/seam/machine-link-protocol";
25
- import { commandSettlementSchema } from "@fungi.computer/shiitake/fleet";
26
+ import { commandSettlementSchema, settlementReadableBy, } from "@fungi.computer/shiitake/fleet";
26
27
  import { BirdDogMailSchema } from "@fungi.computer/bird-dog/effect";
27
28
  export class LocalSessionPathConflict extends Error {
28
29
  name = "LocalSessionPathConflict";
@@ -163,7 +164,7 @@ serviceNative, onExecApprovalDecision = () => undefined) {
163
164
  return { status: "accepted", receipt: { sessionId } };
164
165
  }
165
166
  const facts = await admitted(owner, () => owner.readSettlement(operation.sessionId, operation.commandId));
166
- if (facts !== undefined && facts.acceptedSender !== actorAgentId)
167
+ if (facts !== undefined && !settlementReadableBy(facts, actorAgentId))
167
168
  return "refused";
168
169
  return {
169
170
  status: "accepted",
@@ -902,6 +903,62 @@ serviceNative, onExecApprovalDecision = () => undefined) {
902
903
  throw new AgentArchiveRefused("archived", `Agent ${agentId} is archived; unarchive it to work with it`);
903
904
  throw new Error("Agent is outside this Local project");
904
905
  }
906
+ /**
907
+ * Run one sender-authenticated command on a child's question owner. Which
908
+ * Agent the item waits on is decided by the child's own store; this door
909
+ * only states who is calling.
910
+ */
911
+ async function callQuestionAsSender(target, senderAgentId, senderSessionId, command, commandInput, output, signal) {
912
+ ensureRunning();
913
+ signal.throwIfAborted();
914
+ const databasePath = (await targets(signal, "all")).get(target.agentId);
915
+ if (databasePath === undefined)
916
+ return outsideOrArchived(target.agentId, signal);
917
+ const { owner, release } = await admittedOwner(target.agentId, signal);
918
+ try {
919
+ return await admitted(owner, async () => Schema.decodeUnknownSync(output)(await owner
920
+ .getSession(target.sessionId)
921
+ .extension("botanical-session-question")
922
+ .call(command, commandInput, {
923
+ agentId: senderAgentId,
924
+ sessionId: senderSessionId,
925
+ })));
926
+ }
927
+ finally {
928
+ release();
929
+ }
930
+ }
931
+ async function answerAsSender(input, senderAgentId, senderSessionId, signal) {
932
+ const decoded = await callQuestionAsSender(input, senderAgentId, senderSessionId, "answerAgent", {
933
+ askId: input.askId,
934
+ requestId: input.requestId,
935
+ answers: input.answers,
936
+ }, questionContract.commands.answerAgent.output, signal);
937
+ const { result } = decoded;
938
+ if (result.result === "accepted")
939
+ return result.commandId === undefined
940
+ ? { status: "answered", via: result.via }
941
+ : {
942
+ status: "answered",
943
+ via: result.via,
944
+ commandId: result.commandId,
945
+ };
946
+ if (result.result === "closed")
947
+ return { status: "closed", reason: result.status };
948
+ if (result.result === "refused")
949
+ return { status: "refused", reason: result.reason };
950
+ return { status: "not_found" };
951
+ }
952
+ async function escalateAsSender(input, senderAgentId, senderSessionId, signal) {
953
+ const decoded = await callQuestionAsSender(input, senderAgentId, senderSessionId, "escalateAgent", { askId: input.itemId }, questionContract.commands.escalateAgent.output, signal);
954
+ if (decoded.result === "escalated")
955
+ return { status: "escalated", to: decoded.to };
956
+ if (decoded.result === "closed")
957
+ return { status: "closed", reason: decoded.status };
958
+ if (decoded.result === "refused")
959
+ return { status: "refused", reason: decoded.reason };
960
+ return { status: "not_found" };
961
+ }
905
962
  /**
906
963
  * The first command still queued or running on any of an Agent's Sessions,
907
964
  * so an archive can name it rather than cancel it.
@@ -978,6 +1035,8 @@ serviceNative, onExecApprovalDecision = () => undefined) {
978
1035
  }
979
1036
  return {
980
1037
  owner: admittedOwner,
1038
+ answer: answerAsSender,
1039
+ escalate: escalateAsSender,
981
1040
  archive: (request, signal) => setArchived(request, true, signal),
982
1041
  unarchive: (request, signal) => setArchived(request, false, signal),
983
1042
  list: async (signal, options) => {
@@ -466,7 +466,13 @@ export function createPrivateLocalServiceManager(options = {}) {
466
466
  // stopped it decides when it returns.
467
467
  const ready = await status();
468
468
  if (ready === undefined)
469
- return { kind: "unavailable" };
469
+ return {
470
+ kind: "unavailable",
471
+ // The discovery owner's process-lifetime lease distinguishes an
472
+ // overloaded listener from a stopped service without guessing
473
+ // from the failed HTTP probe or an error message.
474
+ alive: await serviceOwnerIsLive(selectedDirectory),
475
+ };
470
476
  accept(ready);
471
477
  const again = await postSelection("select", {
472
478
  invocationRoot: input.invocationRoot,
@@ -29,24 +29,29 @@ export declare class LocalStoreSchemaError extends Error {
29
29
  readonly name = "LocalStoreSchemaError";
30
30
  constructor(databasePath: string, options?: ErrorOptions);
31
31
  }
32
+ export type PrivateLocalAgentProfile = ReturnType<typeof openAgentProfile>;
32
33
  /**
33
34
  * One Agent store's chosen display name, default model, whether it is
34
35
  * archived and the Agent that created it (absent when a human made it), read
35
- * without opening a host.
36
+ * without opening a host. Directory listings and the sub-agents footer read
37
+ * every store's profile on each call; an unchanged store file answers from
38
+ * memory instead of reopening SQLite (each Bun open/close also leaks native
39
+ * memory).
36
40
  */
37
- export declare function readPrivateLocalAgentProfile(path: string): {
41
+ export declare function readPrivateLocalAgentProfile(path: string): PrivateLocalAgentProfile;
42
+ declare function openAgentProfile(path: string): Readonly<{
38
43
  name: string | undefined;
39
44
  createdBy: string | null;
40
45
  archived: boolean;
41
46
  mode: "read-only" | "workspace" | "full-access";
42
- writableFolders: string[];
47
+ writableFolders: readonly string[];
43
48
  folder: string;
44
49
  model: {
45
50
  readonly providerId: string;
46
51
  readonly modelId: string;
47
52
  readonly reasoning?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
48
53
  } | null;
49
- };
54
+ }>;
50
55
  export declare function readPrivateLocalSessionSummaries(path: string): Promise<readonly LocalSummary[]>;
51
56
  /**
52
57
  * Discover existing folder sessions without opening or reconciling a host.
@@ -71,3 +76,4 @@ export declare function readPrivateLocalSessionDiscovery(invocationRoot: string,
71
76
  }>;
72
77
  /** Project the service-owned discovery into choices that resume through it. */
73
78
  export declare function projectPrivateLocalSessionDiscovery(discovered: Awaited<ReturnType<typeof readPrivateLocalSessionDiscovery>>, connect: PrivateLocalServiceManager["connect"]): PrivateLocalSessionDiscovery;
79
+ export {};
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import { readdir, realpath, stat } from "node:fs/promises";
2
3
  import { basename, dirname, join, resolve } from "node:path";
3
4
  import * as Effect from "effect/Effect";
@@ -151,12 +152,44 @@ export class LocalStoreSchemaError extends Error {
151
152
  this.databasePath = databasePath;
152
153
  }
153
154
  }
155
+ /** Profiles held at once; directory listings touch every store each call. */
156
+ const cachedProfiles = 1_024;
157
+ const profiles = new Map();
158
+ /** Every committed write moves the store file (or its WAL); none rewrites it in place unseen. */
159
+ function storeVersion(path) {
160
+ const version = (file) => {
161
+ const metadata = statSync(file, { bigint: true, throwIfNoEntry: false });
162
+ return metadata === undefined
163
+ ? "-"
164
+ : `${String(metadata.dev)}:${String(metadata.ino)}:${String(metadata.size)}:${String(metadata.mtimeNs)}:${String(metadata.ctimeNs)}`;
165
+ };
166
+ return `${version(path)}/${version(`${path}-wal`)}`;
167
+ }
154
168
  /**
155
169
  * One Agent store's chosen display name, default model, whether it is
156
170
  * archived and the Agent that created it (absent when a human made it), read
157
- * without opening a host.
171
+ * without opening a host. Directory listings and the sub-agents footer read
172
+ * every store's profile on each call; an unchanged store file answers from
173
+ * memory instead of reopening SQLite (each Bun open/close also leaks native
174
+ * memory).
158
175
  */
159
176
  export function readPrivateLocalAgentProfile(path) {
177
+ const version = storeVersion(path);
178
+ const cached = profiles.get(path);
179
+ if (cached?.version === version)
180
+ return cached.profile;
181
+ const profile = openAgentProfile(path);
182
+ // The version is taken before the read, so a write racing it misses next time.
183
+ profiles.delete(path);
184
+ profiles.set(path, { version, profile });
185
+ if (profiles.size > cachedProfiles) {
186
+ const oldest = profiles.keys().next().value;
187
+ if (oldest !== undefined)
188
+ profiles.delete(oldest);
189
+ }
190
+ return profile;
191
+ }
192
+ function openAgentProfile(path) {
160
193
  const database = openPrivateReadOnlySqliteDatabase(path);
161
194
  try {
162
195
  const columns = database
@@ -184,15 +217,15 @@ export function readPrivateLocalAgentProfile(path) {
184
217
  folder: identity?.folder,
185
218
  model: readAgentDefaultModelJson(database),
186
219
  });
187
- return {
220
+ return Object.freeze({
188
221
  name: stored.name ?? undefined,
189
222
  createdBy: stored.createdBy,
190
223
  archived: stored.archived === 1,
191
224
  mode: stored.mode,
192
- writableFolders: stored.writableFolders,
225
+ writableFolders: Object.freeze(stored.writableFolders),
193
226
  folder: stored.folder,
194
227
  model: parseModelPreferenceJson(stored.model),
195
- };
228
+ });
196
229
  }
197
230
  finally {
198
231
  database.close();
@@ -1,5 +1,5 @@
1
1
  import type { AssistantMessage } from "@earendil-works/pi-ai";
2
- import type { ProviderRejection } from "@fungi.computer/mule";
2
+ import { type ProviderRejection } from "@fungi.computer/mule";
3
3
  /** Evidence owned by one Local request, never inferred from assistant prose. */
4
4
  export type ModelFailureInput = Readonly<{
5
5
  refusal?: Readonly<{
@@ -1,24 +1,44 @@
1
1
  import { isContextOverflow } from "@earendil-works/pi-ai/utils/overflow";
2
+ import { isRetryableProviderRejection, } from "@fungi.computer/mule";
2
3
  /** Local retries only a typed transient, never cancellation or a turn refusal. */
3
4
  export function modelFailureRetryable(failure) {
4
5
  return (failure.refusal === undefined &&
5
6
  failure.upstream?.stopReason !== "aborted" &&
6
- failure.rejection?.code === "provider_unavailable");
7
+ isRetryableProviderRejection(failure.rejection));
7
8
  }
8
9
  /** The one owned wording for a failed Local model request. */
9
10
  export function modelFailureMessage(provider, failure) {
11
+ const name = provider === "anthropic"
12
+ ? "Anthropic"
13
+ : provider === "openai"
14
+ ? "OpenAI"
15
+ : provider;
10
16
  if (failure.refusal !== undefined)
11
17
  return `${failure.refusal.category}: ${failure.refusal.message}`;
12
18
  // Typed subscription evidence wins over all SDK/provider wording.
13
- if (failure.rejection?.code === "usage-limit-reached")
14
- return `${provider} usage limit reached (usage-limit-reached). Check ChatGPT Settings > Usage, then try again later.`;
15
- if (modelFailureRetryable(failure))
16
- return `${provider} service unavailable. Try again shortly.`;
19
+ if (failure.rejection?.code === "usage-limit-reached") {
20
+ const subscription = provider === "anthropic"
21
+ ? " on your Claude subscription"
22
+ : provider === "openai"
23
+ ? " on your ChatGPT subscription"
24
+ : "";
25
+ const resetAt = failure.rejection.resetAt;
26
+ const reset = resetAt === undefined
27
+ ? ""
28
+ : `; resets at ${new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(resetAt)}`;
29
+ return `${name} usage limit reached${subscription}${reset}.`;
30
+ }
31
+ if (failure.rejection?.code === "rate-limit")
32
+ return `${name} rate limit reached; try again shortly.`;
33
+ if (failure.rejection?.code === "overloaded")
34
+ return `${name} is overloaded; try again shortly.`;
35
+ if (failure.rejection?.code === "provider_unavailable")
36
+ return `${name} is unavailable; try again shortly.`;
17
37
  if (failure.upstream !== undefined) {
18
38
  if (isContextOverflow(failure.upstream))
19
- return `${provider} request exceeds the context window. Compact the Session or choose another model.`;
39
+ return `${name} request exceeds the context window; compact the Session or choose another model.`;
20
40
  }
21
41
  if (failure.rejection?.code === "provider_rejected")
22
- return `${provider} rejected the model request (${failure.rejection.code}, HTTP ${String(failure.rejection.status)}). Check /login or choose another model with /model.`;
23
- return `${provider} model request failed without a provider rejection. Retry the request.`;
42
+ return `${name} rejected the request (HTTP ${String(failure.rejection.status)}); check your sign-in or choose another model.`;
43
+ return `${name} model request failed; try again.`;
24
44
  }
@@ -18,13 +18,19 @@ export type PrivateBunSqliteDatabase = {
18
18
  };
19
19
  /** The actual Node connection or Local's explicit native Bun adapter. */
20
20
  export type PrivateSqliteDatabase = DatabaseSync | PrivateBunSqliteDatabase;
21
+ /**
22
+ * How long a store's writer waits out a reader's shared lock before its
23
+ * commit fails. Status reads and the Board read a live store; a reader holds
24
+ * its lock for one statement, so this bounds a rare wait, never a hang.
25
+ */
26
+ export declare const storeWriteBusyTimeoutMs = 2000;
21
27
  /**
22
28
  * A listing owns this database until its existing finally close. Bun's
23
29
  * node:sqlite prepare/close leaves native statements alive until GC. Native
24
30
  * close(true) on the native Bun driver explicitly finalizes every statement
25
31
  * before releasing the connection, including when a read or decoding fails.
26
32
  */
27
- export declare function openPrivateReadOnlySqliteDatabase(path: string): PrivateSqliteDatabase;
33
+ export declare function openPrivateReadOnlySqliteDatabase(path: string, timeout?: number): PrivateSqliteDatabase;
28
34
  /** Writable connections own their statements through deterministic close. */
29
35
  export declare function openPrivateWritableSqliteDatabase(path: string, timeout: number): PrivateSqliteDatabase;
30
36
  export type PrivateSqliteFailureCode = "busy" | "locked" | "corrupt" | "not_a_database" | "other";
@@ -7,14 +7,26 @@ const decodeSqliteRow = Schema.decodeUnknownSync(sqliteRowSchema);
7
7
  const bunSqlite = Object.hasOwn(process.versions, "bun")
8
8
  ? await import("bun:sqlite")
9
9
  : undefined;
10
+ /**
11
+ * How long a reader waits out a live runtime's commit. Stores use a rollback
12
+ * journal, so a commit briefly locks readers out; a reader that gives up at
13
+ * once would read a busy store as an unreadable one.
14
+ */
15
+ const readBusyTimeoutMs = 250;
16
+ /**
17
+ * How long a store's writer waits out a reader's shared lock before its
18
+ * commit fails. Status reads and the Board read a live store; a reader holds
19
+ * its lock for one statement, so this bounds a rare wait, never a hang.
20
+ */
21
+ export const storeWriteBusyTimeoutMs = 2_000;
10
22
  /**
11
23
  * A listing owns this database until its existing finally close. Bun's
12
24
  * node:sqlite prepare/close leaves native statements alive until GC. Native
13
25
  * close(true) on the native Bun driver explicitly finalizes every statement
14
26
  * before releasing the connection, including when a read or decoding fails.
15
27
  */
16
- export function openPrivateReadOnlySqliteDatabase(path) {
17
- return openPrivateSqliteDatabase(path, true, 0);
28
+ export function openPrivateReadOnlySqliteDatabase(path, timeout = readBusyTimeoutMs) {
29
+ return openPrivateSqliteDatabase(path, true, timeout);
18
30
  }
19
31
  /** Writable connections own their statements through deterministic close. */
20
32
  export function openPrivateWritableSqliteDatabase(path, timeout) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/truffle",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Truffle: Fungi's local Agent runtime, bundled into the fungi command",
6
6
  "type": "module",
@@ -57,6 +57,14 @@
57
57
  "./rules": {
58
58
  "types": "./dist/private-local-exec-rules.d.ts",
59
59
  "import": "./dist/private-local-exec-rules.js"
60
+ },
61
+ "./board-agents": {
62
+ "types": "./dist/local-board-agents.d.ts",
63
+ "import": "./dist/local-board-agents.js"
64
+ },
65
+ "./board-shipping": {
66
+ "types": "./dist/local-board-shipping.d.ts",
67
+ "import": "./dist/local-board-shipping.js"
60
68
  }
61
69
  },
62
70
  "files": [
@@ -69,26 +77,26 @@
69
77
  "@cf-wasm/photon": "0.4.0",
70
78
  "@earendil-works/pi-ai": "1.3.0",
71
79
  "@effect/platform-node": "4.0.2",
72
- "@fungi.computer/agar": "0.4.0",
80
+ "@fungi.computer/agar": "0.5.0",
73
81
  "@fungi.computer/approvals": "0.2.0",
74
- "@fungi.computer/bird-dog": "0.2.1",
82
+ "@fungi.computer/bird-dog": "0.3.0",
75
83
  "@fungi.computer/cairn": "0.1.0",
76
84
  "@fungi.computer/capnweb": "0.12.0",
77
85
  "@fungi.computer/carrier": "0.2.0",
78
86
  "@fungi.computer/cordyceps": "0.4.0",
79
87
  "@fungi.computer/gills": "0.2.0",
80
- "@fungi.computer/graft": "0.1.3",
81
- "@fungi.computer/knapsack": "0.3.1",
82
- "@fungi.computer/lantern": "0.3.0",
83
- "@fungi.computer/mycelium": "0.2.2",
84
- "@fungi.computer/nacre": "0.0.3",
88
+ "@fungi.computer/graft": "0.1.4",
89
+ "@fungi.computer/knapsack": "0.4.0",
90
+ "@fungi.computer/lantern": "0.3.1",
91
+ "@fungi.computer/mycelium": "0.2.3",
92
+ "@fungi.computer/nacre": "0.1.0",
85
93
  "@fungi.computer/seam": "0.2.0",
86
- "@fungi.computer/shiitake": "0.5.0",
87
- "@fungi.computer/shiitake-extensions": "0.3.0",
94
+ "@fungi.computer/shiitake": "0.6.0",
95
+ "@fungi.computer/shiitake-extensions": "0.4.0",
88
96
  "@fungi.computer/veil": "0.2.1",
89
97
  "@fungi.computer/watchdog": "0.1.0",
90
- "@fungi.computer/whistle": "0.2.0",
91
- "@fungi.computer/zookeeper": "0.0.4",
98
+ "@fungi.computer/whistle": "0.3.0",
99
+ "@fungi.computer/zookeeper": "0.0.5",
92
100
  "@jellybrick/dbus-next": "0.11.4",
93
101
  "@modelcontextprotocol/client": "2.0.0",
94
102
  "@napi-rs/keyring": "2.1.2",
@@ -109,7 +117,7 @@
109
117
  },
110
118
  "devDependencies": {
111
119
  "@cloudflare/codemode": "0.5.1",
112
- "@fungi.computer/mule": "0.2.0",
120
+ "@fungi.computer/mule": "0.2.1",
113
121
  "@modelcontextprotocol/server": "2.0.0",
114
122
  "@types/node": "24.13.3",
115
123
  "@types/proper-lockfile": "4.1.4",
@@ -156,6 +164,7 @@
156
164
  "build": "if [ ! -f src/private-local-native-digests.js ]; then pnpm run build:native; fi && node scripts/generate-native-digests.mjs --verify && pnpm run clean && tsc -p tsconfig.build.json && cp src/private-local-native-digests.js dist/private-local-native-digests.js && node scripts/copy-seatbelt.mjs && esbuild src/internal/private-workerd-sandbox-worker.mjs --outfile=dist/internal/private-workerd-sandbox-worker.mjs --bundle --format=esm --platform=neutral --conditions=workerd,browser --target=es2024 --external:cloudflare:workers --metafile=dist/internal/sandbox-metafile.json --log-level=warning && esbuild src/internal/local-image-fit-worker.mjs --outfile=dist/internal/local-image-fit-worker.mjs --bundle --format=esm --platform=node --target=es2024 --metafile=dist/internal/image-fit-metafile.json --log-level=warning",
157
165
  "clean": "node --input-type=module --eval \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\"",
158
166
  "test": "pnpm run test:node && pnpm run test:merge && pnpm run test:stop-all",
167
+ "soak:memory": "bun scripts/service-memory-soak.mjs",
159
168
  "test:stop-all": "bun test ./test/private-local-stop-all.bun.test.ts",
160
169
  "test:full": "pnpm run test && pnpm run test:bun",
161
170
  "test:node": "../../node_modules/.bin/vitest run",