@ottocode/sdk 0.1.237 → 0.1.243

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.
@@ -5,6 +5,7 @@ const OAUTH_MODEL_PREFIXES: Partial<Record<ProviderId, string[]>> = {
5
5
  'claude-haiku-4-5',
6
6
  'claude-opus-4-5',
7
7
  'claude-opus-4-6',
8
+ 'claude-opus-4-7',
8
9
  'claude-sonnet-4-5',
9
10
  'claude-sonnet-4-6',
10
11
  ],
@@ -19,6 +20,7 @@ const OAUTH_MODEL_IDS: Partial<Record<ProviderId, string[]>> = {
19
20
  'gpt-5.2-codex',
20
21
  'gpt-5.3-codex',
21
22
  'gpt-5.4',
23
+ 'gpt-5.4-mini',
22
24
  ],
23
25
  };
24
26
 
@@ -40,7 +40,7 @@ const PREFERRED_FAST_MODELS: Partial<Record<ProviderId, string[]>> = {
40
40
  };
41
41
 
42
42
  const PREFERRED_FAST_MODELS_OAUTH: Partial<Record<ProviderId, string[]>> = {
43
- openai: ['gpt-5.1-codex-mini'],
43
+ openai: ['gpt-5.4-mini'],
44
44
  anthropic: ['claude-haiku-4-5'],
45
45
  };
46
46
 
@@ -74,6 +74,7 @@ export async function discoverSkills(
74
74
  let current = cwd;
75
75
  const visited = new Set<string>();
76
76
  while (current && !visited.has(current)) {
77
+ if (repoRoot && !current.startsWith(repoRoot)) break;
77
78
  visited.add(current);
78
79
  const scope: SkillScope =
79
80
  current === cwd ? 'cwd' : current === repoRoot ? 'repo' : 'parent';
@@ -82,7 +83,6 @@ export async function discoverSkills(
82
83
  }
83
84
  const parent = dirname(current);
84
85
  if (parent === current) break;
85
- if (repoRoot && !current.startsWith(repoRoot)) break;
86
86
  current = parent;
87
87
  }
88
88
 
@@ -35,50 +35,225 @@ function parseYamlFrontmatter(
35
35
  ): Record<string, unknown> {
36
36
  const result: Record<string, unknown> = {};
37
37
  const lines = yaml.split('\n');
38
- let currentKey: string | null = null;
39
- let currentIndent = 0;
40
- let nestedObject: Record<string, string> | null = null;
38
+ let index = 0;
41
39
 
42
- for (const line of lines) {
43
- if (!line.trim()) continue;
40
+ while (index < lines.length) {
41
+ const line = lines[index];
42
+ if (!line || !line.trim()) {
43
+ index += 1;
44
+ continue;
45
+ }
44
46
 
45
47
  const indent = line.search(/\S/);
48
+ if (indent > 0) {
49
+ index += 1;
50
+ continue;
51
+ }
52
+
46
53
  const trimmed = line.trim();
54
+ const colonIdx = trimmed.indexOf(':');
55
+ if (colonIdx === -1) {
56
+ index += 1;
57
+ continue;
58
+ }
47
59
 
48
- if (indent === 0 || (indent <= currentIndent && nestedObject)) {
49
- if (nestedObject && currentKey) {
50
- result[currentKey] = nestedObject;
51
- nestedObject = null;
52
- currentKey = null;
53
- }
60
+ const key = normalizeKey(trimmed.slice(0, colonIdx).trim());
61
+ const value = trimmed.slice(colonIdx + 1).trim();
62
+
63
+ // Handle YAML block scalars with optional chomping/indentation indicators:
64
+ // `|`, `>`, `|-`, `>-`, `|+`, `>+`, `|2`, `>2-`, etc.
65
+ const blockScalarMatch = value.match(/^([|>])[-+]?\d*[-+]?$/);
66
+ if (blockScalarMatch) {
67
+ const style = blockScalarMatch[1] as '|' | '>';
68
+ const { content, nextIndex } = readBlockScalar(
69
+ lines,
70
+ index + 1,
71
+ indent,
72
+ style,
73
+ );
74
+ result[key] = content;
75
+ index = nextIndex;
76
+ continue;
77
+ }
78
+
79
+ if (!value) {
80
+ const { content, nextIndex } = readIndentedValue(
81
+ lines,
82
+ index + 1,
83
+ indent,
84
+ );
85
+ result[key] = content;
86
+ index = nextIndex;
87
+ continue;
88
+ }
89
+
90
+ result[key] = parseYamlValue(value);
91
+ index += 1;
92
+ }
93
+
94
+ return result;
95
+ }
96
+
97
+ function readIndentedValue(
98
+ lines: string[],
99
+ startIndex: number,
100
+ parentIndent: number,
101
+ ): { content: Record<string, string> | string; nextIndex: number } {
102
+ for (let index = startIndex; index < lines.length; index += 1) {
103
+ const line = lines[index];
104
+ if (!line || !line.trim()) continue;
105
+
106
+ const indent = line.search(/\S/);
107
+ if (indent <= parentIndent) {
108
+ return { content: '', nextIndex: index };
54
109
  }
55
110
 
111
+ if (isNestedObjectLine(line.trim())) {
112
+ return readNestedObject(lines, startIndex, parentIndent);
113
+ }
114
+
115
+ return readIndentedScalar(lines, startIndex, parentIndent);
116
+ }
117
+
118
+ return { content: '', nextIndex: lines.length };
119
+ }
120
+
121
+ function readNestedObject(
122
+ lines: string[],
123
+ startIndex: number,
124
+ parentIndent: number,
125
+ ): { content: Record<string, string>; nextIndex: number } {
126
+ const result: Record<string, string> = {};
127
+ let index = startIndex;
128
+
129
+ while (index < lines.length) {
130
+ const line = lines[index];
131
+ if (!line || !line.trim()) {
132
+ index += 1;
133
+ continue;
134
+ }
135
+
136
+ const indent = line.search(/\S/);
137
+ if (indent <= parentIndent) break;
138
+
139
+ const trimmed = line.trim();
56
140
  const colonIdx = trimmed.indexOf(':');
57
- if (colonIdx === -1) continue;
141
+ if (colonIdx === -1) {
142
+ index += 1;
143
+ continue;
144
+ }
58
145
 
59
- const key = trimmed.slice(0, colonIdx).trim();
146
+ const key = normalizeKey(trimmed.slice(0, colonIdx).trim());
60
147
  const value = trimmed.slice(colonIdx + 1).trim();
61
148
 
62
- if (indent > 0 && nestedObject) {
63
- nestedObject[key] = parseYamlValue(value);
149
+ const blockScalarMatch = value.match(/^([|>])[-+]?\d*[-+]?$/);
150
+ if (blockScalarMatch) {
151
+ const style = blockScalarMatch[1] as '|' | '>';
152
+ const block = readBlockScalar(lines, index + 1, indent, style);
153
+ result[key] = block.content;
154
+ index = block.nextIndex;
64
155
  continue;
65
156
  }
66
157
 
67
- if (!value) {
68
- currentKey = normalizeKey(key);
69
- currentIndent = indent;
70
- nestedObject = {};
158
+ result[key] = String(parseYamlValue(value));
159
+ index += 1;
160
+ }
161
+
162
+ return { content: result, nextIndex: index };
163
+ }
164
+
165
+ function readIndentedScalar(
166
+ lines: string[],
167
+ startIndex: number,
168
+ parentIndent: number,
169
+ ): { content: string; nextIndex: number } {
170
+ const scalarLines: string[] = [];
171
+ let index = startIndex;
172
+ let contentIndent: number | null = null;
173
+
174
+ while (index < lines.length) {
175
+ const line = lines[index];
176
+ if (!line) {
177
+ scalarLines.push('');
178
+ index += 1;
179
+ continue;
180
+ }
181
+
182
+ if (!line.trim()) {
183
+ scalarLines.push('');
184
+ index += 1;
185
+ continue;
186
+ }
187
+
188
+ const indent = line.search(/\S/);
189
+ if (indent <= parentIndent) break;
190
+
191
+ contentIndent ??= indent;
192
+ scalarLines.push(line.slice(contentIndent));
193
+ index += 1;
194
+ }
195
+
196
+ return { content: foldBlockScalar(scalarLines), nextIndex: index };
197
+ }
198
+
199
+ function readBlockScalar(
200
+ lines: string[],
201
+ startIndex: number,
202
+ parentIndent: number,
203
+ style: '|' | '>',
204
+ ): { content: string; nextIndex: number } {
205
+ const blockLines: string[] = [];
206
+ let index = startIndex;
207
+ let contentIndent: number | null = null;
208
+
209
+ while (index < lines.length) {
210
+ const line = lines[index];
211
+ if (!line) {
212
+ blockLines.push('');
213
+ index += 1;
214
+ continue;
215
+ }
216
+
217
+ if (!line.trim()) {
218
+ blockLines.push('');
219
+ index += 1;
71
220
  continue;
72
221
  }
73
222
 
74
- result[normalizeKey(key)] = parseYamlValue(value);
223
+ const indent = line.search(/\S/);
224
+ if (indent <= parentIndent) break;
225
+
226
+ contentIndent ??= indent;
227
+ blockLines.push(line.slice(contentIndent));
228
+ index += 1;
75
229
  }
76
230
 
77
- if (nestedObject && currentKey) {
78
- result[currentKey] = nestedObject;
231
+ const content =
232
+ style === '>' ? foldBlockScalar(blockLines) : blockLines.join('\n').trim();
233
+ return { content, nextIndex: index };
234
+ }
235
+
236
+ function foldBlockScalar(lines: string[]): string {
237
+ const segments: string[] = [];
238
+ let current = '';
239
+
240
+ for (const line of lines) {
241
+ if (!line.trim()) {
242
+ if (current) {
243
+ segments.push(current.trim());
244
+ current = '';
245
+ }
246
+ continue;
247
+ }
248
+
249
+ current = current ? `${current} ${line.trim()}` : line.trim();
79
250
  }
80
251
 
81
- return result;
252
+ if (current) {
253
+ segments.push(current.trim());
254
+ }
255
+
256
+ return segments.join('\n');
82
257
  }
83
258
 
84
259
  function normalizeKey(key: string): string {
@@ -86,13 +261,29 @@ function normalizeKey(key: string): string {
86
261
  return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
87
262
  }
88
263
 
89
- function parseYamlValue(value: string): string {
264
+ function isNestedObjectLine(line: string): boolean {
265
+ return /^[A-Za-z0-9_-]+\s*:/.test(line);
266
+ }
267
+
268
+ function parseYamlValue(value: string): unknown {
90
269
  if (
91
270
  (value.startsWith('"') && value.endsWith('"')) ||
92
271
  (value.startsWith("'") && value.endsWith("'"))
93
272
  ) {
94
273
  return value.slice(1, -1);
95
274
  }
275
+
276
+ if (
277
+ (value.startsWith('{') && value.endsWith('}')) ||
278
+ (value.startsWith('[') && value.endsWith(']'))
279
+ ) {
280
+ try {
281
+ return JSON.parse(value) as unknown;
282
+ } catch {
283
+ return value;
284
+ }
285
+ }
286
+
96
287
  return value;
97
288
  }
98
289
 
@@ -120,32 +120,53 @@ async function loadSubFile(
120
120
 
121
121
  function buildSkillDescription(): string {
122
122
  if (cachedSkillList.length === 0) {
123
- return 'Load a skill by name. No skills are currently available.';
123
+ return 'Load a skill by name to get detailed, task-specific instructions. No skills are currently available.';
124
124
  }
125
125
 
126
- const skillsXml = cachedSkillList
126
+ // Dedupe by name — later scopes (cwd > repo > user) have already overwritten
127
+ // earlier ones in the loader, so the cached list is deduplicated. We re-dedupe
128
+ // defensively here in case the same name slipped through from different dirs.
129
+ const seen = new Set<string>();
130
+ const unique: DiscoveredSkill[] = [];
131
+ for (const s of cachedSkillList) {
132
+ const key = s.name.trim();
133
+ if (!key || seen.has(key)) continue;
134
+ seen.add(key);
135
+ unique.push(s);
136
+ }
137
+ unique.sort((a, b) => a.name.localeCompare(b.name));
138
+
139
+ const skillsXml = unique
127
140
  .map(
128
141
  (s) =>
129
- `<skill><name>${escapeXml(s.name)}</name><description>${escapeXml(s.description)}</description></skill>`,
142
+ `<skill><name>${escapeXml(s.name)}</name><description>${escapeXml(summarizeDescription(s.description))}</description><location>${escapeXml(s.scope)}</location></skill>`,
130
143
  )
131
144
  .join('\n');
132
145
 
133
- return `Load a skill by name to get detailed instructions.
146
+ return `Load a skill by name to get detailed, task-specific instructions.
134
147
 
135
- <available_skills>
136
- ${skillsXml}
137
- </available_skills>
148
+ <skills_instructions>
149
+ When the user's request matches one of the available skills below, call this tool with the skill name to load its full instructions. Skills provide specialized capabilities and domain knowledge.
138
150
 
139
- Call this tool with the skill name when you need the full instructions.
140
- If the skill references sub-files (e.g. rules/animations.md), load them with the \`file\` parameter:
141
- skill({ name: "skill-name", file: "rules/animations.md" })
151
+ How to use skills:
152
+ - Invoke with \`skill({ name: "<skill-name>" })\` only the name is required.
153
+ - The response contains the skill's full body plus \`availableFiles\` for sub-files.
154
+ - For sub-files: \`skill({ name: "<skill-name>", file: "rules/animations.md" })\`.
142
155
 
143
- The response includes \`availableFiles\` listing all loadable sub-files in the skill directory.
144
- If \`securityNotices\` are present, review them they flag hidden content (HTML comments, invisible characters, etc.) that may not be visible when reading the markdown normally.`;
156
+ Rules:
157
+ - Only invoke skills listed in <available_skills> below.
158
+ - Do NOT invoke speculatively. Only call when the user's request clearly matches a skill's description or trigger phrases.
159
+ - Do NOT invoke the same skill twice in one turn.
160
+ - If a skill response includes \`securityNotices\`, review them — they flag hidden content (HTML comments, invisible characters, etc.) that may not render visibly.
161
+ </skills_instructions>
162
+
163
+ <available_skills>
164
+ ${skillsXml}
165
+ </available_skills>`;
145
166
  }
146
167
 
147
168
  function escapeXml(str: string): string {
148
- return str
169
+ return String(str)
149
170
  .replace(/&/g, '&amp;')
150
171
  .replace(/</g, '&lt;')
151
172
  .replace(/>/g, '&gt;')
@@ -153,6 +174,63 @@ function escapeXml(str: string): string {
153
174
  .replace(/'/g, '&apos;');
154
175
  }
155
176
 
177
+ // Condense a SKILL.md description to "what it does + when to use it".
178
+ //
179
+ // Most frontmatter follows one of two shapes:
180
+ // A. "<what>. Use when <triggers>. Also use when … For X, see Y."
181
+ // B. "When the user wants X. Also use when <mega trigger list>. For Y, see Z."
182
+ //
183
+ // Rule: keep up to two sentences, but STOP early at cues that mark pure
184
+ // trigger-list expansion or see-also chatter ("Also use when …", "For X, see Y",
185
+ // "Use this …"). Sentence 1 is always kept. Sentence 2 is kept only if it's a
186
+ // "Use when …" clause (trigger phrases the model actually needs) — not an
187
+ // "Also use when" balloon.
188
+ const SENTENCE_END = /[.!?]\s/g;
189
+ const SKIP_PREFIXES = [
190
+ /^also use when\b/i,
191
+ /^for [^.]*?,?\s*see\b/i,
192
+ /^see\s+/i,
193
+ /^use this\b/i,
194
+ /^use proactively\b/i,
195
+ /^distinct from\b/i,
196
+ /^different from\b/i,
197
+ /^not for\b/i,
198
+ ];
199
+
200
+ function shouldSkipSentence(sentence: string): boolean {
201
+ const s = sentence.trim();
202
+ return SKIP_PREFIXES.some((re) => re.test(s));
203
+ }
204
+
205
+ export function summarizeDescription(raw: string): string {
206
+ const text = String(raw ?? '')
207
+ .replace(/\s+/g, ' ')
208
+ .trim();
209
+ if (!text) return '';
210
+
211
+ // Split into sentences, keeping terminal punctuation.
212
+ const sentences: string[] = [];
213
+ SENTENCE_END.lastIndex = 0;
214
+ let lastIndex = 0;
215
+ let match: RegExpExecArray | null;
216
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex iteration
217
+ while ((match = SENTENCE_END.exec(text)) !== null) {
218
+ sentences.push(text.slice(lastIndex, match.index + 1).trim());
219
+ lastIndex = match.index + match[0].length;
220
+ }
221
+ if (lastIndex < text.length) sentences.push(text.slice(lastIndex).trim());
222
+
223
+ if (sentences.length === 0) return text;
224
+
225
+ const kept: string[] = [sentences[0]]; // always keep sentence 1 (what)
226
+ // Keep sentence 2 only if it adds routing signal (e.g. starts with "Use when").
227
+ if (sentences[1] && !shouldSkipSentence(sentences[1])) {
228
+ kept.push(sentences[1]);
229
+ }
230
+
231
+ return kept.join(' ');
232
+ }
233
+
156
234
  export function rebuildSkillDescription(): string {
157
235
  return buildSkillDescription();
158
236
  }
@@ -8,7 +8,7 @@ export type Scope = 'global' | 'local';
8
8
  /**
9
9
  * Default settings for the CLI
10
10
  */
11
- export type ToolApprovalMode = 'auto' | 'dangerous' | 'all';
11
+ export type ToolApprovalMode = 'auto' | 'dangerous' | 'all' | 'yolo';
12
12
  export type ReasoningLevel =
13
13
  | 'minimal'
14
14
  | 'low'
@@ -27,6 +27,7 @@ export type DefaultConfig = {
27
27
  reasoningLevel?: ReasoningLevel;
28
28
  theme?: string;
29
29
  fullWidthContent?: boolean;
30
+ autoCompactThresholdTokens?: number | null;
30
31
  };
31
32
 
32
33
  export type ProviderSettings = Record<