@oxygen-agent/cli 1.1014.18 → 1.1014.69
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/README.md +1 -1
- package/dist/command-manifest.js +1 -1
- package/dist/help.js +21 -1
- package/dist/index.js +50 -14
- package/dist/json-output.d.ts +3 -0
- package/dist/json-output.js +33 -0
- package/dist/workspace-skill-commands.d.ts +9 -0
- package/dist/workspace-skill-commands.js +134 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +9 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
- package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.js +7 -4
- package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.d.ts +14 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.js +18 -0
- package/node_modules/@oxygen/shared/dist/contracts/workspace-skills.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/contracts/workspace-skills.js +62 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.js +5 -1
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +178 -63
- package/node_modules/@oxygen/shared/dist/email-health-status.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/email-health-status.js +16 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +3 -2
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +9 -6
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +4 -2
- package/node_modules/@oxygen/shared/dist/langfuse.js +123 -66
- package/node_modules/@oxygen/shared/dist/table-run-summary.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/table-run-summary.js +26 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-skills.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/workspace-skills.js +102 -0
- package/package.json +1 -1
|
@@ -47,6 +47,10 @@ const WARN_THROTTLE_MS = 30_000;
|
|
|
47
47
|
* meant every flush that ran long lost its spans instead of finishing late.
|
|
48
48
|
*/
|
|
49
49
|
const LANGFUSE_EXPORT_TIMEOUT_SECONDS = 10;
|
|
50
|
+
// Keep retries below BSP's 30s batch deadline. Shutdown must outlive delivery;
|
|
51
|
+
// ordinary flush callers still return at their existing fail-open bound.
|
|
52
|
+
const LANGFUSE_EXPORT_BUDGET_MS = 25_000;
|
|
53
|
+
const LANGFUSE_SHUTDOWN_TIMEOUT_MS = 30_000;
|
|
50
54
|
// Scores are API strings; unlike observation payloads they cannot use chunks.
|
|
51
55
|
const MAX_JSON_FIELD_CHARS = 400_000;
|
|
52
56
|
/**
|
|
@@ -183,10 +187,12 @@ function compact(body) {
|
|
|
183
187
|
// many spans Langfuse acknowledged, how many batches it refused, and how many
|
|
184
188
|
// observations never reached the exporter at all. Counts only; never content.
|
|
185
189
|
const EXPORT_SUMMARY_INTERVAL_MS = 60_000;
|
|
186
|
-
export function createLlmExportSummary(now = Date.now) {
|
|
190
|
+
export function createLlmExportSummary(now = Date.now, context = {}) {
|
|
187
191
|
let exported = 0;
|
|
188
192
|
let failed = 0;
|
|
189
193
|
let dropped = 0;
|
|
194
|
+
let retried = 0;
|
|
195
|
+
let recovered = 0;
|
|
190
196
|
let windowStartedAt = now();
|
|
191
197
|
const report = () => {
|
|
192
198
|
const at = now();
|
|
@@ -196,12 +202,17 @@ export function createLlmExportSummary(now = Date.now) {
|
|
|
196
202
|
exported,
|
|
197
203
|
failed,
|
|
198
204
|
dropped,
|
|
205
|
+
retried,
|
|
206
|
+
recovered,
|
|
207
|
+
...context,
|
|
199
208
|
window_seconds: Math.max(0, Math.round((at - windowStartedAt) / 1000)),
|
|
200
209
|
});
|
|
201
210
|
}
|
|
202
211
|
exported = 0;
|
|
203
212
|
failed = 0;
|
|
204
213
|
dropped = 0;
|
|
214
|
+
retried = 0;
|
|
215
|
+
recovered = 0;
|
|
205
216
|
windowStartedAt = at;
|
|
206
217
|
};
|
|
207
218
|
const add = (count) => (Number.isFinite(count) && count > 0 ? Math.floor(count) : 0);
|
|
@@ -209,6 +220,8 @@ export function createLlmExportSummary(now = Date.now) {
|
|
|
209
220
|
exported: (count) => { exported += add(count); },
|
|
210
221
|
failed: (count) => { failed += add(count); },
|
|
211
222
|
dropped: (count) => { dropped += add(count); },
|
|
223
|
+
retried: () => { retried++; },
|
|
224
|
+
recovered: (count) => { recovered += add(count); },
|
|
212
225
|
maybeReport: () => { if (now() - windowStartedAt >= EXPORT_SUMMARY_INTERVAL_MS)
|
|
213
226
|
report(); },
|
|
214
227
|
reportNow: report,
|
|
@@ -270,75 +283,95 @@ function createOtelEmitter(env, warn, summary) {
|
|
|
270
283
|
if (!encoded)
|
|
271
284
|
throw new Error("serialization_failed");
|
|
272
285
|
const body = new TextDecoder().decode(encoded);
|
|
273
|
-
const
|
|
274
|
-
let
|
|
286
|
+
const startedAt = Date.now();
|
|
287
|
+
let status;
|
|
288
|
+
let failureKind = "transport_failed";
|
|
289
|
+
let attempts = 0;
|
|
275
290
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
if (signal.aborted)
|
|
281
|
-
throw new Error("deadline_exceeded");
|
|
282
|
-
if (attempt === 2)
|
|
283
|
-
throw new Error("network_failed");
|
|
284
|
-
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
if (![429, 502, 503, 504].includes(response.status) || attempt === 2)
|
|
291
|
+
const remaining = LANGFUSE_EXPORT_BUDGET_MS - (Date.now() - startedAt);
|
|
292
|
+
if (remaining <= 0)
|
|
288
293
|
break;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (!response?.ok) {
|
|
294
|
-
await response?.body?.cancel().catch(() => { });
|
|
295
|
-
throw new Error("http_rejected");
|
|
296
|
-
}
|
|
297
|
-
const chunks = [];
|
|
298
|
-
let bytes = 0;
|
|
299
|
-
const reader = response.body?.getReader();
|
|
300
|
-
if (reader) {
|
|
294
|
+
attempts = attempt + 1;
|
|
295
|
+
status = undefined;
|
|
296
|
+
const signal = AbortSignal.timeout(Math.min(LANGFUSE_EXPORT_TIMEOUT_SECONDS * 1000, remaining));
|
|
297
|
+
let response;
|
|
301
298
|
try {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// OTLP acknowledgements are tiny; an echoed payload must never
|
|
308
|
-
// become an unbounded allocation or reach any diagnostic logger.
|
|
309
|
-
if (bytes > 64 * 1024)
|
|
310
|
-
throw new Error("response_too_large");
|
|
311
|
-
chunks.push(next.value);
|
|
299
|
+
try {
|
|
300
|
+
response = await fetch(exportUrl, { method: "POST", headers: exportHeaders, body, signal, redirect: "error" });
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
throw new Error(signal.aborted ? "deadline_exceeded" : "network_failed");
|
|
312
304
|
}
|
|
305
|
+
status = response.status;
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
await response.body?.cancel().catch(() => { });
|
|
308
|
+
throw new Error("http_rejected");
|
|
309
|
+
}
|
|
310
|
+
const chunks = [];
|
|
311
|
+
let bytes = 0;
|
|
312
|
+
const reader = response.body?.getReader();
|
|
313
|
+
if (reader) {
|
|
314
|
+
try {
|
|
315
|
+
for (;;) {
|
|
316
|
+
const next = await reader.read();
|
|
317
|
+
if (next.done)
|
|
318
|
+
break;
|
|
319
|
+
bytes += next.value.byteLength;
|
|
320
|
+
// Never allocate or log an unbounded echoed private payload.
|
|
321
|
+
if (bytes > 64 * 1024)
|
|
322
|
+
throw new Error("response_too_large");
|
|
323
|
+
chunks.push(next.value);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
await reader.cancel().catch(() => { });
|
|
328
|
+
throw new Error(signal.aborted ? "deadline_exceeded" : error instanceof Error && error.message === "response_too_large" ? "response_too_large" : "response_read_failed");
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
reader.releaseLock();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
let acknowledgement;
|
|
335
|
+
try {
|
|
336
|
+
acknowledgement = bytes ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
throw new Error("response_invalid");
|
|
340
|
+
}
|
|
341
|
+
if (!acknowledgement || typeof acknowledgement !== "object" || Array.isArray(acknowledgement))
|
|
342
|
+
throw new Error("response_invalid");
|
|
343
|
+
const envelope = acknowledgement;
|
|
344
|
+
const partial = envelope.partialSuccess ?? envelope.partial_success;
|
|
345
|
+
if (partial !== undefined && partial !== null) {
|
|
346
|
+
if (typeof partial !== "object" || Array.isArray(partial))
|
|
347
|
+
throw new Error("response_invalid");
|
|
348
|
+
const rawRejected = partial.rejectedSpans ?? partial.rejected_spans ?? 0;
|
|
349
|
+
if ((typeof rawRejected !== "number" && typeof rawRejected !== "string") || !/^\d+$/.test(String(rawRejected)))
|
|
350
|
+
throw new Error("response_invalid");
|
|
351
|
+
if (Number(rawRejected) > 0)
|
|
352
|
+
throw new Error("partial_rejection");
|
|
353
|
+
}
|
|
354
|
+
if (attempt > 0)
|
|
355
|
+
summary.recovered(spans.length);
|
|
356
|
+
return;
|
|
313
357
|
}
|
|
314
358
|
catch (error) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
359
|
+
failureKind = error instanceof Error ? error.message : "transport_failed";
|
|
360
|
+
const retryable = ["deadline_exceeded", "network_failed", "response_read_failed"].includes(failureKind)
|
|
361
|
+
|| (failureKind === "http_rejected" && [429, 502, 503, 504].includes(status ?? 0));
|
|
362
|
+
if (!retryable || attempt === 2) {
|
|
363
|
+
// Carry fixed classifications and numbers only, never an SDK or
|
|
364
|
+
// receiver error that could quote the request/response payload.
|
|
365
|
+
throw Object.assign(new Error(failureKind), { attempts: attempt + 1, status });
|
|
366
|
+
}
|
|
367
|
+
const pause = 100 * (attempt + 1);
|
|
368
|
+
if (LANGFUSE_EXPORT_BUDGET_MS - (Date.now() - startedAt) <= pause)
|
|
369
|
+
break;
|
|
370
|
+
summary.retried();
|
|
371
|
+
await new Promise((resolve) => setTimeout(resolve, pause));
|
|
320
372
|
}
|
|
321
373
|
}
|
|
322
|
-
|
|
323
|
-
try {
|
|
324
|
-
acknowledgement = bytes ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : {};
|
|
325
|
-
}
|
|
326
|
-
catch {
|
|
327
|
-
throw new Error("response_invalid");
|
|
328
|
-
}
|
|
329
|
-
if (!acknowledgement || typeof acknowledgement !== "object" || Array.isArray(acknowledgement))
|
|
330
|
-
throw new Error("response_invalid");
|
|
331
|
-
const envelope = acknowledgement;
|
|
332
|
-
const partial = envelope.partialSuccess ?? envelope.partial_success;
|
|
333
|
-
if (partial !== undefined && partial !== null) {
|
|
334
|
-
if (typeof partial !== "object" || Array.isArray(partial))
|
|
335
|
-
throw new Error("response_invalid");
|
|
336
|
-
const rawRejected = partial.rejectedSpans ?? partial.rejected_spans ?? 0;
|
|
337
|
-
if ((typeof rawRejected !== "number" && typeof rawRejected !== "string") || !/^\d+$/.test(String(rawRejected)))
|
|
338
|
-
throw new Error("response_invalid");
|
|
339
|
-
if (Number(rawRejected) > 0)
|
|
340
|
-
throw new Error("partial_rejection");
|
|
341
|
-
}
|
|
374
|
+
throw Object.assign(new Error(failureKind), { attempts, status });
|
|
342
375
|
};
|
|
343
376
|
const privateExporter = {
|
|
344
377
|
export: (spans, done) => {
|
|
@@ -356,7 +389,9 @@ function createOtelEmitter(env, warn, summary) {
|
|
|
356
389
|
done({ code: 1, error: new Error("langfuse_delivery_failed") });
|
|
357
390
|
summary.failed(spans.length);
|
|
358
391
|
summary.maybeReport();
|
|
359
|
-
|
|
392
|
+
const details = error;
|
|
393
|
+
warn(error, { stage: "export", observation_count: spans.length, failure_kind: failureKind,
|
|
394
|
+
attempts: details.attempts, http_status: details.status });
|
|
360
395
|
});
|
|
361
396
|
pendingExports.add(pending);
|
|
362
397
|
// Even an unexpected SDK callback failure must not create a rejected
|
|
@@ -453,7 +488,23 @@ function createOtelEmitter(env, warn, summary) {
|
|
|
453
488
|
identity = undefined;
|
|
454
489
|
}
|
|
455
490
|
};
|
|
456
|
-
return { processor
|
|
491
|
+
return { processor: {
|
|
492
|
+
// BSP forceFlush drains its queue, but does not wait for a batch its
|
|
493
|
+
// interval already removed. Always drain our active exporter too,
|
|
494
|
+
// including when the queued batch rejects.
|
|
495
|
+
forceFlush: async () => { try {
|
|
496
|
+
await processor.forceFlush();
|
|
497
|
+
}
|
|
498
|
+
finally {
|
|
499
|
+
await privateExporter.forceFlush();
|
|
500
|
+
} },
|
|
501
|
+
shutdown: async () => { try {
|
|
502
|
+
await processor.shutdown();
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
await privateExporter.shutdown();
|
|
506
|
+
} },
|
|
507
|
+
}, emit };
|
|
457
508
|
}
|
|
458
509
|
catch (error) {
|
|
459
510
|
warn(error, { stage: "init" });
|
|
@@ -693,6 +744,11 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
693
744
|
}
|
|
694
745
|
return null;
|
|
695
746
|
}
|
|
747
|
+
const deliveryContext = {
|
|
748
|
+
tracing_environment: resolveLlmTracingEnvironment(env),
|
|
749
|
+
worker_role: ["relay", "bulk", "interactive", "provider", "outbound", "maintenance"].includes(env.OXYGEN_WORKER_QUEUE_ROLE ?? "")
|
|
750
|
+
? env.OXYGEN_WORKER_QUEUE_ROLE : undefined,
|
|
751
|
+
};
|
|
696
752
|
const lastWarnAtMs = new Map();
|
|
697
753
|
const warn = (error, context) => {
|
|
698
754
|
const nowMs = Date.now();
|
|
@@ -708,6 +764,7 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
708
764
|
// Exporter/API error messages can contain the rejected request or server
|
|
709
765
|
// response. Only this fixed classification may leave the private store.
|
|
710
766
|
error_code: errorCode,
|
|
767
|
+
...deliveryContext,
|
|
711
768
|
...context,
|
|
712
769
|
});
|
|
713
770
|
};
|
|
@@ -738,7 +795,7 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
738
795
|
return observationSpanId(["langfuse-observation-v1", body.traceId, kind, body.name, body.id,
|
|
739
796
|
body.startTime.toISOString(), body.metadata?.attempt_id ?? null, body.metadata?.call_id ?? null]);
|
|
740
797
|
};
|
|
741
|
-
const summary = createLlmExportSummary();
|
|
798
|
+
const summary = createLlmExportSummary(Date.now, deliveryContext);
|
|
742
799
|
const transport = options?.emitterImpl ?? createOtelEmitter(env, warn, summary);
|
|
743
800
|
const emitter = {
|
|
744
801
|
...transport,
|
|
@@ -970,7 +1027,7 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
970
1027
|
}
|
|
971
1028
|
finally {
|
|
972
1029
|
summary.reportNow();
|
|
973
|
-
} })(), (error) => warn(error, { stage: "shutdown" }),
|
|
1030
|
+
} })(), (error) => warn(error, { stage: "shutdown" }), LANGFUSE_SHUTDOWN_TIMEOUT_MS),
|
|
974
1031
|
};
|
|
975
1032
|
}
|
|
976
1033
|
// --- Process-wide singleton (both runtimes construct at most one client) ------
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SUMMARY_FIELDS = new Set([
|
|
2
|
+
"id", "runId", "tableId", "tableSlug", "status", "counts", "countsAvailable", "summaryLevel", "progress_counts_available", "creditsUsed", "creditsReserved",
|
|
3
|
+
"createdAt", "updatedAt", "startedAt", "completedAt", "lastError", "action_run_id",
|
|
4
|
+
"workspace_run_id", "table_id", "table_slug", "web_url", "deepLink", "plan",
|
|
5
|
+
"execution_status", "health_status", "user_status", "user_message", "progress_label",
|
|
6
|
+
"next_best_action", "recommended_action", "next_actions", "queue_age_seconds",
|
|
7
|
+
"deferred_count", "retrying_count", "credit_limit_skipped", "throughput", "bottlenecks",
|
|
8
|
+
"provider_errors", "provider_health", "provider_timeout_breaker", "classification_review",
|
|
9
|
+
"worker_health", "queue_backend", "execution_backend", "max_concurrency_requested",
|
|
10
|
+
"max_concurrency_effective", "enrichment_rollup", "idempotent_replay",
|
|
11
|
+
]);
|
|
12
|
+
/** Project the already-authorized, customer-safe native run; no provider or retry effects. */
|
|
13
|
+
export function tableRunSummary(run) {
|
|
14
|
+
if (run.view === "summary")
|
|
15
|
+
return run;
|
|
16
|
+
const included = (key) => SUMMARY_FIELDS.has(key) || /credit|cost|approval|error|failure/i.test(key);
|
|
17
|
+
const id = run.action_run_id ?? run.id;
|
|
18
|
+
return {
|
|
19
|
+
...Object.fromEntries(Object.entries(run).filter(([key]) => included(key))),
|
|
20
|
+
view: "summary",
|
|
21
|
+
omitted_fields: Object.keys(run).filter((key) => !included(key)).sort(),
|
|
22
|
+
full_read: { tool: "oxygen_table_runs_get", arguments: { action_run_id: id, view: "full" },
|
|
23
|
+
gateway: { tool: "oxygen_capabilities_read", arguments: { tool: "oxygen_table_runs_get", arguments: { action_run_id: id, view: "full" } } },
|
|
24
|
+
cli: `oxygen table-runs get ${String(id)} --full --json` },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const OXYGEN_BUILD_VERSION = "1.1014.
|
|
1
|
+
export declare const OXYGEN_BUILD_VERSION = "1.1014.69";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
|
|
2
|
-
export const OXYGEN_BUILD_VERSION = "1.1014.
|
|
2
|
+
export const OXYGEN_BUILD_VERSION = "1.1014.69";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const WORKSPACE_SKILL_MAX_FILES = 50;
|
|
2
|
+
export declare const WORKSPACE_SKILL_MAX_FILE_BYTES = 120000;
|
|
3
|
+
export declare const WORKSPACE_SKILL_MAX_BYTES = 1000000;
|
|
4
|
+
export type WorkspaceSkillFile = {
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
};
|
|
8
|
+
export type WorkspaceSkillBundle = {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
files: WorkspaceSkillFile[];
|
|
12
|
+
source_url?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function workspaceSkillPath(path: unknown): string;
|
|
15
|
+
export declare function validateWorkspaceSkillFile(file: WorkspaceSkillFile): number;
|
|
16
|
+
/** Content stays verbatim. Names and paths are metadata, never tool authority. */
|
|
17
|
+
export declare function parseWorkspaceSkillBundle(input: {
|
|
18
|
+
files: readonly WorkspaceSkillFile[];
|
|
19
|
+
name?: string;
|
|
20
|
+
source_url?: string;
|
|
21
|
+
}): WorkspaceSkillBundle;
|
|
22
|
+
export declare function authorWorkspaceSkill(input: {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
instructions: string;
|
|
26
|
+
}): WorkspaceSkillBundle;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { OxygenError } from "./cli-result.js";
|
|
2
|
+
import { parseKnowledgeVaultMarkdown } from "./knowledge-vault-markdown.js";
|
|
3
|
+
export const WORKSPACE_SKILL_MAX_FILES = 50;
|
|
4
|
+
export const WORKSPACE_SKILL_MAX_FILE_BYTES = 120_000;
|
|
5
|
+
export const WORKSPACE_SKILL_MAX_BYTES = 1_000_000;
|
|
6
|
+
function invalid(message) {
|
|
7
|
+
throw new OxygenError("invalid_workspace_skill", message, { exitCode: 2 });
|
|
8
|
+
}
|
|
9
|
+
export function workspaceSkillPath(path) {
|
|
10
|
+
if (typeof path !== "string" ||
|
|
11
|
+
path.length > 240 ||
|
|
12
|
+
!path ||
|
|
13
|
+
/[\\\u0000-\u001f\u007f]/u.test(path) ||
|
|
14
|
+
path
|
|
15
|
+
.split("/")
|
|
16
|
+
.some((part) => !part || part === "." || part === ".." || part.length > 120) ||
|
|
17
|
+
path.split("/").length > 15)
|
|
18
|
+
invalid("File paths must be relative, without empty segments, backslashes or traversal.");
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
export function validateWorkspaceSkillFile(file) {
|
|
22
|
+
const path = workspaceSkillPath(file?.path);
|
|
23
|
+
if (typeof file.content !== "string" || file.content.includes("\0"))
|
|
24
|
+
invalid(`File ${path} must contain text.`);
|
|
25
|
+
const bytes = new TextEncoder().encode(file.content).length;
|
|
26
|
+
if (bytes > WORKSPACE_SKILL_MAX_FILE_BYTES)
|
|
27
|
+
invalid(`File ${path} exceeds ${WORKSPACE_SKILL_MAX_FILE_BYTES} bytes.`);
|
|
28
|
+
return bytes;
|
|
29
|
+
}
|
|
30
|
+
/** Content stays verbatim. Names and paths are metadata, never tool authority. */
|
|
31
|
+
export function parseWorkspaceSkillBundle(input) {
|
|
32
|
+
if (!Array.isArray(input.files) ||
|
|
33
|
+
input.files.length < 1 ||
|
|
34
|
+
input.files.length > WORKSPACE_SKILL_MAX_FILES)
|
|
35
|
+
invalid(`Provide 1–${WORKSPACE_SKILL_MAX_FILES} text files, including SKILL.md.`);
|
|
36
|
+
const paths = new Set();
|
|
37
|
+
let total = 0;
|
|
38
|
+
for (const file of input.files) {
|
|
39
|
+
const path = workspaceSkillPath(file?.path);
|
|
40
|
+
if (paths.has(path.toLowerCase()))
|
|
41
|
+
invalid(`Duplicate file path: ${path}`);
|
|
42
|
+
paths.add(path.toLowerCase());
|
|
43
|
+
total += validateWorkspaceSkillFile(file);
|
|
44
|
+
}
|
|
45
|
+
for (const path of paths) {
|
|
46
|
+
const parts = path.split("/");
|
|
47
|
+
for (let i = 1; i < parts.length; i++)
|
|
48
|
+
if (paths.has(parts.slice(0, i).join("/")))
|
|
49
|
+
invalid("A skill path cannot be both a file and a directory.");
|
|
50
|
+
}
|
|
51
|
+
if (total > WORKSPACE_SKILL_MAX_BYTES)
|
|
52
|
+
invalid(`Skill exceeds ${WORKSPACE_SKILL_MAX_BYTES} bytes.`);
|
|
53
|
+
const entry = input.files.find((file) => file.path === "SKILL.md");
|
|
54
|
+
if (!entry)
|
|
55
|
+
invalid("The skill must include a root SKILL.md file.");
|
|
56
|
+
const parsed = parseKnowledgeVaultMarkdown(entry.content);
|
|
57
|
+
if (parsed.frontmatterError)
|
|
58
|
+
invalid(`Invalid SKILL.md frontmatter: ${parsed.frontmatterError}`);
|
|
59
|
+
const name = input.name ?? parsed.properties.name;
|
|
60
|
+
const description = parsed.properties.description;
|
|
61
|
+
if (typeof name !== "string" ||
|
|
62
|
+
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) ||
|
|
63
|
+
name.length > 64)
|
|
64
|
+
invalid("A skill name must contain 1–64 lowercase letters, digits or single hyphens.");
|
|
65
|
+
if (typeof description !== "string" ||
|
|
66
|
+
!description.trim() ||
|
|
67
|
+
description.length > 1_024)
|
|
68
|
+
invalid("SKILL.md needs a description in its YAML frontmatter (1–1024 characters).");
|
|
69
|
+
if (input.source_url !== undefined) {
|
|
70
|
+
let url;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(input.source_url);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
invalid("source_url must be an HTTPS source URL.");
|
|
76
|
+
}
|
|
77
|
+
if (url.protocol !== "https:" ||
|
|
78
|
+
url.username ||
|
|
79
|
+
url.password ||
|
|
80
|
+
input.source_url.length > 2_000)
|
|
81
|
+
invalid("source_url must be an HTTPS source URL without credentials.");
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
description: description.trim(),
|
|
86
|
+
files: input.files.map((file) => ({ ...file })),
|
|
87
|
+
...(input.source_url ? { source_url: input.source_url } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function authorWorkspaceSkill(input) {
|
|
91
|
+
if (typeof input.instructions !== "string" || !input.instructions.trim())
|
|
92
|
+
invalid("Skill instructions are required.");
|
|
93
|
+
return parseWorkspaceSkillBundle({
|
|
94
|
+
name: input.name,
|
|
95
|
+
files: [
|
|
96
|
+
{
|
|
97
|
+
path: "SKILL.md",
|
|
98
|
+
content: `---\nname: ${JSON.stringify(input.name)}\ndescription: ${JSON.stringify(input.description)}\n---\n\n${input.instructions}\n`,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
}
|