@coderook/cli 0.46.2 → 0.46.3

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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, with cbx or plain git, from Claude Code.",
5
- "version": "0.46.2",
5
+ "version": "0.46.3",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -3334,12 +3334,11 @@ class Uploader {
3334
3334
  if (end > start)
3335
3335
  wanted.push({ at, start, end });
3336
3336
  });
3337
- for (let from = 0; from < wanted.length; from += 64) {
3338
- const group = wanted.slice(from, from + 64);
3337
+ const ask = async (group) => {
3339
3338
  try {
3340
3339
  const token = await this.credentials.token();
3341
3340
  if (!token)
3342
- return found;
3341
+ return "no";
3343
3342
  const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}/objects/chunk-delta/signatures`, {
3344
3343
  method: "POST",
3345
3344
  headers: {
@@ -3357,17 +3356,42 @@ class Uploader {
3357
3356
  }),
3358
3357
  signal: this.controller.signal,
3359
3358
  });
3359
+ if (response.status === 413)
3360
+ return "too-large";
3360
3361
  if (!response.ok)
3361
- continue;
3362
+ return "no";
3362
3363
  const bytes = new Uint8Array(await response.arrayBuffer());
3363
3364
  this.chunkDeltaSignatureBytes += bytes.byteLength;
3364
3365
  const decoded = (0, delta_js_1.decodeDeltaSignatureBatch)(bytes);
3365
3366
  if (decoded.length !== group.length)
3366
- continue;
3367
+ return "no";
3367
3368
  group.forEach((one, index) => found.set(one.at, { signature: decoded[index], start: one.start, end: one.end }));
3369
+ return "ok";
3368
3370
  }
3369
3371
  catch {
3370
3372
  /* Declined or unreachable: those pieces go whole, as they always did. */
3373
+ return "no";
3374
+ }
3375
+ };
3376
+ for (let from = 0; from < wanted.length; from += 64) {
3377
+ const group = wanted.slice(from, from + 64);
3378
+ if ((await ask(group)) !== "too-large")
3379
+ continue;
3380
+ /*
3381
+ One range covered more old pieces than the service will join, and that
3382
+ refused the whole group. Asked for one by one, and a range still too
3383
+ large is asked for once more without its slack: an overwrite that
3384
+ merged old pieces sent 3.1 MB whole for a 64 KiB edit because its
3385
+ slack reached one old piece past the limit.
3386
+ */
3387
+ for (const one of group) {
3388
+ if ((await ask([one])) !== "too-large")
3389
+ continue;
3390
+ const piece = layout[one.at];
3391
+ const start = Math.min(piece.offset, base.baseSize);
3392
+ const end = Math.min(base.baseSize, piece.offset + piece.length);
3393
+ if (end > start && (start !== one.start || end !== one.end))
3394
+ await ask([{ at: one.at, start, end }]);
3371
3395
  }
3372
3396
  }
3373
3397
  return found;
@@ -126,7 +126,7 @@ async function save(repository, options) {
126
126
  previous: before,
127
127
  progress: options.progress,
128
128
  });
129
- const tree = await repository.writeTree([...reading.files.values()]);
129
+ const tree = await repository.writeTree([...reading.files.values()], parent?.tree);
130
130
  await repository.writeIndex(reading.index);
131
131
  if (!merging && parent && parent.tree === tree) {
132
132
  return { saved: false, reason: "unchanged" };
@@ -148,6 +148,14 @@ async function save(repository, options) {
148
148
  await repository.setLineTip(line, made.id);
149
149
  if (merging)
150
150
  await (0, merge_js_1.clearMergeState)(repository);
151
+ /* The folder is what was just saved; said so, for the next quiet status. */
152
+ if (repository.objects.v2) {
153
+ await repository.writeIndex(reading.index, {
154
+ head: made.id,
155
+ clean: true,
156
+ complete: reading.index.size === reading.files.size,
157
+ });
158
+ }
151
159
  let bytes = 0;
152
160
  for (const file of reading.files.values())
153
161
  bytes += file.size;
@@ -190,13 +198,28 @@ async function status(repository) {
190
198
  that file once more, never trust something it has not stored. Written
191
199
  only when it changed; rewriting it whole was a good part of a quiet status.
192
200
  */
193
- if (reading.indexChanged)
194
- await repository.writeIndex(reading.index);
195
201
  const merging = await (0, merge_js_1.readMergeState)(repository);
202
+ const changes = compareTrees(byPath(tree), reading.files);
203
+ /*
204
+ And whether the folder is exactly the head, so the next quiet status can
205
+ be answered without Node (engine/src/quick.rs). Rewritten when what it
206
+ says changes, as well as when the index does.
207
+ */
208
+ const stamp = {
209
+ head: head?.id ?? null,
210
+ clean: !changeCount(changes) && !merging,
211
+ complete: reading.index.size === reading.files.size,
212
+ };
213
+ const told = repository.indexStamp;
214
+ if (reading.indexChanged ||
215
+ (repository.objects.v2 &&
216
+ (told?.head !== stamp.head || told?.clean !== stamp.clean || (told?.complete === true) !== stamp.complete))) {
217
+ await repository.writeIndex(reading.index, stamp);
218
+ }
196
219
  return {
197
220
  line: await repository.currentLine(),
198
221
  head,
199
- changes: compareTrees(byPath(tree), reading.files),
222
+ changes,
200
223
  ...(merging
201
224
  ? {
202
225
  merging: {
@@ -3,12 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.Repository = exports.DEFAULT_LINE = exports.FORMAT_LINE = exports.STORE_DIRECTORY = void 0;
6
+ exports.Repository = exports.DEFAULT_LINE = exports.FORMAT_LINE_V2 = exports.FORMAT_LINE = exports.STORE_DIRECTORY = void 0;
7
7
  exports.lineNameProblem = lineNameProblem;
8
8
  /**
9
9
  * A local history: the `.cbx/` folder at the top of a project.
10
10
  *
11
- * .cbx/format "cbx-local 1", so a later layout can tell an older one
11
+ * .cbx/format "cbx-local 1" or "cbx-local 2", so a later layout can tell an older one
12
12
  * .cbx/objects/ the object store (see store.ts)
13
13
  * .cbx/lines/<name> the id of the newest save on each line of work
14
14
  * .cbx/state.json which line the folder is on
@@ -28,6 +28,15 @@ const node_path_1 = __importDefault(require("node:path"));
28
28
  const store_js_1 = require("./store.js");
29
29
  exports.STORE_DIRECTORY = ".cbx";
30
30
  exports.FORMAT_LINE = "cbx-local 1";
31
+ /*
32
+ The same objects, saves and trees, kept as solid blocks and deltas
33
+ (store_v2.ts, engine/V2-STORE.md). Read by every cbx that knows it; an
34
+ older one refuses the history and asks to be updated rather than misread it.
35
+ New histories are made in it only when CBX_LOCAL_FORMAT=2, until it has
36
+ proved itself on the benchmark suite.
37
+ */
38
+ exports.FORMAT_LINE_V2 = "cbx-local 2";
39
+ const FORMATS = new Set([exports.FORMAT_LINE, exports.FORMAT_LINE_V2]);
31
40
  exports.DEFAULT_LINE = "main";
32
41
  /**
33
42
  * Line names are file names under `.cbx/lines`, so they are held to what is
@@ -69,10 +78,10 @@ class Repository {
69
78
  root;
70
79
  directory;
71
80
  objects;
72
- constructor(root) {
81
+ constructor(root, format = exports.FORMAT_LINE) {
73
82
  this.root = root;
74
83
  this.directory = node_path_1.default.join(root, exports.STORE_DIRECTORY);
75
- this.objects = new store_js_1.ObjectStore(node_path_1.default.join(this.directory, "objects"));
84
+ this.objects = new store_js_1.ObjectStore(node_path_1.default.join(this.directory, "objects"), { v2: format === exports.FORMAT_LINE_V2 });
76
85
  }
77
86
  /** Start a history in `root`. Refuses a folder that already has one. */
78
87
  static async init(root, line = exports.DEFAULT_LINE) {
@@ -86,10 +95,11 @@ class Repository {
86
95
  }
87
96
  await (0, promises_1.mkdir)(node_path_1.default.join(directory, "objects"), { recursive: true });
88
97
  await (0, promises_1.mkdir)(node_path_1.default.join(directory, "lines"), { recursive: true });
89
- await (0, promises_1.writeFile)(node_path_1.default.join(directory, "format"), `${exports.FORMAT_LINE}\n`);
98
+ const format = process.env.CBX_LOCAL_FORMAT === "2" ? exports.FORMAT_LINE_V2 : exports.FORMAT_LINE;
99
+ await (0, promises_1.writeFile)(node_path_1.default.join(directory, "format"), `${format}\n`);
90
100
  await (0, promises_1.writeFile)(node_path_1.default.join(directory, "state.json"), JSON.stringify({ line }));
91
101
  await (0, promises_1.writeFile)(node_path_1.default.join(directory, "config.json"), "{}");
92
- return new Repository(full);
102
+ return new Repository(full, format);
93
103
  }
94
104
  /** The history `from` is inside, looking upward; null when there is none. */
95
105
  static async find(from) {
@@ -98,10 +108,10 @@ class Repository {
98
108
  const directory = node_path_1.default.join(at, exports.STORE_DIRECTORY);
99
109
  if (await exists(node_path_1.default.join(directory, "format"))) {
100
110
  const format = (await (0, promises_1.readFile)(node_path_1.default.join(directory, "format"), "utf8")).trim();
101
- if (format !== exports.FORMAT_LINE) {
111
+ if (!FORMATS.has(format)) {
102
112
  throw new Error(`${directory} is in a format this cbx does not read (${format}). Update cbx.`);
103
113
  }
104
- return new Repository(at);
114
+ return new Repository(at, format);
105
115
  }
106
116
  const parent = node_path_1.default.dirname(at);
107
117
  if (parent === at)
@@ -218,7 +228,13 @@ class Repository {
218
228
  const id = await this.objects.putJson(canonical);
219
229
  return { ...canonical, id };
220
230
  }
221
- async writeTree(files) {
231
+ /**
232
+ * Keep a tree. In a v2 history it goes into a pack as a delta against
233
+ * `base`, the tree it was made from: a tree names every file, so twenty
234
+ * saves of an 8,500-file project kept twenty megabyte-sized lists, where
235
+ * what changed between them was a few lines.
236
+ */
237
+ async writeTree(files, base) {
222
238
  const sorted = [...files].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
223
239
  const tree = {
224
240
  format: "cbx-tree",
@@ -231,6 +247,20 @@ class Repository {
231
247
  chunks: file.chunks,
232
248
  })),
233
249
  };
250
+ if (this.objects.v2) {
251
+ const id = await this.objects.put(Buffer.from(JSON.stringify(tree), "utf8"), {
252
+ ...(base ? { base } : {}),
253
+ path: ".cbx-tree",
254
+ /*
255
+ Every status rebuilds the head tree, so its chain is kept short: four
256
+ deltas of a megabyte-sized list is a few milliseconds, twelve was not.
257
+ */
258
+ maxChain: 4,
259
+ });
260
+ /* On disk before any save can name it: a save is written on its own, at once. */
261
+ await this.objects.flush();
262
+ return id;
263
+ }
234
264
  return this.objects.putJson(tree);
235
265
  }
236
266
  /**
@@ -281,17 +311,66 @@ class Repository {
281
311
  return save;
282
312
  }
283
313
  /* ---- the index ------------------------------------------------------ */
314
+ /*
315
+ What a compact index last said about the folder as a whole: the save it
316
+ matched and whether it matched it exactly. Written by status, read by the
317
+ engine's quiet status (engine/src/quick.rs), which answers "nothing has
318
+ changed" from it after a walk, without Node. Any other write leaves it out.
319
+ */
320
+ stamp = null;
321
+ get indexStamp() {
322
+ return this.stamp;
323
+ }
284
324
  async readIndex() {
325
+ this.stamp = null;
285
326
  try {
286
327
  const raw = JSON.parse(await (0, promises_1.readFile)(node_path_1.default.join(this.directory, "index.json"), "utf8"));
328
+ if (raw.compact === 1) {
329
+ const told = raw.stamp;
330
+ if (told && (typeof told.head === "string" || told.head === null) && typeof told.clean === "boolean") {
331
+ this.stamp = { head: told.head, clean: told.clean, ...(told.complete === true ? { complete: true } : {}) };
332
+ }
333
+ const index = new Map();
334
+ for (const [file, size, mtimeMs, sha256, chunks, runs] of raw.files) {
335
+ index.set(file, {
336
+ size,
337
+ mtimeMs,
338
+ sha256,
339
+ chunks: chunks === 0 ? [sha256] : chunks,
340
+ ...(runs === undefined ? {} : { executable: runs === 1 }),
341
+ });
342
+ }
343
+ return index;
344
+ }
287
345
  return new Map(Object.entries(raw));
288
346
  }
289
347
  catch {
290
348
  return new Map();
291
349
  }
292
350
  }
293
- async writeIndex(index) {
294
- await writeAtomically(node_path_1.default.join(this.directory, "index.json"), JSON.stringify(Object.fromEntries(index)));
351
+ /*
352
+ A v2 history keeps the index compact: one array per file, and no list of
353
+ objects for a file that is one object named by its own digest, which is
354
+ nearly every file. It was 2.1 MB for 8,500 files, read and parsed on
355
+ every status. A v1 history keeps the old shape, which an older cbx reads.
356
+ */
357
+ async writeIndex(index, stamp) {
358
+ this.stamp = stamp && this.objects.v2 ? stamp : null;
359
+ const body = this.objects.v2
360
+ ? JSON.stringify({
361
+ compact: 1,
362
+ ...(stamp ? { stamp } : {}),
363
+ files: [...index].map(([file, entry]) => [
364
+ file,
365
+ entry.size,
366
+ entry.mtimeMs,
367
+ entry.sha256,
368
+ entry.chunks.length === 1 && entry.chunks[0] === entry.sha256 ? 0 : entry.chunks,
369
+ ...(entry.executable === undefined ? [] : [entry.executable ? 1 : 0]),
370
+ ]),
371
+ })
372
+ : JSON.stringify(Object.fromEntries(index));
373
+ await writeAtomically(node_path_1.default.join(this.directory, "index.json"), body);
295
374
  }
296
375
  /* ---- the lock ------------------------------------------------------- */
297
376
  /**
@@ -110,13 +110,65 @@ async function digestFile(full, size) {
110
110
  * that size, so an edit in the middle of a large asset stores the pieces
111
111
  * around the edit and reuses the rest.
112
112
  */
113
- async function storeFile(repository, full, size) {
113
+ async function storeFile(repository, full, size,
114
+ /*
115
+ For a v2 history: where the file sits, and what it was last time (or,
116
+ for a new file, a file like it), so its objects can be kept as deltas.
117
+ */
118
+ hints) {
114
119
  const profile = (0, chunking_js_1.profileForFileSize)(size);
115
120
  if (!profile) {
116
121
  const bytes = await (0, promises_1.readFile)(full);
117
- const digest = await repository.objects.put(bytes);
122
+ const was = hints?.previous?.chunks.length === 1 ? hints.previous : hints?.similar;
123
+ const base = was?.chunks.length === 1 ? was.chunks[0] : undefined;
124
+ const digest = await repository.objects.put(bytes, {
125
+ ...(base ? { base } : {}),
126
+ ...(hints ? { path: hints.path } : {}),
127
+ });
118
128
  return { sha256: digest, chunks: bytes.length ? [digest] : [] };
119
129
  }
130
+ /*
131
+ The old pieces by where they started, so each new piece is kept as a
132
+ delta against the old piece at the same place: an edit inside a big file
133
+ changes the pieces around it, and those resemble what they replaced.
134
+ */
135
+ const before = hints?.previous?.chunks ?? [];
136
+ const starts = [];
137
+ if (before.length && repository.objects.v2) {
138
+ let at = 0;
139
+ for (const digest of before) {
140
+ starts.push(at);
141
+ const length = await repository.objects.rawSize(digest);
142
+ if (length === null) {
143
+ starts.length = 0;
144
+ break;
145
+ }
146
+ at += length;
147
+ }
148
+ }
149
+ const baseAt = (offset) => {
150
+ if (!starts.length)
151
+ return undefined;
152
+ let low = 0;
153
+ let high = starts.length - 1;
154
+ while (low < high) {
155
+ const middle = (low + high + 1) >> 1;
156
+ if (starts[middle] <= offset)
157
+ low = middle;
158
+ else
159
+ high = middle - 1;
160
+ }
161
+ return before[low];
162
+ };
163
+ let written = 0;
164
+ const keep = async (piece) => {
165
+ const base = baseAt(written);
166
+ written += piece.length;
167
+ return repository.objects.put(piece, {
168
+ ...(base ? { base } : {}),
169
+ ...(hints ? { path: hints.path } : {}),
170
+ });
171
+ };
120
172
  const whole = (0, node_crypto_1.createHash)("sha256");
121
173
  const chunks = [];
122
174
  let pending = Buffer.alloc(0);
@@ -126,13 +178,13 @@ async function storeFile(repository, full, size) {
126
178
  pending = pending.length ? Buffer.concat([pending, block]) : Buffer.from(block);
127
179
  let from = 0;
128
180
  for (const cut of (0, chunking_js_1.cutPoints)(pending, profile)) {
129
- chunks.push(await repository.objects.put(Buffer.from(pending.subarray(from, cut))));
181
+ chunks.push(await keep(Buffer.from(pending.subarray(from, cut))));
130
182
  from = cut;
131
183
  }
132
184
  pending = pending.subarray(from);
133
185
  }
134
186
  if (pending.length)
135
- chunks.push(await repository.objects.put(Buffer.from(pending)));
187
+ chunks.push(await keep(Buffer.from(pending)));
136
188
  return { sha256: whole.digest("hex"), chunks };
137
189
  }
138
190
  /**
@@ -169,7 +221,11 @@ async function readFolder(repository, options) {
169
221
  let native = null;
170
222
  let done = listed.length - toRead.length;
171
223
  const readBytes = toRead.reduce((sum, file) => sum + file.size, 0);
172
- if ((0, native_engine_js_1.worthTheEngine)(toRead.length, readBytes)) {
224
+ /*
225
+ Not to store into a v2 history: the engine writes v1 packs, so what it
226
+ stored would miss the solid blocks and deltas. It still measures there.
227
+ */
228
+ if ((0, native_engine_js_1.worthTheEngine)(toRead.length, readBytes) && !(options.store && repository.objects.v2)) {
173
229
  const report = options.progress
174
230
  ? (count, _total, file) => options.progress(done + count, listed.length, file)
175
231
  : undefined;
@@ -201,15 +257,16 @@ async function readFolder(repository, options) {
201
257
  return null;
202
258
  };
203
259
  const record = (file, sha256, chunks, settled) => {
260
+ const executable = executableFrom(file.mode, previous.get(file.path));
204
261
  files.set(file.path, {
205
262
  path: file.path,
206
263
  size: file.size,
207
264
  sha256,
208
- executable: executableFrom(file.mode, previous.get(file.path)),
265
+ executable,
209
266
  chunks,
210
267
  });
211
268
  if (file.mtimeMs < racyBefore && settled) {
212
- next.set(file.path, { size: file.size, mtimeMs: file.mtimeMs, sha256, chunks });
269
+ next.set(file.path, { size: file.size, mtimeMs: file.mtimeMs, sha256, chunks, executable });
213
270
  }
214
271
  };
215
272
  /*
@@ -231,12 +288,50 @@ async function readFolder(repository, options) {
231
288
  options.progress?.(done, listed.length, file.path);
232
289
  }
233
290
  }
291
+ /*
292
+ A new file is kept against the file most like it the last save held:
293
+ one with the same extension in the same folder, nearest in size, and
294
+ within a factor of two. A copy made and edited is then a few bytes.
295
+ */
296
+ let alike = null;
297
+ const kindOf = (filePath) => {
298
+ const slash = filePath.lastIndexOf("/");
299
+ const name = filePath.slice(slash + 1);
300
+ const dot = name.lastIndexOf(".");
301
+ return `${filePath.slice(0, slash + 1)}\u0000${dot > 0 ? name.slice(dot).toLowerCase() : ""}`;
302
+ };
303
+ const hintsFor = (file) => {
304
+ const was = previous.get(file.path);
305
+ if (was)
306
+ return { previous: was };
307
+ if (!alike) {
308
+ alike = new Map();
309
+ for (const entry of previous.values()) {
310
+ if (entry.chunks.length !== 1)
311
+ continue;
312
+ const key = kindOf(entry.path);
313
+ const list = alike.get(key);
314
+ if (list)
315
+ list.push(entry);
316
+ else
317
+ alike.set(key, [entry]);
318
+ }
319
+ }
320
+ let best;
321
+ for (const entry of alike.get(kindOf(file.path)) ?? []) {
322
+ if (entry.size * 2 < file.size || entry.size > file.size * 2)
323
+ continue;
324
+ if (!best || Math.abs(entry.size - file.size) < Math.abs(best.size - file.size))
325
+ best = entry;
326
+ }
327
+ return best ? { similar: best } : {};
328
+ };
234
329
  const one = async (file) => {
235
330
  const full = node_path_1.default.join(repository.root, file.path);
236
331
  let sha256;
237
332
  let chunks = [];
238
333
  if (options.store) {
239
- ({ sha256, chunks } = await storeFile(repository, full, file.size));
334
+ ({ sha256, chunks } = await storeFile(repository, full, file.size, repository.objects.v2 ? { path: file.path, ...hintsFor(file) } : undefined));
240
335
  }
241
336
  else {
242
337
  sha256 = await digestFile(full, file.size);
@@ -251,13 +346,19 @@ async function readFolder(repository, options) {
251
346
  inLanes(toReadHere.filter(large), LARGE_LANES, one),
252
347
  ]);
253
348
  /* Nothing may name an object that is not on disk yet. */
254
- if (options.store)
349
+ /*
350
+ A v2 history writes the files with the tree that names them, in one pack:
351
+ the save flushes after its tree (writeTree), so a pack is not spent on
352
+ the tree alone.
353
+ */
354
+ if (options.store && !repository.objects.v2)
255
355
  await repository.objects.flush();
256
356
  options.progress?.(listed.length, listed.length, "");
257
357
  const sameEntry = (left, right) => Boolean(right) &&
258
358
  left.size === right.size &&
259
359
  left.mtimeMs === right.mtimeMs &&
260
360
  left.sha256 === right.sha256 &&
361
+ left.executable === right.executable &&
261
362
  left.chunks.length === right.chunks.length &&
262
363
  left.chunks.every((chunk, at) => chunk === right.chunks[at]);
263
364
  const indexChanged = next.size !== index.size || [...next].some(([file, entry]) => !sameEntry(entry, index.get(file)));