@omnitype-code/cli 0.1.0 → 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/LICENSE +21 -0
- package/README.md +104 -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 +2 -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,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript-based model detection — TypeScript port of git-ai's model_extraction.rs.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: for each AI tool, find the most recently modified session file,
|
|
5
|
+
* read its tail (50 KB), scan lines in reverse for a model field, fall back
|
|
6
|
+
* to the head for tools that emit model_change events at session start.
|
|
7
|
+
*
|
|
8
|
+
* Results are cached for 60 s so directory scanning doesn't run on every edit.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as os from 'os';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
|
|
15
|
+
export interface TranscriptResult {
|
|
16
|
+
model: string;
|
|
17
|
+
tool: string;
|
|
18
|
+
/** mtime of the session file — callers can use this to judge freshness. */
|
|
19
|
+
mtime: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TAIL_BYTES = 51_200; // 50 KB — mirrors git-ai
|
|
23
|
+
const HEAD_LINES = 20;
|
|
24
|
+
const CACHE_TTL = 60_000; // re-scan directories at most once per minute
|
|
25
|
+
const HOME = os.homedir();
|
|
26
|
+
|
|
27
|
+
// ── Model extraction from a single JSONL line ─────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function modelFromLine(line: string): string | undefined {
|
|
30
|
+
const t = line.trim();
|
|
31
|
+
if (!t) return undefined;
|
|
32
|
+
let j: any;
|
|
33
|
+
try { j = JSON.parse(t); } catch { return undefined; }
|
|
34
|
+
|
|
35
|
+
// Copilot CLI / Windsurf: session.model_change event
|
|
36
|
+
if (j?.type === 'session.model_change') {
|
|
37
|
+
const m = j?.data?.newModel;
|
|
38
|
+
if (typeof m === 'string' && m) return m;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Standard JSONL: message.model, top-level model, or modelID
|
|
42
|
+
const candidate = j?.message?.model ?? j?.model ?? j?.modelID ?? j?.data?.model;
|
|
43
|
+
if (typeof candidate === 'string' && candidate && candidate !== '<synthetic>')
|
|
44
|
+
return candidate;
|
|
45
|
+
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── JSONL extraction: tail scan + head fallback ───────────────────────────────
|
|
50
|
+
|
|
51
|
+
function extractJsonl(filePath: string): string | undefined {
|
|
52
|
+
let fd: number | undefined;
|
|
53
|
+
try {
|
|
54
|
+
fd = fs.openSync(filePath, 'r');
|
|
55
|
+
const size = fs.fstatSync(fd).size;
|
|
56
|
+
if (!size) { fs.closeSync(fd); return undefined; }
|
|
57
|
+
|
|
58
|
+
const readSize = Math.min(TAIL_BYTES, size);
|
|
59
|
+
const seekPos = size - readSize;
|
|
60
|
+
const buf = Buffer.alloc(readSize);
|
|
61
|
+
fs.readSync(fd, buf, 0, readSize, seekPos);
|
|
62
|
+
fs.closeSync(fd);
|
|
63
|
+
fd = undefined;
|
|
64
|
+
|
|
65
|
+
// Reverse-scan the tail
|
|
66
|
+
const lines = buf.toString('utf8').split('\n');
|
|
67
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
68
|
+
const m = modelFromLine(lines[i]);
|
|
69
|
+
if (m) return m;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Head fallback: model_change events are emitted at session start
|
|
73
|
+
if (seekPos > 0) {
|
|
74
|
+
const head = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
75
|
+
for (let i = 0; i < Math.min(HEAD_LINES, head.length); i++) {
|
|
76
|
+
const m = modelFromLine(head[i]);
|
|
77
|
+
if (m) return m;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch { /* unreadable */ }
|
|
81
|
+
finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Amp: thread JSON — model lives in messages[].usage.model ─────────────────
|
|
86
|
+
|
|
87
|
+
function extractAmp(filePath: string): string | undefined {
|
|
88
|
+
try {
|
|
89
|
+
const messages: any[] = JSON.parse(fs.readFileSync(filePath, 'utf8'))?.messages ?? [];
|
|
90
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
91
|
+
const m = messages[i]?.usage?.model;
|
|
92
|
+
if (typeof m === 'string' && m) return m;
|
|
93
|
+
}
|
|
94
|
+
} catch {}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Tool specs: where each tool stores transcripts ────────────────────────────
|
|
99
|
+
|
|
100
|
+
interface ToolSpec {
|
|
101
|
+
tool: string;
|
|
102
|
+
dirs: () => string[];
|
|
103
|
+
ext: string;
|
|
104
|
+
extract: (p: string) => string | undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const SPECS: ToolSpec[] = [
|
|
108
|
+
{
|
|
109
|
+
tool: 'claude-code',
|
|
110
|
+
dirs: () => {
|
|
111
|
+
const base = process.env.CLAUDE_CONFIG_DIR ?? path.join(HOME, '.claude');
|
|
112
|
+
const alt = path.join(HOME, '.config', 'claude');
|
|
113
|
+
return [path.join(base, 'projects'), path.join(alt, 'projects')];
|
|
114
|
+
},
|
|
115
|
+
ext: '.jsonl',
|
|
116
|
+
extract: extractJsonl,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
tool: 'codex',
|
|
120
|
+
dirs: () => [path.join(HOME, '.codex')],
|
|
121
|
+
ext: '.jsonl',
|
|
122
|
+
extract: extractJsonl,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
tool: 'gemini-cli',
|
|
126
|
+
dirs: () => [path.join(HOME, '.gemini', 'tmp')],
|
|
127
|
+
ext: '.jsonl',
|
|
128
|
+
extract: extractJsonl,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
tool: 'amp',
|
|
132
|
+
dirs: () => {
|
|
133
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
134
|
+
const local = process.env.LOCALAPPDATA;
|
|
135
|
+
if (process.platform === 'win32') return local ? [path.join(local, 'amp', 'threads')] : [];
|
|
136
|
+
return [path.join(xdg ?? path.join(HOME, '.local', 'share'), 'amp', 'threads')];
|
|
137
|
+
},
|
|
138
|
+
ext: '.json',
|
|
139
|
+
extract: extractAmp,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
tool: 'windsurf',
|
|
143
|
+
dirs: () => [path.join(HOME, '.codeium', 'windsurf', 'conversations')],
|
|
144
|
+
ext: '.jsonl',
|
|
145
|
+
extract: extractJsonl,
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
// ── Directory scanner: finds the newest file with the given extension ─────────
|
|
150
|
+
|
|
151
|
+
function newestFile(dirs: string[], ext: string): { path: string; mtime: number } | undefined {
|
|
152
|
+
let best: { path: string; mtime: number } | undefined;
|
|
153
|
+
|
|
154
|
+
function scan(dir: string, depth = 0): void {
|
|
155
|
+
if (depth > 6) return;
|
|
156
|
+
let entries: fs.Dirent[];
|
|
157
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
158
|
+
for (const e of entries) {
|
|
159
|
+
const full = path.join(dir, e.name);
|
|
160
|
+
if (e.isDirectory()) scan(full, depth + 1);
|
|
161
|
+
else if (e.isFile() && e.name.endsWith(ext)) {
|
|
162
|
+
try {
|
|
163
|
+
const mtime = fs.statSync(full).mtimeMs;
|
|
164
|
+
if (!best || mtime > best.mtime) best = { path: full, mtime };
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const dir of dirs) scan(dir);
|
|
171
|
+
return best;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
let _cache: TranscriptResult | undefined;
|
|
177
|
+
let _cacheAt = 0;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Returns the model from the most recently modified AI session transcript,
|
|
181
|
+
* across all supported tools. Result is cached for 60 s.
|
|
182
|
+
*/
|
|
183
|
+
export function scanTranscripts(): TranscriptResult | undefined {
|
|
184
|
+
if (_cache && Date.now() - _cacheAt < CACHE_TTL) return _cache;
|
|
185
|
+
|
|
186
|
+
let best: TranscriptResult | undefined;
|
|
187
|
+
|
|
188
|
+
for (const spec of SPECS) {
|
|
189
|
+
const file = newestFile(spec.dirs(), spec.ext);
|
|
190
|
+
if (!file) continue;
|
|
191
|
+
if (best && file.mtime <= best.mtime) continue;
|
|
192
|
+
|
|
193
|
+
const model = spec.extract(file.path);
|
|
194
|
+
if (!model) continue;
|
|
195
|
+
|
|
196
|
+
best = { model, tool: spec.tool, mtime: file.mtime };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_cache = best;
|
|
200
|
+
_cacheAt = Date.now();
|
|
201
|
+
return best;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function invalidateTranscriptCache(): void { _cache = undefined; _cacheAt = 0; }
|
package/src/daemon.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { FileProvenance } from './core/FileProvenance';
|
|
|
9
9
|
import type { StoredProvenance, LineHashBaseline } from './core/FileProvenance';
|
|
10
10
|
import { extensionIsActiveFor, writeDaemonHeartbeat } from './core/Heartbeat';
|
|
11
11
|
import { UI, COLORS } from './core/UI';
|
|
12
|
+
import { installAllToolHooks } from './core/ToolHookInstallers';
|
|
12
13
|
|
|
13
14
|
const PUSH_INTERVAL_MS = 60_000;
|
|
14
15
|
const IDLE_PUSH_MS = 10_000;
|
|
@@ -59,6 +60,9 @@ export function startDaemon(opts: { watchPath: string; projectName: string; bran
|
|
|
59
60
|
process.exit(1);
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
// Auto-install preToolUse hooks into Cursor, Windsurf, Codex if present.
|
|
64
|
+
installAllToolHooks();
|
|
65
|
+
|
|
62
66
|
const watchPath = path.resolve(opts.watchPath);
|
|
63
67
|
const projectName = opts.projectName;
|
|
64
68
|
const branch = opts.branch ?? gitBranch(watchPath);
|