@bastani/atomic 0.9.3-alpha.2 → 0.9.3-alpha.4

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.
@@ -1,6 +1,7 @@
1
1
  import type { ThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
2
2
  import { CURSOR_API, CURSOR_API_BASE_URL } from "./config.js";
3
3
  import rawFallbackModels from "./cursor-models-raw.json" with { type: "json" };
4
+ import { positiveIntOrUndefined, resolveCursorModelReferenceLimits, type CursorModelReferenceCandidate } from "./model-reference.js";
4
5
 
5
6
  export type CursorCatalogSource = "live" | "estimated";
6
7
  export type CursorEffort = "none" | "low" | "medium" | "high" | "xhigh" | "max" | "default";
@@ -60,12 +61,13 @@ interface CursorVariantGroup {
60
61
  const CURSOR_FALLBACK_RAW_MODELS = rawFallbackModels satisfies readonly CursorUsableModel[];
61
62
  const PARSEABLE_EFFORTS: readonly Exclude<CursorEffort, "default">[] = ["none", "low", "medium", "high", "xhigh", "max"];
62
63
  const EFFORT_ORDER: readonly CursorEffort[] = ["none", "low", "default", "medium", "high", "xhigh", "max"];
64
+ const THINKING_LEVELS: readonly ThinkingLevel[] = ["minimal", "low", "medium", "high", "xhigh"];
63
65
  const THINKING_LEVEL_EFFORT_PREFERENCES: Record<ThinkingLevel, readonly CursorEffort[]> = {
64
66
  minimal: ["none", "low", "default"],
65
67
  low: ["low", "none", "default"],
66
68
  medium: ["medium", "default", "low"],
67
69
  high: ["high", "medium", "default"],
68
- xhigh: ["max", "xhigh", "high"],
70
+ xhigh: ["max", "xhigh"],
69
71
  };
70
72
 
71
73
  const ESTIMATED_CONTEXT_WINDOW = 200_000;
@@ -85,38 +87,99 @@ export function createEstimatedCursorCatalog(now = Date.now()): CursorModelCatal
85
87
  }
86
88
 
87
89
  export function mapCursorCatalogToProviderModels(catalog: CursorModelCatalog): CursorProviderModelDefinition[] {
88
- return groupCursorModels(catalog.models).map((group) => {
90
+ const groups = groupCursorModels(catalog.models);
91
+ const familyReferenceVariants = cursorReferenceVariantsByBaseId(groups);
92
+ return groups.map((group) => {
89
93
  const effortVariants = collectEffortVariants(group.variants, group.primaryId);
90
94
  const supportsEffort = group.variants.some((variant) => Boolean(variant.effort)) || effortVariants.size >= 2;
91
95
  const supportsReasoning = supportsReasoningModelId(group.primaryId);
92
96
  const name = catalog.source === "estimated" ? `${group.displayName} (estimated)` : group.displayName;
97
+ // Cursor's private API omits token limits, so when neither a live nor a
98
+ // static explicit limit is present, derive the window/output from the
99
+ // bundled pi-ai model catalog before falling back to a conservative
100
+ // estimate. This never changes which models are registered. One-million
101
+ // labels are tracked across fast/thinking sibling groups for the same
102
+ // family so Cursor's mode suffixes do not hide the advertised long window.
103
+ const referenceLimits = resolveCursorModelReferenceLimits(cursorModelReferenceCandidates(group, familyReferenceVariants.get(group.baseId) ?? []));
93
104
  return {
94
105
  id: group.primaryId,
95
106
  name,
96
107
  api: CURSOR_API,
97
108
  baseUrl: CURSOR_API_BASE_URL,
98
109
  reasoning: supportsReasoning,
99
- thinkingLevelMap: supportsEffort ? buildThinkingLevelMap(effortVariants, group.primaryId) : undefined,
110
+ thinkingLevelMap: supportsEffort ? buildCursorThinkingLevelMap(group, effortVariants) : undefined,
100
111
  input: cursorModelInput(group.primaryId),
101
112
  cost: subscriptionCost(),
102
- contextWindow: chooseLargestNumber(group.variants.map((variant) => variant.contextWindow)) ?? ESTIMATED_CONTEXT_WINDOW,
103
- maxTokens: chooseLargestNumber(group.variants.map((variant) => variant.maxTokens)) ?? ESTIMATED_MAX_TOKENS,
113
+ contextWindow: positiveIntLimit(chooseLargestNumber(group.variants.map((variant) => variant.contextWindow)) ?? referenceLimits.contextWindow, ESTIMATED_CONTEXT_WINDOW),
114
+ maxTokens: positiveIntLimit(chooseLargestNumber(group.variants.map((variant) => variant.maxTokens)) ?? referenceLimits.maxTokens, ESTIMATED_MAX_TOKENS),
104
115
  };
105
116
  });
106
117
  }
107
118
 
119
+ function cursorModelReferenceCandidates(group: CursorVariantGroup, familyVariants: readonly CursorVariant[]): CursorModelReferenceCandidate[] {
120
+ return [
121
+ { id: group.primaryId, displayName: group.displayName },
122
+ ...group.variants.map((variant) => ({ id: variant.id, displayName: variant.displayName })),
123
+ ...familyVariants.map((variant) => ({ id: variant.id, displayName: variant.displayName })),
124
+ ];
125
+ }
126
+
127
+ function cursorReferenceVariantsByBaseId(groups: readonly CursorVariantGroup[]): ReadonlyMap<string, readonly CursorVariant[]> {
128
+ const variantsByBaseId = new Map<string, CursorVariant[]>();
129
+ for (const group of groups) {
130
+ const variants = variantsByBaseId.get(group.baseId) ?? [];
131
+ variants.push(...group.variants);
132
+ variantsByBaseId.set(group.baseId, variants);
133
+ }
134
+ return variantsByBaseId;
135
+ }
136
+
137
+ function positiveIntLimit(value: number | undefined, fallback: number): number {
138
+ // Provider registration rejects non-positive/non-integer windows and would
139
+ // drop the whole catalog; guarantee a valid positive integer here so limit
140
+ // values can never affect which Cursor models are listed.
141
+ return positiveIntOrUndefined(value) ?? fallback;
142
+ }
143
+
108
144
  export function resolveCursorModelVariant(
109
145
  baseModelId: string,
110
146
  thinkingLevelMap: ThinkingLevelMap | undefined,
111
147
  thinkingLevel: ThinkingLevel | undefined,
112
148
  ): string {
113
- if (!thinkingLevel || !thinkingLevelMap) return baseModelId;
114
- const mapped = thinkingLevelMap[thinkingLevel];
115
- if (!mapped || mapped === "default") return baseModelId;
149
+ if (!thinkingLevelMap) return baseModelId;
150
+ // With no explicit thinking level, fall back to the `off` default variant.
151
+ // Effort-only Cursor models have no real base id (Cursor lists only
152
+ // `gpt-5.5-medium`, never a bare `gpt-5.5`), so sending the synthesized base
153
+ // id makes Cursor reject the run with `not_found`; the `off` entry carries a
154
+ // concrete variant id to send instead.
155
+ const mapped = thinkingLevel ? thinkingLevelMap[thinkingLevel] : thinkingLevelMap.off;
156
+ if (mapped === null) {
157
+ const fallbackLevel = nearestSupportedThinkingLevel(thinkingLevelMap, thinkingLevel);
158
+ return fallbackLevel ? resolveCursorModelVariant(baseModelId, thinkingLevelMap, fallbackLevel) : baseModelId;
159
+ }
160
+ if (mapped === undefined || mapped === "default") return baseModelId;
116
161
  if (isCursorEffort(mapped)) return replaceEffortBeforeCursorSuffix(baseModelId, mapped);
117
162
  return mapped;
118
163
  }
119
164
 
165
+ function nearestSupportedThinkingLevel(
166
+ thinkingLevelMap: ThinkingLevelMap,
167
+ thinkingLevel: ThinkingLevel | undefined,
168
+ ): ThinkingLevel | undefined {
169
+ if (!thinkingLevel) return undefined;
170
+ const requestedIndex = THINKING_LEVELS.indexOf(thinkingLevel);
171
+ if (requestedIndex === -1) return undefined;
172
+ for (let index = requestedIndex - 1; index >= 0; index--) {
173
+ const level = THINKING_LEVELS[index];
174
+ if (level && thinkingLevelMap[level] !== null && thinkingLevelMap[level] !== undefined) return level;
175
+ }
176
+ for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index++) {
177
+ const level = THINKING_LEVELS[index];
178
+ if (level && thinkingLevelMap[level] !== null && thinkingLevelMap[level] !== undefined) return level;
179
+ }
180
+ return undefined;
181
+ }
182
+
120
183
  export function insertEffortBeforeCursorSuffix(modelId: string, effort: CursorEffort): string {
121
184
  if (effort === "default") return modelId;
122
185
  let base = modelId;
@@ -205,16 +268,36 @@ function collectEffortVariants(variants: readonly CursorVariant[], primaryId: st
205
268
  return byEffort;
206
269
  }
207
270
 
271
+ function buildCursorThinkingLevelMap(group: CursorVariantGroup, effortVariants: ReadonlyMap<CursorEffort, string>): ThinkingLevelMap {
272
+ const map = buildThinkingLevelMap(effortVariants, group.primaryId);
273
+ // When the group has no real base id (every Cursor variant carries an effort
274
+ // suffix), the synthesized primary id is not a sendable Cursor model. Record
275
+ // an `off` default so a no-thinking request maps to a concrete variant instead
276
+ // of the base id, which Cursor would reject with `not_found`. Prefer the
277
+ // minimal/least-effort variant because `off` means minimum reasoning.
278
+ const hasRealBaseId = group.variants.some((variant) => variant.id === group.primaryId);
279
+ if (!hasRealBaseId) {
280
+ const defaultVariant = map.minimal ?? map.low ?? map.medium ?? map.high ?? map.xhigh ?? null;
281
+ if (defaultVariant) map.off = defaultVariant;
282
+ }
283
+ return map;
284
+ }
285
+
208
286
  function buildThinkingLevelMap(effortVariants: ReadonlyMap<CursorEffort, string>, primaryId: string): ThinkingLevelMap {
209
287
  return {
210
288
  minimal: chooseEffortVariant(effortVariants, THINKING_LEVEL_EFFORT_PREFERENCES.minimal, primaryId),
211
289
  low: chooseEffortVariant(effortVariants, THINKING_LEVEL_EFFORT_PREFERENCES.low, primaryId),
212
290
  medium: chooseEffortVariant(effortVariants, THINKING_LEVEL_EFFORT_PREFERENCES.medium, primaryId),
213
291
  high: chooseEffortVariant(effortVariants, THINKING_LEVEL_EFFORT_PREFERENCES.high, primaryId),
214
- xhigh: chooseEffortVariant(effortVariants, THINKING_LEVEL_EFFORT_PREFERENCES.xhigh, primaryId),
292
+ // `xhigh` should only be offered when Cursor advertises a true xhigh/max variant.
293
+ xhigh: chooseCursorXhighVariant(effortVariants),
215
294
  };
216
295
  }
217
296
 
297
+ function chooseCursorXhighVariant(effortVariants: ReadonlyMap<CursorEffort, string>): string | null {
298
+ return effortVariants.get("max") ?? effortVariants.get("xhigh") ?? null;
299
+ }
300
+
218
301
  function chooseEffortVariant(effortVariants: ReadonlyMap<CursorEffort, string>, preferences: readonly CursorEffort[], _primaryId: string): string | null {
219
302
  for (const effort of preferences) {
220
303
  const variantId = effortVariants.get(effort);
@@ -232,8 +315,10 @@ function isCursorEffort(value: string): value is CursorEffort {
232
315
  }
233
316
 
234
317
  function chooseLargestNumber(values: readonly (number | undefined)[]): number | undefined {
235
- const finiteValues = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
236
- return finiteValues.length > 0 ? Math.max(...finiteValues) : undefined;
318
+ // Cursor's private API omits token limits; treat any non-positive value as
319
+ // bogus so a stray 0/negative never becomes an invalid context window.
320
+ const positiveValues = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0);
321
+ return positiveValues.length > 0 ? Math.max(...positiveValues) : undefined;
237
322
  }
238
323
 
239
324
  function choosePrimaryId(variants: readonly CursorVariant[], baseId: string): string {
@@ -0,0 +1,282 @@
1
+ import { getModels, getProviders } from "@earendil-works/pi-ai";
2
+
3
+ export interface CursorModelReferenceCandidate {
4
+ readonly id: string;
5
+ readonly displayName?: string;
6
+ }
7
+
8
+ export interface CursorModelReferenceLimits {
9
+ readonly contextWindow?: number;
10
+ readonly maxTokens?: number;
11
+ }
12
+
13
+ export interface CursorModelReferenceCatalogEntry {
14
+ readonly provider: string;
15
+ readonly id: string;
16
+ readonly name: string;
17
+ readonly contextWindow: number;
18
+ readonly maxTokens: number;
19
+ }
20
+
21
+ interface ReferenceModelLimits {
22
+ readonly provider: string;
23
+ readonly id: string;
24
+ readonly name: string;
25
+ readonly contextWindow: number;
26
+ readonly maxTokens: number;
27
+ readonly priority: number;
28
+ }
29
+
30
+ const ONE_MILLION_MODEL_NAME_CONTEXT_WINDOW = 1_000_000;
31
+ const UNRANKED_PROVIDER_PRIORITY = 10_000;
32
+ const CURSOR_EFFORT_SUFFIXES = ["none", "low", "medium", "high", "xhigh", "max", "default"] as const;
33
+ const CLAUDE_FAMILY_NAMES = new Set(["fable", "haiku", "opus", "sonnet"]);
34
+
35
+ const REFERENCE_PROVIDER_PRIORITY = new Map<string, number>([
36
+ ["opencode", 0],
37
+ ["opencode-go", 1],
38
+ ["anthropic", 10],
39
+ ["google", 20],
40
+ ["google-vertex", 21],
41
+ ["openai", 30],
42
+ ["openai-codex", 31],
43
+ ["azure-openai-responses", 32],
44
+ ["xai", 40],
45
+ ["moonshotai", 50],
46
+ ["moonshotai-cn", 51],
47
+ ["kimi-coding", 52],
48
+ ["zai", 60],
49
+ ["zai-coding-cn", 61],
50
+ ["openrouter", 100],
51
+ ["vercel-ai-gateway", 101],
52
+ ["cloudflare-ai-gateway", 102],
53
+ ["cloudflare-workers-ai", 103],
54
+ ["github-copilot", 200],
55
+ ]);
56
+
57
+ const EMPTY_REFERENCE_MODEL_INDEX: ReadonlyMap<string, readonly ReferenceModelLimits[]> = new Map();
58
+ let referenceModelIndex: ReadonlyMap<string, readonly ReferenceModelLimits[]> | undefined;
59
+ let referenceModelCatalogOverride: readonly CursorModelReferenceCatalogEntry[] | undefined;
60
+
61
+ export function setCursorModelReferenceCatalogForTesting(models: readonly CursorModelReferenceCatalogEntry[] | undefined): void {
62
+ referenceModelCatalogOverride = models;
63
+ resetCursorModelReferenceIndex();
64
+ }
65
+
66
+ export function resetCursorModelReferenceIndex(): void {
67
+ referenceModelIndex = undefined;
68
+ }
69
+
70
+ export function resolveCursorModelReferenceLimits(candidates: readonly CursorModelReferenceCandidate[]): CursorModelReferenceLimits {
71
+ // Limit resolution must never affect which Cursor models register. Any failure
72
+ // (e.g. an unexpected pi-ai catalog shape at runtime) degrades to "no reference
73
+ // limits" so the caller keeps the model with its estimate.
74
+ try {
75
+ const explicitOneMillion = candidates.some((candidate) => hasOneMillionMarker(candidate.id) || hasOneMillionMarker(candidate.displayName ?? ""));
76
+ const match = findReferenceModel(cursorCandidateAliases(candidates), explicitOneMillion);
77
+ if (!match) {
78
+ return explicitOneMillion ? { contextWindow: ONE_MILLION_MODEL_NAME_CONTEXT_WINDOW } : {};
79
+ }
80
+ const contextWindow = positiveIntOrUndefined(match.contextWindow);
81
+ return {
82
+ contextWindow: explicitOneMillion && contextWindow !== undefined
83
+ ? Math.max(contextWindow, ONE_MILLION_MODEL_NAME_CONTEXT_WINDOW)
84
+ : contextWindow,
85
+ maxTokens: positiveIntOrUndefined(match.maxTokens),
86
+ };
87
+ } catch {
88
+ return {};
89
+ }
90
+ }
91
+
92
+ export function positiveIntOrUndefined(value: number | undefined): number | undefined {
93
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined;
94
+ }
95
+
96
+ function findReferenceModel(aliases: readonly string[], preferOneMillion: boolean): ReferenceModelLimits | undefined {
97
+ const index = getReferenceModelIndex();
98
+ for (const alias of aliases) {
99
+ const matches = index.get(alias);
100
+ if (!matches?.[0]) continue;
101
+ if (preferOneMillion) {
102
+ const oneMillionMatch = matches.find((candidate) => candidate.contextWindow >= ONE_MILLION_MODEL_NAME_CONTEXT_WINDOW);
103
+ if (oneMillionMatch) return oneMillionMatch;
104
+ }
105
+ return matches[0];
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ function getReferenceModelIndex(): ReadonlyMap<string, readonly ReferenceModelLimits[]> {
111
+ if (referenceModelIndex) return referenceModelIndex;
112
+ try {
113
+ const mutableIndex = new Map<string, ReferenceModelLimits[]>();
114
+ for (const model of getReferenceModels()) {
115
+ for (const alias of referenceModelAliases(model)) {
116
+ const existing = mutableIndex.get(alias) ?? [];
117
+ existing.push(model);
118
+ mutableIndex.set(alias, existing);
119
+ }
120
+ }
121
+ for (const matches of mutableIndex.values()) {
122
+ matches.sort(compareReferenceModels);
123
+ }
124
+ referenceModelIndex = mutableIndex;
125
+ } catch {
126
+ referenceModelIndex = EMPTY_REFERENCE_MODEL_INDEX;
127
+ }
128
+ return referenceModelIndex;
129
+ }
130
+
131
+ function getReferenceModels(): ReferenceModelLimits[] {
132
+ return referenceModelCatalogOverride
133
+ ? referenceModelCatalogOverride.map((model) => toReferenceModelLimits(model, referenceProviderPriority(model.provider)))
134
+ : getPiAiReferenceModels();
135
+ }
136
+
137
+ function getPiAiReferenceModels(): ReferenceModelLimits[] {
138
+ const models: ReferenceModelLimits[] = [];
139
+ for (const provider of getProviders()) {
140
+ const priority = referenceProviderPriority(provider);
141
+ for (const model of getModels(provider)) {
142
+ if (!isPositiveFiniteNumber(model.contextWindow) || !isPositiveFiniteNumber(model.maxTokens)) continue;
143
+ models.push(toReferenceModelLimits(model, priority));
144
+ }
145
+ }
146
+ return models;
147
+ }
148
+
149
+ function referenceProviderPriority(provider: string): number {
150
+ return REFERENCE_PROVIDER_PRIORITY.get(provider) ?? UNRANKED_PROVIDER_PRIORITY;
151
+ }
152
+
153
+ function toReferenceModelLimits(model: CursorModelReferenceCatalogEntry, priority: number): ReferenceModelLimits {
154
+ return {
155
+ provider: model.provider,
156
+ id: model.id,
157
+ name: model.name,
158
+ contextWindow: model.contextWindow,
159
+ maxTokens: model.maxTokens,
160
+ priority,
161
+ };
162
+ }
163
+
164
+ function compareReferenceModels(left: ReferenceModelLimits, right: ReferenceModelLimits): number {
165
+ return left.priority - right.priority
166
+ || left.provider.localeCompare(right.provider)
167
+ || left.id.localeCompare(right.id)
168
+ || left.name.localeCompare(right.name);
169
+ }
170
+
171
+ function referenceModelAliases(model: ReferenceModelLimits): string[] {
172
+ const aliases = new Set<string>();
173
+ addNormalizedAlias(aliases, model.id);
174
+ addNormalizedAlias(aliases, lastPathSegment(model.id));
175
+ addNormalizedAlias(aliases, model.name);
176
+ addNormalizedAlias(aliases, displayNameTail(model.name));
177
+ return [...aliases];
178
+ }
179
+
180
+ function cursorCandidateAliases(candidates: readonly CursorModelReferenceCandidate[]): string[] {
181
+ // Match strictly on Cursor model IDs, which already carry the family and
182
+ // version (e.g. `gpt-5.5-high`, `claude-4.6-sonnet-medium`). Cursor display
183
+ // names are short and generic ("Auto", "Sonnet 4") and would produce false
184
+ // matches against unrelated pi-ai models, so they are not used as match keys.
185
+ const aliases = new Set<string>();
186
+ for (const candidate of candidates) {
187
+ for (const alias of cursorIdAliases(candidate.id)) {
188
+ addNormalizedAlias(aliases, alias);
189
+ }
190
+ }
191
+ return [...aliases];
192
+ }
193
+
194
+ function cursorIdAliases(id: string): string[] {
195
+ const aliases = new Set<string>([id]);
196
+ for (const baseAlias of cursorBaseIdAliases(id)) {
197
+ aliases.add(baseAlias);
198
+ for (const claudeAlias of cursorClaudeIdAliases(baseAlias)) {
199
+ aliases.add(claudeAlias);
200
+ }
201
+ }
202
+ return [...aliases];
203
+ }
204
+
205
+ function cursorBaseIdAliases(id: string): string[] {
206
+ const aliases = new Set<string>([id]);
207
+ const withoutModes = stripTrailingCursorModes(id);
208
+ aliases.add(withoutModes);
209
+ const withoutEffort = stripTrailingEffort(withoutModes);
210
+ aliases.add(withoutEffort);
211
+ for (const alias of [...aliases]) {
212
+ if (alias.endsWith("-1m")) aliases.add(alias.slice(0, -"-1m".length));
213
+ }
214
+ return [...aliases];
215
+ }
216
+
217
+ function stripTrailingCursorModes(id: string): string {
218
+ let result = id;
219
+ let changed = true;
220
+ while (changed) {
221
+ changed = false;
222
+ if (result.endsWith("-fast")) {
223
+ result = result.slice(0, -"-fast".length);
224
+ changed = true;
225
+ }
226
+ if (result.endsWith("-thinking")) {
227
+ result = result.slice(0, -"-thinking".length);
228
+ changed = true;
229
+ }
230
+ }
231
+ return result;
232
+ }
233
+
234
+ function stripTrailingEffort(id: string): string {
235
+ for (const effort of CURSOR_EFFORT_SUFFIXES) {
236
+ const suffix = `-${effort}`;
237
+ if (id.endsWith(suffix)) return id.slice(0, -suffix.length);
238
+ }
239
+ return id;
240
+ }
241
+
242
+ function cursorClaudeIdAliases(id: string): string[] {
243
+ const match = /^claude-(\d+(?:[.-]\d+)*)-([a-z]+)(?:$|-)/iu.exec(id);
244
+ if (!match) return [];
245
+ const [, version, family] = match;
246
+ if (!version || !family || !CLAUDE_FAMILY_NAMES.has(family.toLowerCase())) return [];
247
+ return [`claude-${family.toLowerCase()}-${version.replace(/[.-]/gu, "-")}`];
248
+ }
249
+
250
+ function addNormalizedAlias(aliases: Set<string>, value: string): void {
251
+ const normalized = normalizeReferenceKey(value);
252
+ if (normalized) aliases.add(normalized);
253
+ }
254
+
255
+ function normalizeReferenceKey(value: string): string | undefined {
256
+ const normalized = value
257
+ .normalize("NFKD")
258
+ .replace(/[\u0300-\u036f]/gu, "")
259
+ .toLowerCase()
260
+ .replace(/&/gu, " and ")
261
+ .replace(/[^a-z0-9]+/gu, " ")
262
+ .trim();
263
+ return normalized.length > 0 ? normalized : undefined;
264
+ }
265
+
266
+ function lastPathSegment(id: string): string {
267
+ const parts = id.split("/");
268
+ return parts[parts.length - 1] ?? id;
269
+ }
270
+
271
+ function displayNameTail(name: string): string {
272
+ const parts = name.split(":");
273
+ return parts[parts.length - 1]?.trim() ?? name;
274
+ }
275
+
276
+ function hasOneMillionMarker(value: string): boolean {
277
+ return /(?:^|[^a-z0-9])1\s*m(?:$|[^a-z0-9])/iu.test(value);
278
+ }
279
+
280
+ function isPositiveFiniteNumber(value: number): boolean {
281
+ return Number.isFinite(value) && value > 0;
282
+ }
@@ -18,7 +18,7 @@ Protocol behavior intentionally copied from the reference provider:
18
18
  - Run requests use generated `AgentRunRequest`, `ConversationStateStructure`, `ConversationAction`, `UserMessage`, and `ModelDetails` messages.
19
19
  - `UserMessage.message_id`, `UserMessage.correlation_id`, and reconstructed historical turn request ids are UUIDs generated the same way as the reference provider.
20
20
  - Conversation ids are deterministic UUIDs derived from the hashed conversation key (`conv:<session-or-first-user-text>`), matching the reference provider rather than sending raw Atomic session ids to Cursor.
21
- - Static fallback models are the reference `cursor-models-raw.json`; live model discovery is opportunistic and only replaces the registered catalog when Cursor returns usable models.
21
+ - Static fallback models are the reference `cursor-models-raw.json`; live model discovery is opportunistic and only replaces the registered catalog when Cursor returns usable models. Cursor's model-discovery protobuf carries no context-window or output-token fields, so the provider preserves any positive limits and otherwise resolves them from Atomic's bundled `@earendil-works/pi-ai` model catalog by Cursor model ID (with a conservative estimate for unmatched Cursor-only models and a 1,000,000-token floor for explicit `1M` Cursor labels); this only sets limits and does not change catalog membership.
22
22
  - Tool definitions are returned in response to `ExecServerMessage.request_context_args = 10`; `McpArgs` messages become Atomic tool calls and active tool results are sent back as generated `ExecClientMessage.mcp_result` frames.
23
23
  - Checkpoint and blob-store state is persisted per Cursor conversation id and discarded on Cursor end-stream errors such as `not_found`.
24
24
  - `InteractionUpdate.turn_ended` is non-terminal; the stream closes on the Connect stream ending.
@@ -27,7 +27,7 @@ Manual smoke-test procedure after Cursor releases:
27
27
 
28
28
  1. Sign in to the current Cursor CLI/app and capture a successful `api2.cursor.sh` model discovery or agent `Run` request.
29
29
  2. Update `CURSOR_CLIENT_VERSION` in `src/config.ts` from the captured `x-cursor-client-version` header if it changed.
30
- 3. In Atomic, run `/login`, select **Cursor**, complete browser auth, then confirm `/model` lists `cursor/<model-id>` entries from live discovery.
30
+ 3. In Atomic, run `/login`, select **Cursor (Experimental)**, complete browser auth, then confirm `/model` lists `cursor/<model-id>` entries from live discovery.
31
31
  4. Select a Cursor model and run one chat turn plus one tool-using turn; verify the process exits cleanly for a one-shot/noninteractive run.
32
32
  5. Re-run the Cursor unit tests and update these notes for any changed protobuf paths.
33
33
 
@@ -4,6 +4,18 @@ All notable changes to the `pi-intercom` extension will be documented in this fi
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.3-alpha.4] - 2026-06-28
8
+
9
+ ### Changed
10
+
11
+ - Published a synchronized Atomic 0.9.3-alpha.4 prerelease for the intercom extension; no intercom extension changes were made after 0.9.3-alpha.3.
12
+
13
+ ## [0.9.3-alpha.3] - 2026-06-27
14
+
15
+ ### Changed
16
+
17
+ - Published a synchronized Atomic 0.9.3-alpha.3 prerelease for the intercom extension; no intercom extension changes were made after 0.9.2.
18
+
7
19
  ## [0.9.2] - 2026-06-23
8
20
 
9
21
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/intercom",
3
- "version": "0.9.3-alpha.2",
3
+ "version": "0.9.3-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension providing a private coordination channel between parent and child agent sessions. Fork of: https://github.com/nicobailon/pi-intercom",
6
6
  "contributors": [
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.3-alpha.4] - 2026-06-28
11
+
12
+ ### Changed
13
+
14
+ - Published a synchronized Atomic 0.9.3-alpha.4 prerelease for the MCP extension; no MCP extension changes were made after 0.9.3-alpha.3.
15
+
16
+ ## [0.9.3-alpha.3] - 2026-06-27
17
+
18
+ ### Changed
19
+
20
+ - Published a synchronized Atomic 0.9.3-alpha.3 prerelease for the MCP extension; no MCP extension changes were made after 0.9.3-alpha.1.
21
+
10
22
  ## [0.9.3-alpha.1] - 2026-06-25
11
23
 
12
24
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/mcp",
3
- "version": "0.9.3-alpha.2",
3
+ "version": "0.9.3-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension that adapts MCP (Model Context Protocol) servers into the coding agent. Fork of: https://github.com/nicobailon/pi-mcp-adapter",
6
6
  "contributors": [
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.3-alpha.4] - 2026-06-28
6
+
7
+ ### Changed
8
+
9
+ - Published a synchronized Atomic 0.9.3-alpha.4 prerelease for the subagents extension; no subagents extension changes were made after 0.9.3-alpha.3.
10
+
11
+ ## [0.9.3-alpha.3] - 2026-06-27
12
+
13
+ ### Changed
14
+
15
+ - Published the Atomic 0.9.3-alpha.3 prerelease for the subagents extension from the same code as 0.9.3-alpha.2; no subagents extension changes were made after the previous prerelease.
16
+
5
17
  ## [0.9.3-alpha.2] - 2026-06-27
6
18
 
7
19
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/subagents",
3
- "version": "0.9.3-alpha.2",
3
+ "version": "0.9.3-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for delegating tasks to subagents with chains, parallel execution, and TUI clarification. Fork of: https://github.com/nicobailon/pi-subagents",
6
6
  "contributors": [
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.3-alpha.4] - 2026-06-28
8
+
9
+ ### Changed
10
+
11
+ - Published a synchronized Atomic 0.9.3-alpha.4 prerelease for the web-access extension; no web-access extension changes were made after 0.9.3-alpha.3.
12
+
13
+ ## [0.9.3-alpha.3] - 2026-06-27
14
+
15
+ ### Changed
16
+
17
+ - Published a synchronized Atomic 0.9.3-alpha.3 prerelease for the web-access extension; no web-access extension changes were made after 0.9.2.
18
+
7
19
  ## [0.9.2] - 2026-06-23
8
20
 
9
21
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/web-access",
3
- "version": "0.9.3-alpha.2",
3
+ "version": "0.9.3-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for web search, URL fetching, GitHub repo cloning, PDF/video extraction. Fork of: https://github.com/nicobailon/pi-web-access",
6
6
  "contributors": [
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.3-alpha.4] - 2026-06-28
10
+
11
+ ### Changed
12
+
13
+ - Strengthened the model-facing workflow prompt guidance to treat workflows as the default path for non-trivial tasks and requests with inherent structure plus verifiable objectives, explicitly including implementation, build, debugging, bug-fix, migration, new-feature, scoped multi-file, and validation-heavy docs/code prompts alongside loop-shaped phrasing such as `do X until Y`, `repeat until`, `iterate until`, and review/fix/test-until-passing prompts.
14
+
15
+ ## [0.9.3-alpha.3] - 2026-06-27
16
+
17
+ ### Changed
18
+
19
+ - Published the Atomic 0.9.3-alpha.3 prerelease for the workflows extension from the same code as 0.9.3-alpha.2; no workflow changes were made after the previous prerelease.
20
+
9
21
  ## [0.9.3-alpha.2] - 2026-06-27
10
22
 
11
23
  ### Breaking Changes
@@ -5,6 +5,8 @@
5
5
  An open-source Atomic workflow extension: install it, author workflows in TypeScript, run them from chat.
6
6
  </p>
7
7
 
8
+ Use workflows by default for non-trivial tasks, structured requests, and objectives with verifiable done criteria, including implementation, build, debugging, bug-fix, migration, new-feature, scoped multi-file, and validation-heavy docs/code work. Prompt language such as `do X until Y`, `repeat until`, `iterate until`, `review/fix until passing`, or `run checks and fix until green` is a strong signal to map the work to a workflow with an explicit stop condition rather than a direct chat turn.
9
+
8
10
  <p align="center">
9
11
  <a href="#authoring-api">Authoring API</a>
10
12
  &nbsp;·&nbsp;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/workflows",
3
- "version": "0.9.3-alpha.2",
3
+ "version": "0.9.3-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for multi-stage workflow authoring and execution.",
6
6
  "contributors": [