agentsw 0.9.2 → 0.10.1

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.
@@ -1,35 +1,16 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
2
  import YAML from "yaml";
4
- import { backupFile, expandHome, home, localAppDataDir, readTextIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, readTextIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { dshCredentialsFile, dshDir, dshSettingsFile } from "../app-paths.js";
5
5
  import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "../provider-identity.js";
6
6
  import { transactionalTarget } from "../target-transaction.js";
7
7
  import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
8
- import { apiValue, classifyApi, mergeModels, sdkBaseUrl } from "./wire.js";
9
- /** `$DSH_HOME`, or `~/.dsh` (packages/util/home-paths `resolveDshHome`). */
10
- function dshHome() {
11
- const env = process.env.DSH_HOME?.trim();
12
- return env ? expandHome(env) : process.platform === "win32" ? localAppDataDir("dsh") : path.join(home, ".dsh");
13
- }
14
- /**
15
- * Settings document: `<harness home>/settings.yaml` by default; the extension
16
- * picks the format, so an existing `.yml`/`.json` document is written in place.
17
- */
18
- function settingsFile() {
19
- const dir = dshHome();
20
- for (const name of ["settings.yaml", "settings.yml", "settings.json"]) {
21
- const file = path.join(dir, name);
22
- if (fs.existsSync(file))
23
- return file;
24
- }
25
- return path.join(dir, "settings.yaml");
26
- }
27
- const credentialsFile = () => path.join(dshHome(), ".credentials.yaml");
8
+ import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl } from "./wire.js";
28
9
  /** Layout version of `.credentials.yaml` this build reads and writes. */
29
10
  const CREDENTIALS_VERSION = 1;
30
11
  /** Thinking levels a `reasoningEfforts` entry may offer (llm-pi-ai THINKING_LEVEL_GATE). */
31
12
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
32
- /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
13
+ /** Per-model keys this adapter writes; one a metadata-bearing entry stops emitting is cleared, not inherited. */
33
14
  const OWNED_MODEL_KEYS = ["id", "name", "contextWindow", "maxTokens", "input", "reasoningEfforts"];
34
15
  /** models.dev efforts -> `{ level: wire spelling }`; an unoffered level is omitted, not nulled. */
35
16
  function reasoningEfforts(efforts) {
@@ -82,20 +63,23 @@ export const dsh = transactionalTarget({
82
63
  id: "dsh",
83
64
  name: "DeepSeek Harness",
84
65
  protocols: ["openai", "anthropic"],
85
- configPaths: [settingsFile(), credentialsFile()],
86
- detect: () => fs.existsSync(dshHome()),
66
+ get configPaths() {
67
+ return [dshSettingsFile(), dshCredentialsFile()];
68
+ },
69
+ detect: () => fs.existsSync(dshDir()),
87
70
  async apply(provider) {
88
71
  const notes = [];
89
- const file = settingsFile();
72
+ const file = dshSettingsFile();
90
73
  const doc = parseSettings(file);
91
74
  const at = ["llm-pi-ai", "providers", provider.id];
92
75
  const prev = doc.getIn(at)?.toJSON?.();
93
76
  const ref = managedCredentialRef(provider.id);
77
+ const baseURL = sdkBaseUrl(provider.protocol, provider.baseUrl);
94
78
  const entry = {
95
79
  displayName: provider.name,
96
80
  apiKeyEnv: ref, // credential reference, never the secret
97
- api: apiValue(provider.protocol, provider.openaiApi, prev?.api),
98
- baseURL: sdkBaseUrl(provider.protocol, provider.baseUrl),
81
+ api: apiValue(provider.protocol, provider.openaiApi, prev?.api ?? entryApi(prev ?? {})),
82
+ baseURL,
99
83
  };
100
84
  if (YAML.isMap(doc.getIn(at))) {
101
85
  // Key-by-key: compat, headers, defaultInput, retryPolicy and their comments are the user's.
@@ -147,7 +131,7 @@ export const dsh = transactionalTarget({
147
131
  },
148
132
  async prune(provider) {
149
133
  const notes = [];
150
- const file = settingsFile();
134
+ const file = dshSettingsFile();
151
135
  // Settings must be understood before any credential can be removed.
152
136
  const doc = parseSettings(file);
153
137
  const at = ["llm-pi-ai", "providers", provider.id];
@@ -181,7 +165,7 @@ export const dsh = transactionalTarget({
181
165
  return { app: this.id, changed: droppedCredential ? [file, droppedCredential] : [file], notes };
182
166
  },
183
167
  current() {
184
- const text = readTextIfExists(settingsFile());
168
+ const text = readTextIfExists(dshSettingsFile());
185
169
  if (!text)
186
170
  return undefined;
187
171
  try {
@@ -196,7 +180,7 @@ export const dsh = transactionalTarget({
196
180
  }
197
181
  },
198
182
  candidates() {
199
- const text = readTextIfExists(settingsFile());
183
+ const text = readTextIfExists(dshSettingsFile());
200
184
  if (!text)
201
185
  return [];
202
186
  let parsed;
@@ -215,7 +199,8 @@ export const dsh = transactionalTarget({
215
199
  return Object.entries(providers).flatMap(([id, entry]) => {
216
200
  if (!entry || typeof entry.baseURL !== "string")
217
201
  return [];
218
- const wire = classifyApi(entry.api);
202
+ // llm-pi-ai declares the wire at provider level or, agreeing, on every model.
203
+ const wire = classifyApi(entryApi(entry));
219
204
  if (!wire)
220
205
  return [];
221
206
  const keyEnv = typeof entry.apiKeyEnv === "string" && entry.apiKeyEnv ? entry.apiKeyEnv : undefined;
@@ -247,7 +232,7 @@ export const dsh = transactionalTarget({
247
232
  /** Read the managed store's `refs` section; an unreadable document yields no keys. */
248
233
  function readCredentialRefs() {
249
234
  try {
250
- const doc = parseCredentials(credentialsFile());
235
+ const doc = parseCredentials(dshCredentialsFile());
251
236
  const parsed = doc.toJS();
252
237
  return (doc.get("version") === undefined ? parsed : parsed.refs ?? {});
253
238
  }
@@ -285,7 +270,7 @@ function parseCredentials(file) {
285
270
  * instead of being re-rooted one level deeper. Returns the file.
286
271
  */
287
272
  function writeCredential(ref, apiKey, notes, oldRef, referenced) {
288
- const file = credentialsFile();
273
+ const file = dshCredentialsFile();
289
274
  const doc = parseCredentials(file);
290
275
  if (doc.get("version") === undefined) {
291
276
  const refs = doc.contents;
@@ -311,7 +296,7 @@ function writeCredential(ref, apiKey, notes, oldRef, referenced) {
311
296
  }
312
297
  /** Drop only managed references that no surviving route uses. */
313
298
  function deleteCredentials(refs, notes) {
314
- const file = credentialsFile();
299
+ const file = dshCredentialsFile();
315
300
  if (readTextIfExists(file) === undefined)
316
301
  return undefined;
317
302
  const doc = parseCredentials(file);
@@ -1,20 +1,14 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
2
  import YAML from "yaml";
4
- import { backupFile, home, localAppDataDir, readTextIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, readTextIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { hermesConfigFile, hermesDir, hermesEnvFile } from "../app-paths.js";
5
5
  import { envAssignments, removeEnvAssignments, upsertEnvAssignment } from "../envfile.js";
6
6
  import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "../provider-identity.js";
7
7
  import { transactionalTarget } from "../target-transaction.js";
8
8
  import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
9
9
  import { sdkBaseUrl } from "./wire.js";
10
10
  function hermesHome() {
11
- const env = process.env.HERMES_HOME?.trim();
12
- if (env)
13
- return env;
14
- if (process.platform === "win32") {
15
- return localAppDataDir("hermes");
16
- }
17
- return path.join(home, ".hermes");
11
+ return hermesDir();
18
12
  }
19
13
  function parseConfig(file, text) {
20
14
  const doc = parseYamlMapping(file, text);
@@ -58,12 +52,11 @@ export const hermes = transactionalTarget({
58
52
  id: "hermes",
59
53
  name: "Hermes",
60
54
  protocols: ["openai", "anthropic"],
61
- configPaths: [path.join(hermesHome(), "config.yaml"), path.join(hermesHome(), ".env")],
55
+ configPaths: [hermesConfigFile(), hermesEnvFile()],
62
56
  detect: () => fs.existsSync(hermesHome()),
63
57
  async apply(provider) {
64
- const home = hermesHome();
65
- const configFile = path.join(home, "config.yaml");
66
- const envFile = path.join(home, ".env");
58
+ const configFile = hermesConfigFile();
59
+ const envFile = hermesEnvFile();
67
60
  const notes = [];
68
61
  const text = readTextIfExists(configFile);
69
62
  const doc = parseConfig(configFile, text);
@@ -133,9 +126,8 @@ export const hermes = transactionalTarget({
133
126
  return { app: this.id, changed: [configFile, envFile], notes };
134
127
  },
135
128
  async prune(provider) {
136
- const home = hermesHome();
137
- const configFile = path.join(home, "config.yaml");
138
- const envFile = path.join(home, ".env");
129
+ const configFile = hermesConfigFile();
130
+ const envFile = hermesEnvFile();
139
131
  const doc = parseConfig(configFile, readTextIfExists(configFile));
140
132
  const at = ["providers", provider.id];
141
133
  const hasProvider = doc.hasIn(at);
@@ -176,7 +168,7 @@ export const hermes = transactionalTarget({
176
168
  return changed.length ? { app: this.id, changed, notes } : { app: this.id, changed, notes, skipped: `no providers.${provider.id} entry or managed credential` };
177
169
  },
178
170
  current() {
179
- const text = readTextIfExists(path.join(hermesHome(), "config.yaml"));
171
+ const text = readTextIfExists(hermesConfigFile());
180
172
  if (!text)
181
173
  return undefined;
182
174
  try {
@@ -190,8 +182,7 @@ export const hermes = transactionalTarget({
190
182
  }
191
183
  },
192
184
  candidates() {
193
- const hh = hermesHome();
194
- const text = readTextIfExists(path.join(hh, "config.yaml"));
185
+ const text = readTextIfExists(hermesConfigFile());
195
186
  if (!text)
196
187
  return [];
197
188
  let parsed;
@@ -203,7 +194,7 @@ export const hermes = transactionalTarget({
203
194
  }
204
195
  if (!parsed?.providers)
205
196
  return [];
206
- const envFile = path.join(hh, ".env");
197
+ const envFile = hermesEnvFile();
207
198
  const envText = readTextIfExists(envFile) ?? "";
208
199
  const values = new Map(envAssignments(envFile, envText).map((assignment) => [assignment.name, assignment.value]));
209
200
  const readEnv = (key) => process.env[key] ?? values.get(key);
@@ -1,16 +1,18 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
2
  import YAML from "yaml";
4
- import { backupFile, home, readTextIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, readTextIfExists, writeFileAtomic } from "../fsutil.js";
4
+ import { ompAgentDir, ompModelsFiles } from "../app-paths.js";
5
5
  import { looksLikeEnvName } from "../slug.js";
6
6
  import { transactionalTarget } from "../target-transaction.js";
7
7
  import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
8
8
  import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
9
9
  /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
10
10
  const OWNED_MODEL_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
11
- const agentDir = path.join(home, ".omp", "agent");
12
- const modelsYml = path.join(agentDir, "models.yml");
13
- const modelsYaml = path.join(agentDir, "models.yaml");
11
+ /** omp has no directory override; the file set is shared with remove/rename. */
12
+ function modelFiles() {
13
+ const [yml, yaml] = ompModelsFiles();
14
+ return [yml, yaml];
15
+ }
14
16
  function parseModelsDocument(file, text) {
15
17
  const doc = parseYamlMapping(file, text);
16
18
  if (doc.hasIn(["providers"]) && !YAML.isMap(doc.getIn(["providers"]))) {
@@ -26,11 +28,14 @@ export const omp = transactionalTarget({
26
28
  id: "omp",
27
29
  name: "Oh My Pi",
28
30
  protocols: ["openai", "anthropic"],
29
- configPaths: [modelsYml],
30
- detect: () => fs.existsSync(path.join(home, ".omp")),
31
+ get configPaths() {
32
+ return [modelFiles()[0]];
33
+ },
34
+ detect: () => fs.existsSync(ompAgentDir()),
31
35
  async apply(provider) {
32
36
  const notes = [];
33
37
  // Respect an existing models.yaml if models.yml is absent (omp precedence: yml then yaml).
38
+ const [modelsYml, modelsYaml] = modelFiles();
34
39
  const file = !fs.existsSync(modelsYml) && fs.existsSync(modelsYaml) ? modelsYaml : modelsYml;
35
40
  const text = readTextIfExists(file);
36
41
  const doc = parseModelsDocument(file, text);
@@ -92,6 +97,7 @@ export const omp = transactionalTarget({
92
97
  return { app: this.id, changed: [file], notes };
93
98
  },
94
99
  async prune(provider) {
100
+ const [modelsYml, modelsYaml] = modelFiles();
95
101
  const file = fs.existsSync(modelsYml) ? modelsYml : fs.existsSync(modelsYaml) ? modelsYaml : undefined;
96
102
  if (!file)
97
103
  return { app: this.id, changed: [], notes: [], skipped: "no models.yml" };
@@ -109,6 +115,7 @@ export const omp = transactionalTarget({
109
115
  return { app: this.id, changed: [file], notes };
110
116
  },
111
117
  current() {
118
+ const [modelsYml, modelsYaml] = modelFiles();
112
119
  const file = fs.existsSync(modelsYml) ? modelsYml : fs.existsSync(modelsYaml) ? modelsYaml : undefined;
113
120
  if (!file)
114
121
  return undefined;
@@ -122,6 +129,7 @@ export const omp = transactionalTarget({
122
129
  }
123
130
  },
124
131
  candidates() {
132
+ const [modelsYml, modelsYaml] = modelFiles();
125
133
  const file = fs.existsSync(modelsYml) ? modelsYml : fs.existsSync(modelsYaml) ? modelsYaml : undefined;
126
134
  if (!file)
127
135
  return [];
@@ -1,27 +1,34 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
- import { appDataDir, backupFile, expandHome, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
- import { isJsonObject } from "../jsonc.js";
2
+ import { backupFile, readTextIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { isJsonObject, readJsoncObject, editJsoncObject } from "../jsonc.js";
4
+ import { opencodeConfigDirs, opencodeConfigFiles, opencodePrimaryConfigFile } from "../app-paths.js";
5
5
  import { transactionalTarget } from "../target-transaction.js";
6
- const customDir = process.env.OPENCODE_CONFIG_DIR?.trim();
7
- const configFile = customDir
8
- ? path.join(expandHome(customDir), "opencode.json")
9
- : path.join(appDataDir("opencode"), "opencode.json");
10
6
  /**
11
- * opencode: custom providers in ~/.config/opencode/opencode.json "provider" map,
12
- * backed by @ai-sdk/openai-compatible or @ai-sdk/anthropic npm loaders.
7
+ * opencode: custom providers in the "provider" map of its config document
8
+ * (opencode.json / opencode.jsonc / config.json under $OPENCODE_CONFIG_DIR or the
9
+ * default directory; $OPENCODE_CONFIG names one file explicitly), backed by
10
+ * @ai-sdk/openai-compatible or @ai-sdk/anthropic npm loaders. A JSONC round-trip
11
+ * keeps comments and every key agentsw does not own.
13
12
  */
14
13
  export const opencode = transactionalTarget({
15
14
  id: "opencode",
16
15
  name: "opencode",
17
16
  protocols: ["openai", "anthropic"],
18
- configPaths: [configFile],
19
- detect: () => fs.existsSync(path.dirname(configFile)),
17
+ get configPaths() {
18
+ return [opencodePrimaryConfigFile()];
19
+ },
20
+ detect: () => opencodeConfigFiles().some((file) => fs.existsSync(file)) ||
21
+ opencodeConfigDirs().some((dir) => fs.existsSync(dir)),
20
22
  async apply(provider) {
23
+ const configFile = opencodePrimaryConfigFile();
21
24
  const notes = [];
22
- const configValue = readJsonIfExists(configFile);
23
- const config = configValue === undefined ? { $schema: "https://opencode.ai/config.json" } : configValue;
24
- if (!isJsonObject(config) || (config.provider !== undefined && !isJsonObject(config.provider))) {
25
+ const existed = readTextIfExists(configFile) !== undefined;
26
+ const document = readJsoncObject(configFile);
27
+ // Only a missing file is initialized with the schema hint; an existing {} stays as-is.
28
+ const config = existed
29
+ ? { ...document.value }
30
+ : { $schema: "https://opencode.ai/config.json" };
31
+ if (config.provider !== undefined && !isJsonObject(config.provider)) {
25
32
  throw new Error(`${configFile}: expected config and provider to be JSON objects`);
26
33
  }
27
34
  const anthropic = provider.protocol === "anthropic";
@@ -88,26 +95,36 @@ export const opencode = transactionalTarget({
88
95
  };
89
96
  config.provider = providerMap;
90
97
  config.model = `${provider.id}/${provider.defaultModel}`;
91
- if (provider.smallModel)
98
+ if (provider.smallModel) {
92
99
  config.small_model = `${provider.id}/${provider.smallModel}`;
100
+ }
101
+ else if (typeof config.small_model === "string" && config.small_model.startsWith(`${provider.id}/`)) {
102
+ // This provider's own previous small model is obsolete; another provider's
103
+ // entry stays untouched, exactly as prune scopes its cleanup.
104
+ delete config.small_model;
105
+ }
93
106
  const backup = backupFile(configFile);
94
107
  if (backup)
95
108
  notes.push(`backup: ${backup}`);
96
- writeFileAtomic(configFile, JSON.stringify(config, null, 2) + "\n");
109
+ writeFileAtomic(configFile, editJsoncObject(document, config));
97
110
  return { app: this.id, changed: [configFile], notes };
98
111
  },
99
112
  async prune(provider) {
100
- const config = readJsonIfExists(configFile);
101
- if (config !== undefined && (!isJsonObject(config) || (config.provider !== undefined && !isJsonObject(config.provider)))) {
113
+ const configFile = opencodePrimaryConfigFile();
114
+ const document = readJsoncObject(configFile);
115
+ const config = { ...document.value };
116
+ if (config.provider !== undefined && !isJsonObject(config.provider)) {
102
117
  throw new Error(`${configFile}: expected config and provider to be JSON objects`);
103
118
  }
104
- const providerMap = config?.provider;
105
- if (!config || !providerMap?.[provider.id]) {
119
+ const providerMap = { ...config.provider };
120
+ if (!providerMap[provider.id]) {
106
121
  return { app: this.id, changed: [], notes: [], skipped: `no provider.${provider.id} entry` };
107
122
  }
108
123
  delete providerMap[provider.id];
109
124
  if (Object.keys(providerMap).length === 0)
110
125
  delete config.provider;
126
+ else
127
+ config.provider = providerMap;
111
128
  const notes = [];
112
129
  if (typeof config.model === "string" && config.model.startsWith(`${provider.id}/`)) {
113
130
  delete config.model;
@@ -119,16 +136,16 @@ export const opencode = transactionalTarget({
119
136
  const backup = backupFile(configFile);
120
137
  if (backup)
121
138
  notes.push(`backup: ${backup}`);
122
- writeFileAtomic(configFile, JSON.stringify(config, null, 2) + "\n");
139
+ writeFileAtomic(configFile, editJsoncObject(document, config));
123
140
  return { app: this.id, changed: [configFile], notes };
124
141
  },
125
142
  current() {
126
- const config = readJsonIfExists(configFile);
127
- return config?.model;
143
+ const config = readJsoncObject(opencodePrimaryConfigFile()).value;
144
+ return typeof config.model === "string" ? config.model : undefined;
128
145
  },
129
146
  candidates() {
130
- const config = readJsonIfExists(configFile);
131
- if (!config?.provider)
147
+ const config = readJsoncObject(opencodePrimaryConfigFile()).value;
148
+ if (!config.provider)
132
149
  return [];
133
150
  const activeProvider = config.model?.includes("/") ? config.model.split("/")[0] : undefined;
134
151
  const activeModel = activeProvider && config.model && config.model.includes("/") ? config.model.split("/").slice(1).join("/") : undefined;
@@ -1,11 +1,12 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { backupFile, home, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, writeFileAtomic } from "../fsutil.js";
4
+ import { piDir, piModelsFile, piSettingsFile } from "../app-paths.js";
4
5
  import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
5
6
  import { looksLikeEnvName } from "../slug.js";
6
7
  import { transactionalTarget } from "../target-transaction.js";
7
8
  import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
8
- /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
9
+ /** Per-model keys this adapter writes; one a metadata-bearing entry stops emitting is cleared, not inherited. */
9
10
  const OWNED_MODEL_KEYS = [
10
11
  "id",
11
12
  "name",
@@ -57,17 +58,15 @@ function thinkingLevelMap(efforts) {
57
58
  return map;
58
59
  }
59
60
  export function piStyleTarget(opts) {
60
- const resolveDir = () => {
61
- const env = process.env[opts.dirEnvVar];
62
- if (env)
63
- return env.startsWith("~") ? path.join(home, env.slice(1)) : env;
64
- return path.join(home, opts.configDirName);
65
- };
61
+ const id = opts.id;
62
+ const resolveDir = () => piDir(id);
66
63
  return transactionalTarget({
67
64
  id: opts.id,
68
65
  name: opts.name,
69
66
  protocols: ["openai", "anthropic"],
70
- configPaths: [path.join(resolveDir(), "models.json"), path.join(resolveDir(), "settings.json")],
67
+ get configPaths() {
68
+ return [piModelsFile(id), piSettingsFile(id)];
69
+ },
71
70
  detect: () => fs.existsSync(path.dirname(resolveDir())),
72
71
  async apply(provider) {
73
72
  const dir = resolveDir();
@@ -232,15 +231,5 @@ export function piStyleTarget(opts) {
232
231
  },
233
232
  });
234
233
  }
235
- export const pi = piStyleTarget({
236
- id: "pi",
237
- name: "pi",
238
- configDirName: ".pi/agent",
239
- dirEnvVar: "PI_CODING_AGENT_DIR",
240
- });
241
- export const prime = piStyleTarget({
242
- id: "prime",
243
- name: "prime-agent",
244
- configDirName: ".prime/agent",
245
- dirEnvVar: "PRIME_AGENT_CODING_AGENT_DIR",
246
- });
234
+ export const pi = piStyleTarget({ id: "pi", name: "pi" });
235
+ export const prime = piStyleTarget({ id: "prime", name: "prime-agent" });
@@ -69,12 +69,24 @@ export function entryApi(entry) {
69
69
  /**
70
70
  * Merge the model entries agentsw owns over the ones already in an app
71
71
  * config: per-model keys the adapter does not model (`compat`, per-model wire
72
- * overrides, ...) survive a re-sync, while an `owned` key the new entry no
73
- * longer carries is cleared instead of lingering as last sync's value — a
74
- * stale `thinkingLevelMap` beside `reasoning: false` is a state no fresh write
72
+ * overrides, ...) survive a re-sync, and an `owned` key the entry omits is
73
+ * cleared rather than lingering as last sync's value — a stale
74
+ * `thinkingLevelMap` beside `reasoning: false` is a state no fresh write
75
75
  * produces.
76
+ *
77
+ * That clearing is only meaningful for an entry that carries metadata at all.
78
+ * A model the store knows nothing about arrives as a bare `{ id }` — that is
79
+ * exactly what enrichment does with an id no catalog row matched — and every
80
+ * adapter still adds the derived capability in `derived`. Absence in such a
81
+ * stub says nothing about the model, so the config's existing values are kept:
82
+ * deleting them would throw away limits an earlier lookup filled in, or that a
83
+ * person typed by hand, every time a catalog lookup misses. Once an entry
84
+ * carries any owned metadata of its own, the same absence is a statement about
85
+ * the model and is applied as before.
76
86
  */
77
- export function mergeModels(previous, written, owned) {
87
+ export function mergeModels(previous, written, owned,
88
+ /** Owned keys every adapter derives instead of reading them from the model. */
89
+ derived = ["input"]) {
78
90
  const prev = new Map();
79
91
  if (Array.isArray(previous)) {
80
92
  for (const m of previous) {
@@ -82,14 +94,17 @@ export function mergeModels(previous, written, owned) {
82
94
  prev.set(m.id, m);
83
95
  }
84
96
  }
97
+ const informative = owned.filter((key) => key !== "id" && !derived.includes(key));
85
98
  return written.map((m) => {
86
99
  const old = prev.get(m.id);
87
100
  if (!old)
88
101
  return m;
89
102
  const kept = { ...old };
90
- for (const key of owned)
91
- if (!(key in m))
92
- delete kept[key];
103
+ const known = informative.some((key) => key in m);
104
+ if (known)
105
+ for (const key of owned)
106
+ if (!(key in m))
107
+ delete kept[key];
93
108
  return { ...kept, ...m };
94
109
  });
95
110
  }
@@ -1,13 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { appDataDir, backupFile, home, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
3
+ import { backupFile, readJsonIfExists, writeFileAtomic } from "../fsutil.js";
4
4
  import { isJsonObject } from "../jsonc.js";
5
+ import { workbuddyDir } from "../app-paths.js";
5
6
  import { localProviderId } from "../provider-identity.js";
6
7
  import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
7
8
  import { transactionalTarget } from "../target-transaction.js";
8
9
  function configDir() {
9
- return process.env.WORKBUDDY_CONFIG_DIR?.trim() || process.env.CODEBUDDY_CONFIG_DIR?.trim() ||
10
- (process.platform === "win32" ? appDataDir("workbuddy") : path.join(home, ".workbuddy"));
10
+ return workbuddyDir();
11
11
  }
12
12
  function isModel(row) {
13
13
  return isJsonObject(row) && typeof row.id === "string";
@@ -74,7 +74,9 @@ export const workbuddy = transactionalTarget({
74
74
  id: "workbuddy",
75
75
  name: "WorkBuddy",
76
76
  protocols: ["openai"],
77
- configPaths: [path.join(configDir(), "models.json"), path.join(configDir(), "settings.json")],
77
+ get configPaths() {
78
+ return [path.join(configDir(), "models.json"), path.join(configDir(), "settings.json")];
79
+ },
78
80
  detect: () => fs.existsSync(configDir()),
79
81
  async apply(provider) {
80
82
  const dir = configDir();