@oxygen-agent/cli 1.906.0 → 1.917.5
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 +3 -0
- package/dist/index.js +164 -22
- package/node_modules/@oxygen/formula/dist/expression.d.ts +21 -0
- package/node_modules/@oxygen/formula/dist/expression.js +42 -1
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +1 -1
- package/node_modules/@oxygen/formula/dist/formula-functions.js +10 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +11 -6
- package/node_modules/@oxygen/shared/dist/copilot-errors.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/copilot-errors.js +9 -0
- package/node_modules/@oxygen/shared/dist/copilot-plan.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/copilot-plan.js +46 -5
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/langfuse.js +83 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +16 -5
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +23 -2
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/package.json +5 -0
- package/package.json +2 -1
package/README.md
CHANGED
package/dist/command-manifest.js
CHANGED
|
@@ -36,6 +36,9 @@ const MUTATING_VERBS = new Set([
|
|
|
36
36
|
// recurring write, and --run can connect newly eligible mailboxes immediately.
|
|
37
37
|
"auto-enroll",
|
|
38
38
|
"backfill",
|
|
39
|
+
// A noun leaf, so the default would advertise `projects color` — a tenant
|
|
40
|
+
// write — to every agent as a read. `decide` is the same case.
|
|
41
|
+
"color",
|
|
39
42
|
// Exact leaves, not the `billing` prefix — `orgs billing-owners` is a read.
|
|
40
43
|
// Both move a workspace's billing owner, so an agent must treat them as writes.
|
|
41
44
|
"billing-link", "billing-unlink",
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
9
9
|
import { Command, CommanderError, Option } from "commander";
|
|
10
10
|
import { applyOxygenHelp } from "./help.js";
|
|
11
11
|
import { buildCommandManifest, getCommandManifestEntry, searchCommandManifest, suggestCommandNames, } from "./command-manifest.js";
|
|
12
|
-
import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
|
|
12
|
+
import { AGENCY_DIRECTORY_REGIONS, AGENCY_DIRECTORY_SERVICES, COLLAB_GATE_KINDS, COLLAB_GATE_PROSE, COLLAB_SUBJECT_KINDS, COLLAB_SUBJECT_KINDS_PROSE, COLLAB_SUBJECT_LABELS, COLLAB_SUBJECT_PROSE, GATE_KIND_SUBJECTS, describeWorkflowStatusChange, formatCellForDisplay, formatCopilotPlanDuration, formatCopilotPlanSeconds, formatPublicBudgetScopes, SUBJECT_PATH_FORMS_PROSE, formatSubjectPath, exitCodeForOxygenError, parseSubjectPath, parseSubjectRef, parseWorkflowStatusChange, isVersionGreater, isVersionLess, KNOWLEDGE_BOOTSTRAP_MAX_CREDITS, MAX_MCP_TOOL_NAME_LENGTH, normalizeCopilotPlanStepStatus, OXYGEN_CAPABILITY_ROUTES, OXYGEN_VERSION, OxygenError, getCapabilityRouteMatch, inferUserCapabilityRoute, parseKnowledgePageMarkdown, PLAN_LIMITS, serializeCapabilityRoute, sleep, success, TABLE_IMPORT_ROW_LIMIT, TAG_KINDS_PROSE, toFailure, workflowMcpToolName, } from "@oxygen/shared";
|
|
13
13
|
import { TAG_COLORS } from "@oxygen/shared/select-options";
|
|
14
14
|
import { inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer, parseXlsxWorkbookBuffer, } from "@oxygen/shared/file-import";
|
|
15
15
|
import { MAILBOX_IMPORT_FILE_MAX_BYTES as SHARED_MAILBOX_IMPORT_FILE_MAX_BYTES, MAILBOX_IMPORT_ROW_LIMIT as SHARED_MAILBOX_IMPORT_ROW_LIMIT, normalizeMailboxImportFile as normalizeSharedMailboxImportFile, normalizeMailboxImportVendor as normalizeSharedMailboxImportVendor, normalizeMailboxWorkbookRows, parseMailboxImportText, summarizeMailboxImportValidation as summarizeSharedMailboxImportValidation, } from "@oxygen/shared/mailbox-import";
|
|
@@ -567,6 +567,72 @@ function writeObservabilityCapsNotice(data) {
|
|
|
567
567
|
: Array.isArray(record.items) ? record.items.length : 0;
|
|
568
568
|
process.stderr.write(`note: showing ${returned} item(s); some sources hit the per-source cap (${cappedSources.join(", ")}) — raise --limit (max 100) or filter --source to see more.\n`);
|
|
569
569
|
}
|
|
570
|
+
// Discovery lists are windowed by default (200 rows, max 1000) so the cost of
|
|
571
|
+
// asking "what is in this workspace?" does not scale with how much the customer
|
|
572
|
+
// has built. A window nobody can see is indistinguishable from a complete
|
|
573
|
+
// answer, so say it on stderr — same treatment the observability console's
|
|
574
|
+
// per-source cap already gets. Machine-read stdout carries `capped`, `returned`
|
|
575
|
+
// and `limit` either way.
|
|
576
|
+
function writeListCapNotice(data, noun) {
|
|
577
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
578
|
+
return;
|
|
579
|
+
const record = data;
|
|
580
|
+
if (record.capped !== true)
|
|
581
|
+
return;
|
|
582
|
+
const returned = typeof record.returned === "number" ? record.returned : null;
|
|
583
|
+
process.stderr.write(`note: showing ${returned === null ? "a window of" : returned} ${noun}; more exist — raise --limit (max 1000) or pass --all.\n`);
|
|
584
|
+
}
|
|
585
|
+
// The map is bounded twice over — a scan window per kind, then a character
|
|
586
|
+
// budget across all of them — and both bounds are invisible in the rendered
|
|
587
|
+
// output. Say them on stderr for the same reason every other cap here is said:
|
|
588
|
+
// a window presented as a complete answer is a confident lie, and this one is
|
|
589
|
+
// the first thing an agent or a person reads about a workspace. `degraded` gets
|
|
590
|
+
// the same treatment: an empty kind that failed to read is not an empty kind.
|
|
591
|
+
function writeWorkspaceMapNotices(data) {
|
|
592
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
593
|
+
return;
|
|
594
|
+
const record = data;
|
|
595
|
+
const degraded = Array.isArray(record.degraded) ? record.degraded : [];
|
|
596
|
+
if (degraded.length > 0) {
|
|
597
|
+
process.stderr.write(`note: could not read ${degraded.join(", ")} — those kinds are unknown, not empty.\n`);
|
|
598
|
+
}
|
|
599
|
+
const kinds = Array.isArray(record.kinds) ? record.kinds : [];
|
|
600
|
+
const capped = kinds
|
|
601
|
+
.filter((kind) => Boolean(kind) && typeof kind === "object" && !Array.isArray(kind) && kind.capped === true)
|
|
602
|
+
.map((kind) => String(kind.kind));
|
|
603
|
+
if (capped.length > 0) {
|
|
604
|
+
process.stderr.write(`note: ${capped.join(", ")} hold more than this scan saw; their counts are a floor. Narrow with --query or --tag.\n`);
|
|
605
|
+
}
|
|
606
|
+
const budget = record.budget;
|
|
607
|
+
if (budget && typeof budget === "object" && !Array.isArray(budget)) {
|
|
608
|
+
const trimmed = budget.trimmed;
|
|
609
|
+
if (Array.isArray(trimmed) && trimmed.length > 0) {
|
|
610
|
+
process.stderr.write(`note: the character budget trimmed ${trimmed.length} item(s) from ${[...new Set(trimmed.map(String))].join(", ")}.\n`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const tagNote = record.tag_note;
|
|
614
|
+
if (typeof tagNote === "string" && tagNote)
|
|
615
|
+
process.stderr.write(`note: ${tagNote}\n`);
|
|
616
|
+
}
|
|
617
|
+
// The wiki index counts the whole wiki even when the page list is a window, so
|
|
618
|
+
// name both numbers: "200 of 512 pages" is honest, "200 pages" is not.
|
|
619
|
+
function writeKnowledgeIndexCapNotice(data) {
|
|
620
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
621
|
+
return;
|
|
622
|
+
const index = data.index;
|
|
623
|
+
if (!index || typeof index !== "object" || Array.isArray(index))
|
|
624
|
+
return;
|
|
625
|
+
const block = index;
|
|
626
|
+
if (block.capped !== true)
|
|
627
|
+
return;
|
|
628
|
+
const counts = block.counts;
|
|
629
|
+
const returned = typeof counts?.returned === "number" ? counts.returned : null;
|
|
630
|
+
const total = typeof counts?.total === "number" ? counts.total : null;
|
|
631
|
+
const shown = returned === null
|
|
632
|
+
? "a window of this wiki's pages"
|
|
633
|
+
: `${returned}${total === null ? "" : ` of ${total}`} page(s)`;
|
|
634
|
+
process.stderr.write(`note: listing ${shown}, most recently updated first; counts cover the whole wiki — raise --limit (max 1000) or pass --all.\n`);
|
|
635
|
+
}
|
|
570
636
|
// Arming a cron commits recurring spend, so `workflows enable` mirrors its
|
|
571
637
|
// `automation` block as stderr lines: what the schedule burns per day, what
|
|
572
638
|
// share of the monthly allowance that is, and when it runs out. Observed burn
|
|
@@ -2648,6 +2714,38 @@ export function createProgram() {
|
|
|
2648
2714
|
.action(async (options) => {
|
|
2649
2715
|
await handleAsyncAction("home standup", options, readHomeStandup);
|
|
2650
2716
|
});
|
|
2717
|
+
// `oxygen workspace map` sits beside `oxygen home` on purpose: home answers
|
|
2718
|
+
// "what needs me", the map answers "what is in here". Both are Control-surface
|
|
2719
|
+
// compositions over reads the primitives already own — neither is a primitive.
|
|
2720
|
+
program
|
|
2721
|
+
.command("workspace")
|
|
2722
|
+
.description("Read what this workspace contains, across every primitive.")
|
|
2723
|
+
.addCommand(new Command("map")
|
|
2724
|
+
.description("What this workspace actually contains: the newest tables, CRM objects, sequences, workflows, agents, wiki pages, posts and projects, each with its id, last activity, tags and link. Read-only, 0 credits. Capability search tells you what OXYGEN can do; this tells you what is here.")
|
|
2725
|
+
.option("--kind <kinds>", "Comma-separated kinds to include: table, crm_object, sequence, workflow, agent, knowledge_page, post, project.")
|
|
2726
|
+
.option("--tag <tag>", "Only objects carrying this workspace tag. Answered by the Tags footprint read, so it also covers kinds this map does not model.")
|
|
2727
|
+
.option("--query <text>", "Match on name, slug, or tag.")
|
|
2728
|
+
.option("--json", "Print a JSON envelope.")
|
|
2729
|
+
.addHelpText("after", "\nEach kind returns its newest few objects under a character budget the payload echoes back; `capped` says when a kind holds more than the scan saw. Hydrate one object with its own primitive's read (`oxygen tables describe`, `oxygen workflows get`, `oxygen knowledge page get`).\n\nRead-only means read-only: the map creates nothing in your workspace in order to answer, so an empty wiki reports zero pages rather than a starter set this command seeded. Use `oxygen knowledge index` when you do want the starter wiki created.\n")
|
|
2730
|
+
.action(async (options) => {
|
|
2731
|
+
await handleAsyncAction("workspace map", options, async () => {
|
|
2732
|
+
const params = new URLSearchParams();
|
|
2733
|
+
const kind = readOption(options.kind);
|
|
2734
|
+
if (kind)
|
|
2735
|
+
params.set("kind", kind);
|
|
2736
|
+
const tag = readOption(options.tag);
|
|
2737
|
+
if (tag)
|
|
2738
|
+
params.set("tag", tag);
|
|
2739
|
+
const query = readOption(options.query);
|
|
2740
|
+
if (query)
|
|
2741
|
+
params.set("query", query);
|
|
2742
|
+
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
2743
|
+
const data = await requestOxygen(`/api/cli/workspace/map${qs}`);
|
|
2744
|
+
if (!options.json)
|
|
2745
|
+
writeWorkspaceMapNotices(data);
|
|
2746
|
+
return data;
|
|
2747
|
+
});
|
|
2748
|
+
}));
|
|
2651
2749
|
const ACTIVATION_DESCRIPTION = "Where this workspace stands in the prescribed activation play (inbound-led outbound) and the ONE thing to do next: each step's state, what blocks a blocked one, the exact CLI / MCP tool / API call for the next step, and an honest pacing note — LinkedIn is read on a metered drip and a sender's warm-up ramp, not the size of your list, sets how fast anyone is contacted. Read-only, 0 credits. Read the play itself with `oxygen recipes list --stage day-1 --json`.";
|
|
2652
2750
|
// Bare `oxygen activation` is the spelling a founder guesses; `activation state`
|
|
2653
2751
|
// is the exact name discovery routes to (it is a gatewayCommand of the
|
|
@@ -3520,6 +3618,17 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
3520
3618
|
method: "POST",
|
|
3521
3619
|
body: { tags: splitCommaList(options.tags) },
|
|
3522
3620
|
}));
|
|
3621
|
+
}))
|
|
3622
|
+
.addCommand(new Command("color")
|
|
3623
|
+
.description("Set a project's colour in the Tables rail. `--color auto` restores the automatic colour.")
|
|
3624
|
+
.argument("<project>", "Project slug or id.")
|
|
3625
|
+
.requiredOption("--color <color>", "Colour name (gray, red, orange, amber, yellow, green, teal, blue, indigo, purple, pink) or `auto` to clear.")
|
|
3626
|
+
.option("--json", "Print a JSON envelope.")
|
|
3627
|
+
.action(async (project, options) => {
|
|
3628
|
+
await handleAsyncAction("projects color", options, () => requestOxygen(`/api/cli/projects/${encodeURIComponent(project)}/color`, {
|
|
3629
|
+
method: "POST",
|
|
3630
|
+
body: { color: options.color?.trim().toLowerCase() ?? null },
|
|
3631
|
+
}));
|
|
3523
3632
|
}));
|
|
3524
3633
|
program
|
|
3525
3634
|
.command("notetaker")
|
|
@@ -5038,9 +5147,12 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5038
5147
|
.option("--project <project>", "Project id or slug to filter by.")
|
|
5039
5148
|
.option("--tag <tag>", "Only tables carrying this workspace tag (see `oxygen tags list`).")
|
|
5040
5149
|
.option("--include-archived", "Also list archived and delete-scheduled tables (the restorable trash; each carries a lifecycle with purge_scheduled_at).")
|
|
5150
|
+
.option("--limit <n>", "Maximum tables to list. Defaults to 200; hard cap is 1000.")
|
|
5151
|
+
.option("--all", "List every table instead of the newest window.")
|
|
5041
5152
|
.option("--json", "Print a JSON envelope.")
|
|
5153
|
+
.addHelpText("after", "\nLists the 200 newest tables by default; `capped` in the JSON envelope says when there are more.\n")
|
|
5042
5154
|
.action(async (options) => {
|
|
5043
|
-
await handleAsyncAction("tables list", options, () => {
|
|
5155
|
+
await handleAsyncAction("tables list", options, async () => {
|
|
5044
5156
|
const params = new URLSearchParams();
|
|
5045
5157
|
const project = readOption(options.project);
|
|
5046
5158
|
if (project)
|
|
@@ -5050,8 +5162,16 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
5050
5162
|
params.set("tag", tag);
|
|
5051
5163
|
if (options.includeArchived)
|
|
5052
5164
|
params.set("include_archived", "true");
|
|
5165
|
+
const limit = readPositiveInt(options.limit);
|
|
5166
|
+
if (limit !== undefined)
|
|
5167
|
+
params.set("limit", String(limit));
|
|
5168
|
+
if (options.all)
|
|
5169
|
+
params.set("all", "true");
|
|
5053
5170
|
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
5054
|
-
|
|
5171
|
+
const data = await requestOxygen(`/api/cli/tables${qs}`);
|
|
5172
|
+
if (!options.json)
|
|
5173
|
+
writeListCapNotice(data, "table(s)");
|
|
5174
|
+
return data;
|
|
5055
5175
|
});
|
|
5056
5176
|
}))
|
|
5057
5177
|
.addCommand(new Command("query")
|
|
@@ -6242,10 +6362,24 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
|
|
|
6242
6362
|
}));
|
|
6243
6363
|
}))
|
|
6244
6364
|
.addCommand(new Command("index")
|
|
6245
|
-
.description("Compact wiki index:
|
|
6365
|
+
.description("Compact wiki index: each page's slug, one-liner, tags, link degree, plus type/status counts over the whole wiki. Lists the 200 most recently updated pages by default.")
|
|
6366
|
+
.option("--limit <n>", "Maximum pages to list. Defaults to 200; hard cap is 1000. Counts always cover the whole wiki.")
|
|
6367
|
+
.option("--all", "List every page instead of the most recently updated window.")
|
|
6246
6368
|
.option("--json", "Print a JSON envelope.")
|
|
6247
6369
|
.action(async (options) => {
|
|
6248
|
-
await handleAsyncAction("knowledge index", options, () =>
|
|
6370
|
+
await handleAsyncAction("knowledge index", options, async () => {
|
|
6371
|
+
const params = new URLSearchParams();
|
|
6372
|
+
const limit = readPositiveInt(options.limit);
|
|
6373
|
+
if (limit !== undefined)
|
|
6374
|
+
params.set("limit", String(limit));
|
|
6375
|
+
if (options.all)
|
|
6376
|
+
params.set("all", "true");
|
|
6377
|
+
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
6378
|
+
const data = await requestOxygen(`/api/cli/knowledge/index${qs}`);
|
|
6379
|
+
if (!options.json)
|
|
6380
|
+
writeKnowledgeIndexCapNotice(data);
|
|
6381
|
+
return data;
|
|
6382
|
+
});
|
|
6249
6383
|
}))
|
|
6250
6384
|
.addCommand(new Command("graph")
|
|
6251
6385
|
.description("Knowledge graph of pages and their [[wikilink]] edges. Pass --center to render one page's local neighborhood instead of the whole graph.")
|
|
@@ -15292,8 +15426,10 @@ Run completion:
|
|
|
15292
15426
|
.option("--tag <tag>", "Only workflows carrying this workspace tag (see `oxygen tags list`).")
|
|
15293
15427
|
.option("--node-testable", "Only canonical graph workflows, the ones `workflows call --node` can scope a test to. Legacy recipes and v1 step lists own their own execution order and are excluded.")
|
|
15294
15428
|
.option("--search <text>", "Match on name, slug, trigger event, or an integration the workflow uses — so \"meeting\" or \"hubspot\" finds it without knowing what someone named it.")
|
|
15429
|
+
.option("--limit <n>", "Maximum workflows to list. Defaults to 200; hard cap is 1000.")
|
|
15430
|
+
.option("--all", "List every workflow instead of the most-recently-updated window.")
|
|
15295
15431
|
.option("--json", "Print a JSON envelope.")
|
|
15296
|
-
.addHelpText("after", "\nEach row carries `format` (graph, recipe or steps) and `nodeTestable`, so you can tell which workflows support single-step testing without opening them.\n")
|
|
15432
|
+
.addHelpText("after", "\nEach row carries `format` (graph, recipe or steps) and `nodeTestable`, so you can tell which workflows support single-step testing without opening them.\nLists the 200 most recently updated workflows by default; `capped` in the JSON envelope says when there are more.\n")
|
|
15297
15433
|
.action(async (options) => {
|
|
15298
15434
|
await handleAsyncAction("workflows list", options, async () => {
|
|
15299
15435
|
const params = new URLSearchParams();
|
|
@@ -15305,10 +15441,17 @@ Run completion:
|
|
|
15305
15441
|
const search = readOption(options.search);
|
|
15306
15442
|
if (search)
|
|
15307
15443
|
params.set("search", search);
|
|
15444
|
+
const limit = readPositiveInt(options.limit);
|
|
15445
|
+
if (limit !== undefined)
|
|
15446
|
+
params.set("limit", String(limit));
|
|
15447
|
+
if (options.all)
|
|
15448
|
+
params.set("all", "true");
|
|
15308
15449
|
const qs = params.toString() ? `?${params.toString()}` : "";
|
|
15309
15450
|
const data = await requestOxygen(`/api/cli/workflows${qs}`);
|
|
15310
|
-
if (!options.json)
|
|
15451
|
+
if (!options.json) {
|
|
15311
15452
|
writeDisabledWorkflowNotices(data);
|
|
15453
|
+
writeListCapNotice(data, "workflow(s)");
|
|
15454
|
+
}
|
|
15312
15455
|
return data;
|
|
15313
15456
|
});
|
|
15314
15457
|
}))
|
|
@@ -16722,7 +16865,7 @@ function writeCopilotPlan(plan, webUrl) {
|
|
|
16722
16865
|
const lines = [];
|
|
16723
16866
|
if (typeof plan.summary === "string" && plan.summary)
|
|
16724
16867
|
lines.push(plan.summary, "");
|
|
16725
|
-
lines.push(`${progress.done ?? 0}/${progress.total ?? 0} steps done${formatPlanEta(eta)}`, "");
|
|
16868
|
+
lines.push(`${progress.done ?? 0}/${progress.total ?? 0} steps done${formatPlanEta(eta, plan.turnActive !== false)}`, "");
|
|
16726
16869
|
for (const step of steps) {
|
|
16727
16870
|
if (!isRecord(step))
|
|
16728
16871
|
continue;
|
|
@@ -16730,7 +16873,7 @@ function writeCopilotPlan(plan, webUrl) {
|
|
|
16730
16873
|
const mark = status === "done" ? "[x]" : status === "in_progress" ? "[>]" : status === "blocked" ? "[!]" : "[ ]";
|
|
16731
16874
|
const timing = status === "done"
|
|
16732
16875
|
? formatPlanDuration(step.elapsedMs)
|
|
16733
|
-
: typeof step.etaSeconds === "number" ? `~${
|
|
16876
|
+
: typeof step.etaSeconds === "number" ? `~${formatCopilotPlanSeconds(step.etaSeconds)} left` : "";
|
|
16734
16877
|
lines.push(`${mark} ${String(step.title ?? "")}${timing ? ` (${timing})` : ""}`);
|
|
16735
16878
|
for (const sub of Array.isArray(step.substeps) ? step.substeps : []) {
|
|
16736
16879
|
if (!isRecord(sub))
|
|
@@ -16746,7 +16889,12 @@ function writeCopilotPlan(plan, webUrl) {
|
|
|
16746
16889
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
16747
16890
|
}
|
|
16748
16891
|
/** Every estimate names its basis, so nobody reads a guess as a measurement. */
|
|
16749
|
-
function formatPlanEta(eta) {
|
|
16892
|
+
function formatPlanEta(eta, turnActive) {
|
|
16893
|
+
// Nothing is running, so nothing REMAINS. "estimating" on a session that stopped
|
|
16894
|
+
// weeks ago describes work in flight that does not exist; the step marks still
|
|
16895
|
+
// say where the copilot got to, which is the honest half of the answer.
|
|
16896
|
+
if (!turnActive)
|
|
16897
|
+
return "";
|
|
16750
16898
|
const remaining = typeof eta.remainingSeconds === "number" ? eta.remainingSeconds : null;
|
|
16751
16899
|
if (remaining === null)
|
|
16752
16900
|
return " · time remaining: estimating";
|
|
@@ -16755,19 +16903,11 @@ function formatPlanEta(eta) {
|
|
|
16755
16903
|
? `measured from ${measured} completed step${measured === 1 ? "" : "s"}`
|
|
16756
16904
|
: eta.basis === "model" ? "estimated, nothing measured yet" : "no basis";
|
|
16757
16905
|
const bound = eta.deadlineBound === true ? ", capped by the turn deadline" : "";
|
|
16758
|
-
return ` · ~${
|
|
16759
|
-
}
|
|
16760
|
-
function formatPlanSeconds(seconds) {
|
|
16761
|
-
if (seconds < 60)
|
|
16762
|
-
return `${Math.round(seconds)}s`;
|
|
16763
|
-
const minutes = Math.floor(seconds / 60);
|
|
16764
|
-
const rest = Math.round(seconds % 60);
|
|
16765
|
-
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
|
|
16906
|
+
return ` · ~${formatCopilotPlanSeconds(remaining)} left (${basis}${bound})`;
|
|
16766
16907
|
}
|
|
16908
|
+
/** Narrows the untyped ledger value, then defers to the one shared formatter. */
|
|
16767
16909
|
function formatPlanDuration(ms) {
|
|
16768
|
-
|
|
16769
|
-
return "";
|
|
16770
|
-
return ms < 1000 ? `${Math.round(ms)}ms` : formatPlanSeconds(ms / 1000);
|
|
16910
|
+
return (typeof ms === "number" ? formatCopilotPlanDuration(ms) : null) ?? "";
|
|
16771
16911
|
}
|
|
16772
16912
|
function printCopilotEvent(event, sessionId) {
|
|
16773
16913
|
if (!isRecord(event))
|
|
@@ -23458,7 +23598,9 @@ options, orgOverride) {
|
|
|
23458
23598
|
}
|
|
23459
23599
|
// Generated summaries come from the server's own projections, not a client-side
|
|
23460
23600
|
// recomputation. A real wiki page may own the `index`/`log` slug — page bytes win.
|
|
23461
|
-
|
|
23601
|
+
// `all=true` on purpose: the generated index.md must list every page this
|
|
23602
|
+
// mirror just cloned, and the route's default is a 200-page window.
|
|
23603
|
+
const index = await requestOxygen("/api/cli/knowledge/index?all=true", requestOrg);
|
|
23462
23604
|
const logPage = await requestOxygen("/api/cli/knowledge/log", {
|
|
23463
23605
|
method: "POST",
|
|
23464
23606
|
body: {},
|
|
@@ -41,6 +41,27 @@ export type ParseOptions = {
|
|
|
41
41
|
export declare function parseFormulaExpression(expression: string, options?: ParseOptions): FormulaAst;
|
|
42
42
|
/** Validate supported functions and arity on an already-parsed tree. */
|
|
43
43
|
export declare function validateFormulaAst(ast: FormulaAst): void;
|
|
44
|
+
/**
|
|
45
|
+
* SAVE-TIME ONLY: reject a literal regex pattern that is too long to ever compile.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately NOT part of validateFormulaAst. That runs on the READ path too
|
|
48
|
+
* (formula-column-runner prepares every batch through it) and
|
|
49
|
+
* prepareFormulaForRead swallows the throw and nulls the WHOLE column, so
|
|
50
|
+
* tightening it would silently blank already-stored columns rather than reject
|
|
51
|
+
* the edit. Keeping this save-only means a column that stores today keeps
|
|
52
|
+
* reading today, and only a NEW write is refused.
|
|
53
|
+
*
|
|
54
|
+
* Length is checked, not compilation. A pattern over the cap can never succeed in
|
|
55
|
+
* any branch, so flagging it costs the author nothing -- whereas rejecting an
|
|
56
|
+
* unparseable literal would break `if(cond, regex_match(x, "["), "n/a")`, which
|
|
57
|
+
* works today precisely because if/switch/and/or/coalesce are lazy and never
|
|
58
|
+
* evaluate the untaken side. That stricter semantic is a separate decision.
|
|
59
|
+
*
|
|
60
|
+
* Without this, an over-long pattern stored fine and then failed per row on every
|
|
61
|
+
* run forever -- the production shape was one column failing across dozens of rows
|
|
62
|
+
* for days with received_length 290 against the old 256 cap.
|
|
63
|
+
*/
|
|
64
|
+
export declare function validateFormulaRegexLiterals(ast: FormulaAst): void;
|
|
44
65
|
/** Validate syntax, supported functions, and arity without evaluating any data. */
|
|
45
66
|
export declare function validateFormulaExpression(expression: string, options?: ParseOptions): void;
|
|
46
67
|
export declare function walkExpression(node: FormulaAst, visit: (node: FormulaAst) => void): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FORMULA_FUNCTION_REGISTRY, checkFormulaFunctionArity, formulaExpressionError, } from "./formula-functions.js";
|
|
1
|
+
import { FORMULA_FUNCTION_REGISTRY, MAX_FORMULA_REGEX_PATTERN_LENGTH, checkFormulaFunctionArity, formulaExpressionError, } from "./formula-functions.js";
|
|
2
2
|
/**
|
|
3
3
|
* Functions evaluated against the whole column rather than the current scope.
|
|
4
4
|
* They are intercepted before registry dispatch (the registry entry carries
|
|
@@ -37,6 +37,47 @@ export function validateFormulaAst(ast) {
|
|
|
37
37
|
checkFormulaFunctionArity(spec, node.args.length);
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* SAVE-TIME ONLY: reject a literal regex pattern that is too long to ever compile.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately NOT part of validateFormulaAst. That runs on the READ path too
|
|
44
|
+
* (formula-column-runner prepares every batch through it) and
|
|
45
|
+
* prepareFormulaForRead swallows the throw and nulls the WHOLE column, so
|
|
46
|
+
* tightening it would silently blank already-stored columns rather than reject
|
|
47
|
+
* the edit. Keeping this save-only means a column that stores today keeps
|
|
48
|
+
* reading today, and only a NEW write is refused.
|
|
49
|
+
*
|
|
50
|
+
* Length is checked, not compilation. A pattern over the cap can never succeed in
|
|
51
|
+
* any branch, so flagging it costs the author nothing -- whereas rejecting an
|
|
52
|
+
* unparseable literal would break `if(cond, regex_match(x, "["), "n/a")`, which
|
|
53
|
+
* works today precisely because if/switch/and/or/coalesce are lazy and never
|
|
54
|
+
* evaluate the untaken side. That stricter semantic is a separate decision.
|
|
55
|
+
*
|
|
56
|
+
* Without this, an over-long pattern stored fine and then failed per row on every
|
|
57
|
+
* run forever -- the production shape was one column failing across dozens of rows
|
|
58
|
+
* for days with received_length 290 against the old 256 cap.
|
|
59
|
+
*/
|
|
60
|
+
export function validateFormulaRegexLiterals(ast) {
|
|
61
|
+
walkExpression(ast, (node) => {
|
|
62
|
+
if (node.type !== "call")
|
|
63
|
+
return;
|
|
64
|
+
const normalized = node.name.toLowerCase();
|
|
65
|
+
if (normalized !== "regex_match" && normalized !== "regex_extract" && normalized !== "regex_replace") {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Every regex function takes its pattern as the second argument.
|
|
69
|
+
const pattern = node.args[1];
|
|
70
|
+
if (!pattern || pattern.type !== "literal" || typeof pattern.value !== "string")
|
|
71
|
+
return;
|
|
72
|
+
if (pattern.value.length <= MAX_FORMULA_REGEX_PATTERN_LENGTH)
|
|
73
|
+
return;
|
|
74
|
+
throw formulaExpressionError("Regular-expression pattern is too long.", {
|
|
75
|
+
function: node.name,
|
|
76
|
+
max_length: MAX_FORMULA_REGEX_PATTERN_LENGTH,
|
|
77
|
+
received_length: pattern.value.length,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
40
81
|
/** Validate syntax, supported functions, and arity without evaluating any data. */
|
|
41
82
|
export function validateFormulaExpression(expression, options = {}) {
|
|
42
83
|
validateFormulaAst(parseFormulaExpression(expression, options));
|
|
@@ -37,7 +37,7 @@ export type FormulaFunctionSpec = FormulaFunctionMeta & {
|
|
|
37
37
|
evaluate?: (args: unknown[]) => unknown;
|
|
38
38
|
evaluateLazy?: (thunks: Array<() => unknown>) => unknown;
|
|
39
39
|
};
|
|
40
|
-
export declare const MAX_FORMULA_REGEX_PATTERN_LENGTH =
|
|
40
|
+
export declare const MAX_FORMULA_REGEX_PATTERN_LENGTH = 1000;
|
|
41
41
|
export declare const MAX_FORMULA_REGEX_INPUT_LENGTH = 20000;
|
|
42
42
|
export declare function formulaExpressionError(message: string, details: Record<string, unknown>): OxygenError;
|
|
43
43
|
export declare function isBlankFormulaValue(value: unknown): boolean;
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { OxygenError } from "@oxygen/shared/cli-result";
|
|
2
2
|
import { walkJsonPath } from "@oxygen/shared/json-path";
|
|
3
3
|
import { normalizeDomain, normalizeEmail, normalizeLinkedinUrl, } from "./value-normalizers.js";
|
|
4
|
-
|
|
4
|
+
// Pattern LENGTH is not a safety bound and never was -- catastrophic backtracking
|
|
5
|
+
// is a function of pattern SHAPE, and `(a+)+$` hangs at 6 characters, so 256 already
|
|
6
|
+
// permitted an unbounded hang while rejecting harmless long literals. The real bound
|
|
7
|
+
// on scan cost is MAX_FORMULA_REGEX_INPUT_LENGTH below, which caps what a pattern is
|
|
8
|
+
// run against. 256 rejected a customer's 290-character alternation on every row of
|
|
9
|
+
// their table, continuously, for days (production 2026-08-28/31). Raised to a value
|
|
10
|
+
// that still stops a pathological paste while leaving ordinary generated alternations
|
|
11
|
+
// room. If real ReDoS protection is ever needed it belongs in the engine (a
|
|
12
|
+
// backtracking budget or re2), not in a character count.
|
|
13
|
+
export const MAX_FORMULA_REGEX_PATTERN_LENGTH = 1_000;
|
|
5
14
|
export const MAX_FORMULA_REGEX_INPUT_LENGTH = 20_000;
|
|
6
15
|
// ---------------------------------------------------------------------------
|
|
7
16
|
// Shared coercion helpers (used by the registry AND the runner's operators)
|
|
@@ -21,15 +21,20 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
21
21
|
id: "discovery-and-skills",
|
|
22
22
|
layer: "Control",
|
|
23
23
|
primitive: null,
|
|
24
|
-
owns: "Bounded capability, command, provider-operation, Recipe, and product-skill discovery with exact hydration on demand.",
|
|
24
|
+
owns: "Bounded capability, command, provider-operation, Recipe, and product-skill discovery, plus INSTANCE discovery — what this workspace actually holds — with exact hydration on demand.",
|
|
25
25
|
notFor: "Executing GTM work or loading full manifests and schemas before a capability is selected.",
|
|
26
|
-
execution: "Search by outcome, choose the owner, then hydrate one exact command, MCP schema, provider descriptor, or skill.",
|
|
26
|
+
execution: "Search by outcome, choose the owner, then hydrate one exact command, MCP schema, provider descriptor, or skill. For instance discovery, read the map and then the primitive that owns the row.",
|
|
27
27
|
posture: "read_only",
|
|
28
|
-
gatewayTools: ["oxygen_capabilities_search", "oxygen_tools_search", "oxygen_recipes_list"],
|
|
29
|
-
gatewayCommands: ["capabilities search", "commands search", "skills search", "tools search", "recipes list"],
|
|
28
|
+
gatewayTools: ["oxygen_capabilities_search", "oxygen_tools_search", "oxygen_recipes_list", "oxygen_workspace_map"],
|
|
29
|
+
gatewayCommands: ["capabilities search", "commands search", "skills search", "tools search", "recipes list", "workspace map"],
|
|
30
30
|
skills: ["oxygen-quickstart", "oxygen-gtm"],
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// `workspace` (the map) belongs here rather than beside `home`: capability
|
|
32
|
+
// discovery answers what OXYGEN can do and instance discovery answers what THIS
|
|
33
|
+
// workspace holds, and they are the same act — bounded, read-only, hydrate one
|
|
34
|
+
// thing on demand. `home` stayed on the onboarding card because the standup is a
|
|
35
|
+
// first-run surface; the map is not.
|
|
36
|
+
endpointSections: ["skills", "workspace"],
|
|
37
|
+
intentTerms: ["discover", "discovery", "capability", "capabilities", "command", "commands", "skill", "skills", "which tool", "how do i", "what do i have", "what is in my workspace", "workspace map"],
|
|
33
38
|
},
|
|
34
39
|
{
|
|
35
40
|
id: "onboarding-and-copilot",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare const COPILOT_TURN_TIMEOUT_CODE = "copilot_turn_timeout";
|
|
2
2
|
export declare const COPILOT_TURN_TIMEOUT_MESSAGE = "This Copilot request timed out and stopped. Any completed actions are still saved\u2014review this session before trying again.";
|
|
3
|
+
export declare const COPILOT_TURN_DEADLINE_EXCEEDED_CODE = "copilot_turn_deadline_exceeded";
|
|
3
4
|
export type CustomerFacingCopilotError = {
|
|
4
5
|
code: string | null;
|
|
5
6
|
message: string | null;
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
// and already-persisted failures serialize identically across web, CLI, and MCP.
|
|
5
5
|
export const COPILOT_TURN_TIMEOUT_CODE = "copilot_turn_timeout";
|
|
6
6
|
export const COPILOT_TURN_TIMEOUT_MESSAGE = "This Copilot request timed out and stopped. Any completed actions are still saved—review this session before trying again.";
|
|
7
|
+
// A turn the worker refused to start because it was ALREADY past its stamped
|
|
8
|
+
// wall-clock deadline when it was reclaimed. Distinct in the durable row from
|
|
9
|
+
// `copilot_turn_timeout` (a turn that ran and then ran out of clock) so an
|
|
10
|
+
// operator can separate "we never started it" from "we could not finish it" in
|
|
11
|
+
// SQL — but deliberately NOT distinct to the customer: the mapping below folds
|
|
12
|
+
// it into the same timeout contract every Control surface already renders, per
|
|
13
|
+
// this module's policy.
|
|
14
|
+
export const COPILOT_TURN_DEADLINE_EXCEEDED_CODE = "copilot_turn_deadline_exceeded";
|
|
7
15
|
const WORKER_STEP_TIMEOUT_MESSAGE = /\bWorker step '[^']+' exceeded \d+ms deadline\.?/i;
|
|
8
16
|
/**
|
|
9
17
|
* Replace an internal worker deadline with the stable Copilot timeout contract.
|
|
@@ -15,6 +23,7 @@ export function customerFacingCopilotError(input) {
|
|
|
15
23
|
const message = input.message ?? null;
|
|
16
24
|
const isTimeout = code === "worker_step_timeout" ||
|
|
17
25
|
code === COPILOT_TURN_TIMEOUT_CODE ||
|
|
26
|
+
code === COPILOT_TURN_DEADLINE_EXCEEDED_CODE ||
|
|
18
27
|
(message !== null && WORKER_STEP_TIMEOUT_MESSAGE.test(message));
|
|
19
28
|
return isTimeout
|
|
20
29
|
? { code: COPILOT_TURN_TIMEOUT_CODE, message: COPILOT_TURN_TIMEOUT_MESSAGE }
|
|
@@ -109,6 +109,8 @@ export type CopilotPlanProjection = {
|
|
|
109
109
|
/** True when the turn's wall-clock deadline, not the plan, is the binding bound. */
|
|
110
110
|
deadlineBound: boolean;
|
|
111
111
|
};
|
|
112
|
+
/** False when no turn is executing: the plan is history, not work in flight. */
|
|
113
|
+
turnActive: boolean;
|
|
112
114
|
/** Ledger position of the newest plan_updated, so a caller can tell staleness. */
|
|
113
115
|
updatedAtSeq: number;
|
|
114
116
|
updatedAt: string;
|
|
@@ -124,6 +126,20 @@ export type ProjectCopilotPlanInput = {
|
|
|
124
126
|
events: CopilotPlanSourceEvent[];
|
|
125
127
|
/** The active turn's wall-clock deadline, used only to clamp the total. */
|
|
126
128
|
turnDeadlineAt?: string | Date | null;
|
|
129
|
+
/**
|
|
130
|
+
* Whether a turn is executing right now. Defaults to true.
|
|
131
|
+
*
|
|
132
|
+
* A plan OUTLIVES the turn that wrote it. The model routinely stops without
|
|
133
|
+
* marking its last step done, so the session rests in the ledger forever with a
|
|
134
|
+
* step still `in_progress`. Read against a running clock that step accrues
|
|
135
|
+
* elapsed time indefinitely -- production session fd259942 reached 37 DAYS --
|
|
136
|
+
* and the surface goes on offering "time remaining" for work that stopped weeks
|
|
137
|
+
* ago. Both are the same error: treating a historical record as work in flight.
|
|
138
|
+
*
|
|
139
|
+
* So the clock stops with the turn, and a plan nobody is working reports no
|
|
140
|
+
* estimate at all rather than a false one.
|
|
141
|
+
*/
|
|
142
|
+
turnActive?: boolean;
|
|
127
143
|
now?: Date;
|
|
128
144
|
};
|
|
129
145
|
/**
|
|
@@ -135,3 +151,19 @@ export type ProjectCopilotPlanInput = {
|
|
|
135
151
|
* would be worse than one that stays away.
|
|
136
152
|
*/
|
|
137
153
|
export declare function projectCopilotPlan(input: ProjectCopilotPlanInput): CopilotPlanProjection | null;
|
|
154
|
+
/**
|
|
155
|
+
* How long, in words. One owner, because there were three and all three were wrong.
|
|
156
|
+
*
|
|
157
|
+
* The rail, `oxygen copilot plan` and the MCP widget each carried their own copy
|
|
158
|
+
* of this, and every copy did `Math.floor(s / 60)` minutes with `Math.round(s % 60)`
|
|
159
|
+
* seconds -- which rounds the remainder INDEPENDENTLY of the minutes it was taken
|
|
160
|
+
* from. A 179.6s sub-agent therefore rendered as "2m 60s" on all three surfaces
|
|
161
|
+
* (observed live in session 34826105), and 59.6s rendered as "60s" rather than
|
|
162
|
+
* "1m". Rounding the total FIRST and splitting afterwards cannot produce either.
|
|
163
|
+
*
|
|
164
|
+
* The projection promised one implementation behind three surfaces; the numbers
|
|
165
|
+
* were shared and the words describing them were not, so this is where they meet.
|
|
166
|
+
*/
|
|
167
|
+
export declare function formatCopilotPlanSeconds(seconds: number): string;
|
|
168
|
+
/** A duration in ms, or null when there is nothing worth showing. */
|
|
169
|
+
export declare function formatCopilotPlanDuration(ms: number | null | undefined): string | null;
|
|
@@ -169,6 +169,13 @@ export function projectCopilotPlan(input) {
|
|
|
169
169
|
const planEvents = events.filter((event) => event.kind === "plan_updated");
|
|
170
170
|
if (planEvents.length === 0)
|
|
171
171
|
return null;
|
|
172
|
+
// The plan's clock stops when the turn does -- see `turnActive` on the input.
|
|
173
|
+
// `Math.min` because a clock that runs BACKWARDS is worse than one that stops:
|
|
174
|
+
// the last event is normally in the past, but a clock skew must not make an
|
|
175
|
+
// elapsed time negative.
|
|
176
|
+
const turnActive = input.turnActive !== false;
|
|
177
|
+
const lastEvent = events[events.length - 1];
|
|
178
|
+
const clockMs = turnActive || !lastEvent ? nowMs : Math.min(nowMs, toMs(lastEvent.created_at));
|
|
172
179
|
// -- Pass 1: fold the successive plans into one tracked set -----------------
|
|
173
180
|
//
|
|
174
181
|
// Identity is what makes timing possible. Match on `id` first, then on the
|
|
@@ -230,7 +237,7 @@ export function projectCopilotPlan(input) {
|
|
|
230
237
|
if (tracked.length === 0)
|
|
231
238
|
return null;
|
|
232
239
|
// -- Pass 2: attribute observed tool calls to the step that was running -----
|
|
233
|
-
attributeToolCalls(events, planEvents, tracked,
|
|
240
|
+
attributeToolCalls(events, planEvents, tracked, clockMs);
|
|
234
241
|
// -- Pass 3: the ETA --------------------------------------------------------
|
|
235
242
|
const samples = tracked
|
|
236
243
|
.filter((t) => t.status === "done" && t.startedAtMs !== null && t.endedAtMs !== null)
|
|
@@ -250,10 +257,11 @@ export function projectCopilotPlan(input) {
|
|
|
250
257
|
// Once a step has started it has an elapsed time: its own end if it finished,
|
|
251
258
|
// otherwise the clock. A step that never started has none -- which is exactly
|
|
252
259
|
// why it is also never a measurement sample.
|
|
253
|
-
const elapsedMs = entry.startedAtMs === null ? null : (entry.endedAtMs ??
|
|
260
|
+
const elapsedMs = entry.startedAtMs === null ? null : (entry.endedAtMs ?? clockMs) - entry.startedAtMs;
|
|
254
261
|
let etaSeconds = null;
|
|
255
262
|
let etaBasis = "none";
|
|
256
|
-
|
|
263
|
+
// A step only has time REMAINING while something is running to consume it.
|
|
264
|
+
if (turnActive && (entry.status === "pending" || entry.status === "in_progress")) {
|
|
257
265
|
let budget = null;
|
|
258
266
|
if (entry.estimateSeconds !== null && calibration !== null) {
|
|
259
267
|
budget = entry.estimateSeconds * calibration;
|
|
@@ -307,6 +315,7 @@ export function projectCopilotPlan(input) {
|
|
|
307
315
|
}
|
|
308
316
|
const done = steps.filter((step) => step.status === "done").length;
|
|
309
317
|
return {
|
|
318
|
+
turnActive,
|
|
310
319
|
summary: summary.length > 0 ? summary : null,
|
|
311
320
|
steps,
|
|
312
321
|
progress: { done, total: steps.length, ratio: steps.length === 0 ? 0 : done / steps.length },
|
|
@@ -327,7 +336,7 @@ export function projectCopilotPlan(input) {
|
|
|
327
336
|
* Attribution uses the plan as of the call's own seq, not the final plan -- a call
|
|
328
337
|
* made during step 2 belongs to step 2 even after step 4 becomes current.
|
|
329
338
|
*/
|
|
330
|
-
function attributeToolCalls(events, planEvents, tracked,
|
|
339
|
+
function attributeToolCalls(events, planEvents, tracked, clockMs) {
|
|
331
340
|
const byId = new Map(tracked.map((t) => [t.id, t]));
|
|
332
341
|
const byTitle = new Map(tracked.map((t) => [normalizeTitleKey(t.title), t]));
|
|
333
342
|
/** Which tracked step was in progress at a given ledger position. */
|
|
@@ -409,7 +418,7 @@ function attributeToolCalls(events, planEvents, tracked, nowMs) {
|
|
|
409
418
|
title: entry.capability,
|
|
410
419
|
status: "in_progress",
|
|
411
420
|
source: "tool",
|
|
412
|
-
durationMs:
|
|
421
|
+
durationMs: clockMs - entry.startedMs,
|
|
413
422
|
startedAt: new Date(entry.startedMs).toISOString(),
|
|
414
423
|
});
|
|
415
424
|
}
|
|
@@ -433,3 +442,35 @@ function mergeSubsteps(entry) {
|
|
|
433
442
|
}));
|
|
434
443
|
return [...declared, ...entry.toolSubsteps];
|
|
435
444
|
}
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Formatting
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
/**
|
|
449
|
+
* How long, in words. One owner, because there were three and all three were wrong.
|
|
450
|
+
*
|
|
451
|
+
* The rail, `oxygen copilot plan` and the MCP widget each carried their own copy
|
|
452
|
+
* of this, and every copy did `Math.floor(s / 60)` minutes with `Math.round(s % 60)`
|
|
453
|
+
* seconds -- which rounds the remainder INDEPENDENTLY of the minutes it was taken
|
|
454
|
+
* from. A 179.6s sub-agent therefore rendered as "2m 60s" on all three surfaces
|
|
455
|
+
* (observed live in session 34826105), and 59.6s rendered as "60s" rather than
|
|
456
|
+
* "1m". Rounding the total FIRST and splitting afterwards cannot produce either.
|
|
457
|
+
*
|
|
458
|
+
* The projection promised one implementation behind three surfaces; the numbers
|
|
459
|
+
* were shared and the words describing them were not, so this is where they meet.
|
|
460
|
+
*/
|
|
461
|
+
export function formatCopilotPlanSeconds(seconds) {
|
|
462
|
+
if (!Number.isFinite(seconds))
|
|
463
|
+
return "0s";
|
|
464
|
+
const total = Math.max(Math.round(seconds), 0);
|
|
465
|
+
if (total < 60)
|
|
466
|
+
return `${total}s`;
|
|
467
|
+
const minutes = Math.floor(total / 60);
|
|
468
|
+
const rest = total % 60;
|
|
469
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
|
|
470
|
+
}
|
|
471
|
+
/** A duration in ms, or null when there is nothing worth showing. */
|
|
472
|
+
export function formatCopilotPlanDuration(ms) {
|
|
473
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0)
|
|
474
|
+
return null;
|
|
475
|
+
return ms < 1000 ? `${Math.round(ms)}ms` : formatCopilotPlanSeconds(ms / 1000);
|
|
476
|
+
}
|
|
@@ -54,11 +54,42 @@ export type LlmEventBody = {
|
|
|
54
54
|
sessionId?: string | null;
|
|
55
55
|
userId?: string | null;
|
|
56
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* An eval/annotation attached to a trace (ADR 0014 files these as exactly what
|
|
59
|
+
* the LLM store exists to enable). Narrower than the API's CreateScoreRequest on
|
|
60
|
+
* purpose: `traceId` is REQUIRED (a score orphaned from its trace is unreadable
|
|
61
|
+
* in the UI), `value` is numeric-only, and `dataType` drops "CATEGORICAL"
|
|
62
|
+
* because that variant requires a *string* value. Widening later stays additive.
|
|
63
|
+
*/
|
|
64
|
+
export type LlmScoreBody = {
|
|
65
|
+
/** Deterministic id upserts; omit for a new score each call. */
|
|
66
|
+
id?: string;
|
|
67
|
+
traceId: string;
|
|
68
|
+
name: string;
|
|
69
|
+
value: number;
|
|
70
|
+
dataType?: "BOOLEAN" | "NUMERIC";
|
|
71
|
+
comment?: string;
|
|
72
|
+
};
|
|
57
73
|
export type LlmTracingClient = {
|
|
58
74
|
trace(body: LlmTraceBody): void;
|
|
59
75
|
span(body: LlmSpanBody): void;
|
|
60
76
|
generation(body: LlmGenerationBody): void;
|
|
61
77
|
event(body: LlmEventBody): void;
|
|
78
|
+
/**
|
|
79
|
+
* Attach a score to an existing trace.
|
|
80
|
+
*
|
|
81
|
+
* ASYNC, unlike the four above, because it is not the same transport. Spans go
|
|
82
|
+
* through the OTel processor, which batches and is drained by `flush()`; a
|
|
83
|
+
* score has no span, so it is one HTTP call to the scores API. Awaiting it is
|
|
84
|
+
* what keeps it alive on a serverless function that freezes the moment the
|
|
85
|
+
* handler returns — there is nothing for `flush()` to drain on its behalf.
|
|
86
|
+
*
|
|
87
|
+
* Never rejects: resolves `true` when the score was accepted, `false` when it
|
|
88
|
+
* was not (transport down, tracing misconfigured, API refusal). Fail-open like
|
|
89
|
+
* every other path here — telemetry must not break the product write it
|
|
90
|
+
* describes.
|
|
91
|
+
*/
|
|
92
|
+
score(body: LlmScoreBody): Promise<boolean>;
|
|
62
93
|
/** Never rejects; bounded at ~5s. */
|
|
63
94
|
flush(): Promise<void>;
|
|
64
95
|
/** Flush + stop background timers. Never rejects; bounded at ~5s. */
|
|
@@ -108,6 +139,10 @@ export type LlmEmitter = {
|
|
|
108
139
|
flush(): Promise<void>;
|
|
109
140
|
shutdown(): Promise<void>;
|
|
110
141
|
};
|
|
142
|
+
export type LlmScorer = {
|
|
143
|
+
/** Resolves true when the score was accepted. Never rejects. */
|
|
144
|
+
score(body: LlmScoreBody): Promise<boolean>;
|
|
145
|
+
};
|
|
111
146
|
/**
|
|
112
147
|
* Construct a fail-open Langfuse client, or `null` when tracing is disabled or
|
|
113
148
|
* misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
|
|
@@ -115,6 +150,7 @@ export type LlmEmitter = {
|
|
|
115
150
|
*/
|
|
116
151
|
export declare function createLlmTracingClient(env?: EnvMap, options?: {
|
|
117
152
|
emitterImpl?: LlmEmitter;
|
|
153
|
+
scorerImpl?: LlmScorer;
|
|
118
154
|
}): LlmTracingClient | null;
|
|
119
155
|
export declare function getLlmTracingClient(env?: EnvMap): LlmTracingClient | null;
|
|
120
156
|
/** Flush the singleton if it exists. Never rejects. Hang off request/cycle ends. */
|
|
@@ -126,6 +126,17 @@ function boundJsonField(value) {
|
|
|
126
126
|
preview: serialized.slice(0, MAX_JSON_FIELD_CHARS),
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
|
+
// A score's `comment` is free text (a thumbs-down reason is user-authored and
|
|
130
|
+
// unbounded) and the API types it as a STRING, so it cannot take
|
|
131
|
+
// boundJsonField's `{truncated, preview}` envelope. It gets the same
|
|
132
|
+
// MAX_JSON_FIELD_CHARS ceiling and the same "explicit marker, never a silent
|
|
133
|
+
// clip" rule, with the marker counted INSIDE the cap so the bound holds.
|
|
134
|
+
const COMMENT_TRUNCATION_MARKER = "…[truncated]";
|
|
135
|
+
function boundComment(comment) {
|
|
136
|
+
if (comment.length <= MAX_JSON_FIELD_CHARS)
|
|
137
|
+
return comment;
|
|
138
|
+
return comment.slice(0, MAX_JSON_FIELD_CHARS - COMMENT_TRUNCATION_MARKER.length) + COMMENT_TRUNCATION_MARKER;
|
|
139
|
+
}
|
|
129
140
|
function compact(body) {
|
|
130
141
|
const out = {};
|
|
131
142
|
for (const [key, value] of Object.entries(body)) {
|
|
@@ -223,6 +234,65 @@ function createOtelEmitter(env, warn) {
|
|
|
223
234
|
shutdown: () => init().then((h) => h?.processor.shutdown() ?? Promise.resolve()),
|
|
224
235
|
};
|
|
225
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* The real scorer: one authenticated POST to the Langfuse scores API.
|
|
239
|
+
*
|
|
240
|
+
* `@langfuse/core` is imported LAZILY on first score for the same reason the
|
|
241
|
+
* OTel tree is — a runtime with tracing disabled (the packed CLI, every test)
|
|
242
|
+
* never pays for it.
|
|
243
|
+
*
|
|
244
|
+
* The API client's `environment` option is its BASE URL, not the Langfuse
|
|
245
|
+
* environment tag; the tag is the `environment` FIELD on the score body, and it
|
|
246
|
+
* is resolved from the same helper the span processor uses so a score lands in
|
|
247
|
+
* the same Langfuse environment as the trace it scores. OXYGEN always sets
|
|
248
|
+
* LANGFUSE_BASE_URL (the project is US-region and the EU host 401s these keys);
|
|
249
|
+
* the fallback is the SDK's documented default, which `@langfuse/core` itself
|
|
250
|
+
* does not supply.
|
|
251
|
+
*/
|
|
252
|
+
function createApiScorer(env, warn) {
|
|
253
|
+
let handle = null;
|
|
254
|
+
const init = () => {
|
|
255
|
+
handle ??= (async () => {
|
|
256
|
+
try {
|
|
257
|
+
const { LangfuseAPIClient } = await import("@langfuse/core");
|
|
258
|
+
const client = new LangfuseAPIClient({
|
|
259
|
+
environment: env.LANGFUSE_BASE_URL?.trim() || "https://cloud.langfuse.com",
|
|
260
|
+
username: env.LANGFUSE_PUBLIC_KEY,
|
|
261
|
+
password: env.LANGFUSE_SECRET_KEY,
|
|
262
|
+
});
|
|
263
|
+
return client.scores;
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
warn(error, { stage: "score_init" });
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
})();
|
|
270
|
+
return handle;
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
score: async (body) => {
|
|
274
|
+
try {
|
|
275
|
+
const api = await init();
|
|
276
|
+
if (!api)
|
|
277
|
+
return false;
|
|
278
|
+
await api.create(compact({
|
|
279
|
+
id: body.id,
|
|
280
|
+
traceId: body.traceId,
|
|
281
|
+
name: body.name,
|
|
282
|
+
value: body.value,
|
|
283
|
+
dataType: body.dataType,
|
|
284
|
+
comment: typeof body.comment === "string" ? boundComment(body.comment) : undefined,
|
|
285
|
+
environment: resolveLlmTracingEnvironment(env),
|
|
286
|
+
}));
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
warn(error, { stage: "score" });
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
226
296
|
/**
|
|
227
297
|
* Construct a fail-open Langfuse client, or `null` when tracing is disabled or
|
|
228
298
|
* misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
|
|
@@ -244,6 +314,7 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
244
314
|
});
|
|
245
315
|
};
|
|
246
316
|
const emitter = options?.emitterImpl ?? createOtelEmitter(env, warn);
|
|
317
|
+
const scorer = options?.scorerImpl ?? createApiScorer(env, warn);
|
|
247
318
|
const guarded = (fn, stage) => {
|
|
248
319
|
try {
|
|
249
320
|
fn();
|
|
@@ -351,6 +422,18 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
351
422
|
endTime: startTime,
|
|
352
423
|
});
|
|
353
424
|
}, "event"),
|
|
425
|
+
// Already fail-open inside the scorer; the extra catch is here so that a
|
|
426
|
+
// scorer which throws SYNCHRONOUSLY (a substituted one in a test, a future
|
|
427
|
+
// implementation) still cannot escape into a product write.
|
|
428
|
+
score: async (body) => {
|
|
429
|
+
try {
|
|
430
|
+
return await scorer.score(body);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
warn(error, { stage: "score" });
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
},
|
|
354
437
|
// The "never rejects, bounded at ~5s" contract is the CLIENT's, so it is
|
|
355
438
|
// enforced here rather than inside one emitter — an emitter that throws
|
|
356
439
|
// synchronously or rejects must still not escape into product code.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* How a LinkedIn quota denial is read by the background jobs that hit it.
|
|
2
|
+
* How a LinkedIn/WhatsApp quota denial is read by the background jobs that hit it.
|
|
3
3
|
*
|
|
4
4
|
* The denial itself is raised by the chokepoint in
|
|
5
5
|
* packages/integrations/src/linkedin-quota.ts; this module is the consumer half,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* How a LinkedIn quota denial is read by the background jobs that hit it.
|
|
2
|
+
* How a LinkedIn/WhatsApp quota denial is read by the background jobs that hit it.
|
|
3
3
|
*
|
|
4
4
|
* The denial itself is raised by the chokepoint in
|
|
5
5
|
* packages/integrations/src/linkedin-quota.ts; this module is the consumer half,
|
|
@@ -16,10 +16,21 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { OxygenError } from "./cli-result.js";
|
|
18
18
|
import { isRecord } from "./type-guards.js";
|
|
19
|
-
// The
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
19
|
+
// The codes a denial is raised with. Each is a "come back later" signal, not a
|
|
20
|
+
// broken caller: a daily cap / closed active window that reopens on its own clock,
|
|
21
|
+
// or an account the status webhook will reactivate.
|
|
22
|
+
//
|
|
23
|
+
// The WhatsApp mirrors are here because the inbox backstop is shared across
|
|
24
|
+
// networks: a WhatsApp daily-limit denial was reaching it, missing this set, and
|
|
25
|
+
// so was logged as a failure AND never parked -- the same hot re-deny loop the
|
|
26
|
+
// LinkedIn codes were added to stop. Both WhatsApp denials carry `resets_at`, so
|
|
27
|
+
// the park lands on the real reset rather than the fallback below.
|
|
28
|
+
const QUOTA_DENIED_CODES = new Set([
|
|
29
|
+
"linkedin_rate_limited",
|
|
30
|
+
"linkedin_account_unavailable",
|
|
31
|
+
"whatsapp_rate_limited",
|
|
32
|
+
"whatsapp_account_unavailable",
|
|
33
|
+
]);
|
|
23
34
|
/** Park length when a denial carries no usable `resets_at` hint. */
|
|
24
35
|
const QUOTA_FALLBACK_BACKOFF_MS = 60 * 60 * 1000;
|
|
25
36
|
/** Was this thrown error the quota chokepoint refusing the call? */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getCapabilityRouteMatch, inferCapabilityRoute, } from "./capability-discovery.js";
|
|
1
|
+
import { getCapabilityRoute, getCapabilityRouteMatch, inferCapabilityRoute, } from "./capability-discovery.js";
|
|
2
2
|
// Provider brands are intentionally not duplicated into the static capability
|
|
3
3
|
// catalog. A short "connect <provider>" query would otherwise match Tables via
|
|
4
4
|
// its relationship-oriented "connect" term. Correct that narrow ambiguity at
|
|
@@ -13,9 +13,30 @@ export function inferUserCapabilityRoute(query) {
|
|
|
13
13
|
}
|
|
14
14
|
return getCapabilityRouteMatch("connected-integrations") ?? route;
|
|
15
15
|
}
|
|
16
|
+
// The words that mean "this is Tables work, not a provider connection". Hand-typing
|
|
17
|
+
// them drifted: the list covered table/row/column/dataset/join/relate but not csv,
|
|
18
|
+
// import, enrich, waterfall, formula, lookup or score -- all of which the Tables card
|
|
19
|
+
// itself claims. The measured cost was 11 queries ("connect my csv", "connect my
|
|
20
|
+
// enrichment", "connect my formula") losing their Tables tools and being handed
|
|
21
|
+
// oxygen_integrations_connect instead, on ALL THREE surfaces that call this wrapper.
|
|
22
|
+
//
|
|
23
|
+
// So derive from the card rather than restating it. `connect` is excluded because it
|
|
24
|
+
// is the ambiguity itself -- it is a Tables intent term AND the verb this function
|
|
25
|
+
// keys on, and leaving it in would make the guard reject every query the wrapper
|
|
26
|
+
// exists to correct.
|
|
27
|
+
const TABLES_INTENT_AMBIGUITY_TRIGGERS = new Set(["connect"]);
|
|
28
|
+
function tablesIntentGuard() {
|
|
29
|
+
const terms = (getCapabilityRoute("tables")?.intentTerms ?? [])
|
|
30
|
+
.filter((term) => !TABLES_INTENT_AMBIGUITY_TRIGGERS.has(term))
|
|
31
|
+
.flatMap((term) => (term.includes(" ") ? [term] : [term, `${term}s`]))
|
|
32
|
+
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
33
|
+
.sort((a, b) => b.length - a.length);
|
|
34
|
+
return new RegExp(`\\b(?:${terms.join("|")})\\b`);
|
|
35
|
+
}
|
|
36
|
+
const TABLES_INTENT_GUARD = tablesIntentGuard();
|
|
16
37
|
function isSimpleProviderConnectionIntent(query) {
|
|
17
38
|
const normalized = query.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
18
|
-
if (
|
|
39
|
+
if (TABLES_INTENT_GUARD.test(normalized)) {
|
|
19
40
|
return false;
|
|
20
41
|
}
|
|
21
42
|
if (/\b(integration|provider|oauth|byok|api key|connected account)\b/.test(normalized)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const OXYGEN_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_VERSION = "1.917.5";
|
|
2
2
|
export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
|
|
3
3
|
export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
|
|
4
4
|
export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const OXYGEN_VERSION = "1.
|
|
1
|
+
export const OXYGEN_VERSION = "1.917.5";
|
|
2
2
|
// The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
|
|
3
3
|
// operational route. Raising it hard-rejects every older CLI from the entire
|
|
4
4
|
// product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
|
|
@@ -141,6 +141,11 @@
|
|
|
141
141
|
"import": "./dist/pricing-sheet.js",
|
|
142
142
|
"default": "./dist/pricing-sheet.js"
|
|
143
143
|
},
|
|
144
|
+
"./copilot-plan": {
|
|
145
|
+
"types": "./dist/copilot-plan.d.ts",
|
|
146
|
+
"import": "./dist/copilot-plan.js",
|
|
147
|
+
"default": "./dist/copilot-plan.js"
|
|
148
|
+
},
|
|
144
149
|
"./copilot-journeys": {
|
|
145
150
|
"types": "./dist/copilot-journeys.d.ts",
|
|
146
151
|
"import": "./dist/copilot-journeys.js",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxygen-agent/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.917.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@aws-sdk/client-s3": "3.1050.0",
|
|
36
36
|
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
|
37
|
+
"@langfuse/core": "^5.11.0",
|
|
37
38
|
"@langfuse/otel": "^5.11.0",
|
|
38
39
|
"@langfuse/tracing": "^5.11.0",
|
|
39
40
|
"@opentelemetry/api": "1.9.1",
|