@vellumai/assistant 0.10.7-dev.202607100313.fffdfea → 0.10.7-dev.202607100720.fba456e

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.
Files changed (99) hide show
  1. package/openapi.yaml +846 -27
  2. package/package.json +1 -1
  3. package/src/__tests__/config-watcher-skill-reseed.test.ts +1 -0
  4. package/src/__tests__/config-watcher.test.ts +22 -4
  5. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  6. package/src/__tests__/plugin-import-boundary-guard.test.ts +6 -9
  7. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +0 -2
  8. package/src/cli/commands/__tests__/inference-models.test.ts +86 -0
  9. package/src/cli/commands/__tests__/inference-profiles.test.ts +254 -0
  10. package/src/cli/commands/__tests__/inference-providers.test.ts +171 -0
  11. package/src/cli/commands/__tests__/schedules.test.ts +133 -1
  12. package/src/cli/commands/__tests__/usage.test.ts +131 -0
  13. package/src/cli/commands/inference-callsites.ts +158 -0
  14. package/src/cli/commands/inference-models.ts +84 -0
  15. package/src/cli/commands/inference-profiles.ts +371 -0
  16. package/src/cli/commands/inference-providers-default.ts +89 -0
  17. package/src/cli/commands/inference-providers.ts +87 -16
  18. package/src/cli/commands/inference.ts +6 -0
  19. package/src/cli/commands/memory/nodes.ts +90 -50
  20. package/src/cli/commands/schedules.ts +17 -2
  21. package/src/cli/commands/usage.ts +59 -21
  22. package/src/cli/lib/__tests__/cli-output.test.ts +20 -0
  23. package/src/cli/lib/cli-output.ts +55 -0
  24. package/src/config/__tests__/loader-last-known-good.test.ts +229 -0
  25. package/src/config/__tests__/loader-sparse-array-cleanup.test.ts +166 -0
  26. package/src/config/default-profile-catalog.ts +28 -0
  27. package/src/config/loader.ts +276 -46
  28. package/src/daemon/config-watcher.ts +65 -20
  29. package/src/daemon/message-types/sync.ts +1 -0
  30. package/src/oauth/byo-connection.test.ts +1 -1
  31. package/src/oauth/connection-resolver.test.ts +140 -0
  32. package/src/oauth/connection-resolver.ts +48 -12
  33. package/src/plugins/defaults/memory/anisotropy.ts +1 -1
  34. package/src/plugins/defaults/memory/config.ts +1 -1
  35. package/src/plugins/defaults/memory/context-search/agent-protocol.ts +1 -1
  36. package/src/plugins/defaults/memory/context-search/search.ts +1 -1
  37. package/src/plugins/defaults/memory/graph/bootstrap.ts +1 -1
  38. package/src/plugins/defaults/memory/graph/consolidation.ts +1 -1
  39. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +11 -1
  40. package/src/plugins/defaults/memory/graph/extraction.ts +1 -1
  41. package/src/plugins/defaults/memory/graph/narrative.ts +1 -1
  42. package/src/plugins/defaults/memory/graph/pattern-scan.ts +1 -1
  43. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -1
  44. package/src/plugins/defaults/memory/graph/tools.ts +1 -1
  45. package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +43 -16
  46. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +1 -1
  47. package/src/plugins/defaults/memory/host-utils.ts +80 -0
  48. package/src/plugins/defaults/memory/identity-context.ts +1 -1
  49. package/src/plugins/defaults/memory/injectors.ts +1 -1
  50. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +2 -2
  51. package/src/plugins/defaults/memory/jobs-worker.ts +2 -9
  52. package/src/plugins/defaults/memory/memory-retrospective-state.ts +1 -1
  53. package/src/plugins/defaults/memory/paths.ts +48 -0
  54. package/src/plugins/defaults/memory/pkb/autoinject.ts +1 -1
  55. package/src/plugins/defaults/memory/pkb/context.ts +2 -2
  56. package/src/plugins/defaults/memory/pkb/types.ts +1 -1
  57. package/src/plugins/defaults/memory/pkb-schedule.ts +2 -2
  58. package/src/plugins/defaults/memory/rerank-local.ts +2 -3
  59. package/src/plugins/defaults/memory/routes/memory-eval-routes.ts +1 -1
  60. package/src/plugins/defaults/memory/routes/memory-item-routes.test.ts +149 -1
  61. package/src/plugins/defaults/memory/routes/memory-item-routes.ts +144 -1
  62. package/src/plugins/defaults/memory/routes/memory-v2-routes.ts +1 -1
  63. package/src/plugins/defaults/memory/startup.ts +1 -1
  64. package/src/plugins/defaults/memory/v2/backfill-jobs.ts +1 -1
  65. package/src/plugins/defaults/memory/v2/consolidation-job.ts +2 -2
  66. package/src/plugins/defaults/memory/v2/injection.ts +1 -1
  67. package/src/plugins/defaults/memory/v2/memory-v2-startup.ts +1 -1
  68. package/src/plugins/defaults/memory/v2/prompts/consolidation.ts +1 -1
  69. package/src/plugins/defaults/memory/v2/qdrant.ts +1 -1
  70. package/src/plugins/defaults/memory/v2/reranker.ts +1 -1
  71. package/src/plugins/defaults/memory/v2/static-context.ts +1 -1
  72. package/src/plugins/defaults/memory/v2/sweep-job.ts +1 -1
  73. package/src/plugins/defaults/memory/v3/candidate-match.ts +1 -1
  74. package/src/plugins/defaults/memory/v3/maintain-job.ts +1 -1
  75. package/src/plugins/defaults/memory/v3/page-content.ts +1 -1
  76. package/src/plugins/defaults/memory/v3/pool-select.ts +1 -2
  77. package/src/plugins/defaults/memory/v3/selection-log-store.ts +1 -1
  78. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +2 -5
  79. package/src/plugins/defaults/memory/worker.ts +1 -1
  80. package/src/plugins/defaults/workspace/injectors.ts +17 -15
  81. package/src/plugins/types.ts +9 -14
  82. package/src/providers/inference/connection-availability.ts +193 -0
  83. package/src/runtime/routes/__tests__/inference-callsites-routes.test.ts +54 -0
  84. package/src/runtime/routes/__tests__/inference-models-routes.test.ts +54 -0
  85. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +405 -0
  86. package/src/runtime/routes/__tests__/inference-routes-registration.test.ts +42 -0
  87. package/src/runtime/routes/__tests__/theme-routes.test.ts +66 -0
  88. package/src/runtime/routes/conversation-query-routes.ts +5 -3
  89. package/src/runtime/routes/default-provider-routes.ts +5 -152
  90. package/src/runtime/routes/index.ts +8 -0
  91. package/src/runtime/routes/inference-callsites-routes.ts +211 -0
  92. package/src/runtime/routes/inference-models-routes.ts +104 -0
  93. package/src/runtime/routes/inference-profiles-routes.ts +717 -0
  94. package/src/runtime/routes/theme-routes.ts +36 -0
  95. package/src/runtime/sync/resource-sync-events.ts +4 -0
  96. package/src/theme/workspace-theme.test.ts +269 -0
  97. package/src/theme/workspace-theme.ts +304 -0
  98. package/src/watcher/__tests__/engine-auth-notify.test.ts +316 -0
  99. package/src/watcher/engine.ts +87 -2
@@ -27,6 +27,23 @@ let cachedFileSignature: ConfigFileSignature | null = null;
27
27
  let loading = false;
28
28
  let suppressConfigDiskWritesDepth = 0;
29
29
 
30
+ /**
31
+ * The most recent effective config in this process. Captured inside
32
+ * {@link validateWithSchema} on every validation-success path, then overwritten
33
+ * by {@link loadConfig} with the deployment-context-filled config whenever
34
+ * context defaults apply — so it reflects the config as consumers actually see
35
+ * it (managed service modes and all), not just the pre-fill schema-defaulted
36
+ * view. It is a safety net for the recovery ladder — when config.json later
37
+ * fails validation even after per-key cleanup, the last-known-good config is
38
+ * preferred over discarding the user's entire configuration and falling back to
39
+ * schema defaults.
40
+ *
41
+ * Deliberately NOT cleared by {@link invalidateConfigCache}: cache
42
+ * invalidation forces a re-read from disk, but the safety net must survive a
43
+ * re-read so it can rescue a subsequently-corrupted config.
44
+ */
45
+ let lastKnownGoodConfig: AssistantConfig | null = null;
46
+
30
47
  type ConfigFileSignature =
31
48
  | {
32
49
  path: string;
@@ -289,31 +306,46 @@ function writeQuarantineNotice(
289
306
  }
290
307
  }
291
308
 
309
+ /** Minimal structural view of the Zod issues the recovery ladder consumes. */
310
+ type ValidationIssue = { path: PropertyKey[]; message: string };
311
+
312
+ /** Join a Zod issue's path/message into a single human-readable clause. */
313
+ function describeIssues(issues: ValidationIssue[]): string {
314
+ return issues
315
+ .map((issue) => {
316
+ const path = issue.path.join(".");
317
+ return path ? `"${path}": ${issue.message}` : issue.message;
318
+ })
319
+ .join("; ");
320
+ }
321
+
292
322
  /**
293
- * Record a "config was reset to full defaults by schema validation" event so
294
- * the per-turn `config-validation-reset-notice` injector can surface it to the
295
- * agent. Unlike {@link writeQuarantineNotice} (JSON-corrupt file, quarantined),
296
- * this fires when `config.json` parses as valid JSON but fails schema validation
297
- * hard enough that {@link validateWithSchema} falls back to {@link
298
- * cloneDefaultConfig} — e.g. an unknown key that masks a `superRefine`
299
- * violation until the strip unmasks it, forcing a whole-config reset that
300
- * silently reverts unrelated settings (managed service modes, model choices,
301
- * API keys). The on-disk file is left intact, so the user's values are still
302
- * present but inactive until the invalid entries are fixed.
323
+ * Record a "config.json did not take effect as written" event so the per-turn
324
+ * `config-validation-reset-notice` injector can surface it to the agent. Unlike
325
+ * {@link writeQuarantineNotice} (JSON-corrupt file, quarantined), this fires
326
+ * when `config.json` parses as valid JSON but fails schema validation hard
327
+ * enough that per-key cleanup cannot repair it and {@link validateWithSchema}
328
+ * hands off to {@link recoverFromInvalidConfig} — e.g. an unknown key that masks
329
+ * a `superRefine` violation until the strip unmasks it. Every rung of that
330
+ * ladder leaves at least part of the user's saved configuration inactive, and
331
+ * the rungs that reset a section (or the whole config) to schema defaults
332
+ * silently revert settings the user never touched: managed service modes, model
333
+ * choices, API keys. The on-disk file is left intact, so the user's values are
334
+ * still present but inactive until the invalid entries are fixed.
303
335
  *
304
336
  * Deliberately NOT gated on `suppressConfigDiskWritesDepth`. The only suppressed
305
337
  * `getConfig()` is the re-parse inside `commitConfigWrite`, which runs *after*
306
338
  * `saveRawConfig` — so it reflects the persisted config, not a transient one. A
307
- * bad conversational `config set` must surface the reset it just caused;
308
- * skipping the write here would leave that live-session reset silent, because
309
- * the full-default fallback is then cached against the invalid file signature
310
- * and later loads short-circuit before re-validating. The sentinel is a
311
- * separate file, so writing it does not conflict with the suppression's purpose
339
+ * bad conversational `config set` must surface the degradation it just caused;
340
+ * skipping the write here would leave that live-session event silent, because
341
+ * the recovered config is then cached against the invalid file signature and
342
+ * later loads short-circuit before re-validating. The sentinel is a separate
343
+ * file, so writing it does not conflict with the suppression's purpose
312
344
  * (blocking the first-launch-seed / deprecated-strip rewrites of config.json).
313
345
  *
314
- * Best-effort — any failure is logged and swallowed; the `log.warn` reset lines
315
- * remain the authoritative record. `invalidPaths` is capped so a pathological
316
- * config cannot bloat the sentinel.
346
+ * Best-effort — any failure is logged and swallowed; the `log.error` recovery
347
+ * lines remain the authoritative record. `invalidPaths` is capped so a
348
+ * pathological config cannot bloat the sentinel.
317
349
  */
318
350
  function recordConfigValidationReset(invalidPaths: string[]): void {
319
351
  try {
@@ -333,14 +365,14 @@ function recordConfigValidationReset(invalidPaths: string[]): void {
333
365
  writeFileSync(noticePath, JSON.stringify(notice, null, 2) + "\n", "utf-8");
334
366
  log.warn(
335
367
  `Wrote config-validation-reset notice to ${noticePath}; config.json ` +
336
- `failed schema validation and was reset to defaults (${
368
+ `failed schema validation and did not fully take effect (${
337
369
  dedupedPaths.length
338
370
  } invalid path(s): ${dedupedPaths.join(", ") || "top-level"}).`,
339
371
  );
340
372
  } catch (noticeErr) {
341
373
  log.warn(
342
374
  { noticeErr },
343
- `Failed to write config-validation-reset notice; the reset is still ` +
375
+ `Failed to write config-validation-reset notice; the event is still ` +
344
376
  `recorded in the assistant logs.`,
345
377
  );
346
378
  }
@@ -348,13 +380,13 @@ function recordConfigValidationReset(invalidPaths: string[]): void {
348
380
 
349
381
  /**
350
382
  * Clear any stale config-validation-reset sentinel once the config validates
351
- * cleanly again — a validation reset is recoverable (fix the invalid entry and
352
- * the values come back), so the notice must stop as soon as the config parses,
353
- * not linger until age-out like a quarantine. Not gated on suppression (see
354
- * {@link recordConfigValidationReset}): the suppressed re-parse inside
383
+ * cleanly again — the event is recoverable (fix the invalid entry and the values
384
+ * come back), so the notice must stop as soon as the config parses, not linger
385
+ * until age-out like a quarantine. Not gated on suppression (see {@link
386
+ * recordConfigValidationReset}): the suppressed re-parse inside
355
387
  * `commitConfigWrite` is exactly when a user fixes their config via `config
356
- * set`, and the fallback config it produced is about to be cached against the
357
- * new signature — so if we skipped clearing here, later short-circuiting loads
388
+ * set`, and the config it produced is about to be cached against the new
389
+ * signature — so if we skipped clearing here, later short-circuiting loads
358
390
  * would keep injecting the stale notice until age-out. Best-effort.
359
391
  */
360
392
  function clearConfigValidationResetNotice(): void {
@@ -367,14 +399,29 @@ function clearConfigValidationResetNotice(): void {
367
399
  }
368
400
 
369
401
  /**
370
- * Validate a raw config object with Zod. Invalid fields are logged as warnings
371
- * and replaced with defaults (matching prior behavior of per-field fallback).
402
+ * Validate a raw config object with Zod, applying schema defaults for absent
403
+ * keys. Invalid fields are logged and stripped so their schema defaults apply,
404
+ * then the config is re-parsed.
405
+ *
406
+ * When cleanup fails to produce a valid config, recovery follows a ladder
407
+ * (see {@link recoverFromInvalidConfig}) that prefers preserving as much of the
408
+ * user's configuration as possible over discarding it wholesale:
409
+ * 1. the last-known-good config validated earlier in this process,
410
+ * 2. a per-section salvage that keeps every top-level section that validates
411
+ * on its own and defaults only the ones that do not, and
412
+ * 3. full schema defaults as the true last resort.
413
+ *
414
+ * Every success path records a `structuredClone` of the returned config as the
415
+ * last-known-good safety net. Cloning is required because `loadConfig` mutates
416
+ * the returned object in place via `fillContextDefaultsForMissingKeys`.
372
417
  */
373
418
  function validateWithSchema(raw: Record<string, unknown>): AssistantConfig {
374
419
  const result = AssistantConfigSchema.safeParse(raw);
375
420
  if (result.success) {
376
421
  clearConfigValidationResetNotice();
377
- return applyNestedDefaults(result.data);
422
+ const config = applyNestedDefaults(result.data);
423
+ lastKnownGoodConfig = structuredClone(config);
424
+ return config;
378
425
  }
379
426
 
380
427
  // Log each validation issue as a warning
@@ -398,36 +445,190 @@ function validateWithSchema(raw: Record<string, unknown>): AssistantConfig {
398
445
  const cleaned = structuredClone(raw);
399
446
  for (const issue of result.error.issues) {
400
447
  if (issue.path.length === 0) {
401
- // Top-level error — return full defaults. Surface the reset so the agent
402
- // can explain settings that silently reverted (see recordConfigValidationReset).
403
- recordConfigValidationReset(
404
- result.error.issues.map((i) => i.path.join(".")),
448
+ // Top-level error — individual keys cannot be stripped. Recover working
449
+ // from the clone as-is (its top-level sections are still iterable).
450
+ return recoverFromInvalidConfig(
451
+ cleaned,
452
+ [...result.error.issues],
453
+ issuePaths(result.error.issues),
405
454
  );
406
- return cloneDefaultConfig();
407
455
  }
408
456
  deleteNestedKey(cleaned, issue.path as (string | number)[], true);
409
457
  }
410
458
 
459
+ // Deleting an invalid array element via `deleteNestedKey` uses `delete
460
+ // arr[i]` semantics, which leaves a sparse hole that the re-parse reads as
461
+ // `undefined` and rejects — one bad element in any array field would
462
+ // otherwise invalidate the whole cleaned config. Compact the holes away
463
+ // before re-parsing. An array that is emptied purely by stripping invalid
464
+ // elements is removed from its parent (and any ancestor object the removal
465
+ // empties is pruned too) so the schema default applies, matching how
466
+ // `deleteNestedKey` prunes emptied objects — `[]` is not equivalent to the
467
+ // default for fields like `backup.offsite.destinations` (default `null` =
468
+ // iCloud) or `skills.allowBundled` (default `null` = all bundled skills).
469
+ compactSparseArrays(cleaned);
470
+
411
471
  const retry = AssistantConfigSchema.safeParse(cleaned);
412
472
  if (retry.success) {
413
473
  clearConfigValidationResetNotice();
414
- return applyNestedDefaults(retry.data);
474
+ const config = applyNestedDefaults(retry.data);
475
+ lastKnownGoodConfig = structuredClone(config);
476
+ return config;
477
+ }
478
+
479
+ // Record BOTH the first-parse paths (what was stripped) and the retry paths
480
+ // (what still failed) so the notice can name what went wrong — a first-parse
481
+ // `invalid_key` can abort the record parse and mask a latent `superRefine`
482
+ // that only the retry surfaces, and vice versa.
483
+ return recoverFromInvalidConfig(
484
+ cleaned,
485
+ [...retry.error.issues],
486
+ [...issuePaths(result.error.issues), ...issuePaths(retry.error.issues)],
487
+ );
488
+ }
489
+
490
+ /** Dotted paths for a set of Zod issues; a top-level issue yields `""`. */
491
+ function issuePaths(issues: readonly ValidationIssue[]): string[] {
492
+ return issues.map((issue) => issue.path.join("."));
493
+ }
494
+
495
+ /**
496
+ * Recover a usable config when {@link validateWithSchema}'s strip-and-reparse
497
+ * cleanup cannot produce a valid config, minimizing how much of the user's
498
+ * configuration is discarded.
499
+ *
500
+ * Ladder:
501
+ * 1. If a last-known-good config was captured earlier in this process, keep
502
+ * it — config.json on disk is untouched, so only the in-memory view is
503
+ * degraded and the previous good values are the closest safe substitute.
504
+ * 2. Otherwise (first load after startup), salvage section-by-section: keep
505
+ * every top-level section of `cleaned` that validates on its own and reset
506
+ * only the sections that do not. This rung requires `cleaned` to be a plain
507
+ * object; a top-level non-object config (JSON `null`, a primitive, or an
508
+ * array) has no sections to iterate and is skipped straight to rung 3.
509
+ * 3. If even the combined kept sections fail to parse, return full schema
510
+ * defaults.
511
+ *
512
+ * Every rung leaves some part of the user's saved config inactive, so all of
513
+ * them record the validation-reset sentinel (see {@link
514
+ * recordConfigValidationReset}) — the agent needs to explain a setting that
515
+ * reverted (rungs 2 and 3) or a `config set` that never took effect (rung 1).
516
+ *
517
+ * `issues` are the ones still unresolved after cleanup, used for the operator
518
+ * log; `invalidPaths` spans every parse attempt and is what the sentinel
519
+ * records.
520
+ */
521
+ function recoverFromInvalidConfig(
522
+ cleaned: unknown,
523
+ issues: ValidationIssue[],
524
+ invalidPaths: string[],
525
+ ): AssistantConfig {
526
+ recordConfigValidationReset(invalidPaths);
527
+ const issueSummary = describeIssues(issues);
528
+
529
+ if (lastKnownGoodConfig) {
530
+ log.error(
531
+ `config.json failed validation even after stripping invalid fields; ` +
532
+ `keeping the last-known-good configuration from this process. Fix ` +
533
+ `config.json to apply your changes (validation issues: ${issueSummary}).`,
534
+ );
535
+ return structuredClone(lastKnownGoodConfig);
536
+ }
537
+
538
+ if (!isPlainObject(cleaned)) {
539
+ // The per-section salvage below walks `Object.entries(cleaned)`, which
540
+ // throws on `null` and yields no config sections for a primitive or array.
541
+ // A top-level non-object config carries no recoverable sections, so load
542
+ // full schema defaults.
543
+ log.error(
544
+ `config.json is not a JSON object at the top level; loading full ` +
545
+ `defaults. Fix config.json to restore your settings (validation ` +
546
+ `issues: ${issueSummary}).`,
547
+ );
548
+ return cloneDefaultConfig();
415
549
  }
416
550
 
417
- // If still failing, fall back to full defaults. This is the case that
418
- // silently reverts unrelated settings (e.g. a stripped unknown `llm.callSites`
419
- // key unmasks a `superRefine` violation, wiping managed service modes back to
420
- // their `your-own` schema default). Record BOTH the first-parse paths (what
421
- // was stripped) and the retry paths (what still failed) so the notice can name
422
- // what went wrong — the retry issues are otherwise never logged.
423
- log.warn("Config validation failed after cleanup. Using full defaults.");
424
- recordConfigValidationReset([
425
- ...result.error.issues.map((i) => i.path.join(".")),
426
- ...retry.error.issues.map((i) => i.path.join(".")),
427
- ]);
551
+ const kept: Record<string, unknown> = {};
552
+ for (const [key, value] of Object.entries(cleaned)) {
553
+ const sectionResult = AssistantConfigSchema.safeParse({ [key]: value });
554
+ if (sectionResult.success) {
555
+ kept[key] = value;
556
+ } else {
557
+ log.error(
558
+ `config.json section "${key}" failed validation; resetting it to ` +
559
+ `defaults.`,
560
+ );
561
+ }
562
+ }
563
+
564
+ const salvaged = AssistantConfigSchema.safeParse(kept);
565
+ if (salvaged.success) {
566
+ const config = applyNestedDefaults(salvaged.data);
567
+ lastKnownGoodConfig = structuredClone(config);
568
+ return config;
569
+ }
570
+
571
+ log.error(
572
+ `config.json could not be salvaged section-by-section; loading full ` +
573
+ `defaults. Fix config.json to restore your settings (validation ` +
574
+ `issues: ${describeIssues([...salvaged.error.issues])}).`,
575
+ );
428
576
  return cloneDefaultConfig();
429
577
  }
430
578
 
579
+ /**
580
+ * Recursively remove holes from every array reachable inside `value`, mutating
581
+ * in place, and report whether `value` should be removed from its parent.
582
+ *
583
+ * Config values come from `JSON.parse`, which cannot encode holes (or
584
+ * `undefined`), so any hole is the residue of a `deleteNestedKey` strip of an
585
+ * invalid array element and is safe to drop. Surviving elements are recursed
586
+ * into so nested arrays are compacted too.
587
+ *
588
+ * `removeFromParent` is set for a value the parent must drop so the schema
589
+ * default applies:
590
+ *
591
+ * - An **array** that had at least one element removed by compaction (a hole
592
+ * from a stripped invalid element, or a nested child array that itself
593
+ * collapsed) and is now empty. An array that was already `[]` on disk keeps
594
+ * `removeFromParent` false — no element was stripped, so `[]` is the user's
595
+ * explicit choice and is preserved.
596
+ * - A **plain object** that a compaction removal emptied. This mirrors
597
+ * `deleteNestedKey(…, true)` pruning emptied ancestor objects: an emptied
598
+ * wrapper left behind can still read as a present, non-default override. An
599
+ * object that was already empty on disk removed no child, so it is left alone.
600
+ */
601
+ function compactSparseArrays(value: unknown): { removeFromParent: boolean } {
602
+ if (Array.isArray(value)) {
603
+ let removedElement = false;
604
+ for (let i = value.length - 1; i >= 0; i--) {
605
+ if (i in value) {
606
+ if (compactSparseArrays(value[i]).removeFromParent) {
607
+ value.splice(i, 1);
608
+ removedElement = true;
609
+ }
610
+ } else {
611
+ value.splice(i, 1);
612
+ removedElement = true;
613
+ }
614
+ }
615
+ return { removeFromParent: removedElement && value.length === 0 };
616
+ }
617
+ if (isPlainObject(value)) {
618
+ let removedChild = false;
619
+ for (const key of Object.keys(value)) {
620
+ if (compactSparseArrays(value[key]).removeFromParent) {
621
+ delete value[key];
622
+ removedChild = true;
623
+ }
624
+ }
625
+ return {
626
+ removeFromParent: removedChild && Object.keys(value).length === 0,
627
+ };
628
+ }
629
+ return { removeFromParent: false };
630
+ }
631
+
431
632
  /**
432
633
  * Delete the key at `path` from `obj`. When `pruneEmptyAncestors` is set, also
433
634
  * remove any ancestor object the deletion leaves empty, walking up until the
@@ -852,6 +1053,13 @@ export function loadConfig(): AssistantConfig {
852
1053
  fileConfig,
853
1054
  contextDefaults,
854
1055
  );
1056
+ // Refresh the last-known-good safety net with the effective config.
1057
+ // `validateWithSchema` captured a snapshot before these deployment-context
1058
+ // fills (e.g. IS_PLATFORM=true → managed OAuth service modes) were layered
1059
+ // on, so its snapshot reflects pre-fill schema defaults. The fills are part
1060
+ // of this run's effective configuration, so a later recovery must restore
1061
+ // them rather than flip a managed service mode back to its schema default.
1062
+ lastKnownGoodConfig = structuredClone(config);
855
1063
  }
856
1064
 
857
1065
  // First-launch seed only: when config.json does not exist, write the
@@ -1092,3 +1300,25 @@ export function setNestedValue(
1092
1300
  * production use.
1093
1301
  */
1094
1302
  export const _writeQuarantineNotice = writeQuarantineNotice;
1303
+
1304
+ /**
1305
+ * Test-only alias for `recoverFromInvalidConfig`. The strip-and-reparse
1306
+ * cleanup (including sparse-array compaction) repairs every known real-world
1307
+ * config shape, so no config.json fixture reliably reaches the recovery
1308
+ * ladder through `loadConfig` — the ladder is defense-in-depth for schema
1309
+ * shapes the cleanup does not anticipate. Tests exercise its rungs (last
1310
+ * known good, per-section salvage, full defaults) by calling it directly.
1311
+ * Not for production use.
1312
+ */
1313
+ export const _recoverFromInvalidConfigForTests = recoverFromInvalidConfig;
1314
+
1315
+ /**
1316
+ * Test-only reset for the module-level last-known-good config safety net.
1317
+ * Exists so tests can exercise the first-load-after-startup salvage path (where
1318
+ * no last-known-good config exists yet) deterministically. Not for production
1319
+ * use: {@link invalidateConfigCache} deliberately preserves the safety net, so
1320
+ * production code never clears it.
1321
+ */
1322
+ export function _resetLastKnownGoodConfigForTests(): void {
1323
+ lastKnownGoodConfig = null;
1324
+ }
@@ -27,11 +27,13 @@ import {
27
27
  publishConfigChanged,
28
28
  publishIdentityChanged,
29
29
  publishSoundsConfigUpdated,
30
+ publishWorkspaceThemeChanged,
30
31
  } from "../runtime/sync/resource-sync-events.js";
31
32
  import { handleCancelSignal } from "../signals/cancel.js";
32
33
  import { handleConversationUndoSignal } from "../signals/conversation-undo.js";
33
34
  import { handleEmitEventSignal } from "../signals/emit-event.js";
34
35
  import { handleUserMessageSignal } from "../signals/user-message.js";
36
+ import { WORKSPACE_THEME_RELATIVE_PATH } from "../theme/workspace-theme.js";
35
37
  import { DebouncerMap } from "../util/debounce.js";
36
38
  import { getLogger } from "../util/logger.js";
37
39
  import {
@@ -63,7 +65,9 @@ const SKILL_WATCH_SKIPPED_DIRS = new Set([
63
65
  ]);
64
66
 
65
67
  function isSkippedSkillWatchPath(relativePath: string): boolean {
66
- if (relativePath === "(unknown)") return false;
68
+ if (relativePath === "(unknown)") {
69
+ return false;
70
+ }
67
71
 
68
72
  const segments = relativePath.split(/[\\/]+/).filter(Boolean);
69
73
  return segments.some((segment) => SKILL_WATCH_SKIPPED_DIRS.has(segment));
@@ -204,7 +208,9 @@ export class ConfigWatcher {
204
208
 
205
209
  const workspaceHandlers: Record<string, () => void> = {
206
210
  "config.json": async () => {
207
- if (this.suppressReload) return;
211
+ if (this.suppressReload) {
212
+ return;
213
+ }
208
214
  try {
209
215
  const prevMcpFingerprint = JSON.stringify(this.lastConfig?.mcp ?? {});
210
216
  const changed = await this.refreshConfigFromSources();
@@ -233,6 +239,9 @@ export class ConfigWatcher {
233
239
  evictConversationsForReload();
234
240
  broadcastIdentityChange();
235
241
  },
242
+ [WORKSPACE_THEME_RELATIVE_PATH]: () => {
243
+ publishWorkspaceThemeChanged();
244
+ },
236
245
  };
237
246
 
238
247
  // Per-file watches; don't watch the workspace directory itself because
@@ -272,8 +281,12 @@ export class ConfigWatcher {
272
281
  // edge case must not propagate up to DaemonServer.start().
273
282
  try {
274
283
  watchFile(filePath, { interval: this.pollIntervalMs }, (curr, prev) => {
275
- if (this.stopped) return;
276
- if (curr.ino === prev.ino && curr.mtimeMs === prev.mtimeMs) return;
284
+ if (this.stopped) {
285
+ return;
286
+ }
287
+ if (curr.ino === prev.ino && curr.mtimeMs === prev.mtimeMs) {
288
+ return;
289
+ }
277
290
  this.debounceTimers.schedule(`file:${filePath}`, () => {
278
291
  log.info({ file: filePath }, "File changed, reloading");
279
292
  handler();
@@ -301,7 +314,9 @@ export class ConfigWatcher {
301
314
 
302
315
  try {
303
316
  const watcher = watch(soundsDir, (_eventType, filename) => {
304
- if (!filename) return;
317
+ if (!filename) {
318
+ return;
319
+ }
305
320
  this.debounceTimers.schedule("file:sounds", () => {
306
321
  log.info(
307
322
  { file: String(filename) },
@@ -333,9 +348,13 @@ export class ConfigWatcher {
333
348
 
334
349
  try {
335
350
  const watcher = watch(usersDir, (_eventType, filename) => {
336
- if (!filename) return;
351
+ if (!filename) {
352
+ return;
353
+ }
337
354
  const file = String(filename);
338
- if (!file.endsWith(".md")) return;
355
+ if (!file.endsWith(".md")) {
356
+ return;
357
+ }
339
358
  this.debounceTimers.schedule(`file:users/${file}`, () => {
340
359
  log.info({ file }, "Users persona file changed, reloading");
341
360
  evictConversationsForReload();
@@ -367,8 +386,12 @@ export class ConfigWatcher {
367
386
 
368
387
  try {
369
388
  const watcher = watch(avatarDir, (_eventType, filename) => {
370
- if (!filename) return;
371
- if (String(filename) !== AVATAR_IMAGE_FILENAME) return;
389
+ if (!filename) {
390
+ return;
391
+ }
392
+ if (String(filename) !== AVATAR_IMAGE_FILENAME) {
393
+ return;
394
+ }
372
395
  this.debounceTimers.schedule("file:avatar", () => {
373
396
  log.info(
374
397
  { file: String(filename) },
@@ -413,7 +436,9 @@ export class ConfigWatcher {
413
436
 
414
437
  try {
415
438
  const watcher = watch(signalsDir, (_eventType, filename) => {
416
- if (!filename) return;
439
+ if (!filename) {
440
+ return;
441
+ }
417
442
  const file = String(filename);
418
443
 
419
444
  if (exactSignalHandlers[file]) {
@@ -447,10 +472,14 @@ export class ConfigWatcher {
447
472
 
448
473
  private startSkillsWatchers(): void {
449
474
  const skillsDir = getWorkspaceSkillsDir();
450
- if (!existsSync(skillsDir)) return;
475
+ if (!existsSync(skillsDir)) {
476
+ return;
477
+ }
451
478
 
452
479
  const scheduleSkillsReload = (file: string): void => {
453
- if (isSkippedSkillWatchPath(file)) return;
480
+ if (isSkippedSkillWatchPath(file)) {
481
+ return;
482
+ }
454
483
 
455
484
  this.debounceTimers.schedule("skills:catalog", () => {
456
485
  log.info({ file }, "Skill file changed, reloading");
@@ -529,8 +558,12 @@ export class ConfigWatcher {
529
558
  }
530
559
 
531
560
  for (const entry of entries) {
532
- if (!entry.isDirectory()) continue;
533
- if (SKILL_WATCH_SKIPPED_DIRS.has(entry.name)) continue;
561
+ if (!entry.isDirectory()) {
562
+ continue;
563
+ }
564
+ if (SKILL_WATCH_SKIPPED_DIRS.has(entry.name)) {
565
+ continue;
566
+ }
534
567
  const childDir = join(dirPath, entry.name);
535
568
  acc.add(childDir);
536
569
  enumerateSkillSubdirectories(childDir, acc);
@@ -554,16 +587,22 @@ export class ConfigWatcher {
554
587
  }
555
588
 
556
589
  for (const [childDir, watcher] of childWatchers.entries()) {
557
- if (nextChildDirs.has(childDir)) continue;
590
+ if (nextChildDirs.has(childDir)) {
591
+ continue;
592
+ }
558
593
  closeChildWatcher(childDir, watcher);
559
594
  }
560
595
 
561
596
  for (const childDir of nextChildDirs) {
562
- if (childWatchers.has(childDir)) continue;
597
+ if (childWatchers.has(childDir)) {
598
+ continue;
599
+ }
563
600
 
564
601
  const watcher = watchDir(childDir, (filename) => {
565
602
  const file = formatSkillChangeLabel(childDir, filename);
566
- if (isSkippedSkillWatchPath(file)) return;
603
+ if (isSkippedSkillWatchPath(file)) {
604
+ return;
605
+ }
567
606
 
568
607
  scheduleSkillsReload(file);
569
608
  refreshChildWatchers();
@@ -575,13 +614,17 @@ export class ConfigWatcher {
575
614
  };
576
615
 
577
616
  const rootWatcher = watchDir(skillsDir, (filename) => {
578
- if (isSkippedSkillWatchPath(filename)) return;
617
+ if (isSkippedSkillWatchPath(filename)) {
618
+ return;
619
+ }
579
620
 
580
621
  scheduleSkillsReload(filename);
581
622
  refreshChildWatchers();
582
623
  });
583
624
 
584
- if (!rootWatcher) return;
625
+ if (!rootWatcher) {
626
+ return;
627
+ }
585
628
 
586
629
  refreshChildWatchers();
587
630
  log.info(
@@ -645,7 +688,9 @@ export function cleanupSettingsChanged(
645
688
  prev: MemoryCleanupConfig | undefined,
646
689
  next: MemoryCleanupConfig | undefined,
647
690
  ): boolean {
648
- if (!prev || !next) return false;
691
+ if (!prev || !next) {
692
+ return false;
693
+ }
649
694
  return (
650
695
  prev.llmRequestLogRetentionMs !== next.llmRequestLogRetentionMs ||
651
696
  prev.conversationRetentionDays !== next.conversationRetentionDays ||
@@ -13,6 +13,7 @@ export const SYNC_TAGS = {
13
13
  assistantConfig: "assistant:self:config",
14
14
  assistantSounds: "assistant:self:sounds",
15
15
  assistantSchedules: "assistant:self:schedules",
16
+ assistantTheme: "assistant:self:theme",
16
17
  appsList: "apps:list",
17
18
  pluginsList: "plugins:list",
18
19
  conversationsList: "conversations:list",
@@ -592,7 +592,7 @@ describe("resolveOAuthConnection", () => {
592
592
 
593
593
  test("throws when no credential metadata exists", async () => {
594
594
  await expect(resolveOAuthConnection("unknown")).rejects.toThrow(
595
- /No active OAuth connection found for "unknown"/,
595
+ /No active OAuth connection found for provider "unknown"/,
596
596
  );
597
597
  });
598
598