@ottocode/sdk 0.1.312 → 0.1.314

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.312",
3
+ "version": "0.1.314",
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,
@@ -46,11 +49,18 @@ const DEFAULTS: {
46
49
  releaseToSend: false,
47
50
  fullWidthContent: false,
48
51
  autoCompactThresholdTokens: null,
52
+ coAuthorCommits: false,
49
53
  ottoEnabled: true,
50
54
  },
51
55
  providers: DEFAULT_PROVIDER_SETTINGS,
52
56
  };
53
57
 
58
+ const LOCAL_DEFAULT_OVERRIDE_KEYS = [
59
+ 'agent',
60
+ 'provider',
61
+ 'model',
62
+ ] satisfies Array<keyof OttoConfig['defaults']>;
63
+
54
64
  export async function loadConfig(
55
65
  projectRootInput?: string,
56
66
  ): Promise<OttoConfig> {
@@ -58,9 +68,21 @@ export async function loadConfig(
58
68
  ? String(projectRootInput)
59
69
  : process.cwd();
60
70
 
61
- const dataDir = getLocalDataDir(projectRoot);
62
- const dbPath = joinPath(dataDir, 'otto.sqlite');
63
- const projectConfigPath = joinPath(dataDir, 'config.json');
71
+ const projectConfigDir = getProjectConfigDir(projectRoot);
72
+ const projectConfigPath = getProjectConfigPath(projectRoot);
73
+ const projectStateDir = await getProjectStateDir(projectRoot);
74
+ const dataDir = projectStateDir;
75
+ const dbPath = joinPath(projectStateDir, 'otto.sqlite');
76
+ const attachmentsDir = joinPath(projectStateDir, 'attachments');
77
+ const debugDir = joinPath(projectStateDir, 'debug');
78
+ const debugDumpsDir = joinPath(projectStateDir, 'debug-dumps');
79
+ const logsDir = joinPath(projectStateDir, 'logs');
80
+ const tmpDir = joinPath(projectStateDir, 'tmp');
81
+ const cacheDir = joinPath(projectStateDir, 'cache');
82
+ const legacyDbPath = joinPath(
83
+ getLegacyProjectDataDir(projectRoot),
84
+ 'otto.sqlite',
85
+ );
64
86
  const globalConfigPath = getGlobalConfigPath();
65
87
  const globalSkillsConfigPath = getGlobalSkillsConfigPath();
66
88
 
@@ -72,10 +94,15 @@ export async function loadConfig(
72
94
  DEFAULTS,
73
95
  globalCfg,
74
96
  globalSkillsCfg ? { skills: globalSkillsCfg } : undefined,
75
- omitGlobalOnlySettings(projectCfg),
97
+ filterProjectConfig(projectCfg),
76
98
  );
77
99
 
78
- await ensureDir(dataDir);
100
+ await ensureDir(projectStateDir);
101
+ if ((await fileExists(legacyDbPath)) && !(await fileExists(dbPath))) {
102
+ console.warn(
103
+ `Legacy Otto database found at ${legacyDbPath}. Run: otto storage migrate`,
104
+ );
105
+ }
79
106
 
80
107
  return {
81
108
  projectRoot,
@@ -83,11 +110,19 @@ export async function loadConfig(
83
110
  providers: merged.providers as OttoConfig['providers'],
84
111
  skills: merged.skills as OttoConfig['skills'],
85
112
  paths: {
86
- dataDir,
87
- dbPath,
113
+ projectConfigDir,
88
114
  projectConfigPath: (await fileExists(projectConfigPath))
89
115
  ? projectConfigPath
90
116
  : null,
117
+ projectStateDir,
118
+ dataDir,
119
+ dbPath,
120
+ attachmentsDir,
121
+ debugDir,
122
+ debugDumpsDir,
123
+ logsDir,
124
+ tmpDir,
125
+ cacheDir,
91
126
  globalConfigPath: (await fileExists(globalConfigPath))
92
127
  ? globalConfigPath
93
128
  : null,
@@ -97,14 +132,32 @@ export async function loadConfig(
97
132
 
98
133
  type JsonObject = Record<string, unknown>;
99
134
 
100
- function omitGlobalOnlySettings(
135
+ function filterProjectConfig(
101
136
  config: JsonObject | undefined,
102
137
  ): JsonObject | undefined {
103
138
  if (!config) return undefined;
104
- const { providers: _providers, skills: _skills, ...rest } = config;
139
+ const { providers: _providers, skills: _skills, defaults, ...rest } = config;
140
+ const localDefaults = pickLocalDefaults(defaults);
141
+ if (localDefaults) {
142
+ return { ...rest, defaults: localDefaults };
143
+ }
105
144
  return rest;
106
145
  }
107
146
 
147
+ function pickLocalDefaults(defaults: unknown): JsonObject | undefined {
148
+ if (!defaults || typeof defaults !== 'object' || Array.isArray(defaults)) {
149
+ return undefined;
150
+ }
151
+ const source = defaults as JsonObject;
152
+ const picked: JsonObject = {};
153
+ for (const key of LOCAL_DEFAULT_OVERRIDE_KEYS) {
154
+ if (Object.hasOwn(source, key)) {
155
+ picked[key] = source[key];
156
+ }
157
+ }
158
+ return Object.keys(picked).length > 0 ? picked : undefined;
159
+ }
160
+
108
161
  async function readJsonOptional(file: string): Promise<JsonObject | undefined> {
109
162
  const f = Bun.file(file);
110
163
  if (!(await f.exists())) return undefined;
@@ -28,6 +28,8 @@ import {
28
28
 
29
29
  export type Scope = 'global' | 'local';
30
30
 
31
+ const LOCAL_DEFAULT_UPDATE_KEYS = new Set(['agent', 'provider', 'model']);
32
+
31
33
  export type DebugConfig = {
32
34
  enabled: boolean;
33
35
  scopes: string[];
@@ -91,10 +93,21 @@ export async function writeDefaults(
91
93
  releaseToSend: boolean;
92
94
  fullWidthContent: boolean;
93
95
  autoCompactThresholdTokens: number | null;
96
+ coAuthorCommits: boolean;
94
97
  ottoEnabled: boolean;
95
98
  }>,
96
99
  projectRoot?: string,
97
100
  ) {
101
+ const scopedUpdates =
102
+ scope === 'local'
103
+ ? (Object.fromEntries(
104
+ Object.entries(updates).filter(([key]) =>
105
+ LOCAL_DEFAULT_UPDATE_KEYS.has(key),
106
+ ),
107
+ ) as typeof updates)
108
+ : updates;
109
+ if (Object.keys(scopedUpdates).length === 0) return;
110
+
98
111
  const filePath = getConfigFilePath(scope, projectRoot);
99
112
  const existing = await readJsonFile(filePath);
100
113
  const prevDefaults =
@@ -103,7 +116,7 @@ export async function writeDefaults(
103
116
  : {};
104
117
  const next = {
105
118
  ...existing,
106
- defaults: { ...prevDefaults, ...updates },
119
+ defaults: { ...prevDefaults, ...scopedUpdates },
107
120
  };
108
121
  await writeConfigFile(filePath, next);
109
122
  }
@@ -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) {
@@ -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,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',
@@ -5,7 +5,10 @@ import { z } from 'zod/v3';
5
5
  import DESCRIPTION from './shell.txt' with { type: 'text' };
6
6
  import { getShellExecutionConfig } from '../bin-manager.ts';
7
7
  import { createToolError, type ToolResponse } from '../error.ts';
8
- import { injectCoAuthorIntoGitCommit } from './git-identity.ts';
8
+ import {
9
+ injectCoAuthorIntoGitCommit,
10
+ shouldCoAuthorCommits,
11
+ } from './git-identity.ts';
9
12
 
10
13
  function normalizePath(p: string) {
11
14
  const normalized = p.replace(/\\/g, '/');
@@ -248,7 +251,10 @@ export function buildShellTool(projectRoot: string): {
248
251
  }
249
252
 
250
253
  const absCwd = resolveSafePath(projectRoot, cwd || '.');
251
- const finalCmd = injectCoAuthorIntoGitCommit(cmd);
254
+ const finalCmd = injectCoAuthorIntoGitCommit(
255
+ cmd,
256
+ shouldCoAuthorCommits(projectRoot),
257
+ );
252
258
  const shellExecutor = shellExecutorContext.getStore();
253
259
  if (shellExecutor) {
254
260
  return shellExecutor(
@@ -5,7 +5,10 @@ import { createToolError } from '../error.ts';
5
5
  import type { TerminalManager } from '../../terminals/index.ts';
6
6
  import type { TerminalStatus } from '../../terminals/terminal.ts';
7
7
  import { normalizeTerminalLine } from '../../utils/ansi.ts';
8
- import { injectCoAuthorIntoGitCommit } from './git-identity.ts';
8
+ import {
9
+ injectCoAuthorIntoGitCommit,
10
+ shouldCoAuthorCommits,
11
+ } from './git-identity.ts';
9
12
 
10
13
  function shellQuote(segment: string): string {
11
14
  if (/^[a-zA-Z0-9._-]+$/.test(segment)) {
@@ -108,6 +111,7 @@ export function buildTerminalTool(
108
111
  execute: async (params) => {
109
112
  try {
110
113
  const { operation } = params;
114
+ const coAuthorCommits = shouldCoAuthorCommits(projectRoot);
111
115
 
112
116
  switch (operation) {
113
117
  case 'start': {
@@ -169,7 +173,9 @@ export function buildTerminalTool(
169
173
 
170
174
  if (initialCommand) {
171
175
  queueMicrotask(() => {
172
- term.write(`${injectCoAuthorIntoGitCommit(initialCommand)}\n`);
176
+ term.write(
177
+ `${injectCoAuthorIntoGitCommit(initialCommand, coAuthorCommits)}\n`,
178
+ );
173
179
  });
174
180
  }
175
181
 
@@ -243,7 +249,9 @@ export function buildTerminalTool(
243
249
  return createToolError(`Terminal ${params.terminalId} not found`);
244
250
  }
245
251
 
246
- term.write(injectCoAuthorIntoGitCommit(params.input));
252
+ term.write(
253
+ injectCoAuthorIntoGitCommit(params.input, coAuthorCommits),
254
+ );
247
255
 
248
256
  return {
249
257
  ok: true,
package/src/index.ts CHANGED
@@ -253,6 +253,20 @@ export type {
253
253
  export { loadConfig, read as readConfig } from './config/src/index.ts';
254
254
  export {
255
255
  getLocalDataDir,
256
+ getLegacyProjectDataDir,
257
+ getOttoHomeDir,
258
+ getProjectsStateRoot,
259
+ getProjectId,
260
+ getProjectConfigDir,
261
+ getProjectConfigPath,
262
+ getProjectStateDir,
263
+ getProjectDbPath,
264
+ getProjectAttachmentsDir,
265
+ getProjectDebugDir,
266
+ getProjectDebugDumpsDir,
267
+ getProjectLogsDir,
268
+ getProjectTmpDir,
269
+ getProjectCacheDir,
256
270
  getGlobalConfigDir,
257
271
  getGlobalConfigPath,
258
272
  getGlobalSkillsConfigPath,
@@ -334,6 +348,7 @@ export {
334
348
  export {
335
349
  appendCoAuthorTrailer,
336
350
  injectCoAuthorIntoGitCommit,
351
+ shouldCoAuthorCommits,
337
352
  OTTOCODE_BOT_NAME,
338
353
  OTTOCODE_BOT_EMAIL,
339
354
  OTTOCODE_CO_AUTHOR,
@@ -14,20 +14,6 @@ type CatalogMap = Partial<Record<BuiltInProviderId, ProviderCatalogEntry>>;
14
14
  const OLLAMA_CLOUD_ID: BuiltInProviderId = 'ollama-cloud';
15
15
  const OTTOROUTER_ID: BuiltInProviderId = 'ottorouter';
16
16
 
17
- const KIMI_K2_7_CODE_MODEL: ModelInfo = {
18
- id: 'kimi-k2.7-code',
19
- ownedBy: 'moonshot',
20
- label: 'Kimi K2.7 Code',
21
- modalities: { input: ['text', 'image', 'video'], output: ['text'] },
22
- toolCall: true,
23
- reasoningText: true,
24
- attachment: true,
25
- temperature: 1,
26
- cost: { input: 0.95, output: 4, cacheRead: 0.19 },
27
- limit: { context: 262_144, output: 32_768 },
28
- provider: { npm: '@ai-sdk/openai-compatible' },
29
- };
30
-
31
17
  const XAI_GROK_CLI_MODELS: ModelInfo[] = [
32
18
  {
33
19
  id: 'grok-build',
@@ -168,24 +154,31 @@ export function appendXaiGrokCliModels<T extends { models: ModelInfo[] }>(
168
154
  return { ...entry, models: [...mergedModels, ...missingModels] };
169
155
  }
170
156
 
171
- export function appendOfficialKimiModels<T extends ProviderCatalogEntry>(
172
- entry: T | undefined,
173
- ): T | undefined {
157
+ const DEPRECATED_KIMI_MODEL_IDS = new Set([
158
+ 'kimi-k2-0711-preview',
159
+ 'kimi-k2-0905-preview',
160
+ 'kimi-k2-thinking',
161
+ 'kimi-k2-thinking-turbo',
162
+ 'kimi-k2-turbo-preview',
163
+ ]);
164
+
165
+ export function filterAvailableKimiModels(models: ModelInfo[]): ModelInfo[] {
166
+ return models.filter((model) => !DEPRECATED_KIMI_MODEL_IDS.has(model.id));
167
+ }
168
+
169
+ export function applyOfficialKimiCatalogMetadata<
170
+ T extends ProviderCatalogEntry,
171
+ >(entry: T | undefined): T | undefined {
174
172
  if (!entry) return undefined;
175
- const hasKimiK27Code = entry.models.some(
176
- (model) => model.id === KIMI_K2_7_CODE_MODEL.id,
177
- );
178
173
  const env = Array.from(
179
174
  new Set(['KIMI_API_KEY', 'MOONSHOT_API_KEY', ...(entry.env ?? [])]),
180
175
  );
181
176
  return {
182
177
  ...entry,
178
+ models: filterAvailableKimiModels(entry.models),
183
179
  label: entry.label === 'Moonshot AI' ? 'Kimi' : entry.label,
184
180
  env,
185
181
  doc: 'https://platform.kimi.ai/docs/api/overview.md',
186
- models: hasKimiK27Code
187
- ? entry.models
188
- : [...entry.models, KIMI_K2_7_CODE_MODEL],
189
182
  };
190
183
  }
191
184
 
@@ -202,7 +195,7 @@ export function mergeManualCatalog(
202
195
  if (xaiEntry) {
203
196
  merged.xai = xaiEntry;
204
197
  }
205
- const moonshotEntry = appendOfficialKimiModels(merged.moonshot);
198
+ const moonshotEntry = applyOfficialKimiCatalogMetadata(merged.moonshot);
206
199
  if (moonshotEntry) {
207
200
  merged.moonshot = moonshotEntry;
208
201
  }
@@ -2343,13 +2343,13 @@ export const catalog: Partial<Record<BuiltInProviderId, ProviderCatalogEntry>> =
2343
2343
  lastUpdated: '2026-04-27',
2344
2344
  openWeights: false,
2345
2345
  cost: {
2346
- input: 0.67,
2347
- output: 3.39,
2348
- cacheRead: 0.14,
2346
+ input: 0.68,
2347
+ output: 3.41,
2348
+ cacheRead: 0.34,
2349
2349
  },
2350
2350
  limit: {
2351
- context: 262144,
2352
- output: 262144,
2351
+ context: 262142,
2352
+ output: 262142,
2353
2353
  },
2354
2354
  },
2355
2355
  {
@@ -3231,7 +3231,7 @@ export const catalog: Partial<Record<BuiltInProviderId, ProviderCatalogEntry>> =
3231
3231
  cacheRead: 0.135,
3232
3232
  },
3233
3233
  limit: {
3234
- context: 32768,
3234
+ context: 163840,
3235
3235
  output: 16384,
3236
3236
  },
3237
3237
  },
@@ -3398,12 +3398,12 @@ export const catalog: Partial<Record<BuiltInProviderId, ProviderCatalogEntry>> =
3398
3398
  lastUpdated: '2026-04-24',
3399
3399
  openWeights: true,
3400
3400
  cost: {
3401
- input: 0.0983,
3402
- output: 0.1966,
3403
- cacheRead: 0.0197,
3401
+ input: 0.098,
3402
+ output: 0.196,
3403
+ cacheRead: 0.02,
3404
3404
  },
3405
3405
  limit: {
3406
- context: 1048576,
3406
+ context: 1048575,
3407
3407
  output: 131072,
3408
3408
  },
3409
3409
  },
@@ -4873,12 +4873,11 @@ export const catalog: Partial<Record<BuiltInProviderId, ProviderCatalogEntry>> =
4873
4873
  lastUpdated: '2026-01',
4874
4874
  openWeights: true,
4875
4875
  cost: {
4876
- input: 0.4,
4877
- output: 1.9,
4878
- cacheRead: 0.09,
4876
+ input: 0.375,
4877
+ output: 2.025,
4879
4878
  },
4880
4879
  limit: {
4881
- context: 262144,
4880
+ context: 256000,
4882
4881
  output: 262144,
4883
4882
  },
4884
4883
  },
@@ -4899,9 +4898,35 @@ export const catalog: Partial<Record<BuiltInProviderId, ProviderCatalogEntry>> =
4899
4898
  lastUpdated: '2026-04-21',
4900
4899
  openWeights: true,
4901
4900
  cost: {
4902
- input: 0.67,
4903
- output: 3.39,
4904
- cacheRead: 0.14,
4901
+ input: 0.68,
4902
+ output: 3.41,
4903
+ cacheRead: 0.34,
4904
+ },
4905
+ limit: {
4906
+ context: 262142,
4907
+ output: 262142,
4908
+ },
4909
+ },
4910
+ {
4911
+ id: 'moonshotai/kimi-k2.7-code',
4912
+ ownedBy: 'moonshot',
4913
+ label: 'Kimi K2.7 Code',
4914
+ modalities: {
4915
+ input: ['text', 'image'],
4916
+ output: ['text'],
4917
+ },
4918
+ toolCall: true,
4919
+ reasoningText: true,
4920
+ attachment: true,
4921
+ temperature: true,
4922
+ knowledge: '2025-01',
4923
+ releaseDate: '2026-06-12',
4924
+ lastUpdated: '2026-06-12',
4925
+ openWeights: true,
4926
+ cost: {
4927
+ input: 0.75,
4928
+ output: 3.5,
4929
+ cacheRead: 0.16,
4905
4930
  },
4906
4931
  limit: {
4907
4932
  context: 262144,
@@ -7633,12 +7658,12 @@ export const catalog: Partial<Record<BuiltInProviderId, ProviderCatalogEntry>> =
7633
7658
  lastUpdated: '2026-04-22',
7634
7659
  openWeights: true,
7635
7660
  cost: {
7636
- input: 0.289,
7637
- output: 2.4,
7661
+ input: 0.2885,
7662
+ output: 3.17,
7638
7663
  },
7639
7664
  limit: {
7640
- context: 131072,
7641
- output: 131072,
7665
+ context: 262140,
7666
+ output: 262140,
7642
7667
  },
7643
7668
  },
7644
7669
  {
@@ -10924,6 +10949,32 @@ export const catalog: Partial<Record<BuiltInProviderId, ProviderCatalogEntry>> =
10924
10949
  output: 131072,
10925
10950
  },
10926
10951
  },
10952
+ {
10953
+ id: 'glm-5.2',
10954
+ ownedBy: 'zai',
10955
+ label: 'GLM-5.2',
10956
+ modalities: {
10957
+ input: ['text'],
10958
+ output: ['text'],
10959
+ },
10960
+ toolCall: true,
10961
+ reasoningText: true,
10962
+ attachment: false,
10963
+ temperature: true,
10964
+ releaseDate: '2026-06-13',
10965
+ lastUpdated: '2026-06-13',
10966
+ openWeights: false,
10967
+ cost: {
10968
+ input: 0,
10969
+ output: 0,
10970
+ cacheRead: 0,
10971
+ cacheWrite: 0,
10972
+ },
10973
+ limit: {
10974
+ context: 1000000,
10975
+ output: 131072,
10976
+ },
10977
+ },
10927
10978
  {
10928
10979
  id: 'glm-5v-turbo',
10929
10980
  ownedBy: 'zai',
@@ -12338,6 +12389,27 @@ export const catalog: Partial<Record<BuiltInProviderId, ProviderCatalogEntry>> =
12338
12389
  output: 262144,
12339
12390
  },
12340
12391
  },
12392
+ {
12393
+ id: 'kimi-k2.7-code',
12394
+ ownedBy: 'moonshot',
12395
+ label: 'kimi-k2.7-code',
12396
+ modalities: {
12397
+ input: ['text', 'image'],
12398
+ output: ['text'],
12399
+ },
12400
+ toolCall: true,
12401
+ reasoningText: true,
12402
+ attachment: true,
12403
+ temperature: false,
12404
+ knowledge: '2025-01',
12405
+ releaseDate: '2026-06-12',
12406
+ lastUpdated: '2026-06-12',
12407
+ openWeights: true,
12408
+ limit: {
12409
+ context: 262144,
12410
+ output: 262144,
12411
+ },
12412
+ },
12341
12413
  {
12342
12414
  id: 'minimax-m2',
12343
12415
  ownedBy: 'minimax',
@@ -5,8 +5,8 @@ import type { ModelInfo } from '../../types/src/index.ts';
5
5
  *
6
6
  * Cached entries override fields for overlapping ids (so remote updates like
7
7
  * pricing/limits still apply), while embedded/manual-only models (for example
8
- * `kimi-k2.7-code` or the manual xai grok-cli models) are always retained even
9
- * when a stale cache does not include them.
8
+ * the manual xai grok-cli models) are always retained even when a stale cache
9
+ * does not include them.
10
10
  */
11
11
  export function mergeModelLists(
12
12
  baseModels: ModelInfo[] | undefined,
@@ -15,6 +15,90 @@ export function readKimiApiKeyFromEnv(): string {
15
15
  return process.env.KIMI_API_KEY || process.env.MOONSHOT_API_KEY || '';
16
16
  }
17
17
 
18
+ /**
19
+ * Kimi/Moonshot streaming responses report token usage on the final chunk's
20
+ * `choices[0].usage` instead of the OpenAI-standard top-level `usage` field.
21
+ * The AI SDK openai-compatible parser only reads top-level `usage`, so we
22
+ * hoist choice-level usage to the top level when it is missing.
23
+ */
24
+ export function hoistKimiSseUsage(line: string): string {
25
+ const hasCarriageReturn = line.endsWith('\r');
26
+ const raw = hasCarriageReturn ? line.slice(0, -1) : line;
27
+ if (!raw.startsWith('data:')) return line;
28
+ const payload = raw.slice(5).trim();
29
+ if (!payload || payload === '[DONE]') return line;
30
+ try {
31
+ const parsed = JSON.parse(payload) as {
32
+ usage?: unknown;
33
+ choices?: Array<{ usage?: unknown } | null>;
34
+ };
35
+ if (!parsed || typeof parsed !== 'object' || parsed.usage != null) {
36
+ return line;
37
+ }
38
+ const choiceUsage = Array.isArray(parsed.choices)
39
+ ? parsed.choices.find((choice) => choice?.usage != null)?.usage
40
+ : undefined;
41
+ if (choiceUsage == null) return line;
42
+ parsed.usage = choiceUsage;
43
+ return `data: ${JSON.stringify(parsed)}${hasCarriageReturn ? '\r' : ''}`;
44
+ } catch {
45
+ return line;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Wraps fetch so Kimi SSE chunks carrying `choices[0].usage` are rewritten to
51
+ * expose a top-level `usage` field the AI SDK can parse.
52
+ */
53
+ export function createKimiUsageFetch(
54
+ baseFetch: typeof fetch = fetch,
55
+ ): typeof fetch {
56
+ const wrappedFetch = async (
57
+ input: Parameters<typeof fetch>[0],
58
+ init?: Parameters<typeof fetch>[1],
59
+ ): Promise<Response> => {
60
+ const response = await baseFetch(input, init);
61
+ const contentType = response.headers.get('content-type') ?? '';
62
+ if (
63
+ !response.ok ||
64
+ !response.body ||
65
+ !contentType.includes('text/event-stream')
66
+ ) {
67
+ return response;
68
+ }
69
+
70
+ const decoder = new TextDecoder();
71
+ const encoder = new TextEncoder();
72
+ let buffered = '';
73
+ const transform = new TransformStream<Uint8Array, Uint8Array>({
74
+ transform(chunk, controller) {
75
+ buffered += decoder.decode(chunk, { stream: true });
76
+ const lines = buffered.split('\n');
77
+ buffered = lines.pop() ?? '';
78
+ for (const line of lines) {
79
+ controller.enqueue(encoder.encode(`${hoistKimiSseUsage(line)}\n`));
80
+ }
81
+ },
82
+ flush(controller) {
83
+ buffered += decoder.decode();
84
+ if (buffered.length) {
85
+ controller.enqueue(encoder.encode(hoistKimiSseUsage(buffered)));
86
+ }
87
+ },
88
+ });
89
+
90
+ const headers = new Headers(response.headers);
91
+ headers.delete('content-length');
92
+ headers.delete('content-encoding');
93
+ return new Response(response.body.pipeThrough(transform), {
94
+ status: response.status,
95
+ statusText: response.statusText,
96
+ headers,
97
+ });
98
+ };
99
+ return wrappedFetch as typeof fetch;
100
+ }
101
+
18
102
  export function createKimiModel(model: string, config?: KimiProviderConfig) {
19
103
  const entry = catalog.moonshot;
20
104
  const oauthAccess = config?.oauth?.access;
@@ -34,6 +118,7 @@ export function createKimiModel(model: string, config?: KimiProviderConfig) {
34
118
  name: 'Kimi',
35
119
  baseURL,
36
120
  headers,
121
+ fetch: createKimiUsageFetch(),
37
122
  });
38
123
 
39
124
  return instance(model);
@@ -1,4 +1,5 @@
1
1
  import { catalog } from './catalog-merged.ts';
2
+ import { filterAvailableKimiModels } from './catalog-manual.ts';
2
3
  import { getCachedProviderCatalogEntry } from './model-catalog-cache.ts';
3
4
  import { mergeModelLists } from './model-merge.ts';
4
5
  import type {
@@ -45,18 +46,25 @@ const PREFERRED_FAST_MODELS: Partial<Record<ProviderId, string[]>> = {
45
46
  xai: ['grok-code-fast-1', 'grok-4-fast'],
46
47
  zai: ['glm-4.5-flash'],
47
48
  copilot: ['gpt-4.1-mini'],
49
+ moonshot: ['kimi-k2.7-code'],
48
50
  };
49
51
 
50
52
  const PREFERRED_FAST_MODELS_OAUTH: Partial<Record<ProviderId, string[]>> = {
51
53
  openai: ['gpt-5.4-mini'],
52
54
  anthropic: ['claude-haiku-4-5'],
55
+ moonshot: ['kimi-k2.7-code'],
53
56
  };
54
57
 
58
+ function preferredFastModelKey(provider: ProviderId): ProviderId {
59
+ return resolveBuiltInProviderCatalogId(provider) ?? provider;
60
+ }
61
+
55
62
  export function getFastModel(provider: ProviderId): string | undefined {
56
63
  const providerModels = getProviderModels(provider);
57
64
  if (!providerModels.length) return undefined;
58
65
 
59
- const preferred = PREFERRED_FAST_MODELS[provider] ?? [];
66
+ const preferred =
67
+ PREFERRED_FAST_MODELS[preferredFastModelKey(provider)] ?? [];
60
68
  for (const modelId of preferred) {
61
69
  if (providerModels.some((m) => m.id === modelId)) {
62
70
  return modelId;
@@ -86,7 +94,7 @@ export function getFastModelForAuth(
86
94
 
87
95
  const preferredMap =
88
96
  authType === 'oauth' ? PREFERRED_FAST_MODELS_OAUTH : PREFERRED_FAST_MODELS;
89
- const preferred = preferredMap[provider] ?? [];
97
+ const preferred = preferredMap[preferredFastModelKey(provider)] ?? [];
90
98
  for (const modelId of preferred) {
91
99
  if (filteredModels.some((m) => m.id === modelId)) {
92
100
  return modelId;
@@ -256,7 +264,10 @@ function getProviderModels(provider: ProviderId): ModelInfo[] {
256
264
  const cachedModels = getCachedProviderCatalogEntry(
257
265
  catalogProvider ?? provider,
258
266
  )?.models;
259
- return mergeModelLists(catalogModels, cachedModels);
267
+ const models = mergeModelLists(catalogModels, cachedModels);
268
+ return catalogProvider === 'moonshot'
269
+ ? filterAvailableKimiModels(models)
270
+ : models;
260
271
  }
261
272
 
262
273
  export function modelSupportsReasoning(
@@ -38,6 +38,8 @@ export type DefaultConfig = {
38
38
  releaseToSend?: boolean;
39
39
  fullWidthContent?: boolean;
40
40
  autoCompactThresholdTokens?: number | null;
41
+ /** Adds the ottocode bot as a co-author on commits made through Otto. */
42
+ coAuthorCommits?: boolean;
41
43
  /** Enables the otto supervisor agent and persistent goals (disabled together). */
42
44
  ottoEnabled?: boolean;
43
45
  };
@@ -74,9 +76,17 @@ export type SkillSettings = {
74
76
  * Path configuration
75
77
  */
76
78
  export type PathConfig = {
79
+ projectConfigDir: string;
80
+ projectConfigPath: string | null;
81
+ projectStateDir: string;
77
82
  dataDir: string;
78
83
  dbPath: string;
79
- projectConfigPath: string | null;
84
+ attachmentsDir: string;
85
+ debugDir: string;
86
+ debugDumpsDir: string;
87
+ logsDir: string;
88
+ tmpDir: string;
89
+ cacheDir: string;
80
90
  globalConfigPath: string | null;
81
91
  };
82
92