@oxygen-agent/cli 1.1013.94 → 1.1014.18

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 (33) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +190 -70
  3. package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +115 -0
  4. package/node_modules/@oxygen/shared/dist/agent-sessions.js +133 -0
  5. package/node_modules/@oxygen/shared/dist/app-storage-maintenance.d.ts +3 -0
  6. package/node_modules/@oxygen/shared/dist/app-storage-maintenance.js +16 -0
  7. package/node_modules/@oxygen/shared/dist/capability-discovery.js +77 -4
  8. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +72 -0
  9. package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +35 -0
  10. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
  11. package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
  12. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.d.ts +3 -0
  13. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.js +16 -4
  14. package/node_modules/@oxygen/shared/dist/copilot-access.d.ts +33 -0
  15. package/node_modules/@oxygen/shared/dist/copilot-access.js +33 -0
  16. package/node_modules/@oxygen/shared/dist/copilot-questions.d.ts +91 -0
  17. package/node_modules/@oxygen/shared/dist/copilot-questions.js +246 -0
  18. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +28 -18
  19. package/node_modules/@oxygen/shared/dist/hosted-ai.js +96 -45
  20. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +1 -1
  21. package/node_modules/@oxygen/shared/dist/langfuse.js +37 -9
  22. package/node_modules/@oxygen/shared/dist/object-storage.js +9 -0
  23. package/node_modules/@oxygen/shared/dist/sequences.d.ts +5 -1
  24. package/node_modules/@oxygen/shared/dist/sequences.js +91 -0
  25. package/node_modules/@oxygen/shared/dist/tool-call-markup.d.ts +23 -0
  26. package/node_modules/@oxygen/shared/dist/tool-call-markup.js +213 -0
  27. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +5 -0
  28. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  29. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  30. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  31. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  32. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +4 -0
  33. package/package.json +1 -1
@@ -51,7 +51,7 @@ const LANGFUSE_EXPORT_TIMEOUT_SECONDS = 10;
51
51
  const MAX_JSON_FIELD_CHARS = 400_000;
52
52
  /**
53
53
  * FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
54
- * "1"/"true" (trimmed, case-insensitive) AND both Langfuse keys are present.
54
+ * "1"/"true" (trimmed, case-insensitive), both keys and an explicit self-hosted URL.
55
55
  */
56
56
  export function isLlmTracingEnabled(env = process.env) {
57
57
  const raw = env.OXYGEN_LLM_TRACING_ENABLED;
@@ -61,7 +61,22 @@ export function isLlmTracingEnabled(env = process.env) {
61
61
  if (normalized !== "1" && normalized !== "true")
62
62
  return false;
63
63
  return ((env.LANGFUSE_PUBLIC_KEY?.trim() ?? "") !== "" &&
64
- (env.LANGFUSE_SECRET_KEY?.trim() ?? "") !== "");
64
+ (env.LANGFUSE_SECRET_KEY?.trim() ?? "") !== "" &&
65
+ langfuseEndpoint(env) !== undefined);
66
+ }
67
+ // Never let the SDK's managed default receive protected prompt payloads.
68
+ function langfuseEndpoint(env) {
69
+ try {
70
+ const url = new URL(env.LANGFUSE_BASE_URL?.trim() ?? "");
71
+ const hostname = url.hostname.replace(/\.$/, "");
72
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash ||
73
+ hostname === "langfuse.com" || hostname.endsWith(".langfuse.com"))
74
+ return undefined;
75
+ return url.toString().replace(/\/+$/, "");
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
65
80
  }
66
81
  // The Langfuse "environment" dimension separates dev/prod traces inside one
67
82
  // project. Explicit LANGFUSE_TRACING_ENVIRONMENT (Doppler, per config) wins.
@@ -234,7 +249,10 @@ function createOtelEmitter(env, warn, summary) {
234
249
  import("@langfuse/tracing"), import("@opentelemetry/api"), import("@langfuse/core"),
235
250
  import("@opentelemetry/otlp-transformer"),
236
251
  ]);
237
- const exportUrl = `${(env.LANGFUSE_BASE_URL?.trim() || "https://cloud.langfuse.com").replace(/\/+$/, "")}/api/public/otel/v1/traces`;
252
+ const endpoint = langfuseEndpoint(env);
253
+ if (!endpoint)
254
+ throw new Error("langfuse_endpoint_invalid");
255
+ const exportUrl = `${endpoint}/api/public/otel/v1/traces`;
238
256
  const exportHeaders = {
239
257
  "Content-Type": "application/json",
240
258
  Authorization: `Basic ${Buffer.from(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`).toString("base64")}`,
@@ -473,9 +491,7 @@ function createOtelEmitter(env, warn, summary) {
473
491
  * environment tag; the tag is the `environment` FIELD on the score body, and it
474
492
  * is resolved from the same helper the span processor uses so a score lands in
475
493
  * the same Langfuse environment as the trace it scores. OXYGEN always sets
476
- * LANGFUSE_BASE_URL (the project is US-region and the EU host 401s these keys);
477
- * the fallback is the SDK's documented default, which `@langfuse/core` itself
478
- * does not supply.
494
+ * LANGFUSE_BASE_URL explicitly; no managed endpoint is a fallback.
479
495
  */
480
496
  function createApiScorer(env, warn) {
481
497
  let handle = null;
@@ -483,8 +499,11 @@ function createApiScorer(env, warn) {
483
499
  handle ??= (async () => {
484
500
  try {
485
501
  const { LangfuseAPIClient } = await import("@langfuse/core");
502
+ const endpoint = langfuseEndpoint(env);
503
+ if (!endpoint)
504
+ throw new Error("langfuse_endpoint_invalid");
486
505
  const client = new LangfuseAPIClient({
487
- environment: env.LANGFUSE_BASE_URL?.trim() || "https://cloud.langfuse.com",
506
+ environment: endpoint,
488
507
  username: env.LANGFUSE_PUBLIC_KEY,
489
508
  password: env.LANGFUSE_SECRET_KEY,
490
509
  });
@@ -570,7 +589,11 @@ function mayPublishMissingPrompts(env, baseUrl) {
570
589
  return host !== "langfuse.com" && !host.endsWith(".langfuse.com");
571
590
  }
572
591
  export function createApiPromptResolver(env, warn, fetchImpl = fetch, now = Date.now) {
573
- const baseUrl = (env.LANGFUSE_BASE_URL?.trim() || "https://cloud.langfuse.com").replace(/\/+$/, "");
592
+ const baseUrl = langfuseEndpoint(env);
593
+ if (!baseUrl) {
594
+ warn(new Error("langfuse_endpoint_invalid"), { stage: "prompt_link" });
595
+ return { versionFor: () => undefined };
596
+ }
574
597
  const authorization = `Basic ${Buffer.from(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`).toString("base64")}`;
575
598
  const cache = new Map();
576
599
  const inflight = new Set();
@@ -663,8 +686,13 @@ export function createApiPromptResolver(env, warn, fetchImpl = fetch, now = Date
663
686
  * this direct factory exists for tests (inject `emitterImpl`).
664
687
  */
665
688
  export function createLlmTracingClient(env = process.env, options) {
666
- if (!options?.emitterImpl && !isLlmTracingEnabled(env))
689
+ if (!options?.emitterImpl && !isLlmTracingEnabled(env)) {
690
+ if (["1", "true"].includes(env.OXYGEN_LLM_TRACING_ENABLED?.trim().toLowerCase() ?? "") &&
691
+ env.LANGFUSE_PUBLIC_KEY?.trim() && env.LANGFUSE_SECRET_KEY?.trim() && !langfuseEndpoint(env)) {
692
+ log("warn", "llm_tracing.ingest_failed", { provider: "langfuse", error_code: "langfuse_endpoint_invalid", stage: "configuration" });
693
+ }
667
694
  return null;
695
+ }
668
696
  const lastWarnAtMs = new Map();
669
697
  const warn = (error, context) => {
670
698
  const nowMs = Date.now();
@@ -1,3 +1,4 @@
1
+ import { assertAppStorageWriteAllowed } from "./app-storage-maintenance.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
3
4
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -152,6 +153,7 @@ export function isPublishingMediaObjectKeyForOrganization(key, organizationId) {
152
153
  * asks, plus the single-PUT maximum below, which no plan exceeds.
153
154
  */
154
155
  export async function presignImportUpload(input) {
156
+ assertAppStorageWriteAllowed();
155
157
  if (!Number.isSafeInteger(input.contentLength) || input.contentLength < 1
156
158
  || input.contentLength > IMPORT_OBJECT_SINGLE_PUT_MAX_BYTES) {
157
159
  throw new OxygenError("request_too_large", "Import file is too large to stage in one upload.", {
@@ -181,6 +183,7 @@ export async function presignImportUpload(input) {
181
183
  };
182
184
  }
183
185
  export async function presignPublishingMediaUpload(input) {
186
+ assertAppStorageWriteAllowed();
184
187
  const { client, config } = resolveClient();
185
188
  const storageKey = buildPublishingMediaObjectKey({
186
189
  organizationId: input.organizationId,
@@ -203,6 +206,7 @@ export async function presignPublishingMediaUpload(input) {
203
206
  };
204
207
  }
205
208
  export async function presignCopilotAttachmentUpload(input) {
209
+ assertAppStorageWriteAllowed();
206
210
  const { client, config } = resolveClient();
207
211
  const storageKey = buildCopilotAttachmentObjectKey({
208
212
  organizationId: input.organizationId,
@@ -331,6 +335,7 @@ export async function presignPublishingMediaDownload(input) {
331
335
  });
332
336
  }
333
337
  export async function presignInboxAvatarUpload(input) {
338
+ assertAppStorageWriteAllowed();
334
339
  const { client, config } = resolveClient();
335
340
  const storageKey = buildInboxAvatarObjectKey({
336
341
  organizationId: input.organizationId,
@@ -363,6 +368,7 @@ export async function presignInboxAvatarUpload(input) {
363
368
  * re-sniffs: the public route serves this back from our own origin.
364
369
  */
365
370
  export async function putInboxAvatarObject(input) {
371
+ assertAppStorageWriteAllowed();
366
372
  const { client, config } = resolveClient();
367
373
  const storageKey = buildInboxAvatarObjectKey({
368
374
  organizationId: input.organizationId,
@@ -385,6 +391,7 @@ export async function putInboxAvatarObject(input) {
385
391
  * in a bucket shared by every tenant and several non-avatar namespaces.
386
392
  */
387
393
  export async function replaceInboxAvatarObject(input) {
394
+ assertAppStorageWriteAllowed();
388
395
  if (!isInboxAvatarObjectKeyForOrganization(input.storageKey, input.organizationId)) {
389
396
  throw new OxygenError("invalid_object_key", "Refusing to replace an avatar that belongs to another organization.", { exitCode: 1 });
390
397
  }
@@ -441,6 +448,7 @@ export async function getInboxAvatarObjectMetadata(input) {
441
448
  * only proves "some org's avatar".
442
449
  */
443
450
  export async function deleteInboxAvatarObject(input) {
451
+ assertAppStorageWriteAllowed();
444
452
  if (!input.storageKey.startsWith("inbox-avatars/")) {
445
453
  throw new OxygenError("invalid_object_key", "Refusing to delete an object outside the inbox-avatars prefix.", { details: { prefix: input.storageKey.split("/")[0] ?? "" }, exitCode: 1 });
446
454
  }
@@ -452,6 +460,7 @@ export async function deleteInboxAvatarObject(input) {
452
460
  await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: input.storageKey }));
453
461
  }
454
462
  export async function deleteImportObject(input) {
463
+ assertAppStorageWriteAllowed();
455
464
  const { client, config } = resolveClient();
456
465
  await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: input.storageKey }));
457
466
  }
@@ -955,10 +955,14 @@ export type SequenceLintIssue = {
955
955
  * - `sequence_step_sender_ignored`: a sender field on a step kind that does not
956
956
  * take it is not stored. A blind CLI run put `sender_profile_id` on an email
957
957
  * step before email took one; it was dropped without a word (2026-09-30).
958
+ * - `sequence_step_field_ignored`: any other field a step kind (or one of its
959
+ * A/B variants) does not take, e.g. `variants` on an invite or `subject_template`
960
+ * on a variant of a threaded email. Steps are rebuilt from known fields, so
961
+ * these read back missing with nothing to say why (2026-10-10).
958
962
  */
959
963
  export type SequenceDefinitionNotice = {
960
964
  path: string;
961
- code: "email_reply_rewritten" | "sequence_linkedin_owner_conflict" | "sequence_step_sender_ignored";
965
+ code: "email_reply_rewritten" | "sequence_linkedin_owner_conflict" | "sequence_step_sender_ignored" | "sequence_step_field_ignored";
962
966
  message: string;
963
967
  };
964
968
  export type ValidateSequenceOptions = {
@@ -1220,6 +1220,8 @@ export function validateSequenceDefinition(input, options = {}) {
1220
1220
  const step = normalizeStep(rawStep, index, options, issues);
1221
1221
  if (!step)
1222
1222
  return;
1223
+ if (options.notices)
1224
+ options.notices.push(...ignoredStepFieldNotices(rawStep, step, index));
1223
1225
  if (usedIds.has(step.id)) {
1224
1226
  issues.push({ path: `steps[${index}].id`, message: `Duplicate step id '${step.id}'.` });
1225
1227
  }
@@ -1755,6 +1757,95 @@ raw, index, options, issues) {
1755
1757
  return null;
1756
1758
  }
1757
1759
  }
1760
+ // The fields each step kind stores beyond id/kind/channel and the sender
1761
+ // fields (which carry their own notice). Anything else is dropped on save.
1762
+ const STEP_KIND_FIELDS = {
1763
+ visit_profile: [],
1764
+ invite: ["note_template"],
1765
+ wait_for_connection: ["timeout_days", "on_timeout"],
1766
+ message: ["template", "variants", "auto_optimize", "attachments"],
1767
+ inmail: ["subject_template", "template", "variants", "auto_optimize"],
1768
+ follow: [],
1769
+ like_post: ["reaction", "post_recency_days", "on_no_post"],
1770
+ comment_post: ["text_template", "ai_prompt", "post_recency_days", "on_no_post"],
1771
+ withdraw_invite: [],
1772
+ email_send: ["subject_template", "body_template", "body_html_template", "cc", "bcc", "send_window", "variants", "auto_optimize"],
1773
+ email_reply: ["body_template", "body_html_template", "cc", "bcc", "send_window", "variants", "auto_optimize"],
1774
+ email_enroll: ["subsequence_id"],
1775
+ email_move: ["subsequence_id"],
1776
+ email_stop: [],
1777
+ whatsapp_message: ["template", "variants", "auto_optimize"],
1778
+ call_task: ["note", "priority", "timeout_days", "on_timeout"],
1779
+ crm_task: ["provider", "connection_id", "property_mappings", "record_mappings", "record_links", "associations"],
1780
+ wait: ["days", "hours", "jitter_hours"],
1781
+ wait_for_signal: ["signal", "timeout_days", "on_timeout"],
1782
+ branch: ["condition", "then", "else", "then_id", "else_id", "timeout_days"],
1783
+ stop: [],
1784
+ };
1785
+ const STEP_COMMON_FIELDS = new Set(["id", "kind", "channel", "sender_profile_id", "sender_mailbox_id"]);
1786
+ const A_B_STEP_KINDS = "message, inmail, email_send and whatsapp_message";
1787
+ function meaningfulField(value) {
1788
+ if (value === undefined || value === null || value === "")
1789
+ return false;
1790
+ return !(Array.isArray(value) && value.length === 0);
1791
+ }
1792
+ /** Copy fields an A/B variant of this (normalized) step may vary. */
1793
+ function variantFieldsFor(step) {
1794
+ switch (step.kind) {
1795
+ case "message":
1796
+ case "whatsapp_message":
1797
+ return ["template"];
1798
+ case "inmail":
1799
+ return ["subject_template", "template"];
1800
+ case "email_send":
1801
+ return sequenceEmailStepThreads(step) ? ["body_template", "body_html_template"] : ["subject_template", "body_template", "body_html_template"];
1802
+ default:
1803
+ return [];
1804
+ }
1805
+ }
1806
+ /** Notices for every field of a raw step, or of its variants, that was not stored. */
1807
+ function ignoredStepFieldNotices(raw, step, index) {
1808
+ if (!isRecord(raw) || typeof raw.kind !== "string")
1809
+ return [];
1810
+ const kind = raw.kind;
1811
+ const takes = STEP_KIND_FIELDS[kind] ?? [];
1812
+ const notices = [];
1813
+ for (const [field, value] of Object.entries(raw)) {
1814
+ if (STEP_COMMON_FIELDS.has(field) || takes.includes(field) || !meaningfulField(value))
1815
+ continue;
1816
+ const hint = field === "variants" || field === "auto_optimize"
1817
+ ? ` A/B variants go on ${A_B_STEP_KINDS} steps.`
1818
+ : field === "attachments"
1819
+ ? " Only LinkedIn message steps take attachments."
1820
+ : takes.length > 0 ? ` ${kind} steps take ${takes.join(", ")}.` : "";
1821
+ notices.push({
1822
+ path: `steps[${index}].${field}`,
1823
+ code: "sequence_step_field_ignored",
1824
+ message: `step '${step.id}': ${kind} steps do not take ${field}, so it was not saved.${hint}`,
1825
+ });
1826
+ }
1827
+ const variants = raw.variants;
1828
+ const variantFields = variantFieldsFor(step);
1829
+ if (Array.isArray(variants) && variantFields.length > 0) {
1830
+ variants.forEach((variant, variantIndex) => {
1831
+ if (!isRecord(variant))
1832
+ return;
1833
+ for (const [field, value] of Object.entries(variant)) {
1834
+ if (variantFields.includes(field) || !meaningfulField(value))
1835
+ continue;
1836
+ const threaded = step.kind === "email_send" && field === "subject_template";
1837
+ notices.push({
1838
+ path: `steps[${index}].variants[${variantIndex}].${field}`,
1839
+ code: "sequence_step_field_ignored",
1840
+ message: threaded
1841
+ ? `step '${step.id}': variant ${sequenceVariantLabel(variantIndex + 1)} sets subject_template, but this email has no subject (it continues the thread), so its variants vary only the body. Give the step a subject_template to test subjects.`
1842
+ : `step '${step.id}': variant ${sequenceVariantLabel(variantIndex + 1)} sets ${field}, which a ${step.kind} variant does not vary, so it was not saved. Its variants vary ${variantFields.join(", ")}.`,
1843
+ });
1844
+ }
1845
+ });
1846
+ }
1847
+ return notices;
1848
+ }
1758
1849
  const LINKEDIN_POST_REACTION_SET = new Set(["like", "celebrate", "support", "funny", "love", "insightful"]);
1759
1850
  /** Max attachments per LinkedIn message (kept small — LinkedIn is not a file host). */
1760
1851
  const MAX_LINKEDIN_ATTACHMENTS = 5;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Removes tool-call markup from text that arrives in chunks. A chunk boundary
3
+ * can fall inside a token, so a suffix that could still become one is held until
4
+ * the next chunk decides it: never more than {@link MAX_HELD_CHARS} characters,
5
+ * and only when it starts with `<`, `[` or `]`.
6
+ */
7
+ export declare function createToolCallMarkupFilter(): {
8
+ push: (chunk: string) => string;
9
+ flush: () => string;
10
+ /** True once anything has been removed from this stream. */
11
+ removedAny: () => boolean;
12
+ };
13
+ /**
14
+ * The text with every tool-call block and stray template token removed. Text
15
+ * that held none comes back unchanged, byte for byte; where something was cut,
16
+ * the blank lines it leaves collapse and the ends are trimmed.
17
+ */
18
+ export declare function stripToolCallMarkup(text: string): string;
19
+ /**
20
+ * Did the model try to CALL something in its text? Only a call block counts: a
21
+ * stray end-of-turn token is leakage worth stripping, not an attempted call.
22
+ */
23
+ export declare function containsToolCallMarkup(text: string): boolean;
@@ -0,0 +1,213 @@
1
+ // Tool calls a model wrote as TEXT instead of making them.
2
+ //
3
+ // Every model behind an OpenAI-compatible endpoint has a native tool-call format
4
+ // that the serving provider is supposed to parse out of the completion and hand
5
+ // back as structured `tool_calls`. When that parse fails -- a template the
6
+ // provider does not know, a spelling the parser does not expect, a block split
7
+ // or left unclosed -- the raw markup arrives in `content` and streams straight
8
+ // to the person reading the reply. Seen in a staff Copilot session on 2026-10-10:
9
+ //
10
+ // I'll check Oxygen's signal sources and the play, then build a morning queue.
11
+ // <|DSML|tool_calls> <|DSML|invoke name="oxygen_knowledge_index"> …
12
+ //
13
+ // Nothing ran: the call never reached the runtime as a call. This module finds
14
+ // that markup and removes it from user-visible text. It is deliberately a closed
15
+ // list of known template tokens rather than a heuristic for "looks like a call":
16
+ // prose that merely mentions a tool, or a JSON example in a code fence, passes
17
+ // through untouched. The DeepSeek family is matched by shape, because its
18
+ // parsers have met spaced (`<|DSML| invoke`), doubled (`<||DSML||tool_calls>`)
19
+ // and slash-less closing spellings in the wild; the others by their literals.
20
+ //
21
+ // Pure and browser-safe: the worker strips the live stream, the runtime strips a
22
+ // final answer, and the web strips rows written before either did.
23
+ function literalMarker(open, close) {
24
+ return {
25
+ find: (text) => {
26
+ const index = text.indexOf(open);
27
+ return index < 0 ? null : { index, match: open };
28
+ },
29
+ ...(close === undefined ? {} : { close: close === null ? null : () => new RegExp(escapeRegExp(close)) }),
30
+ couldStart: (tail) => open.startsWith(tail),
31
+ };
32
+ }
33
+ function patternMarker(pattern, partial, close) {
34
+ return {
35
+ find: (text) => {
36
+ const found = pattern.exec(text);
37
+ return found ? { index: found.index, match: found[0] } : null;
38
+ },
39
+ ...(close === undefined ? {} : { close }),
40
+ couldStart: (tail) => partial.test(tail),
41
+ };
42
+ }
43
+ function escapeRegExp(value) {
44
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ }
46
+ // DeepSeek V3.2 and later: `<|DSML|tool_calls>`, `<|DSML|invoke name="…">`,
47
+ // `<|DSML|parameter …>`, closed by the same tag with a slash (or, seen in the
48
+ // wild, without one). One or two bars of either width, spaces anywhere.
49
+ const DSML = String.raw `<\s*\/?\s*[||]{1,2}\s*DSML\s*[||]{1,2}\s*`;
50
+ const DSML_PARTIAL = /^<\s*\/?\s*(?:[||]{1,2}\s*(?:D(?:S(?:M(?:L\s*[||]{0,2}\s*[A-Za-z_]*)?)?)?)?)?$/i;
51
+ const BLOCK_MARKERS = [
52
+ patternMarker(
53
+ // A lookahead rather than `\b`, so `<|DSML|tool` at the end of a chunk
54
+ // waits for the rest of `tool_calls` instead of opening the wrong block.
55
+ /<\s*[||]{1,2}\s*DSML\s*[||]{1,2}\s*(?:tool_calls|tool_call|function_calls|function_call|toolcalls|calls|tool|invoke|parameter)(?=[^A-Za-z0-9_])/i, DSML_PARTIAL, (opened) => {
56
+ const kind = /([A-Za-z_]+)$/.exec(opened)?.[1] ?? "";
57
+ return new RegExp(`${DSML}${escapeRegExp(kind)}\\s*>`, "i");
58
+ }),
59
+ // DeepSeek V3 and R1.
60
+ literalMarker("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
61
+ literalMarker("<|tool▁call▁begin|>", "<|tool▁call▁end|>"),
62
+ // Kimi K2; K3 opens a call with `<|open|>call` and ends the message with it.
63
+ literalMarker("<|tool_calls_section_begin|>", "<|tool_calls_section_end|>"),
64
+ literalMarker("<|tool_call_begin|>", "<|tool_call_end|>"),
65
+ literalMarker("<|open|>call", null),
66
+ // Hermes, Qwen and GLM; Gemma 4.
67
+ literalMarker("<tool_call>", "</tool_call>"),
68
+ literalMarker("<|tool_call>", "<tool_call|>"),
69
+ // MiniMax.
70
+ literalMarker("<minimax:tool_call>", "</minimax:tool_call>"),
71
+ // Anthropic-style XML, which several open models imitate, with and without
72
+ // its namespace prefix.
73
+ literalMarker("<function_calls>", "</function_calls>"),
74
+ literalMarker("<antml:function_calls>", "</antml:function_calls>"),
75
+ // gpt-oss's harmony format.
76
+ literalMarker("<|channel|>commentary to=", "<|call|>"),
77
+ // Llama and Mistral put the call after a marker and end the message there.
78
+ literalMarker("<|python_tag|>", null),
79
+ literalMarker("[TOOL_CALLS]", null),
80
+ ];
81
+ /**
82
+ * Lone template tokens with nothing around them to strip: `<|im_end|>`,
83
+ * `<|end▁of▁sentence|>`, `<|Assistant|>`, a DSML tag whose block was already
84
+ * cut, MiniMax M3's call prefix. Never prose.
85
+ */
86
+ const TOKEN_MARKERS = [
87
+ patternMarker(new RegExp(`${DSML}[^<>\\n]{0,200}>`, "i"), DSML_PARTIAL),
88
+ patternMarker(/<\|[A-Za-z][A-Za-z0-9_]{0,47}\|>/, /^<(?:\|[A-Za-z0-9_]{0,48}\|?)?$/),
89
+ patternMarker(/<|[^|<>\n]{1,48}|>/, /^<(?:|[^|<>\n]{0,48}|?)?$/),
90
+ literalMarker("]<]minimax[>["),
91
+ ];
92
+ /** Long enough to hold back any opener or token still arriving. */
93
+ const MAX_HELD_CHARS = 64;
94
+ /** A held tail this long that never completed is a cut-off marker, not prose. */
95
+ const MIN_RESIDUE_CHARS = 3;
96
+ function earliestHit(text) {
97
+ let best = null;
98
+ // Blocks first, and a later marker must start strictly earlier to win: an
99
+ // opener such as `<|tool_call_begin|>` is also a well-formed lone token.
100
+ for (const marker of [...BLOCK_MARKERS, ...TOKEN_MARKERS]) {
101
+ const found = marker.find(text);
102
+ if (!found || (best !== null && found.index >= best.index))
103
+ continue;
104
+ best = {
105
+ index: found.index,
106
+ length: found.match.length,
107
+ close: marker.close === undefined ? undefined : marker.close === null ? null : marker.close(found.match),
108
+ };
109
+ }
110
+ return best;
111
+ }
112
+ /** Where text stops being safe to emit: a suffix that may still become markup. */
113
+ function heldFrom(text) {
114
+ for (let index = Math.max(0, text.length - MAX_HELD_CHARS); index < text.length; index += 1) {
115
+ const char = text[index];
116
+ if (char !== "<" && char !== "[" && char !== "]")
117
+ continue;
118
+ const tail = text.slice(index);
119
+ if ([...BLOCK_MARKERS, ...TOKEN_MARKERS].some((marker) => marker.couldStart(tail)))
120
+ return index;
121
+ }
122
+ return text.length;
123
+ }
124
+ /** `<|open|>` is a whole token and also the start of Kimi's `<|open|>call` block. */
125
+ function couldBecomeBlock(tail) {
126
+ return BLOCK_MARKERS.some((marker) => marker.couldStart(tail));
127
+ }
128
+ /**
129
+ * Removes tool-call markup from text that arrives in chunks. A chunk boundary
130
+ * can fall inside a token, so a suffix that could still become one is held until
131
+ * the next chunk decides it: never more than {@link MAX_HELD_CHARS} characters,
132
+ * and only when it starts with `<`, `[` or `]`.
133
+ */
134
+ export function createToolCallMarkupFilter() {
135
+ let pending = "";
136
+ // undefined: outside any block. null: inside a block that runs to the end.
137
+ let closing;
138
+ let removed = false;
139
+ const drain = (final) => {
140
+ let out = "";
141
+ for (;;) {
142
+ if (closing !== undefined) {
143
+ const end = closing === null ? null : closing.exec(pending);
144
+ if (!end) {
145
+ // Keep only what could still be the start of the closing tag.
146
+ pending = closing === null || final ? "" : pending.slice(-MAX_HELD_CHARS);
147
+ return out;
148
+ }
149
+ pending = pending.slice(end.index + end[0].length);
150
+ closing = undefined;
151
+ continue;
152
+ }
153
+ const hit = earliestHit(pending);
154
+ if (hit && !final && hit.close === undefined && couldBecomeBlock(pending.slice(hit.index))) {
155
+ // A lone token that the next chunk may yet turn into a block opener.
156
+ out += pending.slice(0, hit.index);
157
+ pending = pending.slice(hit.index);
158
+ return out;
159
+ }
160
+ if (hit) {
161
+ out += pending.slice(0, hit.index);
162
+ pending = pending.slice(hit.index + hit.length);
163
+ closing = hit.close;
164
+ removed = true;
165
+ continue;
166
+ }
167
+ const held = heldFrom(pending);
168
+ if (final) {
169
+ // The stream ended inside a marker: what arrived of it is residue.
170
+ const residue = pending.length - held >= MIN_RESIDUE_CHARS;
171
+ out += residue ? pending.slice(0, held) : pending;
172
+ removed ||= residue;
173
+ pending = "";
174
+ return out;
175
+ }
176
+ out += pending.slice(0, held);
177
+ pending = pending.slice(held);
178
+ return out;
179
+ }
180
+ };
181
+ return {
182
+ push: (chunk) => {
183
+ pending += chunk;
184
+ return drain(false);
185
+ },
186
+ flush: () => {
187
+ const out = drain(true);
188
+ pending = "";
189
+ closing = undefined;
190
+ return out;
191
+ },
192
+ removedAny: () => removed,
193
+ };
194
+ }
195
+ /**
196
+ * The text with every tool-call block and stray template token removed. Text
197
+ * that held none comes back unchanged, byte for byte; where something was cut,
198
+ * the blank lines it leaves collapse and the ends are trimmed.
199
+ */
200
+ export function stripToolCallMarkup(text) {
201
+ const filter = createToolCallMarkupFilter();
202
+ const out = filter.push(text) + filter.flush();
203
+ if (!filter.removedAny())
204
+ return text;
205
+ return out.replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, "\n\n").trim();
206
+ }
207
+ /**
208
+ * Did the model try to CALL something in its text? Only a call block counts: a
209
+ * stray end-of-turn token is leakage worth stripping, not an attempted call.
210
+ */
211
+ export function containsToolCallMarkup(text) {
212
+ return BLOCK_MARKERS.some((marker) => marker.find(text) !== null);
213
+ }
@@ -5,6 +5,11 @@ import { getCapabilityRoute, getCapabilityRouteMatch, inferCapabilityRoute, } fr
5
5
  // the user-facing boundary while preserving table joins and channel-specific
6
6
  // connection routes.
7
7
  export function inferUserCapabilityRoute(query) {
8
+ if (/\b(ongoing worker|ongoing session|long horizon agent|campaign monitor|watch (?:a |one |this |the |specific )?campaign)\b/i.test(query)) {
9
+ const route = getCapabilityRouteMatch("agents");
10
+ if (route)
11
+ return { ...route, recommendedTools: ["oxygen_agent_session_templates", "oxygen_agent_session_create", "oxygen_agent_session_get", ...route.recommendedTools], recommendedCommands: ["agent session templates", "agent session create", "agent session get", ...route.recommendedCommands], exactCommand: "oxygen agent session templates --slug workspace --json", exactMcpCall: { tool: "oxygen_agent_session_templates", arguments: { slug: "workspace" } } };
12
+ }
8
13
  if (isProviderCredentialSetupIntent(query.toLowerCase())) {
9
14
  const setup = getCapabilityRouteMatch("connected-integrations");
10
15
  if (setup)
@@ -1,3 +1,3 @@
1
1
  export declare const OXYGEN_VERSION: string;
2
- export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.922.14";
2
+ export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.948.1";
3
3
  export declare const GEO_EXPORT_MINIMUM_CLI_VERSION = "1.1013.0";
@@ -1 +1 @@
1
- export declare const OXYGEN_BUILD_VERSION = "1.1013.94";
1
+ export declare const OXYGEN_BUILD_VERSION = "1.1014.18";
@@ -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.1013.94";
2
+ export const OXYGEN_BUILD_VERSION = "1.1014.18";
@@ -49,7 +49,7 @@ export const OXYGEN_VERSION = OXYGEN_BUILD_VERSION;
49
49
  // Since 2026-09-28 the release-preparation step advances this literal to the
50
50
  // highest CLI version published on npm for at least 30 days. It never lowers
51
51
  // the floor. The source change lands on dev before the next production release.
52
- export const OXYGEN_MINIMUM_CLI_VERSION = "1.922.14";
52
+ export const OXYGEN_MINIMUM_CLI_VERSION = "1.948.1";
53
53
  // The internal GEO export first gains required pagination in this dev version.
54
54
  // Earlier pilot clients would save a partial file without its continuation.
55
55
  export const GEO_EXPORT_MINIMUM_CLI_VERSION = "1.1013.0";
@@ -1,3 +1,4 @@
1
+ import { assertAppStorageWriteAllowed } from "./app-storage-maintenance.js";
1
2
  import { Buffer } from "node:buffer";
2
3
  import { createHash } from "node:crypto";
3
4
  import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
@@ -163,6 +164,7 @@ export const WORKSPACE_FILE_UPLOAD_PRESIGN_SECONDS = 900;
163
164
  * is signed by the presigner from ContentLength.
164
165
  */
165
166
  export async function presignWorkspaceFileUpload(input) {
167
+ assertAppStorageWriteAllowed();
166
168
  if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 0 || input.sizeBytes > WORKSPACE_OPAQUE_FILE_MAX_BYTES) {
167
169
  throw new OxygenError("workspace_file_too_large", "File exceeds 100 MiB.");
168
170
  }
@@ -176,6 +178,7 @@ export async function presignWorkspaceFileUpload(input) {
176
178
  }
177
179
  /** Immutable PUT followed by bounded readback. A matching existing object is a safe retry. */
178
180
  export async function putWorkspaceFileObject(input) {
181
+ assertAppStorageWriteAllowed();
179
182
  input.signal?.throwIfAborted();
180
183
  assertWorkspaceStoredFile(input.bytes, input.mimeType);
181
184
  const storageKey = buildWorkspaceFileObjectKey(input);
@@ -199,6 +202,7 @@ export async function putWorkspaceFileObject(input) {
199
202
  }
200
203
  /** Delete only an object key already proven to belong to this workspace file. */
201
204
  export async function deleteWorkspaceFileObject(input) {
205
+ assertAppStorageWriteAllowed();
202
206
  assertWorkspaceFileObjectKey(input);
203
207
  const { client, bucket } = resolveObjectStorageClient();
204
208
  await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: input.storageKey }), {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1013.94",
3
+ "version": "1.1014.18",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",