@vibedgc/sdk 0.6.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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * What a run changed under the session cwd. Mirrors the workspace-change code in
3
+ * sdk/python/dgc_sdk/session.py.
4
+ *
5
+ * Only VCS metadata and dependency/tool caches are skipped. Everything else under the session cwd
6
+ * is reported, including dot-directories (.github), lockfiles and directories named home/ or
7
+ * locks/. The SDK's own stateDir (and, when inheriting user state, ~/.dgc) is skipped by absolute
8
+ * path when it lives under the cwd. Each change carries the before/after text (up to 1 MB) and a
9
+ * `git apply`-able unified diff.
10
+ */
11
+ import { closeSync, constants as fsConstants, fstatSync, lstatSync, openSync, readdirSync, readSync, statSync, } from "node:fs";
12
+ import { join, resolve } from "node:path";
13
+ const SKIP_DIRS = new Set([
14
+ ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", ".mypy_cache",
15
+ ".pytest_cache", ".ruff_cache",
16
+ ]);
17
+ const TEXT_LIMIT = 1_000_000; // bytes kept per file for before/after/diff
18
+ const SNAPSHOT_BUDGET = 64 * 1024 * 1024; // before-content kept per run
19
+ const CONTEXT = 3;
20
+ function normcase(path) {
21
+ return process.platform === "win32" ? path.toLowerCase() : path;
22
+ }
23
+ /**
24
+ * The bytes of a regular file (up to the text limit), or null. O_NONBLOCK: a workspace path
25
+ * swapped for a FIFO would otherwise block the open, and the whole process, forever. O_NOFOLLOW
26
+ * keeps a symlink from redirecting the read.
27
+ */
28
+ export function readRegular(path) {
29
+ let fd;
30
+ try {
31
+ fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0) | (fsConstants.O_NONBLOCK || 0));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ try {
37
+ if (!fstatSync(fd).isFile())
38
+ return null; // a FIFO, device or socket is not a regular file
39
+ const buffer = Buffer.alloc(TEXT_LIMIT + 1);
40
+ let total = 0;
41
+ while (total < buffer.length) {
42
+ const got = readSync(fd, buffer, total, buffer.length - total, null);
43
+ if (!got)
44
+ break;
45
+ total += got;
46
+ }
47
+ return buffer.subarray(0, total);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ finally {
53
+ closeSync(fd);
54
+ }
55
+ }
56
+ /** relpath -> state. Bounded to the session cwd, not a parent git tree. No symlink is followed. */
57
+ export function snapshotWorkspace(root, exclude = [], keepContent = true) {
58
+ const snap = new Map();
59
+ if (!root)
60
+ return snap;
61
+ try {
62
+ if (!statSync(root).isDirectory())
63
+ return snap;
64
+ }
65
+ catch {
66
+ return snap;
67
+ }
68
+ const base = resolve(root);
69
+ const skip = new Set(exclude.map((item) => normcase(resolve(item))));
70
+ let budget = keepContent ? SNAPSHOT_BUDGET : 0;
71
+ const walk = (dir, rel) => {
72
+ let entries;
73
+ try {
74
+ entries = readdirSync(dir, { withFileTypes: true });
75
+ }
76
+ catch {
77
+ return;
78
+ }
79
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
80
+ const subdirs = [];
81
+ for (const entry of entries) {
82
+ const path = join(dir, entry.name);
83
+ if (skip.has(normcase(path)))
84
+ continue;
85
+ let isDir = entry.isDirectory();
86
+ if (entry.isSymbolicLink()) {
87
+ // Like os.walk: a link to a directory is listed with the directories (not descended).
88
+ try {
89
+ isDir = statSync(path).isDirectory();
90
+ }
91
+ catch {
92
+ isDir = false;
93
+ }
94
+ if (isDir)
95
+ continue;
96
+ }
97
+ if (isDir) {
98
+ if (!SKIP_DIRS.has(entry.name))
99
+ subdirs.push(entry.name);
100
+ continue;
101
+ }
102
+ let info;
103
+ try {
104
+ info = lstatSync(path, { bigint: true });
105
+ }
106
+ catch {
107
+ continue;
108
+ }
109
+ const size = Number(info.size);
110
+ let content = null;
111
+ if (budget > 0 && info.isFile() && size <= TEXT_LIMIT && size <= budget) {
112
+ content = readRegular(path);
113
+ if (content !== null)
114
+ budget -= content.length;
115
+ }
116
+ snap.set(rel ? `${rel}/${entry.name}` : entry.name, {
117
+ mtimeNs: info.mtimeNs, size, mode: Number(info.mode), content,
118
+ });
119
+ }
120
+ for (const name of subdirs)
121
+ walk(join(dir, name), rel ? `${rel}/${name}` : name);
122
+ };
123
+ walk(base, "");
124
+ return snap;
125
+ }
126
+ function sameBytes(left, right) {
127
+ return Buffer.from(left.buffer, left.byteOffset, left.byteLength)
128
+ .equals(Buffer.from(right.buffer, right.byteOffset, right.byteLength));
129
+ }
130
+ function asText(payload) {
131
+ if (payload === null || payload.length > TEXT_LIMIT || payload.subarray(0, 8192).includes(0))
132
+ return null;
133
+ try {
134
+ return new TextDecoder("utf-8", { fatal: true }).decode(payload);
135
+ }
136
+ catch {
137
+ return null;
138
+ }
139
+ }
140
+ function splitLines(text) {
141
+ const parts = text.split("\n");
142
+ const lines = parts.slice(0, -1).map((part) => part + "\n");
143
+ const last = parts[parts.length - 1];
144
+ if (last)
145
+ lines.push(last);
146
+ return lines;
147
+ }
148
+ function gitPath(prefix, rel) {
149
+ const path = `${prefix}/${rel}`;
150
+ // eslint-disable-next-line no-control-regex
151
+ if (/["\\\t\n]/.test(path) || [...path].some((ch) => ch.charCodeAt(0) < 32)) {
152
+ const escaped = path.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\t/g, "\\t").replace(/\n/g, "\\n");
153
+ return `"${escaped}"`;
154
+ }
155
+ return path;
156
+ }
157
+ function fileMode(mode) {
158
+ return mode & 0o111 ? "100755" : "100644";
159
+ }
160
+ /** A line edit script (Myers, O(ND)); a very different pair falls back to replace-all. */
161
+ function editScript(a, b) {
162
+ let start = 0;
163
+ while (start < a.length && start < b.length && a[start] === b[start])
164
+ start += 1;
165
+ let endA = a.length;
166
+ let endB = b.length;
167
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
168
+ endA -= 1;
169
+ endB -= 1;
170
+ }
171
+ const head = a.slice(0, start).map((line) => ({ kind: "=", line }));
172
+ const tail = a.slice(endA).map((line) => ({ kind: "=", line }));
173
+ const x = a.slice(start, endA);
174
+ const y = b.slice(start, endB);
175
+ const n = x.length;
176
+ const m = y.length;
177
+ const middle = [];
178
+ const replaceAll = () => {
179
+ for (const line of x)
180
+ middle.push({ kind: "-", line });
181
+ for (const line of y)
182
+ middle.push({ kind: "+", line });
183
+ };
184
+ // Past this many edits the pair is treated as rewritten (still a valid, applicable diff).
185
+ const maxD = Math.min(n + m, 2000);
186
+ if (n === 0 || m === 0) {
187
+ replaceAll();
188
+ return [...head, ...middle, ...tail];
189
+ }
190
+ const offset = maxD + 1;
191
+ const v = new Int32Array(2 * maxD + 3);
192
+ // trace[d] keeps the frontier before round d, for diagonals -d-1..d+1 only (O(D^2) memory).
193
+ const trace = [];
194
+ const at = (frame, d, k) => frame[k + d + 1];
195
+ let found = -1;
196
+ for (let d = 0; d <= maxD && found < 0; d++) {
197
+ trace.push(v.slice(offset - d - 1, offset + d + 2));
198
+ for (let k = -d; k <= d; k += 2) {
199
+ let px;
200
+ if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]))
201
+ px = v[offset + k + 1];
202
+ else
203
+ px = v[offset + k - 1] + 1;
204
+ let py = px - k;
205
+ while (px < n && py < m && x[px] === y[py]) {
206
+ px += 1;
207
+ py += 1;
208
+ }
209
+ v[offset + k] = px;
210
+ if (px >= n && py >= m) {
211
+ found = d;
212
+ break;
213
+ }
214
+ }
215
+ }
216
+ if (found < 0) {
217
+ replaceAll();
218
+ return [...head, ...middle, ...tail];
219
+ }
220
+ // Backtrack through the saved frontiers.
221
+ const reversed = [];
222
+ let px = n;
223
+ let py = m;
224
+ for (let d = found; d > 0; d--) {
225
+ const frame = trace[d];
226
+ const k = px - py;
227
+ const down = k === -d || (k !== d && at(frame, d, k - 1) < at(frame, d, k + 1));
228
+ const prevK = down ? k + 1 : k - 1;
229
+ const prevX = at(frame, d, prevK);
230
+ const prevY = prevX - prevK;
231
+ while (px > prevX && py > prevY) {
232
+ px -= 1;
233
+ py -= 1;
234
+ reversed.push({ kind: "=", line: x[px] });
235
+ }
236
+ if (down) {
237
+ py -= 1;
238
+ reversed.push({ kind: "+", line: y[py] });
239
+ }
240
+ else {
241
+ px -= 1;
242
+ reversed.push({ kind: "-", line: x[px] });
243
+ }
244
+ }
245
+ while (px > 0 && py > 0) {
246
+ px -= 1;
247
+ py -= 1;
248
+ reversed.push({ kind: "=", line: x[px] });
249
+ }
250
+ middle.push(...reversed.reverse());
251
+ return [...head, ...middle, ...tail];
252
+ }
253
+ function formatRange(start, length) {
254
+ let beginning = start + 1;
255
+ if (length === 1)
256
+ return `${beginning}`;
257
+ if (!length)
258
+ beginning -= 1;
259
+ return `${beginning},${length}`;
260
+ }
261
+ function withEol(line) {
262
+ return line.endsWith("\n") ? line : line + "\n\\n";
263
+ }
264
+ /** Unified hunks (3 lines of context) for two line lists, in difflib's format. */
265
+ export function unifiedHunks(a, b) {
266
+ const ops = editScript(a, b);
267
+ const changed = [];
268
+ ops.forEach((op, index) => { if (op.kind !== "=")
269
+ changed.push(index); });
270
+ if (!changed.length)
271
+ return [];
272
+ const groups = [];
273
+ let groupStart = Math.max(0, changed[0] - CONTEXT);
274
+ let groupEnd = Math.min(ops.length, changed[0] + CONTEXT + 1);
275
+ for (const index of changed.slice(1)) {
276
+ if (index - CONTEXT <= groupEnd) {
277
+ groupEnd = Math.min(ops.length, index + CONTEXT + 1);
278
+ }
279
+ else {
280
+ groups.push([groupStart, groupEnd]);
281
+ groupStart = index - CONTEXT;
282
+ groupEnd = Math.min(ops.length, index + CONTEXT + 1);
283
+ }
284
+ }
285
+ groups.push([groupStart, groupEnd]);
286
+ // Line numbers before each op.
287
+ const aAt = [];
288
+ const bAt = [];
289
+ let ai = 0;
290
+ let bi = 0;
291
+ for (const op of ops) {
292
+ aAt.push(ai);
293
+ bAt.push(bi);
294
+ if (op.kind !== "+")
295
+ ai += 1;
296
+ if (op.kind !== "-")
297
+ bi += 1;
298
+ }
299
+ const out = [];
300
+ for (const [from, to] of groups) {
301
+ const slice = ops.slice(from, to);
302
+ const aLen = slice.filter((op) => op.kind !== "+").length;
303
+ const bLen = slice.filter((op) => op.kind !== "-").length;
304
+ out.push(`@@ -${formatRange(aAt[from], aLen)} +${formatRange(bAt[from], bLen)} @@\n`);
305
+ for (const op of slice)
306
+ out.push(withEol((op.kind === "=" ? " " : op.kind) + op.line));
307
+ }
308
+ return out;
309
+ }
310
+ /** A `git apply`-able diff for one text file, or "" when either side is not text. */
311
+ export function unifiedDiff(rel, kind, before, after, modeBefore, modeAfter) {
312
+ if ((kind !== "added" && before === null) || (kind !== "deleted" && after === null))
313
+ return "";
314
+ const oldLines = kind !== "added" ? splitLines(before || "") : [];
315
+ const newLines = kind !== "deleted" ? splitLines(after || "") : [];
316
+ const aName = gitPath("a", rel);
317
+ const bName = gitPath("b", rel);
318
+ const header = [`diff --git ${aName} ${bName}\n`];
319
+ if (kind === "added")
320
+ header.push(`new file mode ${fileMode(modeAfter)}\n`);
321
+ else if (kind === "deleted")
322
+ header.push(`deleted file mode ${fileMode(modeBefore)}\n`);
323
+ else if (fileMode(modeBefore) !== fileMode(modeAfter)) {
324
+ header.push(`old mode ${fileMode(modeBefore)}\nnew mode ${fileMode(modeAfter)}\n`);
325
+ }
326
+ const hunks = unifiedHunks(oldLines, newLines);
327
+ const body = hunks.length
328
+ ? [`--- ${kind === "added" ? "/dev/null" : aName}\n`, `+++ ${kind === "deleted" ? "/dev/null" : bName}\n`, ...hunks]
329
+ : [];
330
+ if (!body.length && kind === "modified" && header.length === 1)
331
+ return "";
332
+ return [...header, ...body].join("");
333
+ }
334
+ function isRegular(mode) {
335
+ return (mode & 0o170000) === 0o100000;
336
+ }
337
+ /**
338
+ * Changes from `before` to `after` (a snapshot taken when the run's last turn ended; a fresh one
339
+ * when omitted). With `after` given, its kept content is used, so edits a later turn made in the
340
+ * meantime are not attributed to this run.
341
+ */
342
+ export function diffWorkspace(root, before, exclude = [], afterSnapshot) {
343
+ const after = afterSnapshot ?? snapshotWorkspace(root, exclude, false);
344
+ const rootText = root ? resolve(root) : "";
345
+ const changes = [];
346
+ const names = [...new Set([...before.keys(), ...after.keys()])].sort();
347
+ for (const rel of names) {
348
+ const old = before.get(rel);
349
+ const now = after.get(rel);
350
+ let kind;
351
+ if (!now && old)
352
+ kind = "deleted";
353
+ else if (!old && now)
354
+ kind = "added";
355
+ else if (old && now && (old.mtimeNs !== now.mtimeNs || old.size !== now.size || old.mode !== now.mode)) {
356
+ kind = "modified";
357
+ }
358
+ else
359
+ continue;
360
+ const oldMode = old ? old.mode : 0;
361
+ const newMode = now ? now.mode : 0;
362
+ let afterBytes = null;
363
+ if (now && isRegular(newMode) && root)
364
+ afterBytes = now.content ?? readRegular(join(root, rel));
365
+ if (kind === "modified" && old && old.content !== null && afterBytes !== null
366
+ && sameBytes(afterBytes, old.content) && oldMode === newMode) {
367
+ continue; // touched, not changed
368
+ }
369
+ const beforeText = old ? asText(old.content) : "";
370
+ let afterText = now ? asText(afterBytes) : "";
371
+ if (kind === "added" && !isRegular(newMode))
372
+ afterText = null;
373
+ const diff = unifiedDiff(rel, kind, beforeText, afterText, oldMode, newMode);
374
+ changes.push({ path: rel, kind, before: beforeText || "", after: afterText || "", root: rootText, diff });
375
+ }
376
+ return changes;
377
+ }
@@ -0,0 +1,53 @@
1
+ import { Session } from "./session.ts";
2
+ import { type UsageReport } from "./usage.ts";
3
+ import { type ClientOptions, type ResumeOptions, type SessionOptions } from "./types.ts";
4
+ /**
5
+ * Own zero or more isolated DGC sessions. Does not talk to a provider on construction.
6
+ *
7
+ * `stateDir` holds this client's isolated HOME, audit and usage logs; it must be private, and
8
+ * left unset a fresh private temporary directory is used ({@link stateDir}). `inheritEnv` picks
9
+ * which host environment variables reach the runtime: false (default) passes only basic ones, a
10
+ * list of names adds those, true passes everything. `startTimeoutMs` bounds the runtime's startup
11
+ * handshake and `requestTimeoutMs` each control request.
12
+ */
13
+ export declare class DGC {
14
+ private readonly sessions;
15
+ private closed;
16
+ private readonly options;
17
+ private readonly policy;
18
+ private readonly sandbox;
19
+ private readonly runtime;
20
+ private readonly startTimeoutMs;
21
+ private readonly requestTimeoutMs;
22
+ private readonly usageLog;
23
+ private readonly auditLog;
24
+ /** This client's private state directory (created when it was not given). */
25
+ readonly stateDir: string;
26
+ private ownsStateDir;
27
+ private readonly trustWorkspace;
28
+ constructor(options?: ClientOptions);
29
+ get version(): string;
30
+ /** The `dgc serve` argv this client starts. */
31
+ get rawRuntime(): string[];
32
+ session(options: SessionOptions): Promise<Session>;
33
+ /**
34
+ * Write this session's config from scratch, then start `dgc serve` on it. A DGC child saves its
35
+ * whole config when it persists anything; if another session's child did that between our write
36
+ * and our child's startup, start again.
37
+ */
38
+ private start;
39
+ private protocolError;
40
+ private startFailure;
41
+ /** Open a session on a persisted transcript (`sessionId`, or `latest: true`). */
42
+ resume(options: ResumeOptions): Promise<Session>;
43
+ /**
44
+ * Usage recorded under this client's stateDir (never the host ~/.dgc): runs, token totals over
45
+ * runs whose provider reported usage, `unknownUsageRuns` for the rest, cost, per department.
46
+ */
47
+ usageReport(department?: string): UsageReport;
48
+ /** Audit rows (redacted unless `redact: false`), for one session or all. */
49
+ exportAudit(sessionId?: string, options?: {
50
+ redact?: boolean;
51
+ }): Array<Record<string, unknown>>;
52
+ close(): Promise<void>;
53
+ }