@oxygen-agent/cli 1.893.0 → 1.906.0
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 +8 -3
- package/dist/index.js +199 -36
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +19 -1
- package/node_modules/@oxygen/shared/dist/copilot-plan.d.ts +137 -0
- package/node_modules/@oxygen/shared/dist/copilot-plan.js +435 -0
- package/node_modules/@oxygen/shared/dist/dnc-identities.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/dnc-identities.js +23 -0
- package/node_modules/@oxygen/shared/dist/egress-transport-readiness.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/egress-transport-readiness.js +67 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/index.js +3 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +57 -5
- package/node_modules/@oxygen/shared/dist/langfuse.js +243 -42
- package/node_modules/@oxygen/shared/dist/product-briefing-rules.d.ts +58 -0
- package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +291 -0
- package/node_modules/@oxygen/shared/dist/product-doctrine.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/product-doctrine.js +70 -0
- package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +31 -8
- package/node_modules/@oxygen/shared/dist/sending-seats.js +19 -15
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +18 -11
- package/node_modules/@oxygen/shared/dist/sequences.js +47 -13
- 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 +4 -2
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four states a plan step may be in.
|
|
3
|
+
*
|
|
4
|
+
* This is the vocabulary `plan_update`'s JSON Schema enforces going forward, and
|
|
5
|
+
* @oxygen/agent-runtime imports it so the schema and this reader can never drift.
|
|
6
|
+
*/
|
|
7
|
+
export declare const COPILOT_PLAN_STEP_STATUSES: readonly ["pending", "in_progress", "done", "blocked"];
|
|
8
|
+
export type CopilotPlanStepStatus = (typeof COPILOT_PLAN_STEP_STATUSES)[number];
|
|
9
|
+
/**
|
|
10
|
+
* An unrecognised status resolves to `pending`, never to `done`.
|
|
11
|
+
*
|
|
12
|
+
* This is the fail-closed direction and it is deliberate. Resolving the unknown
|
|
13
|
+
* to `done` would let a typo report a run as finished and silence the open-steps
|
|
14
|
+
* nudge; resolving it to `pending` at worst leaves a finished step looking open,
|
|
15
|
+
* which is visible and self-correcting on the next plan update.
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizeCopilotPlanStepStatus(raw: unknown): CopilotPlanStepStatus;
|
|
18
|
+
/** A step is open until it is done. `blocked` is open too -- it still owes an outcome. */
|
|
19
|
+
export declare function isCopilotPlanStepOpen(step: {
|
|
20
|
+
status: CopilotPlanStepStatus;
|
|
21
|
+
}): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The step shape after normalisation, before any timing is attached.
|
|
24
|
+
*
|
|
25
|
+
* `title` accepts three spellings because three have been produced: the tool
|
|
26
|
+
* schema and `packages/copilot`'s dormant `PlanStep` type both say `title`, while
|
|
27
|
+
* the model actually shipped `description` on every step of session 4097ef0d.
|
|
28
|
+
* `parsePlanSteps` in the web app required `title` and `continue`d past anything
|
|
29
|
+
* else, which is why it returned null for every real payload ever written.
|
|
30
|
+
*/
|
|
31
|
+
export type CopilotPlanRawStep = {
|
|
32
|
+
id: string | null;
|
|
33
|
+
title: string;
|
|
34
|
+
status: CopilotPlanStepStatus;
|
|
35
|
+
estimateSeconds: number | null;
|
|
36
|
+
substeps: Array<{
|
|
37
|
+
id: string | null;
|
|
38
|
+
title: string;
|
|
39
|
+
status: CopilotPlanStepStatus;
|
|
40
|
+
}>;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Pull the step list out of a `plan_updated` payload.
|
|
44
|
+
*
|
|
45
|
+
* Accepts both `{steps}` at the root (what the runtime emits) and the nested
|
|
46
|
+
* `{plan:{steps}}` shape, so a payload written by either producer renders.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readCopilotPlanPayload(payload: Record<string, unknown> | null | undefined): {
|
|
49
|
+
summary: string;
|
|
50
|
+
steps: CopilotPlanRawStep[];
|
|
51
|
+
} | null;
|
|
52
|
+
/** Where a step's ETA came from. Rendered beside the number, never hidden. */
|
|
53
|
+
export type CopilotPlanEtaBasis =
|
|
54
|
+
/** A model estimate scaled by how wrong its estimates have been so far. */
|
|
55
|
+
"calibrated"
|
|
56
|
+
/** No usable estimate; the mean of this turn's own completed steps. */
|
|
57
|
+
| "measured"
|
|
58
|
+
/** The model's estimate, with nothing completed yet to calibrate it. */
|
|
59
|
+
| "model"
|
|
60
|
+
/** Nothing to go on. The surface must say so rather than invent a number. */
|
|
61
|
+
| "none";
|
|
62
|
+
export type CopilotPlanSubstep = {
|
|
63
|
+
id: string;
|
|
64
|
+
title: string;
|
|
65
|
+
status: CopilotPlanStepStatus;
|
|
66
|
+
/** `model` when the model declared it, `tool` when it is an observed call. */
|
|
67
|
+
source: "model" | "tool";
|
|
68
|
+
durationMs: number | null;
|
|
69
|
+
startedAt: string | null;
|
|
70
|
+
/**
|
|
71
|
+
* Set only for a `subagent_run`. A sub-agent's own capability calls never reach
|
|
72
|
+
* the ledger (it is handed capability tools directly, bypassing the runtime tool
|
|
73
|
+
* path), so the rollup counts are all there is -- and the rail must not imply a
|
|
74
|
+
* deeper trace exists.
|
|
75
|
+
*/
|
|
76
|
+
subagent?: {
|
|
77
|
+
name: string;
|
|
78
|
+
inferences: number;
|
|
79
|
+
capabilityCalls: number;
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
export type CopilotPlanStep = {
|
|
83
|
+
id: string;
|
|
84
|
+
title: string;
|
|
85
|
+
status: CopilotPlanStepStatus;
|
|
86
|
+
estimateSeconds: number | null;
|
|
87
|
+
startedAt: string | null;
|
|
88
|
+
endedAt: string | null;
|
|
89
|
+
/** Wall time the step has taken, or took. Null until it starts. */
|
|
90
|
+
elapsedMs: number | null;
|
|
91
|
+
/** Seconds still expected. Null for a done step, or when nothing can be said. */
|
|
92
|
+
etaSeconds: number | null;
|
|
93
|
+
etaBasis: CopilotPlanEtaBasis;
|
|
94
|
+
substeps: CopilotPlanSubstep[];
|
|
95
|
+
};
|
|
96
|
+
export type CopilotPlanProjection = {
|
|
97
|
+
summary: string | null;
|
|
98
|
+
steps: CopilotPlanStep[];
|
|
99
|
+
progress: {
|
|
100
|
+
done: number;
|
|
101
|
+
total: number;
|
|
102
|
+
ratio: number;
|
|
103
|
+
};
|
|
104
|
+
eta: {
|
|
105
|
+
remainingSeconds: number | null;
|
|
106
|
+
basis: CopilotPlanEtaBasis;
|
|
107
|
+
/** How many completed, timed steps the measurement rests on. */
|
|
108
|
+
measuredSteps: number;
|
|
109
|
+
/** True when the turn's wall-clock deadline, not the plan, is the binding bound. */
|
|
110
|
+
deadlineBound: boolean;
|
|
111
|
+
};
|
|
112
|
+
/** Ledger position of the newest plan_updated, so a caller can tell staleness. */
|
|
113
|
+
updatedAtSeq: number;
|
|
114
|
+
updatedAt: string;
|
|
115
|
+
};
|
|
116
|
+
export type CopilotPlanSourceEvent = {
|
|
117
|
+
seq: number;
|
|
118
|
+
kind: string;
|
|
119
|
+
payload: Record<string, unknown> | null;
|
|
120
|
+
created_at: string | Date;
|
|
121
|
+
};
|
|
122
|
+
export type ProjectCopilotPlanInput = {
|
|
123
|
+
/** Ordered by seq. Only plan_updated / tool_call_* / context_assembled are read. */
|
|
124
|
+
events: CopilotPlanSourceEvent[];
|
|
125
|
+
/** The active turn's wall-clock deadline, used only to clamp the total. */
|
|
126
|
+
turnDeadlineAt?: string | Date | null;
|
|
127
|
+
now?: Date;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Rebuild the plan, with timings, from the session's event ledger.
|
|
131
|
+
*
|
|
132
|
+
* Returns null when the session has never published a plan -- which is the correct
|
|
133
|
+
* answer for a trivial turn, since the prompt tells the model to skip `plan_update`
|
|
134
|
+
* entirely and call `finish`. A rail that renders an empty shell for every greeting
|
|
135
|
+
* would be worse than one that stays away.
|
|
136
|
+
*/
|
|
137
|
+
export declare function projectCopilotPlan(input: ProjectCopilotPlanInput): CopilotPlanProjection | null;
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// The Copilot's plan, as a surface can render it: major steps, their substeps,
|
|
2
|
+
// and a timed projection of what is left.
|
|
3
|
+
//
|
|
4
|
+
// The plan itself is not new -- the shared runtime has published a `plan_update`
|
|
5
|
+
// tool and written a `plan_updated` event since the Strands cut-over. What was
|
|
6
|
+
// missing is anything that READS it. This module is that reader, and it lives in
|
|
7
|
+
// @oxygen/shared for one reason: web, CLI and MCP must show the same numbers, and
|
|
8
|
+
// the only way to guarantee that is for there to be one implementation of them.
|
|
9
|
+
// The web surface renders what the API computed here; it does not re-derive.
|
|
10
|
+
//
|
|
11
|
+
// TIME IS DERIVED, NOT STORED. `copilot_events` carries a single `created_at` per
|
|
12
|
+
// row -- there is no per-step duration column anywhere, and there is no ETA field
|
|
13
|
+
// in the tool schema, the payload, or the DB. Everything below is reconstructed
|
|
14
|
+
// from the ordered ledger, which is also why every number reports the BASIS it
|
|
15
|
+
// rests on: a projection that cannot say where its estimate came from is not
|
|
16
|
+
// inspectable, and an uninspectable number on a progress rail is worse than no
|
|
17
|
+
// number at all.
|
|
18
|
+
/**
|
|
19
|
+
* The four states a plan step may be in.
|
|
20
|
+
*
|
|
21
|
+
* This is the vocabulary `plan_update`'s JSON Schema enforces going forward, and
|
|
22
|
+
* @oxygen/agent-runtime imports it so the schema and this reader can never drift.
|
|
23
|
+
*/
|
|
24
|
+
export const COPILOT_PLAN_STEP_STATUSES = ["pending", "in_progress", "done", "blocked"];
|
|
25
|
+
/**
|
|
26
|
+
* Forgiving on read, strict on write.
|
|
27
|
+
*
|
|
28
|
+
* The tool schema now pins the enum, but the ledger is append-only and already
|
|
29
|
+
* holds events written when `steps` was `{type:"object", additionalProperties:true}`
|
|
30
|
+
* with no validation at all. Session 4097ef0d wrote `status: "completed"` -- which
|
|
31
|
+
* `hasOpenPlanSteps` (testing `status !== "done"`) read as an OPEN step, misfiring
|
|
32
|
+
* the stop-nudge on a plan that was finished. Normalising on read is what lets one
|
|
33
|
+
* predicate serve both the historical rows and the schema-constrained ones.
|
|
34
|
+
*/
|
|
35
|
+
const STATUS_ALIASES = {
|
|
36
|
+
pending: "pending",
|
|
37
|
+
todo: "pending",
|
|
38
|
+
not_started: "pending",
|
|
39
|
+
"not-started": "pending",
|
|
40
|
+
planned: "pending",
|
|
41
|
+
queued: "pending",
|
|
42
|
+
in_progress: "in_progress",
|
|
43
|
+
"in-progress": "in_progress",
|
|
44
|
+
inprogress: "in_progress",
|
|
45
|
+
running: "in_progress",
|
|
46
|
+
active: "in_progress",
|
|
47
|
+
started: "in_progress",
|
|
48
|
+
working: "in_progress",
|
|
49
|
+
done: "done",
|
|
50
|
+
complete: "done",
|
|
51
|
+
completed: "done",
|
|
52
|
+
finished: "done",
|
|
53
|
+
succeeded: "done",
|
|
54
|
+
success: "done",
|
|
55
|
+
blocked: "blocked",
|
|
56
|
+
failed: "blocked",
|
|
57
|
+
error: "blocked",
|
|
58
|
+
stuck: "blocked",
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* An unrecognised status resolves to `pending`, never to `done`.
|
|
62
|
+
*
|
|
63
|
+
* This is the fail-closed direction and it is deliberate. Resolving the unknown
|
|
64
|
+
* to `done` would let a typo report a run as finished and silence the open-steps
|
|
65
|
+
* nudge; resolving it to `pending` at worst leaves a finished step looking open,
|
|
66
|
+
* which is visible and self-correcting on the next plan update.
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeCopilotPlanStepStatus(raw) {
|
|
69
|
+
if (typeof raw !== "string")
|
|
70
|
+
return "pending";
|
|
71
|
+
return STATUS_ALIASES[raw.trim().toLowerCase()] ?? "pending";
|
|
72
|
+
}
|
|
73
|
+
/** A step is open until it is done. `blocked` is open too -- it still owes an outcome. */
|
|
74
|
+
export function isCopilotPlanStepOpen(step) {
|
|
75
|
+
return step.status !== "done";
|
|
76
|
+
}
|
|
77
|
+
function readText(value) {
|
|
78
|
+
return typeof value === "string" ? value.trim() : "";
|
|
79
|
+
}
|
|
80
|
+
function readEstimateSeconds(value) {
|
|
81
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
82
|
+
return null;
|
|
83
|
+
// Same bounds the tool schema advertises. A model that ignores them does not get
|
|
84
|
+
// to skew the calibration factor with a 40-hour step.
|
|
85
|
+
return Math.min(Math.max(Math.round(value), 5), 3600);
|
|
86
|
+
}
|
|
87
|
+
function readRawStep(raw) {
|
|
88
|
+
if (!raw || typeof raw !== "object")
|
|
89
|
+
return null;
|
|
90
|
+
const obj = raw;
|
|
91
|
+
const title = readText(obj.title) || readText(obj.description) || readText(obj.name);
|
|
92
|
+
if (title.length === 0)
|
|
93
|
+
return null;
|
|
94
|
+
const substeps = [];
|
|
95
|
+
if (Array.isArray(obj.substeps)) {
|
|
96
|
+
for (const rawSub of obj.substeps) {
|
|
97
|
+
if (!rawSub || typeof rawSub !== "object")
|
|
98
|
+
continue;
|
|
99
|
+
const sub = rawSub;
|
|
100
|
+
const subTitle = readText(sub.title) || readText(sub.description) || readText(sub.name);
|
|
101
|
+
if (subTitle.length === 0)
|
|
102
|
+
continue;
|
|
103
|
+
substeps.push({
|
|
104
|
+
id: readText(sub.id) || null,
|
|
105
|
+
title: subTitle,
|
|
106
|
+
status: normalizeCopilotPlanStepStatus(sub.status),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
id: readText(obj.id) || null,
|
|
112
|
+
title,
|
|
113
|
+
status: normalizeCopilotPlanStepStatus(obj.status),
|
|
114
|
+
estimateSeconds: readEstimateSeconds(obj.estimate_seconds ?? obj.estimateSeconds),
|
|
115
|
+
substeps,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Pull the step list out of a `plan_updated` payload.
|
|
120
|
+
*
|
|
121
|
+
* Accepts both `{steps}` at the root (what the runtime emits) and the nested
|
|
122
|
+
* `{plan:{steps}}` shape, so a payload written by either producer renders.
|
|
123
|
+
*/
|
|
124
|
+
export function readCopilotPlanPayload(payload) {
|
|
125
|
+
if (!payload || typeof payload !== "object")
|
|
126
|
+
return null;
|
|
127
|
+
const nested = payload.plan;
|
|
128
|
+
const rawSteps = (nested && typeof nested === "object" ? nested.steps : undefined) ??
|
|
129
|
+
payload.steps;
|
|
130
|
+
if (!Array.isArray(rawSteps))
|
|
131
|
+
return null;
|
|
132
|
+
const steps = [];
|
|
133
|
+
for (const raw of rawSteps) {
|
|
134
|
+
const step = readRawStep(raw);
|
|
135
|
+
if (step)
|
|
136
|
+
steps.push(step);
|
|
137
|
+
}
|
|
138
|
+
const summary = readText(payload.summary) ||
|
|
139
|
+
(nested && typeof nested === "object" ? readText(nested.summary) : "");
|
|
140
|
+
if (steps.length === 0 && summary.length === 0)
|
|
141
|
+
return null;
|
|
142
|
+
return { summary, steps };
|
|
143
|
+
}
|
|
144
|
+
/** Bookkeeping calls. Real work, but not work the USER asked for -- not substeps. */
|
|
145
|
+
const BOOKKEEPING_TOOLS = new Set(["plan_update", "set_goal", "finish", "wait"]);
|
|
146
|
+
/**
|
|
147
|
+
* One wild estimate must not be allowed to scale every remaining step.
|
|
148
|
+
*
|
|
149
|
+
* The calibration factor is a ratio of sums, so a single step the model guessed at
|
|
150
|
+
* 10s that actually took 10 minutes would otherwise multiply the whole tail by 60.
|
|
151
|
+
*/
|
|
152
|
+
const MIN_CALIBRATION = 0.25;
|
|
153
|
+
const MAX_CALIBRATION = 4;
|
|
154
|
+
const toMs = (value) => value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
155
|
+
const toIso = (value) => value instanceof Date ? value.toISOString() : new Date(value).toISOString();
|
|
156
|
+
const normalizeTitleKey = (title) => title.replace(/\s+/g, " ").trim().toLowerCase();
|
|
157
|
+
/**
|
|
158
|
+
* Rebuild the plan, with timings, from the session's event ledger.
|
|
159
|
+
*
|
|
160
|
+
* Returns null when the session has never published a plan -- which is the correct
|
|
161
|
+
* answer for a trivial turn, since the prompt tells the model to skip `plan_update`
|
|
162
|
+
* entirely and call `finish`. A rail that renders an empty shell for every greeting
|
|
163
|
+
* would be worse than one that stays away.
|
|
164
|
+
*/
|
|
165
|
+
export function projectCopilotPlan(input) {
|
|
166
|
+
const now = input.now ?? new Date();
|
|
167
|
+
const nowMs = now.getTime();
|
|
168
|
+
const events = [...input.events].sort((a, b) => a.seq - b.seq);
|
|
169
|
+
const planEvents = events.filter((event) => event.kind === "plan_updated");
|
|
170
|
+
if (planEvents.length === 0)
|
|
171
|
+
return null;
|
|
172
|
+
// -- Pass 1: fold the successive plans into one tracked set -----------------
|
|
173
|
+
//
|
|
174
|
+
// Identity is what makes timing possible. Match on `id` first, then on the
|
|
175
|
+
// normalised title. A step that matches NEITHER is genuinely new, and a payload
|
|
176
|
+
// in which nothing matches is a plan REPLACEMENT rather than an update -- which
|
|
177
|
+
// is exactly what session 4097ef0d's second plan_update was, having rewritten
|
|
178
|
+
// every description. Carrying timings across a rewrite would attribute one
|
|
179
|
+
// step's clock to an unrelated step; dropping them loses nothing real.
|
|
180
|
+
let tracked = [];
|
|
181
|
+
let summary = "";
|
|
182
|
+
let updatedAtSeq = planEvents[0]?.seq ?? 0;
|
|
183
|
+
let updatedAt = planEvents[0] ? toIso(planEvents[0].created_at) : now.toISOString();
|
|
184
|
+
for (const event of planEvents) {
|
|
185
|
+
const parsed = readCopilotPlanPayload(event.payload);
|
|
186
|
+
if (!parsed)
|
|
187
|
+
continue;
|
|
188
|
+
const atMs = toMs(event.created_at);
|
|
189
|
+
updatedAtSeq = event.seq;
|
|
190
|
+
updatedAt = toIso(event.created_at);
|
|
191
|
+
if (parsed.summary)
|
|
192
|
+
summary = parsed.summary;
|
|
193
|
+
if (parsed.steps.length === 0)
|
|
194
|
+
continue;
|
|
195
|
+
const byId = new Map(tracked.filter((t) => t.id).map((t) => [t.id, t]));
|
|
196
|
+
const byTitle = new Map(tracked.map((t) => [normalizeTitleKey(t.title), t]));
|
|
197
|
+
const next = [];
|
|
198
|
+
for (const [index, raw] of parsed.steps.entries()) {
|
|
199
|
+
const previous = (raw.id ? byId.get(raw.id) : undefined) ?? byTitle.get(normalizeTitleKey(raw.title));
|
|
200
|
+
const entry = previous ?? {
|
|
201
|
+
id: raw.id ?? `step-${index + 1}`,
|
|
202
|
+
title: raw.title,
|
|
203
|
+
status: raw.status,
|
|
204
|
+
estimateSeconds: raw.estimateSeconds,
|
|
205
|
+
startedAtMs: null,
|
|
206
|
+
endedAtMs: null,
|
|
207
|
+
modelSubsteps: [],
|
|
208
|
+
toolSubsteps: [],
|
|
209
|
+
};
|
|
210
|
+
entry.title = raw.title;
|
|
211
|
+
entry.status = raw.status;
|
|
212
|
+
// An estimate is a prior: keep the first one the model committed to rather
|
|
213
|
+
// than letting it be revised down to match reality after the fact.
|
|
214
|
+
if (entry.estimateSeconds === null)
|
|
215
|
+
entry.estimateSeconds = raw.estimateSeconds;
|
|
216
|
+
if (raw.substeps.length > 0)
|
|
217
|
+
entry.modelSubsteps = raw.substeps;
|
|
218
|
+
// The clock starts when the step is first SEEN in progress and stops when it
|
|
219
|
+
// is first seen finished. A step that appears already-done (no in-progress
|
|
220
|
+
// sighting) has no duration, and must not become a measurement sample.
|
|
221
|
+
if (raw.status === "in_progress" && entry.startedAtMs === null)
|
|
222
|
+
entry.startedAtMs = atMs;
|
|
223
|
+
if ((raw.status === "done" || raw.status === "blocked") && entry.endedAtMs === null) {
|
|
224
|
+
entry.endedAtMs = entry.startedAtMs === null ? null : atMs;
|
|
225
|
+
}
|
|
226
|
+
next.push(entry);
|
|
227
|
+
}
|
|
228
|
+
tracked = next;
|
|
229
|
+
}
|
|
230
|
+
if (tracked.length === 0)
|
|
231
|
+
return null;
|
|
232
|
+
// -- Pass 2: attribute observed tool calls to the step that was running -----
|
|
233
|
+
attributeToolCalls(events, planEvents, tracked, nowMs);
|
|
234
|
+
// -- Pass 3: the ETA --------------------------------------------------------
|
|
235
|
+
const samples = tracked
|
|
236
|
+
.filter((t) => t.status === "done" && t.startedAtMs !== null && t.endedAtMs !== null)
|
|
237
|
+
.map((t) => ({ step: t, seconds: (t.endedAtMs - t.startedAtMs) / 1000 }))
|
|
238
|
+
.filter((sample) => sample.seconds > 0);
|
|
239
|
+
const meanMeasured = samples.length > 0 ? samples.reduce((sum, s) => sum + s.seconds, 0) / samples.length : null;
|
|
240
|
+
const calibrationInputs = samples.filter((s) => s.step.estimateSeconds !== null);
|
|
241
|
+
let calibration = null;
|
|
242
|
+
if (calibrationInputs.length > 0) {
|
|
243
|
+
const measured = calibrationInputs.reduce((sum, s) => sum + s.seconds, 0);
|
|
244
|
+
const estimated = calibrationInputs.reduce((sum, s) => sum + (s.step.estimateSeconds ?? 0), 0);
|
|
245
|
+
if (estimated > 0) {
|
|
246
|
+
calibration = Math.min(Math.max(measured / estimated, MIN_CALIBRATION), MAX_CALIBRATION);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const steps = tracked.map((entry) => {
|
|
250
|
+
// Once a step has started it has an elapsed time: its own end if it finished,
|
|
251
|
+
// otherwise the clock. A step that never started has none -- which is exactly
|
|
252
|
+
// why it is also never a measurement sample.
|
|
253
|
+
const elapsedMs = entry.startedAtMs === null ? null : (entry.endedAtMs ?? nowMs) - entry.startedAtMs;
|
|
254
|
+
let etaSeconds = null;
|
|
255
|
+
let etaBasis = "none";
|
|
256
|
+
if (entry.status === "pending" || entry.status === "in_progress") {
|
|
257
|
+
let budget = null;
|
|
258
|
+
if (entry.estimateSeconds !== null && calibration !== null) {
|
|
259
|
+
budget = entry.estimateSeconds * calibration;
|
|
260
|
+
etaBasis = "calibrated";
|
|
261
|
+
}
|
|
262
|
+
else if (entry.estimateSeconds !== null) {
|
|
263
|
+
budget = entry.estimateSeconds;
|
|
264
|
+
etaBasis = "model";
|
|
265
|
+
}
|
|
266
|
+
else if (meanMeasured !== null) {
|
|
267
|
+
budget = meanMeasured;
|
|
268
|
+
etaBasis = "measured";
|
|
269
|
+
}
|
|
270
|
+
if (budget !== null) {
|
|
271
|
+
const spent = entry.status === "in_progress" && elapsedMs !== null ? elapsedMs / 1000 : 0;
|
|
272
|
+
etaSeconds = Math.max(Math.round(budget - spent), 0);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
id: entry.id,
|
|
277
|
+
title: entry.title,
|
|
278
|
+
status: entry.status,
|
|
279
|
+
estimateSeconds: entry.estimateSeconds,
|
|
280
|
+
startedAt: entry.startedAtMs === null ? null : new Date(entry.startedAtMs).toISOString(),
|
|
281
|
+
endedAt: entry.endedAtMs === null ? null : new Date(entry.endedAtMs).toISOString(),
|
|
282
|
+
elapsedMs,
|
|
283
|
+
etaSeconds,
|
|
284
|
+
etaBasis,
|
|
285
|
+
substeps: mergeSubsteps(entry),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
const contributing = steps.filter((step) => (step.status === "pending" || step.status === "in_progress") && step.etaSeconds !== null);
|
|
289
|
+
let remainingSeconds = contributing.length > 0 ? contributing.reduce((sum, s) => sum + (s.etaSeconds ?? 0), 0) : null;
|
|
290
|
+
const overallBasis = remainingSeconds === null
|
|
291
|
+
? "none"
|
|
292
|
+
: calibration !== null
|
|
293
|
+
? "calibrated"
|
|
294
|
+
: meanMeasured !== null
|
|
295
|
+
? "measured"
|
|
296
|
+
: "model";
|
|
297
|
+
// The turn cannot outlive its own deadline, so neither may the estimate. When
|
|
298
|
+
// the clamp binds, say so -- "4 minutes" and "4 minutes, but the turn is cut off
|
|
299
|
+
// in 90 seconds" are different facts and the second one is the useful one.
|
|
300
|
+
let deadlineBound = false;
|
|
301
|
+
if (remainingSeconds !== null && input.turnDeadlineAt) {
|
|
302
|
+
const deadlineLeft = (toMs(input.turnDeadlineAt) - nowMs) / 1000;
|
|
303
|
+
if (Number.isFinite(deadlineLeft) && deadlineLeft >= 0 && deadlineLeft < remainingSeconds) {
|
|
304
|
+
remainingSeconds = Math.round(deadlineLeft);
|
|
305
|
+
deadlineBound = true;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const done = steps.filter((step) => step.status === "done").length;
|
|
309
|
+
return {
|
|
310
|
+
summary: summary.length > 0 ? summary : null,
|
|
311
|
+
steps,
|
|
312
|
+
progress: { done, total: steps.length, ratio: steps.length === 0 ? 0 : done / steps.length },
|
|
313
|
+
eta: {
|
|
314
|
+
remainingSeconds,
|
|
315
|
+
basis: overallBasis,
|
|
316
|
+
measuredSteps: samples.length,
|
|
317
|
+
deadlineBound,
|
|
318
|
+
},
|
|
319
|
+
updatedAtSeq,
|
|
320
|
+
updatedAt,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Pair tool_call_started/finished on `call_id` and file each pair under whichever
|
|
325
|
+
* step was in progress when the call STARTED.
|
|
326
|
+
*
|
|
327
|
+
* Attribution uses the plan as of the call's own seq, not the final plan -- a call
|
|
328
|
+
* made during step 2 belongs to step 2 even after step 4 becomes current.
|
|
329
|
+
*/
|
|
330
|
+
function attributeToolCalls(events, planEvents, tracked, nowMs) {
|
|
331
|
+
const byId = new Map(tracked.map((t) => [t.id, t]));
|
|
332
|
+
const byTitle = new Map(tracked.map((t) => [normalizeTitleKey(t.title), t]));
|
|
333
|
+
/** Which tracked step was in progress at a given ledger position. */
|
|
334
|
+
const currentStepAt = (seq) => {
|
|
335
|
+
let current = null;
|
|
336
|
+
for (const planEvent of planEvents) {
|
|
337
|
+
if (planEvent.seq > seq)
|
|
338
|
+
break;
|
|
339
|
+
const parsed = readCopilotPlanPayload(planEvent.payload);
|
|
340
|
+
if (!parsed || parsed.steps.length === 0)
|
|
341
|
+
continue;
|
|
342
|
+
const inProgress = parsed.steps.find((step) => step.status === "in_progress");
|
|
343
|
+
if (!inProgress)
|
|
344
|
+
continue;
|
|
345
|
+
current =
|
|
346
|
+
(inProgress.id ? byId.get(inProgress.id) : undefined) ??
|
|
347
|
+
byTitle.get(normalizeTitleKey(inProgress.title)) ??
|
|
348
|
+
current;
|
|
349
|
+
}
|
|
350
|
+
return current;
|
|
351
|
+
};
|
|
352
|
+
const open = new Map();
|
|
353
|
+
// Sub-agent rollups arrive on a separate context_assembled event keyed by call_id.
|
|
354
|
+
const rollups = new Map();
|
|
355
|
+
for (const event of events) {
|
|
356
|
+
const payload = event.payload ?? {};
|
|
357
|
+
if (event.kind === "context_assembled" && payload.mode === "subagent") {
|
|
358
|
+
const callId = readText(payload.call_id);
|
|
359
|
+
if (callId) {
|
|
360
|
+
rollups.set(callId, {
|
|
361
|
+
name: readText(payload.subagent) || "sub-agent",
|
|
362
|
+
inferences: typeof payload.inferences === "number" ? payload.inferences : 0,
|
|
363
|
+
capabilityCalls: typeof payload.capability_calls === "number" ? payload.capability_calls : 0,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (event.kind !== "tool_call_started" && event.kind !== "tool_call_finished")
|
|
369
|
+
continue;
|
|
370
|
+
const callId = readText(payload.call_id);
|
|
371
|
+
const tool = readText(payload.tool);
|
|
372
|
+
if (!callId || BOOKKEEPING_TOOLS.has(tool))
|
|
373
|
+
continue;
|
|
374
|
+
const capability = readText(payload.capability) || tool || "capability";
|
|
375
|
+
if (event.kind === "tool_call_started") {
|
|
376
|
+
open.set(callId, {
|
|
377
|
+
step: currentStepAt(event.seq),
|
|
378
|
+
startedMs: toMs(event.created_at),
|
|
379
|
+
capability,
|
|
380
|
+
callId,
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const started = open.get(callId);
|
|
385
|
+
open.delete(callId);
|
|
386
|
+
const target = started?.step ?? currentStepAt(event.seq);
|
|
387
|
+
if (!target)
|
|
388
|
+
continue;
|
|
389
|
+
const rollup = rollups.get(callId);
|
|
390
|
+
target.toolSubsteps.push({
|
|
391
|
+
id: callId,
|
|
392
|
+
// `summary` is the tool_call_finished remap the Copilot host writes; it is a
|
|
393
|
+
// line ("63 columns"), which is what a substep wants. Fall back to the
|
|
394
|
+
// capability name rather than to a serialized result.
|
|
395
|
+
title: readText(payload.summary) || capability,
|
|
396
|
+
status: payload.ok === false ? "blocked" : "done",
|
|
397
|
+
source: "tool",
|
|
398
|
+
durationMs: started ? toMs(event.created_at) - started.startedMs : null,
|
|
399
|
+
startedAt: started ? new Date(started.startedMs).toISOString() : null,
|
|
400
|
+
...(rollup ? { subagent: rollup } : {}),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// A call still open at the end of the ledger is genuinely running right now.
|
|
404
|
+
for (const [callId, entry] of open) {
|
|
405
|
+
if (!entry.step)
|
|
406
|
+
continue;
|
|
407
|
+
entry.step.toolSubsteps.push({
|
|
408
|
+
id: callId,
|
|
409
|
+
title: entry.capability,
|
|
410
|
+
status: "in_progress",
|
|
411
|
+
source: "tool",
|
|
412
|
+
durationMs: nowMs - entry.startedMs,
|
|
413
|
+
startedAt: new Date(entry.startedMs).toISOString(),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Model-declared substeps first, then the observed calls.
|
|
419
|
+
*
|
|
420
|
+
* Both are kept because they answer different questions: the model's substeps say
|
|
421
|
+
* what it MEANT to do, the tool calls say what it actually did. Showing only the
|
|
422
|
+
* first trusts a field the model frequently omits; showing only the second reduces
|
|
423
|
+
* a plan to a call log.
|
|
424
|
+
*/
|
|
425
|
+
function mergeSubsteps(entry) {
|
|
426
|
+
const declared = entry.modelSubsteps.map((sub, index) => ({
|
|
427
|
+
id: sub.id ?? `${entry.id}-sub-${index + 1}`,
|
|
428
|
+
title: sub.title,
|
|
429
|
+
status: sub.status,
|
|
430
|
+
source: "model",
|
|
431
|
+
durationMs: null,
|
|
432
|
+
startedAt: null,
|
|
433
|
+
}));
|
|
434
|
+
return [...declared, ...entry.toolSubsteps];
|
|
435
|
+
}
|
|
@@ -18,6 +18,16 @@ export type DncIdentityInput = {
|
|
|
18
18
|
detail?: string;
|
|
19
19
|
metadata?: Record<string, unknown>;
|
|
20
20
|
};
|
|
21
|
+
/** Durable subject metadata shared by independently enforced DNC ledgers. */
|
|
22
|
+
export declare const DNC_SUBJECT_KINDS: readonly ["person", "company"];
|
|
23
|
+
export type DncSubjectKind = (typeof DNC_SUBJECT_KINDS)[number];
|
|
24
|
+
export type DncSubject = {
|
|
25
|
+
id: string;
|
|
26
|
+
kind: DncSubjectKind;
|
|
27
|
+
name: string | null;
|
|
28
|
+
};
|
|
29
|
+
export declare function dncSubjectMetadata(subject: DncSubject): Record<string, unknown>;
|
|
30
|
+
export declare function readDncSubject(metadata: unknown): DncSubject | null;
|
|
21
31
|
export type CompanyDncIdentity = {
|
|
22
32
|
kind: "domain" | "linkedin_company";
|
|
23
33
|
value: string;
|
|
@@ -17,6 +17,29 @@ export const DNC_IDENTITY_KINDS = [
|
|
|
17
17
|
"company_domain",
|
|
18
18
|
"linkedin_company",
|
|
19
19
|
];
|
|
20
|
+
/** Durable subject metadata shared by independently enforced DNC ledgers. */
|
|
21
|
+
export const DNC_SUBJECT_KINDS = ["person", "company"];
|
|
22
|
+
export function dncSubjectMetadata(subject) {
|
|
23
|
+
return {
|
|
24
|
+
dnc_subject_id: subject.id,
|
|
25
|
+
dnc_subject_kind: subject.kind,
|
|
26
|
+
...(subject.name ? { dnc_subject_name: subject.name } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function readDncSubject(metadata) {
|
|
30
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
|
|
31
|
+
return null;
|
|
32
|
+
const record = metadata;
|
|
33
|
+
const id = readNonEmptyString(record.dnc_subject_id);
|
|
34
|
+
const kind = readNonEmptyString(record.dnc_subject_kind);
|
|
35
|
+
if (!id || !DNC_SUBJECT_KINDS.includes(kind))
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
id,
|
|
39
|
+
kind: kind,
|
|
40
|
+
name: readNonEmptyString(record.dnc_subject_name),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
20
43
|
const E164_PATTERN = /^\+[1-9]\d{1,14}$/;
|
|
21
44
|
const DOMAIN_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
22
45
|
const COMPANY_DOMAIN_ROW_KEYS = [
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared/dedicated send-drain handoff timing. The worker and every status
|
|
3
|
+
* surface must evaluate the same readiness envelope: inventory existence alone
|
|
4
|
+
* is not proof that a dedicated process may claim sends yet.
|
|
5
|
+
*/
|
|
6
|
+
/** Longest a process may reuse one control-plane scope snapshot. */
|
|
7
|
+
export declare const EGRESS_SEND_SCOPE_CACHE_MAX_AGE_MS = 60000;
|
|
8
|
+
/** Longest a tenant cycle may start provider sends under that snapshot. */
|
|
9
|
+
export declare const EGRESS_SEND_AUTHORITY_RETENTION_MS = 45000;
|
|
10
|
+
/** Clock/scheduling margin between the old authority expiring and the new one starting. */
|
|
11
|
+
export declare const EGRESS_SEND_HANDOFF_MARGIN_MS = 5000;
|
|
12
|
+
/**
|
|
13
|
+
* No new drain may become authoritative until every old cached scope AND every
|
|
14
|
+
* send cycle started on that cache's final millisecond have expired.
|
|
15
|
+
*/
|
|
16
|
+
export declare const DEDICATED_EGRESS_HANDOFF_DELAY_MS: number;
|
|
17
|
+
export declare const DEDICATED_EGRESS_LIVENESS_MAX_AGE_MS: number;
|
|
18
|
+
export type DedicatedEgressTransportReadiness = {
|
|
19
|
+
ready: true;
|
|
20
|
+
} | {
|
|
21
|
+
ready: false;
|
|
22
|
+
reason: "handoff_pending" | "vendor_verification_missing_or_stale";
|
|
23
|
+
};
|
|
24
|
+
export type FlyDedicatedEgressTransportReadiness = {
|
|
25
|
+
ready: true;
|
|
26
|
+
appName: string;
|
|
27
|
+
} | {
|
|
28
|
+
ready: false;
|
|
29
|
+
reason: "dedicated_app_identity_missing" | "handoff_pending" | "vendor_verification_missing_or_stale";
|
|
30
|
+
};
|
|
31
|
+
export declare function evaluateDedicatedEgressTransportReadiness(input: {
|
|
32
|
+
createdAt: Date | null;
|
|
33
|
+
vendorVerifiedAt: Date | null;
|
|
34
|
+
}, now?: number): DedicatedEgressTransportReadiness;
|
|
35
|
+
/**
|
|
36
|
+
* The app name Fly assigns to this inventory row, or null when the row cannot
|
|
37
|
+
* authorize any dedicated process. Automated Fly purchase stores the app name
|
|
38
|
+
* as metadata.vendor_order_id because one app is exactly one vendor lease.
|
|
39
|
+
*/
|
|
40
|
+
export declare function readFlyDedicatedEgressAppName(input: {
|
|
41
|
+
environment: "dev" | "prod";
|
|
42
|
+
metadata: Record<string, unknown> | null;
|
|
43
|
+
}): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Fly-specific readiness shared by the drain, status, and purchase replay.
|
|
46
|
+
* Timestamps cannot authorize an app by themselves: an orphan process for the
|
|
47
|
+
* same org must match the exact active row's immutable Fly app identity.
|
|
48
|
+
*/
|
|
49
|
+
export declare function evaluateFlyDedicatedEgressTransportReadiness(input: {
|
|
50
|
+
createdAt: Date | null;
|
|
51
|
+
vendorVerifiedAt: Date | null;
|
|
52
|
+
environment: "dev" | "prod";
|
|
53
|
+
metadata: Record<string, unknown> | null;
|
|
54
|
+
}, now?: number): FlyDedicatedEgressTransportReadiness;
|
|
55
|
+
/**
|
|
56
|
+
* Retiring a drain is the inverse handoff: shared must remain excluded until
|
|
57
|
+
* every process that could have cached dedicated authority has expired it.
|
|
58
|
+
* Writers stamp this field with DB time while preserving the rest of metadata.
|
|
59
|
+
*/
|
|
60
|
+
export declare function dedicatedEgressRetirementStillCooling(metadata: Record<string, unknown> | null, now?: number): boolean;
|