ctxr-cli 0.38.0 → 0.40.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/version.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Kept in sync with package.json's "version" field. */
2
- export declare const CLI_VERSION = "0.38.0";
2
+ export declare const CLI_VERSION = "0.40.0";
3
3
  /**
4
4
  * The npm package this executable is published as. Bound here, beside the
5
5
  * version, because the release check resolves the latest published release by
package/dist/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Kept in sync with package.json's "version" field. */
2
- export const CLI_VERSION = '0.38.0';
2
+ export const CLI_VERSION = '0.40.0';
3
3
  /**
4
4
  * The npm package this executable is published as. Bound here, beside the
5
5
  * version, because the release check resolves the latest published release by
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxr-cli",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Context store for AI coding agents — markdown and git, catalog and graph retrieval, no vector database.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,9 +12,16 @@
12
12
  // Two facts about `cb` shape this file: search results carry no tags (every `tags` field is empty), so
13
13
  // tags come from `cb meetings read`; and a meeting older than the plan's history window reads back with
14
14
  // a notice in place of its notes, which is how a locked meeting is recognised.
15
+ //
16
+ // Circleback's API documentation limits each account to 3 requests a second and 20 a minute on the Free plan
17
+ // (20 and 300 on paid plans), counting every API key and CLI token of the account together; `cb` reports a
18
+ // refusal as `rate_limited` but passes on no Retry-After. So every `cb` call is spaced to stay under the Free
19
+ // plan's 20 a minute, which also keeps it under 3 a second; a refused call is retried after waiting out a
20
+ // one-minute window; and if the limit still holds, the meetings not yet read are reported as deferred, keeping
21
+ // what was already written: the next run picks them up.
15
22
 
16
23
  import { execFileSync } from 'node:child_process';
17
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
25
  import { tmpdir } from 'node:os';
19
26
  import path from 'node:path';
20
27
  import { fileURLToPath } from 'node:url';
@@ -27,6 +34,32 @@ const PAGE_SIZE = 20;
27
34
  const READ_BATCH = 50;
28
35
  const BRANCH = 'circleback-capture';
29
36
  const LOCKED_NOTICE = 'outside the history available';
37
+ // The Free plan's limits. The runner cannot tell which plan the account is on, so it keeps to the lowest.
38
+ const REQUESTS_PER_MINUTE = 20;
39
+ // A little under the limit, leaving room for whatever else uses the account and for a `cb` call that makes
40
+ // more than one request: about 17 calls a minute.
41
+ const CALL_SPACING_MS = Math.ceil(60_000 / REQUESTS_PER_MINUTE) + 500;
42
+ // The minute window refills within 60 seconds, so the first retry waits half of it and the rest a whole one.
43
+ const RETRY_DELAYS_MS = [30_000, 60_000, 60_000];
44
+ const RATE_LIMITED = /"reason"\s*:\s*"rate_limited"/;
45
+ // Every wait is multiplied by this. Only the tests set it, to 0; a store never does.
46
+ const WAIT_SCALE = Number.isFinite(Number(process.env.CTXR_CIRCLEBACK_WAIT_SCALE)) ? Number(process.env.CTXR_CIRCLEBACK_WAIT_SCALE) : 1;
47
+
48
+ /** Thrown when `cb` is still rate-limited after every retry. */
49
+ class RateLimited extends Error {}
50
+
51
+ function pause(ms) {
52
+ const wait = ms * WAIT_SCALE;
53
+ if (wait > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait);
54
+ }
55
+
56
+ let lastCbCall = -Infinity;
57
+
58
+ /** Waits until CALL_SPACING_MS has passed since the previous `cb` call, retries included. */
59
+ function spaceCbCall() {
60
+ pause(Math.max(0, lastCbCall + CALL_SPACING_MS - Date.now()));
61
+ lastCbCall = Date.now();
62
+ }
30
63
 
31
64
  function run(command, args, { cwd, check = true } = {}) {
32
65
  try {
@@ -34,12 +67,32 @@ function run(command, args, { cwd, check = true } = {}) {
34
67
  } catch (error) {
35
68
  if (!check) return error.stdout ?? '';
36
69
  const detail = String(error.stderr || error.message).trim().slice(0, 300);
37
- throw new Error(`${command} ${args.slice(0, 2).join(' ')} failed: ${detail}`);
70
+ throw Object.assign(new Error(`${command} ${args.slice(0, 2).join(' ')} failed: ${detail}`), {
71
+ output: `${error.stdout ?? ''}\n${error.stderr ?? ''}`,
72
+ });
38
73
  }
39
74
  }
40
75
 
76
+ /** One `cb` call, retried with backoff while Circleback reports a rate limit. */
41
77
  function cb(args) {
42
- return JSON.parse(run('cb', [...args, '--json']));
78
+ for (let attempt = 0; ; attempt += 1) {
79
+ let output;
80
+ spaceCbCall();
81
+ try {
82
+ output = run('cb', [...args, '--json']);
83
+ } catch (error) {
84
+ if (!RATE_LIMITED.test(error.output ?? '')) throw error;
85
+ if (attempt >= RETRY_DELAYS_MS.length) throw new RateLimited(error.message);
86
+ pause(RETRY_DELAYS_MS[attempt]);
87
+ continue;
88
+ }
89
+ const result = JSON.parse(output);
90
+ if (result?.status !== 'error') return result;
91
+ // An error printed as the result, with a zero exit: treat it the same as a failed call.
92
+ if (!RATE_LIMITED.test(output)) throw new Error(`cb ${args.slice(0, 2).join(' ')} failed: ${output.trim().slice(0, 300)}`);
93
+ if (attempt >= RETRY_DELAYS_MS.length) throw new RateLimited(`cb ${args.slice(0, 2).join(' ')} failed: ${output.trim().slice(0, 300)}`);
94
+ pause(RETRY_DELAYS_MS[attempt]);
95
+ }
43
96
  }
44
97
 
45
98
  function storeRoot() {
@@ -134,9 +187,17 @@ function prepareWorktree(root, settings) {
134
187
  return { worktree, continuing: open[0]?.number };
135
188
  }
136
189
 
137
- function captureFileName(meeting) {
138
- const slug = meeting.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || meeting.id;
139
- return `circleback-${meeting.createdAt.slice(0, 10)}-${slug}.md`;
190
+ /**
191
+ * Where a meeting's record goes in the inbox: named for its date and title, with the meeting id added when that
192
+ * name is already taken. The slug keeps only ASCII letters and digits, so two titles mostly in another script can
193
+ * reduce to the same name, and one record must never overwrite another, from this run or already in the inbox.
194
+ */
195
+ function captureFilePath(inbox, meeting) {
196
+ const date = meeting.createdAt.slice(0, 10);
197
+ const slug = meeting.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
198
+ const named = path.join(inbox, `circleback-${date}-${slug || meeting.id}.md`);
199
+ if (!existsSync(named)) return named;
200
+ return path.join(inbox, `circleback-${date}-${slug ? `${slug}-` : ''}${meeting.id}.md`);
140
201
  }
141
202
 
142
203
  /** The mechanical part of a capture. The speaker judgment is the agent's, added afterwards. */
@@ -232,12 +293,26 @@ function main() {
232
293
  mkdirSync(inbox, { recursive: true });
233
294
  report.push(`\nWORKTREE: ${worktree} (branch \`${BRANCH}\`, ${continuing ? `continuing pull request #${continuing}` : `fresh from origin/${settings.default_branch}`})`);
234
295
  report.push('RETRIEVED (verbatim records in the inbox; continue with ctxr-capture from step 2):');
296
+ const deferred = [];
235
297
  for (const meeting of owed) {
236
- const transcript = cb(['transcripts', 'read', meeting.id]);
237
- const file = path.join(inbox, captureFileName(meeting));
298
+ // Once the limit has outlasted every retry, read nothing more this run.
299
+ if (deferred.length > 0) {
300
+ deferred.push(line(meeting));
301
+ continue;
302
+ }
303
+ let transcript;
304
+ try {
305
+ transcript = cb(['transcripts', 'read', meeting.id]);
306
+ } catch (error) {
307
+ if (!(error instanceof RateLimited)) throw error;
308
+ deferred.push(line(meeting));
309
+ continue;
310
+ }
311
+ const file = captureFilePath(inbox, meeting);
238
312
  writeFileSync(file, renderCapture(meeting, transcript, settings));
239
313
  report.push(file);
240
314
  }
315
+ report.push(...section('DEFERRED — owed, but Circleback is rate-limiting transcript reads; the next run retrieves them', deferred));
241
316
  }
242
317
  report.push(
243
318
  ...section('UNPUSHED — captured in a local worktree but not on the default branch or in an open pull request; land that work', unpushed),
@@ -25,6 +25,12 @@ writes the record into `__INBOX_PATH__` there: the meeting's metadata and attend
25
25
  summary (marked as the service's derivation), and the transcript verbatim, with `source_type` and `source_id`
26
26
  stamped. It never writes the recording URL, which is a signed, expiring link and so a credential.
27
27
 
28
+ Circleback's free plan allows an account 20 requests a minute, counting every other use of the same account. The
29
+ runner spaces its calls to stay under that, so a large backlog takes a few minutes, and waits and retries when it is
30
+ refused anyway. If the limit still holds, it keeps the records it already wrote and lists the meetings it could not
31
+ read under DEFERRED; the next run retrieves them. That is a partial result, not a failure: carry on with what was
32
+ retrieved.
33
+
28
34
  The report is authoritative. Do not work the list out again yourself, and do not rebuild the files it wrote. If it
29
35
  prints `COLLECTION FAILED`, report that line and stop.
30
36
 
@@ -53,6 +59,7 @@ Give the counts (retrieved, already present), the pull request link, and the run
53
59
  they are:
54
60
 
55
61
  - **LOCKED**: tagged for this store but no longer readable in full, so it cannot be captured.
62
+ - **DEFERRED**: owed, but not read this run because of the rate limit. The next run retrieves them.
56
63
  - **UNPUSHED**: captured in a local worktree but not yet on the default branch or in a pull request. That work
57
64
  needs landing; nothing here captures it again.
58
65
  - **UNTAGGED**, **TAGGED FOR MORE THAN ONE STORE** and **DECLINED IN THE LAST 8 DAYS**, when the runner prints them.