@ottocode/sdk 0.1.313 → 0.1.315

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/config/src/index.ts +38 -8
  3. package/src/config/src/manager.ts +2 -0
  4. package/src/config/src/paths.ts +221 -3
  5. package/src/core/src/providers/resolver.ts +1 -2
  6. package/src/core/src/tools/builtin/fs/copy-attachment.ts +28 -13
  7. package/src/core/src/tools/builtin/fs/edit.txt +1 -1
  8. package/src/core/src/tools/builtin/fs/index.ts +2 -0
  9. package/src/core/src/tools/builtin/fs/multiedit.txt +1 -1
  10. package/src/core/src/tools/builtin/git-identity.ts +37 -3
  11. package/src/core/src/tools/builtin/git.ts +8 -2
  12. package/src/core/src/tools/builtin/glob.txt +4 -0
  13. package/src/core/src/tools/builtin/patch/indentation.ts +8 -1
  14. package/src/core/src/tools/builtin/patch/normalize.ts +4 -0
  15. package/src/core/src/tools/builtin/patch/repair.ts +42 -0
  16. package/src/core/src/tools/builtin/patch.txt +2 -0
  17. package/src/core/src/tools/builtin/search.txt +4 -0
  18. package/src/core/src/tools/builtin/shell.ts +54 -12
  19. package/src/core/src/tools/builtin/shell.txt +5 -0
  20. package/src/core/src/tools/builtin/terminal.ts +11 -3
  21. package/src/core/src/tools/loader.ts +8 -4
  22. package/src/index.ts +17 -5
  23. package/src/prompts/src/agents/build.txt +2 -2
  24. package/src/prompts/src/providers/{moonshot.txt → kimi.txt} +1 -1
  25. package/src/prompts/src/providers.ts +5 -5
  26. package/src/providers/src/catalog-manual.ts +53 -8
  27. package/src/providers/src/catalog.ts +74 -34
  28. package/src/providers/src/env.ts +4 -7
  29. package/src/providers/src/index.ts +3 -9
  30. package/src/providers/src/{moonshot-client.ts → kimi-client.ts} +131 -15
  31. package/src/providers/src/model-merge.ts +7 -1
  32. package/src/providers/src/pricing.ts +1 -1
  33. package/src/providers/src/registry.ts +8 -19
  34. package/src/providers/src/utils.ts +11 -8
  35. package/src/types/src/config.ts +12 -1
  36. package/src/types/src/provider.ts +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/sdk",
3
- "version": "0.1.313",
3
+ "version": "0.1.315",
4
4
  "description": "AI agent SDK for building intelligent assistants - tree-shakable and comprehensive",
5
5
  "author": "nitishxyz",
6
6
  "license": "MIT",
@@ -1,7 +1,10 @@
1
1
  import {
2
+ getProjectConfigDir,
3
+ getProjectConfigPath,
4
+ getProjectStateDir,
5
+ getLegacyProjectDataDir,
2
6
  getGlobalConfigPath,
3
7
  getGlobalSkillsConfigPath,
4
- getLocalDataDir,
5
8
  ensureDir,
6
9
  fileExists,
7
10
  joinPath,
@@ -22,7 +25,7 @@ const DEFAULT_PROVIDER_SETTINGS: OttoConfig['providers'] = {
22
25
  xai: { enabled: false },
23
26
  zai: { enabled: false },
24
27
  'zai-coding': { enabled: false },
25
- moonshot: { enabled: false },
28
+ kimi: { enabled: false },
26
29
  minimax: { enabled: false },
27
30
  };
28
31
 
@@ -39,6 +42,7 @@ const DEFAULTS: {
39
42
  reasoningText: true,
40
43
  reasoningLevel: 'high',
41
44
  theme: 'dark',
45
+ tuiTheme: 'tokyo-night',
42
46
  vimMode: false,
43
47
  compactThread: true,
44
48
  fontFamily: 'IBM Plex Mono',
@@ -46,6 +50,7 @@ const DEFAULTS: {
46
50
  releaseToSend: false,
47
51
  fullWidthContent: false,
48
52
  autoCompactThresholdTokens: null,
53
+ coAuthorCommits: false,
49
54
  ottoEnabled: true,
50
55
  },
51
56
  providers: DEFAULT_PROVIDER_SETTINGS,
@@ -64,9 +69,21 @@ export async function loadConfig(
64
69
  ? String(projectRootInput)
65
70
  : process.cwd();
66
71
 
67
- const dataDir = getLocalDataDir(projectRoot);
68
- const dbPath = joinPath(dataDir, 'otto.sqlite');
69
- const projectConfigPath = joinPath(dataDir, 'config.json');
72
+ const projectConfigDir = getProjectConfigDir(projectRoot);
73
+ const projectConfigPath = getProjectConfigPath(projectRoot);
74
+ const projectStateDir = await getProjectStateDir(projectRoot);
75
+ const dataDir = projectStateDir;
76
+ const dbPath = joinPath(projectStateDir, 'otto.sqlite');
77
+ const attachmentsDir = joinPath(projectStateDir, 'attachments');
78
+ const debugDir = joinPath(projectStateDir, 'debug');
79
+ const debugDumpsDir = joinPath(projectStateDir, 'debug-dumps');
80
+ const logsDir = joinPath(projectStateDir, 'logs');
81
+ const tmpDir = joinPath(projectStateDir, 'tmp');
82
+ const cacheDir = joinPath(projectStateDir, 'cache');
83
+ const legacyDbPath = joinPath(
84
+ getLegacyProjectDataDir(projectRoot),
85
+ 'otto.sqlite',
86
+ );
70
87
  const globalConfigPath = getGlobalConfigPath();
71
88
  const globalSkillsConfigPath = getGlobalSkillsConfigPath();
72
89
 
@@ -81,7 +98,12 @@ export async function loadConfig(
81
98
  filterProjectConfig(projectCfg),
82
99
  );
83
100
 
84
- await ensureDir(dataDir);
101
+ await ensureDir(projectStateDir);
102
+ if ((await fileExists(legacyDbPath)) && !(await fileExists(dbPath))) {
103
+ console.warn(
104
+ `Legacy Otto database found at ${legacyDbPath}. Run: otto storage migrate`,
105
+ );
106
+ }
85
107
 
86
108
  return {
87
109
  projectRoot,
@@ -89,11 +111,19 @@ export async function loadConfig(
89
111
  providers: merged.providers as OttoConfig['providers'],
90
112
  skills: merged.skills as OttoConfig['skills'],
91
113
  paths: {
92
- dataDir,
93
- dbPath,
114
+ projectConfigDir,
94
115
  projectConfigPath: (await fileExists(projectConfigPath))
95
116
  ? projectConfigPath
96
117
  : null,
118
+ projectStateDir,
119
+ dataDir,
120
+ dbPath,
121
+ attachmentsDir,
122
+ debugDir,
123
+ debugDumpsDir,
124
+ logsDir,
125
+ tmpDir,
126
+ cacheDir,
97
127
  globalConfigPath: (await fileExists(globalConfigPath))
98
128
  ? globalConfigPath
99
129
  : null,
@@ -86,6 +86,7 @@ export async function writeDefaults(
86
86
  reasoningText: boolean;
87
87
  reasoningLevel: 'minimal' | 'low' | 'medium' | 'high' | 'max' | 'xhigh';
88
88
  theme: string;
89
+ tuiTheme: string;
89
90
  vimMode: boolean;
90
91
  compactThread: boolean;
91
92
  fontFamily: string;
@@ -93,6 +94,7 @@ export async function writeDefaults(
93
94
  releaseToSend: boolean;
94
95
  fullWidthContent: boolean;
95
96
  autoCompactThresholdTokens: number | null;
97
+ coAuthorCommits: boolean;
96
98
  ottoEnabled: boolean;
97
99
  }>,
98
100
  projectRoot?: string,
@@ -110,18 +110,236 @@ export function getSessionSystemPromptPath(sessionId: string): string {
110
110
  );
111
111
  }
112
112
 
113
- export function getLocalDataDir(projectRoot: string): string {
113
+ /** Resolve the user-level Otto home directory for project state storage. */
114
+ export function getOttoHomeDir(): string {
115
+ const ottoHome = process.env.OTTO_HOME;
116
+ if (ottoHome?.trim()) return ottoHome.replace(/\\/g, '/');
117
+ if (process.platform === 'win32') {
118
+ const appData = (process.env.APPDATA || '').replace(/\\/g, '/');
119
+ const base = appData || joinPath(getHomeDir(), 'AppData', 'Roaming');
120
+ return joinPath(base, 'otto');
121
+ }
122
+ return joinPath(getHomeDir(), '.local', 'state', 'otto');
123
+ }
124
+
125
+ /** Resolve the root directory containing per-project state directories. */
126
+ export function getProjectsStateRoot(): string {
127
+ return joinPath(getOttoHomeDir(), 'projects');
128
+ }
129
+
130
+ const projectIdCache = new Map<string, Promise<string>>();
131
+
132
+ /** Build a stable readable project ID from basename and git remote/path hash. */
133
+ export async function getProjectId(projectRoot: string): Promise<string> {
134
+ const normalizedProjectRoot = projectRoot.replace(/\\/g, '/');
135
+ const cached = projectIdCache.get(normalizedProjectRoot);
136
+ if (cached) return cached;
137
+
138
+ const projectId = resolveProjectId(normalizedProjectRoot);
139
+ projectIdCache.set(normalizedProjectRoot, projectId);
140
+ return projectId;
141
+ }
142
+
143
+ async function resolveProjectId(
144
+ normalizedProjectRoot: string,
145
+ ): Promise<string> {
146
+ const slug = sanitizeProjectSlug(getPathBasename(normalizedProjectRoot));
147
+ const hashInput =
148
+ (await readGitRemoteUrl(normalizedProjectRoot)) ??
149
+ (await getCanonicalProjectRoot(normalizedProjectRoot));
150
+ const hash = (await sha256Hex(hashInput)).slice(0, 8);
151
+ return `${slug}-${hash}`;
152
+ }
153
+
154
+ /** Resolve the repository-local Otto project config directory. */
155
+ export function getProjectConfigDir(projectRoot: string): string {
114
156
  return joinPath(projectRoot, '.otto');
115
157
  }
116
158
 
159
+ /** Resolve the repository-local Otto project config file path. */
160
+ export function getProjectConfigPath(projectRoot: string): string {
161
+ return joinPath(getProjectConfigDir(projectRoot), 'config.json');
162
+ }
163
+
164
+ /** Resolve the user-level state directory for a project. */
165
+ export async function getProjectStateDir(projectRoot: string): Promise<string> {
166
+ return joinPath(getProjectsStateRoot(), await getProjectId(projectRoot));
167
+ }
168
+
169
+ /** Resolve the project SQLite database path under user-level state. */
170
+ export async function getProjectDbPath(projectRoot: string): Promise<string> {
171
+ return joinPath(await getProjectStateDir(projectRoot), 'otto.sqlite');
172
+ }
173
+
174
+ /** Resolve the project attachments directory under user-level state. */
175
+ export async function getProjectAttachmentsDir(
176
+ projectRoot: string,
177
+ ): Promise<string> {
178
+ return joinPath(await getProjectStateDir(projectRoot), 'attachments');
179
+ }
180
+
181
+ /** Resolve the project debug directory under user-level state. */
182
+ export async function getProjectDebugDir(projectRoot: string): Promise<string> {
183
+ return joinPath(await getProjectStateDir(projectRoot), 'debug');
184
+ }
185
+
186
+ /** Resolve the project debug dumps directory under user-level state. */
187
+ export async function getProjectDebugDumpsDir(
188
+ projectRoot: string,
189
+ ): Promise<string> {
190
+ return joinPath(await getProjectStateDir(projectRoot), 'debug-dumps');
191
+ }
192
+
193
+ /** Resolve the project logs directory under user-level state. */
194
+ export async function getProjectLogsDir(projectRoot: string): Promise<string> {
195
+ return joinPath(await getProjectStateDir(projectRoot), 'logs');
196
+ }
197
+
198
+ /** Resolve the project temporary files directory under user-level state. */
199
+ export async function getProjectTmpDir(projectRoot: string): Promise<string> {
200
+ return joinPath(await getProjectStateDir(projectRoot), 'tmp');
201
+ }
202
+
203
+ /** Resolve the project cache directory under user-level state. */
204
+ export async function getProjectCacheDir(projectRoot: string): Promise<string> {
205
+ return joinPath(await getProjectStateDir(projectRoot), 'cache');
206
+ }
207
+
208
+ /** Resolve the legacy repository-local data directory used before migration. */
209
+ export function getLegacyProjectDataDir(projectRoot: string): string {
210
+ return joinPath(projectRoot, '.otto');
211
+ }
212
+
213
+ /** @deprecated Use getLegacyProjectDataDir() for legacy project-local data. */
214
+ export function getLocalDataDir(projectRoot: string): string {
215
+ return getLegacyProjectDataDir(projectRoot);
216
+ }
217
+
218
+ function sanitizeProjectSlug(name: string): string {
219
+ return name.replace(/[^a-zA-Z0-9._-]+/g, '-') || 'project';
220
+ }
221
+
222
+ function getPathBasename(path: string): string {
223
+ const trimmed = path.replace(/\/+$/g, '');
224
+ const index = trimmed.lastIndexOf('/');
225
+ return index >= 0 ? trimmed.slice(index + 1) : trimmed;
226
+ }
227
+
228
+ async function sha256Hex(input: string): Promise<string> {
229
+ const subtle = globalThis.crypto?.subtle;
230
+ if (subtle) {
231
+ const encoded = new TextEncoder().encode(input);
232
+ const digest = await subtle.digest('SHA-256', encoded);
233
+ return Array.from(new Uint8Array(digest), (byte) =>
234
+ byte.toString(16).padStart(2, '0'),
235
+ ).join('');
236
+ }
237
+
238
+ const { createHash } = await loadCrypto();
239
+ return createHash('sha256').update(input).digest('hex');
240
+ }
241
+
242
+ async function getCanonicalProjectRoot(projectRoot: string): Promise<string> {
243
+ try {
244
+ const { realpath } = await loadFsPromises();
245
+ return (await realpath(projectRoot)).replace(/\\/g, '/');
246
+ } catch {
247
+ return projectRoot.replace(/\\/g, '/');
248
+ }
249
+ }
250
+
251
+ async function readGitRemoteUrl(
252
+ projectRoot: string,
253
+ ): Promise<string | undefined> {
254
+ const currentBranchRemote = await readCurrentBranchRemote(projectRoot);
255
+ const remoteNames = currentBranchRemote
256
+ ? [currentBranchRemote, 'origin']
257
+ : ['origin'];
258
+
259
+ for (const remoteName of remoteNames) {
260
+ const remoteUrl = await readGitConfigValue(
261
+ projectRoot,
262
+ `remote.${remoteName}.url`,
263
+ );
264
+ if (remoteUrl) return remoteUrl;
265
+ }
266
+
267
+ const remoteList = await readGitConfigValues(
268
+ projectRoot,
269
+ 'config',
270
+ '--get-regexp',
271
+ 'remote\\..*\\.url',
272
+ );
273
+ return remoteList
274
+ .map((line) => line.trim().split(/\s+/, 2)[1])
275
+ .find((url) => url?.trim());
276
+ }
277
+
278
+ async function readCurrentBranchRemote(
279
+ projectRoot: string,
280
+ ): Promise<string | undefined> {
281
+ const branch = await readGitConfigValues(
282
+ projectRoot,
283
+ 'rev-parse',
284
+ '--abbrev-ref',
285
+ 'HEAD',
286
+ );
287
+ const branchName = branch[0];
288
+ if (!branchName || branchName === 'HEAD') return undefined;
289
+ return readGitConfigValue(projectRoot, `branch.${branchName}.remote`);
290
+ }
291
+
292
+ async function readGitConfigValue(
293
+ projectRoot: string,
294
+ key: string,
295
+ ): Promise<string | undefined> {
296
+ const values = await readGitConfigValues(projectRoot, 'config', '--get', key);
297
+ return values[0];
298
+ }
299
+
300
+ async function readGitConfigValues(
301
+ projectRoot: string,
302
+ ...args: string[]
303
+ ): Promise<string[]> {
304
+ try {
305
+ const { execFile } = await loadChildProcess();
306
+ const { promisify } = await loadUtil();
307
+ const execFileAsync = promisify(execFile);
308
+ const { stdout } = await execFileAsync('git', ['-C', projectRoot, ...args]);
309
+ const output = String(stdout);
310
+ return output
311
+ .split('\n')
312
+ .map((line) => line.trim())
313
+ .filter(Boolean);
314
+ } catch {
315
+ return [];
316
+ }
317
+ }
318
+
117
319
  async function loadFsPromises(): Promise<typeof import('node:fs/promises')> {
118
320
  return Function('specifier', 'return import(specifier)')('node:fs/promises');
119
321
  }
120
322
 
323
+ async function loadChildProcess(): Promise<
324
+ typeof import('node:child_process')
325
+ > {
326
+ return Function(
327
+ 'specifier',
328
+ 'return import(specifier)',
329
+ )('node:child_process');
330
+ }
331
+
332
+ async function loadUtil(): Promise<typeof import('node:util')> {
333
+ return Function('specifier', 'return import(specifier)')('node:util');
334
+ }
335
+
336
+ async function loadCrypto(): Promise<typeof import('node:crypto')> {
337
+ return Function('specifier', 'return import(specifier)')('node:crypto');
338
+ }
339
+
121
340
  export async function ensureDir(dir: string) {
122
- const { mkdir, writeFile } = await loadFsPromises();
341
+ const { mkdir } = await loadFsPromises();
123
342
  await mkdir(dir, { recursive: true }).catch(() => {});
124
- await writeFile(joinPath(dir, '.keep'), '').catch(() => {});
125
343
  }
126
344
 
127
345
  export async function fileExists(p: string) {
@@ -33,7 +33,6 @@ export type ProviderName =
33
33
  | 'xai'
34
34
  | 'zai'
35
35
  | 'zai-coding'
36
- | 'moonshot'
37
36
  | 'kimi'
38
37
  | 'minimax';
39
38
 
@@ -222,7 +221,7 @@ export async function resolveModel(
222
221
  });
223
222
  }
224
223
 
225
- if (provider === 'moonshot' || provider === 'kimi') {
224
+ if (provider === 'kimi') {
226
225
  return createKimiModel(model, {
227
226
  apiKey: config.apiKey,
228
227
  baseURL: config.baseURL,
@@ -3,6 +3,10 @@ import { copyFile, mkdir, readFile, stat } from 'node:fs/promises';
3
3
  import { dirname, extname, join } from 'node:path';
4
4
  import { tool, type Tool } from 'ai';
5
5
  import { z } from 'zod/v3';
6
+ import {
7
+ loadConfig,
8
+ type OttoConfig,
9
+ } from '../../../../../config/src/index.ts';
6
10
  import { createToolError, type ToolResponse } from '../../error.ts';
7
11
  import { expandTilde, isAbsoluteLike, resolveSafePath } from './util.ts';
8
12
  import { rememberFileWrite } from './read-tracker.ts';
@@ -16,10 +20,11 @@ type AttachmentMetadata = {
16
20
  sha256: string;
17
21
  kind: 'image' | 'pdf' | 'text' | 'binary';
18
22
  originalPath: string;
23
+ storageRoot?: 'project-state';
24
+ relativePath?: string;
19
25
  createdAt: string;
20
26
  };
21
27
 
22
- const ATTACHMENTS_DIR = '.otto/attachments';
23
28
  const MIME_EXTENSIONS: Record<string, string[]> = {
24
29
  'image/png': ['.png'],
25
30
  'image/jpeg': ['.jpg', '.jpeg'],
@@ -43,12 +48,11 @@ function replaceExtension(path: string, extension: string): string {
43
48
  }
44
49
 
45
50
  async function readAttachmentMetadata(
46
- projectRoot: string,
51
+ cfg: OttoConfig,
47
52
  attachmentId: string,
48
53
  ): Promise<AttachmentMetadata> {
49
54
  const metadataPath = join(
50
- projectRoot,
51
- ATTACHMENTS_DIR,
55
+ cfg.paths.attachmentsDir,
52
56
  attachmentId,
53
57
  'metadata.json',
54
58
  );
@@ -56,6 +60,19 @@ async function readAttachmentMetadata(
56
60
  return JSON.parse(raw) as AttachmentMetadata;
57
61
  }
58
62
 
63
+ function resolveAttachmentSource(
64
+ cfg: OttoConfig,
65
+ metadata: AttachmentMetadata,
66
+ ): string {
67
+ if (metadata.storageRoot === 'project-state' && metadata.relativePath) {
68
+ return join(cfg.paths.projectStateDir, metadata.relativePath);
69
+ }
70
+ return join(
71
+ cfg.paths.projectStateDir,
72
+ metadata.relativePath ?? metadata.originalPath,
73
+ );
74
+ }
75
+
59
76
  export function buildCopyAttachmentTool(projectRoot: string): {
60
77
  name: string;
61
78
  tool: Tool;
@@ -83,8 +100,8 @@ export function buildCopyAttachmentTool(projectRoot: string): {
83
100
  async execute({
84
101
  attachmentId,
85
102
  targetPath,
86
- overwrite,
87
- createDirs,
103
+ overwrite = false,
104
+ createDirs = true,
88
105
  }: {
89
106
  attachmentId: string;
90
107
  targetPath: string;
@@ -132,11 +149,9 @@ export function buildCopyAttachmentTool(projectRoot: string): {
132
149
  }
133
150
 
134
151
  try {
135
- const metadata = await readAttachmentMetadata(
136
- projectRoot,
137
- attachmentId,
138
- );
139
- const source = join(projectRoot, metadata.originalPath);
152
+ const cfg = await loadConfig(projectRoot);
153
+ const metadata = await readAttachmentMetadata(cfg, attachmentId);
154
+ const source = resolveAttachmentSource(cfg, metadata);
140
155
  const expectedExtensions = getExpectedExtensions(metadata.mimeType);
141
156
  const targetExtension = extname(requestedPath).toLowerCase();
142
157
  const preferredExtension = expectedExtensions[0];
@@ -145,7 +160,7 @@ export function buildCopyAttachmentTool(projectRoot: string): {
145
160
  ? replaceExtension(requestedPath, preferredExtension)
146
161
  : requestedPath;
147
162
  const extensionAdjusted = req !== requestedPath;
148
- const target = resolveSafePath(projectRoot, req);
163
+ const target = resolveSafePath(cfg.projectRoot, req);
149
164
  try {
150
165
  await stat(target);
151
166
  if (!overwrite) {
@@ -168,7 +183,7 @@ export function buildCopyAttachmentTool(projectRoot: string): {
168
183
  await mkdir(dirname(target), { recursive: true });
169
184
  }
170
185
  await copyFile(source, target);
171
- await rememberFileWrite(projectRoot, target);
186
+ await rememberFileWrite(cfg.projectRoot, target);
172
187
  const bytes = await readFile(target);
173
188
  const sha256 = createHash('sha256').update(bytes).digest('hex');
174
189
  return {
@@ -1,6 +1,6 @@
1
1
  Replace an exact text block in an existing file.
2
2
 
3
- Use this for targeted edits instead of structural patch-style editing whenever possible.
3
+ Prefer `apply_patch` for most code/text edits when it is available, because it produces the clearest diff preview. Use `edit` when you have one precise old/new text replacement, when a patch would be awkward, or after patch attempts fail.
4
4
 
5
5
  Rules:
6
6
  - You must read the file first in the current session before editing it.
@@ -9,6 +9,8 @@ import { buildTreeTool } from './tree.ts';
9
9
  import { buildPwdTool } from './pwd.ts';
10
10
  import { buildCdTool } from './cd.ts';
11
11
 
12
+ export { rememberFileRead } from './read-tracker.ts';
13
+
12
14
  export function buildFsTools(
13
15
  projectRoot: string,
14
16
  ): Array<{ name: string; tool: Tool }> {
@@ -1,6 +1,6 @@
1
1
  Apply multiple exact text replacements to a single existing file atomically.
2
2
 
3
- Use this when you need several edits in one file.
3
+ Prefer `apply_patch` for most code/text edits when it is available, especially when a diff is easy to express. Use `multiedit` when you have several precise old/new replacements in one file, when a patch would be awkward, or after patch attempts fail.
4
4
 
5
5
  Rules:
6
6
  - Read the file first before editing.
@@ -1,10 +1,40 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { getGlobalConfigPath } from '../../../../config/src/paths.ts';
3
+
1
4
  const OTTOCODE_BOT_USER_ID = '261994719';
2
5
 
3
6
  export const OTTOCODE_BOT_NAME = 'ottocode-io[bot]';
4
7
  export const OTTOCODE_BOT_EMAIL = `${OTTOCODE_BOT_USER_ID}+${OTTOCODE_BOT_NAME}@users.noreply.github.com`;
5
8
  export const OTTOCODE_CO_AUTHOR = `Co-authored-by: ${OTTOCODE_BOT_NAME} <${OTTOCODE_BOT_EMAIL}>`;
6
9
 
7
- export function appendCoAuthorTrailer(message: string): string {
10
+ type JsonObject = Record<string, unknown>;
11
+
12
+ function readCoAuthorCommits(filePath: string): boolean | undefined {
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
15
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
16
+ return undefined;
17
+ }
18
+ const defaults = (parsed as JsonObject).defaults;
19
+ if (!defaults || typeof defaults !== 'object' || Array.isArray(defaults)) {
20
+ return undefined;
21
+ }
22
+ const value = (defaults as JsonObject).coAuthorCommits;
23
+ return typeof value === 'boolean' ? value : undefined;
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ export function shouldCoAuthorCommits(_projectRoot?: string): boolean {
30
+ return readCoAuthorCommits(getGlobalConfigPath()) ?? false;
31
+ }
32
+
33
+ export function appendCoAuthorTrailer(
34
+ message: string,
35
+ enabled = false,
36
+ ): string {
37
+ if (!enabled) return message;
8
38
  if (message.includes(OTTOCODE_CO_AUTHOR)) return message;
9
39
  return `${message}\n\n${OTTOCODE_CO_AUTHOR}`;
10
40
  }
@@ -12,9 +42,13 @@ export function appendCoAuthorTrailer(message: string): string {
12
42
  const GIT_COMMIT_MSG_RE =
13
43
  /git\s+commit\s+(?:[^"']*?)(?:-[a-z]*m|-m)\s+(["'])([\s\S]*?)\1/g;
14
44
 
15
- export function injectCoAuthorIntoGitCommit(cmd: string): string {
45
+ export function injectCoAuthorIntoGitCommit(
46
+ cmd: string,
47
+ enabled = false,
48
+ ): string {
49
+ if (!enabled) return cmd;
16
50
  return cmd.replace(GIT_COMMIT_MSG_RE, (match, quote, msg) => {
17
- const patched = appendCoAuthorTrailer(msg);
51
+ const patched = appendCoAuthorTrailer(msg, true);
18
52
  return match.replace(
19
53
  `${quote}${msg}${quote}`,
20
54
  `${quote}${patched}${quote}`,
@@ -6,7 +6,10 @@ import GIT_STATUS_DESCRIPTION from './git.status.txt' with { type: 'text' };
6
6
  import GIT_DIFF_DESCRIPTION from './git.diff.txt' with { type: 'text' };
7
7
  import GIT_COMMIT_DESCRIPTION from './git.commit.txt' with { type: 'text' };
8
8
  import { createToolError, type ToolResponse } from '../error.ts';
9
- import { appendCoAuthorTrailer } from './git-identity.ts';
9
+ import {
10
+ appendCoAuthorTrailer,
11
+ shouldCoAuthorCommits,
12
+ } from './git-identity.ts';
10
13
 
11
14
  const execAsync = promisify(exec);
12
15
 
@@ -120,7 +123,10 @@ export function buildGitTools(
120
123
  });
121
124
  }
122
125
  const gitRoot = await findGitRoot();
123
- const fullMessage = appendCoAuthorTrailer(message);
126
+ const fullMessage = appendCoAuthorTrailer(
127
+ message,
128
+ shouldCoAuthorCommits(projectRoot),
129
+ );
124
130
  const args = [
125
131
  'git',
126
132
  '-C',
@@ -6,8 +6,12 @@
6
6
 
7
7
  **Use `glob` first to discover files** before reading them, unless you already know exact paths.
8
8
 
9
+ Think of `glob` as the repository's fast local `find` replacement for filenames and paths. Use it before shelling out to `find`, `fd`, or `ls **`.
10
+
9
11
  ## Usage tips
10
12
 
11
13
  - Use `glob` for filename patterns; use `search` for file contents.
12
14
  - Combine with `path` to restrict the search to a subdirectory.
13
15
  - Prefer reading a known file directly over globbing to "find" it (check the `<project>` listing in the system prompt first).
16
+ - Instead of `find packages -name "*.ts"`, call `glob` with `pattern: "packages/**/*.ts"`.
17
+ - Instead of `find apps -name package.json`, call `glob` with `pattern: "apps/**/package.json"`.
@@ -23,6 +23,7 @@ export function adjustReplacementIndentation(
23
23
  let fileIndentChar: 'tab' | 'space' = 'space';
24
24
  const deltas: number[] = [];
25
25
  let hasAddStyleMismatch = false;
26
+ let hasContextContentMismatch = false;
26
27
  let fileIndentDetected = false;
27
28
 
28
29
  for (const fl of matchedFileLines) {
@@ -81,6 +82,7 @@ export function adjustReplacementIndentation(
81
82
  if (line.kind === 'context') {
82
83
  const fileLine = matchedFileLines[expectedIdx];
83
84
  if (fileLine !== undefined) {
85
+ if (line.content !== fileLine) hasContextContentMismatch = true;
84
86
  lastDelta = computeIndentDelta(line.content, fileLine, tabSize);
85
87
  lastFileIndentExpanded = expandWhitespace(
86
88
  getLeadingWhitespace(fileLine),
@@ -152,7 +154,12 @@ export function adjustReplacementIndentation(
152
154
  }
153
155
  }
154
156
 
155
- if (!hasDelta && !hasStyleMismatch && !hasAddStyleMismatch) {
157
+ if (
158
+ !hasDelta &&
159
+ !hasStyleMismatch &&
160
+ !hasAddStyleMismatch &&
161
+ !hasContextContentMismatch
162
+ ) {
156
163
  return hunk.lines.filter((l) => l.kind !== 'remove').map((l) => l.content);
157
164
  }
158
165
 
@@ -3,6 +3,7 @@ enum NormalizationLevel {
3
3
  TABS_ONLY = 'tabs',
4
4
  WHITESPACE = 'whitespace',
5
5
  AGGRESSIVE = 'aggressive',
6
+ COLLAPSED = 'collapsed',
6
7
  }
7
8
 
8
9
  const DEFAULT_TAB_SIZE = 2;
@@ -22,6 +23,8 @@ export function normalizeWhitespace(
22
23
  return line.replace(/\t/g, tabReplacement).replace(/\s+$/, '');
23
24
  case NormalizationLevel.AGGRESSIVE:
24
25
  return line.replace(/\t/g, tabReplacement).trim();
26
+ case NormalizationLevel.COLLAPSED:
27
+ return line.replace(/\t/g, tabReplacement).trim().replace(/\s+/g, ' ');
25
28
  default:
26
29
  return line;
27
30
  }
@@ -32,6 +35,7 @@ export const NORMALIZATION_LEVELS: NormalizationLevel[] = [
32
35
  NormalizationLevel.TABS_ONLY,
33
36
  NormalizationLevel.WHITESPACE,
34
37
  NormalizationLevel.AGGRESSIVE,
38
+ NormalizationLevel.COLLAPSED,
35
39
  ];
36
40
 
37
41
  export function getLeadingWhitespace(line: string): string {