@ottocode/sdk 0.1.272 → 0.1.274

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/sdk",
3
- "version": "0.1.272",
3
+ "version": "0.1.274",
4
4
  "description": "AI agent SDK for building intelligent assistants - tree-shakable and comprehensive",
5
5
  "author": "nitishxyz",
6
6
  "license": "MIT",
@@ -11,6 +11,19 @@ import {
11
11
  import { assertFreshRead, rememberFileWrite } from './read-tracker.ts';
12
12
  import { createToolError, type ToolResponse } from '../../error.ts';
13
13
 
14
+ const lineEndpointSchema = z.union([
15
+ z.number().int().min(1),
16
+ z.literal('end'),
17
+ z.literal('eof'),
18
+ ]);
19
+
20
+ const insertAtLineSchema = z.union([
21
+ z.number().int().min(1),
22
+ z.literal('append'),
23
+ z.literal('end'),
24
+ z.literal('eof'),
25
+ ]);
26
+
14
27
  const copyIntoSchema = z.object({
15
28
  sourcePath: z
16
29
  .string()
@@ -20,21 +33,16 @@ const copyIntoSchema = z.object({
20
33
  .int()
21
34
  .min(1)
22
35
  .describe('First source line to copy, 1-indexed and inclusive.'),
23
- endLine: z
24
- .number()
25
- .int()
26
- .min(1)
27
- .describe('Last source line to copy, 1-indexed and inclusive.'),
36
+ endLine: lineEndpointSchema.describe(
37
+ 'Last source line to copy, 1-indexed and inclusive. Use "end" or "eof" to copy through the end of the file.',
38
+ ),
28
39
  targetPath: z
29
40
  .string()
30
41
  .describe('Relative target file path within the project.'),
31
- insertAtLine: z
32
- .number()
33
- .int()
34
- .min(1)
42
+ insertAtLine: insertAtLineSchema
35
43
  .optional()
36
44
  .describe(
37
- 'Line to insert before, 1-indexed. Use totalLines + 1 to append.',
45
+ 'Line to insert before, 1-indexed. Use "append" to add to the end.',
38
46
  ),
39
47
  mode: z
40
48
  .enum(['insert_before', 'insert_after', 'replace_range'])
@@ -47,15 +55,15 @@ const copyIntoSchema = z.object({
47
55
  .min(1)
48
56
  .optional()
49
57
  .describe('First target line to replace when mode is replace_range.'),
50
- targetEndLine: z
51
- .number()
52
- .int()
53
- .min(1)
58
+ targetEndLine: lineEndpointSchema
54
59
  .optional()
55
- .describe('Last target line to replace when mode is replace_range.'),
60
+ .describe(
61
+ 'Last target line to replace when mode is replace_range. Use "end" or "eof" to replace through the end of the file.',
62
+ ),
56
63
  });
57
64
 
58
65
  type CopyIntoInput = z.infer<typeof copyIntoSchema>;
66
+ type LineEndpoint = z.infer<typeof lineEndpointSchema>;
59
67
 
60
68
  function splitLinesForEdit(content: string): {
61
69
  lines: string[];
@@ -83,27 +91,37 @@ function validateRelativePath(path: string, label: string): string | undefined {
83
91
  return undefined;
84
92
  }
85
93
 
94
+ function resolveEndLine(value: LineEndpoint, lineCount: number): number {
95
+ if (typeof value === 'number') return Math.min(value, lineCount);
96
+ return lineCount;
97
+ }
98
+
86
99
  function getLineRange(
87
100
  lines: string[],
88
101
  startLine: number,
89
- endLine: number,
90
- ): string[] {
91
- if (startLine > endLine) {
92
- throw new Error('startLine must be less than or equal to endLine.');
102
+ endLineInput: LineEndpoint,
103
+ ): { copied: string[]; endLine: number } {
104
+ if (startLine > lines.length) {
105
+ throw new Error(
106
+ `Source start line ${startLine} exceeds source file length (${lines.length} lines). Use read to confirm line numbers first.`,
107
+ );
93
108
  }
94
- if (endLine > lines.length) {
109
+
110
+ const endLine = resolveEndLine(endLineInput, lines.length);
111
+ if (startLine > endLine) {
95
112
  throw new Error(
96
- `Source range ${startLine}-${endLine} exceeds source file length (${lines.length} lines).`,
113
+ `startLine must be less than or equal to endLine. Source file has ${lines.length} lines; use endLine: "end" to copy through EOF.`,
97
114
  );
98
115
  }
99
- return lines.slice(startLine - 1, endLine);
116
+
117
+ return { copied: lines.slice(startLine - 1, endLine), endLine };
100
118
  }
101
119
 
102
120
  function applyCopiedLines(
103
121
  input: CopyIntoInput,
104
122
  targetLines: string[],
105
123
  copied: string[],
106
- ): string[] {
124
+ ): { lines: string[]; targetRange: string } {
107
125
  const mode = input.mode ?? 'insert_before';
108
126
  if (mode === 'replace_range') {
109
127
  if (
@@ -114,21 +132,30 @@ function applyCopiedLines(
114
132
  'targetStartLine and targetEndLine are required when mode is replace_range.',
115
133
  );
116
134
  }
117
- if (input.targetStartLine > input.targetEndLine) {
135
+ if (input.targetStartLine > targetLines.length) {
118
136
  throw new Error(
119
- 'targetStartLine must be less than or equal to targetEndLine.',
137
+ `Target start line ${input.targetStartLine} exceeds target file length (${targetLines.length} lines). Use insertAtLine: "append" to append instead.`,
120
138
  );
121
139
  }
122
- if (input.targetEndLine > targetLines.length) {
140
+
141
+ const targetEndLine = resolveEndLine(
142
+ input.targetEndLine,
143
+ targetLines.length,
144
+ );
145
+ if (input.targetStartLine > targetEndLine) {
123
146
  throw new Error(
124
- `Target range ${input.targetStartLine}-${input.targetEndLine} exceeds target file length (${targetLines.length} lines).`,
147
+ `targetStartLine must be less than or equal to targetEndLine. Target file has ${targetLines.length} lines; use targetEndLine: "end" to replace through EOF.`,
125
148
  );
126
149
  }
127
- return [
128
- ...targetLines.slice(0, input.targetStartLine - 1),
129
- ...copied,
130
- ...targetLines.slice(input.targetEndLine),
131
- ];
150
+
151
+ return {
152
+ lines: [
153
+ ...targetLines.slice(0, input.targetStartLine - 1),
154
+ ...copied,
155
+ ...targetLines.slice(targetEndLine),
156
+ ],
157
+ targetRange: `${input.targetStartLine}-${targetEndLine}`,
158
+ };
132
159
  }
133
160
 
134
161
  if (input.insertAtLine === undefined) {
@@ -136,25 +163,31 @@ function applyCopiedLines(
136
163
  'insertAtLine is required for insert_before and insert_after modes.',
137
164
  );
138
165
  }
139
- if (input.insertAtLine > targetLines.length + 1) {
140
- throw new Error(
141
- `insertAtLine ${input.insertAtLine} exceeds append position (${targetLines.length + 1}).`,
142
- );
143
- }
144
166
 
145
- const insertIndex =
146
- mode === 'insert_after' ? input.insertAtLine : input.insertAtLine - 1;
147
- if (insertIndex > targetLines.length) {
167
+ const insertAtLine =
168
+ typeof input.insertAtLine === 'number'
169
+ ? Math.min(
170
+ input.insertAtLine,
171
+ mode === 'insert_after' ? targetLines.length : targetLines.length + 1,
172
+ )
173
+ : mode === 'insert_after'
174
+ ? targetLines.length
175
+ : targetLines.length + 1;
176
+ const insertIndex = mode === 'insert_after' ? insertAtLine : insertAtLine - 1;
177
+ if (insertIndex < 0 || insertIndex > targetLines.length) {
148
178
  throw new Error(
149
- `insertAtLine ${input.insertAtLine} with insert_after exceeds target file length (${targetLines.length} lines).`,
179
+ `insertAtLine ${String(input.insertAtLine)} is outside the target file. Use insertAtLine: "append" to append.`,
150
180
  );
151
181
  }
152
182
 
153
- return [
154
- ...targetLines.slice(0, insertIndex),
155
- ...copied,
156
- ...targetLines.slice(insertIndex),
157
- ];
183
+ return {
184
+ lines: [
185
+ ...targetLines.slice(0, insertIndex),
186
+ ...copied,
187
+ ...targetLines.slice(insertIndex),
188
+ ],
189
+ targetRange: `${insertAtLine}`,
190
+ };
158
191
  }
159
192
 
160
193
  export function buildCopyIntoTool(projectRoot: string): {
@@ -168,6 +201,9 @@ export function buildCopyIntoTool(projectRoot: string): {
168
201
  ToolResponse<{
169
202
  sourcePath: string;
170
203
  targetPath: string;
204
+ sourceRange: string;
205
+ targetRange: string;
206
+ mode: string;
171
207
  linesCopied: number;
172
208
  bytes: number;
173
209
  artifact: unknown;
@@ -206,15 +242,19 @@ export function buildCopyIntoTool(projectRoot: string): {
206
242
  readFile(targetAbs, 'utf-8'),
207
243
  ]);
208
244
  const source = splitLinesForEdit(sourceContent);
209
- const copiedLines = getLineRange(
245
+ const sourceRange = getLineRange(
210
246
  source.lines,
211
247
  input.startLine,
212
248
  input.endLine,
213
249
  );
214
250
  const target = splitLinesForEdit(targetContent);
215
- const nextLines = applyCopiedLines(input, target.lines, copiedLines);
251
+ const applied = applyCopiedLines(
252
+ input,
253
+ target.lines,
254
+ sourceRange.copied,
255
+ );
216
256
  const nextNormalized = joinLinesForEdit(
217
- nextLines,
257
+ applied.lines,
218
258
  target.trailingNewline,
219
259
  );
220
260
  const nextContent = convertToLineEnding(
@@ -241,7 +281,10 @@ export function buildCopyIntoTool(projectRoot: string): {
241
281
  ok: true,
242
282
  sourcePath: input.sourcePath,
243
283
  targetPath: input.targetPath,
244
- linesCopied: copiedLines.length,
284
+ sourceRange: `${input.startLine}-${sourceRange.endLine}`,
285
+ targetRange: applied.targetRange,
286
+ mode: input.mode ?? 'insert_before',
287
+ linesCopied: sourceRange.copied.length,
245
288
  bytes: nextContent.length,
246
289
  artifact,
247
290
  };
@@ -6,6 +6,9 @@ Rules:
6
6
  - Source and target paths must be relative paths within the project.
7
7
  - You must read the target file first in the current session before modifying it.
8
8
  - Line numbers are 1-indexed and inclusive.
9
- - `insertAtLine` inserts before that line. Use `insertAtLine: totalLines + 1` to append.
9
+ - `endLine` may be a line number, `"end"`, or `"eof"`. Prefer `"end"` over guessed sentinel values like `999`.
10
+ - `insertAtLine` inserts before that line. Use `"append"` to add to the end.
10
11
  - Use `mode: "replace_range"` with `targetStartLine` and `targetEndLine` to replace target lines.
12
+ - `targetEndLine` may be a line number, `"end"`, or `"eof"`.
13
+ - If an end line is past EOF, it is treated as EOF. Start lines must still exist.
11
14
  - This tool does not use the system clipboard.
@@ -2,11 +2,7 @@ You are a research assistant with access to session history and codebase search
2
2
 
3
3
  ## Primary Job
4
4
 
5
- Help users find information from past sessions and the codebase.
6
-
7
- ## Critical: "This Session" Means Parent Session
8
-
9
- When the user refers to "this session", they mean the PARENT SESSION, not this research chat. **ALWAYS call `get_parent_session` FIRST** for these questions.
5
+ Help users find information from past sessions, session history, and the codebase.
10
6
 
11
7
  ## Database Structure
12
8
 
@@ -16,12 +12,11 @@ When the user refers to "this session", they mean the PARENT SESSION, not this r
16
12
 
17
13
  ## Available Tools
18
14
 
19
- 1. **get_parent_session** - USE FIRST for "this session" questions. Returns parent session's messages.
20
- 2. **get_session_context** - Get details about ANY session by ID.
21
- 3. **query_sessions** - List/search sessions by agent, type, date range.
22
- 4. **query_messages** - Search messages across sessions.
23
- 5. **search_history** - Full-text search across ALL message content.
24
- 6. **present_action** - Present clickable session links at the end.
15
+ 1. **search_history** - Full-text search across message content.
16
+ 2. **query_sessions** - List/search sessions by agent, type, date range.
17
+ 3. **query_messages** - Search messages across sessions.
18
+ 4. **get_session_context** - Get details about a specific session by ID.
19
+ 5. **present_action** - Present clickable session links when relevant.
25
20
 
26
21
  ## Codebase Tools
27
22
 
@@ -29,22 +24,24 @@ When the user refers to "this session", they mean the PARENT SESSION, not this r
29
24
 
30
25
  ## Research Strategy
31
26
 
32
- **"What did we do" questions:**
33
- 1. Call `get_parent_session`
34
- 2. Summarize key activities
35
-
36
27
  **"Find past work on X" questions:**
37
- 1. Use `search_history` with keywords
38
- 2. Then `get_session_context` on promising sessions
28
+ 1. Use `search_history` with focused keywords.
29
+ 2. Use `query_sessions` or `query_messages` to narrow by agent, dates, or tool usage.
30
+ 3. Use `get_session_context` on promising sessions.
31
+
32
+ **"What did we do" questions:**
33
+ 1. Search session history for the topic the user mentions.
34
+ 2. Open the most relevant sessions.
35
+ 3. Summarize concrete activities and outcomes.
39
36
 
40
37
  **"What tools were used" questions:**
41
- 1. Call `get_parent_session`
42
- 2. Look at `toolCalls` in response
38
+ 1. Use `query_messages` with `toolName` when the tool is known.
39
+ 2. Use `get_session_context` for relevant sessions to inspect tool calls.
43
40
 
44
41
  ## Response Guidelines
45
42
 
46
- 1. Be specific - quote actual content
47
- 2. Cite sources - reference session IDs and timestamps
48
- 3. Summarize clearly - findings may be injected into another session
49
- 4. Don't hallucinate - only report what you find
50
- 5. **Use `present_action`** at the end with links to relevant sessions
43
+ 1. Be specific - quote actual content when helpful.
44
+ 2. Cite sources - reference session titles, IDs, and timestamps.
45
+ 3. Summarize clearly and avoid over-explaining.
46
+ 4. Don't hallucinate - only report what you find.
47
+ 5. Use `present_action` only when session links would help the user navigate.
@@ -31,7 +31,7 @@ export type OpenAIOAuthConfig = {
31
31
  };
32
32
 
33
33
  function shouldDebugOpenAIOAuth() {
34
- return false;
34
+ return process.env.OTTO_OPENAI_OAUTH_DEBUG === '1';
35
35
  }
36
36
 
37
37
  function logOpenAIOAuth(message: string) {
@@ -40,6 +40,45 @@ function logOpenAIOAuth(message: string) {
40
40
  }
41
41
  }
42
42
 
43
+ function summarizeError(error: unknown): Record<string, unknown> {
44
+ if (error instanceof Error) {
45
+ return { name: error.name, message: error.message };
46
+ }
47
+ if (error && typeof error === 'object') {
48
+ const err = error as Record<string, unknown>;
49
+ return {
50
+ name: typeof err.name === 'string' ? err.name : undefined,
51
+ message: typeof err.message === 'string' ? err.message : undefined,
52
+ code: typeof err.code === 'string' ? err.code : undefined,
53
+ };
54
+ }
55
+ return { message: String(error) };
56
+ }
57
+
58
+ function getBodySize(body: unknown): number | undefined {
59
+ if (typeof body === 'string') return body.length;
60
+ if (body instanceof URLSearchParams) return body.toString().length;
61
+ if (body instanceof Blob) return body.size;
62
+ if (body instanceof ArrayBuffer) return body.byteLength;
63
+ if (ArrayBuffer.isView(body)) return body.byteLength;
64
+ return undefined;
65
+ }
66
+
67
+ async function previewResponseBody(
68
+ response: Response,
69
+ ): Promise<string | undefined> {
70
+ try {
71
+ const text = await response.clone().text();
72
+ const normalized = text.replace(/\s+/g, ' ').trim();
73
+ if (!normalized) return undefined;
74
+ return normalized.length > 500
75
+ ? `${normalized.slice(0, 500)}…`
76
+ : normalized;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
43
82
  function shouldUsePreviousResponseId() {
44
83
  return process.env.OTTO_OPENAI_OAUTH_PREVIOUS_RESPONSE_ID === '1';
45
84
  }
@@ -335,6 +374,7 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
335
374
  input: Parameters<typeof fetch>[0],
336
375
  init?: Parameters<typeof fetch>[1],
337
376
  ): Promise<Response> => {
377
+ const requestStartedAt = Date.now();
338
378
  const validated = await ensureValidToken(currentOAuth, config.projectRoot);
339
379
  currentOAuth = validated.oauth;
340
380
 
@@ -373,18 +413,70 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
373
413
  validated.accountId,
374
414
  config.sessionId,
375
415
  );
416
+ const requestBodySize = getBodySize(requestInit?.body);
417
+ const method = requestInit?.method ?? 'POST';
418
+ loggerDebug('[openai-oauth] request start', {
419
+ sessionId: config.sessionId,
420
+ target: isResponsesRequest ? 'codex.responses' : 'other',
421
+ method,
422
+ bodyCharsApprox: requestBodySize,
423
+ model: requestModel,
424
+ });
376
425
 
377
- const response = await fetch(targetUrl, {
378
- ...requestInit,
379
- headers,
380
- // @ts-expect-error Bun-specific fetch option
381
- timeout: false,
426
+ let response: Response;
427
+ try {
428
+ response = await fetch(targetUrl, {
429
+ ...requestInit,
430
+ headers,
431
+ // @ts-expect-error Bun-specific fetch option
432
+ timeout: false,
433
+ });
434
+ } catch (error) {
435
+ loggerWarn('[openai-oauth] request failed before response', {
436
+ sessionId: config.sessionId,
437
+ target: isResponsesRequest ? 'codex.responses' : 'other',
438
+ method,
439
+ bodyCharsApprox: requestBodySize,
440
+ model: requestModel,
441
+ durationMs: Date.now() - requestStartedAt,
442
+ error: summarizeError(error),
443
+ });
444
+ throw error;
445
+ }
446
+ loggerDebug('[openai-oauth] response received', {
447
+ sessionId: config.sessionId,
448
+ target: isResponsesRequest ? 'codex.responses' : 'other',
449
+ status: response.status,
450
+ statusText: response.statusText,
451
+ ok: response.ok,
452
+ durationMs: Date.now() - requestStartedAt,
453
+ bodyCharsApprox: requestBodySize,
454
+ model: requestModel,
382
455
  });
456
+ if (!response.ok && response.status !== 401) {
457
+ loggerWarn('[openai-oauth] non-OK response', {
458
+ sessionId: config.sessionId,
459
+ target: isResponsesRequest ? 'codex.responses' : 'other',
460
+ status: response.status,
461
+ statusText: response.statusText,
462
+ durationMs: Date.now() - requestStartedAt,
463
+ bodyCharsApprox: requestBodySize,
464
+ model: requestModel,
465
+ bodyPreview: await previewResponseBody(response),
466
+ });
467
+ }
383
468
  const trackedResponse = isResponsesRequest
384
469
  ? trackResponsesStream(response, config.sessionId)
385
470
  : response;
386
471
 
387
472
  if (response.status === 401) {
473
+ loggerWarn('[openai-oauth] 401 response, refreshing token and retrying', {
474
+ sessionId: config.sessionId,
475
+ target: isResponsesRequest ? 'codex.responses' : 'other',
476
+ durationMs: Date.now() - requestStartedAt,
477
+ bodyCharsApprox: requestBodySize,
478
+ model: requestModel,
479
+ });
388
480
  try {
389
481
  const refreshedFromDisk = await getAuth('openai', config.projectRoot);
390
482
  if (
@@ -406,18 +498,48 @@ export function createOpenAIOAuthFetch(config: OpenAIOAuthConfig) {
406
498
  config.sessionId,
407
499
  );
408
500
 
501
+ const retryStartedAt = Date.now();
409
502
  const retryResponse = await fetch(targetUrl, {
410
503
  ...requestInit,
411
504
  headers: retryHeaders,
412
505
  // @ts-expect-error Bun-specific fetch option
413
506
  timeout: false,
414
507
  });
508
+ loggerDebug('[openai-oauth] retry response received', {
509
+ sessionId: config.sessionId,
510
+ target: isResponsesRequest ? 'codex.responses' : 'other',
511
+ status: retryResponse.status,
512
+ statusText: retryResponse.statusText,
513
+ ok: retryResponse.ok,
514
+ durationMs: Date.now() - retryStartedAt,
515
+ bodyCharsApprox: requestBodySize,
516
+ model: requestModel,
517
+ });
518
+ if (!retryResponse.ok) {
519
+ loggerWarn('[openai-oauth] retry non-OK response', {
520
+ sessionId: config.sessionId,
521
+ target: isResponsesRequest ? 'codex.responses' : 'other',
522
+ status: retryResponse.status,
523
+ statusText: retryResponse.statusText,
524
+ durationMs: Date.now() - retryStartedAt,
525
+ bodyCharsApprox: requestBodySize,
526
+ model: requestModel,
527
+ bodyPreview: await previewResponseBody(retryResponse),
528
+ });
529
+ }
415
530
  return isResponsesRequest
416
531
  ? trackResponsesStream(retryResponse, config.sessionId)
417
532
  : retryResponse;
418
- } catch {
419
- console.error(
533
+ } catch (error) {
534
+ loggerWarn(
420
535
  '[openai-oauth] 401 retry failed, returning original 401 response',
536
+ {
537
+ sessionId: config.sessionId,
538
+ target: isResponsesRequest ? 'codex.responses' : 'other',
539
+ bodyCharsApprox: requestBodySize,
540
+ model: requestModel,
541
+ error: summarizeError(error),
542
+ },
421
543
  );
422
544
  return response;
423
545
  }