@fungi.computer/truffle 0.0.0-stage → 0.1.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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -2
  3. package/computerd-loopback.cjs +16 -0
  4. package/dist/auth-cli.d.ts +36 -0
  5. package/dist/auth-cli.js +351 -0
  6. package/dist/fungi-account-command.d.ts +18 -0
  7. package/dist/fungi-account-command.js +92 -0
  8. package/dist/index.d.ts +19 -0
  9. package/dist/index.js +17 -0
  10. package/dist/internal/private-workerd-sandbox-worker.mjs +27488 -0
  11. package/dist/internal/sandbox-metafile.json +6726 -0
  12. package/dist/linear-auth.d.ts +4 -0
  13. package/dist/linear-auth.js +72 -0
  14. package/dist/local-extensions.d.ts +97 -0
  15. package/dist/local-extensions.js +484 -0
  16. package/dist/local-login.d.ts +55 -0
  17. package/dist/local-login.js +167 -0
  18. package/dist/local-models.d.ts +14 -0
  19. package/dist/local-models.js +77 -0
  20. package/dist/local-skill-source.d.ts +6 -0
  21. package/dist/local-skill-source.js +357 -0
  22. package/dist/log-event.d.ts +2 -0
  23. package/dist/log-event.js +9 -0
  24. package/dist/private-bun-shiitake-host.d.ts +11 -0
  25. package/dist/private-bun-shiitake-host.js +66 -0
  26. package/dist/private-file-credential-store.d.ts +33 -0
  27. package/dist/private-file-credential-store.js +283 -0
  28. package/dist/private-files-patch.d.ts +3 -0
  29. package/dist/private-files-patch.js +86 -0
  30. package/dist/private-fungi-account.d.ts +235 -0
  31. package/dist/private-fungi-account.js +1149 -0
  32. package/dist/private-fungi-auth-error.d.ts +5 -0
  33. package/dist/private-fungi-auth-error.js +7 -0
  34. package/dist/private-fungi-mcp-connection.d.ts +22 -0
  35. package/dist/private-fungi-mcp-connection.js +143 -0
  36. package/dist/private-fungi-mcp-endpoint.d.ts +2 -0
  37. package/dist/private-fungi-mcp-endpoint.js +15 -0
  38. package/dist/private-fungi-mcp-selection.d.ts +14 -0
  39. package/dist/private-fungi-mcp-selection.js +36 -0
  40. package/dist/private-fungi-model-api.d.ts +6 -0
  41. package/dist/private-fungi-model-api.js +125 -0
  42. package/dist/private-fungi-model-catalog.d.ts +6 -0
  43. package/dist/private-fungi-model-catalog.js +78 -0
  44. package/dist/private-fungi-model-provider.d.ts +6 -0
  45. package/dist/private-fungi-model-provider.js +46 -0
  46. package/dist/private-fungi-pending-logouts.d.ts +34 -0
  47. package/dist/private-fungi-pending-logouts.js +136 -0
  48. package/dist/private-invocation-computer.d.ts +37 -0
  49. package/dist/private-invocation-computer.js +459 -0
  50. package/dist/private-invocation-files.d.ts +15 -0
  51. package/dist/private-invocation-files.js +449 -0
  52. package/dist/private-local-acp-stdio.d.ts +17 -0
  53. package/dist/private-local-acp-stdio.js +142 -0
  54. package/dist/private-local-auth-rpc.d.ts +69 -0
  55. package/dist/private-local-auth-rpc.js +273 -0
  56. package/dist/private-local-computer-attach.d.ts +49 -0
  57. package/dist/private-local-computer-attach.js +216 -0
  58. package/dist/private-local-computer-backend.d.ts +282 -0
  59. package/dist/private-local-computer-backend.js +402 -0
  60. package/dist/private-local-computer-processes.d.ts +199 -0
  61. package/dist/private-local-computer-processes.js +589 -0
  62. package/dist/private-local-default-model.d.ts +11 -0
  63. package/dist/private-local-default-model.js +45 -0
  64. package/dist/private-local-delegation.d.ts +29 -0
  65. package/dist/private-local-delegation.js +78 -0
  66. package/dist/private-local-effective-model.d.ts +19 -0
  67. package/dist/private-local-effective-model.js +14 -0
  68. package/dist/private-local-exec-launch.d.ts +41 -0
  69. package/dist/private-local-exec-launch.js +51 -0
  70. package/dist/private-local-exec-path.d.ts +6 -0
  71. package/dist/private-local-exec-path.js +40 -0
  72. package/dist/private-local-git-exclude.d.ts +2 -0
  73. package/dist/private-local-git-exclude.js +33 -0
  74. package/dist/private-local-http-dispatch.d.ts +5 -0
  75. package/dist/private-local-http-dispatch.js +14 -0
  76. package/dist/private-local-mail.d.ts +11 -0
  77. package/dist/private-local-mail.js +69 -0
  78. package/dist/private-local-model-settings.d.ts +5 -0
  79. package/dist/private-local-model-settings.js +31 -0
  80. package/dist/private-local-runtime-host.d.ts +141 -0
  81. package/dist/private-local-runtime-host.js +988 -0
  82. package/dist/private-local-service-contract.d.ts +90 -0
  83. package/dist/private-local-service-contract.js +1 -0
  84. package/dist/private-local-service-discovery.d.ts +101 -0
  85. package/dist/private-local-service-discovery.js +221 -0
  86. package/dist/private-local-service-ids.d.ts +3 -0
  87. package/dist/private-local-service-ids.js +8 -0
  88. package/dist/private-local-service-listener.d.ts +15 -0
  89. package/dist/private-local-service-listener.js +211 -0
  90. package/dist/private-local-service-owner.d.ts +1567 -0
  91. package/dist/private-local-service-owner.js +371 -0
  92. package/dist/private-local-service-process.d.ts +1 -0
  93. package/dist/private-local-service-process.js +6 -0
  94. package/dist/private-local-service.d.ts +17 -0
  95. package/dist/private-local-service.js +330 -0
  96. package/dist/private-local-session-discovery.d.ts +19 -0
  97. package/dist/private-local-session-discovery.js +157 -0
  98. package/dist/private-local-shiitake-host.d.ts +44 -0
  99. package/dist/private-local-shiitake-host.js +143 -0
  100. package/dist/private-local-subagents.d.ts +27 -0
  101. package/dist/private-local-subagents.js +77 -0
  102. package/dist/private-local-worker-server.d.ts +15 -0
  103. package/dist/private-local-worker-server.js +136 -0
  104. package/dist/private-sqlite-owner.d.ts +7 -0
  105. package/dist/private-sqlite-owner.js +103 -0
  106. package/dist/private-team-computers.d.ts +38 -0
  107. package/dist/private-team-computers.js +101 -0
  108. package/dist/private-workerd-sandbox.d.ts +3 -0
  109. package/dist/private-workerd-sandbox.js +347 -0
  110. package/dist/team-apps.d.ts +24 -0
  111. package/dist/team-apps.js +123 -0
  112. package/dist/tracker-git-credential.d.ts +15 -0
  113. package/dist/tracker-git-credential.js +87 -0
  114. package/native/bin/build.json +4037 -0
  115. package/native/bin/fungi-apply-patch +0 -0
  116. package/package.json +148 -3
@@ -0,0 +1,55 @@
1
+ import type { AuthInteraction, Provider } from "@earendil-works/pi-ai";
2
+ import type { WhistleInteraction } from "@fungi.computer/whistle";
3
+ import type { PrivateLocalServiceManager } from "./private-local-service.js";
4
+ /** Display order for popular model providers; labels remain owned by Pi. */
5
+ export declare const localPopularProviderIds: readonly string[];
6
+ /** One sign-in method, in the provider registry's vocabulary. */
7
+ export type LocalLoginMethod = Readonly<{
8
+ kind: "api_key" | "subscription";
9
+ label: string;
10
+ }>;
11
+ /** One provider Local can sign in to, derived from Pi's provider data. */
12
+ export type LocalLoginProvider = Readonly<{
13
+ id: string;
14
+ name: string;
15
+ methods: readonly LocalLoginMethod[];
16
+ }>;
17
+ /**
18
+ * Describe Pi's providers as registry data. Pi's OAuth sign-in is the
19
+ * provider's consumer subscription; ambient-only API keys (no interactive
20
+ * setup) are not offered. Callers pass the policy-filtered collection from
21
+ * `createPrivateModels`.
22
+ */
23
+ export declare function localLoginProviders(providers: readonly Provider[]): readonly LocalLoginProvider[];
24
+ /**
25
+ * Present Pi's provider sign-in through a Whistle interaction. `option`
26
+ * answers Pi's own method question (e.g. `device_code`) without asking. If
27
+ * the flow's first event -- a prompt or a notification -- is not a `select`
28
+ * offering that option, the flow never asks the question at all (e.g.
29
+ * Anthropic, which has only one sign-in method); cancel before relaying
30
+ * anything to the host, so no browser URL or other handoff is ever shown
31
+ * for a method the provider silently ignored.
32
+ */
33
+ export declare function authInteractionFor(interaction: WhistleInteraction, option?: string): AuthInteraction;
34
+ /** The Local credential owner behind provider-agnostic account commands. */
35
+ export declare function localAccountOwner(manager: Pick<PrivateLocalServiceManager, "auth">, providers: () => readonly Provider[]): {
36
+ popularProviderIds: readonly string[];
37
+ access: (signal: AbortSignal) => Promise<Readonly<{
38
+ providerId: string;
39
+ providerName: string;
40
+ state: "configured";
41
+ type: import("@earendil-works/pi-ai").AuthType;
42
+ }>[]>;
43
+ providers: () => Promise<readonly Readonly<{
44
+ id: string;
45
+ name: string;
46
+ methods: readonly LocalLoginMethod[];
47
+ }>[]>;
48
+ login: (request: Readonly<{
49
+ providerId: string;
50
+ method: LocalLoginMethod["kind"];
51
+ option?: string;
52
+ }>, interaction: WhistleInteraction) => Promise<void>;
53
+ logout: (providerId: string, interaction: WhistleInteraction) => Promise<void>;
54
+ status: (signal: AbortSignal) => Promise<string>;
55
+ };
@@ -0,0 +1,167 @@
1
+ /** Display order for popular model providers; labels remain owned by Pi. */
2
+ export const localPopularProviderIds = Object.freeze([
3
+ "fungi",
4
+ "openai",
5
+ "anthropic",
6
+ "deepseek",
7
+ "kimi-coding",
8
+ "openrouter",
9
+ ]);
10
+ /**
11
+ * Describe Pi's providers as registry data. Pi's OAuth sign-in is the
12
+ * provider's consumer subscription; ambient-only API keys (no interactive
13
+ * setup) are not offered. Callers pass the policy-filtered collection from
14
+ * `createPrivateModels`.
15
+ */
16
+ export function localLoginProviders(providers) {
17
+ return providers.flatMap((provider) => {
18
+ const methods = [];
19
+ if (provider.auth.apiKey?.login !== undefined)
20
+ methods.push({ kind: "api_key", label: provider.auth.apiKey.name });
21
+ if (provider.auth.oauth !== undefined)
22
+ methods.push({
23
+ kind: "subscription",
24
+ label: provider.auth.oauth.loginLabel ?? provider.auth.oauth.name,
25
+ });
26
+ return methods.length === 0
27
+ ? []
28
+ : [{ id: provider.id, name: provider.name, methods }];
29
+ });
30
+ }
31
+ function notice(event) {
32
+ switch (event.type) {
33
+ case "auth_url":
34
+ return event.instructions === undefined
35
+ ? { type: "handoff", url: event.url }
36
+ : { type: "handoff", url: event.url, message: event.instructions };
37
+ case "device_code":
38
+ return {
39
+ type: "handoff",
40
+ url: event.verificationUri,
41
+ code: event.userCode,
42
+ };
43
+ case "info":
44
+ return event.links === undefined
45
+ ? { type: "info", message: event.message }
46
+ : {
47
+ type: "info",
48
+ message: event.message,
49
+ links: event.links.map((link) => link.label === undefined
50
+ ? { url: link.url }
51
+ : { url: link.url, label: link.label }),
52
+ };
53
+ case "progress":
54
+ return { type: "progress", message: event.message };
55
+ }
56
+ }
57
+ async function ask(interaction, prompt) {
58
+ const options = prompt.signal === undefined ? {} : { signal: prompt.signal };
59
+ switch (prompt.type) {
60
+ case "select":
61
+ return interaction.prompt({
62
+ type: "choice",
63
+ message: prompt.message,
64
+ options: prompt.options.map((option) => option.description === undefined
65
+ ? { value: option.id, label: option.label }
66
+ : {
67
+ value: option.id,
68
+ label: option.label,
69
+ description: option.description,
70
+ }),
71
+ }, options);
72
+ case "manual_code": {
73
+ // Generic across every provider that falls back to a pasted code or
74
+ // redirect URL (OpenAI, Anthropic): the provider's own
75
+ // message explains what to paste; this adds the SSH hint once, here,
76
+ // instead of baking it into one provider's registry label.
77
+ const message = `${prompt.message} If you're connecting over SSH, paste the URL here instead of opening a browser.`;
78
+ return interaction.prompt(prompt.placeholder === undefined
79
+ ? { type: "text", message }
80
+ : { type: "text", message, placeholder: prompt.placeholder }, options);
81
+ }
82
+ case "secret":
83
+ case "text":
84
+ return interaction.prompt(prompt.placeholder === undefined
85
+ ? {
86
+ type: "text",
87
+ message: prompt.message,
88
+ secret: prompt.type === "secret",
89
+ }
90
+ : {
91
+ type: "text",
92
+ message: prompt.message,
93
+ placeholder: prompt.placeholder,
94
+ secret: prompt.type === "secret",
95
+ }, options);
96
+ }
97
+ }
98
+ const methodNotOffered = "The provider did not offer that sign-in method";
99
+ /**
100
+ * Present Pi's provider sign-in through a Whistle interaction. `option`
101
+ * answers Pi's own method question (e.g. `device_code`) without asking. If
102
+ * the flow's first event -- a prompt or a notification -- is not a `select`
103
+ * offering that option, the flow never asks the question at all (e.g.
104
+ * Anthropic, which has only one sign-in method); cancel before relaying
105
+ * anything to the host, so no browser URL or other handoff is ever shown
106
+ * for a method the provider silently ignored.
107
+ */
108
+ export function authInteractionFor(interaction, option) {
109
+ let preset = option;
110
+ return {
111
+ signal: interaction.signal,
112
+ prompt: (prompt) => {
113
+ if (preset === undefined)
114
+ return ask(interaction, prompt);
115
+ if (prompt.type !== "select") {
116
+ preset = undefined;
117
+ return Promise.reject(new Error(methodNotOffered));
118
+ }
119
+ const chosen = prompt.options.find((one) => one.id === preset);
120
+ preset = undefined;
121
+ if (chosen === undefined)
122
+ return Promise.reject(new Error(methodNotOffered));
123
+ return Promise.resolve(chosen.id);
124
+ },
125
+ notify: (event) => {
126
+ if (preset !== undefined) {
127
+ preset = undefined;
128
+ throw new Error(methodNotOffered);
129
+ }
130
+ interaction.notify(notice(event));
131
+ },
132
+ };
133
+ }
134
+ /** The Local credential owner behind provider-agnostic account commands. */
135
+ export function localAccountOwner(manager, providers) {
136
+ const access = async (signal) => {
137
+ const result = await manager.auth({ command: "status" }, {
138
+ signal,
139
+ prompt: () => Promise.reject(new Error("Account status cannot prompt")),
140
+ notify: () => undefined,
141
+ });
142
+ if (result.kind !== "status")
143
+ throw new Error("Account status returned a non-status result");
144
+ return result.providers.filter((provider) => provider.state === "configured");
145
+ };
146
+ return {
147
+ popularProviderIds: localPopularProviderIds,
148
+ access,
149
+ providers: () => Promise.resolve(localLoginProviders(providers())),
150
+ login: async (request, interaction) => {
151
+ await manager.auth({
152
+ command: "login",
153
+ provider: request.providerId,
154
+ authType: request.method === "subscription" ? "oauth" : "api_key",
155
+ }, authInteractionFor(interaction, request.option));
156
+ },
157
+ logout: async (providerId, interaction) => {
158
+ await manager.auth({ command: "logout", provider: providerId }, authInteractionFor(interaction));
159
+ },
160
+ status: async (signal) => {
161
+ const configured = (await access(signal)).map((provider) => `${provider.providerName}: configured (${provider.type})`);
162
+ return configured.length === 0
163
+ ? "No model provider is signed in. Use /login to add one."
164
+ : configured.join("\n");
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,14 @@
1
+ import { type MutableModels, type ThinkingLevel } from "@earendil-works/pi-ai";
2
+ /** Register Pi's standalone OAuth modules on the same instance Local uses. */
3
+ export declare function registerPrivateBundledModelOAuthFlows(): void;
4
+ /** Create the private local Pi collection with Pi's policy-permitted providers. */
5
+ export declare function createPrivateModels(credentialPath?: string): MutableModels;
6
+ export type PrivateLocalModelChoice = Readonly<{
7
+ providerId: string;
8
+ providerName: string;
9
+ id: string;
10
+ name: string;
11
+ reasoning: readonly ThinkingLevel[];
12
+ }>;
13
+ /** Refresh configured dynamic catalogs before exposing the bounded owner picker. */
14
+ export declare function listPrivateModels(credentialPath?: string): Promise<readonly PrivateLocalModelChoice[]>;
@@ -0,0 +1,77 @@
1
+ import { createModels, getSupportedThinkingLevels, } from "@earendil-works/pi-ai";
2
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
3
+ import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth";
4
+ import { loginAllowed, subscriptionKillSwitchFrom, } from "@fungi.computer/veil/model-credentials";
5
+ import { createFungiAccountCredentialStore } from "./private-fungi-account.js";
6
+ import { createFungiModelProvider } from "./private-fungi-model-provider.js";
7
+ /** Register Pi's standalone OAuth modules on the same instance Local uses. */
8
+ export function registerPrivateBundledModelOAuthFlows() {
9
+ registerBunOAuthFlows();
10
+ }
11
+ /** Local credentials belong to the one person who owns this machine. */
12
+ const allowedHere = (providerId, method) => loginAllowed({ providerId, method, host: "local", scope: "user" },
13
+ // FUNGI_SUBSCRIPTION_KILL=anthropic,openai turns logins off without a release.
14
+ subscriptionKillSwitchFrom(process.env.FUNGI_SUBSCRIPTION_KILL));
15
+ /** Keep only the login methods Veil's provider policy allows on this machine. */
16
+ function locallyPermitted(provider) {
17
+ const apiKey = allowedHere(provider.id, "api_key")
18
+ ? provider.auth.apiKey
19
+ : undefined;
20
+ const oauth = allowedHere(provider.id, "subscription")
21
+ ? provider.auth.oauth
22
+ : undefined;
23
+ if (apiKey === undefined && oauth === undefined)
24
+ return undefined;
25
+ const auth = {};
26
+ if (apiKey !== undefined)
27
+ auth.apiKey = apiKey;
28
+ if (oauth !== undefined)
29
+ auth.oauth = oauth;
30
+ return { ...provider, auth };
31
+ }
32
+ /** Create the private local Pi collection with Pi's policy-permitted providers. */
33
+ export function createPrivateModels(credentialPath) {
34
+ const options = {};
35
+ if (credentialPath !== undefined)
36
+ options.credentialPath = credentialPath;
37
+ const credentials = createFungiAccountCredentialStore(options);
38
+ const models = createModels({ credentials });
39
+ for (const provider of builtinProviders()) {
40
+ const permitted = locallyPermitted(provider);
41
+ if (permitted !== undefined)
42
+ models.setProvider(permitted);
43
+ }
44
+ const fungi = locallyPermitted(createFungiModelProvider(options));
45
+ if (fungi !== undefined)
46
+ models.setProvider(fungi);
47
+ return models;
48
+ }
49
+ /** Refresh configured dynamic catalogs before exposing the bounded owner picker. */
50
+ export async function listPrivateModels(credentialPath) {
51
+ const models = createPrivateModels(credentialPath);
52
+ const result = await models.refresh();
53
+ if (result.aborted)
54
+ throw new Error("Model catalog refresh was cancelled");
55
+ const fungiFailure = result.errors.get("fungi");
56
+ if (fungiFailure !== undefined)
57
+ throw fungiFailure;
58
+ if (result.errors.size > 0)
59
+ throw new AggregateError([...result.errors.values()], "Could not refresh model catalogs");
60
+ return Object.freeze(models
61
+ .getProviders()
62
+ .flatMap((provider) => models.getModels(provider.id).map((model) => ({
63
+ providerId: provider.id,
64
+ providerName: provider.name,
65
+ id: model.id,
66
+ name: model.name,
67
+ reasoning: getSupportedThinkingLevels(model).filter((level) => level !== "off"),
68
+ })))
69
+ .sort((left, right) => left.providerName.localeCompare(right.providerName) ||
70
+ left.name.localeCompare(right.name) ||
71
+ left.providerId.localeCompare(right.providerId) ||
72
+ left.id.localeCompare(right.id))
73
+ .map((choice) => Object.freeze({
74
+ ...choice,
75
+ reasoning: Object.freeze(choice.reasoning),
76
+ })));
77
+ }
@@ -0,0 +1,6 @@
1
+ import type { SkillSource } from "@fungi.computer/shiitake-extensions/skills/source";
2
+ /** Discover project-first skills without widening the invocation Files jail. */
3
+ export declare function createLocalSkillSource(options: Readonly<{
4
+ cwd: string;
5
+ home?: string;
6
+ }>): SkillSource;
@@ -0,0 +1,357 @@
1
+ import { open, opendir, realpath, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { z } from "zod";
6
+ const maximumSkillCount = 64;
7
+ const maximumCatalogBytes = 16_000;
8
+ const maximumFileBytes = 128 * 1_024;
9
+ const maximumDirectoryEntries = 512;
10
+ const maximumSupportingDepth = 16;
11
+ const maximumDiscoveryDepth = 16;
12
+ const utf8 = new TextEncoder();
13
+ const nameSchema = z
14
+ .string()
15
+ .min(1)
16
+ .max(64)
17
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u);
18
+ const frontmatterSchema = z.object({
19
+ name: z.string().optional(),
20
+ description: z.string().optional(),
21
+ "disable-model-invocation": z
22
+ .unknown()
23
+ .default(false)
24
+ .transform((value) => value === true),
25
+ });
26
+ const fileSchema = z
27
+ .string()
28
+ .min(1)
29
+ .max(4_096)
30
+ .refine((file) => !isAbsolute(file) &&
31
+ !file.includes("\\") &&
32
+ !file.includes("\0") &&
33
+ file
34
+ .split("/")
35
+ .every((part) => part !== "" && part !== "." && part !== ".."), "Skill file must remain a relative path inside its skill directory");
36
+ function absent(cause) {
37
+ return cause instanceof Error && "code" in cause && cause.code === "ENOENT";
38
+ }
39
+ function contained(root, path) {
40
+ const within = relative(root, path);
41
+ if (isAbsolute(within) ||
42
+ within === ".." ||
43
+ within.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`))
44
+ throw new Error("Skill path escapes its directory");
45
+ return path;
46
+ }
47
+ async function readBounded(path, signal) {
48
+ signal.throwIfAborted();
49
+ const handle = await open(path, "r");
50
+ try {
51
+ if (!(await handle.stat()).isFile())
52
+ throw new Error("Skill path is not a regular file");
53
+ const bytes = Buffer.alloc(maximumFileBytes + 1);
54
+ let size = 0;
55
+ while (size < bytes.length) {
56
+ signal.throwIfAborted();
57
+ const page = await handle.read(bytes, size, bytes.length - size, size);
58
+ if (page.bytesRead === 0)
59
+ break;
60
+ size += page.bytesRead;
61
+ }
62
+ if (size > maximumFileBytes)
63
+ throw new Error("Skill file exceeds the 128 KiB bound");
64
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, size));
65
+ }
66
+ finally {
67
+ await handle.close();
68
+ }
69
+ }
70
+ class SkillSourceError extends Error {
71
+ }
72
+ function diagnostic(path, reason, message, cause = new Error(message)) {
73
+ return { path, reason, message, cause };
74
+ }
75
+ function explanation(cause, fallback) {
76
+ return cause instanceof SkillSourceError ? cause.message : fallback;
77
+ }
78
+ function parseSkill(content, directory) {
79
+ const normalized = content.replace(/\r\n?/gu, "\n");
80
+ const match = /^---\n([\s\S]*?)\n---(?:\n|$)/u.exec(normalized);
81
+ if (!match)
82
+ throw new SkillSourceError("SKILL.md requires YAML frontmatter");
83
+ let frontmatter;
84
+ try {
85
+ frontmatter = parseYaml(match[1] ?? "");
86
+ }
87
+ catch (cause) {
88
+ throw new SkillSourceError("Skill frontmatter is not valid YAML", {
89
+ cause,
90
+ });
91
+ }
92
+ const result = frontmatterSchema.safeParse(frontmatter);
93
+ if (!result.success)
94
+ throw new SkillSourceError("Skill frontmatter has invalid field types", {
95
+ cause: result.error,
96
+ });
97
+ const metadata = result.data;
98
+ if (metadata.description === undefined || metadata.description.trim() === "")
99
+ throw new SkillSourceError("description is required");
100
+ const name = metadata.name === ""
101
+ ? basename(directory)
102
+ : (metadata.name ?? basename(directory));
103
+ return {
104
+ metadata: { ...metadata, name, description: metadata.description },
105
+ body: normalized.slice(match[0].length),
106
+ };
107
+ }
108
+ async function discoveryRoots(cwd) {
109
+ const project = [join(cwd, ".fungi/skills")];
110
+ let ancestor = cwd;
111
+ for (;;) {
112
+ project.push(join(ancestor, ".agents/skills"));
113
+ try {
114
+ await stat(join(ancestor, ".git"));
115
+ break;
116
+ }
117
+ catch (cause) {
118
+ if (!absent(cause))
119
+ throw cause;
120
+ }
121
+ const parent = dirname(ancestor);
122
+ if (parent === ancestor)
123
+ break;
124
+ ancestor = parent;
125
+ }
126
+ return project;
127
+ }
128
+ async function configuredRoots(path, base, home, signal, diagnostics) {
129
+ const settingsSchema = z.object({
130
+ skills: z.array(z.string().trim().min(1)).max(64).default([]),
131
+ });
132
+ try {
133
+ const settings = settingsSchema.parse(JSON.parse(await readBounded(path, signal)));
134
+ return settings.skills.map((folder) => folder === "~"
135
+ ? home
136
+ : folder.startsWith("~/")
137
+ ? resolve(home, folder.slice(2))
138
+ : resolve(base, folder));
139
+ }
140
+ catch (cause) {
141
+ signal.throwIfAborted();
142
+ if (!absent(cause))
143
+ diagnostics.push(diagnostic(path, "invalid", "Local settings must be valid JSON with a skills list of extra folders", cause));
144
+ return [];
145
+ }
146
+ }
147
+ /** Discover project-first skills without widening the invocation Files jail. */
148
+ export function createLocalSkillSource(options) {
149
+ const cwd = resolve(options.cwd);
150
+ const home = resolve(options.home ?? homedir());
151
+ const catalog = async (signal) => {
152
+ const skills = [];
153
+ const diagnostics = [];
154
+ const names = new Set();
155
+ const paths = new Set();
156
+ const projectExtras = await configuredRoots(join(cwd, ".fungi/settings.json"), cwd, home, signal, diagnostics);
157
+ const personalExtras = await configuredRoots(join(home, ".fungi/settings.json"), join(home, ".fungi"), home, signal, diagnostics);
158
+ const extras = new Set([...projectExtras, ...personalExtras]);
159
+ const roots = [
160
+ ...(await discoveryRoots(cwd)),
161
+ ...projectExtras,
162
+ join(home, ".fungi/skills"),
163
+ join(home, ".agents/skills"),
164
+ ...personalExtras,
165
+ ];
166
+ const visitedRoots = new Set();
167
+ for (const path of roots) {
168
+ signal.throwIfAborted();
169
+ let root;
170
+ try {
171
+ root = await realpath(path);
172
+ }
173
+ catch (cause) {
174
+ signal.throwIfAborted();
175
+ if (!absent(cause) || extras.has(path))
176
+ diagnostics.push({
177
+ path,
178
+ reason: "unavailable",
179
+ message: "Skill root is unavailable",
180
+ cause,
181
+ });
182
+ continue;
183
+ }
184
+ if (visitedRoots.has(root))
185
+ continue;
186
+ visitedRoots.add(root);
187
+ const visited = new Set();
188
+ let entries = 0;
189
+ let found = 0;
190
+ const before = diagnostics.length;
191
+ const walk = async (directory, depth) => {
192
+ signal.throwIfAborted();
193
+ if (depth > maximumDiscoveryDepth) {
194
+ diagnostics.push(diagnostic(directory, "bound", "Skill discovery exceeds its 16-level depth bound"));
195
+ return;
196
+ }
197
+ if (visited.has(directory))
198
+ return;
199
+ visited.add(directory);
200
+ const skillPath = join(directory, "SKILL.md");
201
+ let location;
202
+ try {
203
+ location = contained(directory, await realpath(skillPath));
204
+ }
205
+ catch (cause) {
206
+ signal.throwIfAborted();
207
+ if (!absent(cause)) {
208
+ diagnostics.push({
209
+ path: skillPath,
210
+ reason: "invalid",
211
+ message: "Skill file escapes its directory or cannot be read",
212
+ cause,
213
+ });
214
+ return;
215
+ }
216
+ }
217
+ if (location !== undefined) {
218
+ found++;
219
+ try {
220
+ if (paths.has(location))
221
+ return;
222
+ paths.add(location);
223
+ const { metadata } = parseSkill(await readBounded(location, signal), directory);
224
+ if (!nameSchema.safeParse(metadata.name).success ||
225
+ metadata.name !== basename(directory))
226
+ diagnostics.push(diagnostic(skillPath, "warning", "Skill name should match its directory, use lowercase letters, digits and single hyphens, and be at most 64 characters"));
227
+ if (metadata.description.length > 1_024)
228
+ diagnostics.push(diagnostic(skillPath, "warning", "Skill description exceeds the recommended 1024 characters"));
229
+ if (names.has(metadata.name)) {
230
+ diagnostics.push(diagnostic(skillPath, "collision", "Earlier skill with this name wins"));
231
+ return;
232
+ }
233
+ names.add(metadata.name);
234
+ if (metadata["disable-model-invocation"]) {
235
+ diagnostics.push(diagnostic(skillPath, "skipped", "Skill disables model invocation"));
236
+ return;
237
+ }
238
+ const skill = {
239
+ name: metadata.name,
240
+ description: metadata.description,
241
+ location,
242
+ };
243
+ if (skills.length >= maximumSkillCount ||
244
+ utf8.encode(JSON.stringify([...skills, skill])).byteLength >
245
+ maximumCatalogBytes) {
246
+ diagnostics.push(diagnostic(skillPath, "bound", "Skill catalog exceeds its 64-skill or 16 KB bound"));
247
+ return;
248
+ }
249
+ skills.push(skill);
250
+ }
251
+ catch (cause) {
252
+ signal.throwIfAborted();
253
+ diagnostics.push({
254
+ path: skillPath,
255
+ reason: "invalid",
256
+ message: explanation(cause, "Skill metadata or file is invalid"),
257
+ cause,
258
+ });
259
+ }
260
+ return;
261
+ }
262
+ const children = [];
263
+ for await (const entry of await opendir(directory)) {
264
+ signal.throwIfAborted();
265
+ if (++entries > maximumDirectoryEntries)
266
+ throw new SkillSourceError("Skill discovery exceeds its 512-entry bound");
267
+ if (entry.name.startsWith(".") || entry.name === "node_modules") {
268
+ if (entry.isDirectory() || entry.isSymbolicLink())
269
+ diagnostics.push(diagnostic(join(directory, entry.name), "skipped", "Dot directories and node_modules are not searched"));
270
+ continue;
271
+ }
272
+ children.push(entry.name);
273
+ }
274
+ for (const child of children.sort()) {
275
+ const candidate = join(directory, child);
276
+ try {
277
+ const canonical = contained(root, await realpath(candidate));
278
+ if ((await stat(canonical)).isDirectory())
279
+ await walk(canonical, depth + 1);
280
+ }
281
+ catch (cause) {
282
+ signal.throwIfAborted();
283
+ if (cause instanceof SkillSourceError)
284
+ throw cause;
285
+ diagnostics.push(diagnostic(join(candidate, "SKILL.md"), "invalid", "Skill entry escapes its root or is unavailable", cause));
286
+ }
287
+ }
288
+ };
289
+ try {
290
+ await walk(root, 0);
291
+ if (found === 0 && diagnostics.length === before)
292
+ diagnostics.push(diagnostic(path, "empty", entries === 0
293
+ ? "Skill root is empty"
294
+ : "Skill root contains no SKILL.md files"));
295
+ }
296
+ catch (cause) {
297
+ signal.throwIfAborted();
298
+ diagnostics.push({
299
+ path,
300
+ reason: cause instanceof SkillSourceError ? "bound" : "unavailable",
301
+ message: explanation(cause, "Skill root cannot be searched"),
302
+ cause,
303
+ });
304
+ }
305
+ }
306
+ return { skills, diagnostics };
307
+ };
308
+ return {
309
+ catalog,
310
+ read: async (name, file, signal) => {
311
+ z.string().min(1).parse(name);
312
+ const found = (await catalog(signal)).skills.find((skill) => skill.name === name);
313
+ if (!found)
314
+ throw new Error(`Unknown or hidden skill: ${name}`);
315
+ const baseDirectory = dirname(found.location);
316
+ const selected = file === undefined ? "SKILL.md" : fileSchema.parse(file);
317
+ const path = contained(baseDirectory, await realpath(join(baseDirectory, selected)));
318
+ const content = await readBounded(path, signal);
319
+ if (selected !== "SKILL.md")
320
+ return {
321
+ name,
322
+ file: selected,
323
+ content,
324
+ baseDirectory,
325
+ supportingFiles: [],
326
+ };
327
+ const parsed = parseSkill(content, baseDirectory);
328
+ if (parsed.metadata["disable-model-invocation"])
329
+ throw new Error(`Hidden skill: ${name}`);
330
+ const supportingFiles = [];
331
+ let entries = 0;
332
+ const walk = async (directory, depth) => {
333
+ if (depth > maximumSupportingDepth)
334
+ throw new Error("Skill supporting files exceed depth bound");
335
+ for await (const entry of await opendir(directory)) {
336
+ signal.throwIfAborted();
337
+ if (++entries > maximumDirectoryEntries)
338
+ throw new Error("Skill supporting files exceed entry bound");
339
+ const path = contained(baseDirectory, await realpath(join(directory, entry.name)));
340
+ // Directory symlinks are not traversed; direct reads still enforce realpath containment.
341
+ if (entry.isDirectory())
342
+ await walk(path, depth + 1);
343
+ else if ((await stat(path)).isFile() && path !== found.location)
344
+ supportingFiles.push(relative(baseDirectory, path));
345
+ }
346
+ };
347
+ await walk(baseDirectory, 0);
348
+ return {
349
+ name,
350
+ file: selected,
351
+ content: parsed.body,
352
+ baseDirectory,
353
+ supportingFiles: supportingFiles.sort(),
354
+ };
355
+ },
356
+ };
357
+ }
@@ -0,0 +1,2 @@
1
+ /** Retain the original Local failure without printing exception or input text. */
2
+ export declare function logEvent(operation: string, cause: unknown): void;