@qilin-agent/workspace-changes 0.0.0-stage → 3.1.4
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/LICENSE +21 -0
- package/README.i18n.yaml +37 -0
- package/README.md +105 -2
- package/README.zh.md +106 -0
- package/lib/index.js +1155 -0
- package/lib/types/capture.d.ts +47 -0
- package/lib/types/capture.js +107 -0
- package/lib/types/compare.d.ts +19 -0
- package/lib/types/compare.js +56 -0
- package/lib/types/git.d.ts +140 -0
- package/lib/types/git.js +224 -0
- package/lib/types/index.d.ts +34 -0
- package/lib/types/index.js +146 -0
- package/lib/types/numstat.d.ts +19 -0
- package/lib/types/numstat.js +45 -0
- package/lib/types/paths.d.ts +65 -0
- package/lib/types/paths.js +106 -0
- package/lib/types/recorder.d.ts +126 -0
- package/lib/types/recorder.js +370 -0
- package/lib/types/types.d.ts +163 -0
- package/lib/types/types.js +2 -0
- package/package.json +56 -3
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/** Per-Session turn recorder: snapshots, captures around file-tool edits, the turn-end diff, and the records kept until disposal. */
|
|
2
|
+
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, relative, resolve } from 'node:path';
|
|
5
|
+
import { captureFile, mutationPath, sameCapture } from "./capture.js";
|
|
6
|
+
import { compareText } from "./compare.js";
|
|
7
|
+
import { blobText, diffTrees, gitlinkPaths, ignoredPaths, locateGitWorkspace, snapshotTree, treeBlob, } from "./git.js";
|
|
8
|
+
import { canonicalPath, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, temporaryRoots, toPosix } from "./paths.js";
|
|
9
|
+
function freshState(turn) {
|
|
10
|
+
return { turn, baseline: null, captures: new Map(), lastToolResultSeq: -1, attemptedAfterSeq: -1, recordedAfterSeq: -1 };
|
|
11
|
+
}
|
|
12
|
+
/** A snapshot side larger than the byte cap. */
|
|
13
|
+
const OVERSIZED = Symbol('oversized');
|
|
14
|
+
/**
|
|
15
|
+
* Serializes one Session's recording work: the turn-start snapshot, the
|
|
16
|
+
* whole-file capture before each file-tool mutation, the turn-end snapshot
|
|
17
|
+
* with its diff, and the appended `workspace/changes` event whose summary and
|
|
18
|
+
* comparisons this recorder keeps. Snapshot objects and captured copies live in
|
|
19
|
+
* a temporary directory owned by the recorder; disposal removes it together
|
|
20
|
+
* with the summaries. Tool execution waits for pending work so a snapshot or
|
|
21
|
+
* capture never races a mutation. A working directory outside any repository,
|
|
22
|
+
* or a Host without git, gets no snapshot; its summary lists the files the file
|
|
23
|
+
* tools changed.
|
|
24
|
+
*/
|
|
25
|
+
export class TurnRecorder {
|
|
26
|
+
session;
|
|
27
|
+
cwd;
|
|
28
|
+
env;
|
|
29
|
+
chain = Promise.resolve();
|
|
30
|
+
/** The open turn; before the first `turn/start` it is an empty placeholder no event can match. */
|
|
31
|
+
state = freshState(0);
|
|
32
|
+
/** Canonical paths, resolved by the first turn. */
|
|
33
|
+
paths;
|
|
34
|
+
/** The located repository, reused across turns once found; null keeps retrying each turn. */
|
|
35
|
+
repository = null;
|
|
36
|
+
/** Temporary directory holding this Session's snapshot objects, scratch indexes, and captured copies. */
|
|
37
|
+
scratch;
|
|
38
|
+
/** Records by the sequence of the event that announced them. */
|
|
39
|
+
records = new Map();
|
|
40
|
+
lifetime = new AbortController();
|
|
41
|
+
constructor(session, cwd, env) {
|
|
42
|
+
this.session = session;
|
|
43
|
+
this.cwd = cwd;
|
|
44
|
+
this.env = env;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Open a turn with fresh per-turn state and queue its baseline snapshot.
|
|
48
|
+
* @param turn - the turn number from `turn/start`.
|
|
49
|
+
*/
|
|
50
|
+
start(turn) {
|
|
51
|
+
const state = freshState(turn);
|
|
52
|
+
this.state = state;
|
|
53
|
+
void this.enqueue(async (signal) => {
|
|
54
|
+
try {
|
|
55
|
+
this.paths ??= { cwd: await realpath(this.cwd), home: await canonicalPath(homedir()), temporaryRoots: await temporaryRoots() };
|
|
56
|
+
const repository = await this.locate(this.paths.cwd, signal);
|
|
57
|
+
if (repository === null)
|
|
58
|
+
return;
|
|
59
|
+
const tree = await snapshotTree(repository.git, repository.workspace, signal);
|
|
60
|
+
state.baseline = { ...repository, tree };
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
// A repository whose snapshot failed must not be summarized as if it had none.
|
|
64
|
+
state.baseline = 'failed';
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Queue the capture of the path a file tool is about to mutate, before the
|
|
71
|
+
* tool runs; only the turn's first mutation of a path captures it. Await
|
|
72
|
+
* {@link settled} afterwards so the tool cannot overtake the capture.
|
|
73
|
+
* @param name - wire tool name.
|
|
74
|
+
* @param args - parsed call arguments.
|
|
75
|
+
*/
|
|
76
|
+
capture(name, args) {
|
|
77
|
+
const path = mutationPath(name, args);
|
|
78
|
+
if (path === undefined)
|
|
79
|
+
return;
|
|
80
|
+
const state = this.state;
|
|
81
|
+
void this.enqueue(async () => {
|
|
82
|
+
const paths = this.paths;
|
|
83
|
+
if (paths === undefined)
|
|
84
|
+
return;
|
|
85
|
+
const absolute = await canonicalPath(resolve(paths.cwd, path));
|
|
86
|
+
if (state.captures.has(absolute))
|
|
87
|
+
return;
|
|
88
|
+
const capture = await captureFile(absolute, join(await this.scratchDir(), 'captures'), this.env.maxFileBytes);
|
|
89
|
+
if (capture !== undefined)
|
|
90
|
+
state.captures.set(absolute, capture);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Remember a settled tool result, so a record after `turn/end` covers it.
|
|
95
|
+
* @param event - the appended `tool/result` event.
|
|
96
|
+
*/
|
|
97
|
+
observe(event) {
|
|
98
|
+
const state = this.state;
|
|
99
|
+
if (event.data.turn === state.turn)
|
|
100
|
+
state.lastToolResultSeq = event.seq;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Record the turn's changes inside the turn, before `turn/end` commits.
|
|
104
|
+
* @param turn - the stopping turn.
|
|
105
|
+
* @returns after the event is appended or the attempt failed.
|
|
106
|
+
*/
|
|
107
|
+
stopping(turn) {
|
|
108
|
+
const state = this.state;
|
|
109
|
+
if (turn !== state.turn)
|
|
110
|
+
return Promise.resolve();
|
|
111
|
+
return this.enqueue(signal => this.record(state, signal));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Record after `turn/end` unless a record was already attempted after the turn's last tool result.
|
|
115
|
+
* @param turn - the turn number from `turn/end`.
|
|
116
|
+
*/
|
|
117
|
+
end(turn) {
|
|
118
|
+
const state = this.state;
|
|
119
|
+
if (turn !== state.turn || state.attemptedAfterSeq >= state.lastToolResultSeq)
|
|
120
|
+
return;
|
|
121
|
+
void this.enqueue(signal => this.record(state, signal));
|
|
122
|
+
}
|
|
123
|
+
/** Resolves once every queued snapshot, capture, and record has settled. */
|
|
124
|
+
settled() {
|
|
125
|
+
return this.chain;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The summary announced by one `workspace/changes` event of this Session.
|
|
129
|
+
* @param seq - the event's sequence number.
|
|
130
|
+
* @returns the summary, or undefined for a sequence this recorder did not announce.
|
|
131
|
+
*/
|
|
132
|
+
summary(seq) {
|
|
133
|
+
return this.records.get(seq)?.summary;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Every file this Session's recorded turns changed, folded by path.
|
|
137
|
+
*
|
|
138
|
+
* The per-file counts and flags come from the LATEST turn that listed the
|
|
139
|
+
* file, so a rewrite reads as the delta it left rather than as the churn of
|
|
140
|
+
* every rewrite; `turns` carries that churn. The Session totals come from
|
|
141
|
+
* each turn's own complete counts, which include files that turn's list cap
|
|
142
|
+
* omitted.
|
|
143
|
+
* @returns the folded list in `display` order, empty before the first recorded turn.
|
|
144
|
+
*/
|
|
145
|
+
sessionChanges() {
|
|
146
|
+
const folded = new Map();
|
|
147
|
+
let added = 0;
|
|
148
|
+
let deleted = 0;
|
|
149
|
+
for (const [seq, record] of [...this.records].sort(([left], [right]) => left - right)) {
|
|
150
|
+
const { turn, files, added: turnAdded, deleted: turnDeleted } = record.summary;
|
|
151
|
+
added += turnAdded;
|
|
152
|
+
deleted += turnDeleted;
|
|
153
|
+
files.forEach((file, index) => {
|
|
154
|
+
const previous = folded.get(file.path);
|
|
155
|
+
folded.set(file.path, {
|
|
156
|
+
path: file.path,
|
|
157
|
+
display: file.display,
|
|
158
|
+
added: file.added,
|
|
159
|
+
deleted: file.deleted,
|
|
160
|
+
turns: (previous?.turns ?? 0) + 1,
|
|
161
|
+
lastTurn: turn,
|
|
162
|
+
lastSeq: seq,
|
|
163
|
+
lastIndex: index,
|
|
164
|
+
...(file.binary === true ? { binary: true } : {}),
|
|
165
|
+
...(file.oversized === true ? { oversized: true } : {}),
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const files = [...folded.values()].sort((left, right) => left.display < right.display ? -1 : left.display > right.display ? 1 : 0);
|
|
170
|
+
return { cwd: this.cwd, files, total: files.length, added, deleted };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Compare one listed file's contents at turn start and turn end.
|
|
174
|
+
* @param seq - the announcing event's sequence number.
|
|
175
|
+
* @param index - the file's index in the summary's `files`.
|
|
176
|
+
* @param signal - cancels the reads.
|
|
177
|
+
* @returns the comparison, or undefined for an unknown sequence or index, or once disposed.
|
|
178
|
+
* @throws when a read fails while the recorder lives.
|
|
179
|
+
*/
|
|
180
|
+
async diff(seq, index, signal) {
|
|
181
|
+
const record = this.records.get(seq);
|
|
182
|
+
const file = record?.summary.files[index];
|
|
183
|
+
const sources = record?.sources[index];
|
|
184
|
+
if (file === undefined || sources === undefined)
|
|
185
|
+
return undefined;
|
|
186
|
+
const { path, display } = file;
|
|
187
|
+
if (sources.refusal !== undefined)
|
|
188
|
+
return { kind: sources.refusal, path, display };
|
|
189
|
+
const combined = AbortSignal.any([signal, this.lifetime.signal]);
|
|
190
|
+
try {
|
|
191
|
+
const [before, after] = await Promise.all([this.readSide(sources.before, combined), this.readSide(sources.after, combined)]);
|
|
192
|
+
if (before === OVERSIZED || after === OVERSIZED)
|
|
193
|
+
return { kind: 'oversized', path, display };
|
|
194
|
+
const { hunks, coarse } = compareText(before, after, this.env.diffTimeoutMs);
|
|
195
|
+
return { kind: 'text', path, display, before: before !== null, after: after !== null, hunks, coarse };
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
// Disposal removes the temporary directory under a running read; the Session is gone either way.
|
|
199
|
+
if (this.lifetime.signal.aborted)
|
|
200
|
+
return undefined;
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Abort queued work, forget every record, and remove the temporary directory.
|
|
206
|
+
* @returns once the temporary directory is gone.
|
|
207
|
+
*/
|
|
208
|
+
async dispose() {
|
|
209
|
+
this.lifetime.abort();
|
|
210
|
+
this.records.clear();
|
|
211
|
+
await this.chain;
|
|
212
|
+
if (this.scratch !== undefined)
|
|
213
|
+
await rm(await this.scratch, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
enqueue(task) {
|
|
216
|
+
const run = this.chain.then(async () => {
|
|
217
|
+
if (this.lifetime.signal.aborted)
|
|
218
|
+
return;
|
|
219
|
+
try {
|
|
220
|
+
await task(this.lifetime.signal);
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
this.warnUnlessDisposed(error);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
this.chain = run;
|
|
227
|
+
return run;
|
|
228
|
+
}
|
|
229
|
+
/** A failure after disposal is expected cancellation and stays silent. */
|
|
230
|
+
warnUnlessDisposed(error) {
|
|
231
|
+
if (!this.lifetime.signal.aborted)
|
|
232
|
+
this.env.warn(`workspace-changes: ${String(error)}`);
|
|
233
|
+
}
|
|
234
|
+
/** This Session's temporary directory, created on first use. */
|
|
235
|
+
scratchDir() {
|
|
236
|
+
this.scratch ??= mkdtemp(join(this.env.tempRoot, 'qilin-workspace-changes-'));
|
|
237
|
+
return this.scratch;
|
|
238
|
+
}
|
|
239
|
+
/** The repository enclosing the working directory, located once; null keeps retrying each turn. */
|
|
240
|
+
async locate(cwd, signal) {
|
|
241
|
+
if (this.repository !== null)
|
|
242
|
+
return this.repository;
|
|
243
|
+
const git = await this.env.git;
|
|
244
|
+
if (git === null)
|
|
245
|
+
return null;
|
|
246
|
+
const workspace = await locateGitWorkspace(git, cwd, () => this.scratchDir(), signal);
|
|
247
|
+
if (workspace === null)
|
|
248
|
+
return null;
|
|
249
|
+
this.repository = { git, workspace };
|
|
250
|
+
return this.repository;
|
|
251
|
+
}
|
|
252
|
+
/** One side's text, null for an absent file, or {@link OVERSIZED} for a snapshot side beyond the byte cap. */
|
|
253
|
+
async readSide(source, signal) {
|
|
254
|
+
switch (source.kind) {
|
|
255
|
+
case 'absent': return null;
|
|
256
|
+
case 'file': return readFile(source.file, { encoding: 'utf8', signal });
|
|
257
|
+
case 'snapshot': {
|
|
258
|
+
const { git, workspace } = source.repository;
|
|
259
|
+
const blob = await treeBlob(git, workspace, source.tree, source.path, signal);
|
|
260
|
+
if (blob === null)
|
|
261
|
+
return null;
|
|
262
|
+
if (blob.size > this.env.maxFileBytes)
|
|
263
|
+
return OVERSIZED;
|
|
264
|
+
return blobText(git, workspace, blob.oid, this.env.maxFileBytes, signal);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async record(state, signal) {
|
|
269
|
+
const paths = this.paths;
|
|
270
|
+
const { baseline } = state;
|
|
271
|
+
if (paths === undefined || baseline === 'failed' || state.lastToolResultSeq < 0)
|
|
272
|
+
return;
|
|
273
|
+
state.attemptedAfterSeq = state.lastToolResultSeq;
|
|
274
|
+
// Without a snapshot the working directory itself bounds the workspace.
|
|
275
|
+
const root = baseline?.workspace.root ?? paths.cwd;
|
|
276
|
+
const listed = new Map();
|
|
277
|
+
let snapshot;
|
|
278
|
+
if (baseline !== null) {
|
|
279
|
+
const after = await snapshotTree(baseline.git, baseline.workspace, signal);
|
|
280
|
+
snapshot = { before: baseline.tree, after };
|
|
281
|
+
const repository = { git: baseline.git, workspace: baseline.workspace };
|
|
282
|
+
for (const entry of await diffTrees(baseline.git, baseline.workspace, baseline.tree, after, signal)) {
|
|
283
|
+
const absolute = resolve(root, entry.path);
|
|
284
|
+
listed.set(absolute, {
|
|
285
|
+
file: changedFile(paths, root, absolute, entry),
|
|
286
|
+
sources: entry.binary ? { refusal: 'binary' } : {
|
|
287
|
+
before: { kind: 'snapshot', repository, tree: baseline.tree, path: entry.oldPath ?? entry.path },
|
|
288
|
+
after: { kind: 'snapshot', repository, tree: after, path: entry.path },
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Captured paths the snapshots do not cover are compared from their copies.
|
|
294
|
+
const captured = [...state.captures.keys()].filter(absolute => !listed.has(absolute));
|
|
295
|
+
const workTreePath = (absolute) => toPosix(relative(root, absolute));
|
|
296
|
+
let inWorkspace = captured.filter(absolute => isInside(root, absolute));
|
|
297
|
+
if (baseline !== null && inWorkspace.length > 0) {
|
|
298
|
+
// Nested repositories and submodules are gitlinks: their contents never enter the summary.
|
|
299
|
+
const gitlinks = await gitlinkPaths(baseline.git, baseline.workspace, signal);
|
|
300
|
+
inWorkspace = inWorkspace.filter(absolute => ![...gitlinks].some(link => isInside(resolve(root, link), absolute)));
|
|
301
|
+
}
|
|
302
|
+
// A snapshot covers every workspace file except the ignored ones; without one, every file-tool edit counts.
|
|
303
|
+
const uncoveredInWorkspace = baseline === null
|
|
304
|
+
? new Set(inWorkspace.map(workTreePath))
|
|
305
|
+
: await ignoredPaths(baseline.git, baseline.workspace, inWorkspace.map(workTreePath), signal);
|
|
306
|
+
for (const absolute of captured) {
|
|
307
|
+
// Outside the workspace, scratch files under a temporary root stay out.
|
|
308
|
+
const uncovered = isInside(root, absolute)
|
|
309
|
+
? uncoveredInWorkspace.has(workTreePath(absolute))
|
|
310
|
+
: !isTemporaryPath(absolute, paths.temporaryRoots);
|
|
311
|
+
if (!uncovered)
|
|
312
|
+
continue;
|
|
313
|
+
const before = state.captures.get(absolute);
|
|
314
|
+
const after = await captureFile(absolute, join(await this.scratchDir(), 'captures'), this.env.maxFileBytes);
|
|
315
|
+
if (after === undefined || sameCapture(before, after))
|
|
316
|
+
continue;
|
|
317
|
+
listed.set(absolute, await this.compared(paths, root, absolute, before, after));
|
|
318
|
+
}
|
|
319
|
+
const sorted = [...listed.values()].sort((a, b) => compareDisplay(a.file, b.file));
|
|
320
|
+
// An empty list after an earlier in-turn record supersedes that record.
|
|
321
|
+
if (sorted.length === 0 && state.recordedAfterSeq < 0)
|
|
322
|
+
return;
|
|
323
|
+
const event = this.session.append('workspace/changes', { turn: state.turn });
|
|
324
|
+
const kept = sorted.slice(0, this.env.maxFiles);
|
|
325
|
+
this.records.set(event.seq, {
|
|
326
|
+
summary: {
|
|
327
|
+
turn: state.turn,
|
|
328
|
+
cwd: this.cwd,
|
|
329
|
+
files: kept.map(entry => entry.file),
|
|
330
|
+
total: sorted.length,
|
|
331
|
+
added: sorted.reduce((sum, entry) => sum + entry.file.added, 0),
|
|
332
|
+
deleted: sorted.reduce((sum, entry) => sum + entry.file.deleted, 0),
|
|
333
|
+
...snapshot === undefined ? {} : { snapshot },
|
|
334
|
+
},
|
|
335
|
+
sources: kept.map(entry => entry.sources),
|
|
336
|
+
});
|
|
337
|
+
state.recordedAfterSeq = event.seq;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The listing of a captured pair: an oversized side lists the file without
|
|
341
|
+
* counts and refuses its comparison, a binary side likewise, and two text
|
|
342
|
+
* sides carry the counts of their line comparison.
|
|
343
|
+
*/
|
|
344
|
+
async compared(paths, root, absolute, before, after) {
|
|
345
|
+
const list = (counts, sources) => ({ file: changedFile(paths, root, absolute, counts), sources });
|
|
346
|
+
if (before.kind === 'oversized' || after.kind === 'oversized') {
|
|
347
|
+
return list({ added: 0, deleted: 0, binary: false, oversized: true }, { refusal: 'oversized' });
|
|
348
|
+
}
|
|
349
|
+
if (isBinary(before) || isBinary(after))
|
|
350
|
+
return list({ added: 0, deleted: 0, binary: true }, { refusal: 'binary' });
|
|
351
|
+
const text = async (side) => side.kind === 'file' ? readFile(side.file, 'utf8') : null;
|
|
352
|
+
const { added, deleted } = compareText(await text(before), await text(after), this.env.diffTimeoutMs);
|
|
353
|
+
return list({ added, deleted, binary: false }, { before, after });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** Whether a captured side holds binary content. */
|
|
357
|
+
function isBinary(capture) {
|
|
358
|
+
return capture.kind === 'file' && capture.binary;
|
|
359
|
+
}
|
|
360
|
+
function changedFile({ cwd, home }, root, absolute, counts) {
|
|
361
|
+
return {
|
|
362
|
+
path: durablePathOf(absolute, cwd),
|
|
363
|
+
display: displayPathOf(absolute, cwd, root, home),
|
|
364
|
+
added: counts.added,
|
|
365
|
+
deleted: counts.deleted,
|
|
366
|
+
...counts.binary ? { binary: true } : {},
|
|
367
|
+
...counts.oversized === true ? { oversized: true } : {},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
//# sourceMappingURL=recorder.js.map
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** Per-turn workspace change summaries, the Session event announcing them, and the Host service serving them with their comparisons. */
|
|
2
|
+
import type { SessionId } from '@qilin-agent/session/types';
|
|
3
|
+
/** One file changed during a turn, with line counts from git or from the whole-file captures around its file-tool edits. */
|
|
4
|
+
export interface WorkspaceChangedFile {
|
|
5
|
+
/** Path relative to the Session working directory, or an absolute Host path outside it. */
|
|
6
|
+
path: string;
|
|
7
|
+
/**
|
|
8
|
+
* Sort key and label: the relative path inside the working directory, a
|
|
9
|
+
* `../` path for repository files above it, a `~` path under the home
|
|
10
|
+
* directory, otherwise the absolute path. Always slash-separated.
|
|
11
|
+
*/
|
|
12
|
+
display: string;
|
|
13
|
+
/** Lines added; zero for a binary or oversized file. */
|
|
14
|
+
added: number;
|
|
15
|
+
/** Lines deleted; zero for a binary or oversized file. */
|
|
16
|
+
deleted: number;
|
|
17
|
+
/** Present when git reported the file as binary, or when a captured side holds a NUL byte. */
|
|
18
|
+
binary?: true;
|
|
19
|
+
/** Present when a captured side exceeded the plugin's `maxFileBytes`; the file is listed without counts or comparison. */
|
|
20
|
+
oversized?: true;
|
|
21
|
+
}
|
|
22
|
+
/** Files changed during one top-level turn, kept on the Host until its Session is disposed. */
|
|
23
|
+
export interface WorkspaceChangesSummary {
|
|
24
|
+
/** The turn whose file changes this summary describes. */
|
|
25
|
+
turn: number;
|
|
26
|
+
/** The Session working directory `path` values are relative to. */
|
|
27
|
+
cwd: string;
|
|
28
|
+
/** Changed files in `display` order, capped at the plugin's `maxFiles`. */
|
|
29
|
+
files: WorkspaceChangedFile[];
|
|
30
|
+
/** Complete changed-file count, including files omitted by the cap. */
|
|
31
|
+
total: number;
|
|
32
|
+
/** Lines added over every changed file, including files omitted by the cap. */
|
|
33
|
+
added: number;
|
|
34
|
+
/** Lines deleted over every changed file, including files omitted by the cap. */
|
|
35
|
+
deleted: number;
|
|
36
|
+
/** Git tree ids of the turn-start and turn-end snapshots; absent when no snapshot was taken. */
|
|
37
|
+
snapshot?: {
|
|
38
|
+
before: string;
|
|
39
|
+
after: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** One unified-diff hunk with three context lines; every line keeps its `+`, `-`, or space prefix. */
|
|
43
|
+
export interface WorkspaceDiffHunk {
|
|
44
|
+
/** First line of the hunk in the turn-start content, 1-based; a side without lines starts at 1 with zero lines. */
|
|
45
|
+
oldStart: number;
|
|
46
|
+
/** Lines of the hunk taken from the turn-start content. */
|
|
47
|
+
oldLines: number;
|
|
48
|
+
/** First line of the hunk in the turn-end content, 1-based; a side without lines starts at 1 with zero lines. */
|
|
49
|
+
newStart: number;
|
|
50
|
+
/** Lines of the hunk taken from the turn-end content. */
|
|
51
|
+
newLines: number;
|
|
52
|
+
/** Hunk body in order, each line prefixed with `+`, `-`, or a space. */
|
|
53
|
+
lines: string[];
|
|
54
|
+
}
|
|
55
|
+
/** The comparison of one listed file's turn-start and turn-end contents, computed when asked for. */
|
|
56
|
+
export type WorkspaceFileDiff = {
|
|
57
|
+
kind: 'text';
|
|
58
|
+
/** The listed file's `path`. */
|
|
59
|
+
path: string;
|
|
60
|
+
/** The listed file's `display`. */
|
|
61
|
+
display: string;
|
|
62
|
+
/** Whether the file existed at turn start. */
|
|
63
|
+
before: boolean;
|
|
64
|
+
/** Whether the file existed at turn end. */
|
|
65
|
+
after: boolean;
|
|
66
|
+
/** Hunks in file order; empty when both sides hold the same lines. */
|
|
67
|
+
hunks: WorkspaceDiffHunk[];
|
|
68
|
+
/** True when the line comparison exceeded the plugin's `diffTimeoutMs` and every line is shown as replaced. */
|
|
69
|
+
coarse: boolean;
|
|
70
|
+
}
|
|
71
|
+
/** A side git reported as binary or that holds a NUL byte; no lines are served. */
|
|
72
|
+
| {
|
|
73
|
+
kind: 'binary';
|
|
74
|
+
path: string;
|
|
75
|
+
display: string;
|
|
76
|
+
}
|
|
77
|
+
/** A side larger than the plugin's `maxFileBytes`; no lines are served. */
|
|
78
|
+
| {
|
|
79
|
+
kind: 'oversized';
|
|
80
|
+
path: string;
|
|
81
|
+
display: string;
|
|
82
|
+
};
|
|
83
|
+
/** One file the whole Session changed, folded over the turns whose summaries listed it. */
|
|
84
|
+
export interface WorkspaceSessionChangedFile {
|
|
85
|
+
/** Path relative to the Session working directory, or an absolute Host path outside it. */
|
|
86
|
+
path: string;
|
|
87
|
+
/** Sort key and label, as the per-turn summary reports it. */
|
|
88
|
+
display: string;
|
|
89
|
+
/** Lines added by the latest turn that changed the file; zero for a binary or oversized file. */
|
|
90
|
+
added: number;
|
|
91
|
+
/** Lines deleted by the latest turn that changed the file; zero for a binary or oversized file. */
|
|
92
|
+
deleted: number;
|
|
93
|
+
/** How many turns listed the file, so a rewrite in a later turn counts again. */
|
|
94
|
+
turns: number;
|
|
95
|
+
/** The latest turn that changed the file. */
|
|
96
|
+
lastTurn: number;
|
|
97
|
+
/** The `workspace/changes` event of that latest turn. */
|
|
98
|
+
lastSeq: number;
|
|
99
|
+
/** The file's index in that event's summary, which `diff` addresses it by. */
|
|
100
|
+
lastIndex: number;
|
|
101
|
+
/** Present when the latest turn reported the file as binary. */
|
|
102
|
+
binary?: true;
|
|
103
|
+
/** Present when a captured side exceeded the plugin's `maxFileBytes` in the latest turn. */
|
|
104
|
+
oversized?: true;
|
|
105
|
+
}
|
|
106
|
+
/** Every file change one Session recorded, folded by path across its turns. */
|
|
107
|
+
export interface WorkspaceSessionChanges {
|
|
108
|
+
/** The Session working directory `path` values are relative to. */
|
|
109
|
+
cwd: string;
|
|
110
|
+
/** Distinct changed files in `display` order. */
|
|
111
|
+
files: WorkspaceSessionChangedFile[];
|
|
112
|
+
/** Distinct changed files: the length of `files`. */
|
|
113
|
+
total: number;
|
|
114
|
+
/** Lines added over every turn, from the per-turn complete counts. */
|
|
115
|
+
added: number;
|
|
116
|
+
/** Lines deleted over every turn, from the per-turn complete counts. */
|
|
117
|
+
deleted: number;
|
|
118
|
+
}
|
|
119
|
+
/** Serves the summaries and file comparisons the recorder keeps for live Sessions. */
|
|
120
|
+
export interface WorkspaceChanges {
|
|
121
|
+
/**
|
|
122
|
+
* The summary announced by one `workspace/changes` event.
|
|
123
|
+
* @param sessionId - the Session that appended the event.
|
|
124
|
+
* @param seq - the event's sequence number.
|
|
125
|
+
* @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
|
|
126
|
+
*/
|
|
127
|
+
summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* Every file the Session's recorded turns changed, folded by path.
|
|
130
|
+
* @param sessionId - the Session that appended the events.
|
|
131
|
+
* @returns the folded list, or undefined once its Session was disposed or when it recorded no turn.
|
|
132
|
+
*/
|
|
133
|
+
session(sessionId: SessionId): WorkspaceSessionChanges | undefined;
|
|
134
|
+
/**
|
|
135
|
+
* Compare one listed file's contents at turn start and turn end.
|
|
136
|
+
* @param sessionId - the Session that appended the event.
|
|
137
|
+
* @param seq - the event's sequence number.
|
|
138
|
+
* @param index - the file's index in the summary's `files`.
|
|
139
|
+
* @param signal - cancels the reads.
|
|
140
|
+
* @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index.
|
|
141
|
+
* @throws when a snapshot read fails for a live Session.
|
|
142
|
+
*/
|
|
143
|
+
diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise<WorkspaceFileDiff | undefined>;
|
|
144
|
+
}
|
|
145
|
+
declare module '@qilin-agent/session/types' {
|
|
146
|
+
interface SessionEventMap {
|
|
147
|
+
/**
|
|
148
|
+
* A completed top-level turn's changed files were summarized; the summary itself stays on the
|
|
149
|
+
* Host and is served by `workspaceChanges.summary` for the event's sequence while the Session
|
|
150
|
+
* lives. The latest event for one turn replaces earlier ones.
|
|
151
|
+
*/
|
|
152
|
+
'workspace/changes': {
|
|
153
|
+
turn: number;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
declare module '@qilin-agent/kylin' {
|
|
158
|
+
interface Context {
|
|
159
|
+
/** Per-turn changed-file summaries and comparisons of live Sessions. */
|
|
160
|
+
workspaceChanges: WorkspaceChanges;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qilin-agent/workspace-changes",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"description": "Per-turn workspace file changes recorded from git working-tree snapshots and whole-file captures, with per-file comparisons, for the QiLin",
|
|
4
|
+
"version": "3.1.4",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kkutysllb/QiLin.git",
|
|
11
|
+
"directory": "packages/deliverables/workspace-changes"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "lib/index.js",
|
|
18
|
+
"types": "lib/types/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./lib/types/index.d.ts",
|
|
22
|
+
"default": "./lib/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./types": {
|
|
25
|
+
"types": "./lib/types/types.d.ts",
|
|
26
|
+
"default": "./lib/types/types.js"
|
|
27
|
+
},
|
|
28
|
+
"./src/*": "./src/*",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"files": [
|
|
33
|
+
"lib/index.js",
|
|
34
|
+
"lib/types/**/*.js",
|
|
35
|
+
"lib/types/**/*.d.ts"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"diff": "^9.0.0",
|
|
39
|
+
"@qilin-agent/schemastery": "~3.18.3"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@qilin-agent/kylin": "~4.0.3",
|
|
43
|
+
"@qilin-agent/agent": "3.1.4",
|
|
44
|
+
"@qilin-agent/session": "3.1.4",
|
|
45
|
+
"@qilin-agent/subprocess": "3.1.4",
|
|
46
|
+
"@qilin-agent/tools": "3.1.4"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@qilin-agent/kylin-plugin-include": "~1.0.8",
|
|
50
|
+
"@qilin-agent/kylin-plugin-loader": "~1.0.4",
|
|
51
|
+
"@qilin-agent/agent": "3.1.4",
|
|
52
|
+
"@qilin-agent/session": "3.1.4",
|
|
53
|
+
"@qilin-agent/kylin": "~4.0.3",
|
|
54
|
+
"@qilin-agent/subprocess-local": "3.1.4",
|
|
55
|
+
"@qilin-agent/subprocess": "3.1.4",
|
|
56
|
+
"@qilin-agent/tools": "3.1.4",
|
|
57
|
+
"@qilin-agent/llm": "3.1.4"
|
|
58
|
+
}
|
|
6
59
|
}
|