@omnitype-code/cli 0.1.0
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/dist/blame.js +242 -0
- package/dist/core/ApiClient.js +234 -0
- package/dist/core/FileProvenance.js +483 -0
- package/dist/core/GitNotes.js +120 -0
- package/dist/core/Heartbeat.js +81 -0
- package/dist/core/ModelDetector.js +243 -0
- package/dist/core/ProvenanceResolver.js +424 -0
- package/dist/core/UI.js +97 -0
- package/dist/daemon.js +194 -0
- package/dist/hooks.js +220 -0
- package/dist/index.js +536 -0
- package/package.json +30 -0
- package/src/blame.ts +240 -0
- package/src/core/ApiClient.ts +197 -0
- package/src/core/FileProvenance.ts +538 -0
- package/src/core/GitNotes.ts +141 -0
- package/src/core/Heartbeat.ts +53 -0
- package/src/core/ModelDetector.ts +216 -0
- package/src/core/ProvenanceResolver.ts +433 -0
- package/src/core/UI.ts +105 -0
- package/src/daemon.ts +171 -0
- package/src/hooks.ts +195 -0
- package/src/index.ts +537 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CLI ModelDetector — editor-agnostic model detection.
|
|
4
|
+
*
|
|
5
|
+
* Detection tiers:
|
|
6
|
+
* 1. Universal sentinel (~/.omnitype/active-model.json)
|
|
7
|
+
* 2. Hooks sentinel file (~/.claude/provenance-hook.json)
|
|
8
|
+
* 3. Host IDE config (Cursor/Windsurf/Zed settings.json — scanned generically)
|
|
9
|
+
* 4. Config files (per-tool config paths: .aider.conf.yml, etc.)
|
|
10
|
+
* 5. Environment variables (CLAUDE_MODEL, AIDER_MODEL, etc.)
|
|
11
|
+
* 6. Process detection (lsof / ps — identifies the writing process)
|
|
12
|
+
*/
|
|
13
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
16
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
17
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(o, k2, desc);
|
|
20
|
+
}) : (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
o[k2] = m[k];
|
|
23
|
+
}));
|
|
24
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
25
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
26
|
+
}) : function(o, v) {
|
|
27
|
+
o["default"] = v;
|
|
28
|
+
});
|
|
29
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
30
|
+
var ownKeys = function(o) {
|
|
31
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
32
|
+
var ar = [];
|
|
33
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
34
|
+
return ar;
|
|
35
|
+
};
|
|
36
|
+
return ownKeys(o);
|
|
37
|
+
};
|
|
38
|
+
return function (mod) {
|
|
39
|
+
if (mod && mod.__esModule) return mod;
|
|
40
|
+
var result = {};
|
|
41
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
42
|
+
__setModuleDefault(result, mod);
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
})();
|
|
46
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.ModelDetector = void 0;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const os = __importStar(require("os"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const child_process_1 = require("child_process");
|
|
52
|
+
const UNKNOWN = { model: 'unknown', tool: 'unknown', confidence: 'low' };
|
|
53
|
+
const HOOKS_SENTINEL_PATH = path.join(os.homedir(), '.claude', 'provenance-hook.json');
|
|
54
|
+
const UNIVERSAL_SENTINEL_PATH = path.join(os.homedir(), '.omnitype', 'active-model.json');
|
|
55
|
+
const SENTINEL_MAX_AGE_MS = 10000;
|
|
56
|
+
const MODEL_PATTERN = /\b(claude-[\w.-]+|gpt-[\w.-]+|o[1234](?:-[\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;
|
|
57
|
+
// Maps known CLI/IDE env vars to their tool
|
|
58
|
+
const ENV_VARS = [
|
|
59
|
+
{ vars: ['CLAUDE_MODEL', 'CLAUDE_CODE_MODEL', 'ANTHROPIC_MODEL'], tool: 'claude-code' },
|
|
60
|
+
{ vars: ['AIDER_MODEL'], tool: 'aider' },
|
|
61
|
+
{ vars: ['OPENAI_MODEL', 'OPENAI_API_MODEL'], tool: 'openai' },
|
|
62
|
+
{ vars: ['GEMINI_MODEL', 'GEMINI_API_KEY'], tool: 'gemini-cli' },
|
|
63
|
+
{ vars: ['OLLAMA_MODEL'], tool: 'ollama' },
|
|
64
|
+
{ vars: ['COPILOT_MODEL'], tool: 'copilot' },
|
|
65
|
+
{ vars: ['LLM_MODEL'], tool: 'openhands' },
|
|
66
|
+
{ vars: ['TABBY_MODEL'], tool: 'tabby' },
|
|
67
|
+
];
|
|
68
|
+
const KNOWN_FORKS = [
|
|
69
|
+
'Cursor', 'Windsurf', 'PearAI', 'Void', 'Trae', 'Zed', 'Antigravity'
|
|
70
|
+
];
|
|
71
|
+
// lsof command-name → tool mapping
|
|
72
|
+
const LSOF_CMD_MAP = [
|
|
73
|
+
{ match: 'claude', tool: 'claude-code' },
|
|
74
|
+
{ match: 'aider', tool: 'aider' },
|
|
75
|
+
{ match: 'cursor', tool: 'cursor' },
|
|
76
|
+
{ match: 'windsurf', tool: 'windsurf' },
|
|
77
|
+
{ match: 'zed', tool: 'zed' },
|
|
78
|
+
{ match: 'pearai', tool: 'pearai' },
|
|
79
|
+
{ match: 'void', tool: 'void' },
|
|
80
|
+
{ match: 'tabby', tool: 'tabby' },
|
|
81
|
+
{ match: 'goose', tool: 'goose' },
|
|
82
|
+
{ match: 'node', tool: 'unknown-cli' },
|
|
83
|
+
{ match: 'python', tool: 'unknown-cli' },
|
|
84
|
+
];
|
|
85
|
+
class ModelDetector {
|
|
86
|
+
detect(changedFilePath) {
|
|
87
|
+
return (this._fromUniversalSentinel() ??
|
|
88
|
+
this._fromHooksSentinel() ??
|
|
89
|
+
this._fromIdeConfigs() ??
|
|
90
|
+
this._fromEnv() ??
|
|
91
|
+
this._fromPsPatterns() ??
|
|
92
|
+
(changedFilePath ? this._fromLsof(changedFilePath) : undefined) ??
|
|
93
|
+
UNKNOWN);
|
|
94
|
+
}
|
|
95
|
+
_fromUniversalSentinel() {
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.statSync(UNIVERSAL_SENTINEL_PATH);
|
|
98
|
+
if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS)
|
|
99
|
+
return undefined;
|
|
100
|
+
const data = JSON.parse(fs.readFileSync(UNIVERSAL_SENTINEL_PATH, 'utf8'));
|
|
101
|
+
if (!data?.model || data.model === 'unknown')
|
|
102
|
+
return undefined;
|
|
103
|
+
return { model: data.model, tool: data.tool ?? 'unknown-tool', confidence: 'deterministic' };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
_fromHooksSentinel() {
|
|
110
|
+
try {
|
|
111
|
+
const stat = fs.statSync(HOOKS_SENTINEL_PATH);
|
|
112
|
+
if (Date.now() - stat.mtimeMs > SENTINEL_MAX_AGE_MS)
|
|
113
|
+
return undefined;
|
|
114
|
+
const data = JSON.parse(fs.readFileSync(HOOKS_SENTINEL_PATH, 'utf8'));
|
|
115
|
+
if (!data?.model || data.model === 'unknown')
|
|
116
|
+
return undefined;
|
|
117
|
+
return { model: data.model, tool: data.tool ?? 'claude-code', confidence: 'deterministic' };
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
_fromIdeConfigs() {
|
|
124
|
+
for (const appName of KNOWN_FORKS) {
|
|
125
|
+
const scan = this._scanIdeSettings(appName);
|
|
126
|
+
if (scan) {
|
|
127
|
+
return {
|
|
128
|
+
model: scan.model,
|
|
129
|
+
tool: appName.toLowerCase(),
|
|
130
|
+
confidence: scan.isAuto ? 'medium' : 'high'
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
_fromEnv() {
|
|
137
|
+
for (const { vars, tool } of ENV_VARS) {
|
|
138
|
+
for (const v of vars) {
|
|
139
|
+
const val = process.env[v];
|
|
140
|
+
if (val && MODEL_PATTERN.test(val)) {
|
|
141
|
+
return { model: val, tool, confidence: 'high' };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
_fromPsPatterns() {
|
|
148
|
+
if (process.platform === 'win32')
|
|
149
|
+
return undefined;
|
|
150
|
+
try {
|
|
151
|
+
const lines = (0, child_process_1.execFileSync)('ps', ['ax', '-o', 'args='], { timeout: 800, encoding: 'utf8' }).split('\n');
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
for (const entry of LSOF_CMD_MAP) {
|
|
154
|
+
if (line.toLowerCase().includes(entry.match)) {
|
|
155
|
+
// Fallback model name
|
|
156
|
+
return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch { /* ps unavailable */ }
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
_fromLsof(filePath) {
|
|
165
|
+
if (process.platform === 'win32')
|
|
166
|
+
return undefined;
|
|
167
|
+
try {
|
|
168
|
+
const out = (0, child_process_1.execFileSync)('lsof', ['-F', 'c', filePath], { timeout: 800, encoding: 'utf8' });
|
|
169
|
+
for (const line of out.split('\n')) {
|
|
170
|
+
if (!line.startsWith('c'))
|
|
171
|
+
continue;
|
|
172
|
+
const cmd = line.slice(1).toLowerCase();
|
|
173
|
+
for (const entry of LSOF_CMD_MAP) {
|
|
174
|
+
if (cmd.includes(entry.match)) {
|
|
175
|
+
return { model: `${entry.tool}-default`, tool: entry.tool, confidence: 'low' };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
catch { /* lsof unavailable */ }
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
_scanIdeSettings(appName) {
|
|
184
|
+
for (const p of this._getIdeSettingsPaths(appName)) {
|
|
185
|
+
try {
|
|
186
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
187
|
+
const json = JSON.parse(raw);
|
|
188
|
+
const flat = this._flattenObject(json);
|
|
189
|
+
const AUTO_SENTINELS = new Set(['auto', 'cursor-auto', 'windsurf-auto', 'default', 'best']);
|
|
190
|
+
const candidates = [];
|
|
191
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
192
|
+
if (typeof value !== 'string' || !value)
|
|
193
|
+
continue;
|
|
194
|
+
if (!key.toLowerCase().includes('model'))
|
|
195
|
+
continue;
|
|
196
|
+
if (AUTO_SENTINELS.has(value.toLowerCase())) {
|
|
197
|
+
candidates.push({ key, value: `${appName.toLowerCase()}-auto` });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (MODEL_PATTERN.test(value)) {
|
|
201
|
+
candidates.push({ key, value: value.toLowerCase() });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (candidates.length > 0) {
|
|
205
|
+
const real = candidates.filter(c => !c.value.endsWith('-auto'));
|
|
206
|
+
const pick = real.length > 0
|
|
207
|
+
? real.sort((a, b) => b.key.length - a.key.length)[0]
|
|
208
|
+
: candidates.sort((a, b) => b.key.length - a.key.length)[0];
|
|
209
|
+
return { model: pick.value, isAuto: pick.value.endsWith('-auto') };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch { /* absent */ }
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
_getIdeSettingsPaths(appName) {
|
|
217
|
+
const toolId = appName.toLowerCase().replace(/\s+/g, '-');
|
|
218
|
+
const dirCandidates = [...new Set([appName, toolId])];
|
|
219
|
+
return dirCandidates.map(dir => {
|
|
220
|
+
switch (process.platform) {
|
|
221
|
+
case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', dir, 'User', 'settings.json');
|
|
222
|
+
case 'win32': return path.join(process.env.APPDATA ?? '', dir, 'User', 'settings.json');
|
|
223
|
+
default: return path.join(os.homedir(), '.config', dir, 'User', 'settings.json');
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
_flattenObject(obj, prefix = '', depth = 0) {
|
|
228
|
+
if (depth > 5)
|
|
229
|
+
return {};
|
|
230
|
+
const out = {};
|
|
231
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
232
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
233
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
234
|
+
Object.assign(out, this._flattenObject(v, key, depth + 1));
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
out[key] = v;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
exports.ModelDetector = ModelDetector;
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ProvenanceResolver — merges git notes + .omni (StoredProvenance) into a
|
|
4
|
+
* single authoritative hash→LineInfo map for a file.
|
|
5
|
+
*
|
|
6
|
+
* Precedence (higher wins):
|
|
7
|
+
* ai(3) > paste(2) > user(1) > existing(0)
|
|
8
|
+
*
|
|
9
|
+
* Rule:
|
|
10
|
+
* - git notes are authoritative for committed history
|
|
11
|
+
* - .omni fills gaps where notes say 'existing'
|
|
12
|
+
* - own tracked (non-existing) origins are NEVER overwritten
|
|
13
|
+
* - trivial lines (charLen ≤ MIN_HINT_CHARLEN) are skipped to guard
|
|
14
|
+
* against FNV hash collisions
|
|
15
|
+
*/
|
|
16
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
+
}
|
|
22
|
+
Object.defineProperty(o, k2, desc);
|
|
23
|
+
}) : (function(o, m, k, k2) {
|
|
24
|
+
if (k2 === undefined) k2 = k;
|
|
25
|
+
o[k2] = m[k];
|
|
26
|
+
}));
|
|
27
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
+
}) : function(o, v) {
|
|
30
|
+
o["default"] = v;
|
|
31
|
+
});
|
|
32
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
+
var ownKeys = function(o) {
|
|
34
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
+
var ar = [];
|
|
36
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
+
return ar;
|
|
38
|
+
};
|
|
39
|
+
return ownKeys(o);
|
|
40
|
+
};
|
|
41
|
+
return function (mod) {
|
|
42
|
+
if (mod && mod.__esModule) return mod;
|
|
43
|
+
var result = {};
|
|
44
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
+
__setModuleDefault(result, mod);
|
|
46
|
+
return result;
|
|
47
|
+
};
|
|
48
|
+
})();
|
|
49
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
+
exports.fnv1a = fnv1a;
|
|
51
|
+
exports.mergeHints = mergeHints;
|
|
52
|
+
exports.hintsFromGitNotes = hintsFromGitNotes;
|
|
53
|
+
exports.lineMapFromOmni = lineMapFromOmni;
|
|
54
|
+
exports.applyHints = applyHints;
|
|
55
|
+
exports.lineMapFromApiStored = lineMapFromApiStored;
|
|
56
|
+
exports.resolveFileLinesAsync = resolveFileLinesAsync;
|
|
57
|
+
exports.resolveFileLines = resolveFileLines;
|
|
58
|
+
exports.getCurrentBranch = getCurrentBranch;
|
|
59
|
+
exports.findRepoRoot = findRepoRoot;
|
|
60
|
+
const child_process_1 = require("child_process");
|
|
61
|
+
const fs = __importStar(require("fs"));
|
|
62
|
+
const path = __importStar(require("path"));
|
|
63
|
+
const GitNotes_1 = require("./GitNotes");
|
|
64
|
+
// Origin precedence: ai=3, paste=2, user=1, existing=0
|
|
65
|
+
const ORIGIN_PREC = { existing: 0, user: 1, paste: 2, ai: 3 };
|
|
66
|
+
const ORIGIN_DECODE = ['existing', 'user', 'ai', 'paste'];
|
|
67
|
+
const MIN_HINT_CHARLEN = 8;
|
|
68
|
+
// ── FNV-1a 32-bit (matches extension's TypingTracker.hashLine) ───────────────
|
|
69
|
+
function fnv1a(s) {
|
|
70
|
+
const canonical = s.trim();
|
|
71
|
+
let h = 2166136261 >>> 0;
|
|
72
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
73
|
+
h = (h ^ canonical.charCodeAt(i)) >>> 0;
|
|
74
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
75
|
+
}
|
|
76
|
+
return h;
|
|
77
|
+
}
|
|
78
|
+
// ── Merge helpers ─────────────────────────────────────────────────────────────
|
|
79
|
+
/** Merge two HintMaps — higher precedence origin wins per hash. */
|
|
80
|
+
function mergeHints(...maps) {
|
|
81
|
+
const out = new Map();
|
|
82
|
+
for (const map of maps) {
|
|
83
|
+
for (const [hash, info] of map) {
|
|
84
|
+
const prev = out.get(hash);
|
|
85
|
+
if (!prev || ORIGIN_PREC[info.origin] > ORIGIN_PREC[prev.origin]) {
|
|
86
|
+
out.set(hash, info);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
// ── Source: git notes ─────────────────────────────────────────────────────────
|
|
93
|
+
/**
|
|
94
|
+
* Build a HintMap by scanning git notes on the last N commits for a file.
|
|
95
|
+
* Returns hash→LineInfo by hashing each line's content from the working tree.
|
|
96
|
+
*/
|
|
97
|
+
function hintsFromGitNotes(repoPath, relPath, absPath, maxCommits = 50) {
|
|
98
|
+
const map = new Map();
|
|
99
|
+
// Read current file to build hash→lineContent lookup
|
|
100
|
+
let fileLines;
|
|
101
|
+
try {
|
|
102
|
+
fileLines = fs.readFileSync(absPath, 'utf8').split('\n');
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return map;
|
|
106
|
+
}
|
|
107
|
+
// Scan recent commits for a note that covers this file
|
|
108
|
+
let ranges;
|
|
109
|
+
try {
|
|
110
|
+
const log = (0, child_process_1.execFileSync)('git', ['-C', repoPath, 'log', '--format=%H', `-${maxCommits}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n').filter(Boolean);
|
|
111
|
+
for (const sha of log) {
|
|
112
|
+
const note = (0, GitNotes_1.readNote)(repoPath, sha);
|
|
113
|
+
// Extension writes git note keys with a leading '/' (same as .omni keys)
|
|
114
|
+
const fileData = note?.files[relPath] ?? note?.files['/' + relPath];
|
|
115
|
+
if (fileData) {
|
|
116
|
+
ranges = fileData;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return map;
|
|
123
|
+
}
|
|
124
|
+
if (!ranges)
|
|
125
|
+
return map;
|
|
126
|
+
// Expand ranges → line number → LineInfo, then hash the actual line content
|
|
127
|
+
for (const r of ranges) {
|
|
128
|
+
if (r.origin === 'existing')
|
|
129
|
+
continue;
|
|
130
|
+
for (let ln = r.start; ln <= r.end; ln++) {
|
|
131
|
+
const content = fileLines[ln - 1]; // 1-indexed
|
|
132
|
+
if (content === undefined)
|
|
133
|
+
continue;
|
|
134
|
+
if (content.trim().length < MIN_HINT_CHARLEN)
|
|
135
|
+
continue;
|
|
136
|
+
const hash = fnv1a(content);
|
|
137
|
+
const prev = map.get(hash);
|
|
138
|
+
if (!prev || ORIGIN_PREC[r.origin] > ORIGIN_PREC[prev.origin]) {
|
|
139
|
+
map.set(hash, { origin: r.origin, model: r.model, tool: r.tool });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return map;
|
|
144
|
+
}
|
|
145
|
+
// ── Source: .omni file ────────────────────────────────────────────────────────
|
|
146
|
+
/** Read .omni in repoPath and return a HintMap for the given branch + relPath. */
|
|
147
|
+
/**
|
|
148
|
+
* Load .omni and reconcile stored line hashes against current file content
|
|
149
|
+
* using LCS — same algorithm as FileProvenance.fromStored() in the extension.
|
|
150
|
+
*
|
|
151
|
+
* Returns a 1-indexed lineNumber → LineInfo map for the current file.
|
|
152
|
+
* Lines that don't match any stored line get no entry (treated as existing).
|
|
153
|
+
*/
|
|
154
|
+
function lineMapFromOmni(repoPath, branch, relPath, currentLines) {
|
|
155
|
+
const result = new Map();
|
|
156
|
+
const omniPath = path.join(repoPath, '.omni');
|
|
157
|
+
if (!fs.existsSync(omniPath))
|
|
158
|
+
return result;
|
|
159
|
+
try {
|
|
160
|
+
const disk = JSON.parse(fs.readFileSync(omniPath, 'utf8'));
|
|
161
|
+
const branchData = disk[branch] ?? disk[Object.keys(disk)[0]];
|
|
162
|
+
if (!branchData)
|
|
163
|
+
return result;
|
|
164
|
+
// .omni keys use URI-relative paths with a leading '/' (extension writes '/src/foo.ts').
|
|
165
|
+
// Prefer the '/'-prefixed key — it is always the canonical extension-written entry.
|
|
166
|
+
// Fall back to the unprefixed form for CLI-written entries.
|
|
167
|
+
const stored = branchData['/' + relPath] ?? branchData[relPath];
|
|
168
|
+
if (!stored)
|
|
169
|
+
return result;
|
|
170
|
+
const storedHashes = stored.hashes;
|
|
171
|
+
const currentHashes = currentLines.map(l => fnv1a(l));
|
|
172
|
+
const m = storedHashes.length;
|
|
173
|
+
const n = currentHashes.length;
|
|
174
|
+
// LCS table (same as FileProvenance.fromStored)
|
|
175
|
+
const MAX_LCS = 3000; // match extension's FileProvenance.MAX_LCS_SIDE
|
|
176
|
+
if (m > MAX_LCS || n > MAX_LCS) {
|
|
177
|
+
// Fall back to direct hash lookup for very large files
|
|
178
|
+
const hashToInfo = new Map();
|
|
179
|
+
for (let i = 0; i < m; i++) {
|
|
180
|
+
const origin = ORIGIN_DECODE[stored.origins[i] ?? 0] ?? 'existing';
|
|
181
|
+
if (origin === 'existing')
|
|
182
|
+
continue;
|
|
183
|
+
const modelId = stored.modelIds?.[i] ?? -1;
|
|
184
|
+
const model = modelId >= 0 ? stored.modelDictionary?.[modelId] : undefined;
|
|
185
|
+
const toolId = stored.toolIds?.[i] ?? -1;
|
|
186
|
+
const tool = toolId >= 0 ? stored.toolDictionary?.[toolId] : undefined;
|
|
187
|
+
hashToInfo.set(storedHashes[i], { origin, model, tool });
|
|
188
|
+
}
|
|
189
|
+
currentLines.forEach((line, idx) => {
|
|
190
|
+
const info = hashToInfo.get(currentHashes[idx]);
|
|
191
|
+
if (info && info.origin !== 'existing')
|
|
192
|
+
result.set(idx + 1, info);
|
|
193
|
+
});
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
const t = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
197
|
+
for (let i = 1; i <= m; i++) {
|
|
198
|
+
for (let j = 1; j <= n; j++) {
|
|
199
|
+
t[i][j] = storedHashes[i - 1] === currentHashes[j - 1]
|
|
200
|
+
? t[i - 1][j - 1] + 1
|
|
201
|
+
: Math.max(t[i - 1][j], t[i][j - 1]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Backtrack — assign stored origins to matched current lines
|
|
205
|
+
let i = m, j = n;
|
|
206
|
+
while (i > 0 && j > 0) {
|
|
207
|
+
if (storedHashes[i - 1] === currentHashes[j - 1] && t[i][j] === t[i - 1][j - 1] + 1) {
|
|
208
|
+
const origin = ORIGIN_DECODE[stored.origins[i - 1] ?? 0] ?? 'existing';
|
|
209
|
+
if (origin !== 'existing') {
|
|
210
|
+
const modelId = stored.modelIds?.[i - 1] ?? -1;
|
|
211
|
+
const model = modelId >= 0 ? stored.modelDictionary?.[modelId] : undefined;
|
|
212
|
+
const toolId = stored.toolIds?.[i - 1] ?? -1;
|
|
213
|
+
const tool = toolId >= 0 ? stored.toolDictionary?.[toolId] : undefined;
|
|
214
|
+
result.set(j, { origin, model, tool }); // j is 1-indexed current line
|
|
215
|
+
}
|
|
216
|
+
i--;
|
|
217
|
+
j--;
|
|
218
|
+
}
|
|
219
|
+
else if (t[i - 1][j] >= t[i][j - 1]) {
|
|
220
|
+
i--;
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
j--;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch { /* corrupt .omni */ }
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
// ── Apply hints to StoredProvenance ──────────────────────────────────────────
|
|
231
|
+
/**
|
|
232
|
+
* Upgrade 'existing' lines in a StoredProvenance using a HintMap.
|
|
233
|
+
* Only lines with origin=existing (0) are touched — own tracked origins stay.
|
|
234
|
+
* Returns true if any lines were upgraded.
|
|
235
|
+
*/
|
|
236
|
+
function applyHints(stored, hints) {
|
|
237
|
+
if (hints.size === 0)
|
|
238
|
+
return false;
|
|
239
|
+
let changed = false;
|
|
240
|
+
for (let i = 0; i < stored.hashes.length; i++) {
|
|
241
|
+
if (stored.origins[i] !== 0)
|
|
242
|
+
continue; // never overwrite tracked origin
|
|
243
|
+
if ((stored.charLens[i] ?? 0) < MIN_HINT_CHARLEN)
|
|
244
|
+
continue;
|
|
245
|
+
const hint = hints.get(stored.hashes[i]);
|
|
246
|
+
if (!hint || hint.origin === 'existing')
|
|
247
|
+
continue;
|
|
248
|
+
const originCode = ORIGIN_DECODE.indexOf(hint.origin);
|
|
249
|
+
if (originCode < 0)
|
|
250
|
+
continue;
|
|
251
|
+
stored.origins[i] = originCode;
|
|
252
|
+
changed = true;
|
|
253
|
+
// Inject model
|
|
254
|
+
if (hint.model) {
|
|
255
|
+
stored.modelDictionary ?? (stored.modelDictionary = []);
|
|
256
|
+
stored.modelIds ?? (stored.modelIds = new Array(stored.hashes.length).fill(-1));
|
|
257
|
+
let mIdx = stored.modelDictionary.indexOf(hint.model);
|
|
258
|
+
if (mIdx === -1) {
|
|
259
|
+
mIdx = stored.modelDictionary.length;
|
|
260
|
+
stored.modelDictionary.push(hint.model);
|
|
261
|
+
}
|
|
262
|
+
stored.modelIds[i] = mIdx;
|
|
263
|
+
}
|
|
264
|
+
// Inject tool
|
|
265
|
+
if (hint.tool) {
|
|
266
|
+
stored.toolDictionary ?? (stored.toolDictionary = []);
|
|
267
|
+
stored.toolIds ?? (stored.toolIds = new Array(stored.hashes.length).fill(-1));
|
|
268
|
+
let tIdx = stored.toolDictionary.indexOf(hint.tool);
|
|
269
|
+
if (tIdx === -1) {
|
|
270
|
+
tIdx = stored.toolDictionary.length;
|
|
271
|
+
stored.toolDictionary.push(hint.tool);
|
|
272
|
+
}
|
|
273
|
+
stored.toolIds[i] = tIdx;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return changed;
|
|
277
|
+
}
|
|
278
|
+
// ── High-level: resolve a file's full line map ────────────────────────────────
|
|
279
|
+
/**
|
|
280
|
+
* Build a lineNumber→LineInfo map from a StoredProvenance fetched from the API.
|
|
281
|
+
* Uses LCS reconciliation against current file lines — same as lineMapFromOmni.
|
|
282
|
+
*/
|
|
283
|
+
function lineMapFromApiStored(stored, currentLines) {
|
|
284
|
+
return lineMapFromStoredInner(stored, currentLines);
|
|
285
|
+
}
|
|
286
|
+
function lineMapFromStoredInner(stored, currentLines) {
|
|
287
|
+
const result = new Map();
|
|
288
|
+
const storedHashes = stored.hashes;
|
|
289
|
+
const currentHashes = currentLines.map(l => fnv1a(l));
|
|
290
|
+
const m = storedHashes.length;
|
|
291
|
+
const n = currentHashes.length;
|
|
292
|
+
const MAX_LCS = 3000; // match extension's FileProvenance.MAX_LCS_SIDE
|
|
293
|
+
if (m > MAX_LCS || n > MAX_LCS) {
|
|
294
|
+
const hashToInfo = new Map();
|
|
295
|
+
for (let i = 0; i < m; i++) {
|
|
296
|
+
const origin = ORIGIN_DECODE[stored.origins[i] ?? 0] ?? 'existing';
|
|
297
|
+
if (origin === 'existing')
|
|
298
|
+
continue;
|
|
299
|
+
const modelId = stored.modelIds?.[i] ?? -1;
|
|
300
|
+
const model = modelId >= 0 ? stored.modelDictionary?.[modelId] : undefined;
|
|
301
|
+
const toolId = stored.toolIds?.[i] ?? -1;
|
|
302
|
+
const tool = toolId >= 0 ? stored.toolDictionary?.[toolId] : undefined;
|
|
303
|
+
hashToInfo.set(storedHashes[i], { origin, model, tool });
|
|
304
|
+
}
|
|
305
|
+
currentLines.forEach((line, idx) => {
|
|
306
|
+
const info = hashToInfo.get(currentHashes[idx]);
|
|
307
|
+
if (info && info.origin !== 'existing')
|
|
308
|
+
result.set(idx + 1, info);
|
|
309
|
+
});
|
|
310
|
+
return result;
|
|
311
|
+
}
|
|
312
|
+
const t = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
313
|
+
for (let i = 1; i <= m; i++)
|
|
314
|
+
for (let j = 1; j <= n; j++)
|
|
315
|
+
t[i][j] = storedHashes[i - 1] === currentHashes[j - 1]
|
|
316
|
+
? t[i - 1][j - 1] + 1
|
|
317
|
+
: Math.max(t[i - 1][j], t[i][j - 1]);
|
|
318
|
+
let i = m, j = n;
|
|
319
|
+
while (i > 0 && j > 0) {
|
|
320
|
+
if (storedHashes[i - 1] === currentHashes[j - 1] && t[i][j] === t[i - 1][j - 1] + 1) {
|
|
321
|
+
const origin = ORIGIN_DECODE[stored.origins[i - 1] ?? 0] ?? 'existing';
|
|
322
|
+
if (origin !== 'existing') {
|
|
323
|
+
const modelId = stored.modelIds?.[i - 1] ?? -1;
|
|
324
|
+
const model = modelId >= 0 ? stored.modelDictionary?.[modelId] : undefined;
|
|
325
|
+
const toolId = stored.toolIds?.[i - 1] ?? -1;
|
|
326
|
+
const tool = toolId >= 0 ? stored.toolDictionary?.[toolId] : undefined;
|
|
327
|
+
result.set(j, { origin, model, tool });
|
|
328
|
+
}
|
|
329
|
+
i--;
|
|
330
|
+
j--;
|
|
331
|
+
}
|
|
332
|
+
else if (t[i - 1][j] >= t[i][j - 1]) {
|
|
333
|
+
i--;
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
j--;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Resolve the authoritative LineInfo for every line of a file.
|
|
343
|
+
* Priority: API (live cloud data) > git notes > .omni
|
|
344
|
+
*
|
|
345
|
+
* Used by `omnitype blame` and any CLI command that needs per-line attribution.
|
|
346
|
+
*/
|
|
347
|
+
async function resolveFileLinesAsync(repoPath, relPath, absPath, branch, projectName) {
|
|
348
|
+
let currentLines;
|
|
349
|
+
try {
|
|
350
|
+
currentLines = fs.readFileSync(absPath, 'utf8').split('\n');
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return new Map();
|
|
354
|
+
}
|
|
355
|
+
// Start with local sources (.omni + git notes) — most complete for current device
|
|
356
|
+
const localMap = resolveFileLines(repoPath, relPath, absPath, branch);
|
|
357
|
+
// Merge with API — fills lines local doesn't have (pushed from other devices)
|
|
358
|
+
// Local wins when it already has attribution; API only adds what's missing
|
|
359
|
+
try {
|
|
360
|
+
const { ApiClient } = await Promise.resolve().then(() => __importStar(require('./ApiClient')));
|
|
361
|
+
const api = new ApiClient();
|
|
362
|
+
if (api.isSignedIn) {
|
|
363
|
+
const files = await api.pullProvenance(projectName, [relPath, '/' + relPath]);
|
|
364
|
+
const apiStored = files?.[relPath] ?? files?.['/' + relPath];
|
|
365
|
+
if (apiStored?.hashes?.length) {
|
|
366
|
+
const apiMap = lineMapFromStoredInner(apiStored, currentLines);
|
|
367
|
+
for (const [lineNum, apiInfo] of apiMap) {
|
|
368
|
+
if (!localMap.has(lineNum)) {
|
|
369
|
+
// API has data local doesn't — add it
|
|
370
|
+
localMap.set(lineNum, apiInfo);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch { /* use local only */ }
|
|
377
|
+
return localMap;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Synchronous fallback — .omni + git notes only.
|
|
381
|
+
*/
|
|
382
|
+
function resolveFileLines(repoPath, relPath, absPath, branch) {
|
|
383
|
+
let currentLines;
|
|
384
|
+
try {
|
|
385
|
+
currentLines = fs.readFileSync(absPath, 'utf8').split('\n');
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return new Map();
|
|
389
|
+
}
|
|
390
|
+
const omniLineMap = lineMapFromOmni(repoPath, branch, relPath, currentLines);
|
|
391
|
+
const gitHints = hintsFromGitNotes(repoPath, relPath, absPath);
|
|
392
|
+
const lineMap = new Map(omniLineMap);
|
|
393
|
+
for (const [lineNum, info] of lineMap) {
|
|
394
|
+
const gitInfo = gitHints.get(fnv1a(currentLines[lineNum - 1] ?? ''));
|
|
395
|
+
if (gitInfo && ORIGIN_PREC[gitInfo.origin] > ORIGIN_PREC[info.origin]) {
|
|
396
|
+
lineMap.set(lineNum, gitInfo);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
currentLines.forEach((content, idx) => {
|
|
400
|
+
const lineNum = idx + 1;
|
|
401
|
+
if (lineMap.has(lineNum))
|
|
402
|
+
return;
|
|
403
|
+
const gitInfo = gitHints.get(fnv1a(content));
|
|
404
|
+
if (gitInfo && gitInfo.origin !== 'existing')
|
|
405
|
+
lineMap.set(lineNum, gitInfo);
|
|
406
|
+
});
|
|
407
|
+
return lineMap;
|
|
408
|
+
}
|
|
409
|
+
function getCurrentBranch(repoPath) {
|
|
410
|
+
try {
|
|
411
|
+
return (0, child_process_1.execFileSync)('git', ['-C', repoPath, 'rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return 'main';
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function findRepoRoot(startPath) {
|
|
418
|
+
try {
|
|
419
|
+
return (0, child_process_1.execFileSync)('git', ['-C', path.dirname(startPath), 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
}
|