@bastani/atomic 0.9.3-alpha.3 → 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,12 @@ 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
+
7
13
  ## [0.9.3-alpha.3] - 2026-06-27
8
14
 
9
15
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/intercom",
3
- "version": "0.9.3-alpha.3",
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,12 @@ 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
+
10
16
  ## [0.9.3-alpha.3] - 2026-06-27
11
17
 
12
18
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/mcp",
3
- "version": "0.9.3-alpha.3",
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,12 @@
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
+
5
11
  ## [0.9.3-alpha.3] - 2026-06-27
6
12
 
7
13
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/subagents",
3
- "version": "0.9.3-alpha.3",
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,12 @@ 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
+
7
13
  ## [0.9.3-alpha.3] - 2026-06-27
8
14
 
9
15
  ### Changed
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/web-access",
3
- "version": "0.9.3-alpha.3",
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,12 @@ 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
+
9
15
  ## [0.9.3-alpha.3] - 2026-06-27
10
16
 
11
17
  ### Changed
@@ -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.3",
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": [
@@ -8,7 +8,10 @@ export const WORKFLOW_TOOL_DESCRIPTION =
8
8
  "then search it with rg/grep and read small ranges; transcript is path-only by default when sessionFile/transcriptPath exists, explicit tail/limit returns bounded previews, and missing transcript paths fall back to a small preview.";
9
9
 
10
10
  export const DEFAULT_PROMPT_GUIDANCE: string[] = [
11
- `**Workflows**: Use the \`workflow\` tool for existing named workflows and for repeatable, inspectable, resumable, or multi-stage processes; use direct \`task\`, \`tasks\`, or \`chain\` workflow calls for one-off tracked work when that is useful.
11
+ `**Workflows**: Treat workflows as the default execution path for any non-trivial task and for any request that has inherent structure plus an objective you can make verifiable. Use the \`workflow\` tool for existing named workflows and for repeatable, inspectable, resumable, multi-stage, or reviewer-gated processes; use direct \`task\`, \`tasks\`, or \`chain\` workflow calls for one-off tracked work when no named workflow fits.
12
+ - Workflow fit check: before implementing directly, ask whether the task is an implementation, build, debug/diagnosis, bug-fix, migration, new-feature, scoped multi-file edit, or docs/code change with validation; or whether it has multiple steps, dependencies, handoffs, uncertainty, validation/review needs, a long-running surface, or measurable done criteria. If yes, launch or design a workflow instead of doing parallel implementation yourself.
13
+ - Treat loop/structural wording as a strong workflow signal, especially requests like "do X until Y", "repeat until", "iterate until", "review/fix until passing", "run checks and fix until green", "keep going until done", or any prompt that names a stop condition, approval gate, or evidence requirement.
14
+ - Only skip workflows for tiny, deterministic, low-risk answers or direct edits that are faster and safer to complete inline without stage tracking, typically a clearly single-file/no-test/no-review change.
12
15
  - For unfamiliar named workflows, discover with \`action: "list"\`, inspect with \`action: "get"\` or \`action: "inputs"\`, and run with \`action: "run"\`, \`workflow\`, and validated \`inputs\`; do not invent workflow names or input keys.
13
16
  - When designing or editing workflows, read docs/workflows.md and reference its Workflow Starter Patterns: Classify-and-act, Fan-out-and-synthesize, Adversarial verification, Generate-and-filter, Tournament, and Loop until done. Choose or combine these patterns before inventing a custom stage graph, and reflect the selected pattern in the spec and Mermaid diagram when using the create-spec skill.
14
17
  - Once you run a workflow with the workflow tool, end your current turn and wait for the next user input or lifecycle notice.
@@ -19,7 +22,7 @@ export const DEFAULT_PROMPT_GUIDANCE: string[] = [
19
22
  - For transcripts, avoid reading whole session transcripts at once. Use \`stages\` or \`stage\` to get \`sessionFile\`/\`transcriptPath\`, quote the exact path without rewriting separators (preserve Windows backslashes), search it with \`rg\`/\`grep\`, and read small relevant ranges; use \`transcript\` with explicit \`tail\` or \`limit\` only for quick recent-context checks.
20
23
  - If a user asks to create or edit a workflow, use the create-spec skill when available and ask detailed clarifying questions until you understand its purpose, inputs, stages, handoffs, validation, success criteria, and selected starter pattern. Then read the workflow docs/examples and implement the workflow from the created spec directly as a TypeScript definition. After you implement the workflow, reload it to access it and run it with test inputs to validate it works as intended before presenting it to the user.
21
24
  - Tip: when designing workflows, implement it in a way that you pass information from stage to stage by writing it to a file or artifact (either deterministic or model-driven), pass the path with \`reads\`, and explicitly prompt the downstream agent with wording like \`Read the file at <path>...\`; do not inject large \`previous\` payloads or session history into the next prompt unless explicitly requested to.
22
- - Prefer using the \`goal\` workflow for small fixes/quick fixes and the \`ralph\` workflow for tasks that are non-trivial (over 2K LoC estimated diff).
23
- - Adjust the \`max_loops\` based on task complexity (estimated LoC and number of unique files that are touched).
25
+ - Prefer the \`goal\` workflow for small-to-medium non-trivial one-off fixes, debugging, docs/code changes, scoped multi-file edits, or test/fix loops when you can name the work surface, outcome, and validation; prefer the \`ralph\` workflow for larger/riskier work, migrations, new features, broad refactors, multi-package changes, or tasks estimated over ~2K LoC.
26
+ - Adjust \`goal.max_turns\` for Goal runs and \`ralph.max_loops\` for Ralph runs based on task complexity (estimated LoC, risk, validation effort, and number of unique files/areas touched).
24
27
  - Define an objective that includes tight scope, concrete and verifiable done criteria, and validation steps; then monitor progress as above instead of doing parallel implementation yourself.`,
25
28
  ];