@fastagent-sh/fastagent 0.12.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.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.js +14 -0
- package/dist/channels/body.d.ts +9 -0
- package/dist/channels/body.js +29 -0
- package/dist/channels/github/github.d.ts +31 -0
- package/dist/channels/github/github.js +80 -0
- package/dist/channels/github/scaffold/channel.ts +25 -0
- package/dist/channels/http.d.ts +13 -0
- package/dist/channels/http.js +161 -0
- package/dist/channels/respond.d.ts +5 -0
- package/dist/channels/respond.js +3 -0
- package/dist/channels/telegram/context-buffer.d.ts +59 -0
- package/dist/channels/telegram/context-buffer.js +139 -0
- package/dist/channels/telegram/invoke-turn.d.ts +53 -0
- package/dist/channels/telegram/invoke-turn.js +127 -0
- package/dist/channels/telegram/parse.d.ts +160 -0
- package/dist/channels/telegram/parse.js +153 -0
- package/dist/channels/telegram/preview.d.ts +22 -0
- package/dist/channels/telegram/preview.js +266 -0
- package/dist/channels/telegram/register-webhook.d.ts +13 -0
- package/dist/channels/telegram/register-webhook.js +73 -0
- package/dist/channels/telegram/scaffold/channel.ts +25 -0
- package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
- package/dist/channels/telegram/state.d.ts +9 -0
- package/dist/channels/telegram/state.js +58 -0
- package/dist/channels/telegram/telegram-api.d.ts +77 -0
- package/dist/channels/telegram/telegram-api.js +345 -0
- package/dist/channels/telegram/telegram.d.ts +35 -0
- package/dist/channels/telegram/telegram.js +352 -0
- package/dist/channels/telegram/turn-queue.d.ts +21 -0
- package/dist/channels/telegram/turn-queue.js +47 -0
- package/dist/channels/telegram/turn-store.d.ts +38 -0
- package/dist/channels/telegram/turn-store.js +150 -0
- package/dist/cli-auth.d.ts +24 -0
- package/dist/cli-auth.js +30 -0
- package/dist/cli-models.d.ts +11 -0
- package/dist/cli-models.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1288 -0
- package/dist/collect.d.ts +20 -0
- package/dist/collect.js +27 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +8 -0
- package/dist/deploy/container.d.ts +56 -0
- package/dist/deploy/container.js +169 -0
- package/dist/deploy/fly/plan.d.ts +65 -0
- package/dist/deploy/fly/plan.js +142 -0
- package/dist/deploy/fly/run.d.ts +51 -0
- package/dist/deploy/fly/run.js +89 -0
- package/dist/deploy/preflight.d.ts +59 -0
- package/dist/deploy/preflight.js +191 -0
- package/dist/deploy/railway/plan.d.ts +48 -0
- package/dist/deploy/railway/plan.js +91 -0
- package/dist/deploy/railway/run.d.ts +81 -0
- package/dist/deploy/railway/run.js +176 -0
- package/dist/deploy/runner.d.ts +18 -0
- package/dist/deploy/runner.js +25 -0
- package/dist/deploy/secrets.d.ts +49 -0
- package/dist/deploy/secrets.js +88 -0
- package/dist/dev-supervisor.d.ts +14 -0
- package/dist/dev-supervisor.js +158 -0
- package/dist/engines/pi/auth.d.ts +17 -0
- package/dist/engines/pi/auth.js +118 -0
- package/dist/engines/pi/channel.d.ts +31 -0
- package/dist/engines/pi/channel.js +113 -0
- package/dist/engines/pi/chat.d.ts +19 -0
- package/dist/engines/pi/chat.js +200 -0
- package/dist/engines/pi/config.d.ts +110 -0
- package/dist/engines/pi/config.js +285 -0
- package/dist/engines/pi/create.d.ts +127 -0
- package/dist/engines/pi/create.js +222 -0
- package/dist/engines/pi/definition.d.ts +72 -0
- package/dist/engines/pi/definition.js +129 -0
- package/dist/engines/pi/harness.d.ts +50 -0
- package/dist/engines/pi/harness.js +41 -0
- package/dist/engines/pi/invoke.d.ts +28 -0
- package/dist/engines/pi/invoke.js +298 -0
- package/dist/engines/pi/login.d.ts +51 -0
- package/dist/engines/pi/login.js +125 -0
- package/dist/engines/pi/models.d.ts +41 -0
- package/dist/engines/pi/models.js +76 -0
- package/dist/engines/pi/report.d.ts +13 -0
- package/dist/engines/pi/report.js +21 -0
- package/dist/engines/pi/sessions.d.ts +15 -0
- package/dist/engines/pi/sessions.js +130 -0
- package/dist/engines/pi/tool-context.d.ts +17 -0
- package/dist/engines/pi/tool-context.js +13 -0
- package/dist/engines/pi/tool.d.ts +45 -0
- package/dist/engines/pi/tool.js +85 -0
- package/dist/engines/pi/wake-tool.d.ts +20 -0
- package/dist/engines/pi/wake-tool.js +110 -0
- package/dist/engines/pi/workspace.d.ts +53 -0
- package/dist/engines/pi/workspace.js +70 -0
- package/dist/env.d.ts +24 -0
- package/dist/env.js +57 -0
- package/dist/github.d.ts +3 -0
- package/dist/github.js +2 -0
- package/dist/host/node.d.ts +41 -0
- package/dist/host/node.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/invoke-stream.d.ts +10 -0
- package/dist/invoke-stream.js +26 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +69 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +51 -0
- package/dist/observe.d.ts +12 -0
- package/dist/observe.js +44 -0
- package/dist/pi.d.ts +16 -0
- package/dist/pi.js +12 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +23 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +29 -0
- package/dist/scaffold/add-channel.d.ts +46 -0
- package/dist/scaffold/add-channel.js +227 -0
- package/dist/scaffold/init.d.ts +54 -0
- package/dist/scaffold/init.js +249 -0
- package/dist/scaffold/templates/env.example +24 -0
- package/dist/scaffold/templates/fastagent.config.mjs +19 -0
- package/dist/scaffold/templates/gitignore +8 -0
- package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
- package/dist/scaffold/templates/gitignore.kit +2 -0
- package/dist/scaffold/templates/persona.md +10 -0
- package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
- package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
- package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
- package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
- package/dist/scaffold/templates.d.ts +17 -0
- package/dist/scaffold/templates.js +56 -0
- package/dist/scaffold/vendor-skill.d.ts +24 -0
- package/dist/scaffold/vendor-skill.js +157 -0
- package/dist/schedule/audit.d.ts +18 -0
- package/dist/schedule/audit.js +57 -0
- package/dist/schedule/cron.d.ts +7 -0
- package/dist/schedule/cron.js +33 -0
- package/dist/schedule/discover.d.ts +17 -0
- package/dist/schedule/discover.js +64 -0
- package/dist/schedule/schedule.d.ts +35 -0
- package/dist/schedule/schedule.js +23 -0
- package/dist/schedule/scheduler.d.ts +36 -0
- package/dist/schedule/scheduler.js +253 -0
- package/dist/schedule/state.d.ts +14 -0
- package/dist/schedule/state.js +56 -0
- package/dist/schedule/wakeups.d.ts +82 -0
- package/dist/schedule/wakeups.js +196 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +2 -0
- package/dist/tunnel.d.ts +31 -0
- package/dist/tunnel.js +139 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +13 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +45 -0
- package/package.json +97 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent login`: authenticate a MODEL PROVIDER into the resolved auth file (project-level
|
|
3
|
+
* `<dir>/.fastagent/auth.json` by default, or `--auth-path`/`FASTAGENT_AUTH_PATH`) via the same
|
|
4
|
+
* {@link fastagentCredentialStore} the runtime uses (one writer, one lock/corruption semantics).
|
|
5
|
+
*
|
|
6
|
+
* Flow (pi-ai's unified `ProviderAuth` API): pick a method (OAuth or API key), then a provider that
|
|
7
|
+
* offers it, then run `provider.auth.{oauth|apiKey}.login(callbacks)` and persist with `store.modify`
|
|
8
|
+
* (which refuses to clobber a corrupt file). The terminal IO and providers are injected, so the
|
|
9
|
+
* routing is testable without real stdin or a real auth round-trip.
|
|
10
|
+
*/
|
|
11
|
+
import type { CredentialStore, Provider } from "@earendil-works/pi-ai";
|
|
12
|
+
/** One picker option: a stable `value`, a human `label`, and an optional `hint` (e.g. configured status). */
|
|
13
|
+
export interface IoOption {
|
|
14
|
+
value: string;
|
|
15
|
+
label: string;
|
|
16
|
+
hint?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Terminal interaction, injectable for tests (no real stdin/stdout or browser). The CLI implements it
|
|
20
|
+
* with `@clack/prompts` (a searchable list for many options, a hidden prompt for keys).
|
|
21
|
+
*/
|
|
22
|
+
export interface LoginIO {
|
|
23
|
+
/** Single-choice picker. Returns the chosen `value`, or undefined on cancel. */
|
|
24
|
+
select(message: string, options: IoOption[]): Promise<string | undefined>;
|
|
25
|
+
/** Free-text or hidden input. Returns the entered string, or undefined on cancel/abort. */
|
|
26
|
+
prompt(message: string, opts?: {
|
|
27
|
+
hidden?: boolean;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}): Promise<string | undefined>;
|
|
30
|
+
/** Print an informational line (auth URL, device code, progress). */
|
|
31
|
+
note(message: string): void;
|
|
32
|
+
/** Best-effort open a URL in the browser (printed regardless). */
|
|
33
|
+
openUrl(url: string): void;
|
|
34
|
+
}
|
|
35
|
+
export type LoginMethod = "oauth" | "api_key";
|
|
36
|
+
export interface LoginResult {
|
|
37
|
+
provider: string;
|
|
38
|
+
method: LoginMethod;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve method + provider (asking only what is not given), run the login flow, and persist. A no-op
|
|
42
|
+
* `modify` up front runs the refuse-corrupt check BEFORE the flow, so a known-bad file fails fast.
|
|
43
|
+
*/
|
|
44
|
+
export declare function loginFlow(io: LoginIO, options?: {
|
|
45
|
+
provider?: string;
|
|
46
|
+
method?: LoginMethod;
|
|
47
|
+
authPath?: string;
|
|
48
|
+
store?: CredentialStore;
|
|
49
|
+
providers?: Provider[];
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}): Promise<LoginResult>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
2
|
+
import { GLOBAL_AUTH_PATH, fastagentCredentialStore } from "./auth.js";
|
|
3
|
+
/** Combine present abort signals into one (no-op when none/one). */
|
|
4
|
+
function anySignal(...signals) {
|
|
5
|
+
const present = signals.filter((s) => s !== undefined);
|
|
6
|
+
return present.length === 0 ? undefined : present.length === 1 ? present[0] : AbortSignal.any(present);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Map pi-ai's `AuthLoginCallbacks` onto the injected {@link LoginIO}. `doneSignal` fires when the flow
|
|
10
|
+
* resolves, cancelling a prompt the provider left pending (a manual-code paste racing a callback
|
|
11
|
+
* server it just won) so the one-shot CLI exits instead of hanging on stdin.
|
|
12
|
+
*/
|
|
13
|
+
function authCallbacks(io, userSignal, doneSignal) {
|
|
14
|
+
return {
|
|
15
|
+
signal: userSignal,
|
|
16
|
+
prompt: async (p) => {
|
|
17
|
+
if (p.type === "select") {
|
|
18
|
+
const v = await io.select(p.message, p.options.map((o) => ({ value: o.id, label: o.label, hint: o.description })));
|
|
19
|
+
if (v === undefined)
|
|
20
|
+
throw new Error("cancelled");
|
|
21
|
+
return v;
|
|
22
|
+
}
|
|
23
|
+
const signal = anySignal(p.signal, userSignal, doneSignal);
|
|
24
|
+
const v = await io.prompt(p.message, { hidden: p.type === "secret", signal });
|
|
25
|
+
if (v === undefined)
|
|
26
|
+
throw new Error("cancelled");
|
|
27
|
+
return v;
|
|
28
|
+
},
|
|
29
|
+
notify: (e) => {
|
|
30
|
+
if (e.type === "auth_url") {
|
|
31
|
+
io.note(`Open this URL to authorize:\n ${e.url}`);
|
|
32
|
+
if (e.instructions)
|
|
33
|
+
io.note(e.instructions);
|
|
34
|
+
io.openUrl(e.url);
|
|
35
|
+
}
|
|
36
|
+
else if (e.type === "device_code") {
|
|
37
|
+
io.note(`Go to ${e.verificationUri} and enter the code: ${e.userCode}`);
|
|
38
|
+
io.openUrl(e.verificationUri);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
io.note(e.message);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Providers offering the given method as an INTERACTIVE login (so `login` can actually run it). */
|
|
47
|
+
function candidatesFor(providers, method) {
|
|
48
|
+
return providers.filter((p) => (method === "oauth" ? p.auth.oauth : p.auth.apiKey?.login));
|
|
49
|
+
}
|
|
50
|
+
async function selectMethod(io) {
|
|
51
|
+
const v = await io.select("Authentication method", [
|
|
52
|
+
{ value: "oauth", label: "Use a subscription (OAuth)" },
|
|
53
|
+
{ value: "api_key", label: "Use an API key" },
|
|
54
|
+
]);
|
|
55
|
+
if (v !== "oauth" && v !== "api_key")
|
|
56
|
+
throw new Error("no authentication method selected");
|
|
57
|
+
return v;
|
|
58
|
+
}
|
|
59
|
+
/** Given a provider arg, pick the method it supports (asking only when it offers both). */
|
|
60
|
+
async function methodForProvider(io, p) {
|
|
61
|
+
const hasOauth = !!p.auth.oauth;
|
|
62
|
+
const hasKeyLogin = !!p.auth.apiKey?.login;
|
|
63
|
+
if (hasOauth && hasKeyLogin)
|
|
64
|
+
return selectMethod(io);
|
|
65
|
+
if (hasOauth)
|
|
66
|
+
return "oauth";
|
|
67
|
+
if (hasKeyLogin)
|
|
68
|
+
return "api_key";
|
|
69
|
+
throw new Error(`provider "${p.id}" has no interactive login — set its API key via the provider's env var`);
|
|
70
|
+
}
|
|
71
|
+
/** List providers for the method with their configured status, and let the user pick one. */
|
|
72
|
+
async function selectProvider(io, providers, method, store) {
|
|
73
|
+
const candidates = candidatesFor(providers, method);
|
|
74
|
+
if (candidates.length === 0)
|
|
75
|
+
throw new Error(`no provider supports ${method} login`);
|
|
76
|
+
const options = await Promise.all(candidates.map(async (p) => {
|
|
77
|
+
const cred = await store.read(p.id);
|
|
78
|
+
const auth = method === "oauth" ? p.auth.oauth : p.auth.apiKey;
|
|
79
|
+
return { value: p.id, label: auth?.name ?? p.name, hint: cred ? `configured (${cred.type})` : undefined };
|
|
80
|
+
}));
|
|
81
|
+
const id = await io.select("Select a provider", options);
|
|
82
|
+
if (!id)
|
|
83
|
+
throw new Error("no provider selected");
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve method + provider (asking only what is not given), run the login flow, and persist. A no-op
|
|
88
|
+
* `modify` up front runs the refuse-corrupt check BEFORE the flow, so a known-bad file fails fast.
|
|
89
|
+
*/
|
|
90
|
+
export async function loginFlow(io, options = {}) {
|
|
91
|
+
const store = options.store ?? fastagentCredentialStore(options.authPath ?? GLOBAL_AUTH_PATH);
|
|
92
|
+
const providers = options.providers ?? builtinProviders();
|
|
93
|
+
let method;
|
|
94
|
+
let providerId;
|
|
95
|
+
if (options.provider) {
|
|
96
|
+
providerId = options.provider;
|
|
97
|
+
const p = providers.find((x) => x.id === providerId);
|
|
98
|
+
if (!p)
|
|
99
|
+
throw new Error(`unknown provider "${providerId}"`);
|
|
100
|
+
method = options.method ?? (await methodForProvider(io, p));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
method = options.method ?? (await selectMethod(io));
|
|
104
|
+
providerId = await selectProvider(io, providers, method, store);
|
|
105
|
+
}
|
|
106
|
+
// Preflight: a no-op modify runs the refuse-corrupt / writability check BEFORE the flow.
|
|
107
|
+
await store.modify(providerId, async () => undefined);
|
|
108
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
109
|
+
if (!provider)
|
|
110
|
+
throw new Error(`unknown provider "${providerId}"`);
|
|
111
|
+
const auth = method === "oauth" ? provider.auth.oauth : provider.auth.apiKey;
|
|
112
|
+
if (!auth?.login)
|
|
113
|
+
throw new Error(`provider "${providerId}" has no ${method} login`);
|
|
114
|
+
// `done` cancels any prompt left pending when login resolves (manual-code race backstop).
|
|
115
|
+
const done = new AbortController();
|
|
116
|
+
let credential;
|
|
117
|
+
try {
|
|
118
|
+
credential = await auth.login(authCallbacks(io, options.signal, done.signal));
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
done.abort();
|
|
122
|
+
}
|
|
123
|
+
await store.modify(providerId, async () => credential);
|
|
124
|
+
return { provider: providerId, method };
|
|
125
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pi `Models` collection — the single hub that owns BOTH model resolution (provider/modelId
|
|
3
|
+
* lookup) AND auth (per-request credential resolution). fastagent builds one per opener and threads
|
|
4
|
+
* it into the harness alongside the selected `model`; the two must come from the same collection so
|
|
5
|
+
* the model's provider auth is in scope.
|
|
6
|
+
*/
|
|
7
|
+
import { type Models, type Provider } from "@earendil-works/pi-ai";
|
|
8
|
+
import { type FastagentAuthOptions } from "./auth.ts";
|
|
9
|
+
export interface CreatePiModelsOptions extends FastagentAuthOptions {
|
|
10
|
+
/** Credentials file path. Defaults to the global `~/.fastagent/auth.json`; the directory opener passes
|
|
11
|
+
* the project-level `<dir>/.fastagent/auth.json`. */
|
|
12
|
+
authPath?: string;
|
|
13
|
+
/** Extra providers registered on top of the built-ins (same id overrides a built-in). */
|
|
14
|
+
providers?: Provider[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A `Models` with every built-in pi provider, wired to fastagent's auth: stored credentials from the
|
|
18
|
+
* {@link CreatePiModelsOptions.authPath} file (via {@link fastagentCredentialStore}; the global
|
|
19
|
+
* `~/.fastagent/auth.json` unless the opener passes a project-level path), then ambient env vars. A
|
|
20
|
+
* stored credential owns the provider; env is consulted only when nothing is stored (resolution order
|
|
21
|
+
* is upstream-owned).
|
|
22
|
+
*/
|
|
23
|
+
export declare function createPiModels(options?: CreatePiModelsOptions): Models;
|
|
24
|
+
/**
|
|
25
|
+
* The "provider/modelId" specs whose provider currently has USABLE credentials (a stored login or an
|
|
26
|
+
* env key) — the menu for the first-run model picker (`fastagent dev`/`start`/`invoke` with no model
|
|
27
|
+
* set). Auth is provider-scoped, so probe once per provider (any of its models) rather than per model.
|
|
28
|
+
* A provider that resolves no auth (unconfigured) or rejects it (configured-but-expired) is omitted:
|
|
29
|
+
* the picker offers only models that would actually run now; `fastagent login` fixes the rest. Sorted.
|
|
30
|
+
*
|
|
31
|
+
* pi deliberately has no "best/tier" ranking on Model, so this does not auto-pick — it narrows the menu
|
|
32
|
+
* to what the user can use and lets them choose (mirroring pi-coding-agent's select-then-persist).
|
|
33
|
+
*/
|
|
34
|
+
export declare function configuredModelSpecs(models: Models): Promise<string[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Which source currently satisfies auth for `spec` — a startup diagnostic. Returns the upstream
|
|
37
|
+
* `AuthResult.source` label: `"OAuth"` for a stored OAuth credential (e.g. a logged-in openai-codex),
|
|
38
|
+
* `"stored credential"` for a stored API key, an env-var name like `"ANTHROPIC_API_KEY"` for env, or
|
|
39
|
+
* undefined when unconfigured. Reporting-only; never throws.
|
|
40
|
+
*/
|
|
41
|
+
export declare function probeAuthSource(models: Models, spec: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pi `Models` collection — the single hub that owns BOTH model resolution (provider/modelId
|
|
3
|
+
* lookup) AND auth (per-request credential resolution). fastagent builds one per opener and threads
|
|
4
|
+
* it into the harness alongside the selected `model`; the two must come from the same collection so
|
|
5
|
+
* the model's provider auth is in scope.
|
|
6
|
+
*/
|
|
7
|
+
import { defaultProviderAuthContext } from "@earendil-works/pi-ai";
|
|
8
|
+
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
|
|
9
|
+
import { log } from "../../log.js";
|
|
10
|
+
import { fastagentCredentialStore } from "./auth.js";
|
|
11
|
+
/**
|
|
12
|
+
* A `Models` with every built-in pi provider, wired to fastagent's auth: stored credentials from the
|
|
13
|
+
* {@link CreatePiModelsOptions.authPath} file (via {@link fastagentCredentialStore}; the global
|
|
14
|
+
* `~/.fastagent/auth.json` unless the opener passes a project-level path), then ambient env vars. A
|
|
15
|
+
* stored credential owns the provider; env is consulted only when nothing is stored (resolution order
|
|
16
|
+
* is upstream-owned).
|
|
17
|
+
*/
|
|
18
|
+
export function createPiModels(options = {}) {
|
|
19
|
+
const models = builtinModels({
|
|
20
|
+
credentials: fastagentCredentialStore(options.authPath, { warn: options.warn }),
|
|
21
|
+
authContext: defaultProviderAuthContext(),
|
|
22
|
+
});
|
|
23
|
+
for (const provider of options.providers ?? [])
|
|
24
|
+
models.setProvider(provider);
|
|
25
|
+
return models;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The "provider/modelId" specs whose provider currently has USABLE credentials (a stored login or an
|
|
29
|
+
* env key) — the menu for the first-run model picker (`fastagent dev`/`start`/`invoke` with no model
|
|
30
|
+
* set). Auth is provider-scoped, so probe once per provider (any of its models) rather than per model.
|
|
31
|
+
* A provider that resolves no auth (unconfigured) or rejects it (configured-but-expired) is omitted:
|
|
32
|
+
* the picker offers only models that would actually run now; `fastagent login` fixes the rest. Sorted.
|
|
33
|
+
*
|
|
34
|
+
* pi deliberately has no "best/tier" ranking on Model, so this does not auto-pick — it narrows the menu
|
|
35
|
+
* to what the user can use and lets them choose (mirroring pi-coding-agent's select-then-persist).
|
|
36
|
+
*/
|
|
37
|
+
export async function configuredModelSpecs(models) {
|
|
38
|
+
const specs = [];
|
|
39
|
+
for (const provider of models.getProviders()) {
|
|
40
|
+
const [probe] = provider.getModels();
|
|
41
|
+
if (!probe)
|
|
42
|
+
continue;
|
|
43
|
+
let usable;
|
|
44
|
+
try {
|
|
45
|
+
usable = (await models.getAuth(probe)) !== undefined;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// Configured-but-broken (expired token, a refresh network failure, a corrupt store): omit it
|
|
49
|
+
// from the menu, but SAY so — a silent disappearance is the same fail-visibly gap the caller
|
|
50
|
+
// guards against for the top-level enumeration. `undefined` (plainly unconfigured) stays quiet.
|
|
51
|
+
log.warn(`[fastagent] skipping provider "${provider.id}": auth check failed (${error.message})`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!usable)
|
|
55
|
+
continue;
|
|
56
|
+
for (const model of provider.getModels())
|
|
57
|
+
specs.push(`${provider.id}/${model.id}`);
|
|
58
|
+
}
|
|
59
|
+
return specs.sort();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Which source currently satisfies auth for `spec` — a startup diagnostic. Returns the upstream
|
|
63
|
+
* `AuthResult.source` label: `"OAuth"` for a stored OAuth credential (e.g. a logged-in openai-codex),
|
|
64
|
+
* `"stored credential"` for a stored API key, an env-var name like `"ANTHROPIC_API_KEY"` for env, or
|
|
65
|
+
* undefined when unconfigured. Reporting-only; never throws.
|
|
66
|
+
*/
|
|
67
|
+
export async function probeAuthSource(models, spec) {
|
|
68
|
+
const slash = spec.indexOf("/");
|
|
69
|
+
if (slash < 1)
|
|
70
|
+
return undefined;
|
|
71
|
+
const model = models.getModel(spec.slice(0, slash), spec.slice(slash + 1));
|
|
72
|
+
if (!model)
|
|
73
|
+
return undefined;
|
|
74
|
+
const auth = await models.getAuth(model).catch(() => undefined);
|
|
75
|
+
return auth?.source;
|
|
76
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render assembly warnings to stderr — the one place both the CLI runners and the `chat` runtime show
|
|
3
|
+
* the non-fatal definition/tool findings the loaders return as data. One copy so the wording can't drift.
|
|
4
|
+
*/
|
|
5
|
+
import type { SkillDiagnostic } from "@earendil-works/pi-agent-core";
|
|
6
|
+
import type { SkillCollision } from "./definition.ts";
|
|
7
|
+
import type { ModuleLoadFailure } from "../../loader.ts";
|
|
8
|
+
import type { ToolCollision } from "./tool.ts";
|
|
9
|
+
export declare function reportDefinitionWarnings(collisions: SkillCollision[], diagnostics: SkillDiagnostic[]): void;
|
|
10
|
+
export declare function reportToolCollisions(collisions: ToolCollision[]): void;
|
|
11
|
+
/** Report per-file module failures. The caller decides whether they are degradations (tools/schedules)
|
|
12
|
+
* or fatal (declared channels on the serving path). */
|
|
13
|
+
export declare function reportModuleLoadFailures(failures: ModuleLoadFailure[]): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { log } from "../../log.js";
|
|
2
|
+
export function reportDefinitionWarnings(collisions, diagnostics) {
|
|
3
|
+
for (const c of collisions) {
|
|
4
|
+
log.warn(`[fastagent] skill "${c.name}" collision — using ${c.winnerPath}, ignoring ${c.loserPath}`);
|
|
5
|
+
}
|
|
6
|
+
for (const d of diagnostics) {
|
|
7
|
+
log.warn(`[fastagent] ${d.code}: ${d.message} (${d.path})`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function reportToolCollisions(collisions) {
|
|
11
|
+
for (const c of collisions) {
|
|
12
|
+
log.warn(`[fastagent] tool "${c.name}" (${c.source}) dropped — a default/config tool already uses that name`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Report per-file module failures. The caller decides whether they are degradations (tools/schedules)
|
|
16
|
+
* or fatal (declared channels on the serving path). */
|
|
17
|
+
export function reportModuleLoadFailures(failures) {
|
|
18
|
+
for (const f of failures) {
|
|
19
|
+
log.warn(`[fastagent] ${f.label} failed to load, skipping it — ${f.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Session } from "@earendil-works/pi-agent-core";
|
|
2
|
+
/** What fastagent needs from a session backend: open-or-create by opaque id. */
|
|
3
|
+
export interface PiSessionStore {
|
|
4
|
+
openOrCreate(sessionId: string): Promise<Session>;
|
|
5
|
+
}
|
|
6
|
+
/** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
|
|
7
|
+
export declare function inMemorySessionStore(): PiSessionStore;
|
|
8
|
+
/**
|
|
9
|
+
* Disk-backed store (pi JsonlSessionRepo under `dir`): restart the process, conversations continue.
|
|
10
|
+
* `cwd` is recorded in session metadata; defaults to process.cwd().
|
|
11
|
+
*/
|
|
12
|
+
export declare function jsonlSessionStore(options: {
|
|
13
|
+
dir: string;
|
|
14
|
+
cwd?: string;
|
|
15
|
+
}): PiSessionStore;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session persistence — the K-axis port and its first two backends.
|
|
3
|
+
*
|
|
4
|
+
* PiSessionStore is the consumer-owned port: open-or-create by opaque session id, nothing more.
|
|
5
|
+
* pi's full SessionRepo surface (list/open/create/delete/fork) stays behind the adapters. The `Pi`
|
|
6
|
+
* prefix is honest — `openOrCreate` returns pi's `Session`, so this is pi-coupled, not a neutral
|
|
7
|
+
* persistence contract.
|
|
8
|
+
*
|
|
9
|
+
* Continuity = same backing store + same session id: in-memory continuity dies with the instance;
|
|
10
|
+
* jsonl survives process restarts (disk is the truth).
|
|
11
|
+
*/
|
|
12
|
+
import { InMemorySessionRepo } from "@earendil-works/pi-agent-core";
|
|
13
|
+
import { JsonlSessionRepo, NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
14
|
+
import { log } from "../../log.js";
|
|
15
|
+
/**
|
|
16
|
+
* Crash-safety reconciliation, run on every OPEN of an existing session.
|
|
17
|
+
*
|
|
18
|
+
* A turn that dies mid tool-execution leaves an assistant `tool_use` with no matching result (pi
|
|
19
|
+
* persists the assistant message before the tool runs). The next turn would then hand the provider an
|
|
20
|
+
* `assistant(tool_use) -> user` sequence that Anthropic/OpenAI reject — the session is poisoned. We
|
|
21
|
+
* append an honest "interrupted" error result for each dangling call, restoring a valid transcript.
|
|
22
|
+
* Tool side-effect idempotency stays the tool's responsibility (SPEC §6); this only restores
|
|
23
|
+
* transcript validity, not exactly-once execution.
|
|
24
|
+
*
|
|
25
|
+
* Pairing is TURN-LOCAL: a tool_use is paired only by a toolResult that immediately follows it (up to
|
|
26
|
+
* the next non-toolResult). tool-call ids are not unique across turns (a local model may restart ids
|
|
27
|
+
* each response), so matching against the whole transcript could falsely settle a leaf call against an
|
|
28
|
+
* earlier turn's identical id. Append-only logs can only repair a gap AT THE LEAF (last assistant
|
|
29
|
+
* followed by nothing but its own results); an earlier gap is surfaced via log.warn rather than
|
|
30
|
+
* "fixed" with an orphaned result that appending cannot place.
|
|
31
|
+
*
|
|
32
|
+
* The synthetic result splits its audiences: `content` (read by the model, may reach the end user)
|
|
33
|
+
* stays neutral — it must NOT say "aborted" (pi's word for a user cancellation) or leak infra detail;
|
|
34
|
+
* `details` carries the operational marker for developers and is never sent to the provider.
|
|
35
|
+
*/
|
|
36
|
+
async function reconcileInterruptedToolCalls(session) {
|
|
37
|
+
const { messages } = await session.buildContext();
|
|
38
|
+
let leafIdx = -1;
|
|
39
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
40
|
+
if (messages[i]?.role === "assistant") {
|
|
41
|
+
leafIdx = i;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (leafIdx === -1)
|
|
46
|
+
return; // no assistant turn yet
|
|
47
|
+
const leafReparable = messages.slice(leafIdx + 1).every((m) => m.role === "toolResult");
|
|
48
|
+
const toRepair = [];
|
|
49
|
+
const orphaned = [];
|
|
50
|
+
messages.forEach((m, idx) => {
|
|
51
|
+
if (m.role !== "assistant")
|
|
52
|
+
return;
|
|
53
|
+
const paired = new Set();
|
|
54
|
+
for (let j = idx + 1; j < messages.length; j++) {
|
|
55
|
+
const next = messages[j];
|
|
56
|
+
if (next?.role !== "toolResult")
|
|
57
|
+
break;
|
|
58
|
+
paired.add(next.toolCallId);
|
|
59
|
+
}
|
|
60
|
+
for (const block of m.content) {
|
|
61
|
+
if (block.type !== "toolCall" || paired.has(block.id))
|
|
62
|
+
continue;
|
|
63
|
+
if (idx === leafIdx && leafReparable)
|
|
64
|
+
toRepair.push({ id: block.id, name: block.name });
|
|
65
|
+
else
|
|
66
|
+
orphaned.push(block.id);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
if (orphaned.length > 0) {
|
|
70
|
+
log.warn(`[fastagent] unmatched tool_use is not at the session leaf; leaving it unreconciled ` +
|
|
71
|
+
`(an append-only log cannot repair a mid-history gap): toolCallIds=${orphaned.join(",")}`);
|
|
72
|
+
}
|
|
73
|
+
for (const { id, name } of toRepair) {
|
|
74
|
+
const result = {
|
|
75
|
+
role: "toolResult",
|
|
76
|
+
toolCallId: id,
|
|
77
|
+
toolName: name,
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: "text",
|
|
81
|
+
text: "This tool call did not complete and its result is unavailable. Re-run it if the result is still needed.",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
details: { fastagent: "interrupted-tool-call" },
|
|
85
|
+
isError: true,
|
|
86
|
+
timestamp: Date.now(),
|
|
87
|
+
};
|
|
88
|
+
await session.appendMessage(result);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
|
|
92
|
+
export function inMemorySessionStore() {
|
|
93
|
+
const repo = new InMemorySessionRepo();
|
|
94
|
+
return {
|
|
95
|
+
async openOrCreate(sessionId) {
|
|
96
|
+
const existing = (await repo.list()).find((m) => m.id === sessionId);
|
|
97
|
+
if (!existing)
|
|
98
|
+
return repo.create({ id: sessionId });
|
|
99
|
+
const session = await repo.open(existing);
|
|
100
|
+
await reconcileInterruptedToolCalls(session);
|
|
101
|
+
return session;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Disk-backed store (pi JsonlSessionRepo under `dir`): restart the process, conversations continue.
|
|
107
|
+
* `cwd` is recorded in session metadata; defaults to process.cwd().
|
|
108
|
+
*/
|
|
109
|
+
export function jsonlSessionStore(options) {
|
|
110
|
+
const cwd = options.cwd ?? process.cwd();
|
|
111
|
+
const repo = new JsonlSessionRepo({ fs: new NodeExecutionEnv({ cwd }), sessionsRoot: options.dir });
|
|
112
|
+
return {
|
|
113
|
+
async openOrCreate(sessionId) {
|
|
114
|
+
// Caller-provided ids land in jsonl FILENAMES — encode anything unsafe before it reaches disk.
|
|
115
|
+
const id = encodeSessionId(sessionId);
|
|
116
|
+
// Scope the lookup to this store's cwd: two stores sharing a sessionsRoot must not open each
|
|
117
|
+
// other's sessions (pi groups sessions by project dir).
|
|
118
|
+
const existing = (await repo.list({ cwd })).find((m) => m.id === id);
|
|
119
|
+
if (!existing)
|
|
120
|
+
return repo.create({ id, cwd });
|
|
121
|
+
const session = await repo.open(existing);
|
|
122
|
+
await reconcileInterruptedToolCalls(session);
|
|
123
|
+
return session;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Injective filename-safe encoding: [A-Za-z0-9._-] verbatim, the rest %-escaped. */
|
|
128
|
+
function encodeSessionId(id) {
|
|
129
|
+
return id.replace(/[^A-Za-z0-9._-]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
|
|
130
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-turn context a tool's `execute` can read (beyond its abort signal): the SESSION the current
|
|
3
|
+
* turn runs in. A `defineTool` tool is built ONCE and reused across sessions, so the session can't be a
|
|
4
|
+
* closure — it rides an AsyncLocalStorage set around the harness turn (invoke.ts) and read inside
|
|
5
|
+
* `execute` (tool.ts). Undefined outside a turn (e.g. `fastagent tool`, which runs a tool with no
|
|
6
|
+
* session). This is what lets a tool know which conversation it is in — the mechanism the agent's
|
|
7
|
+
* self-scheduling `wake` tool needs to fire a later turn back into the SAME session.
|
|
8
|
+
*
|
|
9
|
+
* Only `session` lives here (a per-turn runtime value). Deploy-time ambients a tool closes over at
|
|
10
|
+
* build time (e.g. a stateRoot) do NOT belong here — pass them via the tool's own closure.
|
|
11
|
+
*/
|
|
12
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
13
|
+
export interface TurnContext {
|
|
14
|
+
/** The session id of the current turn. */
|
|
15
|
+
session: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const turnContext: AsyncLocalStorage<TurnContext>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-turn context a tool's `execute` can read (beyond its abort signal): the SESSION the current
|
|
3
|
+
* turn runs in. A `defineTool` tool is built ONCE and reused across sessions, so the session can't be a
|
|
4
|
+
* closure — it rides an AsyncLocalStorage set around the harness turn (invoke.ts) and read inside
|
|
5
|
+
* `execute` (tool.ts). Undefined outside a turn (e.g. `fastagent tool`, which runs a tool with no
|
|
6
|
+
* session). This is what lets a tool know which conversation it is in — the mechanism the agent's
|
|
7
|
+
* self-scheduling `wake` tool needs to fire a later turn back into the SAME session.
|
|
8
|
+
*
|
|
9
|
+
* Only `session` lives here (a per-turn runtime value). Deploy-time ambients a tool closes over at
|
|
10
|
+
* build time (e.g. a stateRoot) do NOT belong here — pass them via the tool's own closure.
|
|
11
|
+
*/
|
|
12
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
13
|
+
export const turnContext = new AsyncLocalStorage();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type ModuleLoadFailure } from "../../loader.ts";
|
|
4
|
+
export interface ToolContext {
|
|
5
|
+
/** Abort signal for the current turn — honor it to cancel in-flight work on cancellation. */
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
/** The session id of the current turn — which conversation this tool is running in. A general tool
|
|
8
|
+
* capability: partition per-conversation data, tag logs, scope state. Undefined outside a turn (a bare
|
|
9
|
+
* `fastagent tool` run, or any call with no session). (The built-in `wake` tool is one consumer — it
|
|
10
|
+
* fires a later turn back into this same session.) */
|
|
11
|
+
session?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface DefineToolOptions<I extends z.ZodType> {
|
|
14
|
+
/** Explicit name. Usually omitted — a `tools/<name>.ts` tool is named from its filename. */
|
|
15
|
+
name?: string;
|
|
16
|
+
description: string;
|
|
17
|
+
input: I;
|
|
18
|
+
execute: (input: z.infer<I>, ctx: ToolContext) => unknown | Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
export declare function defineTool<I extends z.ZodType>(options: DefineToolOptions<I>): AgentTool;
|
|
21
|
+
/** A discarded same-name tool (within `tools/`, or against an existing tool). Surfaced, never silent. */
|
|
22
|
+
export interface ToolCollision {
|
|
23
|
+
name: string;
|
|
24
|
+
source: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Discover code tools in `<dir>/tools/`: each `*.ts|.js|.mjs` default-exports a tool, named from its
|
|
28
|
+
* filename. A file broken for ANY reason — a failed import (from {@link loadModuleDir}) or not being a
|
|
29
|
+
* tool (no `execute`) — is ISOLATED into `failures` (skipped + reported, not thrown) so one broken file
|
|
30
|
+
* can't crash `start`; the agent serves the tools that loaded. A repo turned into an agent often has a
|
|
31
|
+
* `tools/` dir of its OWN scripts, which is exactly this case.
|
|
32
|
+
*/
|
|
33
|
+
export declare function loadTools(dir: string): Promise<{
|
|
34
|
+
tools: AgentTool[];
|
|
35
|
+
collisions: ToolCollision[];
|
|
36
|
+
failures: ModuleLoadFailure[];
|
|
37
|
+
}>;
|
|
38
|
+
/**
|
|
39
|
+
* Merge resolved tools (pi defaults + `config.tools`) with discovered `tools/`, deduped by name.
|
|
40
|
+
* Existing tools win; dropped discovered tools surface as collisions.
|
|
41
|
+
*/
|
|
42
|
+
export declare function mergeDiscoveredTools(existing: AgentTool[], discovered: AgentTool[]): {
|
|
43
|
+
tools: AgentTool[];
|
|
44
|
+
collisions: ToolCollision[];
|
|
45
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool authoring: `defineTool` (the authoring surface) and `loadTools` (filesystem discovery).
|
|
3
|
+
* Drop a file in `tools/`, default-export `defineTool({...})`, and it is discovered, named from
|
|
4
|
+
* the filename, validated, and injected.
|
|
5
|
+
*
|
|
6
|
+
* // tools/lookup-order.ts → tool "lookup-order"
|
|
7
|
+
* export default defineTool({
|
|
8
|
+
* description: "Look up an order by id.",
|
|
9
|
+
* input: z.object({ orderId: z.string() }),
|
|
10
|
+
* async execute({ orderId }) { return await db.find(orderId); },
|
|
11
|
+
* });
|
|
12
|
+
*/
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { loadModuleDir } from "../../loader.js";
|
|
16
|
+
import { turnContext } from "./tool-context.js";
|
|
17
|
+
/** Wrap a plain return value into pi's tool-result shape; pass a full result through unchanged. */
|
|
18
|
+
function wrapResult(value) {
|
|
19
|
+
if (value && typeof value === "object" && Array.isArray(value.content)) {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
const text = typeof value === "string" ? value : JSON.stringify(value ?? null);
|
|
23
|
+
return { content: [{ type: "text", text }], details: value };
|
|
24
|
+
}
|
|
25
|
+
export function defineTool(options) {
|
|
26
|
+
const { $schema: _drop, ...parameters } = z.toJSONSchema(options.input);
|
|
27
|
+
const tool = {
|
|
28
|
+
name: options.name ?? "",
|
|
29
|
+
description: options.description,
|
|
30
|
+
parameters,
|
|
31
|
+
async execute(_toolCallId, rawParams, signal) {
|
|
32
|
+
const parsed = options.input.safeParse(rawParams);
|
|
33
|
+
if (!parsed.success) {
|
|
34
|
+
// Validation failure is reported TO THE MODEL (it can correct and retry), not thrown.
|
|
35
|
+
const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
36
|
+
return { content: [{ type: "text", text: `Invalid arguments: ${detail}` }], details: { error: detail } };
|
|
37
|
+
}
|
|
38
|
+
return wrapResult(await options.execute(parsed.data, { signal, session: turnContext.getStore()?.session }));
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
return tool;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Discover code tools in `<dir>/tools/`: each `*.ts|.js|.mjs` default-exports a tool, named from its
|
|
45
|
+
* filename. A file broken for ANY reason — a failed import (from {@link loadModuleDir}) or not being a
|
|
46
|
+
* tool (no `execute`) — is ISOLATED into `failures` (skipped + reported, not thrown) so one broken file
|
|
47
|
+
* can't crash `start`; the agent serves the tools that loaded. A repo turned into an agent often has a
|
|
48
|
+
* `tools/` dir of its OWN scripts, which is exactly this case.
|
|
49
|
+
*/
|
|
50
|
+
export async function loadTools(dir) {
|
|
51
|
+
const { modules, failures } = await loadModuleDir(join(dir, "tools"));
|
|
52
|
+
const byName = new Map();
|
|
53
|
+
const collisions = [];
|
|
54
|
+
for (const { name, label, file, mod } of modules) {
|
|
55
|
+
const tool = mod.default;
|
|
56
|
+
if (!tool || typeof tool.execute !== "function") {
|
|
57
|
+
failures.push({ label, file, message: `${label} must default-export defineTool({...})` });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (byName.has(name)) {
|
|
61
|
+
collisions.push({ name, source: label });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
byName.set(name, { ...tool, name });
|
|
65
|
+
}
|
|
66
|
+
return { tools: [...byName.values()], collisions, failures };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Merge resolved tools (pi defaults + `config.tools`) with discovered `tools/`, deduped by name.
|
|
70
|
+
* Existing tools win; dropped discovered tools surface as collisions.
|
|
71
|
+
*/
|
|
72
|
+
export function mergeDiscoveredTools(existing, discovered) {
|
|
73
|
+
const names = new Set(existing.map((t) => t.name));
|
|
74
|
+
const tools = [...existing];
|
|
75
|
+
const collisions = [];
|
|
76
|
+
for (const tool of discovered) {
|
|
77
|
+
if (names.has(tool.name)) {
|
|
78
|
+
collisions.push({ name: tool.name, source: `tools/${tool.name}` });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
names.add(tool.name);
|
|
82
|
+
tools.push(tool);
|
|
83
|
+
}
|
|
84
|
+
return { tools, collisions };
|
|
85
|
+
}
|