wave-agent-sdk 0.16.8 → 0.16.10

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 (96) hide show
  1. package/dist/agent.d.ts.map +1 -1
  2. package/dist/agent.js +3 -0
  3. package/dist/constants/tools.d.ts +0 -1
  4. package/dist/constants/tools.d.ts.map +1 -1
  5. package/dist/constants/tools.js +0 -1
  6. package/dist/managers/aiManager.d.ts +0 -8
  7. package/dist/managers/aiManager.d.ts.map +1 -1
  8. package/dist/managers/aiManager.js +0 -45
  9. package/dist/managers/mcpManager.d.ts +5 -0
  10. package/dist/managers/mcpManager.d.ts.map +1 -1
  11. package/dist/managers/mcpManager.js +107 -12
  12. package/dist/managers/toolManager.d.ts +0 -6
  13. package/dist/managers/toolManager.d.ts.map +1 -1
  14. package/dist/managers/toolManager.js +1 -28
  15. package/dist/prompts/index.d.ts.map +1 -1
  16. package/dist/prompts/index.js +1 -12
  17. package/dist/services/authService.d.ts +10 -0
  18. package/dist/services/authService.d.ts.map +1 -1
  19. package/dist/services/authService.js +45 -0
  20. package/dist/services/configurationService.d.ts +1 -0
  21. package/dist/services/configurationService.d.ts.map +1 -1
  22. package/dist/services/configurationService.js +48 -15
  23. package/dist/services/initializationService.d.ts.map +1 -1
  24. package/dist/services/initializationService.js +11 -0
  25. package/dist/services/pluginLoader.d.ts.map +1 -1
  26. package/dist/services/pluginLoader.js +2 -2
  27. package/dist/services/remoteSettingsService.d.ts +21 -0
  28. package/dist/services/remoteSettingsService.d.ts.map +1 -0
  29. package/dist/services/remoteSettingsService.js +279 -0
  30. package/dist/telemetry/instrumentation.d.ts +5 -2
  31. package/dist/telemetry/instrumentation.d.ts.map +1 -1
  32. package/dist/telemetry/instrumentation.js +8 -4
  33. package/dist/tools/buildTool.d.ts +0 -2
  34. package/dist/tools/buildTool.d.ts.map +1 -1
  35. package/dist/tools/buildTool.js +0 -2
  36. package/dist/tools/cronCreateTool.d.ts.map +1 -1
  37. package/dist/tools/cronCreateTool.js +0 -1
  38. package/dist/tools/cronDeleteTool.d.ts.map +1 -1
  39. package/dist/tools/cronDeleteTool.js +0 -1
  40. package/dist/tools/cronListTool.d.ts.map +1 -1
  41. package/dist/tools/cronListTool.js +0 -1
  42. package/dist/tools/enterWorktreeTool.d.ts.map +1 -1
  43. package/dist/tools/enterWorktreeTool.js +0 -1
  44. package/dist/tools/exitWorktreeTool.d.ts.map +1 -1
  45. package/dist/tools/exitWorktreeTool.js +0 -1
  46. package/dist/tools/taskManagementTools.d.ts.map +1 -1
  47. package/dist/tools/taskManagementTools.js +0 -4
  48. package/dist/tools/types.d.ts +0 -15
  49. package/dist/tools/types.d.ts.map +1 -1
  50. package/dist/tools/webFetchTool.d.ts.map +1 -1
  51. package/dist/tools/webFetchTool.js +0 -1
  52. package/dist/types/configuration.d.ts +20 -0
  53. package/dist/types/configuration.d.ts.map +1 -1
  54. package/dist/types/mcp.d.ts +3 -1
  55. package/dist/types/mcp.d.ts.map +1 -1
  56. package/dist/utils/containerSetup.d.ts.map +1 -1
  57. package/dist/utils/containerSetup.js +10 -0
  58. package/dist/utils/mcpUtils.d.ts.map +1 -1
  59. package/dist/utils/mcpUtils.js +0 -1
  60. package/dist/utils/openaiClient.d.ts.map +1 -1
  61. package/dist/utils/openaiClient.js +4 -2
  62. package/package.json +1 -1
  63. package/src/agent.ts +3 -0
  64. package/src/constants/tools.ts +0 -1
  65. package/src/managers/aiManager.ts +0 -48
  66. package/src/managers/mcpManager.ts +122 -16
  67. package/src/managers/toolManager.ts +1 -32
  68. package/src/prompts/index.ts +0 -13
  69. package/src/services/authService.ts +56 -0
  70. package/src/services/configurationService.ts +56 -19
  71. package/src/services/initializationService.ts +13 -0
  72. package/src/services/pluginLoader.ts +2 -2
  73. package/src/services/remoteSettingsService.ts +314 -0
  74. package/src/telemetry/instrumentation.ts +12 -4
  75. package/src/tools/buildTool.ts +0 -4
  76. package/src/tools/cronCreateTool.ts +0 -1
  77. package/src/tools/cronDeleteTool.ts +0 -1
  78. package/src/tools/cronListTool.ts +0 -1
  79. package/src/tools/enterWorktreeTool.ts +0 -1
  80. package/src/tools/exitWorktreeTool.ts +0 -1
  81. package/src/tools/taskManagementTools.ts +0 -4
  82. package/src/tools/types.ts +0 -15
  83. package/src/tools/webFetchTool.ts +0 -1
  84. package/src/types/configuration.ts +23 -0
  85. package/src/types/mcp.ts +8 -1
  86. package/src/utils/containerSetup.ts +10 -0
  87. package/src/utils/mcpUtils.ts +0 -1
  88. package/src/utils/openaiClient.ts +5 -2
  89. package/dist/tools/toolSearchTool.d.ts +0 -15
  90. package/dist/tools/toolSearchTool.d.ts.map +0 -1
  91. package/dist/tools/toolSearchTool.js +0 -200
  92. package/dist/utils/isDeferredTool.d.ts +0 -19
  93. package/dist/utils/isDeferredTool.d.ts.map +0 -1
  94. package/dist/utils/isDeferredTool.js +0 -31
  95. package/src/tools/toolSearchTool.ts +0 -245
  96. package/src/utils/isDeferredTool.ts +0 -36
@@ -18,9 +18,7 @@ import {
18
18
  READ_TOOL_NAME,
19
19
  GLOB_TOOL_NAME,
20
20
  GREP_TOOL_NAME,
21
- TOOL_SEARCH_TOOL_NAME,
22
21
  } from "../constants/tools.js";
23
- import { isDeferredTool } from "../utils/isDeferredTool.js";
24
22
 
25
23
  export const BASE_SYSTEM_PROMPT = `You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.`;
26
24
 
@@ -260,17 +258,6 @@ export function buildSystemPrompt(
260
258
  prompt += `\n\n${TOOL_POLICY}`;
261
259
  }
262
260
 
263
- // List available deferred tool names with descriptions so the model knows they exist
264
- // and can decide which ones to discover via ToolSearch
265
- const deferredTools = tools.filter(isDeferredTool);
266
- if (deferredTools.length > 0) {
267
- const lines = deferredTools.map((t) => {
268
- const desc = t.config.function?.description;
269
- return desc ? `${t.name} - ${desc}` : t.name;
270
- });
271
- prompt += `\n\n<available-deferred-tools>\n${lines.join("\n")}\nThese tools are NOT loaded yet — call ${TOOL_SEARCH_TOOL_NAME} first to discover their schemas before invoking them.</available-deferred-tools>`;
272
- }
273
-
274
261
  prompt += `\n\n${OUTPUT_EFFICIENCY_PROMPT}`;
275
262
  prompt += `\n\n${TONE_AND_STYLE_PROMPT}`;
276
263
 
@@ -15,12 +15,16 @@ import {
15
15
  } from "fs";
16
16
  import * as path from "path";
17
17
  import * as os from "os";
18
+ import { randomBytes } from "crypto";
18
19
  import { createServer, Server } from "http";
19
20
  import { URL } from "url";
20
21
  import { execFile } from "child_process";
21
22
  import { promisify } from "util";
22
23
  import type { AuthConfig, AuthUser } from "../types/auth.js";
23
24
 
25
+ /** Persistent anonymous ID for telemetry fallback when SSO is not authenticated. */
26
+ let _anonymousId: string | undefined;
27
+
24
28
  const execFileAsync = promisify(execFile);
25
29
 
26
30
  export class AuthService {
@@ -312,3 +316,55 @@ export class AuthService {
312
316
  }
313
317
 
314
318
  export const authService = AuthService.getInstance();
319
+
320
+ /**
321
+ * Get or create a persistent anonymous ID for telemetry.
322
+ *
323
+ * Stored in ~/.wave/config.json as { anonymousId: "..." }.
324
+ * Generated once on first run (32-byte random hex) and reused thereafter.
325
+ * Falls back to an in-memory ID if file I/O fails.
326
+ */
327
+ export function getOrCreateAnonymousId(): string {
328
+ if (_anonymousId) return _anonymousId;
329
+
330
+ try {
331
+ const configPath = path.join(os.homedir(), ".wave", "config.json");
332
+ if (existsSync(configPath)) {
333
+ const content = readFileSync(configPath, "utf-8");
334
+ const config = JSON.parse(content) as { anonymousId?: string };
335
+ if (config.anonymousId) {
336
+ _anonymousId = config.anonymousId;
337
+ return _anonymousId;
338
+ }
339
+ }
340
+
341
+ // Generate and persist
342
+ _anonymousId = randomBytes(32).toString("hex");
343
+ const waveDir = path.dirname(configPath);
344
+ if (!existsSync(waveDir)) {
345
+ mkdirSync(waveDir, { recursive: true });
346
+ }
347
+ const existing = existsSync(configPath)
348
+ ? (JSON.parse(readFileSync(configPath, "utf-8")) as Record<
349
+ string,
350
+ unknown
351
+ >)
352
+ : {};
353
+ writeFileSync(
354
+ configPath,
355
+ JSON.stringify({ ...existing, anonymousId: _anonymousId }, null, 2),
356
+ "utf-8",
357
+ );
358
+ chmodSync(configPath, 0o600);
359
+ } catch {
360
+ // File I/O failed — use in-memory fallback
361
+ _anonymousId = randomBytes(32).toString("hex");
362
+ }
363
+
364
+ return _anonymousId;
365
+ }
366
+
367
+ /** @internal — reset anonymous ID cache for testing only */
368
+ export function __resetAnonymousIdForTesting(): void {
369
+ _anonymousId = undefined;
370
+ }
@@ -44,6 +44,10 @@ import {
44
44
  } from "../utils/constants.js";
45
45
  import { ClientOptions } from "openai";
46
46
  import { parseCustomHeaders } from "../utils/stringUtils.js";
47
+ import {
48
+ getRemoteSettingsSync,
49
+ mergeRemoteSettings,
50
+ } from "./remoteSettingsService.js";
47
51
 
48
52
  /**
49
53
  * Default ConfigurationService implementation
@@ -101,19 +105,25 @@ export class ConfigurationService {
101
105
  };
102
106
  }
103
107
 
108
+ // Merge remote settings (highest priority: Remote > Local > Project > User)
109
+ const remoteSettings = getRemoteSettingsSync();
110
+ const finalConfig = remoteSettings
111
+ ? mergeRemoteSettings(mergedConfig, remoteSettings)
112
+ : mergedConfig;
113
+
104
114
  // Success case
105
- this.currentConfiguration = mergedConfig;
115
+ this.currentConfiguration = finalConfig;
106
116
 
107
117
  // Set environment variables from merged config and inject system variables
108
118
  const env = {
109
- ...(mergedConfig.env || {}),
119
+ ...(finalConfig.env || {}),
110
120
  WAVE_PROJECT_DIR: workdir,
111
121
  };
112
122
  this.setEnvironmentVars(env);
113
- mergedConfig.env = env;
123
+ finalConfig.env = env;
114
124
 
115
125
  return {
116
- configuration: mergedConfig,
126
+ configuration: finalConfig,
117
127
  success: true,
118
128
  sourcePath: "merged configuration",
119
129
  warnings: validation.warnings,
@@ -461,8 +471,7 @@ export class ConfigurationService {
461
471
  }
462
472
 
463
473
  // Resolve custom headers from environment: env (settings.json) > process.env
464
- const envCustomHeaders =
465
- process.env.WAVE_CUSTOM_HEADERS || process.env.WAVE_CUSTOM_HEADERS || "";
474
+ const envCustomHeaders = process.env.WAVE_CUSTOM_HEADERS || "";
466
475
  const parsedEnvHeaders = parseCustomHeaders(envCustomHeaders);
467
476
 
468
477
  // Merge headers: env headers < options < override
@@ -497,19 +506,16 @@ export class ConfigurationService {
497
506
  maxTokens?: number,
498
507
  permissionMode?: PermissionMode,
499
508
  ): ModelConfig {
500
- // Resolve agent model: override > options > env (settings.json) > process.env
509
+ // Resolve agent model: override > options > process.env (includes settings.json env)
501
510
  const resolvedAgentModel =
502
511
  model ||
503
512
  this.options.model ||
504
513
  process.env.WAVE_MODEL ||
505
- process.env.WAVE_MODEL;
514
+ this.currentConfiguration?.model;
506
515
 
507
- // Resolve fast model: override > options > env (settings.json) > process.env
516
+ // Resolve fast model: override > options > process.env (includes settings.json env)
508
517
  const resolvedFastModel =
509
- fastModel ||
510
- this.options.fastModel ||
511
- process.env.WAVE_FAST_MODEL ||
512
- process.env.WAVE_FAST_MODEL;
518
+ fastModel || this.options.fastModel || process.env.WAVE_FAST_MODEL;
513
519
 
514
520
  // Validate required fields
515
521
  if (!resolvedAgentModel) {
@@ -572,8 +578,7 @@ export class ConfigurationService {
572
578
  }
573
579
 
574
580
  // Try env (settings.json) first, then process.env
575
- const envMaxInputTokens =
576
- process.env.WAVE_MAX_INPUT_TOKENS || process.env.WAVE_MAX_INPUT_TOKENS;
581
+ const envMaxInputTokens = process.env.WAVE_MAX_INPUT_TOKENS;
577
582
  if (envMaxInputTokens) {
578
583
  const parsed = parseInt(envMaxInputTokens, 10);
579
584
  if (!isNaN(parsed)) {
@@ -677,8 +682,7 @@ export class ConfigurationService {
677
682
  }
678
683
 
679
684
  // Try env (settings.json) first, then process.env
680
- const envMaxOutputTokens =
681
- process.env.WAVE_MAX_OUTPUT_TOKENS || process.env.WAVE_MAX_OUTPUT_TOKENS;
685
+ const envMaxOutputTokens = process.env.WAVE_MAX_OUTPUT_TOKENS;
682
686
  if (envMaxOutputTokens) {
683
687
  const parsed = parseInt(envMaxOutputTokens, 10);
684
688
  if (!isNaN(parsed) && parsed > 0) {
@@ -695,6 +699,28 @@ export class ConfigurationService {
695
699
  */
696
700
  setModel(model: string): void {
697
701
  this.options.model = model;
702
+ this.persistModelToSettings(model);
703
+ }
704
+
705
+ private async persistModelToSettings(model: string): Promise<void> {
706
+ const configPath = getUserConfigPaths()[0]; // ~/.wave/settings.json
707
+ const configDir = path.dirname(configPath);
708
+ if (!existsSync(configDir)) {
709
+ await fs.mkdir(configDir, { recursive: true });
710
+ }
711
+
712
+ let config: WaveConfiguration = {};
713
+ if (existsSync(configPath)) {
714
+ try {
715
+ const content = await fs.readFile(configPath, "utf-8");
716
+ config = JSON.parse(content);
717
+ } catch {
718
+ // Start fresh if corrupted
719
+ }
720
+ }
721
+
722
+ config.model = model;
723
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
698
724
  }
699
725
 
700
726
  /**
@@ -704,12 +730,16 @@ export class ConfigurationService {
704
730
  const models = new Set<string>();
705
731
 
706
732
  // Add current model from options or environment
707
- const currentModel =
708
- this.options.model || process.env.WAVE_MODEL || process.env.WAVE_MODEL;
733
+ const currentModel = this.options.model || process.env.WAVE_MODEL;
709
734
  if (currentModel) {
710
735
  models.add(currentModel);
711
736
  }
712
737
 
738
+ // Persisted model from settings (includes remote-merged)
739
+ if (this.currentConfiguration?.model) {
740
+ models.add(this.currentConfiguration.model);
741
+ }
742
+
713
743
  // Add models from merged configuration
714
744
  if (this.currentConfiguration?.models) {
715
745
  Object.keys(this.currentConfiguration.models).forEach((model) => {
@@ -1139,6 +1169,7 @@ export function loadWaveConfigFromFile(
1139
1169
  permissions: config.permissions || undefined,
1140
1170
  enabledPlugins: config.enabledPlugins || undefined,
1141
1171
  language: config.language || undefined,
1172
+ model: config.model || undefined,
1142
1173
  autoMemoryEnabled:
1143
1174
  config.autoMemoryEnabled !== undefined
1144
1175
  ? config.autoMemoryEnabled
@@ -1268,6 +1299,11 @@ export function loadMergedWaveConfig(
1268
1299
  mergedConfig.language = config.language;
1269
1300
  }
1270
1301
 
1302
+ // Merge model (last one wins)
1303
+ if (config.model !== undefined) {
1304
+ mergedConfig.model = config.model;
1305
+ }
1306
+
1271
1307
  // Merge autoMemoryEnabled (last one wins)
1272
1308
  if (config.autoMemoryEnabled !== undefined) {
1273
1309
  mergedConfig.autoMemoryEnabled = config.autoMemoryEnabled;
@@ -1316,6 +1352,7 @@ export function loadMergedWaveConfig(
1316
1352
  ? mergedConfig.enabledPlugins
1317
1353
  : undefined,
1318
1354
  language: mergedConfig.language,
1355
+ model: mergedConfig.model,
1319
1356
  autoMemoryEnabled: mergedConfig.autoMemoryEnabled,
1320
1357
  marketplaces:
1321
1358
  mergedConfig.marketplaces &&
@@ -22,6 +22,7 @@ import type { MemoryRuleManager } from "../managers/MemoryRuleManager.js";
22
22
  import type { LiveConfigManager } from "../managers/liveConfigManager.js";
23
23
  import type { TaskManager } from "./taskManager.js";
24
24
  import type { PermissionManager } from "../managers/permissionManager.js";
25
+ import { remoteSettingsService } from "./remoteSettingsService.js";
25
26
 
26
27
  export interface InitializationContext {
27
28
  skillManager: SkillManager;
@@ -288,6 +289,18 @@ export class InitializationService {
288
289
  // Don't throw error to prevent app startup failure - continue without live reload
289
290
  }
290
291
 
292
+ // Initialize remote settings (fetch server-managed config)
293
+ try {
294
+ const phaseStart = performance.now();
295
+ await remoteSettingsService.initialize();
296
+ logger?.debug(
297
+ `Initialization Phase [Remote Settings] took ${(performance.now() - phaseStart).toFixed(2)}ms`,
298
+ );
299
+ } catch (error) {
300
+ logger?.error("Failed to initialize remote settings:", error);
301
+ // Don't throw error to prevent app startup failure - continue without remote settings
302
+ }
303
+
291
304
  // Memory is lazy-cached on first getCombinedMemoryContent call
292
305
  // No explicit loading needed during initialization
293
306
 
@@ -14,7 +14,6 @@ import {
14
14
  parseAgentFile,
15
15
  type SubagentConfiguration,
16
16
  } from "../utils/subagentParser.js";
17
- import { resolveMcpConfig } from "../managers/mcpManager.js";
18
17
  import { logger } from "../utils/globalLogger.js";
19
18
 
20
19
  export class PluginLoader {
@@ -143,7 +142,8 @@ export class PluginLoader {
143
142
  const mcpPath = path.join(pluginPath, ".mcp.json");
144
143
  try {
145
144
  const content = await fs.readFile(mcpPath, "utf-8");
146
- return resolveMcpConfig(JSON.parse(content)) as McpConfig;
145
+ // Return raw config — let McpManager resolve templates and capture originalUrl
146
+ return JSON.parse(content) as McpConfig;
147
147
  } catch {
148
148
  return undefined;
149
149
  }
@@ -0,0 +1,314 @@
1
+ import * as fs from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ import { authService } from "./authService.js";
6
+ import type {
7
+ RemoteSettingsCache,
8
+ RemoteSettingsFetchResult,
9
+ RemoteSettingsResponse,
10
+ } from "../types/configuration.js";
11
+ import type { WaveConfiguration } from "../types/configuration.js";
12
+ import type { HookEvent, HookEventConfig } from "../types/hooks.js";
13
+ import { logger } from "../utils/globalLogger.js";
14
+
15
+ const CACHE_FILE = path.join(homedir(), ".wave", "remote-settings.json");
16
+ const POLLING_INTERVAL_MS = 60 * 60 * 1000; // 60 minutes
17
+ const FETCH_TIMEOUT_MS = 10_000;
18
+
19
+ let _cachedSettings: RemoteSettingsCache | null = null;
20
+ let _pollingTimer: ReturnType<typeof setInterval> | null = null;
21
+
22
+ function loadCacheFromDisk(): void {
23
+ try {
24
+ if (!fs.existsSync(CACHE_FILE)) {
25
+ return;
26
+ }
27
+ const raw = fs.readFileSync(CACHE_FILE, "utf-8");
28
+ const parsed: RemoteSettingsCache = JSON.parse(raw);
29
+ _cachedSettings = parsed;
30
+ logger.debug("remoteSettings: loaded cache from disk", {
31
+ checksum: parsed.checksum,
32
+ });
33
+ } catch (err) {
34
+ logger.debug("remoteSettings: failed to load cache from disk", { err });
35
+ }
36
+ }
37
+
38
+ function writeCacheToDisk(): void {
39
+ if (!_cachedSettings) {
40
+ return;
41
+ }
42
+ try {
43
+ const dir = path.dirname(CACHE_FILE);
44
+ if (!fs.existsSync(dir)) {
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ }
47
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(_cachedSettings, null, 2), {
48
+ mode: 0o600,
49
+ });
50
+ logger.debug("remoteSettings: wrote cache to disk", {
51
+ checksum: _cachedSettings.checksum,
52
+ });
53
+ } catch (err) {
54
+ logger.debug("remoteSettings: failed to write cache to disk", { err });
55
+ }
56
+ }
57
+
58
+ function removeCacheFromDisk(): void {
59
+ try {
60
+ if (fs.existsSync(CACHE_FILE)) {
61
+ fs.unlinkSync(CACHE_FILE);
62
+ }
63
+ } catch (err) {
64
+ logger.debug("remoteSettings: failed to remove cache file", { err });
65
+ }
66
+ }
67
+
68
+ async function fetchRemoteSettings(): Promise<RemoteSettingsFetchResult> {
69
+ if (!authService.isSSOAuthenticated()) {
70
+ logger.debug("remoteSettings: skipping fetch — not SSO authenticated");
71
+ return { success: false, error: "Not SSO authenticated" };
72
+ }
73
+
74
+ const token = authService.getSSOToken();
75
+ const serverUrl = authService.getServerUrl();
76
+ if (!token || !serverUrl) {
77
+ return { success: false, error: "Missing SSO token or server URL" };
78
+ }
79
+
80
+ const headers: Record<string, string> = {
81
+ Authorization: `Bearer ${token}`,
82
+ };
83
+ if (_cachedSettings?.checksum) {
84
+ headers["If-None-Match"] = _cachedSettings.checksum;
85
+ }
86
+
87
+ try {
88
+ const response = await fetch(`${serverUrl}/api/wave/settings`, {
89
+ method: "GET",
90
+ headers,
91
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
92
+ });
93
+
94
+ if (response.status === 304) {
95
+ logger.debug("remoteSettings: 304 unchanged", {
96
+ checksum: _cachedSettings?.checksum,
97
+ });
98
+ return {
99
+ success: true,
100
+ settings: _cachedSettings!.settings,
101
+ checksum: _cachedSettings!.checksum,
102
+ };
103
+ }
104
+
105
+ if (response.status === 404) {
106
+ logger.debug("remoteSettings: 404 not configured — clearing stale cache");
107
+ _cachedSettings = null;
108
+ removeCacheFromDisk();
109
+ return { success: true, notConfigured: true, settings: null };
110
+ }
111
+
112
+ if (!response.ok) {
113
+ const body = await response.text().catch(() => "");
114
+ logger.debug("remoteSettings: fetch failed", {
115
+ status: response.status,
116
+ body: body.slice(0, 200),
117
+ });
118
+ return {
119
+ success: false,
120
+ error: `HTTP ${response.status}`,
121
+ settings: _cachedSettings?.settings ?? null,
122
+ };
123
+ }
124
+
125
+ const data = (await response.json()) as RemoteSettingsResponse;
126
+ _cachedSettings = {
127
+ uuid: data.uuid,
128
+ checksum: data.checksum,
129
+ settings: data.settings,
130
+ fetchedAt: new Date().toISOString(),
131
+ };
132
+ writeCacheToDisk();
133
+ logger.debug("remoteSettings: fetched new settings", {
134
+ checksum: data.checksum,
135
+ });
136
+ return {
137
+ success: true,
138
+ settings: data.settings,
139
+ checksum: data.checksum,
140
+ };
141
+ } catch (err) {
142
+ logger.debug("remoteSettings: network error, using cache", { err });
143
+ return {
144
+ success: false,
145
+ error: err instanceof Error ? err.message : String(err),
146
+ settings: _cachedSettings?.settings ?? null,
147
+ };
148
+ }
149
+ }
150
+
151
+ function startPolling(): void {
152
+ if (_pollingTimer) {
153
+ return;
154
+ }
155
+ _pollingTimer = setInterval(async () => {
156
+ try {
157
+ await fetchRemoteSettings();
158
+ } catch (err) {
159
+ logger.debug("remoteSettings: polling fetch error", { err });
160
+ }
161
+ }, POLLING_INTERVAL_MS);
162
+ _pollingTimer.unref();
163
+ }
164
+
165
+ export function initialize(): void {
166
+ loadCacheFromDisk();
167
+ // Fire-and-forget the initial fetch, then start background polling
168
+ fetchRemoteSettings()
169
+ .then(() => startPolling())
170
+ .catch((err) => {
171
+ logger.debug("remoteSettings: initial fetch failed", { err });
172
+ startPolling();
173
+ });
174
+ }
175
+
176
+ export function getRemoteSettingsSync(): WaveConfiguration | null {
177
+ return _cachedSettings?.settings ?? null;
178
+ }
179
+
180
+ export async function refresh(): Promise<RemoteSettingsFetchResult> {
181
+ // Clear in-memory so we force a fresh fetch
182
+ _cachedSettings = null;
183
+ removeCacheFromDisk();
184
+ return fetchRemoteSettings();
185
+ }
186
+
187
+ export function clear(): void {
188
+ _cachedSettings = null;
189
+ removeCacheFromDisk();
190
+ if (_pollingTimer) {
191
+ clearInterval(_pollingTimer);
192
+ _pollingTimer = null;
193
+ }
194
+ }
195
+
196
+ export function shutdown(): void {
197
+ if (_pollingTimer) {
198
+ clearInterval(_pollingTimer);
199
+ _pollingTimer = null;
200
+ }
201
+ }
202
+
203
+ function dedupe(arr: string[]): string[] {
204
+ return [...new Set(arr)];
205
+ }
206
+
207
+ function mergeHooks(
208
+ local: Partial<Record<HookEvent, HookEventConfig[]>> | undefined,
209
+ remote: Partial<Record<HookEvent, HookEventConfig[]>> | undefined,
210
+ ): Partial<Record<HookEvent, HookEventConfig[]>> | undefined {
211
+ if (!remote && !local) {
212
+ return undefined;
213
+ }
214
+ if (!remote) {
215
+ return local;
216
+ }
217
+ if (!local) {
218
+ return remote;
219
+ }
220
+ const merged: Partial<Record<HookEvent, HookEventConfig[]>> = { ...local };
221
+ for (const [event, remoteHooks] of Object.entries(remote)) {
222
+ const localHooks = merged[event as HookEvent] ?? [];
223
+ // Concatenate + dedupe by JSON serialization
224
+ const combined = [...localHooks, ...(remoteHooks ?? [])];
225
+ const seen = new Set<string>();
226
+ merged[event as HookEvent] = combined.filter((h) => {
227
+ const key = JSON.stringify(h);
228
+ if (seen.has(key)) {
229
+ return false;
230
+ }
231
+ seen.add(key);
232
+ return true;
233
+ });
234
+ }
235
+ return merged;
236
+ }
237
+
238
+ export function mergeRemoteSettings(
239
+ localMerged: WaveConfiguration,
240
+ remote: WaveConfiguration,
241
+ ): WaveConfiguration {
242
+ const result: WaveConfiguration = { ...localMerged };
243
+
244
+ // env: merge by key, remote wins per-key
245
+ if (remote.env || localMerged.env) {
246
+ result.env = { ...localMerged.env, ...remote.env };
247
+ }
248
+
249
+ // permissions
250
+ if (remote.permissions || localMerged.permissions) {
251
+ const lp = localMerged.permissions ?? {};
252
+ const rp = remote.permissions ?? {};
253
+ result.permissions = {
254
+ // allow: concatenate + dedupe
255
+ allow:
256
+ lp.allow || rp.allow
257
+ ? dedupe([...(lp.allow ?? []), ...(rp.allow ?? [])])
258
+ : undefined,
259
+ // deny: concatenate + dedupe
260
+ deny:
261
+ lp.deny || rp.deny
262
+ ? dedupe([...(lp.deny ?? []), ...(rp.deny ?? [])])
263
+ : undefined,
264
+ // permissionMode: remote wins (scalar)
265
+ permissionMode: rp.permissionMode ?? lp.permissionMode,
266
+ // additionalDirectories: concatenate + dedupe
267
+ additionalDirectories:
268
+ lp.additionalDirectories || rp.additionalDirectories
269
+ ? dedupe([
270
+ ...(lp.additionalDirectories ?? []),
271
+ ...(rp.additionalDirectories ?? []),
272
+ ])
273
+ : undefined,
274
+ };
275
+ // Clean up undefined keys
276
+ if (!result.permissions.allow) delete result.permissions.allow;
277
+ if (!result.permissions.deny) delete result.permissions.deny;
278
+ if (!result.permissions.permissionMode)
279
+ delete result.permissions.permissionMode;
280
+ if (!result.permissions.additionalDirectories)
281
+ delete result.permissions.additionalDirectories;
282
+ }
283
+
284
+ // hooks: concatenate per-event
285
+ result.hooks = mergeHooks(localMerged.hooks, remote.hooks);
286
+
287
+ // Scalar / last-write-wins fields: remote wins
288
+ if (remote.language !== undefined) result.language = remote.language;
289
+ if (remote.model !== undefined) result.model = remote.model;
290
+ if (remote.autoMemoryEnabled !== undefined)
291
+ result.autoMemoryEnabled = remote.autoMemoryEnabled;
292
+ if (remote.autoMemoryFrequency !== undefined)
293
+ result.autoMemoryFrequency = remote.autoMemoryFrequency;
294
+ if (remote.models !== undefined) result.models = remote.models;
295
+ if (remote.marketplaces !== undefined)
296
+ result.marketplaces = remote.marketplaces;
297
+ if (remote.enabledPlugins !== undefined)
298
+ result.enabledPlugins = remote.enabledPlugins;
299
+
300
+ return result;
301
+ }
302
+
303
+ /**
304
+ * Singleton object for consumers that prefer a namespace-style import.
305
+ * Usage: import { remoteSettingsService } from "./remoteSettingsService.js"
306
+ */
307
+ export const remoteSettingsService = {
308
+ initialize,
309
+ getRemoteSettingsSync,
310
+ refresh,
311
+ clear,
312
+ shutdown,
313
+ mergeRemoteSettings,
314
+ } as const;
@@ -18,7 +18,10 @@ import type {
18
18
  LogRecordExporter,
19
19
  ReadableLogRecord,
20
20
  } from "@opentelemetry/sdk-logs";
21
- import { AuthService } from "../services/authService.js";
21
+ import {
22
+ AuthService,
23
+ getOrCreateAnonymousId,
24
+ } from "../services/authService.js";
22
25
 
23
26
  // Lazy-loaded OTEL modules — only imported when telemetry is initialized
24
27
  let sdkNode: typeof import("@opentelemetry/sdk-node") | undefined;
@@ -471,8 +474,11 @@ export function isInitialized(): boolean {
471
474
  export { JsonlSpanExporter, JsonlLogExporter };
472
475
 
473
476
  /**
474
- * Get telemetry attributes based on the authenticated SSO user.
475
- * Returns user.id and user.email when SSO authenticated, empty object otherwise.
477
+ * Get telemetry attributes for the current session.
478
+ *
479
+ * Priority:
480
+ * 1. SSO authenticated → server-provided user.id + user.email
481
+ * 2. Not authenticated → persistent anonymous ID from ~/.wave/config.json
476
482
  */
477
483
  export function getTelemetryAttributes(): Record<string, string> {
478
484
  try {
@@ -487,5 +493,7 @@ export function getTelemetryAttributes(): Record<string, string> {
487
493
  } catch {
488
494
  // AuthService not available or not authenticated
489
495
  }
490
- return {};
496
+
497
+ // Fallback to anonymous ID
498
+ return { "user.id": getOrCreateAnonymousId() };
491
499
  }
@@ -24,8 +24,6 @@ export interface ToolDef {
24
24
  params: Record<string, unknown>,
25
25
  context: ToolContext,
26
26
  ) => string;
27
- shouldDefer?: boolean;
28
- alwaysLoad?: boolean;
29
27
  additionalProperties?: boolean;
30
28
  }
31
29
 
@@ -59,7 +57,5 @@ export function buildTool(def: ToolDef): ToolPlugin {
59
57
  execute: def.execute,
60
58
  prompt: promptFn,
61
59
  formatCompactParams: def.formatCompactParams,
62
- shouldDefer: def.shouldDefer ?? false,
63
- alwaysLoad: def.alwaysLoad ?? false,
64
60
  };
65
61
  }
@@ -48,7 +48,6 @@ Returns a job ID you can pass to CronDelete.`;
48
48
 
49
49
  export const cronCreateTool: ToolPlugin = {
50
50
  name: CRON_CREATE_TOOL_NAME,
51
- shouldDefer: true,
52
51
  config: {
53
52
  type: "function",
54
53
  function: {
@@ -7,7 +7,6 @@ const CRON_DELETE_PROMPT = `Cancel a cron job previously scheduled with CronCrea
7
7
 
8
8
  export const cronDeleteTool: ToolPlugin = {
9
9
  name: CRON_DELETE_TOOL_NAME,
10
- shouldDefer: true,
11
10
  config: {
12
11
  type: "function",
13
12
  function: {