@oxygen-agent/cli 1.1014.19 → 1.1014.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +1 -1
  3. package/dist/help.d.ts +1 -1
  4. package/dist/help.js +25 -3
  5. package/dist/index.js +81 -16
  6. package/dist/json-output.d.ts +3 -0
  7. package/dist/json-output.js +33 -0
  8. package/dist/workspace-skill-commands.d.ts +9 -0
  9. package/dist/workspace-skill-commands.js +135 -0
  10. package/node_modules/@oxygen/shared/dist/capability-discovery.js +9 -0
  11. package/node_modules/@oxygen/shared/dist/column-decision.js +1 -1
  12. package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
  13. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.d.ts +1 -1
  14. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.js +7 -4
  15. package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.d.ts +14 -0
  16. package/node_modules/@oxygen/shared/dist/contracts/table-query-raw.js +18 -0
  17. package/node_modules/@oxygen/shared/dist/contracts/workspace-skills.d.ts +6 -0
  18. package/node_modules/@oxygen/shared/dist/contracts/workspace-skills.js +62 -0
  19. package/node_modules/@oxygen/shared/dist/dev-eval-classifier.d.ts +3 -0
  20. package/node_modules/@oxygen/shared/dist/dev-eval-classifier.js +4 -0
  21. package/node_modules/@oxygen/shared/dist/dev-eval-context.d.ts +35 -0
  22. package/node_modules/@oxygen/shared/dist/dev-eval-context.js +108 -0
  23. package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.js +5 -1
  24. package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +178 -63
  25. package/node_modules/@oxygen/shared/dist/email-health-status.d.ts +11 -0
  26. package/node_modules/@oxygen/shared/dist/email-health-status.js +16 -0
  27. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +3 -2
  28. package/node_modules/@oxygen/shared/dist/hosted-ai.js +9 -6
  29. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +4 -2
  30. package/node_modules/@oxygen/shared/dist/langfuse.js +123 -66
  31. package/node_modules/@oxygen/shared/dist/table-run-summary.d.ts +2 -0
  32. package/node_modules/@oxygen/shared/dist/table-run-summary.js +26 -0
  33. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  34. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  35. package/node_modules/@oxygen/shared/dist/workspace-skills.d.ts +26 -0
  36. package/node_modules/@oxygen/shared/dist/workspace-skills.js +102 -0
  37. package/package.json +1 -1
@@ -184,8 +184,9 @@ export declare function resolveHostedAiModel(input: {
184
184
  export declare function managedModelLabel(model: string, preferredUseCase?: HostedAiUseCase): string | null;
185
185
  /**
186
186
  * What an AI-column run's provenance records as its model: the tier label for
187
- * a managed run, and the model itself only when the customer pinned it on
188
- * their own key. A BYOK run on the tier default is still OXYGEN's pick.
187
+ * a managed generative run, System One for classification, and the model itself
188
+ * only when the customer pinned it on their own key. A BYOK run on the tier
189
+ * default is still OXYGEN's pick.
189
190
  */
190
191
  export declare function customerFacingAiColumnModel(input: {
191
192
  model: string;
@@ -481,11 +481,11 @@ export function resolveHostedAiModel(input) {
481
481
  const BACKUP_MODEL_LABEL = "Oxygen backup model";
482
482
  // TypeSafe's System One classifier (`SYSTEM_ONE_MODEL` in @oxygen/integrations,
483
483
  // pinned as `jev-<version>`) is OXYGEN's choice too. It answers classification
484
- // columns and inbox triage at the managed Fast price, so provenance written with
485
- // its id (before the write-time fix, or by a later version bump) reads as that
486
- // tier. Matched by shape so a version bump stays covered.
484
+ // columns and inbox triage without text generation. Its flat classification
485
+ // price and inference class differ from the generative tiers. Keep a distinct
486
+ // label while hiding the managed vendor id, including after a version bump.
487
487
  const SYSTEM_ONE_MODEL_ID = /^jev-(?:\d+(?:\.\d+)*|latest)$/;
488
- const SYSTEM_ONE_MODEL_LABEL = MANAGED_AI_TIER_LABELS.low;
488
+ const SYSTEM_ONE_MODEL_LABEL = "System One";
489
489
  // Doppler can swap any tier's model without a deploy; the swapped-in id is still
490
490
  // OXYGEN's choice, so it is redacted like the registry default it replaces.
491
491
  const MODEL_OVERRIDE_ENV_PREFIXES = ["OXYGEN_OPENROUTER_MODEL_", "OXYGEN_COPILOT_MODEL_", "OXYGEN_AGENT_MODEL_"];
@@ -566,10 +566,13 @@ function labelForManagedModel(labels, model) {
566
566
  }
567
567
  /**
568
568
  * What an AI-column run's provenance records as its model: the tier label for
569
- * a managed run, and the model itself only when the customer pinned it on
570
- * their own key. A BYOK run on the tier default is still OXYGEN's pick.
569
+ * a managed generative run, System One for classification, and the model itself
570
+ * only when the customer pinned it on their own key. A BYOK run on the tier
571
+ * default is still OXYGEN's pick.
571
572
  */
572
573
  export function customerFacingAiColumnModel(input) {
574
+ if (SYSTEM_ONE_MODEL_ID.test(input.model))
575
+ return SYSTEM_ONE_MODEL_LABEL;
573
576
  if (input.credentialMode === "byok" && managedModelLabel(input.model, "ai_column") === null)
574
577
  return input.model;
575
578
  return MANAGED_AI_TIER_LABELS[input.reasoningLevel];
@@ -105,7 +105,7 @@ export type LlmTracingClient = {
105
105
  score(body: LlmScoreBody): Promise<boolean>;
106
106
  /** Never rejects; bounded at 5s on workers, 15s in serverless after(). */
107
107
  flush(): Promise<void>;
108
- /** Flush + stop timers. Same worker/serverless bound as flush(). */
108
+ /** Flush + stop timers. Never rejects; bounded at 30s to drain active retries. */
109
109
  shutdown(): Promise<void>;
110
110
  };
111
111
  /**
@@ -178,12 +178,14 @@ export type LlmExportSummary = {
178
178
  * spans, so exported:dropped is not a like-for-like ratio.
179
179
  */
180
180
  dropped(count: number): void;
181
+ retried(): void;
182
+ recovered(count: number): void;
181
183
  /** Logs the window when it is at least a minute old; otherwise no-op. */
182
184
  maybeReport(): void;
183
185
  /** Logs whatever is pending regardless of age (process shutdown). */
184
186
  reportNow(): void;
185
187
  };
186
- export declare function createLlmExportSummary(now?: () => number): LlmExportSummary;
188
+ export declare function createLlmExportSummary(now?: () => number, context?: Record<string, unknown>): LlmExportSummary;
187
189
  /**
188
190
  * Resolves a registered code prompt (name + content hash) to the native
189
191
  * Langfuse prompt version the deploy-time sync created for it. Synchronous
@@ -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 signal = AbortSignal.timeout(LANGFUSE_EXPORT_TIMEOUT_SECONDS * 1000);
274
- let response;
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
- try {
277
- response = await fetch(exportUrl, { method: "POST", headers: exportHeaders, body, signal, redirect: "error" });
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
- // A retryable rejection has no useful private response to retain.
290
- await response.body?.cancel().catch(() => { });
291
- await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
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
- for (;;) {
303
- const next = await reader.read();
304
- if (next.done)
305
- break;
306
- bytes += next.value.byteLength;
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
- await reader.cancel().catch(() => { });
316
- throw new Error(error instanceof Error && error.message === "response_too_large" ? "response_too_large" : "response_read_failed");
317
- }
318
- finally {
319
- reader.releaseLock();
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
- let acknowledgement;
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
- warn(error, { stage: "export", observation_count: spans.length, failure_kind: failureKind });
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, emit };
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" }), env.VERCEL ? 15_000 : FLUSH_TIMEOUT_MS),
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,2 @@
1
+ /** Project the already-authorized, customer-safe native run; no provider or retry effects. */
2
+ export declare function tableRunSummary(run: Record<string, unknown>): Record<string, unknown>;
@@ -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.19";
1
+ export declare const OXYGEN_BUILD_VERSION = "1.1014.81";
@@ -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.19";
2
+ export const OXYGEN_BUILD_VERSION = "1.1014.81";
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1014.19",
3
+ "version": "1.1014.81",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",