wave-agent-sdk 1.0.7 → 1.0.9
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/builtin/skills/artifact/SKILL.md +14 -0
- package/builtin/skills/settings/ENV.md +2 -1
- package/builtin/skills/settings/MODELS.md +3 -0
- package/builtin/skills/settings/SKILL.md +7 -0
- package/builtin/skills/settings/SUBAGENTS.md +1 -1
- package/builtin/subagents/vision.md +18 -0
- package/dist/constants/tools.d.ts +1 -0
- package/dist/constants/tools.js +1 -0
- package/dist/managers/aiManager.js +46 -4
- package/dist/managers/backgroundTaskManager.js +52 -4
- package/dist/managers/messageManager.js +8 -2
- package/dist/managers/permissionManager.d.ts +5 -0
- package/dist/managers/permissionManager.js +18 -2
- package/dist/managers/pluginManager.d.ts +9 -0
- package/dist/managers/pluginManager.js +20 -0
- package/dist/managers/skillManager.d.ts +13 -0
- package/dist/managers/skillManager.js +30 -0
- package/dist/managers/subagentManager.d.ts +12 -0
- package/dist/managers/subagentManager.js +32 -2
- package/dist/managers/toolManager.d.ts +8 -0
- package/dist/managers/toolManager.js +18 -0
- package/dist/services/aiService.d.ts +1 -1
- package/dist/services/aiService.js +3 -2
- package/dist/services/artifactAvailability.d.ts +9 -0
- package/dist/services/artifactAvailability.js +34 -0
- package/dist/services/artifactSession.d.ts +27 -0
- package/dist/services/artifactSession.js +52 -0
- package/dist/services/configurationService.d.ts +2 -1
- package/dist/services/configurationService.js +16 -1
- package/dist/services/initializationService.js +5 -0
- package/dist/services/remoteSettingsService.js +2 -0
- package/dist/tools/agentTool.js +2 -1
- package/dist/tools/artifactTool.d.ts +2 -0
- package/dist/tools/artifactTool.js +357 -0
- package/dist/tools/bashTool.js +24 -13
- package/dist/tools/editTool.js +7 -2
- package/dist/tools/types.d.ts +3 -1
- package/dist/tools/webFetchTool.js +141 -0
- package/dist/types/agent.d.ts +2 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/configuration.d.ts +2 -0
- package/dist/types/messaging.d.ts +3 -1
- package/dist/types/permissions.d.ts +3 -1
- package/dist/types/permissions.js +2 -1
- package/dist/utils/bashParser.d.ts +14 -0
- package/dist/utils/bashParser.js +45 -1
- package/dist/utils/containerSetup.js +8 -0
- package/dist/utils/convertMessagesForAPI.js +51 -4
- package/dist/utils/encoding.d.ts +28 -0
- package/dist/utils/encoding.js +99 -0
- package/dist/utils/messageOperations.d.ts +4 -2
- package/dist/utils/messageOperations.js +23 -7
- package/dist/utils/subagentParser.d.ts +5 -2
- package/dist/utils/subagentParser.js +14 -4
- package/package.json +2 -1
|
@@ -425,7 +425,9 @@ async function processStreamingResponse(stream, onContentUpdate, onToolUpdate, o
|
|
|
425
425
|
if (functionDelta.arguments) {
|
|
426
426
|
existingCall.function.arguments += functionDelta.arguments;
|
|
427
427
|
}
|
|
428
|
-
// Emit streaming updates for all chunks with actual content (including first chunk)
|
|
428
|
+
// Emit streaming updates for all chunks with actual content (including first chunk).
|
|
429
|
+
// Streaming carries only the delta `parametersChunk` — consumers accumulate it;
|
|
430
|
+
// authoritative `parameters` arrives at `start` (empty) / `running` / `end`.
|
|
429
431
|
if (onToolUpdate &&
|
|
430
432
|
existingCall.function.name &&
|
|
431
433
|
functionDelta.arguments &&
|
|
@@ -434,7 +436,6 @@ async function processStreamingResponse(stream, onContentUpdate, onToolUpdate, o
|
|
|
434
436
|
onToolUpdate({
|
|
435
437
|
id: existingCall.id,
|
|
436
438
|
name: existingCall.function.name,
|
|
437
|
-
parameters: existingCall.function.arguments,
|
|
438
439
|
parametersChunk: functionDelta.arguments,
|
|
439
440
|
stage: "streaming",
|
|
440
441
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Code default for Artifact availability. Flip to true after the frame backend goes live. */
|
|
2
|
+
export declare const ARTIFACT_DEFAULT_ENABLED = false;
|
|
3
|
+
/**
|
|
4
|
+
* Whether the Artifact tool should be registered / usable for the given workdir.
|
|
5
|
+
* Resolution order: remote managed settings (`enableArtifact` from
|
|
6
|
+
* `GET /api/wave/settings`, admin override) → explicit `enableArtifact` in local
|
|
7
|
+
* merged settings → code default.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isArtifactEnabled(workdir?: string): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact feature availability.
|
|
3
|
+
*
|
|
4
|
+
* The frame backend (`POST /api/frame/deploy/direct`) is not live yet, so the
|
|
5
|
+
* code default is DISABLED. Internal beta / gradual rollout enables the feature
|
|
6
|
+
* via settings.json `enableArtifact: true` without code changes. Once the
|
|
7
|
+
* backend goes live, flip `ARTIFACT_DEFAULT_ENABLED` to `true` so an unset
|
|
8
|
+
* `enableArtifact` defaults to enabled (matching Claude Code's `enableArtifact`
|
|
9
|
+
* "unset follows feature availability" semantics).
|
|
10
|
+
*/
|
|
11
|
+
import { loadMergedWaveConfig } from "./configurationService.js";
|
|
12
|
+
import { getRemoteSettingsSync } from "./remoteSettingsService.js";
|
|
13
|
+
/** Code default for Artifact availability. Flip to true after the frame backend goes live. */
|
|
14
|
+
export const ARTIFACT_DEFAULT_ENABLED = false;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the Artifact tool should be registered / usable for the given workdir.
|
|
17
|
+
* Resolution order: remote managed settings (`enableArtifact` from
|
|
18
|
+
* `GET /api/wave/settings`, admin override) → explicit `enableArtifact` in local
|
|
19
|
+
* merged settings → code default.
|
|
20
|
+
*/
|
|
21
|
+
export function isArtifactEnabled(workdir) {
|
|
22
|
+
// Remote managed settings win (same last-write-wins semantics as `model`).
|
|
23
|
+
const remote = getRemoteSettingsSync();
|
|
24
|
+
if (remote?.enableArtifact !== undefined) {
|
|
25
|
+
return remote.enableArtifact;
|
|
26
|
+
}
|
|
27
|
+
if (workdir) {
|
|
28
|
+
const config = loadMergedWaveConfig(workdir);
|
|
29
|
+
if (config?.enableArtifact !== undefined) {
|
|
30
|
+
return config.enableArtifact;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return ARTIFACT_DEFAULT_ENABLED;
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped state shared between the Artifact tool and the WebFetch tool.
|
|
3
|
+
*
|
|
4
|
+
* - file_path → artifact record: lets a model republish a file it already
|
|
5
|
+
* published this session without passing `url` again, and enables the
|
|
6
|
+
* same-session auto-allow (the publish was already confirmed once).
|
|
7
|
+
* - slug → latest known version: powers the stale-version guard
|
|
8
|
+
* (local knowledge older than the live version blocks a redeploy
|
|
9
|
+
* unless `force` is set) and lets WebFetch record versions it observed.
|
|
10
|
+
*
|
|
11
|
+
* State is keyed by sessionId so parallel sessions never observe each other.
|
|
12
|
+
*/
|
|
13
|
+
export interface ArtifactRecord {
|
|
14
|
+
url: string;
|
|
15
|
+
slug: string;
|
|
16
|
+
version: string;
|
|
17
|
+
}
|
|
18
|
+
/** Record a successful publish so same-session republishes auto-allow. */
|
|
19
|
+
export declare function recordArtifact(sessionId: string, filePath: string, record: ArtifactRecord): void;
|
|
20
|
+
/** Look up an artifact previously published in this session by file path. */
|
|
21
|
+
export declare function getArtifactByFilePath(sessionId: string, filePath: string): ArtifactRecord | undefined;
|
|
22
|
+
/** Latest version of a slug this session has observed (publish, conflict, or read). */
|
|
23
|
+
export declare function getRecordedVersion(sessionId: string, slug: string): string | undefined;
|
|
24
|
+
/** Record an observed version (from a publish, a 409 conflict, or a WebFetch read). */
|
|
25
|
+
export declare function recordVersion(sessionId: string, slug: string, version: string): void;
|
|
26
|
+
/** Clear all session-scoped artifact state (used when a session ends). */
|
|
27
|
+
export declare function clearArtifactSession(sessionId: string): void;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped state shared between the Artifact tool and the WebFetch tool.
|
|
3
|
+
*
|
|
4
|
+
* - file_path → artifact record: lets a model republish a file it already
|
|
5
|
+
* published this session without passing `url` again, and enables the
|
|
6
|
+
* same-session auto-allow (the publish was already confirmed once).
|
|
7
|
+
* - slug → latest known version: powers the stale-version guard
|
|
8
|
+
* (local knowledge older than the live version blocks a redeploy
|
|
9
|
+
* unless `force` is set) and lets WebFetch record versions it observed.
|
|
10
|
+
*
|
|
11
|
+
* State is keyed by sessionId so parallel sessions never observe each other.
|
|
12
|
+
*/
|
|
13
|
+
const sessionArtifacts = new Map();
|
|
14
|
+
const sessionSlugVersions = new Map();
|
|
15
|
+
function fileMapFor(sessionId) {
|
|
16
|
+
let map = sessionArtifacts.get(sessionId);
|
|
17
|
+
if (!map) {
|
|
18
|
+
map = new Map();
|
|
19
|
+
sessionArtifacts.set(sessionId, map);
|
|
20
|
+
}
|
|
21
|
+
return map;
|
|
22
|
+
}
|
|
23
|
+
function slugMapFor(sessionId) {
|
|
24
|
+
let map = sessionSlugVersions.get(sessionId);
|
|
25
|
+
if (!map) {
|
|
26
|
+
map = new Map();
|
|
27
|
+
sessionSlugVersions.set(sessionId, map);
|
|
28
|
+
}
|
|
29
|
+
return map;
|
|
30
|
+
}
|
|
31
|
+
/** Record a successful publish so same-session republishes auto-allow. */
|
|
32
|
+
export function recordArtifact(sessionId, filePath, record) {
|
|
33
|
+
fileMapFor(sessionId).set(filePath, record);
|
|
34
|
+
slugMapFor(sessionId).set(record.slug, record.version);
|
|
35
|
+
}
|
|
36
|
+
/** Look up an artifact previously published in this session by file path. */
|
|
37
|
+
export function getArtifactByFilePath(sessionId, filePath) {
|
|
38
|
+
return sessionArtifacts.get(sessionId)?.get(filePath);
|
|
39
|
+
}
|
|
40
|
+
/** Latest version of a slug this session has observed (publish, conflict, or read). */
|
|
41
|
+
export function getRecordedVersion(sessionId, slug) {
|
|
42
|
+
return sessionSlugVersions.get(sessionId)?.get(slug);
|
|
43
|
+
}
|
|
44
|
+
/** Record an observed version (from a publish, a 409 conflict, or a WebFetch read). */
|
|
45
|
+
export function recordVersion(sessionId, slug, version) {
|
|
46
|
+
slugMapFor(sessionId).set(slug, version);
|
|
47
|
+
}
|
|
48
|
+
/** Clear all session-scoped artifact state (used when a session ends). */
|
|
49
|
+
export function clearArtifactSession(sessionId) {
|
|
50
|
+
sessionArtifacts.delete(sessionId);
|
|
51
|
+
sessionSlugVersions.delete(sessionId);
|
|
52
|
+
}
|
|
@@ -81,9 +81,10 @@ export declare class ConfigurationService {
|
|
|
81
81
|
* @param fastModel - Fast model override (optional)
|
|
82
82
|
* @param maxTokens - Max output tokens override (optional)
|
|
83
83
|
* @param permissionMode - Permission mode override (optional)
|
|
84
|
+
* @param visionModel - Vision model override (optional)
|
|
84
85
|
* @returns Resolved model configuration with defaults
|
|
85
86
|
*/
|
|
86
|
-
resolveModelConfig(model?: string, fastModel?: string, maxTokens?: number, permissionMode?: PermissionMode): ModelConfig;
|
|
87
|
+
resolveModelConfig(model?: string, fastModel?: string, maxTokens?: number, permissionMode?: PermissionMode, visionModel?: string): ModelConfig;
|
|
87
88
|
/**
|
|
88
89
|
* Resolves token limit with fallbacks
|
|
89
90
|
* Resolution priority: override > options > env (from settings.json) > process.env > default
|
|
@@ -459,9 +459,10 @@ export class ConfigurationService {
|
|
|
459
459
|
* @param fastModel - Fast model override (optional)
|
|
460
460
|
* @param maxTokens - Max output tokens override (optional)
|
|
461
461
|
* @param permissionMode - Permission mode override (optional)
|
|
462
|
+
* @param visionModel - Vision model override (optional)
|
|
462
463
|
* @returns Resolved model configuration with defaults
|
|
463
464
|
*/
|
|
464
|
-
resolveModelConfig(model, fastModel, maxTokens, permissionMode) {
|
|
465
|
+
resolveModelConfig(model, fastModel, maxTokens, permissionMode, visionModel) {
|
|
465
466
|
// Resolve agent model: override > options > currentConfiguration (settings.json model, possibly remote-merged) > process.env
|
|
466
467
|
// Priority: user's explicit model field > admin's env.WAVE_MODEL default.
|
|
467
468
|
// If admin wants hard enforcement, they set the `model` scalar field (overwrites local in mergeRemoteSettings).
|
|
@@ -473,11 +474,16 @@ export class ConfigurationService {
|
|
|
473
474
|
const resolvedFastModel = fastModel ||
|
|
474
475
|
this.options.fastModel ||
|
|
475
476
|
(this.envSnapshot.WAVE_FAST_MODEL ?? process.env.WAVE_FAST_MODEL);
|
|
477
|
+
// Resolve vision model: override > options > process.env (includes settings.json env)
|
|
478
|
+
const resolvedVisionModel = visionModel ||
|
|
479
|
+
this.options.visionModel ||
|
|
480
|
+
(this.envSnapshot.WAVE_VISION_MODEL ?? process.env.WAVE_VISION_MODEL);
|
|
476
481
|
// Resolve max output tokens
|
|
477
482
|
const resolvedMaxTokens = this.resolveMaxOutputTokens(maxTokens);
|
|
478
483
|
const baseConfig = {
|
|
479
484
|
model: resolvedAgentModel,
|
|
480
485
|
fastModel: resolvedFastModel,
|
|
486
|
+
visionModel: resolvedVisionModel,
|
|
481
487
|
maxTokens: resolvedMaxTokens,
|
|
482
488
|
permissionMode: permissionMode ?? this.options.permissionMode,
|
|
483
489
|
};
|
|
@@ -1062,9 +1068,13 @@ export function loadWaveConfigFromFile(filePath) {
|
|
|
1062
1068
|
autoMemoryEnabled: config.autoMemoryEnabled !== undefined
|
|
1063
1069
|
? config.autoMemoryEnabled
|
|
1064
1070
|
: undefined,
|
|
1071
|
+
autoMemoryFrequency: config.autoMemoryFrequency !== undefined
|
|
1072
|
+
? config.autoMemoryFrequency
|
|
1073
|
+
: undefined,
|
|
1065
1074
|
models: config.models || undefined,
|
|
1066
1075
|
marketplaces: config.marketplaces || undefined,
|
|
1067
1076
|
worktree: config.worktree || undefined,
|
|
1077
|
+
enableArtifact: config.enableArtifact !== undefined ? config.enableArtifact : undefined,
|
|
1068
1078
|
};
|
|
1069
1079
|
}
|
|
1070
1080
|
catch (error) {
|
|
@@ -1209,6 +1219,10 @@ export function loadMergedWaveConfig(workdir) {
|
|
|
1209
1219
|
if (config.worktree !== undefined) {
|
|
1210
1220
|
mergedConfig.worktree = config.worktree;
|
|
1211
1221
|
}
|
|
1222
|
+
// Merge enableArtifact (last one wins)
|
|
1223
|
+
if (config.enableArtifact !== undefined) {
|
|
1224
|
+
mergedConfig.enableArtifact = config.enableArtifact;
|
|
1225
|
+
}
|
|
1212
1226
|
// Merge models
|
|
1213
1227
|
if (config.models) {
|
|
1214
1228
|
if (!mergedConfig.models)
|
|
@@ -1247,5 +1261,6 @@ export function loadMergedWaveConfig(workdir) {
|
|
|
1247
1261
|
? mergedConfig.models
|
|
1248
1262
|
: undefined,
|
|
1249
1263
|
worktree: mergedConfig.worktree,
|
|
1264
|
+
enableArtifact: mergedConfig.enableArtifact,
|
|
1250
1265
|
};
|
|
1251
1266
|
}
|
|
@@ -64,6 +64,11 @@ export class InitializationService {
|
|
|
64
64
|
const phaseStart = performance.now();
|
|
65
65
|
// Load hooks configuration using ConfigurationService
|
|
66
66
|
const configResult = await configurationService.loadMergedConfiguration(workdir);
|
|
67
|
+
// Rebuild subagent configurations now that settings.json env is loaded
|
|
68
|
+
// into the snapshot: conditional builtin subagents (model: visionModel
|
|
69
|
+
// → WAVE_VISION_MODEL) can only register once the env is available.
|
|
70
|
+
// Plugin agents are preserved by refreshConfigurations.
|
|
71
|
+
await subagentManager.refreshConfigurations();
|
|
67
72
|
hookManager.loadConfigurationFromWaveConfig(configResult.configuration);
|
|
68
73
|
// Update plugin manager with enabled plugins configuration
|
|
69
74
|
if (configResult.configuration?.enabledPlugins) {
|
|
@@ -307,6 +307,8 @@ export function mergeRemoteSettings(localMerged, remote) {
|
|
|
307
307
|
result.marketplaces = remote.marketplaces;
|
|
308
308
|
if (remote.enabledPlugins !== undefined)
|
|
309
309
|
result.enabledPlugins = remote.enabledPlugins;
|
|
310
|
+
if (remote.enableArtifact !== undefined)
|
|
311
|
+
result.enableArtifact = remote.enableArtifact;
|
|
310
312
|
return result;
|
|
311
313
|
}
|
|
312
314
|
/**
|
package/dist/tools/agentTool.js
CHANGED
|
@@ -172,7 +172,8 @@ When using the Agent tool, you must specify a subagent_type parameter to select
|
|
|
172
172
|
success: true,
|
|
173
173
|
content: `Agent backgrounded with ID: ${taskId}.${outputPath ? ` Real-time output: ${outputPath}` : ""}`,
|
|
174
174
|
shortResult: "Agent backgrounded",
|
|
175
|
-
|
|
175
|
+
backgroundedByUser: true,
|
|
176
|
+
backgroundTaskId: taskId,
|
|
176
177
|
});
|
|
177
178
|
},
|
|
178
179
|
});
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import { ARTIFACT_TOOL_NAME } from "../constants/tools.js";
|
|
5
|
+
import { authService, createAuthAwareFetch } from "../services/authService.js";
|
|
6
|
+
import { logger } from "../utils/globalLogger.js";
|
|
7
|
+
import { recordArtifact, getArtifactByFilePath, getRecordedVersion, recordVersion, } from "../services/artifactSession.js";
|
|
8
|
+
// --- Limits ---
|
|
9
|
+
const DEPLOY_TIMEOUT_MS = 30000;
|
|
10
|
+
const PROBE_TIMEOUT_MS = 15000;
|
|
11
|
+
/** Server-side content limit — POSTs above this get a 413. */
|
|
12
|
+
const ARTIFACT_MAX_CONTENT_BYTES = 16 * 1024 * 1024; // 16MB
|
|
13
|
+
const LABEL_MAX_LENGTH = 60;
|
|
14
|
+
const DEFAULT_FAVICON = "📄";
|
|
15
|
+
function isValidFavicon(favicon) {
|
|
16
|
+
if (!favicon || favicon.trim().length === 0)
|
|
17
|
+
return false;
|
|
18
|
+
// Count code points excluding variation selectors (👨👩👧 counts as 3 and is
|
|
19
|
+
// rejected — only simple 1-2 emoji are accepted per the server contract).
|
|
20
|
+
const codePoints = [...favicon].filter((cp) => cp !== "\uFE0F");
|
|
21
|
+
if (codePoints.length < 1 || codePoints.length > 2)
|
|
22
|
+
return false;
|
|
23
|
+
return codePoints.every((cp) => {
|
|
24
|
+
// \p{Emoji} also matches ASCII digits/letters — plain text is not an emoji.
|
|
25
|
+
if (/[A-Za-z0-9]/.test(cp))
|
|
26
|
+
return false;
|
|
27
|
+
return /\p{Emoji}/u.test(cp);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Extract the artifact slug from a `{host}/code/artifact/{slug}` URL. */
|
|
31
|
+
function extractSlugFromUrl(url) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = new URL(url);
|
|
34
|
+
const match = parsed.pathname.match(/^\/code\/artifact\/([^/]+)\/?$/);
|
|
35
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function escapeHtml(s) {
|
|
42
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
43
|
+
}
|
|
44
|
+
/** Render Markdown to a complete HTML document (client-side md→HTML). */
|
|
45
|
+
function renderMarkdown(md, title) {
|
|
46
|
+
const body = marked.parse(md, { async: false });
|
|
47
|
+
const titleTag = title ? `<title>${escapeHtml(title)}</title>` : "";
|
|
48
|
+
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${titleTag}</head><body>${body}</body></html>`;
|
|
49
|
+
}
|
|
50
|
+
/** Probe an artifact's current metadata (`via=model_read` marks model access). */
|
|
51
|
+
async function probeFrame(slug, signal) {
|
|
52
|
+
const serverUrl = authService.getServerUrl();
|
|
53
|
+
const authFetch = createAuthAwareFetch(globalThis.fetch);
|
|
54
|
+
try {
|
|
55
|
+
const res = await authFetch(`${serverUrl}/api/frame/${encodeURIComponent(slug)}?via=model_read`, { method: "GET", signal });
|
|
56
|
+
if (res.status === 404)
|
|
57
|
+
return null;
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
logger?.warn("Artifact probe failed", {
|
|
60
|
+
slug,
|
|
61
|
+
status: res.status,
|
|
62
|
+
statusText: res.statusText,
|
|
63
|
+
});
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return (await res.json());
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
logger?.warn("Artifact probe error", { slug, error: String(err) });
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export const artifactTool = {
|
|
74
|
+
name: ARTIFACT_TOOL_NAME,
|
|
75
|
+
isConcurrencySafe: false,
|
|
76
|
+
config: {
|
|
77
|
+
type: "function",
|
|
78
|
+
function: {
|
|
79
|
+
name: ARTIFACT_TOOL_NAME,
|
|
80
|
+
description: "Publish local HTML or Markdown files as shareable web pages (artifacts). " +
|
|
81
|
+
"Each publish returns a private URL you can share; the page is only accessible to you " +
|
|
82
|
+
"unless you change its sharing. Only .html and .md files are supported — inline content " +
|
|
83
|
+
"is not accepted. Markdown files are rendered to HTML automatically. " +
|
|
84
|
+
"Pass `url` (an existing artifact URL) to redeploy that artifact, " +
|
|
85
|
+
"or omit it to republish a file already published in this session. " +
|
|
86
|
+
"Use `force` to overwrite an artifact that has been updated by someone else.",
|
|
87
|
+
parameters: {
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: {
|
|
90
|
+
file_path: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "Path to the .html or .md file to publish (relative to the working directory). The file must exist.",
|
|
93
|
+
},
|
|
94
|
+
favicon: {
|
|
95
|
+
type: "string",
|
|
96
|
+
description: "1-2 emoji characters shown as the page favicon (no text, URLs, or HTML). Defaults to 📄.",
|
|
97
|
+
},
|
|
98
|
+
label: {
|
|
99
|
+
type: "string",
|
|
100
|
+
description: `Optional short label for the artifact (max ${LABEL_MAX_LENGTH} characters).`,
|
|
101
|
+
},
|
|
102
|
+
url: {
|
|
103
|
+
type: "string",
|
|
104
|
+
description: "Existing artifact URL to redeploy, e.g. https://host/code/artifact/abc123. Omit when republishing a file already published earlier in this session.",
|
|
105
|
+
},
|
|
106
|
+
force: {
|
|
107
|
+
type: "boolean",
|
|
108
|
+
description: "Set true to overwrite an artifact that was updated since this session last saw it (stale version or conflict).",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
required: ["file_path"],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
formatCompactParams: (params) => {
|
|
116
|
+
const filePath = typeof params.file_path === "string" ? params.file_path : "";
|
|
117
|
+
const url = typeof params.url === "string" ? params.url : "";
|
|
118
|
+
return `${ARTIFACT_TOOL_NAME}(${filePath}${url ? ` → ${url}` : ""})`;
|
|
119
|
+
},
|
|
120
|
+
execute: async (args, context) => {
|
|
121
|
+
const filePath = typeof args.file_path === "string" ? args.file_path.trim() : "";
|
|
122
|
+
if (!filePath) {
|
|
123
|
+
return {
|
|
124
|
+
success: false,
|
|
125
|
+
content: "",
|
|
126
|
+
error: `${ARTIFACT_TOOL_NAME}: missing required parameter "file_path"`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const faviconRaw = typeof args.favicon === "string" ? args.favicon.trim() : "";
|
|
130
|
+
const favicon = faviconRaw || DEFAULT_FAVICON;
|
|
131
|
+
if (!isValidFavicon(favicon)) {
|
|
132
|
+
return {
|
|
133
|
+
success: false,
|
|
134
|
+
content: "",
|
|
135
|
+
error: `${ARTIFACT_TOOL_NAME}: favicon must be 1-2 emoji characters (e.g. "📄" or "🔖"), no text, URLs, or HTML markup`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const labelRaw = typeof args.label === "string" ? args.label.trim() : "";
|
|
139
|
+
const label = labelRaw || undefined;
|
|
140
|
+
if (label !== undefined && label.length > LABEL_MAX_LENGTH) {
|
|
141
|
+
return {
|
|
142
|
+
success: false,
|
|
143
|
+
content: "",
|
|
144
|
+
error: `${ARTIFACT_TOOL_NAME}: label must be at most ${LABEL_MAX_LENGTH} characters (got ${label.length})`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const force = args.force === true;
|
|
148
|
+
const urlRaw = typeof args.url === "string" ? args.url.trim() : "";
|
|
149
|
+
const url = urlRaw || undefined;
|
|
150
|
+
let slug;
|
|
151
|
+
if (url) {
|
|
152
|
+
slug = extractSlugFromUrl(url);
|
|
153
|
+
if (!slug) {
|
|
154
|
+
return {
|
|
155
|
+
success: false,
|
|
156
|
+
content: "",
|
|
157
|
+
error: `${ARTIFACT_TOOL_NAME}: url must point to an artifact page ({host}/code/artifact/{slug})`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Resolve and read the file (relative to the workdir).
|
|
162
|
+
const absolutePath = path.resolve(context.workdir, filePath);
|
|
163
|
+
const ext = path.extname(absolutePath).toLowerCase();
|
|
164
|
+
if (ext !== ".html" && ext !== ".md") {
|
|
165
|
+
return {
|
|
166
|
+
success: false,
|
|
167
|
+
content: "",
|
|
168
|
+
error: `${ARTIFACT_TOOL_NAME}: only .html and .md files can be published as artifacts (got "${ext || "no extension"}")`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
let fileContent;
|
|
172
|
+
try {
|
|
173
|
+
fileContent = readFileSync(absolutePath, "utf-8");
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return {
|
|
177
|
+
success: false,
|
|
178
|
+
content: "",
|
|
179
|
+
error: `${ARTIFACT_TOOL_NAME}: file not found or unreadable: ${filePath}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const content = ext === ".md" ? renderMarkdown(fileContent, label) : fileContent;
|
|
183
|
+
const contentBytes = Buffer.byteLength(content, "utf-8");
|
|
184
|
+
if (contentBytes > ARTIFACT_MAX_CONTENT_BYTES) {
|
|
185
|
+
return {
|
|
186
|
+
success: false,
|
|
187
|
+
content: "",
|
|
188
|
+
error: `${ARTIFACT_TOOL_NAME}: content exceeds the ${Math.floor(ARTIFACT_MAX_CONTENT_BYTES / 1024 / 1024)}MB server limit (${(contentBytes / 1024 / 1024).toFixed(1)}MB). Reduce the file or split it up.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (!authService.getSSOToken()) {
|
|
192
|
+
return {
|
|
193
|
+
success: false,
|
|
194
|
+
content: "",
|
|
195
|
+
error: `${ARTIFACT_TOOL_NAME}: not authenticated. Run /login to connect your account before publishing artifacts.`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const sessionId = context.sessionId || "";
|
|
199
|
+
const signal = context.abortSignal
|
|
200
|
+
? AbortSignal.any([
|
|
201
|
+
context.abortSignal,
|
|
202
|
+
AbortSignal.timeout(DEPLOY_TIMEOUT_MS),
|
|
203
|
+
])
|
|
204
|
+
: AbortSignal.timeout(DEPLOY_TIMEOUT_MS);
|
|
205
|
+
// Redeploy: probe current metadata for baseVersion, shared-live detection,
|
|
206
|
+
// and the stale-version guard.
|
|
207
|
+
let serverVersion;
|
|
208
|
+
let sharedLive = false;
|
|
209
|
+
if (url && slug) {
|
|
210
|
+
const meta = await probeFrame(slug, AbortSignal.timeout(PROBE_TIMEOUT_MS));
|
|
211
|
+
if (!meta) {
|
|
212
|
+
return {
|
|
213
|
+
success: false,
|
|
214
|
+
content: "",
|
|
215
|
+
error: `${ARTIFACT_TOOL_NAME}: artifact not found at ${url}. It may have been deleted or the URL is invalid.`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
serverVersion = meta.version;
|
|
219
|
+
sharedLive = !!(meta.perm && meta.perm.mode !== "owner" && !meta.shared);
|
|
220
|
+
const recorded = getRecordedVersion(sessionId, slug);
|
|
221
|
+
if (recorded !== undefined && recorded !== meta.version && !force) {
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
content: "",
|
|
225
|
+
error: `${ARTIFACT_TOOL_NAME}: stale version — this artifact has been updated to version ${meta.version} since this session last saw version ${recorded}. Pass "force": true to overwrite it anyway.`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Permission check: first publish and shared-live redeploys require
|
|
230
|
+
// confirmation; republishing a file/artifact this session already
|
|
231
|
+
// confirmed once auto-allows (matching Claude Code's behavior).
|
|
232
|
+
const publishedThisSession = !!getArtifactByFilePath(sessionId, filePath) ||
|
|
233
|
+
(slug ? getRecordedVersion(sessionId, slug) !== undefined : false);
|
|
234
|
+
const needsConfirm = !publishedThisSession || sharedLive;
|
|
235
|
+
if (context.permissionManager && needsConfirm) {
|
|
236
|
+
const permissionContext = context.permissionManager.createContext(ARTIFACT_TOOL_NAME, context.permissionMode || "default", context.canUseToolCallback, {
|
|
237
|
+
file_path: filePath,
|
|
238
|
+
...(favicon !== DEFAULT_FAVICON ? { favicon } : {}),
|
|
239
|
+
...(label !== undefined ? { label } : {}),
|
|
240
|
+
...(url !== undefined ? { url } : {}),
|
|
241
|
+
...(force ? { force: true } : {}),
|
|
242
|
+
}, context.toolCallId);
|
|
243
|
+
if (sharedLive) {
|
|
244
|
+
permissionContext.warning =
|
|
245
|
+
"此 artifact 处于 shared-live 状态(共享且实时更新),重新部署后所有访问者都会立即看到新内容。";
|
|
246
|
+
permissionContext.hidePersistentOption = true;
|
|
247
|
+
}
|
|
248
|
+
const permissionResult = await context.permissionManager.checkPermission(permissionContext);
|
|
249
|
+
if (permissionResult.behavior === "deny") {
|
|
250
|
+
return {
|
|
251
|
+
success: false,
|
|
252
|
+
content: "",
|
|
253
|
+
error: `${ARTIFACT_TOOL_NAME} operation denied by user, reason: ${permissionResult.message || "No reason provided"}`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Deploy.
|
|
258
|
+
const serverUrl = authService.getServerUrl();
|
|
259
|
+
const authFetch = createAuthAwareFetch(globalThis.fetch);
|
|
260
|
+
const body = {
|
|
261
|
+
content,
|
|
262
|
+
favicon,
|
|
263
|
+
...(label !== undefined ? { label } : {}),
|
|
264
|
+
...(url !== undefined ? { url } : {}),
|
|
265
|
+
...(serverVersion !== undefined ? { baseVersion: serverVersion } : {}),
|
|
266
|
+
...(force ? { force: true } : {}),
|
|
267
|
+
};
|
|
268
|
+
let res;
|
|
269
|
+
try {
|
|
270
|
+
res = await authFetch(`${serverUrl}/api/frame/deploy/direct`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { "Content-Type": "application/json" },
|
|
273
|
+
body: JSON.stringify(body),
|
|
274
|
+
signal,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
logger?.warn("Artifact deploy request failed", { error: String(err) });
|
|
279
|
+
return {
|
|
280
|
+
success: false,
|
|
281
|
+
content: "",
|
|
282
|
+
error: `${ARTIFACT_TOOL_NAME}: failed to reach the server: ${err instanceof Error ? err.message : String(err)}`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const data = (await res.json().catch(() => ({})));
|
|
286
|
+
if (res.status === 201) {
|
|
287
|
+
const deploy = data;
|
|
288
|
+
recordArtifact(sessionId, filePath, {
|
|
289
|
+
url: deploy.url,
|
|
290
|
+
slug: deploy.slug,
|
|
291
|
+
version: deploy.version,
|
|
292
|
+
});
|
|
293
|
+
const lines = [`Artifact published: ${deploy.url}`];
|
|
294
|
+
if (deploy.path)
|
|
295
|
+
lines.push(`Path: ${deploy.path}`);
|
|
296
|
+
if (deploy.title)
|
|
297
|
+
lines.push(`Title: ${deploy.title}`);
|
|
298
|
+
lines.push(`Version: ${deploy.version}`);
|
|
299
|
+
return {
|
|
300
|
+
success: true,
|
|
301
|
+
content: lines.join("\n"),
|
|
302
|
+
shortResult: `Published ${filePath} → ${deploy.url}`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (res.status === 409) {
|
|
306
|
+
const live = typeof data.live === "string" ? data.live : undefined;
|
|
307
|
+
if (live && slug) {
|
|
308
|
+
recordVersion(sessionId, slug, live);
|
|
309
|
+
}
|
|
310
|
+
const serverMessage = typeof data.message === "string"
|
|
311
|
+
? data.message
|
|
312
|
+
: typeof data.error === "string"
|
|
313
|
+
? data.error
|
|
314
|
+
: "the artifact has been updated by someone else";
|
|
315
|
+
return {
|
|
316
|
+
success: false,
|
|
317
|
+
content: "",
|
|
318
|
+
error: `${ARTIFACT_TOOL_NAME}: conflict detected — ${serverMessage}${live ? ` (live version: ${live})` : ""}. Pass "force": true to overwrite the live version.`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (res.status === 413) {
|
|
322
|
+
return {
|
|
323
|
+
success: false,
|
|
324
|
+
content: "",
|
|
325
|
+
error: `${ARTIFACT_TOOL_NAME}: the published content is too large (server limit is 16MB). Reduce the file or split it up.`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (res.status === 400) {
|
|
329
|
+
const serverMessage = typeof data.message === "string"
|
|
330
|
+
? data.message
|
|
331
|
+
: typeof data.error === "string"
|
|
332
|
+
? data.error
|
|
333
|
+
: "the server rejected the content";
|
|
334
|
+
return {
|
|
335
|
+
success: false,
|
|
336
|
+
content: "",
|
|
337
|
+
error: `${ARTIFACT_TOOL_NAME}: the server rejected the publish — ${serverMessage}`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (res.status === 401 || res.status === 403) {
|
|
341
|
+
return {
|
|
342
|
+
success: false,
|
|
343
|
+
content: "",
|
|
344
|
+
error: `${ARTIFACT_TOOL_NAME}: authentication failed (HTTP ${res.status}). Run /login again and retry.`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
logger?.warn("Artifact deploy unexpected status", {
|
|
348
|
+
status: res.status,
|
|
349
|
+
statusText: res.statusText,
|
|
350
|
+
});
|
|
351
|
+
return {
|
|
352
|
+
success: false,
|
|
353
|
+
content: "",
|
|
354
|
+
error: `${ARTIFACT_TOOL_NAME}: server returned HTTP ${res.status} ${res.statusText}`,
|
|
355
|
+
};
|
|
356
|
+
},
|
|
357
|
+
};
|