@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.
- package/openapi.yaml +846 -27
- package/package.json +1 -1
- package/src/__tests__/config-watcher-skill-reseed.test.ts +1 -0
- package/src/__tests__/config-watcher.test.ts +22 -4
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +6 -9
- package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +0 -2
- package/src/cli/commands/__tests__/inference-models.test.ts +86 -0
- package/src/cli/commands/__tests__/inference-profiles.test.ts +254 -0
- package/src/cli/commands/__tests__/inference-providers.test.ts +171 -0
- package/src/cli/commands/__tests__/schedules.test.ts +133 -1
- package/src/cli/commands/__tests__/usage.test.ts +131 -0
- package/src/cli/commands/inference-callsites.ts +158 -0
- package/src/cli/commands/inference-models.ts +84 -0
- package/src/cli/commands/inference-profiles.ts +371 -0
- package/src/cli/commands/inference-providers-default.ts +89 -0
- package/src/cli/commands/inference-providers.ts +87 -16
- package/src/cli/commands/inference.ts +6 -0
- package/src/cli/commands/memory/nodes.ts +90 -50
- package/src/cli/commands/schedules.ts +17 -2
- package/src/cli/commands/usage.ts +59 -21
- package/src/cli/lib/__tests__/cli-output.test.ts +20 -0
- package/src/cli/lib/cli-output.ts +55 -0
- package/src/config/__tests__/loader-last-known-good.test.ts +229 -0
- package/src/config/__tests__/loader-sparse-array-cleanup.test.ts +166 -0
- package/src/config/default-profile-catalog.ts +28 -0
- package/src/config/loader.ts +276 -46
- package/src/daemon/config-watcher.ts +65 -20
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/oauth/byo-connection.test.ts +1 -1
- package/src/oauth/connection-resolver.test.ts +140 -0
- package/src/oauth/connection-resolver.ts +48 -12
- package/src/plugins/defaults/memory/anisotropy.ts +1 -1
- package/src/plugins/defaults/memory/config.ts +1 -1
- package/src/plugins/defaults/memory/context-search/agent-protocol.ts +1 -1
- package/src/plugins/defaults/memory/context-search/search.ts +1 -1
- package/src/plugins/defaults/memory/graph/bootstrap.ts +1 -1
- package/src/plugins/defaults/memory/graph/consolidation.ts +1 -1
- package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +11 -1
- package/src/plugins/defaults/memory/graph/extraction.ts +1 -1
- package/src/plugins/defaults/memory/graph/narrative.ts +1 -1
- package/src/plugins/defaults/memory/graph/pattern-scan.ts +1 -1
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -1
- package/src/plugins/defaults/memory/graph/tools.ts +1 -1
- package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +43 -16
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +1 -1
- package/src/plugins/defaults/memory/host-utils.ts +80 -0
- package/src/plugins/defaults/memory/identity-context.ts +1 -1
- package/src/plugins/defaults/memory/injectors.ts +1 -1
- package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +2 -2
- package/src/plugins/defaults/memory/jobs-worker.ts +2 -9
- package/src/plugins/defaults/memory/memory-retrospective-state.ts +1 -1
- package/src/plugins/defaults/memory/paths.ts +48 -0
- package/src/plugins/defaults/memory/pkb/autoinject.ts +1 -1
- package/src/plugins/defaults/memory/pkb/context.ts +2 -2
- package/src/plugins/defaults/memory/pkb/types.ts +1 -1
- package/src/plugins/defaults/memory/pkb-schedule.ts +2 -2
- package/src/plugins/defaults/memory/rerank-local.ts +2 -3
- package/src/plugins/defaults/memory/routes/memory-eval-routes.ts +1 -1
- package/src/plugins/defaults/memory/routes/memory-item-routes.test.ts +149 -1
- package/src/plugins/defaults/memory/routes/memory-item-routes.ts +144 -1
- package/src/plugins/defaults/memory/routes/memory-v2-routes.ts +1 -1
- package/src/plugins/defaults/memory/startup.ts +1 -1
- package/src/plugins/defaults/memory/v2/backfill-jobs.ts +1 -1
- package/src/plugins/defaults/memory/v2/consolidation-job.ts +2 -2
- package/src/plugins/defaults/memory/v2/injection.ts +1 -1
- package/src/plugins/defaults/memory/v2/memory-v2-startup.ts +1 -1
- package/src/plugins/defaults/memory/v2/prompts/consolidation.ts +1 -1
- package/src/plugins/defaults/memory/v2/qdrant.ts +1 -1
- package/src/plugins/defaults/memory/v2/reranker.ts +1 -1
- package/src/plugins/defaults/memory/v2/static-context.ts +1 -1
- package/src/plugins/defaults/memory/v2/sweep-job.ts +1 -1
- package/src/plugins/defaults/memory/v3/candidate-match.ts +1 -1
- package/src/plugins/defaults/memory/v3/maintain-job.ts +1 -1
- package/src/plugins/defaults/memory/v3/page-content.ts +1 -1
- package/src/plugins/defaults/memory/v3/pool-select.ts +1 -2
- package/src/plugins/defaults/memory/v3/selection-log-store.ts +1 -1
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +2 -5
- package/src/plugins/defaults/memory/worker.ts +1 -1
- package/src/plugins/defaults/workspace/injectors.ts +17 -15
- package/src/plugins/types.ts +9 -14
- package/src/providers/inference/connection-availability.ts +193 -0
- package/src/runtime/routes/__tests__/inference-callsites-routes.test.ts +54 -0
- package/src/runtime/routes/__tests__/inference-models-routes.test.ts +54 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +405 -0
- package/src/runtime/routes/__tests__/inference-routes-registration.test.ts +42 -0
- package/src/runtime/routes/__tests__/theme-routes.test.ts +66 -0
- package/src/runtime/routes/conversation-query-routes.ts +5 -3
- package/src/runtime/routes/default-provider-routes.ts +5 -152
- package/src/runtime/routes/index.ts +8 -0
- package/src/runtime/routes/inference-callsites-routes.ts +211 -0
- package/src/runtime/routes/inference-models-routes.ts +104 -0
- package/src/runtime/routes/inference-profiles-routes.ts +717 -0
- package/src/runtime/routes/theme-routes.ts +36 -0
- package/src/runtime/sync/resource-sync-events.ts +4 -0
- package/src/theme/workspace-theme.test.ts +269 -0
- package/src/theme/workspace-theme.ts +304 -0
- package/src/watcher/__tests__/engine-auth-notify.test.ts +316 -0
- package/src/watcher/engine.ts +87 -2
package/src/config/loader.ts
CHANGED
|
@@ -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
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
* violation until the strip unmasks it
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
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
|
|
308
|
-
* skipping the write here would leave that live-session
|
|
309
|
-
* the
|
|
310
|
-
*
|
|
311
|
-
*
|
|
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.
|
|
315
|
-
* remain the authoritative record. `invalidPaths` is capped so a
|
|
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
|
|
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
|
|
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 —
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
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
|
|
357
|
-
*
|
|
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
|
|
371
|
-
*
|
|
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
|
-
|
|
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 —
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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)")
|
|
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)
|
|
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)
|
|
276
|
-
|
|
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)
|
|
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)
|
|
351
|
+
if (!filename) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
337
354
|
const file = String(filename);
|
|
338
|
-
if (!file.endsWith(".md"))
|
|
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)
|
|
371
|
-
|
|
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)
|
|
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))
|
|
475
|
+
if (!existsSync(skillsDir)) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
451
478
|
|
|
452
479
|
const scheduleSkillsReload = (file: string): void => {
|
|
453
|
-
if (isSkippedSkillWatchPath(file))
|
|
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())
|
|
533
|
-
|
|
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))
|
|
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))
|
|
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))
|
|
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))
|
|
617
|
+
if (isSkippedSkillWatchPath(filename)) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
579
620
|
|
|
580
621
|
scheduleSkillsReload(filename);
|
|
581
622
|
refreshChildWatchers();
|
|
582
623
|
});
|
|
583
624
|
|
|
584
|
-
if (!rootWatcher)
|
|
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)
|
|
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
|
|