@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,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: writing-great-skills
|
|
3
|
+
description: Reference for writing and editing skills well — the vocabulary and principles that make a skill predictable.
|
|
4
|
+
disable-model-invocation: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
A skill exists to wrangle determinism out of a stochastic system. **Predictability** — the agent taking the same _process_ every run, not producing the same output — is the root virtue; every lever below serves it.
|
|
8
|
+
|
|
9
|
+
**Bold terms** are defined in [`GLOSSARY.md`](GLOSSARY.md); look them up there for the full meaning.
|
|
10
|
+
|
|
11
|
+
## Invocation
|
|
12
|
+
|
|
13
|
+
Two choices, trading different costs:
|
|
14
|
+
|
|
15
|
+
- A **model-invoked** skill keeps a **description**, so the agent can fire it autonomously _and_ other skills can reach it (you can still type its name too). It contributes to **context load** — the description sits in the window every turn. Mechanics: omit `disable-model-invocation`, and write a model-facing description with rich trigger phrasing ("Use when the user wants…, mentions…").
|
|
16
|
+
- A **user-invoked** skill strips the description from the agent's reach: only you, typing its name, can invoke it — and no other skill can. Zero context load, but it spends **cognitive load**: _you_ are the index that must remember it exists. Mechanics: set `disable-model-invocation: true`; the `description` becomes human-facing — a one-line summary, trigger lists stripped.
|
|
17
|
+
|
|
18
|
+
Pick model-invocation only when the agent must reach the skill on its own, or another skill must. If it only ever fires by hand, make it user-invoked and pay no context load.
|
|
19
|
+
|
|
20
|
+
When user-invoked skills multiply past what you can remember, that piled-up cognitive load is cured by a **router skill**: one user-invoked skill that names the others and when to reach for each.
|
|
21
|
+
|
|
22
|
+
## Writing the description
|
|
23
|
+
|
|
24
|
+
A model-invoked **description** does two jobs — state what the skill is, and list the **branches** that should trigger it. Every word increases **context load**, so a description earns even harder pruning than the body:
|
|
25
|
+
|
|
26
|
+
- **Front-load the skill's leading word** — the description is where it does its invocation work.
|
|
27
|
+
- **One trigger per branch.** Synonyms that rename a single branch are **duplication** — "build features using TDD … asks for test-first development" is one branch written twice. Collapse them; keep only genuinely distinct branches.
|
|
28
|
+
- **Cut identity that's already in the body.** Keep the description to triggers, plus any "when another skill needs…" reach clause.
|
|
29
|
+
|
|
30
|
+
## Information hierarchy
|
|
31
|
+
|
|
32
|
+
A skill is built from two content types — **steps** and **reference** — that mix freely: a skill can be all steps, all reference, or both. The core decision is which to use and where each sits on the **information hierarchy**, a ladder ranked by how immediately the agent needs the material:
|
|
33
|
+
|
|
34
|
+
1. **In-skill step** — an ordered action in `SKILL.md`, the primary tier: what the agent does, in order. Each step ends on a **completion criterion**, the condition that tells the agent the work is done. Make it _checkable_ (can the agent tell done from not-done?) and, where it matters, _exhaustive_ ("every modified model accounted for", not "produce a change list") — a vague criterion invites **premature completion**.
|
|
35
|
+
2. **In-skill reference** — a definition, rule, or fact in `SKILL.md`, consulted on demand. Often a legitimately flat peer-set (every rule of a review on one rung) — a fine arrangement, not a smell. _This skill is all reference._
|
|
36
|
+
3. **External reference** — reference pushed out of `SKILL.md` into a separate file, reached by a **context pointer**, loaded only when the pointer fires. (Spans _disclosed_ reference — a sibling file like `GLOSSARY.md`, still part of the skill — through fully **external reference** that lives outside the skill system and any skill can point at.)
|
|
37
|
+
|
|
38
|
+
A demanding completion criterion drives thorough **legwork** — the digging the agent does within the work — whether the skill has steps or not, since "every rule applied" binds flat reference just as "every step done" binds a sequence.
|
|
39
|
+
|
|
40
|
+
Push too little down and the top bloats; push too much and you hide material the agent actually needs. That tension is the whole decision.
|
|
41
|
+
|
|
42
|
+
**Progressive disclosure** is the move down the ladder — out of `SKILL.md` into a linked file — so the top stays legible. Mechanics: a linked `.md` file in the skill folder, named for what it holds (this skill discloses its full definitions to `GLOSSARY.md`). Some skills are used in more than one way, and each distinct way is a **branch** — different runs taking different paths through the skill. Branching is the cleanest disclosure test: inline what every branch needs, and push behind a pointer what only some branches reach. A **context pointer**'s _wording_, not its target, decides when and how reliably the agent reaches the material.
|
|
43
|
+
|
|
44
|
+
Where the ladder decides _how far down_ a piece sits, **co-location** decides _what sits beside it_ once there: keep a concept's definition, rules, and caveats under one heading rather than scattered, so reading one part brings its neighbours with it.
|
|
45
|
+
|
|
46
|
+
## When to split
|
|
47
|
+
|
|
48
|
+
**Granularity** is how finely you divide skills, and each cut spends one of the two loads, so split only when the cut earns it. Two cuts:
|
|
49
|
+
|
|
50
|
+
- **By invocation** — split off a **model-invoked** skill when you have a distinct **leading word** that should trigger it on its own, or another skill must reach it. You pay **context load** for the new always-loaded **description**, so that independent reach has to be worth it.
|
|
51
|
+
- **By sequence** — split a run of **steps** when the steps still ahead (a step's **post-completion steps**) tempt the agent to rush the one in front of it (**premature completion**). Keeping them out of view encourages the agent to do more **legwork** on the current task.
|
|
52
|
+
|
|
53
|
+
## Pruning
|
|
54
|
+
|
|
55
|
+
Keep each meaning in a **single source of truth**: one authoritative place, so changing the behaviour is a one-place edit.
|
|
56
|
+
|
|
57
|
+
Check every line for **relevance**: does it still bear on what the skill does?
|
|
58
|
+
|
|
59
|
+
Then hunt **no-ops** sentence by sentence, not just line by line: run the no-op test on each sentence in isolation, and when one fails, delete the whole sentence rather than trim words from it. Be aggressive — most prose that fails should go, not be rewritten.
|
|
60
|
+
|
|
61
|
+
## Leading words
|
|
62
|
+
|
|
63
|
+
A **leading word** is a compact concept already living in the model's pretraining that the agent thinks with while running the skill (e.g. _lesson_, _fog of war_, _tracer bullets_). Repeated throughout the text (though not necessarily - a strong leading word might only be needed once), it accumulates a distributed definition and anchors a whole region of behaviour in the fewest tokens, by recruiting priors the model already holds.
|
|
64
|
+
|
|
65
|
+
It serves predictability twice. In the body it anchors _execution_: the agent reaches for the same behaviour every time the word appears. In the description it anchors _invocation_: when the same word lives in your prompts, docs, and code, the agent links that shared language to the skill and fires it more reliably.
|
|
66
|
+
|
|
67
|
+
Hunt for opportunities to refactor skills to use leading words. A triad spelled out at three sites (**duplication**), a description spending a sentence to gesture at one idea — each is a passage begging to **collapse** into a single token. Examples include:
|
|
68
|
+
|
|
69
|
+
- "fast, deterministic, low-overhead" -> _tight_ — one quality restated across a phase — into a single pretrained word (a _tight_ loop).
|
|
70
|
+
- "a loop you believe in" -> _red_ — converts a fuzzy gate into a binary observable state (the loop goes _red_ on the bug, or it doesn't).
|
|
71
|
+
|
|
72
|
+
You win twice over: fewer tokens, _and_ a sharper hook for the agent to hang its thinking on. Assume every skill is carrying restatements that leading words retire — go find them.
|
|
73
|
+
|
|
74
|
+
## Failure modes
|
|
75
|
+
|
|
76
|
+
Use these to diagnose issues the user may be having with the skill.
|
|
77
|
+
|
|
78
|
+
- **Premature completion** — ending a step before it's genuinely done, attention slipping to _being done_. Defence, in order: sharpen the completion criterion first (cheap, local); only if it is irreducibly fuzzy _and_ you observe the rush, hide the post-completion steps by splitting (the sequence cut).
|
|
79
|
+
- **Duplication** — the same meaning in more than one place. Costs maintenance and tokens, and inflates a meaning's prominence on the ladder past its real rank.
|
|
80
|
+
- **Sediment** — stale layers that settle because adding feels safe and removing feels risky. The default fate of any skill without a pruning discipline.
|
|
81
|
+
- **Sprawl** — a skill simply too long, even when every line is live and unique. Hurts readability and maintainability and wastes tokens. The cure is the ladder: disclose **reference** behind pointers, and split by **branch** or sequence so each path carries only what it needs.
|
|
82
|
+
- **No-op** — a line the model already obeys by default, so you pay load to say nothing. The test: does it change behaviour versus the default? A weak leading word (_be thorough_ when the agent is already thorough-ish) is a no-op; the fix is a stronger word (_relentless_), not a different technique.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineTool, z } from "@fastagent-sh/fastagent";
|
|
2
|
+
|
|
3
|
+
// A code tool: filename (fetch-url.ts) is the tool name. tools/ is auto-discovered,
|
|
4
|
+
// so it needs no registration in fastagent.config. Test it without a model:
|
|
5
|
+
// fastagent tool fetch-url '{"url":"https://example.com"}'
|
|
6
|
+
// Serving this behind a public channel? The URL then comes from untrusted users — add an
|
|
7
|
+
// allowlist or block private-network addresses (localhost, 169.254.169.254, …) to prevent SSRF.
|
|
8
|
+
const MAX_TEXT = 20_000; // keep a huge page from flooding the model's context
|
|
9
|
+
|
|
10
|
+
export default defineTool({
|
|
11
|
+
description: "Fetch a web page and return its readable text (HTML stripped, truncated).",
|
|
12
|
+
input: z.object({ url: z.url({ protocol: /^https?$/ }).describe("The http(s) URL to fetch") }),
|
|
13
|
+
async execute({ url }) {
|
|
14
|
+
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(15_000) });
|
|
15
|
+
if (!res.ok) throw new Error(`GET ${url} failed: ${res.status} ${res.statusText}`);
|
|
16
|
+
const html = await res.text();
|
|
17
|
+
// ponytail: naive tag strip — fine for articles and docs; swap in a readability library if it falls short.
|
|
18
|
+
const entities: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", nbsp: " " };
|
|
19
|
+
const text = html
|
|
20
|
+
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, " ")
|
|
21
|
+
.replace(/<[^>]+>/g, " ")
|
|
22
|
+
.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (_, e) => entities[e] ?? " ")
|
|
23
|
+
.replace(/\s+/g, " ")
|
|
24
|
+
.trim();
|
|
25
|
+
return { url: res.url, text: text.slice(0, MAX_TEXT), truncated: text.length > MAX_TEXT };
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Read a base workspace template (src/scaffold/templates/<name>). */
|
|
2
|
+
export declare const baseTemplate: (name: string) => string;
|
|
3
|
+
/** The persona template; in the agentDir layout a locator note is appended (anchor-free — no silent-miss
|
|
4
|
+
* risk) so the self-iteration guidance ("write skills beside this file") points into the kit, not the
|
|
5
|
+
* run root — a skill written to the root `skills/` would never be scanned. */
|
|
6
|
+
export declare function personaTemplate(agentDir?: string): string;
|
|
7
|
+
/** The config template; when the kit is placed in a subdirectory, `agentDir` is injected as the first key. */
|
|
8
|
+
export declare function configTemplate(agentDir?: string): string;
|
|
9
|
+
/** Read one file from a channel's scaffold bundle (src/channels/<kind>/scaffold/<name>). */
|
|
10
|
+
export declare const channelTemplate: (kind: string, name: string) => string;
|
|
11
|
+
/** The .ts files in a channel's bundle: `channel.ts` is the channel adapter; the rest are companion tools. */
|
|
12
|
+
export declare const channelBundleFiles: (kind: string) => string[];
|
|
13
|
+
/** package.json for the complete agent. The @fastagent-sh/fastagent range tracks THIS build's
|
|
14
|
+
* version, and tool authors use its `z` re-export rather than installing a second zod copy. */
|
|
15
|
+
export declare function packageJson(name: string, version: string): string;
|
|
16
|
+
/** Sanitize a directory basename into a valid npm package name (lowercase, safe chars). */
|
|
17
|
+
export declare function toPackageName(dir: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffold template ACCESS (data, not logic): readers for the real files `init`/`add` write into a
|
|
3
|
+
* workspace, plus the parametric pieces. Base workspace templates live under ./templates/; each
|
|
4
|
+
* channel's bundle lives WITH the channel at ../channels/<kind>/scaffold/ (so a channel owns its
|
|
5
|
+
* starter kit and could ship as its own package). Both trees are excluded from this package's tsc +
|
|
6
|
+
* biome (they import the published @fastagent-sh/fastagent, not this source) and copied into dist/ by the build.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
9
|
+
import { basename, resolve } from "node:path";
|
|
10
|
+
/** Read a base workspace template (src/scaffold/templates/<name>). */
|
|
11
|
+
export const baseTemplate = (name) => readFileSync(new URL(`./templates/${name}`, import.meta.url), "utf8");
|
|
12
|
+
/** The persona template; in the agentDir layout a locator note is appended (anchor-free — no silent-miss
|
|
13
|
+
* risk) so the self-iteration guidance ("write skills beside this file") points into the kit, not the
|
|
14
|
+
* run root — a skill written to the root `skills/` would never be scanned. */
|
|
15
|
+
export function personaTemplate(agentDir) {
|
|
16
|
+
const base = baseTemplate("persona.md");
|
|
17
|
+
if (!agentDir)
|
|
18
|
+
return base;
|
|
19
|
+
return `${base}\nNote: your definition lives under \`${agentDir}/\` relative to the workspace root — this file is \`${agentDir}/persona.md\`, and a new skill goes to \`${agentDir}/skills/<name>/SKILL.md\` (a \`skills/\` at the root is not scanned).\n`;
|
|
20
|
+
}
|
|
21
|
+
/** The config template; when the kit is placed in a subdirectory, `agentDir` is injected as the first key. */
|
|
22
|
+
export function configTemplate(agentDir) {
|
|
23
|
+
const base = baseTemplate("fastagent.config.mjs");
|
|
24
|
+
if (!agentDir)
|
|
25
|
+
return base;
|
|
26
|
+
const out = base.replace("export default {\n", `export default {\n agentDir: ${JSON.stringify(agentDir)}, // the agent's own surface (persona.md / skills / tools / channels) lives there\n`);
|
|
27
|
+
// Fail visibly if the template drifted and the anchor no longer matches — a config that silently
|
|
28
|
+
// doesn't point at the kit would assemble an EMPTY agent with no error. Check the OPERATION happened
|
|
29
|
+
// (out !== base), not a substring — a commented `agentDir:` example in the template would fool that.
|
|
30
|
+
if (out === base)
|
|
31
|
+
throw new Error("configTemplate: anchor not found in fastagent.config.mjs template");
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
const channelScaffoldDir = (kind) => new URL(`../channels/${kind}/scaffold/`, import.meta.url);
|
|
35
|
+
/** Read one file from a channel's scaffold bundle (src/channels/<kind>/scaffold/<name>). */
|
|
36
|
+
export const channelTemplate = (kind, name) => readFileSync(new URL(name, channelScaffoldDir(kind)), "utf8");
|
|
37
|
+
/** The .ts files in a channel's bundle: `channel.ts` is the channel adapter; the rest are companion tools. */
|
|
38
|
+
export const channelBundleFiles = (kind) => readdirSync(channelScaffoldDir(kind)).filter((f) => f.endsWith(".ts"));
|
|
39
|
+
/** package.json for the complete agent. The @fastagent-sh/fastagent range tracks THIS build's
|
|
40
|
+
* version, and tool authors use its `z` re-export rather than installing a second zod copy. */
|
|
41
|
+
export function packageJson(name, version) {
|
|
42
|
+
return `${JSON.stringify({
|
|
43
|
+
name,
|
|
44
|
+
private: true,
|
|
45
|
+
type: "module",
|
|
46
|
+
dependencies: { "@fastagent-sh/fastagent": `^${version}` },
|
|
47
|
+
}, null, 2)}\n`;
|
|
48
|
+
}
|
|
49
|
+
/** Sanitize a directory basename into a valid npm package name (lowercase, safe chars). */
|
|
50
|
+
export function toPackageName(dir) {
|
|
51
|
+
const base = basename(resolve(dir))
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/[^a-z0-9._-]/g, "-")
|
|
54
|
+
.replace(/^[._-]+/, "");
|
|
55
|
+
return base || "agent";
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type LoadedDefinition } from "../engines/pi/definition.ts";
|
|
2
|
+
export interface VendoredSkill {
|
|
3
|
+
/** The skill's real name (from SKILL.md frontmatter, per the Agent Skills spec). */
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
/** Workspace-relative destination (e.g. `skills/pdf`). */
|
|
7
|
+
dest: string;
|
|
8
|
+
/** The skill ships a `scripts/` dir (executable code) — a trust signal for the caller. */
|
|
9
|
+
hasScripts: boolean;
|
|
10
|
+
/** Spec diagnostics for THIS skill from the runtime loader (e.g. name ≠ dir). */
|
|
11
|
+
diagnostics: LoadedDefinition["diagnostics"];
|
|
12
|
+
/** True when an existing skill was overwritten (--update); false for a fresh vendor. */
|
|
13
|
+
overwritten: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Vendor an Agent Skills skill into `<workspace>/skills/<name>/` from a giget ref (github default), a
|
|
17
|
+
* local path, or a bare name (resolved against the local global skill dirs). Copy-in, git-tracked.
|
|
18
|
+
* Refuses to overwrite unless `options.update` (then a plain git-tracked overwrite, never a merge).
|
|
19
|
+
* Validates a staging copy with the runtime loader BEFORE replacing, so a bad fetch never destroys an
|
|
20
|
+
* existing skill.
|
|
21
|
+
*/
|
|
22
|
+
export declare function vendorSkill(workspaceDir: string, source: string, options?: {
|
|
23
|
+
update?: boolean;
|
|
24
|
+
}): Promise<VendoredSkill>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent add skill <source>`: vendor an Agent Skills skill into `<workspace>/skills/<name>/` —
|
|
3
|
+
* copy-in, git-tracked, never a runtime registry. Source is a giget ref (github default), a local
|
|
4
|
+
* path, or a bare name (resolved against the local global skill dirs as an add-time copy source only).
|
|
5
|
+
* Fetch → staging → validate → rollback-protected replace, so a bad fetch never destroys an existing
|
|
6
|
+
* skill.
|
|
7
|
+
*/
|
|
8
|
+
import { cp, mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { loadAgentDefinition } from "../engines/pi/definition.js";
|
|
14
|
+
import { assertInsideWorkspace } from "../workspace.js";
|
|
15
|
+
/** Derive the destination skill name from a source ref: the last path segment, sans `#ref`. */
|
|
16
|
+
function skillNameFromSource(source) {
|
|
17
|
+
const noRef = source.split("#")[0] ?? source;
|
|
18
|
+
return basename(noRef.replace(/\/+$/, ""));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The skills/ path must be safe to write through: never follow a symlink that escapes the workspace
|
|
22
|
+
* (mkdir would), and reject a plain file with one clear message.
|
|
23
|
+
*/
|
|
24
|
+
async function assertSkillsDirUsable(workspaceDir) {
|
|
25
|
+
await assertInsideWorkspace(workspaceDir, "skills");
|
|
26
|
+
const skillsDir = join(workspaceDir, "skills");
|
|
27
|
+
const st = await stat(skillsDir).catch(() => undefined);
|
|
28
|
+
if (st && !st.isDirectory()) {
|
|
29
|
+
throw new Error(`${skillsDir} exists and is not a directory — remove it (skills must be a directory)`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** A local source is an explicit path (./x, ../x, /abs); anything else is a giget ref or a bare name. */
|
|
33
|
+
function isLocalSource(source) {
|
|
34
|
+
return source.startsWith("./") || source.startsWith("../") || source === "." || isAbsolute(source);
|
|
35
|
+
}
|
|
36
|
+
/** A bare name (no `/`, no `scheme:`) resolves against the local global skill dirs (see below). */
|
|
37
|
+
function isBareName(source) {
|
|
38
|
+
return !source.includes("/") && !/^[a-z][a-z0-9+.-]*:/i.test(source);
|
|
39
|
+
}
|
|
40
|
+
/** Local "global" skill dirs, used ONLY as an add-time vendoring source (a bare `add skill <name>`
|
|
41
|
+
* copies the match in, git-tracked) — nothing is loaded from here at run time. */
|
|
42
|
+
function findGlobalSkillSource(name) {
|
|
43
|
+
for (const root of [join(homedir(), ".agents", "skills"), join(homedir(), ".pi", "agent", "skills")]) {
|
|
44
|
+
if (existsSync(join(root, name, "SKILL.md")))
|
|
45
|
+
return join(root, name);
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Vendor an Agent Skills skill into `<workspace>/skills/<name>/` from a giget ref (github default), a
|
|
51
|
+
* local path, or a bare name (resolved against the local global skill dirs). Copy-in, git-tracked.
|
|
52
|
+
* Refuses to overwrite unless `options.update` (then a plain git-tracked overwrite, never a merge).
|
|
53
|
+
* Validates a staging copy with the runtime loader BEFORE replacing, so a bad fetch never destroys an
|
|
54
|
+
* existing skill.
|
|
55
|
+
*/
|
|
56
|
+
export async function vendorSkill(workspaceDir, source, options = {}) {
|
|
57
|
+
const name = skillNameFromSource(source);
|
|
58
|
+
if (name === "" || name === "." || name === "..") {
|
|
59
|
+
throw new Error(`cannot derive a skill name from "${source}" — point at a skill directory (…/skills/<name>)`);
|
|
60
|
+
}
|
|
61
|
+
const skillsDir = join(workspaceDir, "skills");
|
|
62
|
+
await assertSkillsDirUsable(workspaceDir);
|
|
63
|
+
const dest = join(skillsDir, name);
|
|
64
|
+
// A process can die between moving the old skill aside and installing staging. Never guess that an
|
|
65
|
+
// arbitrary hidden directory is ours or delete it: stop with the exact backup path for manual restore.
|
|
66
|
+
const skillEntries = await readdir(skillsDir).catch((error) => {
|
|
67
|
+
if (error.code === "ENOENT")
|
|
68
|
+
return [];
|
|
69
|
+
throw error;
|
|
70
|
+
});
|
|
71
|
+
const interrupted = skillEntries.filter((entry) => entry.startsWith(`.${name}.previous-`));
|
|
72
|
+
if (interrupted.length > 0) {
|
|
73
|
+
throw new Error(`found an interrupted skill update backup (${interrupted.join(", ")}) — inspect it, then restore it to skills/${name} or remove it before retrying`);
|
|
74
|
+
}
|
|
75
|
+
// Refuse to clobber unless --update; a git-tracked overwrite is safe (review with `git diff`, undo
|
|
76
|
+
// with `git checkout`).
|
|
77
|
+
const overwritten = existsSync(dest);
|
|
78
|
+
if (overwritten && !options.update) {
|
|
79
|
+
throw new Error(`skills/${name} already exists — re-run with --update to overwrite it (git tracks the change), or remove it`);
|
|
80
|
+
}
|
|
81
|
+
await mkdir(skillsDir, { recursive: true });
|
|
82
|
+
// Fetch into a STAGING dir (same filesystem → atomic rename), validate, and only THEN replace dest,
|
|
83
|
+
// so a failed/invalid fetch never destroys an existing skill. The leading "." keeps the loader from
|
|
84
|
+
// treating staging as a skill.
|
|
85
|
+
const staging = join(skillsDir, `.${name}.vendoring`);
|
|
86
|
+
await rm(staging, { recursive: true, force: true }); // clear any leftover from a prior crash
|
|
87
|
+
try {
|
|
88
|
+
if (isLocalSource(source)) {
|
|
89
|
+
const src = resolve(source);
|
|
90
|
+
if (!existsSync(join(src, "SKILL.md"))) {
|
|
91
|
+
throw new Error(`"${source}" has no SKILL.md — an Agent Skills skill is a directory containing SKILL.md`);
|
|
92
|
+
}
|
|
93
|
+
await cp(src, staging, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
else if (isBareName(source)) {
|
|
96
|
+
// bare name → vendor from a local global skill dir (add-time copy, not a runtime scan).
|
|
97
|
+
const src = findGlobalSkillSource(source);
|
|
98
|
+
if (!src) {
|
|
99
|
+
throw new Error(`no skill "${source}" in your global skill dirs (~/.agents/skills, ~/.pi/agent/skills) — ` +
|
|
100
|
+
`give a git ref (owner/repo/path) or a local path instead`);
|
|
101
|
+
}
|
|
102
|
+
await cp(src, staging, { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// giget defaults a BARE ref to its own template registry, not github — so default the provider to
|
|
106
|
+
// github for a plain `owner/repo/path` (an explicit `github:`/`gh:`/`gitlab:`… scheme is kept).
|
|
107
|
+
// Supports a subdir + #ref, fetched via the tar API (no git binary).
|
|
108
|
+
const ref = /^[a-z][a-z0-9+.-]*:/i.test(source) ? source : `github:${source}`;
|
|
109
|
+
// Lazy import: giget is only needed for a git ref, so the serve path (index.ts → init.ts) and the
|
|
110
|
+
// local/bare-name sources never load it.
|
|
111
|
+
const { downloadTemplate } = await import("giget");
|
|
112
|
+
await downloadTemplate(ref, { dir: staging, force: true });
|
|
113
|
+
}
|
|
114
|
+
if (!existsSync(join(staging, "SKILL.md"))) {
|
|
115
|
+
throw new Error(`"${source}" did not yield a SKILL.md — expected an Agent Skills skill directory`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
await rm(staging, { recursive: true, force: true }); // failed/invalid: drop staging, leave dest intact
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
// Install with rollback: move the old skill to a unique backup, then put staging in place. A failed
|
|
123
|
+
// second rename restores it; a process crash leaves a backup the next invocation reports without
|
|
124
|
+
// touching it.
|
|
125
|
+
const previous = join(skillsDir, `.${name}.previous-${randomUUID()}`);
|
|
126
|
+
if (overwritten)
|
|
127
|
+
await rename(dest, previous);
|
|
128
|
+
try {
|
|
129
|
+
await rename(staging, dest);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (overwritten) {
|
|
133
|
+
try {
|
|
134
|
+
await rename(previous, dest);
|
|
135
|
+
}
|
|
136
|
+
catch (restoreError) {
|
|
137
|
+
throw new AggregateError([error, restoreError], `skill update failed and ${previous} could not be restored`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
if (overwritten)
|
|
143
|
+
await rm(previous, { recursive: true, force: true });
|
|
144
|
+
// Report via the runtime loader, matching THIS skill by EXACT directory (a substring match would
|
|
145
|
+
// prefix-pollute a sibling `<name>-x` and break on Windows path separators).
|
|
146
|
+
const def = await loadAgentDefinition(workspaceDir);
|
|
147
|
+
const rel = join("skills", name);
|
|
148
|
+
const skill = def.skills.find((sk) => relative(workspaceDir, dirname(sk.filePath)) === rel);
|
|
149
|
+
return {
|
|
150
|
+
name: skill?.name ?? name,
|
|
151
|
+
description: skill?.description,
|
|
152
|
+
dest: rel,
|
|
153
|
+
hasScripts: existsSync(join(dest, "scripts")),
|
|
154
|
+
diagnostics: def.diagnostics.filter((d) => d.path !== undefined && relative(workspaceDir, dirname(d.path)) === rel),
|
|
155
|
+
overwritten,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface RunRecord {
|
|
2
|
+
/** The schedule's name for a cron fire; `"wake"` for a self-scheduled wake-up (its session tells which). */
|
|
3
|
+
name: string;
|
|
4
|
+
session: string;
|
|
5
|
+
firedAt: string;
|
|
6
|
+
ms: number;
|
|
7
|
+
/** `deferred` = a wake into a busy session, re-scheduled (not a final outcome for that wake-up). */
|
|
8
|
+
outcome: "completed" | "failed" | "deferred";
|
|
9
|
+
/** The turn's full reply text (completed). Delivery is the agent's tools' job — this is the audit copy. */
|
|
10
|
+
reply?: string;
|
|
11
|
+
/** The failure details (failed). */
|
|
12
|
+
error?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Append one run record. Total — an audit-write failure is logged and swallowed (never breaks a fire). */
|
|
15
|
+
export declare function appendRun(stateRoot: string, record: RunRecord): void;
|
|
16
|
+
/** Read the run history (optionally filtered by name), oldest first. A malformed line is skipped with a
|
|
17
|
+
* warn (fail-visible, like the wakeups store); a missing file is an empty history (first run). */
|
|
18
|
+
export declare function readRuns(stateRoot: string, name?: string): RunRecord[];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scheduler's run audit: ONE line per fired turn in `<stateRoot>/schedule/runs.jsonl` — the answer
|
|
3
|
+
* to cron's classic pain, "did last night's run silently fail?". Append-only JSONL (grows, never
|
|
4
|
+
* rewritten — the same shape as the core session store), carrying the FULL reply text: a run record is
|
|
5
|
+
* an immutable snapshot of what that fire produced, which the rolling session (where multiple fires
|
|
6
|
+
* interleave with user turns) cannot give you per-fire. Operational audit only — the conversational
|
|
7
|
+
* truth stays in the session store.
|
|
8
|
+
*
|
|
9
|
+
* Appending is TOTAL (failures are logged, never thrown): the audit must not be able to break a fire.
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { log } from "../log.js";
|
|
14
|
+
function runsPath(stateRoot) {
|
|
15
|
+
return join(stateRoot, "schedule", "runs.jsonl");
|
|
16
|
+
}
|
|
17
|
+
/** Append one run record. Total — an audit-write failure is logged and swallowed (never breaks a fire). */
|
|
18
|
+
export function appendRun(stateRoot, record) {
|
|
19
|
+
try {
|
|
20
|
+
const path = runsPath(stateRoot);
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
log.warn(`[schedule] could not append the run audit (the fire itself is unaffected): ${String(e)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Read the run history (optionally filtered by name), oldest first. A malformed line is skipped with a
|
|
29
|
+
* warn (fail-visible, like the wakeups store); a missing file is an empty history (first run). */
|
|
30
|
+
export function readRuns(stateRoot, name) {
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(runsPath(stateRoot), "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e.code === "ENOENT")
|
|
37
|
+
return [];
|
|
38
|
+
throw new Error(`run audit ${runsPath(stateRoot)} is unreadable: ${String(e)}`, { cause: e });
|
|
39
|
+
}
|
|
40
|
+
const records = [];
|
|
41
|
+
for (const line of raw.split("\n")) {
|
|
42
|
+
if (line.trim() === "")
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
const r = JSON.parse(line);
|
|
46
|
+
if (typeof r.name !== "string" || typeof r.firedAt !== "string" || typeof r.outcome !== "string") {
|
|
47
|
+
throw new Error("missing fields");
|
|
48
|
+
}
|
|
49
|
+
if (name === undefined || r.name === name)
|
|
50
|
+
records.push(r);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
log.warn(`[schedule] skipping a malformed run-audit line: ${line.slice(0, 80)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return records;
|
|
57
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The next scheduled instant STRICTLY AFTER `from` (in `tz`, default UTC), or undefined if the
|
|
2
|
+
* expression will never fire again. */
|
|
3
|
+
export declare function nextRun(cron: string, tz: string | undefined, from: Date): Date | undefined;
|
|
4
|
+
/** Why `cron`/`tz` is invalid (for load-time validation), or undefined if valid. croner validates the
|
|
5
|
+
* timezone lazily (not at construction), so check it explicitly via Intl (throws on an unknown IANA
|
|
6
|
+
* zone); the pattern is validated by constructing the Cron. Both turned into a message, never a throw. */
|
|
7
|
+
export declare function cronError(cron: string, tz: string | undefined): string | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that touches the cron library (`croner`). The scheduler owns FIRE control (for
|
|
3
|
+
* durability / overdue catch-up), so it borrows only croner's next-instant computation, not its
|
|
4
|
+
* scheduling. croner is chosen for zero transitive dependencies + built-in IANA timezone/DST handling
|
|
5
|
+
* (wall-clock schedules like "daily 9am America/New_York" need DST-correct arithmetic that hand-rolling
|
|
6
|
+
* gets wrong at the spring-forward / fall-back boundary).
|
|
7
|
+
*/
|
|
8
|
+
import { Cron } from "croner";
|
|
9
|
+
/** The next scheduled instant STRICTLY AFTER `from` (in `tz`, default UTC), or undefined if the
|
|
10
|
+
* expression will never fire again. */
|
|
11
|
+
export function nextRun(cron, tz, from) {
|
|
12
|
+
return new Cron(cron, { timezone: tz ?? "UTC" }).nextRun(from) ?? undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Why `cron`/`tz` is invalid (for load-time validation), or undefined if valid. croner validates the
|
|
15
|
+
* timezone lazily (not at construction), so check it explicitly via Intl (throws on an unknown IANA
|
|
16
|
+
* zone); the pattern is validated by constructing the Cron. Both turned into a message, never a throw. */
|
|
17
|
+
export function cronError(cron, tz) {
|
|
18
|
+
if (tz !== undefined) {
|
|
19
|
+
try {
|
|
20
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return `unknown timezone "${tz}"`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
new Cron(cron, { timezone: tz ?? "UTC" });
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
return e.message;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ModuleLoadFailure } from "../loader.ts";
|
|
2
|
+
import type { LoadedSchedule } from "./schedule.ts";
|
|
3
|
+
/** Schedule file basenames under `<dir>/schedules/` — an existence probe listed WITHOUT importing
|
|
4
|
+
* (deploy pre-flight's time-trigger detection; `fastagent info` uses {@link loadSchedules} instead,
|
|
5
|
+
* since it also reports broken files and next instants). */
|
|
6
|
+
export declare function discoverScheduleFiles(dir: string): Promise<string[]>;
|
|
7
|
+
/**
|
|
8
|
+
* Discover schedules in `<dir>/schedules/`: each file default-exports a `defineSchedule({...})`, named
|
|
9
|
+
* from its filename. A file broken for ANY reason — a failed import, not a schedule (no cron/prompt), or
|
|
10
|
+
* an invalid cron/tz — is ISOLATED into `failures` (skipped + reported, never a crash-loop, G2), the same
|
|
11
|
+
* way `loadTools`/`loadChannels` isolate theirs. A duplicate name (foo.ts + foo.js) keeps the first and
|
|
12
|
+
* reports the rest.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadSchedules(dir: string): Promise<{
|
|
15
|
+
schedules: LoadedSchedule[];
|
|
16
|
+
failures: ModuleLoadFailure[];
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule discovery (the N axis, clock form): a workspace declares its time-triggers by dropping files
|
|
3
|
+
* in `schedules/`, mirroring `tools/` and `channels/`. Each file default-exports `defineSchedule({...})`,
|
|
4
|
+
* named from its filename. This is the FILE producer of scheduled invocations (the author's, declarative,
|
|
5
|
+
* git-tracked, deploy-guaranteed); the agent's `wake` tool is the second producer.
|
|
6
|
+
*/
|
|
7
|
+
import { readdir } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { isModuleFile, loadModuleDir } from "../loader.js";
|
|
10
|
+
import { assertInsideWorkspace } from "../workspace.js";
|
|
11
|
+
import { cronError } from "./cron.js";
|
|
12
|
+
/** Schedule file basenames under `<dir>/schedules/` — an existence probe listed WITHOUT importing
|
|
13
|
+
* (deploy pre-flight's time-trigger detection; `fastagent info` uses {@link loadSchedules} instead,
|
|
14
|
+
* since it also reports broken files and next instants). */
|
|
15
|
+
export async function discoverScheduleFiles(dir) {
|
|
16
|
+
await assertInsideWorkspace(dir, "schedules");
|
|
17
|
+
let names;
|
|
18
|
+
try {
|
|
19
|
+
names = await readdir(join(dir, "schedules"));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "ENOENT")
|
|
23
|
+
return [];
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
return names
|
|
27
|
+
.filter(isModuleFile)
|
|
28
|
+
.map((n) => n.replace(/\.(ts|js|mjs)$/, ""))
|
|
29
|
+
.sort();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Discover schedules in `<dir>/schedules/`: each file default-exports a `defineSchedule({...})`, named
|
|
33
|
+
* from its filename. A file broken for ANY reason — a failed import, not a schedule (no cron/prompt), or
|
|
34
|
+
* an invalid cron/tz — is ISOLATED into `failures` (skipped + reported, never a crash-loop, G2), the same
|
|
35
|
+
* way `loadTools`/`loadChannels` isolate theirs. A duplicate name (foo.ts + foo.js) keeps the first and
|
|
36
|
+
* reports the rest.
|
|
37
|
+
*/
|
|
38
|
+
export async function loadSchedules(dir) {
|
|
39
|
+
await assertInsideWorkspace(dir, "schedules");
|
|
40
|
+
const { modules, failures } = await loadModuleDir(join(dir, "schedules"));
|
|
41
|
+
const byName = new Map();
|
|
42
|
+
for (const { name, label, file, mod } of modules) {
|
|
43
|
+
try {
|
|
44
|
+
const s = mod.default;
|
|
45
|
+
if (!s || typeof s.cron !== "string" || typeof s.prompt !== "string") {
|
|
46
|
+
throw new Error(`${label} must default-export defineSchedule({ cron, prompt })`);
|
|
47
|
+
}
|
|
48
|
+
const err = cronError(s.cron, s.tz);
|
|
49
|
+
if (err)
|
|
50
|
+
throw new Error(`${label}: invalid cron/tz — ${err}`);
|
|
51
|
+
// "wake" is reserved: the run audit records self-scheduled wake-ups under that name, so a schedule
|
|
52
|
+
// named wake would make `schedule history wake` an unreadable mix of two different things.
|
|
53
|
+
if (name === "wake")
|
|
54
|
+
throw new Error(`${label}: "wake" is a reserved schedule name (the self-scheduling audit uses it)`);
|
|
55
|
+
if (byName.has(name))
|
|
56
|
+
throw new Error(`${label}: duplicate schedule name "${name}" — kept the first`);
|
|
57
|
+
byName.set(name, { name, cron: s.cron, tz: s.tz, prompt: s.prompt });
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
failures.push({ label, file, message: error.message });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { schedules: [...byName.values()], failures };
|
|
64
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule authoring: `defineSchedule` (the authoring surface). Drop a file in `schedules/`,
|
|
3
|
+
* default-export `defineSchedule({...})`, and it is discovered, named from the filename, and run by the
|
|
4
|
+
* scheduler — a time-trigger (the N axis, clock form) that fires the agent on a cron.
|
|
5
|
+
*
|
|
6
|
+
* // schedules/daily-digest.ts → schedule "daily-digest"
|
|
7
|
+
* export default defineSchedule({
|
|
8
|
+
* cron: "0 9 * * *",
|
|
9
|
+
* tz: "America/New_York",
|
|
10
|
+
* prompt: "Generate today's digest and send it to the team Telegram.",
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* A schedule carries NO session field: a session id is runtime conversational context (the K side —
|
|
14
|
+
* host-provided per invoke, core.md §10.1 / §7), not a build-time (M) value. The scheduler derives a
|
|
15
|
+
* stable per-schedule session from the name at runtime (like
|
|
16
|
+
* the telegram channel derives one from chat.id), so a schedule's turns share one continuing
|
|
17
|
+
* conversation, persisted by the core session store. Output is the AGENT's tools' job — the scheduler
|
|
18
|
+
* only fires and logs, it does not deliver.
|
|
19
|
+
*/
|
|
20
|
+
/** A time-triggered invocation: at each `cron` instant (in `tz`, default UTC) the scheduler invokes the
|
|
21
|
+
* agent with `prompt`. */
|
|
22
|
+
export interface Schedule {
|
|
23
|
+
/** 5-field cron expression (`minute hour day-of-month month day-of-week`). */
|
|
24
|
+
cron: string;
|
|
25
|
+
/** IANA timezone for the cron (default "UTC"). Wall-clock schedules (daily 9am) need it. */
|
|
26
|
+
tz?: string;
|
|
27
|
+
/** The turn's text = the job's instruction. The agent's tools decide where any output goes. */
|
|
28
|
+
prompt: string;
|
|
29
|
+
}
|
|
30
|
+
/** Identity function for typing + IDE completion (like `defineTool`/`defineConfig`). */
|
|
31
|
+
export declare function defineSchedule(schedule: Schedule): Schedule;
|
|
32
|
+
/** A loaded schedule: its authored fields plus the name derived from its filename (authoritative). */
|
|
33
|
+
export interface LoadedSchedule extends Schedule {
|
|
34
|
+
name: string;
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule authoring: `defineSchedule` (the authoring surface). Drop a file in `schedules/`,
|
|
3
|
+
* default-export `defineSchedule({...})`, and it is discovered, named from the filename, and run by the
|
|
4
|
+
* scheduler — a time-trigger (the N axis, clock form) that fires the agent on a cron.
|
|
5
|
+
*
|
|
6
|
+
* // schedules/daily-digest.ts → schedule "daily-digest"
|
|
7
|
+
* export default defineSchedule({
|
|
8
|
+
* cron: "0 9 * * *",
|
|
9
|
+
* tz: "America/New_York",
|
|
10
|
+
* prompt: "Generate today's digest and send it to the team Telegram.",
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* A schedule carries NO session field: a session id is runtime conversational context (the K side —
|
|
14
|
+
* host-provided per invoke, core.md §10.1 / §7), not a build-time (M) value. The scheduler derives a
|
|
15
|
+
* stable per-schedule session from the name at runtime (like
|
|
16
|
+
* the telegram channel derives one from chat.id), so a schedule's turns share one continuing
|
|
17
|
+
* conversation, persisted by the core session store. Output is the AGENT's tools' job — the scheduler
|
|
18
|
+
* only fires and logs, it does not deliver.
|
|
19
|
+
*/
|
|
20
|
+
/** Identity function for typing + IDE completion (like `defineTool`/`defineConfig`). */
|
|
21
|
+
export function defineSchedule(schedule) {
|
|
22
|
+
return schedule;
|
|
23
|
+
}
|