@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,483 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* FileProvenance — character-level origin tracking for a single file.
|
|
4
|
+
*
|
|
5
|
+
* Every character belongs to one of four origins:
|
|
6
|
+
* 'ai' — inserted by an AI tool
|
|
7
|
+
* 'user' — typed manually
|
|
8
|
+
* 'paste' — pasted from clipboard
|
|
9
|
+
* 'existing' — present before tracking began (unknown origin)
|
|
10
|
+
*
|
|
11
|
+
* Internally the file is an ordered array of contiguous Spans covering
|
|
12
|
+
* [start, end) character offsets. Adjacent spans with the same origin
|
|
13
|
+
* are coalesced automatically.
|
|
14
|
+
*
|
|
15
|
+
* For external disk writes we accept a LineHashBaseline (hashes + char lengths)
|
|
16
|
+
* instead of raw text — no source code is ever stored or reconstructed.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.FileProvenance = void 0;
|
|
20
|
+
/** Compact origin encoding: 0=existing, 1=user, 2=ai, 3=paste */
|
|
21
|
+
const ORIGIN_ENCODE = { existing: 0, user: 1, ai: 2, paste: 3 };
|
|
22
|
+
const ORIGIN_DECODE = ['existing', 'user', 'ai', 'paste'];
|
|
23
|
+
class FileProvenance {
|
|
24
|
+
constructor(existingLength) {
|
|
25
|
+
this.aiEditedByManual = 0;
|
|
26
|
+
this.manualEditedByAi = 0;
|
|
27
|
+
/** File-local model dictionary for string interning. */
|
|
28
|
+
this._modelDict = [];
|
|
29
|
+
this.spans = existingLength > 0
|
|
30
|
+
? [{ start: 0, end: existingLength, origin: 'existing', timestamp: 0 }]
|
|
31
|
+
: [];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Intern a model name and return its numeric ID.
|
|
35
|
+
* Returns undefined for falsy/unknown model names.
|
|
36
|
+
*/
|
|
37
|
+
internModel(model) {
|
|
38
|
+
if (!model || model === 'unknown')
|
|
39
|
+
return undefined;
|
|
40
|
+
let idx = this._modelDict.indexOf(model);
|
|
41
|
+
if (idx === -1) {
|
|
42
|
+
idx = this._modelDict.length;
|
|
43
|
+
this._modelDict.push(model);
|
|
44
|
+
}
|
|
45
|
+
return idx;
|
|
46
|
+
}
|
|
47
|
+
/** Resolve a modelId back to its string name. */
|
|
48
|
+
resolveModel(modelId) {
|
|
49
|
+
if (modelId === undefined || modelId < 0 || modelId >= this._modelDict.length)
|
|
50
|
+
return undefined;
|
|
51
|
+
return this._modelDict[modelId];
|
|
52
|
+
}
|
|
53
|
+
/** Get the full model dictionary (for serialization). */
|
|
54
|
+
getModelDictionary() {
|
|
55
|
+
return this._modelDict;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Adjust the length of the span(s) at `offset` without changing origin attribution.
|
|
59
|
+
* Used for "silent" updates like indentation/formatting where the line identity
|
|
60
|
+
* (hash) remains the same but the number of whitespace characters changed.
|
|
61
|
+
*/
|
|
62
|
+
adjustLength(offset, oldLen, newLen) {
|
|
63
|
+
if (oldLen === newLen)
|
|
64
|
+
return;
|
|
65
|
+
const delta = newLen - oldLen;
|
|
66
|
+
const idx = this._spanAt(offset);
|
|
67
|
+
if (idx === -1)
|
|
68
|
+
return;
|
|
69
|
+
// We extend/shrink the span that contains the offset.
|
|
70
|
+
// Usually this is the start of a line.
|
|
71
|
+
this.spans[idx].end += delta;
|
|
72
|
+
this._shiftFrom(idx + 1, delta);
|
|
73
|
+
this._coalesce();
|
|
74
|
+
}
|
|
75
|
+
// ── Core operations ──────────────────────────────────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* Insert `length` characters at `offset` with the given origin.
|
|
78
|
+
* timestamp defaults to Date.now() for tracked origins, 0 for 'existing'.
|
|
79
|
+
*/
|
|
80
|
+
insert(offset, length, origin, timestamp, modelId) {
|
|
81
|
+
if (length <= 0)
|
|
82
|
+
return;
|
|
83
|
+
const ts = timestamp ?? (origin === 'existing' ? 0 : Date.now());
|
|
84
|
+
const idx = this._spanAt(offset);
|
|
85
|
+
if (idx === -1) {
|
|
86
|
+
const last = this.spans[this.spans.length - 1];
|
|
87
|
+
const start = last ? last.end : 0;
|
|
88
|
+
this.spans.push({ start, end: start + length, origin, modelId, timestamp: ts });
|
|
89
|
+
this._coalesce();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const span = this.spans[idx];
|
|
93
|
+
if (span.start === offset) {
|
|
94
|
+
this.spans.splice(idx, 0, { start: offset, end: offset + length, origin, modelId, timestamp: ts });
|
|
95
|
+
this._shiftFrom(idx + 1, length);
|
|
96
|
+
}
|
|
97
|
+
else if (span.end === offset) {
|
|
98
|
+
this.spans.splice(idx + 1, 0, { start: offset, end: offset + length, origin, modelId, timestamp: ts });
|
|
99
|
+
this._shiftFrom(idx + 2, length);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// Split span — right half inherits the original span's timestamp and modelId
|
|
103
|
+
const right = { start: offset + length, end: span.end + length, origin: span.origin, modelId: span.modelId, timestamp: span.timestamp };
|
|
104
|
+
span.end = offset;
|
|
105
|
+
this.spans.splice(idx + 1, 0, { start: offset, end: offset + length, origin, modelId, timestamp: ts }, right);
|
|
106
|
+
this._shiftFrom(idx + 3, length);
|
|
107
|
+
}
|
|
108
|
+
this._coalesce();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Delete `length` characters starting at `offset`.
|
|
112
|
+
* Returns the composition of the deleted segment.
|
|
113
|
+
*/
|
|
114
|
+
delete(offset, length) {
|
|
115
|
+
const deleted = { ai: 0, user: 0, paste: 0, existing: 0 };
|
|
116
|
+
if (length <= 0)
|
|
117
|
+
return deleted;
|
|
118
|
+
const end = offset + length;
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < this.spans.length) {
|
|
121
|
+
const s = this.spans[i];
|
|
122
|
+
if (s.end <= offset) {
|
|
123
|
+
i++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (s.start >= end)
|
|
127
|
+
break;
|
|
128
|
+
const overlapLen = Math.min(s.end, end) - Math.max(s.start, offset);
|
|
129
|
+
deleted[s.origin] += overlapLen;
|
|
130
|
+
s.end -= overlapLen;
|
|
131
|
+
if (s.start >= s.end) {
|
|
132
|
+
this.spans.splice(i, 1);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (let j = i; j < this.spans.length; j++) {
|
|
139
|
+
this.spans[j].start -= length;
|
|
140
|
+
this.spans[j].end -= length;
|
|
141
|
+
}
|
|
142
|
+
this._coalesce();
|
|
143
|
+
return deleted;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Replace [offset, offset+deleteLen) with insertLen chars of origin.
|
|
147
|
+
* Returns a delta indicating what was added and what was deleted.
|
|
148
|
+
*/
|
|
149
|
+
replace(offset, deleteLen, insertLen, origin, modelId) {
|
|
150
|
+
const res = {
|
|
151
|
+
added: insertLen,
|
|
152
|
+
deleted: { ai: 0, user: 0, paste: 0, existing: 0 },
|
|
153
|
+
aiEditedByManual: 0,
|
|
154
|
+
manualEditedByAi: 0
|
|
155
|
+
};
|
|
156
|
+
if (deleteLen > 0) {
|
|
157
|
+
const del = this.delete(offset, deleteLen);
|
|
158
|
+
Object.assign(res.deleted, del);
|
|
159
|
+
// Attribution: if a user edit replaces AI work, track that "edit"
|
|
160
|
+
if (origin === 'user' && res.deleted.ai > 0) {
|
|
161
|
+
const amt = Math.min(res.deleted.ai, insertLen);
|
|
162
|
+
this.aiEditedByManual += amt;
|
|
163
|
+
res.aiEditedByManual += amt;
|
|
164
|
+
}
|
|
165
|
+
if (origin === 'ai' && res.deleted.user > 0) {
|
|
166
|
+
const amt = Math.min(res.deleted.user, insertLen);
|
|
167
|
+
this.manualEditedByAi += amt;
|
|
168
|
+
res.manualEditedByAi += amt;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (insertLen > 0)
|
|
172
|
+
this.insert(offset, insertLen, origin, undefined, modelId);
|
|
173
|
+
return res;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Apply a diff between an old baseline and a new baseline, attributing
|
|
177
|
+
* all added/changed lines to `origin`.
|
|
178
|
+
*/
|
|
179
|
+
applyLineDiff(oldBaseline, newBaseline, origin, modelId) {
|
|
180
|
+
const res = {
|
|
181
|
+
added: 0,
|
|
182
|
+
deleted: { ai: 0, user: 0, paste: 0, existing: 0 },
|
|
183
|
+
aiEditedByManual: 0,
|
|
184
|
+
manualEditedByAi: 0
|
|
185
|
+
};
|
|
186
|
+
const m = oldBaseline.length;
|
|
187
|
+
const n = newBaseline.length;
|
|
188
|
+
const t = this._lcsTable(oldBaseline, newBaseline);
|
|
189
|
+
const ops = [];
|
|
190
|
+
let i = m, j = n;
|
|
191
|
+
while (i > 0 || j > 0) {
|
|
192
|
+
if (i > 0 && j > 0 && oldBaseline[i - 1].hash === newBaseline[j - 1].hash && t[i][j] === t[i - 1][j - 1] + 1) {
|
|
193
|
+
ops.push({ type: 'match', oi: i - 1, ni: j - 1 });
|
|
194
|
+
i--;
|
|
195
|
+
j--;
|
|
196
|
+
}
|
|
197
|
+
else if (i > 0 && (j === 0 || t[i - 1][j] >= t[i][j - 1])) {
|
|
198
|
+
ops.push({ type: 'delete', oi: i - 1, ni: -1 });
|
|
199
|
+
i--;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
ops.push({ type: 'add', oi: -1, ni: j - 1 });
|
|
203
|
+
j--;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
ops.reverse();
|
|
207
|
+
// 2. Apply operations forward to maintain correct character offsets
|
|
208
|
+
let offset = 0;
|
|
209
|
+
let lastDeleted = null;
|
|
210
|
+
for (const op of ops) {
|
|
211
|
+
if (op.type === 'match') {
|
|
212
|
+
const olen = oldBaseline[op.oi].charLen;
|
|
213
|
+
const nlen = newBaseline[op.ni].charLen;
|
|
214
|
+
if (olen !== nlen)
|
|
215
|
+
this.adjustLength(offset, olen, nlen);
|
|
216
|
+
offset += nlen;
|
|
217
|
+
lastDeleted = null;
|
|
218
|
+
}
|
|
219
|
+
else if (op.type === 'add') {
|
|
220
|
+
const len = newBaseline[op.ni].charLen;
|
|
221
|
+
this.insert(offset, len, origin, undefined, modelId);
|
|
222
|
+
res.added += len;
|
|
223
|
+
// Tracking transitions for consecutive replacements
|
|
224
|
+
if (lastDeleted) {
|
|
225
|
+
if (origin === 'user' && lastDeleted.ai > 0) {
|
|
226
|
+
const amt = Math.min(lastDeleted.ai, len);
|
|
227
|
+
this.aiEditedByManual += amt;
|
|
228
|
+
res.aiEditedByManual += amt;
|
|
229
|
+
}
|
|
230
|
+
else if (origin === 'ai' && lastDeleted.user > 0) {
|
|
231
|
+
const amt = Math.min(lastDeleted.user, len);
|
|
232
|
+
this.manualEditedByAi += amt;
|
|
233
|
+
res.manualEditedByAi += amt;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
offset += len;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
const len = oldBaseline[op.oi].charLen;
|
|
240
|
+
const del = this.delete(offset, len);
|
|
241
|
+
for (const k in del)
|
|
242
|
+
res.deleted[k] += del[k];
|
|
243
|
+
lastDeleted = del;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return res;
|
|
247
|
+
}
|
|
248
|
+
getComposition() {
|
|
249
|
+
const c = {
|
|
250
|
+
ai: 0, user: 0, paste: 0, existing: 0, total: 0,
|
|
251
|
+
aiEditedByManual: this.aiEditedByManual,
|
|
252
|
+
manualEditedByAi: this.manualEditedByAi
|
|
253
|
+
};
|
|
254
|
+
for (const s of this.spans) {
|
|
255
|
+
const len = s.end - s.start;
|
|
256
|
+
c[s.origin] += len;
|
|
257
|
+
c.total += len;
|
|
258
|
+
}
|
|
259
|
+
return c;
|
|
260
|
+
}
|
|
261
|
+
// ── Persistence ──────────────────────────────────────────────────────────────
|
|
262
|
+
/**
|
|
263
|
+
* Returns the dominant origin for each line.
|
|
264
|
+
* A line's origin is whichever origin covers the most characters in that line.
|
|
265
|
+
*/
|
|
266
|
+
getLineOrigins(baseline) {
|
|
267
|
+
return this._getLineData(baseline).map(d => d.origin);
|
|
268
|
+
}
|
|
269
|
+
/** Get per-line model names (resolved from dictionary). */
|
|
270
|
+
getLineModels(baseline) {
|
|
271
|
+
return this._getLineData(baseline).map(d => this.resolveModel(d.modelId));
|
|
272
|
+
}
|
|
273
|
+
/** Returns per-model char counts for AI-attributed content in this file. */
|
|
274
|
+
getModelBreakdown(baseline) {
|
|
275
|
+
const result = {};
|
|
276
|
+
for (const span of this.spans) {
|
|
277
|
+
if (span.origin !== 'ai')
|
|
278
|
+
continue;
|
|
279
|
+
const model = this.resolveModel(span.modelId);
|
|
280
|
+
if (!model || model === 'unknown')
|
|
281
|
+
continue;
|
|
282
|
+
// Count chars covered by this span that overlap with the baseline
|
|
283
|
+
const totalChars = span.end - span.start;
|
|
284
|
+
result[model] = (result[model] ?? 0) + totalChars;
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
_getLineData(baseline) {
|
|
289
|
+
const result = [];
|
|
290
|
+
let offset = 0;
|
|
291
|
+
for (const line of baseline) {
|
|
292
|
+
const lineEnd = offset + line.charLen;
|
|
293
|
+
const counts = { ai: 0, user: 0, paste: 0, existing: 0 };
|
|
294
|
+
const timestamps = { ai: 0, user: 0, paste: 0, existing: 0 };
|
|
295
|
+
const modelCounts = new Map();
|
|
296
|
+
for (const span of this.spans) {
|
|
297
|
+
if (span.end <= offset || span.start >= lineEnd)
|
|
298
|
+
continue;
|
|
299
|
+
const lo = Math.max(span.start, offset);
|
|
300
|
+
const hi = Math.min(span.end, lineEnd);
|
|
301
|
+
const covered = hi - lo;
|
|
302
|
+
counts[span.origin] += covered;
|
|
303
|
+
if (span.timestamp > timestamps[span.origin])
|
|
304
|
+
timestamps[span.origin] = span.timestamp;
|
|
305
|
+
modelCounts.set(span.modelId, (modelCounts.get(span.modelId) ?? 0) + covered);
|
|
306
|
+
}
|
|
307
|
+
let dominant = 'existing';
|
|
308
|
+
let max = 0;
|
|
309
|
+
for (const k of ['ai', 'user', 'paste', 'existing']) {
|
|
310
|
+
if (counts[k] > max) {
|
|
311
|
+
max = counts[k];
|
|
312
|
+
dominant = k;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Pick the model with the most coverage on this line
|
|
316
|
+
let dominantModel;
|
|
317
|
+
let maxModelCov = 0;
|
|
318
|
+
for (const [mid, cov] of modelCounts) {
|
|
319
|
+
if (mid !== undefined && cov > maxModelCov) {
|
|
320
|
+
maxModelCov = cov;
|
|
321
|
+
dominantModel = mid;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
result.push({ origin: dominant, timestamp: timestamps[dominant], modelId: dominantModel });
|
|
325
|
+
offset = lineEnd;
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
/** Serialize to a compact, storable format. */
|
|
330
|
+
toStored(baseline) {
|
|
331
|
+
const lineData = this._getLineData(baseline);
|
|
332
|
+
const hasModels = this._modelDict.length > 0;
|
|
333
|
+
const stored = {
|
|
334
|
+
hashes: baseline.map(l => l.hash),
|
|
335
|
+
origins: lineData.map(d => ORIGIN_ENCODE[d.origin]),
|
|
336
|
+
charLens: baseline.map(l => l.charLen),
|
|
337
|
+
timestamps: lineData.map(d => d.timestamp),
|
|
338
|
+
aiEditedByManual: this.aiEditedByManual,
|
|
339
|
+
manualEditedByAi: this.manualEditedByAi,
|
|
340
|
+
};
|
|
341
|
+
if (hasModels) {
|
|
342
|
+
stored.modelDictionary = [...this._modelDict];
|
|
343
|
+
stored.modelIds = lineData.map(d => d.modelId ?? -1);
|
|
344
|
+
}
|
|
345
|
+
return stored;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Reconstruct FileProvenance from a previous session's stored data,
|
|
349
|
+
* reconciling against the current file baseline via LCS on hashes.
|
|
350
|
+
*
|
|
351
|
+
* Lines that match (same hash) keep their stored origin.
|
|
352
|
+
* New or changed lines (no hash match) are marked 'existing'.
|
|
353
|
+
*/
|
|
354
|
+
static fromStored(stored, currentBaseline) {
|
|
355
|
+
const m = stored.hashes.length;
|
|
356
|
+
const n = currentBaseline.length;
|
|
357
|
+
// LCS table on line hashes
|
|
358
|
+
const t = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
359
|
+
for (let i = 1; i <= m; i++) {
|
|
360
|
+
for (let j = 1; j <= n; j++) {
|
|
361
|
+
t[i][j] = stored.hashes[i - 1] === currentBaseline[j - 1].hash
|
|
362
|
+
? t[i - 1][j - 1] + 1
|
|
363
|
+
: Math.max(t[i - 1][j], t[i][j - 1]);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Backtrack to assign origins and timestamps to current lines
|
|
367
|
+
const lineOrigins = new Array(n).fill('existing');
|
|
368
|
+
const lineTimestamps = new Array(n).fill(0);
|
|
369
|
+
let i = m, j = n;
|
|
370
|
+
while (i > 0 && j > 0) {
|
|
371
|
+
if (stored.hashes[i - 1] === currentBaseline[j - 1].hash && t[i][j] === t[i - 1][j - 1] + 1) {
|
|
372
|
+
lineOrigins[j - 1] = ORIGIN_DECODE[stored.origins[i - 1]] ?? 'existing';
|
|
373
|
+
lineTimestamps[j - 1] = stored.timestamps?.[i - 1] ?? 0;
|
|
374
|
+
i--;
|
|
375
|
+
j--;
|
|
376
|
+
}
|
|
377
|
+
else if (t[i - 1][j] >= t[i][j - 1]) {
|
|
378
|
+
i--;
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
j--;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Restore model dictionary
|
|
385
|
+
const fp = new FileProvenance(0);
|
|
386
|
+
if (stored.modelDictionary) {
|
|
387
|
+
fp._modelDict = [...stored.modelDictionary];
|
|
388
|
+
}
|
|
389
|
+
fp.aiEditedByManual = stored.aiEditedByManual || 0;
|
|
390
|
+
fp.manualEditedByAi = stored.manualEditedByAi || 0;
|
|
391
|
+
// Build per-line modelId array from stored data (reconciled via LCS)
|
|
392
|
+
const lineModelIds = new Array(n).fill(undefined);
|
|
393
|
+
// Re-walk the LCS backtrack to map stored modelIds to current lines
|
|
394
|
+
let ii = m, jj = n;
|
|
395
|
+
while (ii > 0 && jj > 0) {
|
|
396
|
+
if (stored.hashes[ii - 1] === currentBaseline[jj - 1].hash && t[ii][jj] === t[ii - 1][jj - 1] + 1) {
|
|
397
|
+
const mid = stored.modelIds?.[ii - 1];
|
|
398
|
+
lineModelIds[jj - 1] = (mid !== undefined && mid >= 0) ? mid : undefined;
|
|
399
|
+
ii--;
|
|
400
|
+
jj--;
|
|
401
|
+
}
|
|
402
|
+
else if (t[ii - 1][jj] >= t[ii][jj - 1]) {
|
|
403
|
+
ii--;
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
jj--;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
let offset = 0;
|
|
410
|
+
for (let k = 0; k < n; k++) {
|
|
411
|
+
const len = currentBaseline[k].charLen;
|
|
412
|
+
if (len > 0) {
|
|
413
|
+
fp.insert(offset, len, lineOrigins[k], lineTimestamps[k], lineModelIds[k]);
|
|
414
|
+
offset += len;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return fp;
|
|
418
|
+
}
|
|
419
|
+
// ── Private helpers ──────────────────────────────────────────────────────────
|
|
420
|
+
/** Index of the span that contains `offset`, or -1 if past end. Binary search O(log n). */
|
|
421
|
+
_spanAt(offset) {
|
|
422
|
+
let lo = 0, hi = this.spans.length - 1;
|
|
423
|
+
while (lo <= hi) {
|
|
424
|
+
const mid = (lo + hi) >>> 1;
|
|
425
|
+
const s = this.spans[mid];
|
|
426
|
+
if (offset < s.start) {
|
|
427
|
+
hi = mid - 1;
|
|
428
|
+
}
|
|
429
|
+
else if (offset > s.end) {
|
|
430
|
+
lo = mid + 1;
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
return mid;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return -1;
|
|
437
|
+
}
|
|
438
|
+
_shiftFrom(idx, delta) {
|
|
439
|
+
for (let i = idx; i < this.spans.length; i++) {
|
|
440
|
+
this.spans[i].start += delta;
|
|
441
|
+
this.spans[i].end += delta;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
_coalesce() {
|
|
445
|
+
this.spans = this.spans.filter(s => s.end > s.start);
|
|
446
|
+
let i = 0;
|
|
447
|
+
while (i < this.spans.length - 1) {
|
|
448
|
+
const a = this.spans[i], b = this.spans[i + 1];
|
|
449
|
+
if (a.origin === b.origin && a.modelId === b.modelId && a.end === b.start) {
|
|
450
|
+
a.end = b.end;
|
|
451
|
+
a.timestamp = Math.max(a.timestamp, b.timestamp);
|
|
452
|
+
this.spans.splice(i + 1, 1);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
i++;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
_lcsTable(a, b) {
|
|
460
|
+
const m = a.length, n = b.length;
|
|
461
|
+
if (m > FileProvenance.MAX_LCS_SIDE || n > FileProvenance.MAX_LCS_SIDE) {
|
|
462
|
+
return Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
463
|
+
}
|
|
464
|
+
const t = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
465
|
+
for (let i = 1; i <= m; i++) {
|
|
466
|
+
for (let j = 1; j <= n; j++) {
|
|
467
|
+
t[i][j] = a[i - 1].hash === b[j - 1].hash
|
|
468
|
+
? t[i - 1][j - 1] + 1
|
|
469
|
+
: Math.max(t[i - 1][j], t[i][j - 1]);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return t;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
exports.FileProvenance = FileProvenance;
|
|
476
|
+
/**
|
|
477
|
+
* O(m×n) LCS table over hash values.
|
|
478
|
+
* Guard: if either side exceeds MAX_LCS_SIDE lines the table would exceed ~36 MB.
|
|
479
|
+
* In that case return an all-zeros table — the diff algorithm will treat every
|
|
480
|
+
* line as deleted+added, which is conservative (no lines get wrongly matched)
|
|
481
|
+
* but loses attribution for unchanged lines on those huge files.
|
|
482
|
+
*/
|
|
483
|
+
FileProvenance.MAX_LCS_SIDE = 3000;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* GitNotes — read/write omnitype attribution data as Git Notes.
|
|
4
|
+
*
|
|
5
|
+
* Notes live at refs/notes/ai. Each note on a commit is a JSON blob:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* "v": 1,
|
|
9
|
+
* "tool": "omnitype",
|
|
10
|
+
* "ts": <unix-ms>,
|
|
11
|
+
* "files": {
|
|
12
|
+
* "src/foo.ts": [
|
|
13
|
+
* { "start": 1, "end": 45, "origin": "ai", "model": "claude-sonnet-4-5" },
|
|
14
|
+
* { "start": 46, "end": 60, "origin": "user" }
|
|
15
|
+
* ]
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Line numbers are 1-indexed, inclusive — matching git blame output.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.buildNote = buildNote;
|
|
23
|
+
exports.writeNote = writeNote;
|
|
24
|
+
exports.readNote = readNote;
|
|
25
|
+
exports.mergeNotes = mergeNotes;
|
|
26
|
+
exports.provenanceToNoteFile = provenanceToNoteFile;
|
|
27
|
+
exports.fetchNotes = fetchNotes;
|
|
28
|
+
exports.pushNotes = pushNotes;
|
|
29
|
+
const child_process_1 = require("child_process");
|
|
30
|
+
const util_1 = require("util");
|
|
31
|
+
const _execFile = (0, util_1.promisify)(child_process_1.execFile);
|
|
32
|
+
const NOTES_REF = 'refs/notes/ai';
|
|
33
|
+
function buildNote(files) {
|
|
34
|
+
return { v: 1, tool: 'omnitype', ts: Date.now(), files };
|
|
35
|
+
}
|
|
36
|
+
/** Write (or overwrite) a Git Note for the given commit in the repo at repoPath. */
|
|
37
|
+
async function writeNote(repoPath, commitish, note) {
|
|
38
|
+
const json = JSON.stringify(note);
|
|
39
|
+
await _execFile('git', ['-C', repoPath, 'notes', '--ref', NOTES_REF, 'add', '-f', '-m', json, commitish]);
|
|
40
|
+
}
|
|
41
|
+
/** Read the omnitype Git Note for a commit. Returns null if none exists. */
|
|
42
|
+
function readNote(repoPath, commitish) {
|
|
43
|
+
try {
|
|
44
|
+
const out = (0, child_process_1.execFileSync)('git', ['-C', repoPath, 'notes', '--ref', NOTES_REF, 'show', commitish], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
45
|
+
const parsed = JSON.parse(out);
|
|
46
|
+
if (parsed?.v === 1 && parsed?.tool === 'omnitype')
|
|
47
|
+
return parsed;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Merge two notes — second note's ranges overwrite first for conflicting files. */
|
|
55
|
+
function mergeNotes(base, overlay) {
|
|
56
|
+
return {
|
|
57
|
+
...base,
|
|
58
|
+
ts: Math.max(base.ts, overlay.ts),
|
|
59
|
+
files: { ...base.files, ...overlay.files },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Build a NoteFile from a StoredProvenance map (as returned by FileProvenance.toStored).
|
|
64
|
+
* Converts span-level data into 1-indexed line ranges for the note.
|
|
65
|
+
*/
|
|
66
|
+
function provenanceToNoteFile(files) {
|
|
67
|
+
const DECODE = { 0: 'existing', 1: 'user', 2: 'ai', 3: 'paste' };
|
|
68
|
+
const result = {};
|
|
69
|
+
for (const [relPath, sp] of Object.entries(files)) {
|
|
70
|
+
const ranges = [];
|
|
71
|
+
let lineNum = 1;
|
|
72
|
+
let runStart = lineNum;
|
|
73
|
+
let runOrigin = DECODE[sp.origins[0]] ?? 'existing';
|
|
74
|
+
let runModel;
|
|
75
|
+
let runTool;
|
|
76
|
+
const flush = (end) => {
|
|
77
|
+
if (end < runStart)
|
|
78
|
+
return;
|
|
79
|
+
const r = { start: runStart, end, origin: runOrigin };
|
|
80
|
+
if (runModel)
|
|
81
|
+
r.model = runModel;
|
|
82
|
+
if (runTool)
|
|
83
|
+
r.tool = runTool;
|
|
84
|
+
ranges.push(r);
|
|
85
|
+
};
|
|
86
|
+
for (let i = 0; i < sp.origins.length; i++) {
|
|
87
|
+
const origin = DECODE[sp.origins[i]] ?? 'existing';
|
|
88
|
+
const model = (sp.modelIds && sp.modelDictionary && sp.modelIds[i] >= 0)
|
|
89
|
+
? sp.modelDictionary[sp.modelIds[i]] : undefined;
|
|
90
|
+
const tool = (sp.toolIds && sp.toolDictionary && sp.toolIds[i] >= 0)
|
|
91
|
+
? sp.toolDictionary[sp.toolIds[i]] : undefined;
|
|
92
|
+
if (origin !== runOrigin || model !== runModel || tool !== runTool) {
|
|
93
|
+
flush(lineNum - 1);
|
|
94
|
+
runStart = lineNum;
|
|
95
|
+
runOrigin = origin;
|
|
96
|
+
runModel = model;
|
|
97
|
+
runTool = tool;
|
|
98
|
+
}
|
|
99
|
+
lineNum++;
|
|
100
|
+
}
|
|
101
|
+
flush(lineNum - 1);
|
|
102
|
+
if (ranges.length > 0)
|
|
103
|
+
result[relPath] = ranges;
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
/** Fetch git notes from remote so teammates' attributions are visible locally. */
|
|
108
|
+
async function fetchNotes(repoPath, remote = 'origin') {
|
|
109
|
+
try {
|
|
110
|
+
await _execFile('git', [
|
|
111
|
+
'-C', repoPath, 'fetch', remote,
|
|
112
|
+
`${NOTES_REF}:${NOTES_REF}`,
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
catch { /* remote may not have notes yet */ }
|
|
116
|
+
}
|
|
117
|
+
/** Push git notes to remote. */
|
|
118
|
+
async function pushNotes(repoPath, remote = 'origin') {
|
|
119
|
+
await _execFile('git', ['-C', repoPath, 'push', remote, NOTES_REF]);
|
|
120
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Heartbeat coordination between the VS Code extension and the CLI daemon.
|
|
4
|
+
*
|
|
5
|
+
* The extension writes ~/.omnitype/vscode-heartbeat.json periodically.
|
|
6
|
+
* The daemon reads it before pushing to decide whether to yield.
|
|
7
|
+
*
|
|
8
|
+
* Schema: { pid: number, workspacePaths: string[], ts: number }
|
|
9
|
+
*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
27
|
+
var ownKeys = function(o) {
|
|
28
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
29
|
+
var ar = [];
|
|
30
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
31
|
+
return ar;
|
|
32
|
+
};
|
|
33
|
+
return ownKeys(o);
|
|
34
|
+
};
|
|
35
|
+
return function (mod) {
|
|
36
|
+
if (mod && mod.__esModule) return mod;
|
|
37
|
+
var result = {};
|
|
38
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
39
|
+
__setModuleDefault(result, mod);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.readExtensionHeartbeat = readExtensionHeartbeat;
|
|
45
|
+
exports.extensionIsActiveFor = extensionIsActiveFor;
|
|
46
|
+
exports.writeDaemonHeartbeat = writeDaemonHeartbeat;
|
|
47
|
+
const fs = __importStar(require("fs"));
|
|
48
|
+
const os = __importStar(require("os"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const HEARTBEAT_PATH = path.join(os.homedir(), '.omnitype', 'vscode-heartbeat.json');
|
|
51
|
+
const YIELD_WINDOW_MS = 30000; // daemon yields if extension was seen within 30s
|
|
52
|
+
function readExtensionHeartbeat() {
|
|
53
|
+
try {
|
|
54
|
+
const raw = fs.readFileSync(HEARTBEAT_PATH, 'utf8');
|
|
55
|
+
return JSON.parse(raw);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Returns true if the VS Code extension is actively tracking the given workspace.
|
|
63
|
+
* When true, the daemon should skip pushing for files in that workspace.
|
|
64
|
+
*/
|
|
65
|
+
function extensionIsActiveFor(workspacePath) {
|
|
66
|
+
const hb = readExtensionHeartbeat();
|
|
67
|
+
if (!hb)
|
|
68
|
+
return false;
|
|
69
|
+
if (Date.now() - hb.ts > YIELD_WINDOW_MS)
|
|
70
|
+
return false;
|
|
71
|
+
return hb.workspacePaths.some(p => workspacePath.startsWith(p) || p.startsWith(workspacePath));
|
|
72
|
+
}
|
|
73
|
+
/** Write our own daemon heartbeat so the extension can also yield to us if needed. */
|
|
74
|
+
function writeDaemonHeartbeat(watchPath) {
|
|
75
|
+
try {
|
|
76
|
+
const data = { pid: process.pid, watchPath, ts: Date.now() };
|
|
77
|
+
fs.mkdirSync(path.dirname(HEARTBEAT_PATH), { recursive: true });
|
|
78
|
+
fs.writeFileSync(path.join(os.homedir(), '.omnitype', 'daemon-heartbeat.json'), JSON.stringify(data));
|
|
79
|
+
}
|
|
80
|
+
catch { /* non-fatal */ }
|
|
81
|
+
}
|