@sweny-ai/core 0.1.62 → 0.1.64
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/dist/__tests__/cascade.test.d.ts +11 -0
- package/dist/__tests__/cascade.test.js +449 -0
- package/dist/cli/main.js +4 -4
- package/dist/cli/marketplace.d.ts +103 -0
- package/dist/cli/marketplace.js +290 -0
- package/dist/cli/marketplace.test.d.ts +1 -0
- package/dist/cli/marketplace.test.js +355 -0
- package/dist/cli/new.d.ts +35 -2
- package/dist/cli/new.js +156 -51
- package/dist/cli/new.test.js +215 -2
- package/dist/executor.js +105 -26
- package/dist/schema.d.ts +724 -0
- package/dist/schema.js +51 -1
- package/dist/source-resolver.d.ts +22 -0
- package/dist/source-resolver.js +174 -0
- package/dist/sources.d.ts +9 -6
- package/dist/sources.js +13 -146
- package/dist/sources.test.js +2 -1
- package/dist/sources.validation.test.js +2 -1
- package/dist/templates.js +2 -1
- package/dist/types.d.ts +20 -0
- package/package.json +1 -1
package/dist/schema.js
CHANGED
|
@@ -63,12 +63,26 @@ export const skillZ = z
|
|
|
63
63
|
.refine((s) => s.tools.length > 0 || s.instruction || s.mcp, {
|
|
64
64
|
message: "Skill must provide at least one of: tools, instruction, or mcp",
|
|
65
65
|
});
|
|
66
|
+
/**
|
|
67
|
+
* NodeSources: either an array of Sources (additive — inherits cascade)
|
|
68
|
+
* or an object `{ only?: boolean, sources: Source[] }` where `only: true`
|
|
69
|
+
* blocks the cascade for that field.
|
|
70
|
+
*/
|
|
71
|
+
export const nodeSourcesZ = z.union([
|
|
72
|
+
z.array(sourceZ),
|
|
73
|
+
z.object({
|
|
74
|
+
only: z.boolean().optional(),
|
|
75
|
+
sources: z.array(sourceZ),
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
66
78
|
export const nodeZ = z.object({
|
|
67
79
|
name: z.string().min(1),
|
|
68
80
|
instruction: sourceZ,
|
|
69
81
|
skills: z.array(z.string()).default([]),
|
|
70
82
|
output: jsonSchemaZ.optional(),
|
|
71
83
|
max_turns: z.number().int().min(1).optional(),
|
|
84
|
+
rules: nodeSourcesZ.optional(),
|
|
85
|
+
context: nodeSourcesZ.optional(),
|
|
72
86
|
});
|
|
73
87
|
export const edgeZ = z.object({
|
|
74
88
|
from: z.string().min(1),
|
|
@@ -84,6 +98,8 @@ export const workflowZ = z.object({
|
|
|
84
98
|
edges: z.array(edgeZ),
|
|
85
99
|
entry: z.string().min(1),
|
|
86
100
|
skills: z.record(skillDefinitionZ).default({}),
|
|
101
|
+
rules: z.array(sourceZ).optional(),
|
|
102
|
+
context: z.array(sourceZ).optional(),
|
|
87
103
|
});
|
|
88
104
|
/** Parse + validate a raw object as a Workflow. Throws on invalid input. */
|
|
89
105
|
export function parseWorkflow(raw) {
|
|
@@ -269,12 +285,38 @@ export const workflowJsonSchema = {
|
|
|
269
285
|
type: "object",
|
|
270
286
|
required: ["id", "name", "nodes", "edges", "entry"],
|
|
271
287
|
additionalProperties: false,
|
|
272
|
-
$defs: {
|
|
288
|
+
$defs: {
|
|
289
|
+
Source: sourceJsonSchema,
|
|
290
|
+
NodeSources: {
|
|
291
|
+
oneOf: [
|
|
292
|
+
{ type: "array", items: { $ref: "#/$defs/Source" } },
|
|
293
|
+
{
|
|
294
|
+
type: "object",
|
|
295
|
+
required: ["sources"],
|
|
296
|
+
additionalProperties: false,
|
|
297
|
+
properties: {
|
|
298
|
+
only: { type: "boolean" },
|
|
299
|
+
sources: { type: "array", items: { $ref: "#/$defs/Source" } },
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
},
|
|
273
305
|
properties: {
|
|
274
306
|
id: { type: "string", minLength: 1 },
|
|
275
307
|
name: { type: "string", minLength: 1 },
|
|
276
308
|
description: { type: "string" },
|
|
277
309
|
entry: { type: "string", minLength: 1, description: "ID of the entry node" },
|
|
310
|
+
rules: {
|
|
311
|
+
type: "array",
|
|
312
|
+
items: { $ref: "#/$defs/Source" },
|
|
313
|
+
description: "Directives prepended to every node's instruction.",
|
|
314
|
+
},
|
|
315
|
+
context: {
|
|
316
|
+
type: "array",
|
|
317
|
+
items: { $ref: "#/$defs/Source" },
|
|
318
|
+
description: "Background knowledge prepended to every node's instruction.",
|
|
319
|
+
},
|
|
278
320
|
nodes: {
|
|
279
321
|
type: "object",
|
|
280
322
|
additionalProperties: {
|
|
@@ -298,6 +340,14 @@ export const workflowJsonSchema = {
|
|
|
298
340
|
minimum: 1,
|
|
299
341
|
description: "Max AI model turns for this node. When absent, the executor's default applies.",
|
|
300
342
|
},
|
|
343
|
+
rules: {
|
|
344
|
+
$ref: "#/$defs/NodeSources",
|
|
345
|
+
description: "Per-node rules. Additive by default; set { only: true } to block cascade.",
|
|
346
|
+
},
|
|
347
|
+
context: {
|
|
348
|
+
$ref: "#/$defs/NodeSources",
|
|
349
|
+
description: "Per-node context. Additive by default; set { only: true } to block cascade.",
|
|
350
|
+
},
|
|
301
351
|
},
|
|
302
352
|
},
|
|
303
353
|
},
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source resolution (Node-only).
|
|
3
|
+
*
|
|
4
|
+
* Runtime helpers that read files, hash content, and fetch URLs. These are
|
|
5
|
+
* separated from `./sources.ts` so the browser-safe bits of the Source type
|
|
6
|
+
* (Zod schema, classifier, types) can be imported without pulling `node:fs`
|
|
7
|
+
* or `node:crypto` into bundlers that target the browser.
|
|
8
|
+
*/
|
|
9
|
+
import type { ResolvedSource, Source, SourceResolutionContext, SourceResolutionMap } from "./sources.js";
|
|
10
|
+
/**
|
|
11
|
+
* Produce a stable 16-hex-char hash of `content` for use as a cache key /
|
|
12
|
+
* change-detection ID.
|
|
13
|
+
*
|
|
14
|
+
* Intentionally avoids `node:crypto` so this module can be bundled for the
|
|
15
|
+
* browser (studio SPA's in-browser simulator reaches this via the inline
|
|
16
|
+
* Source resolver). `cyrb64` is a well-known non-crypto 64-bit hash — fine
|
|
17
|
+
* for cache keys, not fine for security. Deterministic across Node and
|
|
18
|
+
* browser (both operate on UTF-16 code units).
|
|
19
|
+
*/
|
|
20
|
+
export declare function hashContent(content: string): string;
|
|
21
|
+
export declare function resolveSource(source: Source, fieldPath: string, ctx: SourceResolutionContext): Promise<ResolvedSource>;
|
|
22
|
+
export declare function resolveSources(sources: Record<string, Source>, ctx: SourceResolutionContext): Promise<SourceResolutionMap>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source resolution (Node-only).
|
|
3
|
+
*
|
|
4
|
+
* Runtime helpers that read files, hash content, and fetch URLs. These are
|
|
5
|
+
* separated from `./sources.ts` so the browser-safe bits of the Source type
|
|
6
|
+
* (Zod schema, classifier, types) can be imported without pulling `node:fs`
|
|
7
|
+
* or `node:crypto` into bundlers that target the browser.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { normalizeSource } from "./sources.js";
|
|
12
|
+
/**
|
|
13
|
+
* Produce a stable 16-hex-char hash of `content` for use as a cache key /
|
|
14
|
+
* change-detection ID.
|
|
15
|
+
*
|
|
16
|
+
* Intentionally avoids `node:crypto` so this module can be bundled for the
|
|
17
|
+
* browser (studio SPA's in-browser simulator reaches this via the inline
|
|
18
|
+
* Source resolver). `cyrb64` is a well-known non-crypto 64-bit hash — fine
|
|
19
|
+
* for cache keys, not fine for security. Deterministic across Node and
|
|
20
|
+
* browser (both operate on UTF-16 code units).
|
|
21
|
+
*/
|
|
22
|
+
export function hashContent(content) {
|
|
23
|
+
let h1 = 0xdeadbeef;
|
|
24
|
+
let h2 = 0x41c6ce57;
|
|
25
|
+
for (let i = 0; i < content.length; i++) {
|
|
26
|
+
const ch = content.charCodeAt(i);
|
|
27
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
28
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
29
|
+
}
|
|
30
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
|
|
31
|
+
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
32
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
|
|
33
|
+
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
34
|
+
return (h2 >>> 0).toString(16).padStart(8, "0") + (h1 >>> 0).toString(16).padStart(8, "0");
|
|
35
|
+
}
|
|
36
|
+
export async function resolveSource(source, fieldPath, ctx) {
|
|
37
|
+
const [kind, value] = normalizeSource(source);
|
|
38
|
+
if (kind === "inline") {
|
|
39
|
+
return {
|
|
40
|
+
content: value,
|
|
41
|
+
kind: "inline",
|
|
42
|
+
origin: source,
|
|
43
|
+
resolver: "inline",
|
|
44
|
+
hash: hashContent(value),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (kind === "file") {
|
|
48
|
+
const absolute = path.isAbsolute(value) ? value : path.resolve(ctx.cwd, value);
|
|
49
|
+
let content;
|
|
50
|
+
try {
|
|
51
|
+
content = await fs.readFile(absolute, "utf-8");
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const e = err;
|
|
55
|
+
if (e.code === "ENOENT") {
|
|
56
|
+
throw new Error(`SOURCE_FILE_NOT_FOUND: file not found: ${absolute} (referenced by ${fieldPath})`);
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`SOURCE_FILE_READ_FAILED: could not read ${absolute} (referenced by ${fieldPath}): ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
// Strip UTF-8 BOM (Windows editors emit this)
|
|
61
|
+
if (content.charCodeAt(0) === 0xfeff)
|
|
62
|
+
content = content.slice(1);
|
|
63
|
+
return {
|
|
64
|
+
content,
|
|
65
|
+
kind: "file",
|
|
66
|
+
origin: source,
|
|
67
|
+
resolver: "file",
|
|
68
|
+
hash: hashContent(content),
|
|
69
|
+
sourcePath: absolute,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (kind === "url") {
|
|
73
|
+
if (ctx.offline) {
|
|
74
|
+
throw new Error(`SOURCE_OFFLINE_REQUIRES_FETCH: cannot fetch ${value} in --offline mode (referenced by ${fieldPath}).`);
|
|
75
|
+
}
|
|
76
|
+
// Validate type field on tagged URL forms — v1 only accepts "fetch" (the default).
|
|
77
|
+
if (typeof source === "object" && "url" in source && source.type && source.type !== "fetch") {
|
|
78
|
+
throw new Error(`SOURCE_INVALID_TYPE: unknown resolver type "${source.type}" on ${value} (referenced by ${fieldPath}). v1 supports only "fetch".`);
|
|
79
|
+
}
|
|
80
|
+
const url = value;
|
|
81
|
+
const headers = buildFetchHeaders(url, ctx);
|
|
82
|
+
const res = await fetchWithRetry(url, headers, fieldPath);
|
|
83
|
+
if (res.status === 401 || res.status === 403) {
|
|
84
|
+
throw new Error(`SOURCE_URL_AUTH_REQUIRED: ${url} returned HTTP ${res.status} (referenced by ${fieldPath}). Configure fetch.auth in .sweny.yml or set SWENY_FETCH_TOKEN.`);
|
|
85
|
+
}
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
throw new Error(`SOURCE_URL_HTTP_ERROR: ${url} returned HTTP ${res.status} (referenced by ${fieldPath}).`);
|
|
88
|
+
}
|
|
89
|
+
const content = await res.text();
|
|
90
|
+
return {
|
|
91
|
+
content,
|
|
92
|
+
kind: "url",
|
|
93
|
+
origin: source,
|
|
94
|
+
resolver: "fetch",
|
|
95
|
+
hash: hashContent(content),
|
|
96
|
+
fetchedAt: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`source error: unreachable (kind: ${kind}, field: ${fieldPath})`);
|
|
100
|
+
}
|
|
101
|
+
export async function resolveSources(sources, ctx) {
|
|
102
|
+
const cache = new Map();
|
|
103
|
+
const out = {};
|
|
104
|
+
const entries = Object.entries(sources);
|
|
105
|
+
await Promise.all(entries.map(async ([fieldPath, source]) => {
|
|
106
|
+
const key = canonicalKey(source, ctx);
|
|
107
|
+
let promise = key ? cache.get(key) : undefined;
|
|
108
|
+
if (!promise) {
|
|
109
|
+
promise = resolveSource(source, fieldPath, ctx);
|
|
110
|
+
if (key)
|
|
111
|
+
cache.set(key, promise);
|
|
112
|
+
}
|
|
113
|
+
out[fieldPath] = await promise;
|
|
114
|
+
}));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function canonicalKey(source, ctx) {
|
|
118
|
+
const [kind, value] = normalizeSource(source);
|
|
119
|
+
if (kind === "file") {
|
|
120
|
+
return "file:" + (path.isAbsolute(value) ? value : path.resolve(ctx.cwd, value));
|
|
121
|
+
}
|
|
122
|
+
if (kind === "url")
|
|
123
|
+
return "url:" + value;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const RETRY_DELAYS = [250, 1000]; // ms — exponential backoff for 5xx
|
|
127
|
+
async function fetchWithRetry(url, headers, fieldPath) {
|
|
128
|
+
let lastError;
|
|
129
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
|
|
132
|
+
// Don't retry on 4xx (client errors) or 2xx (success)
|
|
133
|
+
if (res.status < 500)
|
|
134
|
+
return res;
|
|
135
|
+
// 5xx — retry if attempts remain
|
|
136
|
+
if (attempt < RETRY_DELAYS.length) {
|
|
137
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
return res; // final 5xx — let caller handle it
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
144
|
+
if (attempt < RETRY_DELAYS.length) {
|
|
145
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`SOURCE_URL_UNREACHABLE: ${url} (referenced by ${fieldPath}): ${lastError?.message ?? "unknown error"}. Pass --offline to skip URL sources.`);
|
|
151
|
+
}
|
|
152
|
+
function buildFetchHeaders(url, ctx) {
|
|
153
|
+
const headers = {
|
|
154
|
+
Accept: "text/plain, text/markdown, */*",
|
|
155
|
+
};
|
|
156
|
+
const token = resolveFetchToken(url, ctx);
|
|
157
|
+
if (token)
|
|
158
|
+
headers.Authorization = `Bearer ${token}`;
|
|
159
|
+
return headers;
|
|
160
|
+
}
|
|
161
|
+
function resolveFetchToken(url, ctx) {
|
|
162
|
+
let host;
|
|
163
|
+
try {
|
|
164
|
+
host = new URL(url).host;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
const envVar = ctx.authConfig[host];
|
|
170
|
+
if (envVar && ctx.env[envVar])
|
|
171
|
+
return ctx.env[envVar];
|
|
172
|
+
const fallback = ctx.env.SWENY_FETCH_TOKEN;
|
|
173
|
+
return fallback || undefined;
|
|
174
|
+
}
|
package/dist/sources.d.ts
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
* absolute file paths, and HTTP(S) URLs via a single ergonomic form.
|
|
5
5
|
*
|
|
6
6
|
* See docs/superpowers/specs/2026-04-12-unified-source-type-design.md.
|
|
7
|
+
*
|
|
8
|
+
* This module is browser-safe. Runtime resolution (fs/crypto/fetch helpers)
|
|
9
|
+
* lives in `./source-resolver.ts` so bundlers that target the browser (studio
|
|
10
|
+
* SPA, docs site) can import `sourceZ` and the Source types without dragging
|
|
11
|
+
* in `node:crypto` / `node:fs`.
|
|
7
12
|
*/
|
|
8
13
|
import { z } from "zod";
|
|
9
14
|
import type { Logger } from "./types.js";
|
|
@@ -44,9 +49,6 @@ export declare const sourceZ: z.ZodUnion<[z.ZodString, z.ZodObject<{
|
|
|
44
49
|
url: string;
|
|
45
50
|
type?: string | undefined;
|
|
46
51
|
}>]>;
|
|
47
|
-
/**
|
|
48
|
-
* Classify a plain-string Source by prefix. Throws on empty strings.
|
|
49
|
-
*/
|
|
50
52
|
export type ResolvedSource = {
|
|
51
53
|
content: string;
|
|
52
54
|
kind: SourceKind;
|
|
@@ -57,8 +59,11 @@ export type ResolvedSource = {
|
|
|
57
59
|
sourcePath?: string;
|
|
58
60
|
};
|
|
59
61
|
export type SourceResolutionMap = Record<string, ResolvedSource>;
|
|
60
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Classify a plain-string Source by prefix. Throws on empty strings.
|
|
64
|
+
*/
|
|
61
65
|
export declare function classifySource(raw: string): SourceKind;
|
|
66
|
+
export declare function normalizeSource(source: Source): [SourceKind, string];
|
|
62
67
|
export type SourceResolutionContext = {
|
|
63
68
|
cwd: string;
|
|
64
69
|
env: NodeJS.ProcessEnv;
|
|
@@ -66,5 +71,3 @@ export type SourceResolutionContext = {
|
|
|
66
71
|
offline: boolean;
|
|
67
72
|
logger: Logger;
|
|
68
73
|
};
|
|
69
|
-
export declare function resolveSource(source: Source, fieldPath: string, ctx: SourceResolutionContext): Promise<ResolvedSource>;
|
|
70
|
-
export declare function resolveSources(sources: Record<string, Source>, ctx: SourceResolutionContext): Promise<SourceResolutionMap>;
|
package/dist/sources.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* absolute file paths, and HTTP(S) URLs via a single ergonomic form.
|
|
5
5
|
*
|
|
6
6
|
* See docs/superpowers/specs/2026-04-12-unified-source-type-design.md.
|
|
7
|
+
*
|
|
8
|
+
* This module is browser-safe. Runtime resolution (fs/crypto/fetch helpers)
|
|
9
|
+
* lives in `./source-resolver.ts` so bundlers that target the browser (studio
|
|
10
|
+
* SPA, docs site) can import `sourceZ` and the Source types without dragging
|
|
11
|
+
* in `node:crypto` / `node:fs`.
|
|
7
12
|
*/
|
|
8
|
-
import { createHash } from "node:crypto";
|
|
9
|
-
import * as fs from "node:fs/promises";
|
|
10
|
-
import * as path from "node:path";
|
|
11
13
|
import { z } from "zod";
|
|
12
14
|
const inlineTagZ = z.object({ inline: z.string().min(1) }).strict();
|
|
13
15
|
const fileTagZ = z.object({ file: z.string().min(1) }).strict();
|
|
@@ -24,9 +26,9 @@ const urlTagZ = z
|
|
|
24
26
|
* the schema stays permissive so new resolvers slot in without schema bumps.
|
|
25
27
|
*/
|
|
26
28
|
export const sourceZ = z.union([z.string().min(1), inlineTagZ, fileTagZ, urlTagZ]);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Classify a plain-string Source by prefix. Throws on empty strings.
|
|
31
|
+
*/
|
|
30
32
|
export function classifySource(raw) {
|
|
31
33
|
const trimmed = raw.trim();
|
|
32
34
|
if (!trimmed) {
|
|
@@ -40,146 +42,7 @@ export function classifySource(raw) {
|
|
|
40
42
|
}
|
|
41
43
|
return "inline";
|
|
42
44
|
}
|
|
43
|
-
export
|
|
44
|
-
const [kind, value] = normalizeSource(source);
|
|
45
|
-
if (kind === "inline") {
|
|
46
|
-
return {
|
|
47
|
-
content: value,
|
|
48
|
-
kind: "inline",
|
|
49
|
-
origin: source,
|
|
50
|
-
resolver: "inline",
|
|
51
|
-
hash: hashContent(value),
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
if (kind === "file") {
|
|
55
|
-
const absolute = path.isAbsolute(value) ? value : path.resolve(ctx.cwd, value);
|
|
56
|
-
let content;
|
|
57
|
-
try {
|
|
58
|
-
content = await fs.readFile(absolute, "utf-8");
|
|
59
|
-
}
|
|
60
|
-
catch (err) {
|
|
61
|
-
const e = err;
|
|
62
|
-
if (e.code === "ENOENT") {
|
|
63
|
-
throw new Error(`SOURCE_FILE_NOT_FOUND: file not found: ${absolute} (referenced by ${fieldPath})`);
|
|
64
|
-
}
|
|
65
|
-
throw new Error(`SOURCE_FILE_READ_FAILED: could not read ${absolute} (referenced by ${fieldPath}): ${e.message}`);
|
|
66
|
-
}
|
|
67
|
-
// Strip UTF-8 BOM (Windows editors emit this)
|
|
68
|
-
if (content.charCodeAt(0) === 0xfeff)
|
|
69
|
-
content = content.slice(1);
|
|
70
|
-
return {
|
|
71
|
-
content,
|
|
72
|
-
kind: "file",
|
|
73
|
-
origin: source,
|
|
74
|
-
resolver: "file",
|
|
75
|
-
hash: hashContent(content),
|
|
76
|
-
sourcePath: absolute,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
if (kind === "url") {
|
|
80
|
-
if (ctx.offline) {
|
|
81
|
-
throw new Error(`SOURCE_OFFLINE_REQUIRES_FETCH: cannot fetch ${value} in --offline mode (referenced by ${fieldPath}).`);
|
|
82
|
-
}
|
|
83
|
-
// Validate type field on tagged URL forms — v1 only accepts "fetch" (the default).
|
|
84
|
-
if (typeof source === "object" && "url" in source && source.type && source.type !== "fetch") {
|
|
85
|
-
throw new Error(`SOURCE_INVALID_TYPE: unknown resolver type "${source.type}" on ${value} (referenced by ${fieldPath}). v1 supports only "fetch".`);
|
|
86
|
-
}
|
|
87
|
-
const url = value;
|
|
88
|
-
const headers = buildFetchHeaders(url, ctx);
|
|
89
|
-
const res = await fetchWithRetry(url, headers, fieldPath);
|
|
90
|
-
if (res.status === 401 || res.status === 403) {
|
|
91
|
-
throw new Error(`SOURCE_URL_AUTH_REQUIRED: ${url} returned HTTP ${res.status} (referenced by ${fieldPath}). Configure fetch.auth in .sweny.yml or set SWENY_FETCH_TOKEN.`);
|
|
92
|
-
}
|
|
93
|
-
if (!res.ok) {
|
|
94
|
-
throw new Error(`SOURCE_URL_HTTP_ERROR: ${url} returned HTTP ${res.status} (referenced by ${fieldPath}).`);
|
|
95
|
-
}
|
|
96
|
-
const content = await res.text();
|
|
97
|
-
return {
|
|
98
|
-
content,
|
|
99
|
-
kind: "url",
|
|
100
|
-
origin: source,
|
|
101
|
-
resolver: "fetch",
|
|
102
|
-
hash: hashContent(content),
|
|
103
|
-
fetchedAt: new Date().toISOString(),
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
throw new Error(`source error: unreachable (kind: ${kind}, field: ${fieldPath})`);
|
|
107
|
-
}
|
|
108
|
-
export async function resolveSources(sources, ctx) {
|
|
109
|
-
const cache = new Map();
|
|
110
|
-
const out = {};
|
|
111
|
-
const entries = Object.entries(sources);
|
|
112
|
-
await Promise.all(entries.map(async ([fieldPath, source]) => {
|
|
113
|
-
const key = canonicalKey(source, ctx);
|
|
114
|
-
let promise = key ? cache.get(key) : undefined;
|
|
115
|
-
if (!promise) {
|
|
116
|
-
promise = resolveSource(source, fieldPath, ctx);
|
|
117
|
-
if (key)
|
|
118
|
-
cache.set(key, promise);
|
|
119
|
-
}
|
|
120
|
-
out[fieldPath] = await promise;
|
|
121
|
-
}));
|
|
122
|
-
return out;
|
|
123
|
-
}
|
|
124
|
-
function canonicalKey(source, ctx) {
|
|
125
|
-
const [kind, value] = normalizeSource(source);
|
|
126
|
-
if (kind === "file") {
|
|
127
|
-
return "file:" + (path.isAbsolute(value) ? value : path.resolve(ctx.cwd, value));
|
|
128
|
-
}
|
|
129
|
-
if (kind === "url")
|
|
130
|
-
return "url:" + value;
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
const RETRY_DELAYS = [250, 1000]; // ms — exponential backoff for 5xx
|
|
134
|
-
async function fetchWithRetry(url, headers, fieldPath) {
|
|
135
|
-
let lastError;
|
|
136
|
-
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
|
|
137
|
-
try {
|
|
138
|
-
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
|
|
139
|
-
// Don't retry on 4xx (client errors) or 2xx (success)
|
|
140
|
-
if (res.status < 500)
|
|
141
|
-
return res;
|
|
142
|
-
// 5xx — retry if attempts remain
|
|
143
|
-
if (attempt < RETRY_DELAYS.length) {
|
|
144
|
-
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
return res; // final 5xx — let caller handle it
|
|
148
|
-
}
|
|
149
|
-
catch (err) {
|
|
150
|
-
lastError = err instanceof Error ? err : new Error(String(err));
|
|
151
|
-
if (attempt < RETRY_DELAYS.length) {
|
|
152
|
-
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
throw new Error(`SOURCE_URL_UNREACHABLE: ${url} (referenced by ${fieldPath}): ${lastError?.message ?? "unknown error"}. Pass --offline to skip URL sources.`);
|
|
158
|
-
}
|
|
159
|
-
function buildFetchHeaders(url, ctx) {
|
|
160
|
-
const headers = {
|
|
161
|
-
Accept: "text/plain, text/markdown, */*",
|
|
162
|
-
};
|
|
163
|
-
const token = resolveFetchToken(url, ctx);
|
|
164
|
-
if (token)
|
|
165
|
-
headers.Authorization = `Bearer ${token}`;
|
|
166
|
-
return headers;
|
|
167
|
-
}
|
|
168
|
-
function resolveFetchToken(url, ctx) {
|
|
169
|
-
let host;
|
|
170
|
-
try {
|
|
171
|
-
host = new URL(url).host;
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
return undefined;
|
|
175
|
-
}
|
|
176
|
-
const envVar = ctx.authConfig[host];
|
|
177
|
-
if (envVar && ctx.env[envVar])
|
|
178
|
-
return ctx.env[envVar];
|
|
179
|
-
const fallback = ctx.env.SWENY_FETCH_TOKEN;
|
|
180
|
-
return fallback || undefined;
|
|
181
|
-
}
|
|
182
|
-
function normalizeSource(source) {
|
|
45
|
+
export function normalizeSource(source) {
|
|
183
46
|
if (typeof source === "string") {
|
|
184
47
|
const kind = classifySource(source);
|
|
185
48
|
return [kind, source.trim()];
|
|
@@ -192,3 +55,7 @@ function normalizeSource(source) {
|
|
|
192
55
|
return ["url", source.url.trim()];
|
|
193
56
|
throw new Error("source error: unrecognised Source shape");
|
|
194
57
|
}
|
|
58
|
+
// Runtime resolvers (hashContent, resolveSource, resolveSources) live in
|
|
59
|
+
// `./source-resolver.ts`. They are Node-only (fs, crypto, fetch). Importing
|
|
60
|
+
// them from here would drag `node:crypto` into browser bundles that only
|
|
61
|
+
// need `sourceZ`/types from this file.
|
package/dist/sources.test.js
CHANGED
|
@@ -2,7 +2,8 @@ import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
5
|
-
import { classifySource, sourceZ
|
|
5
|
+
import { classifySource, sourceZ } from "./sources.js";
|
|
6
|
+
import { hashContent, resolveSource, resolveSources } from "./source-resolver.js";
|
|
6
7
|
const fileTmp = path.join(tmpdir(), "sweny-sources-file-test");
|
|
7
8
|
const baseCtx = () => ({
|
|
8
9
|
cwd: "/tmp",
|
|
@@ -14,7 +14,8 @@ import { randomBytes } from "node:crypto";
|
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
17
|
-
import { classifySource, sourceZ
|
|
17
|
+
import { classifySource, sourceZ } from "./sources.js";
|
|
18
|
+
import { hashContent, resolveSource, resolveSources } from "./source-resolver.js";
|
|
18
19
|
const fileTmp = path.join(tmpdir(), `sweny-sources-validation-test-${randomBytes(4).toString("hex")}`);
|
|
19
20
|
const baseCtx = () => ({
|
|
20
21
|
cwd: "/tmp",
|
package/dist/templates.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* backwards-compatible wrappers with fallback semantics.
|
|
11
11
|
*/
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
-
import {
|
|
13
|
+
import { classifySource } from "./sources.js";
|
|
14
|
+
import { resolveSource } from "./source-resolver.js";
|
|
14
15
|
import { consoleLogger } from "./types.js";
|
|
15
16
|
export const DEFAULT_ISSUE_TEMPLATE = `## Summary
|
|
16
17
|
<!-- One-line description of the issue -->
|
package/dist/types.d.ts
CHANGED
|
@@ -37,6 +37,18 @@ export interface Skill {
|
|
|
37
37
|
/** External MCP server definition wired for nodes referencing this skill. */
|
|
38
38
|
mcp?: McpServerConfig;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Per-node rules or context.
|
|
42
|
+
*
|
|
43
|
+
* - Array form (additive): inherits workflow/runtime sources AND adds these.
|
|
44
|
+
* - Object form: when `only: true`, blocks the cascade for this field and
|
|
45
|
+
* uses only `sources`. When `only` is absent/false, behaves like the
|
|
46
|
+
* array form.
|
|
47
|
+
*/
|
|
48
|
+
export type NodeSources = _Source[] | {
|
|
49
|
+
only?: boolean;
|
|
50
|
+
sources: _Source[];
|
|
51
|
+
};
|
|
40
52
|
/** A node in the workflow DAG */
|
|
41
53
|
export interface Node {
|
|
42
54
|
/** Human-readable name */
|
|
@@ -49,6 +61,10 @@ export interface Node {
|
|
|
49
61
|
output?: JSONSchema;
|
|
50
62
|
/** Max AI model turns for this node. When absent, the executor's default applies. */
|
|
51
63
|
max_turns?: number;
|
|
64
|
+
/** Per-node directives. Additive by default; set `{ only: true, sources: [...] }` to block cascade. */
|
|
65
|
+
rules?: NodeSources;
|
|
66
|
+
/** Per-node background knowledge. Additive by default; set `{ only: true, sources: [...] }` to block cascade. */
|
|
67
|
+
context?: NodeSources;
|
|
52
68
|
}
|
|
53
69
|
/** An edge connecting two nodes */
|
|
54
70
|
export interface Edge {
|
|
@@ -68,6 +84,10 @@ export interface Workflow {
|
|
|
68
84
|
edges: Edge[];
|
|
69
85
|
entry: string;
|
|
70
86
|
skills?: Record<string, SkillDefinition>;
|
|
87
|
+
/** Directives prepended to every node's instruction. Cascade into per-node rules. */
|
|
88
|
+
rules?: _Source[];
|
|
89
|
+
/** Background knowledge prepended to every node's instruction. Cascade into per-node context. */
|
|
90
|
+
context?: _Source[];
|
|
71
91
|
}
|
|
72
92
|
/**
|
|
73
93
|
* Inline skill definition in a workflow's `skills` block.
|