ticketlens 0.21.7 → 0.21.9

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/README.md CHANGED
@@ -401,9 +401,11 @@ Every note is scanned before saving — anything shaped like a real secret (API
401
401
 
402
402
  **Quality loop:** inside a Claude Code session using the jtb skill, a saved note can be silently refined afterward — a generator subagent drafts a more actionable version, a validator subagent checks it against other notes on the same ticket for duplication, up to 3 rounds — and the improved draft overwrites the original via the internal `note patch` command (not typically invoked by hand). This makes zero API calls and costs zero extra tokens beyond your already-running session; it never runs for a bare shell invocation of `note add`, which is skipped silently. Known limitation: a refined draft is not re-synced to your team even if the original was — teammates who already pulled the note keep the earlier draft.
403
403
 
404
- **Team sync:** on a Team plan with Recall enabled for your account (owner-managed, per-tier or per-client), notes also sync to your team's shared pool — `note add` pushes in the background, `recall` pulls the team's notes (cached 4h) before searching. A team manager reviews and verifies incoming notes at `console/admin/recall` before they're marked trusted. Without Team Recall entitlement, everything stays on your machine — no network call.
404
+ **Team sync:** on a Team plan with Recall enabled for your account (owner-managed, per-tier or per-client), notes also sync to your team's shared pool — `note add` pushes in the background, `recall` always pulls the team's notes fresh before searching, so a manager's verify/delete in Console is visible on that very search. (Ticket-brief injection uses a separate, short-timeout 4h-cached pull so it never slows down your everyday `ticketlens PROJ-123` — it doesn't need to be instant the way an explicit search does.) A team manager reviews and verifies incoming notes at `console/admin/recall` before they're marked trusted. Without Team Recall entitlement, everything stays on your machine — no network call.
405
405
 
406
- **Offline resilience:** if a team push fails for a transient reason (network error, timeout, or a 5xx from the backend), the note stays safely in your local vault and is queued for retry — nothing is lost. The queue flushes automatically in the background (at most once every 15 minutes) before every command, not just `recall`/`note add` — a short 4s timeout on that check means it never stalls an unrelated command — or on demand with `ticketlens recall sync`. A session-expired (401) or not-entitled (403) push is never queued — those need you to act (`ticketlens login`, or an owner grant), not a retry. Queued entries expire after 30 days and are capped at 200; switching accounts never flushes a note under the wrong login.
406
+ **Offline resilience:** if a team push fails for a transient reason (network error, timeout, or a 5xx from the backend), the note stays safely in your local vault and is queued for retry — nothing is lost. The queue flushes automatically in the background before every command, not just `recall`/`note add` — a short timeout on that check means it never stalls an unrelated command — or on demand with `ticketlens recall sync`. A session-expired (401) or not-entitled (403) push is never queued — those need you to act (`ticketlens login`, or an owner grant), not a retry. Switching accounts never flushes a note under the wrong login.
407
+
408
+ **Queue settings:** the retry cooldown (default 15 min), per-request timeout (4s), max queued notes (200), and queued-note expiry (30 days) are set by your team manager at `console/admin/recall` and apply to every member's CLI — solo users get the same platform defaults. `ticketlens recall settings` shows the values currently in effect, fetched live: a manager's change is visible the moment your CLI's next retry decision runs, not on a delay.
407
409
 
408
410
  **Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
409
411
 
@@ -684,6 +686,7 @@ ticketlens note delete --id="..." --ticket=CNV1-2 # Remove a note from your loc
684
686
  ticketlens recall CNV1-2 # Search saved notes by ticket key [Pro]
685
687
  ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
686
688
  ticketlens recall sync # Retry any notes stuck in the local queue [Pro]
689
+ ticketlens recall settings # Show effective retry-queue settings, fetched live [Pro]
687
690
 
688
691
  # ── Stats ──────────────────────────────────────────────────────────────────────
689
692
  ticketlens stats # Response-time metrics from local history
@@ -764,6 +767,7 @@ ticketlens note add --title="..." # Save a Recall note (body from stdin)
764
767
  ticketlens note delete --id="..." # Remove a note from your local vault
765
768
  ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
766
769
  ticketlens recall sync # Retry any notes stuck in the local queue
770
+ ticketlens recall settings # Show effective retry-queue settings, fetched live
767
771
  ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
768
772
  ```
769
773
 
@@ -70,9 +70,10 @@ checkForUpdate();
70
70
  try {
71
71
  const cliToken = readCliToken(DEFAULT_CONFIG_DIR);
72
72
  if (cliToken) {
73
- // Short timeout — this runs before every command, unrelated to Recall or
73
+ // Timeout defaults to the effective settings' timeout_ms (Console-managed,
74
+ // 4s by default) — this runs before every command, unrelated to Recall or
74
75
  // not, so it must never be the reason an unrelated command feels slow.
75
- const result = await maybeAutoFlush({ cliToken, configDir: DEFAULT_CONFIG_DIR, timeoutMs: 4000 });
76
+ const result = await maybeAutoFlush({ cliToken, configDir: DEFAULT_CONFIG_DIR });
76
77
  if (result?.flushed) {
77
78
  process.stderr.write(` ✔ Synced ${result.flushed} pending Recall note${result.flushed === 1 ? '' : 's'}.\n`);
78
79
  }
@@ -678,6 +679,16 @@ switch (command) {
678
679
  });
679
680
  break;
680
681
  }
682
+ if (cmdArgs[0] === 'settings') {
683
+ const { runRecallSettings } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
684
+ runRecallSettings(cmdArgs.slice(1)).then(({ ok }) => {
685
+ if (!ok) process.exitCode = 1;
686
+ }).catch(err => {
687
+ process.stderr.write(`Error: ${err.message}\n`);
688
+ process.exitCode = 1;
689
+ });
690
+ break;
691
+ }
681
692
  const { runRecall } = await import('../skills/jtb/scripts/lib/recall-command.mjs');
682
693
  runRecall(cmdArgs).then(({ ok }) => {
683
694
  if (!ok) process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.21.7",
3
+ "version": "0.21.9",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,6 +55,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
55
55
  /jtb note "gotcha text" --ticket=PROD-1234 # save a Recall note (Pro)
56
56
  /jtb recall PROD-1234 # search saved Recall notes (Pro)
57
57
  /jtb recall sync # retry any notes stuck in the local queue (Pro)
58
+ /jtb recall settings # show effective retry-queue settings, fetched live (Pro)
58
59
  ```
59
60
 
60
61
  ## Prerequisites
@@ -548,6 +548,7 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
548
548
  ` ${s.bold('COMMANDS')}`,
549
549
  '',
550
550
  ` ${s.brand('sync')} Manually retry any team-synced notes stuck in the local retry queue ${s.dim('[Pro, requires login]')}`,
551
+ ` ${s.brand('settings')} Show effective retry-queue settings (cooldown, timeout, queue limits) ${s.dim('[Pro]')}`,
551
552
  '',
552
553
  ` ${s.bold('EXAMPLES')}`,
553
554
  '',
@@ -556,6 +557,7 @@ export function printRecallHelp({ stream = process.stdout } = {}) {
556
557
  ` ${s.dim('$')} ticketlens recall PROD-123 --plain`,
557
558
  ` ${s.dim('$')} ticketlens recall PROD-123 --full`,
558
559
  ` ${s.dim('$')} ticketlens recall sync`,
560
+ ` ${s.dim('$')} ticketlens recall settings`,
559
561
  '',
560
562
  ];
561
563
  stream.write(lines.join('\n') + '\n');
@@ -141,7 +141,7 @@ export async function runNoteAdd(cmdArgs, {
141
141
  const payload = { external_id: id, title, tickets: ticketKeys, tags, author, sources: [], body };
142
142
  const result = await pushNoteFn(payload, { cliToken, configDir, warn });
143
143
  if (isRetryableFailureFn(result)) {
144
- enqueueNoteFn(payload, { cliToken, configDir, warn });
144
+ await enqueueNoteFn(payload, { cliToken, configDir, warn });
145
145
  }
146
146
  await maybeAutoFlushFn({ cliToken, configDir });
147
147
  }
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Implements `tl recall <query|TICKET-KEY>` — a search over saved Recall
3
- * notes, local-first. When logged in, pulls the team vault down before
4
- * searching (the user is explicitly waiting on this command, so the full
5
- * request timeout applies here — unlike the passive brief-fetch pull path).
3
+ * notes, local-first. When logged in, always pulls the team vault fresh
4
+ * before searching (ttlMs: 0, bypassing the TTL cache entirely) — the user
5
+ * is explicitly waiting on this command, so a manager's Console verify/
6
+ * delete action is visible on this very invocation, not up to 4h later.
7
+ * The full request timeout applies here — unlike the passive brief-fetch
8
+ * pull path (fetch-ticket.mjs), which deliberately keeps its short timeout
9
+ * and TTL cache since it runs on nearly every command, not just this one.
6
10
  * A pull failure never blocks the local search from returning results.
7
11
  */
8
12
 
@@ -13,6 +17,7 @@ import { listNotes } from './recall-vault.mjs';
13
17
  import { readCliToken } from './cli-auth.mjs';
14
18
  import { pullNotes } from './recall-sync.mjs';
15
19
  import { maybeAutoFlush, flushQueue, readQueue } from './recall-queue.mjs';
20
+ import { getEffectiveRecallSettings } from './recall-settings-sync.mjs';
16
21
  import { styleRecallResults } from './styled-assembler.mjs';
17
22
 
18
23
  /**
@@ -42,11 +47,7 @@ export async function runRecall(cmdArgs, {
42
47
 
43
48
  const cliToken = readCliTokenFn(configDir);
44
49
  if (cliToken) {
45
- await pullNotesFn({
46
- cliToken,
47
- configDir,
48
- ...(cmdArgs.includes('--no-cache') && { ttlMs: 0 }),
49
- });
50
+ await pullNotesFn({ cliToken, configDir, ttlMs: 0 });
50
51
  await maybeAutoFlushFn({ cliToken, configDir });
51
52
  }
52
53
 
@@ -96,3 +97,38 @@ export async function runRecallSync(cmdArgs, {
96
97
  stream.write(`Synced ${flushed} note(s). ${remaining} still pending.\n`);
97
98
  return { ok: true };
98
99
  }
100
+
101
+ /**
102
+ * Implements `tl recall settings` — read-only display of the effective
103
+ * Recall queue settings (flush cooldown, per-request timeout, max queue
104
+ * size, max entry age). Management happens in Console (console/admin/recall,
105
+ * manager-only); this fetches live so it always reflects the manager's
106
+ * latest saved value, falling back to the last-known cache/platform default
107
+ * if offline.
108
+ *
109
+ * @param {string[]} cmdArgs
110
+ * @returns {Promise<{ ok: boolean }>}
111
+ */
112
+ export async function runRecallSettings(cmdArgs, {
113
+ configDir = DEFAULT_CONFIG_DIR,
114
+ stream = process.stdout,
115
+ isLicensedFn = isLicensed,
116
+ readCliTokenFn = readCliToken,
117
+ getEffectiveRecallSettingsFn = getEffectiveRecallSettings,
118
+ } = {}) {
119
+ if (!isLicensedFn('pro', configDir)) {
120
+ showUpgradePrompt('pro', 'ticketlens recall', { stream });
121
+ return { ok: false };
122
+ }
123
+
124
+ const cliToken = readCliTokenFn(configDir);
125
+ const settings = await getEffectiveRecallSettingsFn({ cliToken, configDir });
126
+ const source = cliToken ? 'your team manager (or platform default if unset)' : 'platform default — log in to pick up a team override';
127
+
128
+ stream.write(` Retry cooldown: ${settings.flush_cooldown_ms / 60_000} min\n`);
129
+ stream.write(` Per-request timeout: ${settings.timeout_ms / 1_000} sec\n`);
130
+ stream.write(` Max queued notes: ${settings.max_queue_size}\n`);
131
+ stream.write(` Queued note expiry: ${settings.max_entry_age_ms / 86_400_000} days\n`);
132
+ stream.write(` Source: ${source}\n`);
133
+ return { ok: true };
134
+ }
@@ -29,13 +29,17 @@ import path from 'node:path';
29
29
  import { DEFAULT_CONFIG_DIR } from './config.mjs';
30
30
  import { writeFileAtomically } from './recall-vault.mjs';
31
31
  import { pushNote, hashToken } from './recall-sync.mjs';
32
+ import { getEffectiveRecallSettings, DEFAULT_RECALL_SETTINGS } from './recall-settings-sync.mjs';
32
33
 
33
34
  const QUEUE_FILE = 'recall-pending.json';
34
35
  const FLUSH_STATE_FILE = 'recall-flush-state.json';
35
36
 
36
- export const MAX_QUEUE_SIZE = 200;
37
- export const MAX_ENTRY_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
38
- export const AUTO_FLUSH_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
37
+ // Platform defaults — actual effective values now come from
38
+ // getEffectiveRecallSettings(), which is the manager's Console override for
39
+ // their team if one exists (fetched live), else these same numbers.
40
+ export const DEFAULT_MAX_QUEUE_SIZE = DEFAULT_RECALL_SETTINGS.max_queue_size;
41
+ export const DEFAULT_MAX_ENTRY_AGE_MS = DEFAULT_RECALL_SETTINGS.max_entry_age_ms;
42
+ export const DEFAULT_AUTO_FLUSH_INTERVAL_MS = DEFAULT_RECALL_SETTINGS.flush_cooldown_ms;
39
43
 
40
44
  function queuePath(configDir) {
41
45
  return path.join(configDir, QUEUE_FILE);
@@ -62,8 +66,8 @@ function writeQueue(configDir, entries) {
62
66
  writeFileAtomically(queuePath(configDir), JSON.stringify(entries));
63
67
  }
64
68
 
65
- function purgeExpired(entries, now) {
66
- return entries.filter(entry => now - new Date(entry.firstQueuedAt).getTime() <= MAX_ENTRY_AGE_MS);
69
+ function purgeExpired(entries, now, maxEntryAgeMs) {
70
+ return entries.filter(entry => now - new Date(entry.firstQueuedAt).getTime() <= maxEntryAgeMs);
67
71
  }
68
72
 
69
73
  /**
@@ -84,7 +88,10 @@ export function isRetryableFailure(result) {
84
88
  /**
85
89
  * Queues a note for later retry after a transient push failure. Purges
86
90
  * expired entries first, then evicts the oldest entry (with a single warn)
87
- * if appending would exceed MAX_QUEUE_SIZE.
91
+ * if appending would exceed the effective max queue size (Console-managed
92
+ * per team, fetched live — see recall-settings-sync.mjs). Async: a push just
93
+ * failed, so one more short network call to get the current cap/expiry
94
+ * doesn't change this path's performance characteristics.
88
95
  *
89
96
  * @param {object} notePayload - exact wire payload passed to pushNote
90
97
  * @param {object} opts
@@ -92,17 +99,20 @@ export function isRetryableFailure(result) {
92
99
  * @param {string} [opts.configDir]
93
100
  * @param {() => number} [opts.now]
94
101
  * @param {Function} [opts.warn]
102
+ * @returns {Promise<void>}
95
103
  */
96
- export function enqueueNote(notePayload, {
104
+ export async function enqueueNote(notePayload, {
97
105
  cliToken,
98
106
  configDir = DEFAULT_CONFIG_DIR,
99
107
  now = () => Date.now(),
100
108
  warn = (s) => process.stderr.write(s),
109
+ getEffectiveRecallSettingsFn = getEffectiveRecallSettings,
101
110
  } = {}) {
102
111
  const nowMs = now();
103
- let entries = purgeExpired(readQueue(configDir), nowMs);
112
+ const settings = await getEffectiveRecallSettingsFn({ cliToken, configDir });
113
+ let entries = purgeExpired(readQueue(configDir), nowMs, settings.max_entry_age_ms);
104
114
 
105
- if (entries.length >= MAX_QUEUE_SIZE) {
115
+ if (entries.length >= settings.max_queue_size) {
106
116
  entries = entries.slice(1);
107
117
  warn(' Recall queue full — dropped the oldest queued note to make room.\n');
108
118
  }
@@ -142,10 +152,22 @@ export async function flushQueue({
142
152
  warn = () => {},
143
153
  now = () => Date.now(),
144
154
  timeoutMs,
155
+ settings,
156
+ getEffectiveRecallSettingsFn = getEffectiveRecallSettings,
145
157
  } = {}) {
146
158
  const nowMs = now();
147
159
  const currentHash = hashToken(cliToken);
148
- const entries = purgeExpired(readQueue(configDir), nowMs);
160
+ // Only the age bound comes from settings here — timeoutMs is caller-supplied
161
+ // or falls through to pushNote's own default. maybeAutoFlush (below) is the
162
+ // one call site that maps the Console-configured timeout_ms onto this param;
163
+ // runRecallSync's manual, user-initiated flush deliberately keeps a longer,
164
+ // uncapped-by-this-setting timeout since the user is actively waiting.
165
+ // `settings` is an optional already-fetched value — maybeAutoFlush passes
166
+ // its own fetch through here so a single auto-flush attempt never fetches
167
+ // settings twice; a direct/manual call (runRecallSync) has none yet, so
168
+ // this fetches its own.
169
+ const effectiveSettings = settings ?? await getEffectiveRecallSettingsFn({ cliToken, configDir });
170
+ const entries = purgeExpired(readQueue(configDir), nowMs, effectiveSettings.max_entry_age_ms);
149
171
 
150
172
  let flushed = 0;
151
173
  const remaining = [];
@@ -199,14 +221,15 @@ function writeLastFlushAttemptAt(configDir, isoTimestamp) {
199
221
  * a burst of failures (e.g. a debugging session) would otherwise sit queued
200
222
  * until the user happens to run `note add`/`recall` again, or runs
201
223
  * `recall sync` by hand. A no-op unless the queue is non-empty AND at least
202
- * AUTO_FLUSH_INTERVAL_MS has passed since the last attempt — the interval is
203
- * a single global cooldown, not per-entry, so a burst of failures in one
204
- * short window still only gets one automatic retry pass, by design: this
205
- * runs on every command now, so the next opportunity is never far away, and
206
- * the cooldown exists specifically to stop a down backend from being hit by
207
- * every single command in the meantime. The attempt timestamp is recorded
208
- * even on failure, so a down backend can't be hammered once per command
209
- * within the window.
224
+ * the effective flush cooldown (Console-managed per team, defaults to 15
225
+ * minutes — see recall-settings-sync.mjs) has passed since the last attempt.
226
+ * The interval is a single global cooldown, not per-entry, so a burst of
227
+ * failures in one short window still only gets one automatic retry pass, by
228
+ * design: this runs on every command now, so the next opportunity is never
229
+ * far away, and the cooldown exists specifically to stop a down backend from
230
+ * being hit by every single command in the meantime. The attempt timestamp is
231
+ * recorded even on failure, so a down backend can't be hammered once per
232
+ * command within the window.
210
233
  *
211
234
  * @param {object} opts
212
235
  * @param {string} opts.cliToken
@@ -214,9 +237,10 @@ function writeLastFlushAttemptAt(configDir, isoTimestamp) {
214
237
  * @param {() => number} [opts.now]
215
238
  * @param {Function} [opts.flushQueueFn]
216
239
  * @param {number} [opts.timeoutMs] - per-request timeout, passed through to
217
- * pushNote. Callers running this unconditionally on every command (as
218
- * opposed to an explicit `recall sync`) should pass something short — this
219
- * must feel instant, not stall an unrelated command behind a slow network.
240
+ * pushNote. Defaults to the effective settings' timeout_ms (Console-managed,
241
+ * defaults to 4s) — short, because this runs unconditionally on every
242
+ * command and must feel instant, not stall an unrelated command behind a
243
+ * slow network. Pass explicitly to override.
220
244
  * @returns {Promise<{flushed: number, remaining: number}|null>} null if skipped
221
245
  * (empty queue or still cooling down) — distinguishes "nothing to report"
222
246
  * from "attempted, flushed 0" for a caller that wants to print a summary.
@@ -227,15 +251,21 @@ export async function maybeAutoFlush({
227
251
  now = () => Date.now(),
228
252
  flushQueueFn = flushQueue,
229
253
  timeoutMs,
254
+ getEffectiveRecallSettingsFn = getEffectiveRecallSettings,
230
255
  } = {}) {
231
256
  if (readQueue(configDir).length === 0) return null;
232
257
 
258
+ // Fetched once here (live) and threaded through to flushQueueFn below —
259
+ // flushQueue also needs max_entry_age_ms but must not fetch a second time
260
+ // for what is, from the outside, a single "attempt a flush" decision.
261
+ const settings = await getEffectiveRecallSettingsFn({ cliToken, configDir });
233
262
  const lastAttemptAt = readLastFlushAttemptAt(configDir);
234
263
  const nowMs = now();
235
- if (lastAttemptAt && nowMs - new Date(lastAttemptAt).getTime() < AUTO_FLUSH_INTERVAL_MS) return null;
264
+ if (lastAttemptAt && nowMs - new Date(lastAttemptAt).getTime() < settings.flush_cooldown_ms) return null;
236
265
 
266
+ const effectiveTimeoutMs = timeoutMs !== undefined ? timeoutMs : settings.timeout_ms;
237
267
  try {
238
- return await flushQueueFn({ cliToken, configDir, now, ...(timeoutMs !== undefined ? { timeoutMs } : {}) });
268
+ return await flushQueueFn({ cliToken, configDir, now, timeoutMs: effectiveTimeoutMs, settings });
239
269
  } catch {
240
270
  // A down backend or a thrown network error must never crash the command
241
271
  // that opportunistically triggered this background attempt.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Effective Recall queue settings (flush cooldown, per-request timeout, max
3
+ * queue size, max entry age) — manager-controlled per team via the Console
4
+ * (console/admin/recall).
5
+ *
6
+ * getEffectiveRecallSettings() fetches live at the moment a caller actually
7
+ * needs a decision (queue non-empty, a push just failed, or an explicit `tl
8
+ * recall settings`) — never on a background timer. Those moments are already
9
+ * rare (recall-queue.mjs's callers all short-circuit on a local, network-free
10
+ * read when there's nothing to do), so a manager's Console change is visible
11
+ * on the very next command that matters, without adding network cost to the
12
+ * overwhelming majority of commands that never touch this at all.
13
+ *
14
+ * A local cache (recall-settings-cache.json) exists only as an offline/
15
+ * failure fallback — written on every successful fetch, read when a fetch
16
+ * fails or there's no cliToken. Values from it are never trusted blindly,
17
+ * even though they came from our own backend: they're re-clamped against the
18
+ * same bounds the server enforces, so a corrupted cache file or a future
19
+ * server bug can never push a CLI-side value (e.g. a near-zero cooldown)
20
+ * outside safe range.
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
26
+ import { apiBase } from './api-utils.mjs';
27
+ import { writeFileAtomically } from './recall-vault.mjs';
28
+ import { hashToken } from './recall-sync.mjs';
29
+
30
+ const SETTINGS_PATH = '/v1/recall/settings';
31
+ const CACHE_FILE = 'recall-settings-cache.json';
32
+
33
+ // Fixed, not manager-configurable — this timeout gates the fetch of the
34
+ // settings themselves, so it can't depend on a settings value that hasn't
35
+ // been fetched yet. Short: this fires only on already-rare paths, but must
36
+ // still never let a slow/down backend meaningfully stall those commands.
37
+ const FETCH_TIMEOUT_MS = 3_000;
38
+
39
+ // Platform defaults — must match RecallSettings::DEFAULTS in ticketlens-api.
40
+ export const DEFAULT_RECALL_SETTINGS = {
41
+ flush_cooldown_ms: 900_000, // 15 minutes
42
+ timeout_ms: 4_000, // 4 seconds
43
+ max_queue_size: 200,
44
+ max_entry_age_ms: 2_592_000_000, // 30 days
45
+ };
46
+
47
+ // Inclusive [min, max] bounds — must match RecallSettings::BOUNDS in ticketlens-api.
48
+ export const RECALL_SETTINGS_BOUNDS = {
49
+ flush_cooldown_ms: [60_000, 86_400_000], // 1m .. 24h
50
+ timeout_ms: [1_000, 30_000], // 1s .. 30s
51
+ max_queue_size: [10, 2_000],
52
+ max_entry_age_ms: [3_600_000, 7_776_000_000], // 1h .. 90d
53
+ };
54
+
55
+ function cachePath(configDir) {
56
+ return path.join(configDir, CACHE_FILE);
57
+ }
58
+
59
+ function clamp(field, value) {
60
+ if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_RECALL_SETTINGS[field];
61
+ const [min, max] = RECALL_SETTINGS_BOUNDS[field];
62
+ return Math.min(Math.max(value, min), max);
63
+ }
64
+
65
+ /**
66
+ * Local-only, synchronous fallback — used when there's no cliToken, or a live
67
+ * fetch just failed. Requires cliToken to return a cached override at all: a
68
+ * cache written under a different (or since-logged-out) account must never
69
+ * silently apply to this one, so a tokenHash mismatch (or no token) falls
70
+ * through to platform defaults rather than trusting stale/foreign data.
71
+ *
72
+ * @param {string} configDir
73
+ * @param {object} [opts]
74
+ * @param {string} [opts.cliToken]
75
+ * @returns {{flush_cooldown_ms: number, timeout_ms: number, max_queue_size: number, max_entry_age_ms: number}}
76
+ */
77
+ export function readEffectiveRecallSettings(configDir = DEFAULT_CONFIG_DIR, { cliToken } = {}) {
78
+ if (!cliToken) return { ...DEFAULT_RECALL_SETTINGS };
79
+
80
+ let cached;
81
+ try {
82
+ cached = JSON.parse(fs.readFileSync(cachePath(configDir), 'utf8'));
83
+ } catch {
84
+ return { ...DEFAULT_RECALL_SETTINGS };
85
+ }
86
+ if (!cached || typeof cached.values !== 'object' || cached.tokenHash !== hashToken(cliToken)) {
87
+ return { ...DEFAULT_RECALL_SETTINGS };
88
+ }
89
+
90
+ const effective = {};
91
+ for (const field of Object.keys(DEFAULT_RECALL_SETTINGS)) {
92
+ effective[field] = clamp(field, cached.values[field]);
93
+ }
94
+ return effective;
95
+ }
96
+
97
+ /**
98
+ * GET /v1/recall/settings. Returns {ok:false} on any network/parse failure —
99
+ * callers fall back to readEffectiveRecallSettings' cached/default behavior.
100
+ */
101
+ export async function fetchRecallSettings({
102
+ cliToken,
103
+ timeoutMs = FETCH_TIMEOUT_MS,
104
+ fetcher = globalThis.fetch,
105
+ } = {}) {
106
+ if (!cliToken) return { ok: false, error: 'no-token' };
107
+
108
+ let res;
109
+ try {
110
+ res = await fetcher(`${apiBase()}${SETTINGS_PATH}`, {
111
+ headers: { Authorization: `Bearer ${cliToken}`, Accept: 'application/json' },
112
+ redirect: 'manual',
113
+ signal: AbortSignal.timeout(timeoutMs),
114
+ });
115
+ } catch (e) {
116
+ return { ok: false, error: 'network', message: e.message };
117
+ }
118
+
119
+ if (!res.ok) return { ok: false, error: `http-${res.status}` };
120
+
121
+ let payload;
122
+ try {
123
+ payload = await res.json();
124
+ } catch {
125
+ return { ok: false, error: 'parse' };
126
+ }
127
+
128
+ return { ok: true, values: payload, isOverride: payload.is_override === true };
129
+ }
130
+
131
+ /**
132
+ * The main entry point — fetches live so a manager's Console change is
133
+ * visible immediately, falling back to the last-known-good local cache (or
134
+ * platform defaults) on any failure so a network blip never blocks a retry
135
+ * decision. Every successful fetch refreshes that fallback cache too, tagged
136
+ * with a hash of cliToken so a later account switch never serves a stale
137
+ * team's settings as a "fallback."
138
+ *
139
+ * @param {object} opts
140
+ * @param {string} [opts.cliToken]
141
+ * @param {string} [opts.configDir]
142
+ * @param {() => number} [opts.now]
143
+ * @param {Function} [opts.fetchRecallSettingsFn]
144
+ * @returns {Promise<{flush_cooldown_ms: number, timeout_ms: number, max_queue_size: number, max_entry_age_ms: number}>}
145
+ */
146
+ export async function getEffectiveRecallSettings({
147
+ cliToken,
148
+ configDir = DEFAULT_CONFIG_DIR,
149
+ now = () => Date.now(),
150
+ fetchRecallSettingsFn = fetchRecallSettings,
151
+ } = {}) {
152
+ if (!cliToken) return readEffectiveRecallSettings(configDir);
153
+
154
+ const result = await fetchRecallSettingsFn({ cliToken });
155
+ if (!result.ok) return readEffectiveRecallSettings(configDir, { cliToken });
156
+
157
+ writeFileAtomically(cachePath(configDir), JSON.stringify({
158
+ values: result.values,
159
+ tokenHash: hashToken(cliToken),
160
+ fetchedAt: new Date(now()).toISOString(),
161
+ }));
162
+
163
+ const effective = {};
164
+ for (const field of Object.keys(DEFAULT_RECALL_SETTINGS)) {
165
+ effective[field] = clamp(field, result.values[field]);
166
+ }
167
+ return effective;
168
+ }