@omnitype-code/cli 0.1.1 → 0.1.2
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/CHANGELOG.md +50 -0
- package/dist/core/ModelDetector.js +84 -113
- package/dist/core/ToolHookInstallers.js +243 -0
- package/dist/core/TranscriptScanner.js +233 -0
- package/dist/daemon.js +3 -0
- package/package.json +1 -1
- package/src/core/ModelDetector.ts +88 -130
- package/src/core/ToolHookInstallers.ts +192 -0
- package/src/core/TranscriptScanner.ts +204 -0
- package/src/daemon.ts +4 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Transcript-based model detection — TypeScript port of git-ai's model_extraction.rs.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: for each AI tool, find the most recently modified session file,
|
|
6
|
+
* read its tail (50 KB), scan lines in reverse for a model field, fall back
|
|
7
|
+
* to the head for tools that emit model_change events at session start.
|
|
8
|
+
*
|
|
9
|
+
* Results are cached for 60 s so directory scanning doesn't run on every edit.
|
|
10
|
+
*/
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.scanTranscripts = scanTranscripts;
|
|
46
|
+
exports.invalidateTranscriptCache = invalidateTranscriptCache;
|
|
47
|
+
const fs = __importStar(require("fs"));
|
|
48
|
+
const os = __importStar(require("os"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const TAIL_BYTES = 51200; // 50 KB — mirrors git-ai
|
|
51
|
+
const HEAD_LINES = 20;
|
|
52
|
+
const CACHE_TTL = 60000; // re-scan directories at most once per minute
|
|
53
|
+
const HOME = os.homedir();
|
|
54
|
+
// ── Model extraction from a single JSONL line ─────────────────────────────────
|
|
55
|
+
function modelFromLine(line) {
|
|
56
|
+
const t = line.trim();
|
|
57
|
+
if (!t)
|
|
58
|
+
return undefined;
|
|
59
|
+
let j;
|
|
60
|
+
try {
|
|
61
|
+
j = JSON.parse(t);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
// Copilot CLI / Windsurf: session.model_change event
|
|
67
|
+
if (j?.type === 'session.model_change') {
|
|
68
|
+
const m = j?.data?.newModel;
|
|
69
|
+
if (typeof m === 'string' && m)
|
|
70
|
+
return m;
|
|
71
|
+
}
|
|
72
|
+
// Standard JSONL: message.model, top-level model, or modelID
|
|
73
|
+
const candidate = j?.message?.model ?? j?.model ?? j?.modelID ?? j?.data?.model;
|
|
74
|
+
if (typeof candidate === 'string' && candidate && candidate !== '<synthetic>')
|
|
75
|
+
return candidate;
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
// ── JSONL extraction: tail scan + head fallback ───────────────────────────────
|
|
79
|
+
function extractJsonl(filePath) {
|
|
80
|
+
let fd;
|
|
81
|
+
try {
|
|
82
|
+
fd = fs.openSync(filePath, 'r');
|
|
83
|
+
const size = fs.fstatSync(fd).size;
|
|
84
|
+
if (!size) {
|
|
85
|
+
fs.closeSync(fd);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const readSize = Math.min(TAIL_BYTES, size);
|
|
89
|
+
const seekPos = size - readSize;
|
|
90
|
+
const buf = Buffer.alloc(readSize);
|
|
91
|
+
fs.readSync(fd, buf, 0, readSize, seekPos);
|
|
92
|
+
fs.closeSync(fd);
|
|
93
|
+
fd = undefined;
|
|
94
|
+
// Reverse-scan the tail
|
|
95
|
+
const lines = buf.toString('utf8').split('\n');
|
|
96
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
97
|
+
const m = modelFromLine(lines[i]);
|
|
98
|
+
if (m)
|
|
99
|
+
return m;
|
|
100
|
+
}
|
|
101
|
+
// Head fallback: model_change events are emitted at session start
|
|
102
|
+
if (seekPos > 0) {
|
|
103
|
+
const head = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
104
|
+
for (let i = 0; i < Math.min(HEAD_LINES, head.length); i++) {
|
|
105
|
+
const m = modelFromLine(head[i]);
|
|
106
|
+
if (m)
|
|
107
|
+
return m;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch { /* unreadable */ }
|
|
112
|
+
finally {
|
|
113
|
+
if (fd !== undefined)
|
|
114
|
+
try {
|
|
115
|
+
fs.closeSync(fd);
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
// ── Amp: thread JSON — model lives in messages[].usage.model ─────────────────
|
|
122
|
+
function extractAmp(filePath) {
|
|
123
|
+
try {
|
|
124
|
+
const messages = JSON.parse(fs.readFileSync(filePath, 'utf8'))?.messages ?? [];
|
|
125
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
126
|
+
const m = messages[i]?.usage?.model;
|
|
127
|
+
if (typeof m === 'string' && m)
|
|
128
|
+
return m;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
const SPECS = [
|
|
135
|
+
{
|
|
136
|
+
tool: 'claude-code',
|
|
137
|
+
dirs: () => {
|
|
138
|
+
const base = process.env.CLAUDE_CONFIG_DIR ?? path.join(HOME, '.claude');
|
|
139
|
+
const alt = path.join(HOME, '.config', 'claude');
|
|
140
|
+
return [path.join(base, 'projects'), path.join(alt, 'projects')];
|
|
141
|
+
},
|
|
142
|
+
ext: '.jsonl',
|
|
143
|
+
extract: extractJsonl,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
tool: 'codex',
|
|
147
|
+
dirs: () => [path.join(HOME, '.codex')],
|
|
148
|
+
ext: '.jsonl',
|
|
149
|
+
extract: extractJsonl,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
tool: 'gemini-cli',
|
|
153
|
+
dirs: () => [path.join(HOME, '.gemini', 'tmp')],
|
|
154
|
+
ext: '.jsonl',
|
|
155
|
+
extract: extractJsonl,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
tool: 'amp',
|
|
159
|
+
dirs: () => {
|
|
160
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
161
|
+
const local = process.env.LOCALAPPDATA;
|
|
162
|
+
if (process.platform === 'win32')
|
|
163
|
+
return local ? [path.join(local, 'amp', 'threads')] : [];
|
|
164
|
+
return [path.join(xdg ?? path.join(HOME, '.local', 'share'), 'amp', 'threads')];
|
|
165
|
+
},
|
|
166
|
+
ext: '.json',
|
|
167
|
+
extract: extractAmp,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
tool: 'windsurf',
|
|
171
|
+
dirs: () => [path.join(HOME, '.codeium', 'windsurf', 'conversations')],
|
|
172
|
+
ext: '.jsonl',
|
|
173
|
+
extract: extractJsonl,
|
|
174
|
+
},
|
|
175
|
+
];
|
|
176
|
+
// ── Directory scanner: finds the newest file with the given extension ─────────
|
|
177
|
+
function newestFile(dirs, ext) {
|
|
178
|
+
let best;
|
|
179
|
+
function scan(dir, depth = 0) {
|
|
180
|
+
if (depth > 6)
|
|
181
|
+
return;
|
|
182
|
+
let entries;
|
|
183
|
+
try {
|
|
184
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
for (const e of entries) {
|
|
190
|
+
const full = path.join(dir, e.name);
|
|
191
|
+
if (e.isDirectory())
|
|
192
|
+
scan(full, depth + 1);
|
|
193
|
+
else if (e.isFile() && e.name.endsWith(ext)) {
|
|
194
|
+
try {
|
|
195
|
+
const mtime = fs.statSync(full).mtimeMs;
|
|
196
|
+
if (!best || mtime > best.mtime)
|
|
197
|
+
best = { path: full, mtime };
|
|
198
|
+
}
|
|
199
|
+
catch { }
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const dir of dirs)
|
|
204
|
+
scan(dir);
|
|
205
|
+
return best;
|
|
206
|
+
}
|
|
207
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
208
|
+
let _cache;
|
|
209
|
+
let _cacheAt = 0;
|
|
210
|
+
/**
|
|
211
|
+
* Returns the model from the most recently modified AI session transcript,
|
|
212
|
+
* across all supported tools. Result is cached for 60 s.
|
|
213
|
+
*/
|
|
214
|
+
function scanTranscripts() {
|
|
215
|
+
if (_cache && Date.now() - _cacheAt < CACHE_TTL)
|
|
216
|
+
return _cache;
|
|
217
|
+
let best;
|
|
218
|
+
for (const spec of SPECS) {
|
|
219
|
+
const file = newestFile(spec.dirs(), spec.ext);
|
|
220
|
+
if (!file)
|
|
221
|
+
continue;
|
|
222
|
+
if (best && file.mtime <= best.mtime)
|
|
223
|
+
continue;
|
|
224
|
+
const model = spec.extract(file.path);
|
|
225
|
+
if (!model)
|
|
226
|
+
continue;
|
|
227
|
+
best = { model, tool: spec.tool, mtime: file.mtime };
|
|
228
|
+
}
|
|
229
|
+
_cache = best;
|
|
230
|
+
_cacheAt = Date.now();
|
|
231
|
+
return best;
|
|
232
|
+
}
|
|
233
|
+
function invalidateTranscriptCache() { _cache = undefined; _cacheAt = 0; }
|
package/dist/daemon.js
CHANGED
|
@@ -47,6 +47,7 @@ const ModelDetector_1 = require("./core/ModelDetector");
|
|
|
47
47
|
const FileProvenance_1 = require("./core/FileProvenance");
|
|
48
48
|
const Heartbeat_1 = require("./core/Heartbeat");
|
|
49
49
|
const UI_1 = require("./core/UI");
|
|
50
|
+
const ToolHookInstallers_1 = require("./core/ToolHookInstallers");
|
|
50
51
|
const PUSH_INTERVAL_MS = 60000;
|
|
51
52
|
const IDLE_PUSH_MS = 10000;
|
|
52
53
|
const IGNORE_DIRS = /[/\\](\.git|node_modules|\.next|dist|build|__pycache__|\.venv|venv)[/\\]/;
|
|
@@ -90,6 +91,8 @@ function startDaemon(opts) {
|
|
|
90
91
|
UI_1.UI.error('Not signed in. Run: omnitype login');
|
|
91
92
|
process.exit(1);
|
|
92
93
|
}
|
|
94
|
+
// Auto-install preToolUse hooks into Cursor, Windsurf, Codex if present.
|
|
95
|
+
(0, ToolHookInstallers_1.installAllToolHooks)();
|
|
93
96
|
const watchPath = path.resolve(opts.watchPath);
|
|
94
97
|
const projectName = opts.projectName;
|
|
95
98
|
const branch = opts.branch ?? gitBranch(watchPath);
|
package/package.json
CHANGED
|
@@ -1,19 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI ModelDetector — editor-agnostic model detection.
|
|
3
|
-
*
|
|
4
|
-
* Detection tiers:
|
|
5
|
-
* 1. Universal sentinel (~/.omnitype/active-model.json)
|
|
6
|
-
* 2. Hooks sentinel file (~/.claude/provenance-hook.json)
|
|
7
|
-
* 3. Host IDE config (Cursor/Windsurf/Zed settings.json — scanned generically)
|
|
8
|
-
* 4. Config files (per-tool config paths: .aider.conf.yml, etc.)
|
|
9
|
-
* 5. Environment variables (CLAUDE_MODEL, AIDER_MODEL, etc.)
|
|
10
|
-
* 6. Process detection (lsof / ps — identifies the writing process)
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
1
|
import * as fs from 'fs';
|
|
14
2
|
import * as os from 'os';
|
|
15
3
|
import * as path from 'path';
|
|
16
4
|
import { execFileSync } from 'child_process';
|
|
5
|
+
import { scanTranscripts } from './TranscriptScanner';
|
|
17
6
|
|
|
18
7
|
export interface ModelDetectionResult {
|
|
19
8
|
model: string;
|
|
@@ -22,115 +11,107 @@ export interface ModelDetectionResult {
|
|
|
22
11
|
}
|
|
23
12
|
|
|
24
13
|
const UNKNOWN: ModelDetectionResult = { model: 'unknown', tool: 'unknown', confidence: 'low' };
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
14
|
+
const SENTINEL_MAX_AGE_MS = 30_000; // 30 s — covers slow multi-file AI diffs
|
|
15
|
+
const UNIVERSAL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
|
|
16
|
+
const HOOKS_PATH = path.join(os.homedir(), '.claude', 'provenance-hook.json');
|
|
28
17
|
|
|
29
|
-
|
|
18
|
+
// Matches model identifier strings from all major providers.
|
|
19
|
+
const MODEL_PATTERN = /\b(claude-[\w.-]+|gpt-[\w.-]+|o[134](?:-[\w.-]+)?|gemini-[\w.-]+|gemma[\w.-]*|llama-[\w.-]+|mistral[\w.-]*|codestral[\w.-]*|deepseek[\w.-]*|qwen[\w.-]+|command[\w.-]*|phi[\w.-]+|grok[\w.-]*|kimi[\w.-]*|moonshot[\w.-]*)\b/i;
|
|
30
20
|
|
|
31
|
-
//
|
|
21
|
+
// Each tool lists its OWN env vars only. Shared vars (OPENAI_MODEL) are NOT duplicated
|
|
22
|
+
// across tools — the first matching entry wins, so ambiguous vars are assigned to
|
|
23
|
+
// the most common owner (openai). Tools with a dedicated var (CODEX_MODEL) are checked
|
|
24
|
+
// earlier so they can claim edits even when OPENAI_MODEL is also set.
|
|
32
25
|
const ENV_VARS: Array<{ vars: string[]; tool: string }> = [
|
|
33
|
-
{ vars: ['
|
|
26
|
+
{ vars: ['CLAUDE_CODE_MODEL', 'CLAUDE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
|
|
34
27
|
{ vars: ['AIDER_MODEL'], tool: 'aider' },
|
|
28
|
+
{ vars: ['CODEX_MODEL'], tool: 'codex' }, // before generic OPENAI_MODEL
|
|
35
29
|
{ vars: ['OPENAI_MODEL', 'OPENAI_API_MODEL'], tool: 'openai' },
|
|
36
|
-
{ vars: ['GEMINI_MODEL'
|
|
37
|
-
{ vars: ['OLLAMA_MODEL'],
|
|
38
|
-
{ vars: ['COPILOT_MODEL'],
|
|
39
|
-
{ vars: ['LLM_MODEL'],
|
|
40
|
-
{ vars: ['TABBY_MODEL'],
|
|
30
|
+
{ vars: ['GEMINI_MODEL'], tool: 'gemini-cli' },
|
|
31
|
+
{ vars: ['OLLAMA_MODEL'], tool: 'ollama' },
|
|
32
|
+
{ vars: ['COPILOT_MODEL'], tool: 'copilot' },
|
|
33
|
+
{ vars: ['LLM_MODEL'], tool: 'openhands' },
|
|
34
|
+
{ vars: ['TABBY_MODEL'], tool: 'tabby' },
|
|
41
35
|
];
|
|
42
36
|
|
|
43
|
-
const KNOWN_FORKS = [
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
const KNOWN_FORKS = ['Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'];
|
|
38
|
+
const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
|
|
39
|
+
|
|
40
|
+
// Host IDE processes: always running — their presence does NOT mean they caused the edit.
|
|
41
|
+
const HOST_IDE_PROCS = new Set(['cursor', 'windsurf', 'antigravity', 'pearai', 'void', 'trae', 'zed']);
|
|
46
42
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
{ match: '
|
|
50
|
-
{ match: '
|
|
51
|
-
{ match: '
|
|
52
|
-
{ match: '
|
|
53
|
-
{ match: 'zed', tool: 'zed' },
|
|
54
|
-
{ match: 'pearai', tool: 'pearai' },
|
|
55
|
-
{ match: 'void', tool: 'void' },
|
|
56
|
-
{ match: 'tabby', tool: 'tabby' },
|
|
57
|
-
{ match: 'goose', tool: 'goose' },
|
|
58
|
-
{ match: 'node', tool: 'unknown-cli' },
|
|
59
|
-
{ match: 'python', tool: 'unknown-cli' },
|
|
43
|
+
const PROC_MAP: Array<{ match: string; tool: string }> = [
|
|
44
|
+
{ match: 'claude', tool: 'claude-code' },
|
|
45
|
+
{ match: 'aider', tool: 'aider' },
|
|
46
|
+
{ match: 'goose', tool: 'goose' },
|
|
47
|
+
{ match: 'codex', tool: 'codex' },
|
|
48
|
+
{ match: 'tabby', tool: 'tabby' },
|
|
60
49
|
];
|
|
61
50
|
|
|
62
51
|
export class ModelDetector {
|
|
63
52
|
detect(changedFilePath?: string): ModelDetectionResult {
|
|
64
53
|
return (
|
|
65
|
-
this.
|
|
66
|
-
this.
|
|
67
|
-
this._fromIdeConfigs() ??
|
|
54
|
+
this._sentinel(UNIVERSAL_PATH, changedFilePath) ??
|
|
55
|
+
this._sentinel(HOOKS_PATH, changedFilePath) ??
|
|
68
56
|
this._fromEnv() ??
|
|
69
|
-
this.
|
|
57
|
+
this._fromTranscripts() ??
|
|
58
|
+
this._fromIdeConfigs() ??
|
|
59
|
+
this._fromPs() ??
|
|
70
60
|
(changedFilePath ? this._fromLsof(changedFilePath) : undefined) ??
|
|
71
61
|
UNKNOWN
|
|
72
62
|
);
|
|
73
63
|
}
|
|
74
64
|
|
|
75
|
-
private
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const data = JSON.parse(fs.readFileSync(UNIVERSAL_SENTINEL_PATH, 'utf8'));
|
|
80
|
-
if (!data?.model || data.model === 'unknown') return undefined;
|
|
81
|
-
return { model: data.model, tool: data.tool ?? 'unknown-tool', confidence: 'deterministic' };
|
|
82
|
-
} catch { return undefined; }
|
|
65
|
+
private _fromTranscripts(): ModelDetectionResult | undefined {
|
|
66
|
+
const r = scanTranscripts();
|
|
67
|
+
if (!r) return undefined;
|
|
68
|
+
return { model: r.model, tool: r.tool, confidence: 'high' };
|
|
83
69
|
}
|
|
84
70
|
|
|
85
|
-
private
|
|
71
|
+
private _sentinel(filePath: string, changedPath?: string): ModelDetectionResult | undefined {
|
|
86
72
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
73
|
+
const mtime = fs.statSync(filePath).mtimeMs;
|
|
74
|
+
const d = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
75
|
+
if (!d?.model || d.model === 'unknown') return undefined;
|
|
76
|
+
|
|
77
|
+
if (d.file && changedPath) {
|
|
78
|
+
// File-path gated: trust only if the sentinel targets this exact file.
|
|
79
|
+
// Use a generous TTL since path specificity eliminates cross-file contamination.
|
|
80
|
+
if (d.file !== changedPath) return undefined;
|
|
81
|
+
if (Date.now() - mtime > 120_000) return undefined; // 2 min max
|
|
82
|
+
} else {
|
|
83
|
+
// No path info: fall back to strict TTL to minimise false attribution.
|
|
84
|
+
if (Date.now() - mtime > SENTINEL_MAX_AGE_MS) return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!d?.model || d.model === 'unknown') return undefined;
|
|
88
|
+
return { model: d.model, tool: d.tool ?? 'unknown-tool', confidence: 'deterministic' };
|
|
92
89
|
} catch { return undefined; }
|
|
93
90
|
}
|
|
94
91
|
|
|
95
|
-
private
|
|
96
|
-
for (const
|
|
97
|
-
const
|
|
98
|
-
if (scan) {
|
|
99
|
-
return {
|
|
100
|
-
model: scan.model,
|
|
101
|
-
tool: appName.toLowerCase(),
|
|
102
|
-
confidence: scan.isAuto ? 'medium' : 'high'
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
}
|
|
92
|
+
private _fromEnv(): ModelDetectionResult | undefined {
|
|
93
|
+
for (const { vars, tool } of ENV_VARS)
|
|
94
|
+
for (const v of vars) { const val = process.env[v]; if (val && MODEL_PATTERN.test(val)) return { model: val, tool, confidence: 'high' }; }
|
|
106
95
|
return undefined;
|
|
107
96
|
}
|
|
108
97
|
|
|
109
|
-
private
|
|
110
|
-
for (const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (val && MODEL_PATTERN.test(val)) {
|
|
114
|
-
return { model: val, tool, confidence: 'high' };
|
|
115
|
-
}
|
|
116
|
-
}
|
|
98
|
+
private _fromIdeConfigs(): ModelDetectionResult | undefined {
|
|
99
|
+
for (const appName of KNOWN_FORKS) {
|
|
100
|
+
const scan = this._scanIdeSettings(appName);
|
|
101
|
+
if (scan) return { model: scan.model, tool: appName.toLowerCase(), confidence: scan.isAuto ? 'medium' : 'high' };
|
|
117
102
|
}
|
|
118
103
|
return undefined;
|
|
119
104
|
}
|
|
120
105
|
|
|
121
|
-
private
|
|
106
|
+
private _fromPs(): ModelDetectionResult | undefined {
|
|
122
107
|
if (process.platform === 'win32') return undefined;
|
|
123
108
|
try {
|
|
124
109
|
const lines = execFileSync('ps', ['ax', '-o', 'args='], { timeout: 800, encoding: 'utf8' }).split('\n');
|
|
125
|
-
for (const line of lines)
|
|
126
|
-
for (const
|
|
127
|
-
if (line.toLowerCase().includes(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
} catch { /* ps unavailable */ }
|
|
110
|
+
for (const line of lines)
|
|
111
|
+
for (const { match, tool } of PROC_MAP)
|
|
112
|
+
if (line.toLowerCase().includes(match) && !HOST_IDE_PROCS.has(tool))
|
|
113
|
+
return { model: `${tool}-default`, tool, confidence: 'low' };
|
|
114
|
+
} catch {}
|
|
134
115
|
return undefined;
|
|
135
116
|
}
|
|
136
117
|
|
|
@@ -141,57 +122,36 @@ export class ModelDetector {
|
|
|
141
122
|
for (const line of out.split('\n')) {
|
|
142
123
|
if (!line.startsWith('c')) continue;
|
|
143
124
|
const cmd = line.slice(1).toLowerCase();
|
|
144
|
-
for (const
|
|
145
|
-
if (cmd.includes(
|
|
146
|
-
return { model: `${
|
|
147
|
-
}
|
|
148
|
-
}
|
|
125
|
+
for (const { match, tool } of PROC_MAP)
|
|
126
|
+
if (cmd.includes(match) && !HOST_IDE_PROCS.has(tool))
|
|
127
|
+
return { model: `${tool}-default`, tool, confidence: 'low' };
|
|
149
128
|
}
|
|
150
|
-
} catch {
|
|
129
|
+
} catch {}
|
|
151
130
|
return undefined;
|
|
152
131
|
}
|
|
153
132
|
|
|
154
133
|
private _scanIdeSettings(appName: string): { model: string; isAuto: boolean } | undefined {
|
|
155
|
-
for (const p of this.
|
|
134
|
+
for (const p of this._idePaths(appName)) {
|
|
156
135
|
try {
|
|
157
|
-
const
|
|
158
|
-
const json = JSON.parse(raw);
|
|
159
|
-
const flat = this._flattenObject(json);
|
|
160
|
-
|
|
161
|
-
const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
|
|
136
|
+
const flat = this._flatten(JSON.parse(fs.readFileSync(p, 'utf8')));
|
|
162
137
|
const candidates: Array<{ key: string; value: string }> = [];
|
|
163
|
-
|
|
164
138
|
for (const [key, value] of Object.entries(flat)) {
|
|
165
|
-
if (typeof value !== 'string' || !
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
if (AUTO_SENTINELS.has(value.toLowerCase())) {
|
|
169
|
-
candidates.push({ key, value: `${appName.toLowerCase()}-auto` });
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (MODEL_PATTERN.test(value)) {
|
|
174
|
-
candidates.push({ key, value: value.toLowerCase() });
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (candidates.length > 0) {
|
|
179
|
-
const real = candidates.filter(c => !c.value.endsWith('-auto'));
|
|
180
|
-
const pick = real.length > 0
|
|
181
|
-
? real.sort((a, b) => b.key.length - a.key.length)[0]
|
|
182
|
-
: candidates.sort((a, b) => b.key.length - a.key.length)[0];
|
|
183
|
-
return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
|
|
139
|
+
if (typeof value !== 'string' || !key.toLowerCase().includes('model')) continue;
|
|
140
|
+
if (AUTO_SENTINELS.has(value.toLowerCase())) { candidates.push({ key, value: `${appName.toLowerCase()}-auto` }); continue; }
|
|
141
|
+
if (MODEL_PATTERN.test(value)) candidates.push({ key, value: value.toLowerCase() });
|
|
184
142
|
}
|
|
185
|
-
|
|
143
|
+
if (!candidates.length) continue;
|
|
144
|
+
const real = candidates.filter(c => !c.value.endsWith('-auto'));
|
|
145
|
+
const pick = (real.length ? real : candidates).sort((a, b) => b.key.length - a.key.length)[0];
|
|
146
|
+
return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
|
|
147
|
+
} catch {}
|
|
186
148
|
}
|
|
187
149
|
return undefined;
|
|
188
150
|
}
|
|
189
151
|
|
|
190
|
-
private
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return dirCandidates.map(dir => {
|
|
152
|
+
private _idePaths(appName: string): string[] {
|
|
153
|
+
const id = appName.toLowerCase().replace(/\s+/g, '-');
|
|
154
|
+
return [...new Set([appName, id])].map(dir => {
|
|
195
155
|
switch (process.platform) {
|
|
196
156
|
case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', dir, 'User', 'settings.json');
|
|
197
157
|
case 'win32': return path.join(process.env.APPDATA ?? '', dir, 'User', 'settings.json');
|
|
@@ -200,16 +160,14 @@ export class ModelDetector {
|
|
|
200
160
|
});
|
|
201
161
|
}
|
|
202
162
|
|
|
203
|
-
private
|
|
163
|
+
private _flatten(obj: Record<string, unknown>, prefix = '', depth = 0): Record<string, unknown> {
|
|
204
164
|
if (depth > 5) return {};
|
|
205
165
|
const out: Record<string, unknown> = {};
|
|
206
166
|
for (const [k, v] of Object.entries(obj)) {
|
|
207
167
|
const key = prefix ? `${prefix}.${k}` : k;
|
|
208
|
-
if (v !== null && typeof v === 'object' && !Array.isArray(v))
|
|
209
|
-
Object.assign(out, this.
|
|
210
|
-
|
|
211
|
-
out[key] = v;
|
|
212
|
-
}
|
|
168
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v))
|
|
169
|
+
Object.assign(out, this._flatten(v as Record<string, unknown>, key, depth + 1));
|
|
170
|
+
else out[key] = v;
|
|
213
171
|
}
|
|
214
172
|
return out;
|
|
215
173
|
}
|