@dotdrelle/wiki-manager 0.15.41 → 0.15.43

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.
@@ -86,3 +86,98 @@ test('propagates an abort thrown by executeCall', async () => {
86
86
  /aborted/,
87
87
  );
88
88
  });
89
+
90
+ // Le tour paraissait figé pendant toute sa durée : la réponse n'apparaissait
91
+ // qu'une fois complète, parce que la boucle n'appelait que completeWithTools.
92
+ test('streams the answer when the caller asks for deltas', async () => {
93
+ const deltas = [];
94
+ const llm = {
95
+ async completeWithTools() { throw new Error('streamWithTools devait être préféré'); },
96
+ async streamWithTools({ onTextDelta }) {
97
+ onTextDelta('Le wiki ');
98
+ onTextDelta('contient 12 pages.');
99
+ return { content: 'Le wiki contient 12 pages.', tool_calls: [] };
100
+ },
101
+ };
102
+
103
+ const out = await runBoundedToolLoop({
104
+ llm,
105
+ tools: [],
106
+ executeCall: async () => 'unused',
107
+ onTextDelta: (delta) => deltas.push(delta),
108
+ });
109
+
110
+ assert.deepEqual(deltas, ['Le wiki ', 'contient 12 pages.']);
111
+ assert.equal(out.content, 'Le wiki contient 12 pages.');
112
+ });
113
+
114
+ test('keeps the non-streaming path when no delta callback is given', async () => {
115
+ let streamed = false;
116
+ const llm = {
117
+ async completeWithTools() { return { content: 'plain', tool_calls: [] }; },
118
+ async streamWithTools() { streamed = true; return { content: 'streamed', tool_calls: [] }; },
119
+ };
120
+
121
+ const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'unused' });
122
+
123
+ assert.equal(streamed, false, 'sans onTextDelta, rien ne doit changer');
124
+ assert.equal(out.content, 'plain');
125
+ });
126
+
127
+ test('discards text emitted by an iteration that ends in tool calls', async () => {
128
+ // Un modèle peut écrire un raisonnement puis décider d'appeler un outil. Ce
129
+ // texte est remplacé par le tour suivant : le laisser afficherait des
130
+ // paragraphes qui disparaissent, pire que pas de streaming du tout.
131
+ let round = 0;
132
+ const deltas = [];
133
+ let resets = 0;
134
+ const llm = {
135
+ async streamWithTools({ onTextDelta }) {
136
+ round += 1;
137
+ if (round === 1) {
138
+ onTextDelta('Je vais regarder…');
139
+ const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
140
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
141
+ }
142
+ onTextDelta('12 pages.');
143
+ return { content: '12 pages.', tool_calls: [] };
144
+ },
145
+ };
146
+
147
+ const out = await runBoundedToolLoop({
148
+ llm,
149
+ tools: [],
150
+ executeCall: async () => 'ok',
151
+ onTextDelta: (delta) => deltas.push(delta),
152
+ onTextReset: () => { resets += 1; },
153
+ });
154
+
155
+ assert.deepEqual(deltas, ['Je vais regarder…', '12 pages.']);
156
+ assert.equal(resets, 1, 'le texte intermédiaire doit être annulé, une fois');
157
+ assert.equal(out.content, '12 pages.');
158
+ });
159
+
160
+ test('never asks to discard text that was never emitted', async () => {
161
+ let resets = 0;
162
+ let round = 0;
163
+ const llm = {
164
+ async streamWithTools() {
165
+ round += 1;
166
+ if (round === 1) {
167
+ const calls = [toolCall('c1', 'wiki__wiki_list_pages')];
168
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
169
+ }
170
+ return { content: 'fini', tool_calls: [] };
171
+ },
172
+ };
173
+
174
+ await runBoundedToolLoop({
175
+ llm,
176
+ tools: [],
177
+ executeCall: async () => 'ok',
178
+ onTextDelta: () => {},
179
+ onTextReset: () => { resets += 1; },
180
+ });
181
+
182
+ assert.equal(resets, 0);
183
+ });
@@ -5,7 +5,8 @@ import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import YAML from 'yaml';
7
7
  import { checkMissingDockerImages } from './dockerImages.js';
8
- import { patchWikircProfile } from './wikirc.js';
8
+ import { loadWikircProfile, patchWikircProfile } from './wikirc.js';
9
+ import { buildInheritedWikircPatch, copyCmeCredentials } from './workspaceInherit.js';
9
10
  import { resolveAgentsComposeContext } from './agentsCompose.js';
10
11
  import { managerEnvFile, managerMcpEndpointsFile, resolveAgentsDataDir } from './env.js';
11
12
  import { createWorkspace, findWorkspace, isValidWorkspaceName, listWorkspaces, managerRoot, workspacesDir } from './workspaces.js';
@@ -208,20 +209,62 @@ export async function refreshRunningContainers(options = {}) {
208
209
  return { skipped: false, refreshed, errors };
209
210
  }
210
211
 
211
- export async function createNewWorkspace(name, targetPath) {
212
+ export async function createNewWorkspace(name, targetPath, options = {}) {
212
213
  try {
213
214
  const output = await createWorkspace(name, targetPath, { timeout: 600_000 });
214
- const workspace = finalizeCreatedWorkspace(name);
215
- return { output, workspace };
215
+ const { workspace, inherited } = await finalizeCreatedWorkspace(name, options);
216
+ return { output, workspace, inherited };
216
217
  } catch (err) {
217
218
  throw wrapDockerError(err);
218
219
  }
219
220
  }
220
221
 
221
- export function finalizeCreatedWorkspace(name) {
222
+ /**
223
+ * @param options.inheritFrom name of the workspace to copy LLM config and CME
224
+ * credentials from — normally the session's current workspace. Omitted, or
225
+ * unknown, means "scaffold defaults only", the previous behaviour.
226
+ */
227
+ export async function finalizeCreatedWorkspace(name, options = {}) {
222
228
  const workspace = findWorkspace(name);
223
- if (workspace) initializeWorkspaceWikirc(workspace);
224
- return workspace;
229
+ if (!workspace) return { workspace: null, inherited: [] };
230
+ // Order matters: the access key is this workspace's own credential and must
231
+ // land before anything else touches the file.
232
+ initializeWorkspaceWikirc(workspace);
233
+ const inherited = await inheritWorkspaceSetup(workspace, options.inheritFrom ?? null);
234
+ return { workspace, inherited };
235
+ }
236
+
237
+ /**
238
+ * Carry an existing workspace's proven setup over to a new one. Best effort by
239
+ * design: a workspace that exists and works must never fail to be created
240
+ * because the workspace next door has an unreadable config.
241
+ */
242
+ export async function inheritWorkspaceSetup(workspace, sourceName) {
243
+ if (!workspace?.workspacePath || !sourceName || sourceName === workspace.name) return [];
244
+ const source = findWorkspace(sourceName);
245
+ if (!source?.workspacePath) return [];
246
+
247
+ const inherited = [];
248
+ try {
249
+ const { config: sourceConfig } = loadWikircProfile(source.workspacePath, 'default');
250
+ const { config: targetConfig } = loadWikircProfile(workspace.workspacePath, 'default');
251
+ const { patch, inherited: keys } = buildInheritedWikircPatch(sourceConfig, targetConfig);
252
+ if (keys.length > 0) {
253
+ patchWikircProfile(workspace.workspacePath, 'default', patch);
254
+ inherited.push(...keys);
255
+ }
256
+ } catch {
257
+ // Unreadable or absent profile on either side — nothing to inherit.
258
+ }
259
+
260
+ try {
261
+ const copied = await copyCmeCredentials(resolveAgentsDataDir(), source.name, workspace.name);
262
+ if (copied) inherited.push('cme.app_data.json');
263
+ } catch {
264
+ // Credentials are a convenience, not a prerequisite.
265
+ }
266
+
267
+ return inherited;
225
268
  }
226
269
 
227
270
  export function initializeWorkspaceWikirc(workspace) {
@@ -105,3 +105,67 @@ test('container refresh pulls and renews only services that are already running'
105
105
  assert.match(script, /refresh_running_services 'No running external agent containers to refresh' 'Refreshed running agents: %s' _agents_dc/);
106
106
  assert.match(script, /refresh_running_services "No running workspace containers to refresh: \$workspace" "Refreshed running workspace containers: \$workspace \(%s\)" compose_for_workspace "\$workspace"/);
107
107
  });
108
+
109
+ // `${arr[@]}` on an empty array is an unbound-variable error under `set -u` in
110
+ // bash <= 4.3, and macOS still ships /bin/bash 3.2. `parse_global_options`
111
+ // tripped on it, so `wiki-workspace` with no global option died before reaching
112
+ // any subcommand.
113
+ //
114
+ // The execution check below cannot catch a regression on a CI running bash 5,
115
+ // where an empty array is legal — hence the source assertion that every
116
+ // optional array keeps the `${arr[@]+"${arr[@]}"}` guard. Both are needed.
117
+ test('optional arrays keep the empty-array guard required by bash 3.2', async () => {
118
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
119
+
120
+ for (const name of ['parsed', 'PARSED_ARGS', 'log_args']) {
121
+ const unguarded = new RegExp(String.raw`(?<!\+)"\$\{${name}\[@\]\}"`);
122
+ assert.doesNotMatch(script, unguarded, `${name}[@] must use the \${name[@]+"..."} guard`);
123
+ }
124
+ assert.match(script, /set -- \$\{parsed\[@\]\+"\$\{parsed\[@\]\}"\}/);
125
+ assert.match(script, /set -- \$\{PARSED_ARGS\[@\]\+"\$\{PARSED_ARGS\[@\]\}"\}/);
126
+ });
127
+
128
+ test('wiki-workspace reaches its usage text with no arguments', async () => {
129
+ const { spawnSync } = await import('node:child_process');
130
+ const scriptPath = new URL('../../wiki-workspace', import.meta.url).pathname;
131
+
132
+ const result = spawnSync('bash', [scriptPath], { encoding: 'utf8' });
133
+
134
+ assert.doesNotMatch(`${result.stdout}${result.stderr}`, /unbound variable/);
135
+ assert.match(result.stdout, /^Usage:/);
136
+ assert.equal(result.status, 2);
137
+ });
138
+
139
+ // `reset` deletes everything workspace_reset_keep rejects, and its very next
140
+ // statement is `run_wiki … init`, which opens with need_workspace_env. If
141
+ // `.env` ever falls out of the keep list, reset erases the workspace and then
142
+ // dies before re-scaffolding it — and recovering with `config` mints new ports
143
+ // and MCP tokens, invalidating every reference to the old ones. Exercise the
144
+ // real function rather than asserting on the source text.
145
+ test('workspace_reset_keep protects the workspace registration and method', async () => {
146
+ const { spawnSync } = await import('node:child_process');
147
+ const scriptPath = new URL('../../wiki-workspace', import.meta.url).pathname;
148
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
149
+ const start = script.indexOf('workspace_reset_keep() {');
150
+ assert.notEqual(start, -1, 'workspace_reset_keep must exist');
151
+ const body = script.slice(start, script.indexOf('\n}', start) + 2);
152
+
153
+ const ask = (name) =>
154
+ spawnSync('bash', ['-c', `${body}\nworkspace_reset_keep "$1"`, '_', name]).status === 0;
155
+
156
+ for (const kept of ['.env', '.env.local', '.wikirc.yaml', '.wikirc.yaml.prod', 'templates', 'build-context', '.git']) {
157
+ assert.equal(ask(kept), true, `${kept} must survive a reset`);
158
+ }
159
+ for (const removed of ['wiki', 'deliverables', 'raw', '.wiki', 'CLAUDE.md', '.gitignore', '.environment']) {
160
+ assert.equal(ask(removed), false, `${removed} must be reset`);
161
+ }
162
+ });
163
+
164
+ test('reset re-scaffolds through run_wiki, which requires the workspace env', async () => {
165
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
166
+
167
+ // The coupling the keep list depends on: if either half moves, revisit it.
168
+ assert.match(script, /run_wiki\(\) \{\n local workspace="\$1"\n shift\n need_workspace_env "\$workspace"/);
169
+ assert.match(script, /run_wiki "\$workspace" init/);
170
+ assert.match(script, /workspace_env_file\(\) \{\n local workspace="\$1"\n printf '%s\/%s\/\.env\\n'/);
171
+ });
@@ -163,7 +163,7 @@ test('writeVectorConfig removes commented vector placeholders it replaces', () =
163
163
  assert.equal(parsed.retrieval.vector.apiKey, 'vector-key');
164
164
  });
165
165
 
166
- test('finalizeCreatedWorkspace copies generated wiki token into default wikirc', () => {
166
+ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc', async () => {
167
167
  const root = mkdtempSync(join(tmpdir(), 'wikirc-workspace-token-'));
168
168
  const registryRoot = join(root, 'registry');
169
169
  const registryPath = join(registryRoot, 'demo');
@@ -190,7 +190,7 @@ test('finalizeCreatedWorkspace copies generated wiki token into default wikirc',
190
190
  const previousDir = process.env.WIKI_WORKSPACES_DIR;
191
191
  process.env.WIKI_WORKSPACES_DIR = registryRoot;
192
192
  try {
193
- const workspace = finalizeCreatedWorkspace('demo');
193
+ const { workspace } = await finalizeCreatedWorkspace('demo');
194
194
  const parsed = YAML.parse(readFileSync(join(workspacePath, '.wikirc.yaml'), 'utf8'));
195
195
 
196
196
  assert.equal(workspace.name, 'demo');
@@ -249,3 +249,122 @@ test('containerReachableUrl only touches loopback hosts', () => {
249
249
  assert.equal(containerReachableUrl('http://gateway:4000/v1').rewritten, false);
250
250
  assert.equal(containerReachableUrl(undefined).rewritten, false);
251
251
  });
252
+
253
+ // /new used to leave the operator re-entering the same endpoint, key and model
254
+ // they had just proven working in the workspace next door.
255
+ test('finalizeCreatedWorkspace seeds a new workspace from the one in use', async () => {
256
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-inherit-'));
257
+ const registryRoot = join(root, 'registry');
258
+ const agentsData = join(root, '.agents-data');
259
+ const token = 'b'.repeat(64);
260
+
261
+ const makeWorkspace = (name, wikirc) => {
262
+ const registryPath = join(registryRoot, name);
263
+ const workspacePath = join(root, name);
264
+ mkdirSync(registryPath, { recursive: true });
265
+ mkdirSync(workspacePath, { recursive: true });
266
+ writeFileSync(join(registryPath, '.env'), [
267
+ `WORKSPACE_NAME=${name}`,
268
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
269
+ `WIKI_MCP_AUTH_TOKEN=${token}`,
270
+ '',
271
+ ].join('\n'), 'utf8');
272
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), wikirc, 'utf8');
273
+ return workspacePath;
274
+ };
275
+
276
+ makeWorkspace('acpi', [
277
+ 'language: en',
278
+ 'llm:',
279
+ ' provider: ai-gateway',
280
+ ' engine: generic',
281
+ ' baseUrl: https://itsdonna.events/v1',
282
+ ' model: deepseek-v4-pro',
283
+ ' apiKey: sk-real-gateway-key',
284
+ 'mcp:',
285
+ ' accessKey: ' + 'c'.repeat(64),
286
+ '',
287
+ ].join('\n'));
288
+
289
+ // Exactly what the scaffold writes: placeholders everywhere.
290
+ const targetPath = makeWorkspace('nouveau', [
291
+ 'language: en',
292
+ 'llm:',
293
+ ' provider: openai-compatible',
294
+ ' engine: generic',
295
+ ' baseUrl: https://mon-provider.example.com/v1',
296
+ ' model: YOUR_MODEL_NAME',
297
+ ' apiKey: YOUR_LLM_API_KEY',
298
+ 'mcp:',
299
+ ' # accessKey: your-secret-key',
300
+ '',
301
+ ].join('\n'));
302
+
303
+ mkdirSync(join(agentsData, 'cme', 'acpi', 'cme'), { recursive: true });
304
+ writeFileSync(join(agentsData, 'cme', 'acpi', 'cme', 'app_data.json'), '{"pat":"x"}', 'utf8');
305
+
306
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
307
+ const previousData = process.env.AGENTS_DATA_DIR;
308
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
309
+ process.env.AGENTS_DATA_DIR = agentsData;
310
+ try {
311
+ const { inherited } = await finalizeCreatedWorkspace('nouveau', { inheritFrom: 'acpi' });
312
+ const parsed = YAML.parse(readFileSync(join(targetPath, '.wikirc.yaml'), 'utf8'));
313
+
314
+ assert.equal(parsed.llm.baseUrl, 'https://itsdonna.events/v1');
315
+ assert.equal(parsed.llm.model, 'deepseek-v4-pro');
316
+ assert.equal(parsed.llm.apiKey, 'sk-real-gateway-key');
317
+ assert.equal(parsed.llm.provider, 'ai-gateway');
318
+ // The workspace keeps ITS own MCP credential, written just before.
319
+ assert.equal(parsed.mcp.accessKey, token);
320
+ assert.ok(inherited.includes('llm.baseUrl'));
321
+ assert.ok(inherited.includes('cme.app_data.json'));
322
+ assert.equal(
323
+ readFileSync(join(agentsData, 'cme', 'nouveau', 'cme', 'app_data.json'), 'utf8'),
324
+ '{"pat":"x"}',
325
+ );
326
+ } finally {
327
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
328
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
329
+ if (previousData === undefined) delete process.env.AGENTS_DATA_DIR;
330
+ else process.env.AGENTS_DATA_DIR = previousData;
331
+ }
332
+ });
333
+
334
+ test('finalizeCreatedWorkspace without a source keeps scaffold defaults', async () => {
335
+ const root = mkdtempSync(join(tmpdir(), 'wikirc-noinherit-'));
336
+ const registryRoot = join(root, 'registry');
337
+ const registryPath = join(registryRoot, 'solo');
338
+ const workspacePath = join(root, 'solo');
339
+ const token = 'd'.repeat(64);
340
+ mkdirSync(registryPath, { recursive: true });
341
+ mkdirSync(workspacePath, { recursive: true });
342
+ writeFileSync(join(registryPath, '.env'), [
343
+ 'WORKSPACE_NAME=solo',
344
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
345
+ `WIKI_MCP_AUTH_TOKEN=${token}`,
346
+ '',
347
+ ].join('\n'), 'utf8');
348
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), [
349
+ 'language: en',
350
+ 'llm:',
351
+ ' baseUrl: https://mon-provider.example.com/v1',
352
+ 'mcp:',
353
+ ' # accessKey: your-secret-key',
354
+ '',
355
+ ].join('\n'), 'utf8');
356
+
357
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
358
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
359
+ try {
360
+ const { inherited } = await finalizeCreatedWorkspace('solo');
361
+ const parsed = YAML.parse(readFileSync(join(workspacePath, '.wikirc.yaml'), 'utf8'));
362
+
363
+ assert.deepEqual(inherited, []);
364
+ assert.equal(parsed.llm.baseUrl, 'https://mon-provider.example.com/v1');
365
+ assert.equal(parsed.mcp.accessKey, token);
366
+ } finally {
367
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
368
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
369
+ }
370
+ });
@@ -0,0 +1,149 @@
1
+ import { copyFile, mkdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * Config a freshly created workspace inherits from an existing one.
7
+ *
8
+ * A new workspace was scaffolded with placeholder values only
9
+ * (`YOUR_MODEL_NAME`, `https://mon-provider.example.com/v1`…), so the operator
10
+ * had to redo the whole LLM setup by hand — reachable endpoint, gateway key,
11
+ * model name, vector settings — every single time, for values they had already
12
+ * proven working next door. `/new` now carries them over.
13
+ *
14
+ * Two rules make this safe rather than merely convenient:
15
+ *
16
+ * - Only ABSENT or PLACEHOLDER target values are filled. Anything the
17
+ * scaffold or the operator already set for real is left alone, so this can
18
+ * never overwrite a deliberate choice.
19
+ * - `mcp.accessKey` is never inherited. It is the workspace's own credential
20
+ * and `initializeWorkspaceWikirc` has already written the right one.
21
+ */
22
+
23
+ // Kept identical to SetupWizard.tsx on purpose — the wizard applies the same
24
+ // "a scaffolded value is not an answer" rule. `workspaceInherit.test.js`
25
+ // asserts the two literals stay in sync.
26
+ export const PLACEHOLDER_VALUE_RE = /YOUR_|<your|example\.com|infinity\.local/i;
27
+
28
+ const INHERITED_LLM_KEYS = ['baseUrl', 'model', 'apiKey', 'temperature', 'timeoutMs'];
29
+
30
+ // `provider` and `engine` describe HOW the endpoint in `baseUrl` behaves, so
31
+ // they are bound to it rather than inherited on their own merit.
32
+ //
33
+ // The scaffold ships `provider: openai-compatible` / `engine: generic` as
34
+ // defaults, and those are real values — not placeholders the fill rule would
35
+ // replace. Inheriting a gateway's `baseUrl` while leaving them behind produced
36
+ // a config claiming a direct openai-compatible server against an AI gateway:
37
+ // exactly the conflation the provider/engine split was introduced to end.
38
+ const ENDPOINT_BOUND_KEYS = ['provider', 'engine'];
39
+
40
+ // `retrieval.vector` stays independent from `llm` but inherits provider/engine/
41
+ // baseUrl/apiKey when absent, so only keys the source states EXPLICITLY are
42
+ // worth carrying: copying an inherited value would freeze it and break the
43
+ // link the source deliberately left open.
44
+ const INHERITED_VECTOR_KEYS = [
45
+ 'enabled',
46
+ 'provider',
47
+ 'engine',
48
+ 'baseUrl',
49
+ 'apiKey',
50
+ 'embeddingModel',
51
+ 'rerankerModel',
52
+ ];
53
+
54
+ /** A value the operator actually chose, as opposed to scaffold filler. */
55
+ export function isRealValue(value) {
56
+ if (value === null || value === undefined) return false;
57
+ if (typeof value === 'boolean' || typeof value === 'number') return true;
58
+ if (typeof value !== 'string') return false;
59
+ const text = value.trim();
60
+ return text.length > 0 && !PLACEHOLDER_VALUE_RE.test(text);
61
+ }
62
+
63
+ function inheritSection(sourceSection, targetSection, keys, prefix, patch, inherited) {
64
+ for (const key of keys) {
65
+ const from = sourceSection?.[key];
66
+ if (!isRealValue(from)) continue;
67
+ // Present AND real on the target: a deliberate value, never overwritten.
68
+ if (isRealValue(targetSection?.[key])) continue;
69
+ patch[key] = from;
70
+ inherited.push(`${prefix}.${key}`);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param sourceConfig parsed `.wikirc.yaml` of the workspace to copy from
76
+ * @param targetConfig parsed `.wikirc.yaml` of the freshly created workspace
77
+ * @returns `{ patch, inherited }` — `patch` is shaped for `patchWikircProfile`
78
+ * and is `{}` when there is nothing to carry over.
79
+ */
80
+ export function buildInheritedWikircPatch(sourceConfig, targetConfig) {
81
+ const inherited = [];
82
+ const llm = {};
83
+ const vector = {};
84
+
85
+ inheritSection(sourceConfig?.llm, targetConfig?.llm, INHERITED_LLM_KEYS, 'llm', llm, inherited);
86
+ // Only once the endpoint itself has been taken over.
87
+ if (llm.baseUrl) {
88
+ for (const key of ENDPOINT_BOUND_KEYS) {
89
+ const from = sourceConfig?.llm?.[key];
90
+ if (!isRealValue(from) || from === targetConfig?.llm?.[key]) continue;
91
+ llm[key] = from;
92
+ inherited.push(`llm.${key}`);
93
+ }
94
+ }
95
+
96
+ inheritSection(
97
+ sourceConfig?.retrieval?.vector,
98
+ targetConfig?.retrieval?.vector,
99
+ INHERITED_VECTOR_KEYS,
100
+ 'retrieval.vector',
101
+ vector,
102
+ inherited,
103
+ );
104
+
105
+ // The gateway key unlocks every provider behind it. If the vector endpoint
106
+ // ends up on a different host than the LLM one, an inherited apiKey would be
107
+ // sent there — the exact leak the wizard already refuses to create. Drop the
108
+ // inherited baseUrl rather than the key: staying on the LLM endpoint is the
109
+ // conservative default, and the operator can still point it elsewhere.
110
+ const effectiveLlmBaseUrl = llm.baseUrl ?? targetConfig?.llm?.baseUrl ?? sourceConfig?.llm?.baseUrl;
111
+ const effectiveVectorBaseUrl = vector.baseUrl ?? targetConfig?.retrieval?.vector?.baseUrl;
112
+ const hasOwnVectorKey = isRealValue(vector.apiKey ?? targetConfig?.retrieval?.vector?.apiKey);
113
+ if (effectiveVectorBaseUrl && effectiveVectorBaseUrl !== effectiveLlmBaseUrl && !hasOwnVectorKey) {
114
+ delete vector.baseUrl;
115
+ const index = inherited.indexOf('retrieval.vector.baseUrl');
116
+ if (index >= 0) inherited.splice(index, 1);
117
+ }
118
+
119
+ const patch = {};
120
+ if (Object.keys(llm).length > 0) patch.llm = llm;
121
+ if (Object.keys(vector).length > 0) patch.retrieval = { vector };
122
+ return { patch, inherited };
123
+ }
124
+
125
+ export function cmeCredentialsPath(agentsDataDir, workspaceName) {
126
+ return join(agentsDataDir, 'cme', workspaceName, 'cme', 'app_data.json');
127
+ }
128
+
129
+ /**
130
+ * Carry the Confluence credentials of an existing workspace over to a new one.
131
+ *
132
+ * `app_data.json` only — NOT `sources-manifest.yaml`. The credentials are a
133
+ * property of the operator's Confluence account and are identical everywhere;
134
+ * which spaces and pages a workspace exports is precisely what makes it a
135
+ * different workspace, and copying that would silently re-export someone
136
+ * else's scope on the first run.
137
+ */
138
+ export async function copyCmeCredentials(agentsDataDir, sourceWorkspace, targetWorkspace) {
139
+ if (!agentsDataDir || !sourceWorkspace || !targetWorkspace) return null;
140
+ if (sourceWorkspace === targetWorkspace) return null;
141
+ const from = cmeCredentialsPath(agentsDataDir, sourceWorkspace);
142
+ const to = cmeCredentialsPath(agentsDataDir, targetWorkspace);
143
+ if (!existsSync(from)) return null;
144
+ // An existing target file is a real configuration; never clobber it.
145
+ if (existsSync(to)) return null;
146
+ await mkdir(join(agentsDataDir, 'cme', targetWorkspace, 'cme'), { recursive: true });
147
+ await copyFile(from, to);
148
+ return to;
149
+ }