dsh-qoder-connect 0.2.6 → 0.2.7

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.
@@ -215,24 +215,46 @@ function qoderMachineIdPath() {
215
215
  //#endregion
216
216
  //#region src/auth.ts
217
217
  /**
218
- * Qoder credential storage — one Personal Access Token per variant.
218
+ * Qoder credential storage — a list of Personal Access Tokens per variant,
219
+ * one of which is active.
219
220
  *
220
221
  * Every credential the plugin holds is a PAT the human pasted into the
221
- * variant's card (or exported into the environment, or saved by the CLI).
222
+ * variant's card (or saved by the CLI, or exported into the environment).
222
223
  * There is no device flow, no token refresh, and no expiry: a PAT is valid
223
224
  * until the user revokes it, so this store only reads, writes, and deletes a
224
225
  * small JSON document.
225
226
  *
227
+ * WHAT IS PER-ACCOUNT AND WHAT IS NOT
228
+ *
229
+ * Only the *credential* is per-account. Every setting the cards expose —
230
+ * disabled models, the maximum-context preference, per-model window
231
+ * overrides, probe consent, sidebar quota, check-in toggles — lives in the
232
+ * plugin's own settings file and is shared by every saved PAT. Switching
233
+ * accounts therefore never changes configuration; it only changes which
234
+ * token signs the next request.
235
+ *
236
+ * FILE
237
+ *
226
238
  * One file per variant inside the plugin's data directory
227
239
  * (`<.dsh-qoder-connect>/`). The on-disk schema is
228
- * `{version: 2, pat, region, savedAt}` — deliberately narrower than the
229
- * WorkBuddy-era document it replaces: anything this file does not name is
230
- * not a fact the plugin knows.
240
+ *
241
+ * {version: 3, activeId, credentials: [{id, pat, region, savedAt, label?}]}
242
+ *
243
+ * `id` is a one-way hash prefix of the token, so re-pasting the same token
244
+ * updates its own entry instead of piling up duplicates, and the id can be
245
+ * shown (in a select, a log line, or a test) without leaking the secret.
246
+ * `label` is optional display copy stored at save time; it never reaches the
247
+ * upstream.
248
+ *
249
+ * A version-2 document (`{version: 2, pat, region, savedAt}`) is read as a
250
+ * one-entry list and rewritten as v3 on the next mutation. A WorkBuddy-era
251
+ * document (it carries `accessToken` and no `pat`) is not a credential this
252
+ * plugin can use in any form — it is reported, never migrated.
231
253
  *
232
254
  * @module dsh-qoder-connect/auth
233
255
  */
234
256
  /** The on-disk document version this writer produces. */
235
- const AUTH_SCHEMA_VERSION = 2;
257
+ const AUTH_SCHEMA_VERSION = 3;
236
258
  /** Environment fallback for the global arm when no file exists. */
237
259
  const QODER_PAT_ENV_GLOBAL = "QODER_PERSONAL_ACCESS_TOKEN";
238
260
  /** Environment fallback for the China arm when no file exists. */
@@ -245,22 +267,56 @@ function patTail(pat) {
245
267
  return trimmed.length >= 4 ? trimmed.slice(-4) : void 0;
246
268
  }
247
269
  /**
270
+ * Stable local id for one token.
271
+ *
272
+ * A PAT is a bearer secret, so the id is a one-way hash prefix rather than
273
+ * any suffix of it: surfaces that show the id (the select, status documents,
274
+ * logs) leak nothing usable, while two saves of the same token still resolve
275
+ * to the same entry.
276
+ */
277
+ function qoderCredentialId(pat) {
278
+ return createHash("sha256").update(pat).digest("hex").slice(0, 12);
279
+ }
280
+ /**
248
281
  * Stable identity key for a credential, used by probe records and saved
249
282
  * catalogs.
250
283
  *
251
- * A PAT is a bearer secret, so the key is its one-way hash rather than any
252
- * suffix of it: surfaces that show the key (cards, JSON diagnostics) leak
253
- * nothing usable, while two reads of the same token still correlate locally.
284
+ * Deliberately wider than the local id: these keys address stored data, not
285
+ * UI, and 16 hex characters keep two tokens from ever sharing a bucket.
254
286
  */
255
287
  function qoderCredentialIdentity(credential) {
256
288
  return `pat:${createHash("sha256").update(credential.pat).digest("hex").slice(0, 16)}`;
257
289
  }
258
- function isRecord$1(value) {
290
+ function isRecord$2(value) {
259
291
  return typeof value === "object" && value !== null && !Array.isArray(value);
260
292
  }
261
293
  function isRegion(value) {
262
294
  return value === "global" || value === "china";
263
295
  }
296
+ /** Trim a label and cap it at a length a card row can render. */
297
+ function normalizeLabel(value) {
298
+ if (typeof value !== "string") return void 0;
299
+ const trimmed = value.trim();
300
+ return trimmed === "" ? void 0 : trimmed.slice(0, 48);
301
+ }
302
+ /** Parse one stored entry, dropping anything this store cannot use. */
303
+ function normalizeEntry(value) {
304
+ if (!isRecord$2(value)) return void 0;
305
+ const pat = typeof value["pat"] === "string" ? value["pat"].trim() : "";
306
+ if (pat === "") return void 0;
307
+ const region = value["region"];
308
+ if (!isRegion(region)) return void 0;
309
+ const rawId = typeof value["id"] === "string" ? value["id"].trim() : "";
310
+ const label = normalizeLabel(value["label"]);
311
+ const savedAt = typeof value["savedAt"] === "number" && Number.isFinite(value["savedAt"]) ? value["savedAt"] : void 0;
312
+ return {
313
+ id: rawId === "" ? qoderCredentialId(pat) : rawId,
314
+ pat,
315
+ region,
316
+ ...savedAt === void 0 ? {} : { savedAtMs: savedAt },
317
+ ...label === void 0 ? {} : { label }
318
+ };
319
+ }
264
320
  var QoderCredentialStore = class {
265
321
  variant;
266
322
  ownPath;
@@ -279,14 +335,25 @@ var QoderCredentialStore = class {
279
335
  /**
280
336
  * The credential currently in effect, or `undefined` when there is none.
281
337
  *
282
- * Resolution order: the plugin-owned file, then the arm's environment
283
- * variable (only when no file exists — a saved token always wins over a
284
- * stray env), then nothing. There is no refresh and no expiry check: a PAT
285
- * lives until revoked.
338
+ * Resolution order: the active saved entry (or the first one when the
339
+ * pointer is stale), then the arm's environment variable (only when no
340
+ * saved entry exists — a saved token always wins over a stray env), then
341
+ * nothing. There is no refresh and no expiry check: a PAT lives until
342
+ * revoked.
286
343
  */
287
344
  async current() {
288
345
  const stored = await this.readOwn();
289
- if (stored !== void 0) return stored;
346
+ if (stored !== void 0 && stored.entries.length > 0) {
347
+ const entry = stored.entries.find((candidate) => candidate.id === stored.activeId) ?? stored.entries[0];
348
+ return {
349
+ id: entry.id,
350
+ pat: entry.pat,
351
+ region: entry.region,
352
+ ...entry.savedAtMs === void 0 ? {} : { savedAtMs: entry.savedAtMs },
353
+ ...entry.label === void 0 ? {} : { label: entry.label },
354
+ source: "card"
355
+ };
356
+ }
290
357
  return this.fromEnvironment();
291
358
  }
292
359
  /**
@@ -305,23 +372,94 @@ var QoderCredentialStore = class {
305
372
  async patPromise() {
306
373
  return (await this.resolve()).pat;
307
374
  }
375
+ /** Every saved entry (tokens included; host-side callers only). */
376
+ async entries() {
377
+ return (await this.readOwn())?.entries ?? [];
378
+ }
308
379
  /**
309
- * Persist a token for this variant. The caller is responsible for having
310
- * validated it against the region first (`validateApiKey` upstream).
380
+ * Add a token and make it active.
381
+ *
382
+ * Re-saving an existing token updates that entry (its saved time, and its
383
+ * label when one is supplied) instead of appending a duplicate — the id is
384
+ * derived from the token, so "same PAT" is the same entry by construction.
385
+ * The caller is responsible for having validated the token against the
386
+ * region first (`validateApiKey` upstream).
387
+ *
388
+ * @param pat - the token to store.
389
+ * @param label - optional display label for the select row.
390
+ * @returns the stored entry.
311
391
  */
312
- async save(pat, source = "card") {
392
+ async add(pat, label) {
313
393
  const trimmed = pat.trim();
314
394
  if (trimmed === "") throw new Error("qoder: cannot save an empty Personal Access Token");
315
- const credential = {
395
+ const entries = (await this.readOwn())?.entries ?? [];
396
+ const id = qoderCredentialId(trimmed);
397
+ const index = entries.findIndex((entry) => entry.id === id);
398
+ const existing = index === -1 ? void 0 : entries[index];
399
+ const nextLabel = normalizeLabel(label) ?? existing?.label;
400
+ const next = {
401
+ id,
316
402
  pat: trimmed,
317
403
  region: this.variant.region,
318
404
  savedAtMs: Date.now(),
405
+ ...nextLabel === void 0 ? {} : { label: nextLabel }
406
+ };
407
+ if (index === -1) entries.push(next);
408
+ else entries[index] = next;
409
+ await this.writeDocument(entries, id);
410
+ return next;
411
+ }
412
+ /**
413
+ * Make one saved entry active.
414
+ *
415
+ * @param id - the entry id.
416
+ * @returns the activated entry, or `undefined` when no entry has that id.
417
+ */
418
+ async activate(id) {
419
+ const stored = await this.readOwn();
420
+ if (stored === void 0) return void 0;
421
+ const entry = stored.entries.find((candidate) => candidate.id === id);
422
+ if (entry === void 0) return void 0;
423
+ await this.writeDocument(stored.entries, id);
424
+ return entry;
425
+ }
426
+ /**
427
+ * Remove one saved entry.
428
+ *
429
+ * The active pointer moves to the first remaining entry; removing the last
430
+ * entry deletes the file, exactly like {@link clear} — a saved token is the
431
+ * only thing that shadows the environment fallback.
432
+ *
433
+ * @param id - the entry id.
434
+ * @returns the removed entry, or `undefined` when no entry has that id.
435
+ */
436
+ async remove(id) {
437
+ const stored = await this.readOwn();
438
+ if (stored === void 0) return void 0;
439
+ const index = stored.entries.findIndex((entry) => entry.id === id);
440
+ if (index === -1) return void 0;
441
+ const [removed] = stored.entries.splice(index, 1);
442
+ const activeId = stored.activeId === id ? stored.entries[0]?.id : stored.activeId;
443
+ await this.writeDocument(stored.entries, activeId);
444
+ return removed;
445
+ }
446
+ /**
447
+ * Persist a token for this variant and make it active.
448
+ *
449
+ * Kept for the CLI and older call sites: it is {@link add} behind the
450
+ * runtime-credential shape they expect.
451
+ */
452
+ async save(pat, source = "card") {
453
+ const entry = await this.add(pat);
454
+ return {
455
+ id: entry.id,
456
+ pat: entry.pat,
457
+ region: entry.region,
458
+ ...entry.savedAtMs === void 0 ? {} : { savedAtMs: entry.savedAtMs },
319
459
  source
320
460
  };
321
- await this.writeOwn(credential);
322
- return credential;
323
461
  }
324
- /** Remove the stored credential. A missing file is not an error. */
462
+ /** Remove every stored credential. A missing file is not an error. */
325
463
  async clear() {
326
464
  try {
327
465
  await rm(this.ownPath, { force: true });
@@ -337,7 +475,35 @@ var QoderCredentialStore = class {
337
475
  filePath: this.ownPath,
338
476
  region: this.variant.region
339
477
  };
340
- const credential = await this.current();
478
+ const stored = await this.readOwn();
479
+ const entries = stored?.entries ?? [];
480
+ if (entries.length > 0) {
481
+ const active = entries.find((entry) => entry.id === stored?.activeId) ?? entries[0];
482
+ const tail = patTail(active.pat);
483
+ const profiles = [...entries].sort(newestFirst$1).map((entry) => {
484
+ const entryTail = patTail(entry.pat);
485
+ return {
486
+ id: entry.id,
487
+ ...entry.label === void 0 ? {} : { label: entry.label },
488
+ ...entryTail === void 0 ? {} : { patTail: entryTail },
489
+ ...entry.savedAtMs === void 0 ? {} : { savedAtMs: entry.savedAtMs }
490
+ };
491
+ });
492
+ return {
493
+ state: "configured",
494
+ ...base,
495
+ pat: {
496
+ id: active.id,
497
+ ...active.label === void 0 ? {} : { label: active.label },
498
+ source: "card",
499
+ ...active.savedAtMs === void 0 ? {} : { savedAtMs: active.savedAtMs },
500
+ ...tail === void 0 ? {} : { patTail: tail }
501
+ },
502
+ activeId: active.id,
503
+ profiles
504
+ };
505
+ }
506
+ const credential = await this.fromEnvironment();
341
507
  if (credential === void 0) return {
342
508
  state: "missing",
343
509
  ...base,
@@ -349,7 +515,6 @@ var QoderCredentialStore = class {
349
515
  ...base,
350
516
  pat: {
351
517
  source: credential.source,
352
- ...credential.savedAtMs === void 0 ? {} : { savedAtMs: credential.savedAtMs },
353
518
  ...tail === void 0 ? {} : { patTail: tail }
354
519
  }
355
520
  };
@@ -370,7 +535,7 @@ var QoderCredentialStore = class {
370
535
  let text;
371
536
  try {
372
537
  text = await readFile(this.ownPath, "utf8");
373
- } catch (error) {
538
+ } catch {
374
539
  this.pendingLegacyReason = void 0;
375
540
  return;
376
541
  }
@@ -381,11 +546,11 @@ var QoderCredentialStore = class {
381
546
  this.pendingLegacyReason = void 0;
382
547
  return;
383
548
  }
384
- if (!isRecord$1(parsed)) {
549
+ if (!isRecord$2(parsed)) {
385
550
  this.pendingLegacyReason = void 0;
386
551
  return;
387
552
  }
388
- if (typeof parsed["accessToken"] === "string" && parsed["pat"] === void 0) {
553
+ if (typeof parsed["accessToken"] === "string" && parsed["pat"] === void 0 && parsed["credentials"] === void 0) {
389
554
  this.pendingLegacyReason = "stale WorkBuddy credential file; save a Qoder Personal Access Token instead";
390
555
  if (!this.legacyWarned) {
391
556
  this.legacyWarned = true;
@@ -394,23 +559,49 @@ var QoderCredentialStore = class {
394
559
  return;
395
560
  }
396
561
  this.pendingLegacyReason = void 0;
562
+ if (Array.isArray(parsed["credentials"])) {
563
+ const byId = /* @__PURE__ */ new Map();
564
+ for (const raw of parsed["credentials"]) {
565
+ const entry = normalizeEntry(raw);
566
+ if (entry !== void 0) byId.set(entry.id, entry);
567
+ }
568
+ const entries = [...byId.values()];
569
+ const requested = typeof parsed["activeId"] === "string" ? parsed["activeId"] : void 0;
570
+ return {
571
+ entries,
572
+ activeId: requested !== void 0 && entries.some((entry) => entry.id === requested) ? requested : entries[0]?.id
573
+ };
574
+ }
397
575
  const pat = typeof parsed["pat"] === "string" ? parsed["pat"].trim() : "";
398
576
  if (pat === "") return void 0;
399
577
  const region = isRegion(parsed["region"]) ? parsed["region"] : this.variant.region;
400
578
  const savedAt = typeof parsed["savedAt"] === "number" && Number.isFinite(parsed["savedAt"]) ? parsed["savedAt"] : void 0;
401
- return {
579
+ const entry = {
580
+ id: qoderCredentialId(pat),
402
581
  pat,
403
582
  region,
404
- ...savedAt === void 0 ? {} : { savedAtMs: savedAt },
405
- source: "card"
583
+ ...savedAt === void 0 ? {} : { savedAtMs: savedAt }
584
+ };
585
+ return {
586
+ entries: [entry],
587
+ activeId: entry.id
406
588
  };
407
589
  }
408
- async writeOwn(credential) {
590
+ async writeDocument(entries, activeId) {
591
+ if (entries.length === 0) {
592
+ await this.clear();
593
+ return;
594
+ }
409
595
  const document = {
410
596
  version: AUTH_SCHEMA_VERSION,
411
- pat: credential.pat,
412
- region: credential.region,
413
- savedAt: credential.savedAtMs ?? Date.now()
597
+ activeId: activeId !== void 0 && entries.some((entry) => entry.id === activeId) ? activeId : entries[0].id,
598
+ credentials: entries.map((entry) => ({
599
+ id: entry.id,
600
+ pat: entry.pat,
601
+ region: entry.region,
602
+ savedAt: entry.savedAtMs ?? Date.now(),
603
+ ...entry.label === void 0 ? {} : { label: entry.label }
604
+ }))
414
605
  };
415
606
  const directory = dirname(this.ownPath);
416
607
  try {
@@ -426,6 +617,10 @@ var QoderCredentialStore = class {
426
617
  this.pendingLegacyReason = void 0;
427
618
  }
428
619
  };
620
+ /** Newest first, with a stable tie-break so the select order never jitters. */
621
+ function newestFirst$1(a, b) {
622
+ return (b.savedAtMs ?? 0) - (a.savedAtMs ?? 0) || a.id.localeCompare(b.id);
623
+ }
429
624
  /**
430
625
  * The plugin-owned credential path for one variant's default filename.
431
626
  * Kept as a free function for the CLI, which builds no store per display line.
@@ -4105,7 +4300,7 @@ const IMAGE_MEDIA_TYPES = [
4105
4300
  "image/webp",
4106
4301
  "image/gif"
4107
4302
  ];
4108
- function isRecord(value) {
4303
+ function isRecord$1(value) {
4109
4304
  return typeof value === "object" && value !== null && !Array.isArray(value);
4110
4305
  }
4111
4306
  function requiredString(value, what) {
@@ -4146,7 +4341,7 @@ async function openAiContentBlocks(content, role, imagesAs) {
4146
4341
  throw new UpstreamRequestError(`${role} message content must be a string or a parts array`);
4147
4342
  })();
4148
4343
  for (const raw of parts) {
4149
- if (!isRecord(raw) || typeof raw["type"] !== "string") throw new UpstreamRequestError(`${role} message content part is malformed`);
4344
+ if (!isRecord$1(raw) || typeof raw["type"] !== "string") throw new UpstreamRequestError(`${role} message content part is malformed`);
4150
4345
  switch (raw["type"]) {
4151
4346
  case "text":
4152
4347
  blocks.push({
@@ -4157,7 +4352,7 @@ async function openAiContentBlocks(content, role, imagesAs) {
4157
4352
  case "image_url": {
4158
4353
  if (role !== "user") throw new UpstreamRequestError("image parts are valid only in user messages");
4159
4354
  const imageUrl = raw["image_url"];
4160
- const url = isRecord(imageUrl) && typeof imageUrl["url"] === "string" ? imageUrl["url"] : "";
4355
+ const url = isRecord$1(imageUrl) && typeof imageUrl["url"] === "string" ? imageUrl["url"] : "";
4161
4356
  if (!url.toLowerCase().startsWith("data:")) throw new UpstreamRequestError("image_url must be an inline data: URL; remote image URLs are not fetched");
4162
4357
  if (imagesAs === void 0) throw new UpstreamRequestError("image input requires the DSH attachment service");
4163
4358
  const decoded = decodeDataImage(url);
@@ -4186,14 +4381,14 @@ async function toGenerateOptions(bodyJson, context) {
4186
4381
  } catch {
4187
4382
  throw new UpstreamRequestError("request body is not valid JSON");
4188
4383
  }
4189
- if (!isRecord(parsed)) throw new UpstreamRequestError("request body must be a JSON object");
4384
+ if (!isRecord$1(parsed)) throw new UpstreamRequestError("request body must be a JSON object");
4190
4385
  const model = requiredString(parsed["model"], "model");
4191
4386
  const rawMessages = parsed["messages"];
4192
4387
  if (!Array.isArray(rawMessages) || rawMessages.length === 0) throw new UpstreamRequestError("messages must be a non-empty array");
4193
4388
  const systemParts = [];
4194
4389
  const messages = [];
4195
4390
  for (const raw of rawMessages) {
4196
- if (!isRecord(raw)) throw new UpstreamRequestError("each message must be an object");
4391
+ if (!isRecord$1(raw)) throw new UpstreamRequestError("each message must be an object");
4197
4392
  const role = raw["role"];
4198
4393
  switch (role) {
4199
4394
  case "system":
@@ -4219,8 +4414,8 @@ async function toGenerateOptions(bodyJson, context) {
4219
4414
  const content = await openAiContentBlocks(raw["content"], "assistant", void 0);
4220
4415
  const rawCalls = raw["tool_calls"];
4221
4416
  if (Array.isArray(rawCalls)) for (const rawCall of rawCalls) {
4222
- if (!isRecord(rawCall)) throw new UpstreamRequestError("each tool call must be an object");
4223
- const fn = isRecord(rawCall["function"]) ? rawCall["function"] : rawCall;
4417
+ if (!isRecord$1(rawCall)) throw new UpstreamRequestError("each tool call must be an object");
4418
+ const fn = isRecord$1(rawCall["function"]) ? rawCall["function"] : rawCall;
4224
4419
  content.push({
4225
4420
  type: "tool-call",
4226
4421
  id: ToolCallId(requiredString(rawCall["id"], "tool call id")),
@@ -4244,8 +4439,8 @@ async function toGenerateOptions(bodyJson, context) {
4244
4439
  let text;
4245
4440
  if (typeof content === "string") text = content;
4246
4441
  else if (Array.isArray(content)) {
4247
- text = content.filter((part) => isRecord(part) && part["type"] === "text").map((part) => typeof part["text"] === "string" ? part["text"] : "").join("");
4248
- if (content.some((part) => isRecord(part) && part["type"] !== "text")) throw new UpstreamRequestError("tool result content is limited to text");
4442
+ text = content.filter((part) => isRecord$1(part) && part["type"] === "text").map((part) => typeof part["text"] === "string" ? part["text"] : "").join("");
4443
+ if (content.some((part) => isRecord$1(part) && part["type"] !== "text")) throw new UpstreamRequestError("tool result content is limited to text");
4249
4444
  } else throw new UpstreamRequestError("tool message content must be a string or a text-parts array");
4250
4445
  messages.push(createToolResultMessage({
4251
4446
  callId: ToolCallId(callId),
@@ -4262,13 +4457,13 @@ async function toGenerateOptions(bodyJson, context) {
4262
4457
  }
4263
4458
  if (messages.length === 0) throw new UpstreamRequestError("the request carries no model-facing message");
4264
4459
  const tools = Array.isArray(parsed["tools"]) ? parsed["tools"].map((raw) => {
4265
- if (!isRecord(raw)) throw new UpstreamRequestError("each tool must be an object");
4266
- const fn = isRecord(raw["function"]) ? raw["function"] : raw;
4267
- if (!isRecord(fn)) throw new UpstreamRequestError("each tool must carry a function object");
4460
+ if (!isRecord$1(raw)) throw new UpstreamRequestError("each tool must be an object");
4461
+ const fn = isRecord$1(raw["function"]) ? raw["function"] : raw;
4462
+ if (!isRecord$1(fn)) throw new UpstreamRequestError("each tool must carry a function object");
4268
4463
  return {
4269
4464
  name: requiredString(fn["name"], "tool name"),
4270
4465
  description: typeof fn["description"] === "string" ? fn["description"] : "",
4271
- parameters: isRecord(fn["parameters"]) ? fn["parameters"] : {}
4466
+ parameters: isRecord$1(fn["parameters"]) ? fn["parameters"] : {}
4272
4467
  };
4273
4468
  }) : void 0;
4274
4469
  const effort = parsed["reasoning_effort"];
@@ -4823,8 +5018,223 @@ const DEFAULT_MAX_TOKENS = 32768;
4823
5018
  /** A conservative input budget for rows that declare none. */
4824
5019
  const DEFAULT_CONTEXT_WINDOW = 18e4;
4825
5020
  //#endregion
5021
+ //#region src/probe-store.ts
5022
+ /**
5023
+ * Local record of reasoning-effort probes.
5024
+ *
5025
+ * What this stores is an *observation*, never a claim about the upstream: a
5026
+ * model's row is only consulted when the catalog carries no explicit
5027
+ * `supportedEfforts` set, and it always loses to a declared set. The plan this
5028
+ * implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
5029
+ * is invalidated whenever the model's catalog row changes, so every record
5030
+ * carries a fingerprint of the fields the probe depended on.
5031
+ *
5032
+ * The file lives in the plugin's own state directory, never beside the
5033
+ * product's files, and carries no token, prompt, or response body — only
5034
+ * model ids, effort spellings, and timestamps.
5035
+ *
5036
+ * @module dsh-qoder-connect/probe-store
5037
+ */
5038
+ /** Basename of the China variant's probe record inside the plugin's state dir. */
5039
+ const QODER_PROBE_FILENAME = ".qoder-probe.json";
5040
+ /** On-disk format this reader accepts; other versions are discarded. */
5041
+ const PROBE_FORMAT_VERSION = 1;
5042
+ /**
5043
+ * How long an observation stays usable. Conservative on purpose: the plan's
5044
+ * whole argument is that upstream metadata moves fast, so a result that has
5045
+ * outlived its fingerprint's usefulness should not quietly keep granting a
5046
+ * picker entry.
5047
+ */
5048
+ const DEFAULT_TTL_MS = 12096e5;
5049
+ /**
5050
+ * Plugin-owned probe record path inside the plugin's state directory.
5051
+ *
5052
+ * One file per variant. The two regions serve overlapping model ids with
5053
+ * different entitlements, and {@link fingerprintModel} covers only
5054
+ * `id`/`reasoning`/`supportsImages` — never the provider — so a single shared
5055
+ * file would let one variant's observation answer for the other. The paths
5056
+ * differ; the format does not.
5057
+ */
5058
+ function qoderProbePath(filename = QODER_PROBE_FILENAME) {
5059
+ return join(qoderStateDir(), filename);
5060
+ }
5061
+ /**
5062
+ * Fingerprint the catalog fields a probe depends on.
5063
+ *
5064
+ * Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
5065
+ * so a rename or a rate relabel does not throw away a valid observation, and
5066
+ * deliberately includes the whole reasoning object so any change to the
5067
+ * declared shape re-probes.
5068
+ */
5069
+ function fingerprintModel(info) {
5070
+ const basis = JSON.stringify({
5071
+ id: info.id,
5072
+ reasoning: info.reasoning ?? null,
5073
+ supportsImages: info.supportsImages ?? null
5074
+ });
5075
+ return createHash("sha256").update(basis).digest("hex").slice(0, 16);
5076
+ }
5077
+ /** Read-and-validate the documents on disk; anything malformed reads as empty. */
5078
+ function readDocument(path) {
5079
+ if (!existsSync(path)) return void 0;
5080
+ let parsed;
5081
+ try {
5082
+ parsed = JSON.parse(readFileSync(path, "utf8"));
5083
+ } catch {
5084
+ return;
5085
+ }
5086
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
5087
+ const wrapped = parsed;
5088
+ if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
5089
+ const records = wrapped["records"];
5090
+ if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
5091
+ return parsed;
5092
+ }
5093
+ /** One record's shape check; a bad row is dropped rather than trusted. */
5094
+ function isRecord(value) {
5095
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
5096
+ const wrapped = value;
5097
+ const validation = wrapped["validation"];
5098
+ if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
5099
+ if (typeof wrapped["fingerprint"] !== "string") return false;
5100
+ if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
5101
+ if (typeof wrapped["pluginVersion"] !== "string") return false;
5102
+ const efforts = wrapped["efforts"];
5103
+ if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
5104
+ return true;
5105
+ }
5106
+ /**
5107
+ * The plugin's probe records: read once, written atomically, never trusted
5108
+ * across a fingerprint change or past the TTL.
5109
+ */
5110
+ var QoderProbeStore = class {
5111
+ path;
5112
+ ttlMs;
5113
+ pluginVersion;
5114
+ now;
5115
+ records;
5116
+ constructor(options) {
5117
+ const opts = typeof options === "string" ? {
5118
+ path: options,
5119
+ pluginVersion: "0.0.0"
5120
+ } : options;
5121
+ this.path = opts.path ?? qoderProbePath();
5122
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
5123
+ this.pluginVersion = opts.pluginVersion;
5124
+ this.now = opts.now ?? (() => Date.now());
5125
+ }
5126
+ /** Resolved state-file path, for the CLI and tests. */
5127
+ filePath() {
5128
+ return this.path;
5129
+ }
5130
+ load() {
5131
+ if (this.records === void 0) {
5132
+ const document = readDocument(this.path);
5133
+ const records = {};
5134
+ for (const [id, record] of Object.entries(document?.records ?? {})) if (isRecord(record)) records[id] = record;
5135
+ this.records = records;
5136
+ }
5137
+ return this.records;
5138
+ }
5139
+ /**
5140
+ * The usable record for a model, or `undefined` when there is none, it is
5141
+ * expired, it was taken against a different catalog row, or it belongs to a
5142
+ * different account.
5143
+ *
5144
+ * @param account - the account in effect, as `uid:enterpriseId`. Records are
5145
+ * only returned for the account that produced them.
5146
+ */
5147
+ get(modelId, fingerprint, account) {
5148
+ const record = this.load()[modelId];
5149
+ if (record === void 0) return void 0;
5150
+ if (record.fingerprint !== fingerprint) return void 0;
5151
+ if (record.account !== account) return void 0;
5152
+ if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
5153
+ return record;
5154
+ }
5155
+ /**
5156
+ * Store one observation. Only a decisive answer (`validating` /
5157
+ * `non-validating`) replaces an existing decisive record: a transient
5158
+ * `unknown` must not erase knowledge the user already paid for.
5159
+ */
5160
+ set(modelId, record) {
5161
+ const records = this.load();
5162
+ const existing = records[modelId];
5163
+ if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
5164
+ records[modelId] = record;
5165
+ this.persist();
5166
+ }
5167
+ /** Drop every record; used by the card's explicit "clear" action. */
5168
+ clear() {
5169
+ this.records = {};
5170
+ this.persist();
5171
+ }
5172
+ /**
5173
+ * Drop every record that belongs to one account, leaving the other saved
5174
+ * accounts' observations intact.
5175
+ *
5176
+ * Used when a saved token is removed: its detected effort levels are that
5177
+ * account's data, and no other account can use them. A plain {@link clear}
5178
+ * here would erase the observations of every account the user still has.
5179
+ */
5180
+ clearFor(account) {
5181
+ const records = this.load();
5182
+ let removed = false;
5183
+ for (const [modelId, record] of Object.entries(records)) {
5184
+ if (record.account !== account) continue;
5185
+ delete records[modelId];
5186
+ removed = true;
5187
+ }
5188
+ if (removed) this.persist();
5189
+ }
5190
+ /** Every record currently held, for status display. */
5191
+ all() {
5192
+ return { ...this.load() };
5193
+ }
5194
+ /** Build a record stamped with this store's clock, version, and account. */
5195
+ record(fingerprint, validation, efforts, account) {
5196
+ return {
5197
+ fingerprint,
5198
+ validation,
5199
+ efforts: validation === "validating" ? [...efforts] : [],
5200
+ probedAtMs: this.now(),
5201
+ pluginVersion: this.pluginVersion,
5202
+ account
5203
+ };
5204
+ }
5205
+ /**
5206
+ * Write through a temporary file and rename, so a crash mid-write cannot
5207
+ * leave a half-parsed document that reads as "no records" and silently drops
5208
+ * every observation.
5209
+ */
5210
+ persist() {
5211
+ const directory = dirname(this.path);
5212
+ try {
5213
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
5214
+ const document = {
5215
+ version: PROBE_FORMAT_VERSION,
5216
+ records: this.load()
5217
+ };
5218
+ const temporary = resolve(`${this.path}.tmp`);
5219
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
5220
+ renameSync(temporary, this.path);
5221
+ } catch {}
5222
+ }
5223
+ };
5224
+ /**
5225
+ * Order observations newest-first for display.
5226
+ *
5227
+ * The store keeps insertion order so the file reads chronologically, but the
5228
+ * card wants the most recent detection at the top: a sweep the user just ran
5229
+ * should not appear below every earlier one, which is what appending to an
5230
+ * insertion-ordered list does.
5231
+ */
5232
+ function newestFirst(records) {
5233
+ return [...records].sort((a, b) => b.probedAt - a.probedAt);
5234
+ }
5235
+ //#endregion
4826
5236
  //#region src/version.ts
4827
- const QODER_CONNECT_VERSION = "0.2.6";
5237
+ const QODER_CONNECT_VERSION = "0.2.7";
4828
5238
  //#endregion
4829
5239
  //#region src/host-heartbeat.ts
4830
5240
  /**
@@ -5021,4 +5431,4 @@ function variantFor(id) {
5021
5431
  return QODER_VARIANTS.find((variant) => variant.id === id);
5022
5432
  }
5023
5433
  //#endregion
5024
- export { QoderCatalog as A, QODER_PAT_ENV_GLOBAL as B, getMachineId as C, QoderCatalogStore as D, randomSentinel as E, QODER_PROBE_PATH as F, QODER_DATA_DIR_ENV as G, patTail as H, QODER_SETTINGS_FACE_PATH as I, qoderMachineIdPath as J, QODER_DATA_DIR_NAME as K, QODER_STATUS_PATH as L, QODER_GLOBAL_AUTH_PATH as M, QODER_GLOBAL_PROBE_PATH as N, qoderCatalogPath as O, QODER_GLOBAL_STATUS_PATH as P, QODER_AUTH_FILENAME as R, utc8WallClock as S, probeModel as T, qoderCredentialIdentity as U, QoderCredentialStore as V, qoderOwnAuthPath as W, qoderStateDir as X, qoderPluginDataDir as Y, modelInfoOf as _, QODER_HOST_HEARTBEAT_FILENAME as a, createQoderTransport as b, processStartTimeMs as c, writeHostHeartbeat as d, QODER_CONNECT_VERSION as f, kindFromQoderFailure as g, classifyUpstreamError as h, variantFor as i, QODER_AUTH_PATH as j, FALLBACK_QODER_MODELS as k, qoderHostHeartbeatPath as l, QoderUpstreamClient as m, GLOBAL_VARIANT as n, clearHostHeartbeat as o, KIND_STATUS as p, configurePluginPaths as q, QODER_VARIANTS as r, isHeartbeatProcessAlive as s, CHINA_VARIANT as t, readHostHeartbeat as u, normalizeCredits as v, PROBE_EFFORT_CANDIDATES as w, formatUtc8Date as x, validateApiKey as y, QODER_PAT_ENV_CN as z };
5434
+ export { configurePluginPaths as $, probeModel as A, QODER_PROBE_PATH as B, normalizeCredits as C, utc8WallClock as D, formatUtc8Date as E, QoderCatalog as F, QODER_PAT_ENV_GLOBAL as G, QODER_STATUS_PATH as H, QODER_AUTH_PATH as I, qoderCredentialId as J, QoderCredentialStore as K, QODER_GLOBAL_AUTH_PATH as L, QoderCatalogStore as M, qoderCatalogPath as N, getMachineId as O, FALLBACK_QODER_MODELS as P, QODER_DATA_DIR_NAME as Q, QODER_GLOBAL_PROBE_PATH as R, modelInfoOf as S, createQoderTransport as T, QODER_AUTH_FILENAME as U, QODER_SETTINGS_FACE_PATH as V, QODER_PAT_ENV_CN as W, qoderOwnAuthPath as X, qoderCredentialIdentity as Y, QODER_DATA_DIR_ENV as Z, qoderProbePath as _, QODER_HOST_HEARTBEAT_FILENAME as a, classifyUpstreamError as b, processStartTimeMs as c, writeHostHeartbeat as d, qoderMachineIdPath as et, QODER_CONNECT_VERSION as f, newestFirst as g, fingerprintModel as h, variantFor as i, randomSentinel as j, PROBE_EFFORT_CANDIDATES as k, qoderHostHeartbeatPath as l, QoderProbeStore as m, GLOBAL_VARIANT as n, clearHostHeartbeat as o, QODER_PROBE_FILENAME as p, patTail as q, QODER_VARIANTS as r, isHeartbeatProcessAlive as s, CHINA_VARIANT as t, qoderPluginDataDir as tt, readHostHeartbeat as u, KIND_STATUS as v, validateApiKey as w, kindFromQoderFailure as x, QoderUpstreamClient as y, QODER_GLOBAL_STATUS_PATH as z };