@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.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/dist/agent.d.ts +72 -0
  4. package/dist/agent.js +14 -0
  5. package/dist/channels/body.d.ts +9 -0
  6. package/dist/channels/body.js +29 -0
  7. package/dist/channels/github/github.d.ts +31 -0
  8. package/dist/channels/github/github.js +80 -0
  9. package/dist/channels/github/scaffold/channel.ts +25 -0
  10. package/dist/channels/http.d.ts +13 -0
  11. package/dist/channels/http.js +161 -0
  12. package/dist/channels/respond.d.ts +5 -0
  13. package/dist/channels/respond.js +3 -0
  14. package/dist/channels/telegram/context-buffer.d.ts +59 -0
  15. package/dist/channels/telegram/context-buffer.js +139 -0
  16. package/dist/channels/telegram/invoke-turn.d.ts +53 -0
  17. package/dist/channels/telegram/invoke-turn.js +127 -0
  18. package/dist/channels/telegram/parse.d.ts +160 -0
  19. package/dist/channels/telegram/parse.js +153 -0
  20. package/dist/channels/telegram/preview.d.ts +22 -0
  21. package/dist/channels/telegram/preview.js +266 -0
  22. package/dist/channels/telegram/register-webhook.d.ts +13 -0
  23. package/dist/channels/telegram/register-webhook.js +73 -0
  24. package/dist/channels/telegram/scaffold/channel.ts +25 -0
  25. package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
  26. package/dist/channels/telegram/state.d.ts +9 -0
  27. package/dist/channels/telegram/state.js +58 -0
  28. package/dist/channels/telegram/telegram-api.d.ts +77 -0
  29. package/dist/channels/telegram/telegram-api.js +345 -0
  30. package/dist/channels/telegram/telegram.d.ts +35 -0
  31. package/dist/channels/telegram/telegram.js +352 -0
  32. package/dist/channels/telegram/turn-queue.d.ts +21 -0
  33. package/dist/channels/telegram/turn-queue.js +47 -0
  34. package/dist/channels/telegram/turn-store.d.ts +38 -0
  35. package/dist/channels/telegram/turn-store.js +150 -0
  36. package/dist/cli-auth.d.ts +24 -0
  37. package/dist/cli-auth.js +30 -0
  38. package/dist/cli-models.d.ts +11 -0
  39. package/dist/cli-models.js +20 -0
  40. package/dist/cli.d.ts +2 -0
  41. package/dist/cli.js +1288 -0
  42. package/dist/collect.d.ts +20 -0
  43. package/dist/collect.js +27 -0
  44. package/dist/core.d.ts +10 -0
  45. package/dist/core.js +8 -0
  46. package/dist/deploy/container.d.ts +56 -0
  47. package/dist/deploy/container.js +169 -0
  48. package/dist/deploy/fly/plan.d.ts +65 -0
  49. package/dist/deploy/fly/plan.js +142 -0
  50. package/dist/deploy/fly/run.d.ts +51 -0
  51. package/dist/deploy/fly/run.js +89 -0
  52. package/dist/deploy/preflight.d.ts +59 -0
  53. package/dist/deploy/preflight.js +191 -0
  54. package/dist/deploy/railway/plan.d.ts +48 -0
  55. package/dist/deploy/railway/plan.js +91 -0
  56. package/dist/deploy/railway/run.d.ts +81 -0
  57. package/dist/deploy/railway/run.js +176 -0
  58. package/dist/deploy/runner.d.ts +18 -0
  59. package/dist/deploy/runner.js +25 -0
  60. package/dist/deploy/secrets.d.ts +49 -0
  61. package/dist/deploy/secrets.js +88 -0
  62. package/dist/dev-supervisor.d.ts +14 -0
  63. package/dist/dev-supervisor.js +158 -0
  64. package/dist/engines/pi/auth.d.ts +17 -0
  65. package/dist/engines/pi/auth.js +118 -0
  66. package/dist/engines/pi/channel.d.ts +31 -0
  67. package/dist/engines/pi/channel.js +113 -0
  68. package/dist/engines/pi/chat.d.ts +19 -0
  69. package/dist/engines/pi/chat.js +200 -0
  70. package/dist/engines/pi/config.d.ts +110 -0
  71. package/dist/engines/pi/config.js +285 -0
  72. package/dist/engines/pi/create.d.ts +127 -0
  73. package/dist/engines/pi/create.js +222 -0
  74. package/dist/engines/pi/definition.d.ts +72 -0
  75. package/dist/engines/pi/definition.js +129 -0
  76. package/dist/engines/pi/harness.d.ts +50 -0
  77. package/dist/engines/pi/harness.js +41 -0
  78. package/dist/engines/pi/invoke.d.ts +28 -0
  79. package/dist/engines/pi/invoke.js +298 -0
  80. package/dist/engines/pi/login.d.ts +51 -0
  81. package/dist/engines/pi/login.js +125 -0
  82. package/dist/engines/pi/models.d.ts +41 -0
  83. package/dist/engines/pi/models.js +76 -0
  84. package/dist/engines/pi/report.d.ts +13 -0
  85. package/dist/engines/pi/report.js +21 -0
  86. package/dist/engines/pi/sessions.d.ts +15 -0
  87. package/dist/engines/pi/sessions.js +130 -0
  88. package/dist/engines/pi/tool-context.d.ts +17 -0
  89. package/dist/engines/pi/tool-context.js +13 -0
  90. package/dist/engines/pi/tool.d.ts +45 -0
  91. package/dist/engines/pi/tool.js +85 -0
  92. package/dist/engines/pi/wake-tool.d.ts +20 -0
  93. package/dist/engines/pi/wake-tool.js +110 -0
  94. package/dist/engines/pi/workspace.d.ts +53 -0
  95. package/dist/engines/pi/workspace.js +70 -0
  96. package/dist/env.d.ts +24 -0
  97. package/dist/env.js +57 -0
  98. package/dist/github.d.ts +3 -0
  99. package/dist/github.js +2 -0
  100. package/dist/host/node.d.ts +41 -0
  101. package/dist/host/node.js +50 -0
  102. package/dist/index.d.ts +2 -0
  103. package/dist/index.js +5 -0
  104. package/dist/invoke-stream.d.ts +10 -0
  105. package/dist/invoke-stream.js +26 -0
  106. package/dist/loader.d.ts +38 -0
  107. package/dist/loader.js +69 -0
  108. package/dist/log.d.ts +26 -0
  109. package/dist/log.js +51 -0
  110. package/dist/observe.d.ts +12 -0
  111. package/dist/observe.js +44 -0
  112. package/dist/pi.d.ts +16 -0
  113. package/dist/pi.js +12 -0
  114. package/dist/proxy.d.ts +9 -0
  115. package/dist/proxy.js +23 -0
  116. package/dist/runtime.d.ts +26 -0
  117. package/dist/runtime.js +29 -0
  118. package/dist/scaffold/add-channel.d.ts +46 -0
  119. package/dist/scaffold/add-channel.js +227 -0
  120. package/dist/scaffold/init.d.ts +54 -0
  121. package/dist/scaffold/init.js +249 -0
  122. package/dist/scaffold/templates/env.example +24 -0
  123. package/dist/scaffold/templates/fastagent.config.mjs +19 -0
  124. package/dist/scaffold/templates/gitignore +8 -0
  125. package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
  126. package/dist/scaffold/templates/gitignore.kit +2 -0
  127. package/dist/scaffold/templates/persona.md +10 -0
  128. package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
  129. package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
  130. package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
  131. package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
  132. package/dist/scaffold/templates.d.ts +17 -0
  133. package/dist/scaffold/templates.js +56 -0
  134. package/dist/scaffold/vendor-skill.d.ts +24 -0
  135. package/dist/scaffold/vendor-skill.js +157 -0
  136. package/dist/schedule/audit.d.ts +18 -0
  137. package/dist/schedule/audit.js +57 -0
  138. package/dist/schedule/cron.d.ts +7 -0
  139. package/dist/schedule/cron.js +33 -0
  140. package/dist/schedule/discover.d.ts +17 -0
  141. package/dist/schedule/discover.js +64 -0
  142. package/dist/schedule/schedule.d.ts +35 -0
  143. package/dist/schedule/schedule.js +23 -0
  144. package/dist/schedule/scheduler.d.ts +36 -0
  145. package/dist/schedule/scheduler.js +253 -0
  146. package/dist/schedule/state.d.ts +14 -0
  147. package/dist/schedule/state.js +56 -0
  148. package/dist/schedule/wakeups.d.ts +82 -0
  149. package/dist/schedule/wakeups.js +196 -0
  150. package/dist/telegram.d.ts +2 -0
  151. package/dist/telegram.js +2 -0
  152. package/dist/tunnel.d.ts +31 -0
  153. package/dist/tunnel.js +139 -0
  154. package/dist/version.d.ts +5 -0
  155. package/dist/version.js +13 -0
  156. package/dist/workspace.d.ts +9 -0
  157. package/dist/workspace.js +45 -0
  158. package/package.json +97 -0
@@ -0,0 +1,285 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ /**
10
+ * The config subsystem: schema (defineConfig), loading (loadConfig), and value interpretation
11
+ * (resolveModel, resolveModelSpec). One concern: everything about fastagent.config.ts.
12
+ *
13
+ * Red line: config describes deployment/runtime choices, never authored identity or expertise (those
14
+ * live in persona.md + skills, with AGENTS.md as project context). Deleting the config still leaves a
15
+ * zero-config agent runnable with a model supplied by --model / FASTAGENT_MODEL.
16
+ */
17
+ import { existsSync, lstatSync, realpathSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+ import { moduleLoadHint } from "../../loader.js";
22
+ /** Identity function for typing and IDE completion (vite/next-style). */
23
+ export function defineConfig(config) {
24
+ return config;
25
+ }
26
+ /** A valid bindable port. */
27
+ export function isValidPort(n) {
28
+ return Number.isInteger(n) && n >= 0 && n <= 65535;
29
+ }
30
+ /** Validate an optional `string[]` config field where each entry must match `shape` — used for
31
+ * deploy.secrets (env-var names) and deploy.apt (package names); undefined is fine (field omitted). */
32
+ function validateStringList(value, key, shape, desc, path) {
33
+ if (value === undefined)
34
+ return;
35
+ if (!Array.isArray(value))
36
+ throw new Error(`${path}: "${key}" must be an array of strings`);
37
+ for (const [i, v] of value.entries()) {
38
+ if (typeof v !== "string" || !shape.test(v)) {
39
+ throw new Error(`${path}: "${key}[${i}]" must be ${desc}`);
40
+ }
41
+ }
42
+ }
43
+ /** The config filenames that make a directory a fastagent workspace, in load precedence. ONE source: the
44
+ * loader (below) and `scaffoldWorkspace`'s already-a-workspace refusal both read this, so "is there a
45
+ * config?" can't diverge between them when the set changes. */
46
+ export const WORKSPACE_CONFIG_NAMES = ["fastagent.config.ts", "fastagent.config.js", "fastagent.config.mjs"];
47
+ /** Load `<dir>/fastagent.config.ts|.js|.mjs`. No file = zero-config; a wrong-shape file throws. */
48
+ export async function loadConfig(dir) {
49
+ const found = WORKSPACE_CONFIG_NAMES.map((name) => join(dir, name)).filter((path) => existsSync(path));
50
+ if (found.length === 0)
51
+ return { config: {} };
52
+ if (found.length > 1) {
53
+ throw new Error(`${dir}: multiple fastagent config files found; keep exactly one (${found.map((p) => basename(p)).join(", ")})`);
54
+ }
55
+ // biome-ignore lint/style/noNonNullAssertion: length checked above — exactly one element here
56
+ const path = found[0];
57
+ let mod;
58
+ try {
59
+ mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)));
60
+ }
61
+ catch (error) {
62
+ throw new Error(`${path}: ${error.message}${moduleLoadHint(error)}`);
63
+ }
64
+ const config = mod.default;
65
+ if (!config || typeof config !== "object") {
66
+ throw new Error(`${path}: must default-export defineConfig({...})`);
67
+ }
68
+ const c = config;
69
+ // Unknown keys throw: defineConfig only type-protects .ts authors; a typo in a .js/.mjs config
70
+ // (`modle:`) must not silently degrade to zero-config.
71
+ for (const key of Object.keys(c)) {
72
+ if (key !== "model" &&
73
+ key !== "agentDir" &&
74
+ key !== "tools" &&
75
+ key !== "http" &&
76
+ key !== "deploy" &&
77
+ key !== "selfSchedule") {
78
+ throw new Error(`${path}: unknown key "${key}" (valid keys: model, agentDir, tools, http, deploy, selfSchedule)`);
79
+ }
80
+ }
81
+ if (c.model !== undefined && typeof c.model !== "string") {
82
+ throw new Error(`${path}: "model" must be a "provider/modelId" string`);
83
+ }
84
+ if (c.agentDir !== undefined && typeof c.agentDir !== "string") {
85
+ throw new Error(`${path}: "agentDir" must be a string (a subdirectory relative to the config file)`);
86
+ }
87
+ if (typeof c.agentDir === "string") {
88
+ // Enforce the documented "subdirectory of the config dir" contract: an escaping agentDir (e.g.
89
+ // "../shared") would still resolve for tool/channel/persona discovery, but `dev`'s chokidar only
90
+ // watches the config dir subtree — edits outside it would silently never trigger a restart. Reject
91
+ // it here (fail visibly) rather than let hot-reload break without a signal.
92
+ const rel = relative(dir, resolve(dir, c.agentDir));
93
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
94
+ throw new Error(`${path}: "agentDir" ("${c.agentDir}") must be a subdirectory of the config directory, not escape it`);
95
+ }
96
+ // An explicitly declared agentDir that doesn't exist is a typo until proven otherwise ("./agnet"):
97
+ // without this check every opener would assemble an EMPTY agent (no persona, no skills, no tools)
98
+ // with zero errors — the worst silent failure this config can produce. Deliberately NOT auto-created:
99
+ // config load is read-only (no implicit operations), and a mkdir would turn the typo into a served
100
+ // empty agent plus a junk directory.
101
+ // lstat, not stat: a symlink would pass the literal containment check above while its TARGET lives
102
+ // outside the config dir — exactly what that check exists to prevent (dev's watch would silently
103
+ // never see edits). Same rule as init's parent preflight: reject, don't follow.
104
+ const agentDirAbs = resolve(dir, c.agentDir);
105
+ const st = lstatSync(agentDirAbs, { throwIfNoEntry: false });
106
+ if (!st) {
107
+ throw new Error(`${path}: "agentDir" ("${c.agentDir}") does not exist — create it, or fix the path`);
108
+ }
109
+ if (st.isSymbolicLink()) {
110
+ // Separate message: to its user a symlink LOOKS like a working directory — name the reason and the fix.
111
+ throw new Error(`${path}: "agentDir" ("${c.agentDir}") is a symlink — not allowed (its target can live outside the ` +
112
+ `config directory, where dev's watch would never see edits); use a real directory, or point agentDir at the target's real path`);
113
+ }
114
+ if (!st.isDirectory()) {
115
+ throw new Error(`${path}: "agentDir" ("${c.agentDir}") is not a directory`);
116
+ }
117
+ // The leaf lstat can't see a symlinked INTERMEDIATE segment (agentDir "./a/b" with `a` → outside):
118
+ // realpath equality covers every segment under the config dir in one comparison. dir itself is
119
+ // realpath'd on both sides, so a symlinked config-dir path (macOS /tmp) stays legal.
120
+ if (realpathSync(agentDirAbs) !== resolve(realpathSync(dir), relative(dir, agentDirAbs))) {
121
+ throw new Error(`${path}: "agentDir" ("${c.agentDir}") resolves through a symlink — not allowed (the target can ` +
122
+ `live outside the config directory, where dev's watch would never see edits); use the real path`);
123
+ }
124
+ }
125
+ if (c.selfSchedule !== undefined && typeof c.selfSchedule !== "boolean") {
126
+ throw new Error(`${path}: "selfSchedule" must be a boolean`);
127
+ }
128
+ if (c.tools !== undefined && !Array.isArray(c.tools)) {
129
+ throw new Error(`${path}: "tools" must be an array of AgentTool`);
130
+ }
131
+ if (c.tools !== undefined) {
132
+ for (const [i, tool] of c.tools.entries()) {
133
+ if (!tool || typeof tool !== "object") {
134
+ throw new Error(`${path}: "tools[${i}]" must be an AgentTool object`);
135
+ }
136
+ const candidate = tool;
137
+ if (typeof candidate.name !== "string" || typeof candidate.execute !== "function") {
138
+ throw new Error(`${path}: "tools[${i}]" must have string "name" and function "execute"`);
139
+ }
140
+ }
141
+ }
142
+ if (c.http !== undefined && (typeof c.http !== "object" || c.http === null)) {
143
+ throw new Error(`${path}: "http" must be an object`);
144
+ }
145
+ for (const key of Object.keys(c.http ?? {})) {
146
+ if (key !== "port") {
147
+ throw new Error(`${path}: unknown key "http.${key}" (valid keys: port)`);
148
+ }
149
+ }
150
+ if (c.http?.port !== undefined && (typeof c.http.port !== "number" || !isValidPort(c.http.port))) {
151
+ throw new Error(`${path}: "http.port" must be an integer 0-65535`);
152
+ }
153
+ if (c.deploy !== undefined && (typeof c.deploy !== "object" || c.deploy === null)) {
154
+ throw new Error(`${path}: "deploy" must be an object`);
155
+ }
156
+ for (const key of Object.keys(c.deploy ?? {})) {
157
+ if (key !== "secrets" && key !== "apt") {
158
+ throw new Error(`${path}: unknown key "deploy.${key}" (valid keys: secrets, apt)`);
159
+ }
160
+ }
161
+ // secrets are UPPER_SNAKE env-var names (deploy reads their VALUES from the local env); apt entries are
162
+ // Debian package names. Both go into a shell/env context on the host, so shape-validate them — catches a
163
+ // typo and refuses an injection-shaped value from an otherwise-trusted config.
164
+ validateStringList(c.deploy?.secrets, "deploy.secrets", /^[A-Z_][A-Z0-9_]*$/, "an UPPER_SNAKE env-var name", path);
165
+ validateStringList(c.deploy?.apt, "deploy.apt", /^[a-z0-9][a-z0-9.+-]*$/, "a Debian package name", path);
166
+ return { config: c, path };
167
+ }
168
+ /**
169
+ * The agent-definition dir from config: `config.agentDir` resolved against `dir`, or `dir` itself when
170
+ * unset (flat). The ONE place this is computed — every opener (`dev`/`start`/`info`/`tool`/`deploy`/`chat`)
171
+ * calls it, so the "relative to the config dir, default `.`" rule can never diverge. loadConfig has
172
+ * already validated that agentDir stays under `dir`.
173
+ */
174
+ export function resolveAgentDir(dir, config) {
175
+ return resolve(dir, config.agentDir ?? ".");
176
+ }
177
+ /** Resolve "provider/modelId" → a pi Model from `models`, so the harness resolves auth from the same collection. */
178
+ export function resolveModel(models, spec) {
179
+ const slash = spec.indexOf("/");
180
+ if (slash < 1 || slash === spec.length - 1) {
181
+ throw new Error(`model must be "provider/modelId" (e.g. "openai-codex/gpt-5.5"), got "${spec}"`);
182
+ }
183
+ const provider = spec.slice(0, slash);
184
+ const modelId = spec.slice(slash + 1);
185
+ const model = models.getModel(provider, modelId);
186
+ if (!model) {
187
+ throw new Error(`unknown model "${spec}" (provider "${provider}" / id "${modelId}" not in registry); run \`fastagent models\` to list available specs`);
188
+ }
189
+ return model;
190
+ }
191
+ /** All registered "provider/modelId" specs in `models`, sorted — the list behind `fastagent models`. */
192
+ export function listModels(models) {
193
+ const specs = [];
194
+ for (const provider of models.getProviders()) {
195
+ for (const model of provider.getModels())
196
+ specs.push(`${provider.id}/${model.id}`);
197
+ }
198
+ return specs.sort();
199
+ }
200
+ /**
201
+ * Rewrite the `model` in a config file's SOURCE TEXT to `spec`, for the first-run picker's write-back.
202
+ * Handles the scaffold's commented placeholder (`// model: "…"`) and an existing `model:` line; returns
203
+ * null when neither is present (zero-config or a hand-shaped config) so the caller falls back to a
204
+ * printed hint instead of guessing where to insert. Text-level (not AST) on purpose — it only ever
205
+ * touches a line it recognizes, never reformats the author's file.
206
+ */
207
+ export function rewriteConfigModel(src, spec) {
208
+ const line = ` model: ${JSON.stringify(spec)},`;
209
+ const commented = /^[ \t]*\/\/[ \t]*model:.*$/m;
210
+ const active = /^[ \t]*model:[ \t]*["'].*$/m;
211
+ if (commented.test(src))
212
+ return src.replace(commented, line);
213
+ if (active.test(src))
214
+ return src.replace(active, line);
215
+ return null;
216
+ }
217
+ /** Model selection precedence: CLI flag > FASTAGENT_MODEL env > config default. */
218
+ export function resolveModelSpec(flag, config, env = process.env) {
219
+ return flag ?? env.FASTAGENT_MODEL ?? config.model;
220
+ }
221
+ /**
222
+ * Resolve a user-supplied path override (a CLI flag or an env var) to an absolute path, expanding a
223
+ * leading `~`/`~/` to the home dir FIRST. Path-valued config from `.env` (or any non-shell source)
224
+ * never gets the shell's `~` expansion, so a bare `resolve("~/x")` would silently create a literal `~`
225
+ * directory — a fail-silently footgun for a secret/state path. Expanding here makes `~` mean home
226
+ * everywhere these knobs are read.
227
+ */
228
+ function resolveOverridePath(raw) {
229
+ if (!raw)
230
+ return undefined;
231
+ const expanded = raw === "~" ? homedir() : raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : raw;
232
+ return resolve(expanded);
233
+ }
234
+ /**
235
+ * `start`'s sessions-dir override: `--sessions-dir` flag > `FASTAGENT_SESSIONS_DIR` env > undefined
236
+ * (the opener then falls back to {@link defaultSessionsDir} under the {@link resolveStateRoot} root).
237
+ * Resolved to absolute so the store and the startup report agree regardless of cwd.
238
+ */
239
+ export function resolveSessionsDirOverride(flag, env = process.env) {
240
+ return resolveOverridePath(flag ?? env.FASTAGENT_SESSIONS_DIR);
241
+ }
242
+ /**
243
+ * The auth-file override: `--auth-path` flag > `FASTAGENT_AUTH_PATH` env > undefined (the opener then
244
+ * falls back to {@link defaultAuthPath} under the {@link resolveStateRoot} root). Resolved to absolute
245
+ * so the store and the startup report agree regardless of cwd. No implicit project↔global fallback (isolation
246
+ * + fail-visibly; see auth.ts); to share one account across projects, point this at the global
247
+ * `~/.fastagent/auth.json` — sharing ONE file is safe under the store's cross-process refresh lock.
248
+ */
249
+ export function resolveAuthPathOverride(flag, env = process.env) {
250
+ return resolveOverridePath(flag ?? env.FASTAGENT_AUTH_PATH);
251
+ }
252
+ /**
253
+ * The IN-TREE default state root, `<dir>/.fastagent` — what {@link resolveStateRoot} falls back to when
254
+ * `FASTAGENT_STATE_DIR` moves state nowhere. THE single definition of that path segment.
255
+ */
256
+ export function projectStateDir(dir) {
257
+ return join(dir, ".fastagent");
258
+ }
259
+ /**
260
+ * The resolved state root — the ONE durable machine-state home everything derives from (auth.json,
261
+ * sessions/, channels/<kind>/): `FASTAGENT_STATE_DIR` env > `<dir>/.fastagent`. Absolute, so channels
262
+ * and the startup report agree regardless of cwd. Definition: single lifecycle (precious, survives
263
+ * redeploy), single process — a container mounts ONE volume here. The finer knobs
264
+ * (`FASTAGENT_SESSIONS_DIR`, `FASTAGENT_AUTH_PATH`) still override their specific path on top.
265
+ *
266
+ * `FASTAGENT_STATE_DIR` is an OPERATOR override, so a relative value resolves against `process.cwd()`
267
+ * — the CLI convention its sibling knobs share (`resolveOverridePath`), NOT against `dir`. Only the
268
+ * DEFAULT (`<dir>/.fastagent`) is dir-anchored. Deployments set an absolute path (a mounted volume);
269
+ * a relative value is in-tree — hence self-ignored — only when run from the definition dir (cwd == dir).
270
+ */
271
+ export function resolveStateRoot(dir, env = process.env) {
272
+ return resolveOverridePath(env.FASTAGENT_STATE_DIR) ?? resolve(projectStateDir(dir));
273
+ }
274
+ /** The default credentials file under a resolved state root ({@link resolveStateRoot}). */
275
+ export function defaultAuthPath(stateRoot) {
276
+ return join(stateRoot, "auth.json");
277
+ }
278
+ /** The effective auth file for a workspace: override if present, else the project-level auth.json. */
279
+ export function resolveAuthPath(dir, flag, env = process.env) {
280
+ return resolveAuthPathOverride(flag, env) ?? defaultAuthPath(resolveStateRoot(dir, env));
281
+ }
282
+ /** The default sessions dir under a resolved state root ({@link resolveStateRoot}). */
283
+ export function defaultSessionsDir(stateRoot) {
284
+ return join(stateRoot, "sessions");
285
+ }
@@ -0,0 +1,127 @@
1
+ import type { AgentTool, ExecutionEnv, Skill } from "@earendil-works/pi-agent-core";
2
+ import type { Provider } from "@earendil-works/pi-ai";
3
+ import type { Agent } from "../../agent.ts";
4
+ import { type FastagentConfig } from "./config.ts";
5
+ import { type LoadedDefinition } from "./definition.ts";
6
+ import { type PiSessionStore } from "./sessions.ts";
7
+ import type { ModuleLoadFailure } from "../../loader.ts";
8
+ import { type ToolCollision } from "./tool.ts";
9
+ import { type Lease } from "./invoke.ts";
10
+ /** pi's core default toolset (read/bash/edit/write), rooted at cwd. */
11
+ export declare function piDefaultTools(cwd: string): AgentTool[];
12
+ /** `config.tools` semantics: extra tools APPENDED after pi's defaults, never replacing them. */
13
+ export declare function resolveTools(config: FastagentConfig, cwd: string): AgentTool[];
14
+ /**
15
+ * The full tool set a workspace mounts: pi defaults + `config.tools` + discovered `tools/` (deduped,
16
+ * existing win), plus the non-default tool names and collisions to report. One source for the
17
+ * dev/start openers AND `fastagent tool`, so they all mount exactly the same set.
18
+ */
19
+ export declare function resolveWorkspaceTools(config: FastagentConfig, agentDir: string, cwd?: string): Promise<{
20
+ tools: AgentTool[];
21
+ toolNames: string[];
22
+ toolCollisions: ToolCollision[];
23
+ toolFailures: ModuleLoadFailure[];
24
+ }>;
25
+ /**
26
+ * The pi engine's base prompt (segment ①), mirroring pi-coding-agent's default path with two
27
+ * deviations: the pi-TUI docs section is dropped (those paths don't exist in deployments), and the
28
+ * tool list is generated from the actually-mounted tools (base and toolset must agree). An authored
29
+ * `persona` (from persona.md) replaces the default identity line, keeping the tools list + guidelines.
30
+ */
31
+ export declare function piBasePrompt(options?: {
32
+ tools?: AgentTool[];
33
+ persona?: string;
34
+ }): string;
35
+ export interface AssembleSystemPromptOptions {
36
+ /**
37
+ * Base prompt (①), REQUIRED — no default: a defaulted piBasePrompt() would render
38
+ * "Available tools: (none)" even when tools are mounted. Pass piBasePrompt({ tools }) for pi.
39
+ */
40
+ base: string;
41
+ /** ② project-context files (AGENTS.md et al. from loadProjectContextFiles); each wrapped `<project_instructions path=…>`. */
42
+ contextFiles?: Array<{
43
+ path: string;
44
+ content: string;
45
+ }>;
46
+ /** ③ Skills for the <available_skills> listing. */
47
+ skills?: Skill[];
48
+ /** ④ Env context, caller-provided (keeps this function pure). Omitted = segment omitted. */
49
+ date?: string;
50
+ cwd?: string;
51
+ }
52
+ export declare function assembleSystemPrompt(options: AssembleSystemPromptOptions): string;
53
+ /** L1 options. Tier 1: model (spec) + instructions + tools. Tier 2: the injectable ports. */
54
+ export interface CreatePiAgentOptions {
55
+ /** Model spec "provider/modelId" (e.g. "openai-codex/gpt-5.5"), resolved against {@link models}. */
56
+ model: string;
57
+ /**
58
+ * The system prompt itself — verbatim, no engine base and no wrapping (unlike the directory path,
59
+ * which assembles the engine base + AGENTS.md as segment ② + persona.md as segment ①). A plain string
60
+ * or a factory re-evaluated per invoke. When {@link skills} are mounted their listing is appended.
61
+ */
62
+ instructions?: string | (() => string);
63
+ tools?: AgentTool[];
64
+ skills?: Skill[];
65
+ /**
66
+ * Extra providers registered on top of the built-ins — your own gateway / self-hosted endpoint /
67
+ * test fake — selected by the `model` spec's provider id. Built-ins cover the rest; static keys
68
+ * still come from the {@link authPath} credentials file (fastagent login) or env, not from here.
69
+ */
70
+ providers?: Provider[];
71
+ /**
72
+ * Credentials file for stored OAuth/API-key auth. Defaults to `~/.fastagent/auth.json`; the
73
+ * directory opener passes the project-level `<dir>/.fastagent/auth.json` instead. Env vars are still
74
+ * consulted when a provider is absent from the file (resolution order is upstream-owned).
75
+ */
76
+ authPath?: string;
77
+ /** Session persistence. Defaults to in-memory; inject jsonlSessionStore for restart-surviving continuity. */
78
+ sessions?: PiSessionStore;
79
+ /** Harness filesystem/process environment. Defaults to local NodeExecutionEnv (cwd). This is not yet
80
+ * a sandbox boundary for pi's cwd-bound coding tools; a sandbox adapter must wire those tools too. */
81
+ env?: ExecutionEnv;
82
+ /** Single-writer lease. Defaults to in-process fail-fast inProcessLease(). */
83
+ lease?: Lease;
84
+ }
85
+ /** L1: assemble from typed parts. */
86
+ export declare function createPiAgent(options: CreatePiAgentOptions): Agent;
87
+ /**
88
+ * L2 options. `instructions`/`skills` are absent by design — they come from the definition directory
89
+ * (AGENTS.md + skills/), which is the whole point of L2.
90
+ */
91
+ export interface CreatePiAgentFromDefinitionOptions {
92
+ /** Model spec "provider/modelId", resolved against {@link models}. */
93
+ model: string;
94
+ /** Override the engine base prompt (segment ①). Defaults to piBasePrompt({ tools, persona }) using the
95
+ * live-read persona.md; pass base to fully opt out of persona.md. */
96
+ base?: string;
97
+ /** Override tools. Defaults to piDefaultTools (lock down with a custom list). */
98
+ tools?: AgentTool[];
99
+ /**
100
+ * The agent's working directory: where the default tools operate AND whose ancestors are walked for
101
+ * ② project context (AGENTS.md). Defaults to `dir` (flat: the definition dir is also the run root).
102
+ * Set it to the enclosing repo so a coding agent whose definition lives in `dir` operates on — and
103
+ * reads the AGENTS.md of — that repo (core.md scenario grid).
104
+ */
105
+ cwd?: string;
106
+ /** Extra providers registered on top of the built-ins (your own gateway / self-hosted endpoint). */
107
+ providers?: Provider[];
108
+ /**
109
+ * Credentials file (see {@link CreatePiAgentOptions.authPath}). Being dir-aware, this rung defaults
110
+ * to the PROJECT-level `<dir>/.fastagent/auth.json` (matching `fastagent dev`/`start` on the same
111
+ * dir) — unlike the dir-less {@link createPiAgent}/{@link createPiModels}, which default global.
112
+ */
113
+ authPath?: string;
114
+ sessions?: PiSessionStore;
115
+ /** Harness environment; see {@link CreatePiAgentOptions.env}. The default coding tools and project-
116
+ * context loader remain local today, so injecting this alone does not sandbox a directory agent. */
117
+ env?: ExecutionEnv;
118
+ lease?: Lease;
119
+ }
120
+ /**
121
+ * L2: "point at a directory → agent": load + assemble (base + AGENTS.md + skills + env) + L1 in one
122
+ * call. Returns the definition so callers can surface diagnostics/collisions.
123
+ */
124
+ export declare function createPiAgentFromDefinition(dir: string, options: CreatePiAgentFromDefinitionOptions): Promise<{
125
+ agent: Agent;
126
+ definition: LoadedDefinition;
127
+ }>;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Agent assembly (configuration-time): the engine assets (tools, prompt) plus the reusable ladder
3
+ * that puts a pi agent together.
4
+ *
5
+ * L2 createPiAgentFromDefinition(dir, options) — load a definition directory, assemble, then L1.
6
+ * L1 createPiAgent(options) — assemble from typed parts (the canonical ctor).
7
+ * L0 createPiAgentFromHarness({ harnessFactory }) — in invoke.ts (its body is the turn mechanism).
8
+ *
9
+ * Above L2 sits the workspace opener createPiAgentFromWorkspace (workspace.ts), which both `dev` and
10
+ * `start` drive. Each rung calls the one below; options narrow as you go up (L2 owns systemPrompt/skills —
11
+ * they come from the definition; the openers own model/tools — from config resolution).
12
+ */
13
+ import { formatSkillsForSystemPrompt } from "@earendil-works/pi-agent-core";
14
+ import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
15
+ import { createCodingTools } from "@earendil-works/pi-coding-agent";
16
+ import { defaultAuthPath, resolveModel, resolveStateRoot } from "./config.js";
17
+ import { loadAgentDefinition } from "./definition.js";
18
+ import { piHarnessFactory } from "./harness.js";
19
+ import { createPiModels } from "./models.js";
20
+ import { reportDefinitionWarnings } from "./report.js";
21
+ import { inMemorySessionStore } from "./sessions.js";
22
+ import { loadTools, mergeDiscoveredTools } from "./tool.js";
23
+ import { createPiAgentFromHarness } from "./invoke.js";
24
+ // ── §1 tools ─────────────────────────────────────────────────────────────────
25
+ //
26
+ // The full pi toolset is the default for fidelity: authors vibe in local pi with it, so serving with
27
+ // fewer tools is behavior drift. Isolation is the K-side ExecutionEnv/sandbox's job, not the tool
28
+ // layer's; locking down for public exposure = passing a restricted `tools` list (a deployment posture).
29
+ /** pi's core default toolset (read/bash/edit/write), rooted at cwd. */
30
+ export function piDefaultTools(cwd) {
31
+ return createCodingTools(cwd);
32
+ }
33
+ /** `config.tools` semantics: extra tools APPENDED after pi's defaults, never replacing them. */
34
+ export function resolveTools(config, cwd) {
35
+ const defaults = piDefaultTools(cwd);
36
+ return config.tools ? [...defaults, ...config.tools] : defaults;
37
+ }
38
+ /**
39
+ * The full tool set a workspace mounts: pi defaults + `config.tools` + discovered `tools/` (deduped,
40
+ * existing win), plus the non-default tool names and collisions to report. One source for the
41
+ * dev/start openers AND `fastagent tool`, so they all mount exactly the same set.
42
+ */
43
+ export async function resolveWorkspaceTools(config, agentDir, cwd = agentDir) {
44
+ // Default coding tools (read/bash/edit/write) are rooted at `cwd` (the run root the agent operates on);
45
+ // discovered `tools/` come from `agentDir` (the agent's own surface). They coincide in the flat case.
46
+ const discovered = await loadTools(agentDir);
47
+ const { tools, collisions } = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
48
+ const toolCollisions = [...discovered.collisions, ...collisions];
49
+ const defaultNames = new Set(piDefaultTools(cwd).map((t) => t.name));
50
+ const toolNames = tools.map((t) => t.name).filter((n) => !defaultNames.has(n));
51
+ return { tools, toolNames, toolCollisions, toolFailures: discovered.failures };
52
+ }
53
+ // ── §2 prompt: four-segment systemPrompt assembly ───────────────────────────
54
+ //
55
+ // systemPrompt = ① base (engine asset; a persona.md persona overrides its identity line)
56
+ // + ② project context (AGENTS.md files via pi's loadProjectContextFiles, <project_context>-wrapped)
57
+ // + ③ skills listing + ④ env context (date/cwd)
58
+ //
59
+ // AGENTS.md ≠ system prompt. Pure functions: segment ④ inputs (date/cwd) are caller-provided, so the
60
+ // same inputs always produce the same prompt (testable, reproducible).
61
+ /**
62
+ * The pi engine's base prompt (segment ①), mirroring pi-coding-agent's default path with two
63
+ * deviations: the pi-TUI docs section is dropped (those paths don't exist in deployments), and the
64
+ * tool list is generated from the actually-mounted tools (base and toolset must agree). An authored
65
+ * `persona` (from persona.md) replaces the default identity line, keeping the tools list + guidelines.
66
+ */
67
+ export function piBasePrompt(options = {}) {
68
+ const tools = options.tools ?? [];
69
+ const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
70
+ // Segment ① identity: an authored persona (persona.md) replaces the default engine identity line
71
+ // (the standalone×code-repo cell's persona; core.md §11), keeping the tools list + guidelines below.
72
+ const identity = options.persona?.trim() ||
73
+ "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
74
+ return `${identity}
75
+
76
+ Available tools:
77
+ ${toolsList}
78
+
79
+ In addition to the tools above, you may have access to other custom tools depending on the project.
80
+
81
+ Guidelines:
82
+ - Be concise in your responses
83
+ - Show file paths clearly when working with files`;
84
+ }
85
+ export function assembleSystemPrompt(options) {
86
+ let prompt = options.base;
87
+ const contextFiles = options.contextFiles ?? [];
88
+ if (contextFiles.length > 0) {
89
+ // Mirrors pi's system-prompt.js: one <project_context> block, one <project_instructions path=…> per file.
90
+ prompt += `\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n`;
91
+ for (const { path, content } of contextFiles) {
92
+ prompt += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
93
+ }
94
+ prompt += `</project_context>\n`;
95
+ }
96
+ if (options.skills && options.skills.length > 0) {
97
+ prompt += `\n${formatSkillsForSystemPrompt(options.skills)}\n`;
98
+ }
99
+ if (options.date)
100
+ prompt += `\nCurrent date: ${options.date}`;
101
+ if (options.cwd)
102
+ prompt += `\nCurrent working directory: ${options.cwd}`;
103
+ return prompt;
104
+ }
105
+ // ── §3 the reusable assembly ladder: L1 / L2 ────────────────────────────────
106
+ /**
107
+ * Shared low-level wiring: resolve the model spec against the collection, default the K ports, build
108
+ * the agent. Internal — the public rungs decide the systemPrompt (L1 from instructions, L2 from the
109
+ * directory) and route through here.
110
+ */
111
+ function buildPiAgent(opts) {
112
+ const models = createPiModels({ providers: opts.providers, authPath: opts.authPath });
113
+ return createPiAgentFromHarness({
114
+ lease: opts.lease,
115
+ harnessFactory: piHarnessFactory({
116
+ sessions: opts.sessions ?? inMemorySessionStore(),
117
+ env: opts.env ?? new NodeExecutionEnv({ cwd: process.cwd() }),
118
+ models,
119
+ model: resolveModel(models, opts.model),
120
+ systemPrompt: opts.systemPrompt,
121
+ tools: opts.tools,
122
+ skills: opts.skills,
123
+ live: opts.live,
124
+ }),
125
+ });
126
+ }
127
+ /**
128
+ * L1 system prompt: `instructions` ARE the prompt (no engine base, no wrapping); the skills listing
129
+ * is appended only when skills are mounted (the model must know what it can invoke). A factory so a
130
+ * dynamic `instructions` and per-invoke freshness both work; undefined when there is nothing to send.
131
+ */
132
+ function instructionsPrompt(instructions, skills) {
133
+ const hasSkills = skills !== undefined && skills.length > 0;
134
+ if (instructions === undefined && !hasSkills)
135
+ return undefined;
136
+ return () => {
137
+ const prose = typeof instructions === "function" ? instructions() : (instructions ?? "");
138
+ const listing = hasSkills ? formatSkillsForSystemPrompt(skills) : "";
139
+ return [prose, listing].filter((s) => s !== "").join("\n");
140
+ };
141
+ }
142
+ /** L1: assemble from typed parts. */
143
+ export function createPiAgent(options) {
144
+ return buildPiAgent({
145
+ model: options.model,
146
+ providers: options.providers,
147
+ authPath: options.authPath,
148
+ systemPrompt: instructionsPrompt(options.instructions, options.skills),
149
+ tools: options.tools,
150
+ skills: options.skills,
151
+ sessions: options.sessions,
152
+ env: options.env,
153
+ lease: options.lease,
154
+ });
155
+ }
156
+ /** Stable identity of a definition's non-fatal findings, for change-detection in `live` (dedup only). */
157
+ function findingsSignature(def) {
158
+ const collisions = def.collisions.map((c) => `c:${c.name}:${c.winnerPath}:${c.loserPath}`);
159
+ const diagnostics = def.diagnostics.map((d) => `d:${d.code}:${d.path}`);
160
+ return [...collisions, ...diagnostics].sort().join("\n");
161
+ }
162
+ /**
163
+ * L2: "point at a directory → agent": load + assemble (base + AGENTS.md + skills + env) + L1 in one
164
+ * call. Returns the definition so callers can surface diagnostics/collisions.
165
+ */
166
+ export async function createPiAgentFromDefinition(dir, options) {
167
+ // `dir` = the agent-definition dir (persona.md/skills/); `cwd` (default = dir) is the run root where
168
+ // tools operate and whose ancestors are walked for ② context.
169
+ const cwd = options.cwd ?? dir;
170
+ const env = options.env ?? new NodeExecutionEnv({ cwd });
171
+ // Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to
172
+ // report (skills/diagnostics/collisions). Serving does NOT close over it — see `live` below.
173
+ const definition = await loadAgentDefinition(dir, { cwd: env.cwd, env });
174
+ // Findings the caller already reported at boot; `live` re-reports only when the set CHANGES — a
175
+ // runtime-written bad skill surfaces the moment it appears, while a static finding does not spam
176
+ // every turn's log. A log-dedup memo, not session state (stateless invoke holds).
177
+ let reportedFindings = findingsSignature(definition);
178
+ const tools = options.tools ?? piDefaultTools(env.cwd);
179
+ const agent = buildPiAgent({
180
+ model: options.model,
181
+ providers: options.providers,
182
+ // Dir-aware default: the same state-root-derived file the opener uses for this dir (the opener
183
+ // passes an explicit authPath, so this only affects direct L2 callers).
184
+ authPath: options.authPath ?? defaultAuthPath(resolveStateRoot(dir)),
185
+ // The directory is the agent, LIVE: re-read the definition on every invoke, so AGENTS.md/skills
186
+ // edits (the author's, or the agent's own self-modification) take effect on the next turn with
187
+ // no process restart — restarts are reserved for code (tools/channels/config, module cache).
188
+ // One read yields prompt AND skills (they can never diverge), `date` is the turn's date, and the
189
+ // fs cost is a few reads against a model call. Broken edits stay visible: a throw-class problem
190
+ // (unreadable AGENTS.md) fails that turn's invoke, and the loader's NON-fatal findings (bad
191
+ // SKILL.md frontmatter, name collisions — returned as data, not thrown) are warned the moment
192
+ // the finding set changes (boot findings are the baseline) — a runtime-written bad skill must
193
+ // not silently vanish from the agent, and a static one must not spam every turn's log. The
194
+ // next good edit heals both.
195
+ live: async () => {
196
+ const def = await loadAgentDefinition(dir, { cwd: env.cwd, env });
197
+ const sig = findingsSignature(def);
198
+ if (sig !== reportedFindings) {
199
+ reportedFindings = sig;
200
+ reportDefinitionWarnings(def.collisions, def.diagnostics);
201
+ }
202
+ return {
203
+ systemPrompt: assembleSystemPrompt({
204
+ // Segment ①: an authored persona (persona.md, def.persona) overrides the engine identity,
205
+ // re-read per turn like AGENTS.md so edits go live; options.base still wins for full control.
206
+ base: options.base ?? piBasePrompt({ tools, persona: def.persona }),
207
+ // ② project context: AGENTS.md files (agentDir + cwd-ancestor walk) via loadProjectContextFiles.
208
+ contextFiles: def.contextFiles,
209
+ skills: def.skills,
210
+ date: new Date().toISOString().slice(0, 10),
211
+ cwd: env.cwd,
212
+ }),
213
+ skills: def.skills,
214
+ };
215
+ },
216
+ tools,
217
+ sessions: options.sessions,
218
+ env,
219
+ lease: options.lease,
220
+ });
221
+ return { agent, definition };
222
+ }