akm-cli 0.9.16-alpha.1 → 0.9.16

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 (147) hide show
  1. package/CHANGELOG.md +56 -132
  2. package/dist/assets/hints/cli-hints-full.md +13 -6
  3. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  4. package/dist/assets/tasks/improve/akm-improve-catchup.yml +3 -6
  5. package/dist/cli/retired-commands.js +0 -4
  6. package/dist/cli/unknown-flags.js +3 -36
  7. package/dist/commands/env/env-binding.js +4 -4
  8. package/dist/commands/env/env-cli.js +3 -3
  9. package/dist/commands/improve/collapse-detector.js +2 -2
  10. package/dist/commands/improve/consolidate.js +4 -6
  11. package/dist/commands/improve/improve-cli.js +20 -15
  12. package/dist/commands/improve/reflect.js +23 -2
  13. package/dist/commands/lint/base-linter.js +9 -0
  14. package/dist/commands/lint/env-key-rules.js +2 -2
  15. package/dist/commands/proposal/propose.js +15 -1
  16. package/dist/commands/proposal/repository.js +3 -12
  17. package/dist/commands/proposal/validators/proposal-quality-validators.js +40 -3
  18. package/dist/commands/proposal/validators/proposal-validators.js +5 -4
  19. package/dist/commands/read/curate.js +44 -34
  20. package/dist/commands/read/search.js +35 -54
  21. package/dist/commands/read/show.js +21 -2
  22. package/dist/commands/registry-cli.js +5 -5
  23. package/dist/commands/sources/add-cli.js +59 -16
  24. package/dist/commands/sources/bundle-cli.js +35 -11
  25. package/dist/commands/sources/bundle-config-ops.js +30 -0
  26. package/dist/commands/sources/dangerous-env-audit.js +4 -4
  27. package/dist/commands/sources/info.js +8 -8
  28. package/dist/commands/sources/installed-stashes.js +55 -61
  29. package/dist/commands/sources/source-add.js +39 -38
  30. package/dist/commands/sources/source-manage.js +34 -12
  31. package/dist/commands/sources/stash-cli.js +111 -119
  32. package/dist/commands/sources/stash-skeleton.js +6 -3
  33. package/dist/commands/tasks/explain.js +4 -1
  34. package/dist/commands/tasks/tasks-cli.js +31 -9
  35. package/dist/commands/tasks/tasks.js +239 -194
  36. package/dist/commands/tasks/validate.js +20 -32
  37. package/dist/core/activation-policy.js +4 -4
  38. package/dist/core/adapter/adapters/akm-adapter.js +8 -35
  39. package/dist/core/adapter/adapters/akm-metadata.js +1 -11
  40. package/dist/core/adapter/execution-source.js +10 -29
  41. package/dist/core/asset/asset-placement.js +0 -35
  42. package/dist/core/config/config-schema.js +64 -8
  43. package/dist/core/config/config-sources.js +96 -2
  44. package/dist/core/config/config.js +190 -24
  45. package/dist/core/config/legacy-source-shape-shim.js +9 -0
  46. package/dist/core/config/schema/embedding.js +30 -7
  47. package/dist/core/config/schema/execution.js +23 -0
  48. package/dist/core/config/schema/experimental.js +1 -1
  49. package/dist/core/config/schema/scheduler.js +20 -0
  50. package/dist/core/config/schema/search.js +10 -12
  51. package/dist/core/config/schema/sources-bundles.js +32 -1
  52. package/dist/core/content-safety.js +52 -0
  53. package/dist/core/errors.js +2 -5
  54. package/dist/core/maintenance-barrier.js +11 -13
  55. package/dist/core/paths.js +11 -0
  56. package/dist/core/run-lock.js +2 -5
  57. package/dist/core/state/migrations.js +1 -26
  58. package/dist/core/state-db.js +27 -63
  59. package/dist/core/type-presentation.js +1 -1
  60. package/dist/core/write-source.js +13 -8
  61. package/dist/indexer/bundle-identity-guard.js +45 -8
  62. package/dist/indexer/ensure-index.js +0 -5
  63. package/dist/indexer/index-db-contention.js +56 -0
  64. package/dist/indexer/index-rebuild-lock.js +73 -0
  65. package/dist/indexer/index-written-assets.js +171 -133
  66. package/dist/indexer/indexer.js +1621 -458
  67. package/dist/indexer/lookup/adapter-concept-owner.js +5 -19
  68. package/dist/indexer/materialize-embeddings.js +785 -0
  69. package/dist/indexer/passes/dir-staleness.js +161 -0
  70. package/dist/indexer/passes/metadata.js +1 -18
  71. package/dist/indexer/scan/drain-dir.js +70 -27
  72. package/dist/indexer/search/db-search.js +89 -373
  73. package/dist/indexer/search/ranking-contributors.js +16 -21
  74. package/dist/indexer/search/ranking.js +57 -135
  75. package/dist/indexer/search/search-source.js +29 -11
  76. package/dist/integrations/agent/execution-lowering.js +3 -2
  77. package/dist/integrations/agent/execution-preparation.js +32 -1
  78. package/dist/integrations/agent/prompts.js +1 -1
  79. package/dist/integrations/agent/request-lowering.js +3 -2
  80. package/dist/llm/client.js +3 -11
  81. package/dist/llm/embedder.js +3 -10
  82. package/dist/llm/embedders/remote.js +104 -133
  83. package/dist/llm/feature-gate.js +2 -4
  84. package/dist/llm/rerank-client.js +3 -3
  85. package/dist/output/html-render.js +2 -1
  86. package/dist/output/shapes/passthrough.js +2 -1
  87. package/dist/output/stdout.js +24 -0
  88. package/dist/output/text/command-format.js +13 -19
  89. package/dist/output/text/helpers.js +1 -1
  90. package/dist/output/text/index.js +2 -5
  91. package/dist/output/text.js +4 -3
  92. package/dist/registry/resolve.js +37 -10
  93. package/dist/scripts/akm-migrate-node.js +15197 -11351
  94. package/dist/scripts/akm-migrate.js +15514 -11668
  95. package/dist/setup/semantic-assets.js +2 -2
  96. package/dist/setup/setup.js +3 -3
  97. package/dist/setup/steps/connection.js +2 -3
  98. package/dist/setup/steps/tasks.js +29 -36
  99. package/dist/sources/providers/git-install.js +17 -11
  100. package/dist/sources/providers/git-provider.js +12 -5
  101. package/dist/sources/providers/git-stash.js +38 -16
  102. package/dist/sources/snapshot-fetchers/website-ingest.js +3 -3
  103. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  104. package/dist/storage/repositories/index-connection.js +3 -1
  105. package/dist/storage/repositories/index-entries-repository.js +68 -77
  106. package/dist/storage/repositories/index-entry-schema.js +25 -16
  107. package/dist/storage/repositories/index-fts-repository.js +263 -29
  108. package/dist/storage/repositories/index-meta-repository.js +29 -0
  109. package/dist/storage/repositories/index-schema.js +122 -115
  110. package/dist/storage/repositories/index-utility-repository.js +1 -1
  111. package/dist/storage/repositories/index-vec-repository.js +435 -22
  112. package/dist/tasks/activation-config.js +90 -0
  113. package/dist/tasks/backends/cron.js +9 -0
  114. package/dist/tasks/backends/launchd.js +1 -0
  115. package/dist/tasks/backends/schtasks.js +2 -0
  116. package/dist/tasks/embedded.js +4 -5
  117. package/dist/tasks/scheduler-binding.js +2 -2
  118. package/dist/tasks/scheduler-sync-preview.js +8 -1
  119. package/dist/tasks/scheduler-sync.js +19 -10
  120. package/dist/tasks/source/parse-task-source.js +10 -113
  121. package/dist/tasks/source/project-v4.js +2 -2
  122. package/dist/tasks/source/task-source-v4.js +4 -12
  123. package/dist/tasks/source/task-to-v3.js +4 -12
  124. package/dist/tasks/source/task-to-v4.js +40 -7
  125. package/docs/migration/README.md +1 -0
  126. package/docs/migration/release-notes/0.9.15.md +36 -34
  127. package/docs/migration/release-notes/0.9.16.md +60 -98
  128. package/docs/migration/release-notes/README.md +0 -5
  129. package/docs/migration/v0.9.1-to-v0.9.2.md +6 -9
  130. package/docs/reference/cli.md +124 -122
  131. package/docs/reference/configuration.md +137 -133
  132. package/docs/reference/data-and-telemetry.md +1 -2
  133. package/docs/reference/tasks.md +34 -29
  134. package/package.json +1 -1
  135. package/schemas/akm-config.json +170 -6
  136. package/schemas/akm-task.json +1 -2
  137. package/dist/commands/sources/index-status.js +0 -99
  138. package/dist/core/hash.js +0 -18
  139. package/dist/indexer/drain.js +0 -306
  140. package/dist/indexer/embedding-identity.js +0 -20
  141. package/dist/indexer/enrich.js +0 -260
  142. package/dist/indexer/reconcile.js +0 -890
  143. package/dist/indexer/scan/parse-file.js +0 -66
  144. package/dist/indexer/units/unit.js +0 -159
  145. package/dist/llm/embedders/provider-limits.js +0 -288
  146. package/dist/storage/repositories/files-repository.js +0 -181
  147. package/dist/storage/repositories/units-repository.js +0 -510
@@ -11,8 +11,8 @@ import { ConfigError } from "../errors.js";
11
11
  import { liftLegacyEngineExtraParams } from "../extra-params.js";
12
12
  import { formatRegistryLabel, hasRegistryUrlCredentials } from "../registry-url.js";
13
13
  import { acquireConfigLock, backupExistingConfig, parseConfigText, readConfigText, withConfigLock, writeConfigAtomic, } from "./config-io.js";
14
- import { AkmConfigSchema, CURRENT_CONFIG_VERSION } from "./config-schema.js";
15
- import { bundleComponentConfig, bundleContentRoot, bundlesToSourceEntries } from "./config-sources.js";
14
+ import { AkmConfigSchema, CURRENT_CONFIG_VERSION, listTopLevelConfigKeys } from "./config-schema.js";
15
+ import { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundlesToSourceEntries } from "./config-sources.js";
16
16
  import { upgradeConfigVersion } from "./config-version-shim.js";
17
17
  import { deepMergeConfig, isPlainObject } from "./deep-merge.js";
18
18
  import { migrateLegacySourceShape } from "./legacy-source-shape-shim.js";
@@ -192,21 +192,60 @@ function liftExtraParamsOrThrow(parsedRaw, sourcePath) {
192
192
  * keeps around, instead of only getting the final merged `AkmConfig` back.
193
193
  */
194
194
  function buildEffectiveConfig(liftedLocalRaw, sourcePath) {
195
+ warnUnknownTopLevelConfigKeys(liftedLocalRaw, sourcePath);
195
196
  const withExtends = resolveExtendsChain(liftedLocalRaw, sourcePath);
196
197
  const where = sourcePath ? ` at ${sourcePath}` : "";
197
198
  const parsed = AkmConfigSchema.safeParse(withExtends);
198
199
  if (!parsed.success) {
199
200
  const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
200
- throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
201
+ const needsSchedulerMigration = parsed.error.issues.some((issue) => issue.path[0] === "scheduler" && issue.path.at(-1) === "sourceId");
202
+ throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE", needsSchedulerMigration
203
+ ? "Run `akm migrate apply` to bind existing scheduler grants to their source."
204
+ : undefined);
201
205
  }
202
206
  const merged = deepMergeConfig(DEFAULT_CONFIG, parsed.data);
203
207
  const finalResult = AkmConfigSchema.safeParse(merged);
204
208
  if (!finalResult.success) {
205
209
  const lines = finalResult.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
206
- throw new ConfigError(`Invalid merged config${sourcePath ? ` at ${sourcePath}` : ""}:\n${lines}`, "INVALID_CONFIG_FILE");
210
+ const needsSchedulerMigration = finalResult.error.issues.some((issue) => issue.path[0] === "scheduler" && issue.path.at(-1) === "sourceId");
211
+ throw new ConfigError(`Invalid merged config${sourcePath ? ` at ${sourcePath}` : ""}:\n${lines}`, "INVALID_CONFIG_FILE", needsSchedulerMigration
212
+ ? "Run `akm migrate apply` to bind existing scheduler grants to their source."
213
+ : undefined);
207
214
  }
215
+ assertUniquePhysicalBundleRoots(finalResult.data, sourcePath);
208
216
  return finalResult.data;
209
217
  }
218
+ const RETIRED_TOP_LEVEL_CONFIG_KEYS = new Set([
219
+ "agent",
220
+ "bindings",
221
+ "features",
222
+ "installed",
223
+ "llm",
224
+ "modelAliases",
225
+ "profiles",
226
+ "sources",
227
+ "stashDir",
228
+ "stashes",
229
+ "writable",
230
+ ]);
231
+ function warnUnknownTopLevelConfigKeys(raw, sourcePath) {
232
+ const known = new Set(listTopLevelConfigKeys());
233
+ for (const key of Object.keys(raw).sort()) {
234
+ if (known.has(key) || RETIRED_TOP_LEVEL_CONFIG_KEYS.has(key))
235
+ continue;
236
+ warnOnce(`config:unknown-key:${sourcePath ?? "inline"}:${key}`, `Unknown config key ${JSON.stringify(key)}${sourcePath ? ` at ${sourcePath}` : ""} has no defined akm behavior. Check the spelling or remove it.`);
237
+ }
238
+ }
239
+ function assertUniquePhysicalBundleRoots(config, sourcePath) {
240
+ const owners = new Map();
241
+ for (const { id, contentRoot } of bundleContentRoots(config)) {
242
+ const prior = owners.get(contentRoot);
243
+ if (prior !== undefined) {
244
+ throw new ConfigError(`Invalid config${sourcePath ? ` at ${sourcePath}` : ""}: bundles ${JSON.stringify(prior)} and ${JSON.stringify(id)} resolve to the same physical content root ${contentRoot}.`, "INVALID_CONFIG_FILE", "Configure one bundle id per physical source root; symbolic-link aliases are not separate bundles.");
245
+ }
246
+ owners.set(contentRoot, id);
247
+ }
248
+ }
210
249
  /**
211
250
  * Parse raw config text and validate via Zod.
212
251
  * ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig.
@@ -233,6 +272,7 @@ function collectExtendsLayers(localRaw, configPath) {
233
272
  const visited = new Set(configPath ? [path.resolve(configPath)] : []);
234
273
  let current = localRaw;
235
274
  let currentPath = configPath;
275
+ let containmentRoot;
236
276
  while (true) {
237
277
  const ref = current.extends;
238
278
  if (ref === undefined)
@@ -240,15 +280,18 @@ function collectExtendsLayers(localRaw, configPath) {
240
280
  if (typeof ref !== "string" || !ref.trim()) {
241
281
  throw new ConfigError(`Invalid "extends"${currentPath ? ` at ${currentPath}` : ""}: expected a non-empty string (a file path or bundle//path ref), got ${JSON.stringify(ref)}.`, "INVALID_CONFIG_FILE");
242
282
  }
243
- const { text, resolvedPath } = resolveConfigRefSource(ref, current, currentPath);
283
+ const resolved = resolveConfigRefSource(ref, current, currentPath, containmentRoot);
284
+ const { text, resolvedPath } = resolved;
244
285
  if (visited.has(resolvedPath)) {
245
286
  throw new ConfigError(`Config "extends" cycle detected: "${ref}"${currentPath ? ` (from ${currentPath})` : ""} resolves back to an already-visited config at ${resolvedPath}.`, "INVALID_CONFIG_FILE");
246
287
  }
247
288
  visited.add(resolvedPath);
248
289
  const baseRaw = runConfigFilePipeline(text, resolvedPath);
290
+ warnUnknownTopLevelConfigKeys(baseRaw, resolvedPath);
249
291
  layers.push({ ref, raw: baseRaw });
250
292
  current = baseRaw;
251
293
  currentPath = resolvedPath;
294
+ containmentRoot = resolved.containmentRoot;
252
295
  }
253
296
  }
254
297
  /**
@@ -274,10 +317,84 @@ function resolveExtendsChain(localRaw, configPath) {
274
317
  const layers = collectExtendsLayers(localRaw, configPath);
275
318
  let merged = {};
276
319
  for (let i = layers.length - 1; i >= 0; i--) {
277
- merged = deepMergeConfig(merged, layers[i].raw);
320
+ const layer = layers[i];
321
+ merged = deepMergeConfig(merged, i > 0 ? sanitizeInheritedConfig(layer.raw, layer.ref ?? String(i)) : layer.raw);
278
322
  }
279
323
  return merged;
280
324
  }
325
+ const HOST_LOCAL_CONFIG_KEYS = new Set([
326
+ "bundles",
327
+ "defaultBundle",
328
+ "defaultWriteTarget",
329
+ "embedding",
330
+ "execution",
331
+ "experimental",
332
+ "registries",
333
+ "scheduler",
334
+ "setup",
335
+ ]);
336
+ /**
337
+ * Shared config contributes portable behavior only. Source ownership,
338
+ * credentials, executable paths/arguments, and activation remain in the
339
+ * host's top-level config even when a bundle supplies the inherited file.
340
+ * LLM endpoints and model selection remain portable; their credentials never
341
+ * do.
342
+ */
343
+ function sanitizeInheritedConfig(raw, label) {
344
+ const inherited = { ...raw };
345
+ for (const key of HOST_LOCAL_CONFIG_KEYS) {
346
+ if (!Object.hasOwn(inherited, key))
347
+ continue;
348
+ delete inherited[key];
349
+ warnOnce(`config:inherited-host-local:${label}:${key}`, `Ignoring inherited config key ${JSON.stringify(key)} from ${label}; it is host-local and must be declared in the top-level config file.`);
350
+ }
351
+ if (isPlainObject(inherited.engines)) {
352
+ const engines = {};
353
+ let strippedAuthority = false;
354
+ for (const [name, engine] of Object.entries(inherited.engines)) {
355
+ if (!isPlainObject(engine)) {
356
+ engines[name] = engine;
357
+ continue;
358
+ }
359
+ const portable = { ...engine };
360
+ for (const key of ["apiKey", "apiKeyFile", "bin", "args", "workspace"]) {
361
+ if (!Object.hasOwn(portable, key))
362
+ continue;
363
+ delete portable[key];
364
+ strippedAuthority = true;
365
+ }
366
+ engines[name] = portable;
367
+ }
368
+ inherited.engines = engines;
369
+ if (strippedAuthority) {
370
+ warnOnce(`config:inherited-host-local:${label}:engines-authority`, `Ignoring inherited engine credentials, executable arguments, or workspace from ${label}; those fields are host-local.`);
371
+ }
372
+ }
373
+ if (isPlainObject(inherited.search) && Object.hasOwn(inherited.search, "curateRerank")) {
374
+ const { curateRerank: _curateRerank, ...portableSearch } = inherited.search;
375
+ inherited.search = portableSearch;
376
+ warnOnce(`config:inherited-host-local:${label}:search.curateRerank`, `Ignoring inherited config key "search.curateRerank" from ${label}; network endpoints and credentials are host-local.`);
377
+ }
378
+ if (isPlainObject(inherited.improve) && isPlainObject(inherited.improve.strategies)) {
379
+ const strategies = {};
380
+ let strippedSync = false;
381
+ for (const [name, profile] of Object.entries(inherited.improve.strategies)) {
382
+ if (isPlainObject(profile) && Object.hasOwn(profile, "sync")) {
383
+ const { sync: _sync, ...portableProfile } = profile;
384
+ strategies[name] = portableProfile;
385
+ strippedSync = true;
386
+ }
387
+ else {
388
+ strategies[name] = profile;
389
+ }
390
+ }
391
+ inherited.improve = { ...inherited.improve, strategies };
392
+ if (strippedSync) {
393
+ warnOnce(`config:inherited-host-local:${label}:improve.sync`, `Ignoring inherited improve strategy sync policy from ${label}; publication policy is host-local.`);
394
+ }
395
+ }
396
+ return inherited;
397
+ }
281
398
  /** `~` expands to the home directory, mirroring `apiKeyFile`'s resolution (engine-resolution.ts). */
282
399
  function expandExtendsHomePath(p) {
283
400
  return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
@@ -299,12 +416,12 @@ function looksLikeBundleAssetRef(ref) {
299
416
  * a missing file/bundle throws {@link ConfigError} naming the ref, as does an
300
417
  * empty, absolute, or content-root-escaping path after `//`.
301
418
  */
302
- function resolveConfigRefSource(ref, context, fromConfigPath) {
419
+ function resolveConfigRefSource(ref, context, fromConfigPath, containmentRoot) {
303
420
  return looksLikeBundleAssetRef(ref)
304
- ? resolveConfigBundleRefSource(ref, context)
305
- : resolveConfigFileRefSource(ref, fromConfigPath);
421
+ ? resolveConfigBundleRefSource(ref, context, containmentRoot)
422
+ : resolveConfigFileRefSource(ref, fromConfigPath, containmentRoot);
306
423
  }
307
- function resolveConfigFileRefSource(ref, fromConfigPath) {
424
+ function resolveConfigFileRefSource(ref, fromConfigPath, containmentRoot) {
308
425
  const expanded = expandExtendsHomePath(ref);
309
426
  let resolvedPath;
310
427
  if (path.isAbsolute(expanded)) {
@@ -316,13 +433,15 @@ function resolveConfigFileRefSource(ref, fromConfigPath) {
316
433
  else {
317
434
  throw new ConfigError(`extends "${ref}" is a relative path, but the current config has no known file location to resolve it against.`, "INVALID_CONFIG_FILE");
318
435
  }
436
+ if (containmentRoot !== undefined)
437
+ assertConfigExtendsPhysicalContainment(containmentRoot, resolvedPath, ref);
319
438
  const text = readConfigText(resolvedPath);
320
439
  if (text === undefined) {
321
440
  throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist. Create the file first, or point "extends" at an existing config.`, "INVALID_CONFIG_FILE");
322
441
  }
323
- return { text, resolvedPath };
442
+ return { text, resolvedPath, ...(containmentRoot ? { containmentRoot } : {}) };
324
443
  }
325
- function resolveConfigBundleRefSource(ref, context) {
444
+ function resolveConfigBundleRefSource(ref, context, outerContainmentRoot) {
326
445
  // Split by hand rather than through `parseBundleRef`: the part after `//`
327
446
  // is a plain file path here, not an asset conceptId, so it must not be run
328
447
  // through conceptId validation (which, for instance, rejects every `..`
@@ -360,11 +479,31 @@ function resolveConfigBundleRefSource(ref, context) {
360
479
  if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) {
361
480
  throw new ConfigError(`extends "${ref}" escapes bundle "${bundleId}"'s content root.`, "INVALID_CONFIG_FILE");
362
481
  }
482
+ const containmentRoot = fs.realpathSync.native(bundleRoot);
483
+ assertConfigExtendsPhysicalContainment(containmentRoot, resolvedPath, ref);
484
+ if (outerContainmentRoot !== undefined) {
485
+ assertConfigExtendsPhysicalContainment(outerContainmentRoot, resolvedPath, ref);
486
+ }
363
487
  const text = readConfigText(resolvedPath);
364
488
  if (text === undefined) {
365
489
  throw new ConfigError(`extends "${ref}" resolves to ${resolvedPath}, which does not exist locally. Sync the bundle first, or point "extends" at an existing file.`, "INVALID_CONFIG_FILE");
366
490
  }
367
- return { text, resolvedPath };
491
+ return { text, resolvedPath, containmentRoot: outerContainmentRoot ?? containmentRoot };
492
+ }
493
+ function assertConfigExtendsPhysicalContainment(root, candidate, ref) {
494
+ let physicalRoot;
495
+ let physicalCandidate;
496
+ try {
497
+ physicalRoot = fs.realpathSync.native(root);
498
+ physicalCandidate = fs.realpathSync.native(candidate);
499
+ }
500
+ catch (cause) {
501
+ throw new ConfigError(`Unable to verify physical containment for extends ${JSON.stringify(ref)}: ${cause instanceof Error ? cause.message : String(cause)}`, "INVALID_CONFIG_FILE");
502
+ }
503
+ const relative = path.relative(physicalRoot, physicalCandidate);
504
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
505
+ throw new ConfigError(`extends ${JSON.stringify(ref)} resolves through a symbolic link outside its bundle content root.`, "INVALID_CONFIG_FILE");
506
+ }
368
507
  }
369
508
  /**
370
509
  * `akm config get --show-source` (#945): which raw layer the dotted path's
@@ -382,7 +521,8 @@ export function getConfigValueSource(dotted) {
382
521
  const liftedConfig = runConfigFilePipeline(text, configPath);
383
522
  const segments = dotted.split(".").filter((s) => s.length > 0);
384
523
  for (const layer of collectExtendsLayers(liftedConfig, configPath)) {
385
- if (hasRawPath(layer.raw, segments)) {
524
+ const effectiveLayer = layer.ref === undefined ? layer.raw : sanitizeInheritedConfig(layer.raw, layer.ref);
525
+ if (hasRawPath(effectiveLayer, segments)) {
386
526
  return layer.ref === undefined ? "local" : `extends:${layer.ref}`;
387
527
  }
388
528
  }
@@ -487,15 +627,41 @@ function pruneUnchangedInheritedFields(before, after, localRaw) {
487
627
  }
488
628
  /**
489
629
  * What to persist for a `mutateConfig`/`mutateConfigWithPrecommit` write:
490
- * the full effective `next` when the local file has no `extends` (unchanged
491
- * pre-#945 behavior), otherwise only the changed-or-already-local fields
492
- * (#945 finding above).
630
+ * only the changed-or-already-local fields. This applies both to inherited
631
+ * configs and ordinary configs: lifecycle mutations must not serialize every
632
+ * schema default merely because validation materialized it in memory (#972).
493
633
  */
494
- function configWriteBody(localRaw, current, next) {
495
- const usesExtends = typeof localRaw?.extends === "string" && localRaw.extends.trim().length > 0;
496
- if (!usesExtends)
634
+ function configWriteBody(localRaw, current, next, persistTopLevelKeys = []) {
635
+ // A first write keeps the established full-default scaffold. Besides being
636
+ // useful to a new user, the add -> rejected-install -> remove lifecycle
637
+ // relies on that symmetry to return a pristine install to DEFAULT_CONFIG.
638
+ if (localRaw === undefined)
497
639
  return next;
498
- return pruneUnchangedInheritedFields(current, next, localRaw);
640
+ const pruned = pruneUnchangedInheritedFields(current, next, localRaw);
641
+ // A caller may need to persist an explicit user choice even when it equals
642
+ // the schema default. Interactive setup uses this for semanticSearchMode:
643
+ // "off" means the user declined the opt-in, not an incidental default that
644
+ // should disappear from the saved configuration.
645
+ for (const key of persistTopLevelKeys) {
646
+ if (Object.hasOwn(next, key))
647
+ pruned[key] = next[key];
648
+ }
649
+ pruned.configVersion = CURRENT_CONFIG_VERSION;
650
+ // Keep existing top-level keys in their authored order. Lifecycle rollback
651
+ // writes the same logical object twice (add, then remove); reordering those
652
+ // surviving keys would violate its byte-parity guarantee even though the
653
+ // parsed JSON is equivalent. Newly changed keys follow in `next` order,
654
+ // while nested maps (notably `bundles`) retain mutation-selected ordering.
655
+ const ordered = {};
656
+ for (const key of Object.keys(localRaw)) {
657
+ if (Object.hasOwn(pruned, key))
658
+ ordered[key] = pruned[key];
659
+ }
660
+ for (const [key, value] of Object.entries(pruned)) {
661
+ if (!Object.hasOwn(ordered, key))
662
+ ordered[key] = value;
663
+ }
664
+ return ordered;
499
665
  }
500
666
  /**
501
667
  * Mutate config under one fail-closed lock spanning read, merge, validation,
@@ -527,7 +693,7 @@ export function mutateConfig(mutate, options) {
527
693
  * side effect. Setup uses this to reject a three-way conflict before creating
528
694
  * its stash, while preventing another config writer from racing the final save.
529
695
  */
530
- export async function mutateConfigWithPrecommit(mutate, precommit) {
696
+ export async function mutateConfigWithPrecommit(mutate, precommit, options) {
531
697
  cachedConfig = undefined;
532
698
  const configPath = getConfigPath();
533
699
  const release = acquireConfigLock();
@@ -543,7 +709,7 @@ export async function mutateConfigWithPrecommit(mutate, precommit) {
543
709
  if (mutated === current)
544
710
  return { config: current, written: false, precommit: precommitResult };
545
711
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
546
- writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next)));
712
+ writeConfigAtomic(configPath, sanitizeConfigForWrite(configWriteBody(localRaw, current, next, options?.persistTopLevelKeys)));
547
713
  return { config: next, written: true, precommit: precommitResult };
548
714
  }
549
715
  finally {
@@ -682,7 +848,7 @@ export function getIndexPassConfig(config, passName) {
682
848
  return entry;
683
849
  }
684
850
  // Re-export source runtime helpers — implementation lives in config-sources.ts.
685
- export { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundleEntryToSourceEntry, bundleKeyForContentRoot, bundlesToSourceEntries, installedSourceDescriptor, parseSourceSpec, primaryBundlePath, resolveConfiguredSources, } from "./config-sources.js";
851
+ export { bundleComponentConfig, bundleContentRoot, bundleContentRoots, bundleEntryToSourceEntry, bundleKeyForContentRoot, bundlePhysicalContentRoot, bundleSourceId, bundlesToSourceEntries, installedSourceDescriptor, isBundleEnabled, parseSourceSpec, primaryBundlePath, resolveActiveConfiguredSources, resolveConfiguredSources, } from "./config-sources.js";
686
852
  /**
687
853
  * Merge a partial user-config override onto a base config. Used by
688
854
  * {@link loadUserConfig} (DEFAULT_CONFIG + on-disk) and {@link updateConfig}
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { isBundleSlug } from "../asset/asset-ref.js";
2
3
  import { warnOnce } from "../warn.js";
3
4
  function isPlainRecord(value) {
@@ -49,6 +50,11 @@ function bundleFromLegacySource(entry, index) {
49
50
  const key = name && isBundleSlug(name) ? name : `source-${index + 1}`;
50
51
  return [key, bundle];
51
52
  }
53
+ function filesystemLocator(bundle) {
54
+ if (!isPlainRecord(bundle) || typeof bundle.path !== "string" || bundle.path.length === 0)
55
+ return undefined;
56
+ return path.resolve(bundle.path);
57
+ }
52
58
  export function migrateLegacySourceShape(raw, sourcePath) {
53
59
  const hasStashDir = typeof raw.stashDir === "string" && raw.stashDir.trim().length > 0;
54
60
  const hasSources = Array.isArray(raw.sources) && raw.sources.length > 0;
@@ -68,6 +74,9 @@ export function migrateLegacySourceShape(raw, sourcePath) {
68
74
  if (!converted)
69
75
  return;
70
76
  const [key, bundle] = converted;
77
+ const locator = filesystemLocator(bundle);
78
+ if (locator && Object.values(bundles).some((candidate) => filesystemLocator(candidate) === locator))
79
+ return;
71
80
  bundles[key] = bundle;
72
81
  defaultBundle ??= key;
73
82
  });
@@ -33,6 +33,32 @@ export const EmbeddingConnectionConfigSchema = z
33
33
  // `akm index` when ensureSchema rejects it (§24.2 "Semantic" gate).
34
34
  dimension: positiveInt.max(4096).optional(),
35
35
  localModel: z.string().min(1).optional(),
36
+ /**
37
+ * Per-document token cap applied BEFORE batching (default 512,
38
+ * `DEFAULT_MAX_INPUT_TOKENS` in `src/llm/embedders/remote.ts`, #956).
39
+ * The materializer truncates a document's embedded text to
40
+ * this cap (head only, unicode-safe) instead of skipping it outright, so
41
+ * one oversized entry can no longer fail a whole batch. Distinct from
42
+ * `maxTokens` below, which bounds a whole HTTP REQUEST (many documents);
43
+ * this bounds one DOCUMENT.
44
+ */
45
+ maxInputTokens: positiveInt.optional(),
46
+ /**
47
+ * Client-side per-request token budget — how many documents' estimated
48
+ * tokens fit in one HTTP request (default `DEFAULT_TOKEN_BUDGET` = 6000
49
+ * in `src/llm/embedders/remote.ts`). With the 512-token `maxInputTokens`
50
+ * cap above, a request carries about 11 documents by default.
51
+ */
52
+ maxTokens: positiveInt.optional(),
53
+ batchSize: positiveInt.optional(),
54
+ /**
55
+ * Ollama's `num_ctx` ONLY (#956) — sent verbatim as
56
+ * `options.num_ctx` on the native `/api/embed` request. It no longer also
57
+ * feeds the client-side request token budget (`maxTokens` above): the two
58
+ * used to share this one field, so setting it for the server's context
59
+ * window silently changed request batching too.
60
+ */
61
+ contextLength: positiveInt.optional(),
36
62
  ollamaOptions: EmbeddingOllamaOptionsSchema.optional(),
37
63
  /**
38
64
  * Per-request timeout in milliseconds for a remote embedding request
@@ -46,13 +72,10 @@ export const EmbeddingConnectionConfigSchema = z
46
72
  * Overrides the fixed in-flight request window (#954, added after field
47
73
  * evidence from multi-slot local servers). Bounded 1-16. Unset keeps
48
74
  * today's default: 1 for a loopback endpoint, 2 for a remote one
49
- * (`resolveEmbeddingConcurrency`, `src/llm/embedders/remote.ts`), unless
50
- * the provider's own probed slot count overrides it
51
- * (`probeProviderLimits`, `src/llm/embedders/provider-limits.ts`, used by
52
- * `akm index`'s drain queue). Set it only for an endpoint that genuinely
53
- * serves parallel requests (llama.cpp `--parallel N`, vLLM) — request
54
- * SIZE, packed against the provider's own probed context window, remains
55
- * the first throughput lever.
75
+ * (`resolveEmbeddingConcurrency`, `src/llm/embedders/remote.ts`). Set it
76
+ * only for an endpoint that genuinely serves parallel requests (llama.cpp
77
+ * `--parallel N`, vLLM) — request SIZE (`batchSize`, `maxTokens`/
78
+ * `contextLength`) remains the first throughput lever.
56
79
  */
57
80
  concurrency: positiveInt.max(16).optional(),
58
81
  })
@@ -0,0 +1,23 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { z } from "zod";
5
+ const toolName = z.string().trim().min(1, "expected a non-empty tool name");
6
+ /** Host-owned ceiling for capabilities requested by executable assets. */
7
+ export const ExecutionPolicyConfigSchema = z
8
+ .object({
9
+ /** Exact tool names an asset may request. `*` is an explicit allow-all ceiling. */
10
+ allowedTools: z
11
+ .array(toolName)
12
+ .default([])
13
+ .superRefine((tools, ctx) => {
14
+ const seen = new Set();
15
+ for (const [index, tool] of tools.entries()) {
16
+ if (seen.has(tool)) {
17
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [index], message: "duplicates an earlier tool name" });
18
+ }
19
+ seen.add(tool);
20
+ }
21
+ }),
22
+ })
23
+ .strict();
@@ -27,4 +27,4 @@ export const ExperimentalConfigSchema = z
27
27
  */
28
28
  improveAutonomy: z.boolean().optional(),
29
29
  })
30
- .passthrough();
30
+ .strict();
@@ -0,0 +1,20 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { z } from "zod";
5
+ import { nonEmptyString } from "./primitives.js";
6
+ /** One host-local grant allowing an authored asset to create scheduler bindings. */
7
+ export const SchedulerActivationSchema = z
8
+ .object({
9
+ kind: z.enum(["task", "workflow"]),
10
+ ref: nonEmptyString,
11
+ /** Identity of the configured source this host approved, not merely its mutable bundle name. */
12
+ sourceId: z.string().regex(/^sha256:[0-9a-f]{64}$/),
13
+ })
14
+ .strict();
15
+ /** Absence from this allow-list means disabled. */
16
+ export const SchedulerConfigSchema = z
17
+ .object({
18
+ enabled: z.array(SchedulerActivationSchema).default([]),
19
+ })
20
+ .strict();
@@ -23,9 +23,8 @@ const SearchGraphBoostSchema = z
23
23
  })
24
24
  .passthrough();
25
25
  /**
26
- * `search.rerank` (#951, moved from `search.curateRerank` in 0.9.16 — the
27
- * pass was always meant for `akm search`, not `akm curate`) — an optional
28
- * cross-encoder rerank pass over search's already-ranked LOCAL stash hits.
26
+ * `search.curateRerank` (#951) — an optional cross-encoder rerank pass over
27
+ * `akm curate`'s already-selected candidates.
29
28
  *
30
29
  * Deliberately its own small config arm rather than a third member of the
31
30
  * `engines` map (`EngineConfigSchema` in ./engines.ts): that union's "llm" /
@@ -38,27 +37,26 @@ const SearchGraphBoostSchema = z
38
37
  * connection shape as an LLM engine without inheriting that machinery.
39
38
  *
40
39
  * `curate_rerank` was removed as a dead `llm.features.*` key in 0.8.0 (no
41
- * implementation ever sent a request); 0.9.15 shipped a real implementation
42
- * wired to curate under `search.curateRerank`, disabled by default; 0.9.16
43
- * moves it to search and renames the key (no compatibility alias — the old
44
- * key shipped hours earlier, default-off, so nobody has it meaningfully set).
40
+ * implementation ever sent a request); this is a new, real implementation,
41
+ * disabled by default.
45
42
  */
46
- export const SearchRerankConfigSchema = z
43
+ export const CurateRerankConfigSchema = z
47
44
  .object({
48
45
  enabled: z.boolean().optional(),
49
46
  /** Full URL of the reranker's rerank endpoint, e.g. `http://host:port/rerank`. */
50
47
  endpoint: httpUrl.optional(),
51
48
  model: nonEmptyString.optional(),
52
- apiKey: symbolicOrWarnApiKey("search.rerank.apiKey").optional(),
49
+ apiKey: symbolicOrWarnApiKey("search.curateRerank.apiKey").optional(),
53
50
  timeoutMs: positiveInt.optional(),
54
- /** How many of search's already-ranked LOCAL hits to send to the reranker. Default 8. */
51
+ /** How many of curate's already-ranked candidates to send to the reranker. Default 8. */
55
52
  topN: positiveInt.max(50).optional(),
56
53
  })
57
- .passthrough();
54
+ .strict();
58
55
  export const SearchConfigSchema = z
59
56
  .object({
57
+ minScore: nonNegativeNumber.optional(),
60
58
  defaultExcludeTypes: z.array(nonEmptyString).optional(),
61
59
  graphBoost: SearchGraphBoostSchema.optional(),
62
- rerank: SearchRerankConfigSchema.optional(),
60
+ curateRerank: CurateRerankConfigSchema.optional(),
63
61
  })
64
62
  .passthrough();
@@ -13,7 +13,7 @@ import { z } from "zod";
13
13
  // and, transitively, the indexer modules they delegate to).
14
14
  import { VALID_ADAPTER_IDS } from "../../adapter/adapter-ids.js";
15
15
  import { isBundleSlug } from "../../asset/asset-ref.js";
16
- import { httpUrl, nonEmptyString, positiveInt } from "./primitives.js";
16
+ import { httpUrl, isApiKeyReference, nonEmptyString, positiveInt } from "./primitives.js";
17
17
  const VALID_ADAPTER_IDS_SET = new Set(VALID_ADAPTER_IDS);
18
18
  // ── Sources / registries / installed ────────────────────────────────────────
19
19
  const SourceConfigEntryOptionsSchema = z.record(z.unknown());
@@ -25,6 +25,7 @@ export const SourceConfigEntrySchema = z
25
25
  name: z.string().min(1).optional(),
26
26
  enabled: z.boolean().optional(),
27
27
  writable: z.boolean().optional(),
28
+ credential: z.string().min(1).optional(),
28
29
  primary: z.boolean().optional(),
29
30
  options: SourceConfigEntryOptionsSchema.optional(),
30
31
  })
@@ -52,6 +53,20 @@ export const SourceConfigEntrySchema = z
52
53
  ").",
53
54
  });
54
55
  }
56
+ if (entry.credential !== undefined && entry.type !== "git") {
57
+ ctx.addIssue({
58
+ code: z.ZodIssueCode.custom,
59
+ path: ["credential"],
60
+ message: "credential is only supported on git sources",
61
+ });
62
+ }
63
+ if (entry.credential !== undefined && !isApiKeyReference(entry.credential)) {
64
+ ctx.addIssue({
65
+ code: z.ZodIssueCode.custom,
66
+ path: ["credential"],
67
+ message: "git credential must be a $VAR or secret://<name> reference",
68
+ });
69
+ }
55
70
  });
56
71
  export const RegistryConfigEntrySchema = z
57
72
  .object({
@@ -110,6 +125,8 @@ export const BundleConfigEntrySchema = z
110
125
  git: z.string().min(1).optional(),
111
126
  website: BundleWebsiteDescriptorSchema.optional(),
112
127
  npm: z.string().min(1).optional(),
128
+ /** Symbolic HTTPS bearer credential; resolved only at the Git subprocess boundary. */
129
+ credential: z.string().min(1).optional(),
113
130
  writable: z.boolean().optional(),
114
131
  // Opt a bundle out of indexing, search, refresh, and write targeting
115
132
  // without deleting it. The runtime honors the derived value in write and
@@ -150,6 +167,20 @@ export const BundleConfigEntrySchema = z
150
167
  message: "writable: true is only supported on path and git bundle sources",
151
168
  });
152
169
  }
170
+ if (entry.credential !== undefined && entry.git === undefined) {
171
+ ctx.addIssue({
172
+ code: z.ZodIssueCode.custom,
173
+ path: ["credential"],
174
+ message: "credential is only supported on git bundle sources",
175
+ });
176
+ }
177
+ if (entry.credential !== undefined && !isApiKeyReference(entry.credential)) {
178
+ ctx.addIssue({
179
+ code: z.ZodIssueCode.custom,
180
+ path: ["credential"],
181
+ message: "git credential must be a $VAR or secret://<name> reference",
182
+ });
183
+ }
153
184
  const componentEntries = entry.components ? Object.entries(entry.components) : [];
154
185
  if (entry.components !== undefined && componentEntries.length !== 1) {
155
186
  ctx.addIssue({
@@ -0,0 +1,52 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /** Marker emitted by output redaction. It must never become durable asset content. */
5
+ export const REDACTED_CONTENT_MARKER = "[REDACTED]";
6
+ /** Reflect prompt section that contains run diagnostics, not proposed asset content. */
7
+ export const REFLECT_AVOID_PATTERNS_HEADING = "Avoid These Patterns";
8
+ const REFLECT_AVOID_PATTERNS_RE = /^##[ \t]+Avoid These Patterns[ \t]*$/i;
9
+ const SECTION_BOUNDARY_RE = /^#{1,2}(?:[ \t]+|$)/;
10
+ export function containsRedactedContent(content) {
11
+ return content.includes(REDACTED_CONTENT_MARKER);
12
+ }
13
+ export function containsReflectPromptScaffolding(content) {
14
+ return content.split(/\r?\n/).some((line) => REFLECT_AVOID_PATTERNS_RE.test(line));
15
+ }
16
+ /**
17
+ * Remove every echoed run-only "Avoid These Patterns" section while preserving
18
+ * the next peer/top-level section. Reflect alone calls this sanitizer; authored
19
+ * source assets are never rewritten by this helper.
20
+ */
21
+ export function stripReflectPromptScaffolding(content) {
22
+ const newline = content.includes("\r\n") ? "\r\n" : "\n";
23
+ const lines = content.split(/\r?\n/);
24
+ const kept = [];
25
+ let stripped = false;
26
+ for (let index = 0; index < lines.length;) {
27
+ if (!REFLECT_AVOID_PATTERNS_RE.test(lines[index] ?? "")) {
28
+ kept.push(lines[index] ?? "");
29
+ index += 1;
30
+ continue;
31
+ }
32
+ stripped = true;
33
+ index += 1;
34
+ while (index < lines.length && !SECTION_BOUNDARY_RE.test(lines[index] ?? ""))
35
+ index += 1;
36
+ }
37
+ return { content: kept.join(newline).replace(/(?:\r?\n){3,}/g, `${newline}${newline}`), stripped };
38
+ }
39
+ /**
40
+ * Return a secret-free rejection reason when generated content is unsafe to
41
+ * persist. `redactedContent` is the same body after applying the dispatch
42
+ * lease's sensitive-value inventory; comparing it avoids exposing the value.
43
+ */
44
+ export function generatedContentRejection(content, redactedContent) {
45
+ if (containsRedactedContent(content)) {
46
+ return `Agent proposal content contains ${REDACTED_CONTENT_MARKER}; refusing to persist already-redacted text.`;
47
+ }
48
+ if (redactedContent !== content) {
49
+ return "Agent proposal content echoed a configured credential; refusing to persist either the secret or a redacted replacement.";
50
+ }
51
+ return undefined;
52
+ }