@fro.bot/systematic 2.14.1 → 2.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -767,13 +767,39 @@ async function getAvailableModels(client, options = {}) {
767
767
  if (response.error !== undefined || response.data === undefined) {
768
768
  return readFallbackCache();
769
769
  }
770
+ const models = buildSetFromProviders(response.data.providers);
771
+ if (models.size === 0) {
772
+ return emptyAvailability();
773
+ }
770
774
  return {
771
775
  status: "api",
772
- models: buildSetFromProviders(response.data.providers)
776
+ models
773
777
  };
774
778
  }
775
779
 
776
780
  // src/lib/config-handler.ts
781
+ function isSystematicAgentConfig(agent) {
782
+ const description = agent?.description;
783
+ return typeof description === "string" && /\(.* - Systematic\)$/.test(description);
784
+ }
785
+ function isSystematicCommandConfig(command) {
786
+ const description = command?.description;
787
+ return typeof description === "string" && (description.startsWith("(Systematic) ") || description.startsWith("(Systematic - Skill) "));
788
+ }
789
+ function mergeSystematicEntries(existing, emitted, shouldDropExisting) {
790
+ const merged = { ...existing ?? {} };
791
+ for (const [key, value] of Object.entries(existing ?? {})) {
792
+ if (shouldDropExisting(key, value)) {
793
+ delete merged[key];
794
+ }
795
+ }
796
+ for (const [key, value] of Object.entries(emitted)) {
797
+ if (Object.hasOwn(merged, key))
798
+ continue;
799
+ merged[key] = value;
800
+ }
801
+ return merged;
802
+ }
777
803
  function toTitleCase(name) {
778
804
  return name.split("-").map((segment) => segment.length > 0 ? segment.charAt(0).toUpperCase() + segment.slice(1) : segment).join("-");
779
805
  }
@@ -912,30 +938,37 @@ function applyAgentOverlays(config, agentInfo, overlays, availabilitySet) {
912
938
  addPermissionRules(permissionRules, config.permission);
913
939
  }
914
940
  result.temperature = inferBuiltInTemperature(agentInfo.name, result.description);
915
- if (agentInfo.category && availabilitySet !== undefined) {
916
- const resolved = resolveSourceModel(agentInfo.category, availabilitySet);
917
- result.model = `${resolved.provider}/${resolved.model}`;
918
- if (resolved.variant !== undefined) {
919
- result.variant = resolved.variant;
920
- } else {
921
- delete result.variant;
922
- }
923
- }
924
- if (categoryOverlay) {
925
- applyOverlayObjectWithVariantClearing(result, categoryOverlay.value, permissionRules);
926
- }
927
- if (exactOverlay) {
928
- applyOverlayObjectWithVariantClearing(result, exactOverlay.value, permissionRules);
941
+ applySourceModelDefault(result, agentInfo, availabilitySet);
942
+ applyAgentOverlay(result, categoryOverlay?.value, permissionRules);
943
+ applyAgentOverlay(result, exactOverlay?.value, permissionRules);
944
+ applyPermissionOverlay(result, permissionRules, hasPermissionOverlay);
945
+ return result;
946
+ }
947
+ function applySourceModelDefault(target, agentInfo, availabilitySet) {
948
+ if (!agentInfo.category || availabilitySet === undefined)
949
+ return;
950
+ const resolved = resolveSourceModel(agentInfo.category, availabilitySet);
951
+ target.model = `${resolved.provider}/${resolved.model}`;
952
+ if (resolved.variant !== undefined) {
953
+ target.variant = resolved.variant;
954
+ } else {
955
+ delete target.variant;
929
956
  }
930
- if (hasPermissionOverlay) {
931
- const permission = permissionFromRules(permissionRules);
932
- if (permission) {
933
- result.permission = permission;
934
- } else {
935
- delete result.permission;
936
- }
957
+ }
958
+ function applyAgentOverlay(target, overlay, permissionRules) {
959
+ if (overlay === undefined)
960
+ return;
961
+ applyOverlayObjectWithVariantClearing(target, overlay, permissionRules);
962
+ }
963
+ function applyPermissionOverlay(target, permissionRules, hasPermissionOverlay) {
964
+ if (!hasPermissionOverlay)
965
+ return;
966
+ const permission = permissionFromRules(permissionRules);
967
+ if (permission) {
968
+ target.permission = permission;
969
+ } else {
970
+ delete target.permission;
937
971
  }
938
- return result;
939
972
  }
940
973
  function overlayControlsPermission(overlay) {
941
974
  return overlay !== undefined && (Object.hasOwn(overlay, "permission") || Object.hasOwn(overlay, "skills"));
@@ -1055,42 +1088,54 @@ function createConfigHandler(deps) {
1055
1088
  const { config: systematicConfig, overlays } = loadConfigWithSources(directory);
1056
1089
  const existingAgents = { ...config.agent ?? {} };
1057
1090
  const existingCommands = { ...config.command ?? {} };
1091
+ const nativeAgents = Object.fromEntries(Object.entries(existingAgents).filter(([, agent]) => !isSystematicAgentConfig(agent)));
1058
1092
  const bundledSkills = collectSkillsAsCommands(bundledSkillsDir, systematicConfig.disabled_skills);
1059
1093
  const enabledSkillNames = collectEnabledSkillNames(bundledSkillsDir, systematicConfig.disabled_skills);
1060
1094
  const inventory = buildBundledAgentInventory(bundledAgentsDir2, systematicConfig.disabled_agents);
1095
+ const availability = deps.client ? await getAvailableModels(deps.client) : undefined;
1096
+ const availabilitySet = availability && availability.status !== "unknown" ? availability.models : undefined;
1061
1097
  assertSourceCategoryModelCoverage(inventory.categories);
1062
1098
  const validatedOverlays = validateAgentOverlays({
1063
1099
  inventory,
1064
1100
  overlays,
1065
- nativeAgents: existingAgents,
1101
+ nativeAgents,
1066
1102
  enabledSkills: enabledSkillNames
1067
1103
  });
1068
1104
  const resolvedOverlays = resolveAgentOverlaySet(validatedOverlays);
1069
- const availability = deps.client ? await getAvailableModels(deps.client) : undefined;
1070
- const availabilitySet = availability && availability.status !== "unknown" ? availability.models : undefined;
1071
- const bundledAgents = collectAgents(bundledAgentsDir2, systematicConfig.disabled_agents, existingAgents, resolvedOverlays, availabilitySet);
1105
+ const bundledAgents = collectAgents(bundledAgentsDir2, systematicConfig.disabled_agents, nativeAgents, resolvedOverlays, availabilitySet);
1072
1106
  const bundledCommands = collectCommands(bundledCommandsDir, systematicConfig.disabled_commands);
1073
- config.agent = {
1074
- ...bundledAgents,
1075
- ...existingAgents
1076
- };
1077
- config.command = {
1078
- ...bundledCommands,
1079
- ...bundledSkills,
1080
- ...existingCommands
1081
- };
1107
+ const bundledAgentKeys = new Set(Object.keys(bundledAgents));
1108
+ config.agent = mergeSystematicEntries(existingAgents, bundledAgents, (key, agent) => bundledAgentKeys.has(key) && isSystematicAgentConfig(agent));
1109
+ const emittedCommands = { ...bundledCommands, ...bundledSkills };
1110
+ const emittedCommandKeys = new Set(Object.keys(emittedCommands));
1111
+ config.command = mergeSystematicEntries(existingCommands, emittedCommands, (key, command) => isSystematicCommandConfig(command) && (emittedCommandKeys.has(key) || isSystematicOwnedCommandKey(key)));
1082
1112
  registerSkillsPaths(config, bundledSkillsDir);
1083
1113
  };
1084
1114
  }
1085
1115
  function registerSkillsPaths(config, skillsDir) {
1086
1116
  const extended = config;
1087
1117
  const paths = extended.skills?.paths ?? [];
1088
- const nextPaths = paths.includes(skillsDir) ? [...paths] : [...paths, skillsDir];
1118
+ const nextPaths = removeSystematicSkillPaths(paths);
1119
+ if (!nextPaths.includes(skillsDir))
1120
+ nextPaths.push(skillsDir);
1089
1121
  extended.skills = {
1090
1122
  ...extended.skills,
1091
1123
  paths: nextPaths
1092
1124
  };
1093
1125
  }
1126
+ function removeSystematicSkillPaths(paths) {
1127
+ return paths.filter((path6) => !isSystematicSkillPath(path6));
1128
+ }
1129
+ function isSystematicSkillPath(path6) {
1130
+ const normalizedPath = normalizePath(path6);
1131
+ return normalizedPath.endsWith("/.config/opencode/systematic/skills") || normalizedPath.endsWith("/.cache/opencode/systematic/skills") || normalizedPath.endsWith("/.local/share/opencode/systematic/skills") || normalizedPath.endsWith("/.opencode/systematic/skills") || /(?:^|\/)\.cache\/opencode\/packages\/@fro\.bot\/systematic@[^/]+\/node_modules\/@fro\.bot\/systematic\/skills(?:$|\/)/u.test(normalizedPath);
1132
+ }
1133
+ function normalizePath(path6) {
1134
+ return path6.replaceAll("\\", "/").replace(/\/+$/u, "");
1135
+ }
1136
+ function isSystematicOwnedCommandKey(key) {
1137
+ return key.startsWith("systematic:") || key.startsWith("ce:");
1138
+ }
1094
1139
 
1095
1140
  // src/lib/skill-tool.ts
1096
1141
  import fs4 from "fs";
@@ -22,16 +22,22 @@ export interface OpencodeClientLike {
22
22
  * Outcome of model availability discovery.
23
23
  *
24
24
  * - `api`: The OpenCode server's `/config/providers` endpoint responded with
25
- * a connected-providers payload. `models` may be empty if no providers
26
- * are authenticated; that is authoritative.
25
+ * a connected-providers payload AND `models` is non-empty. An authoritatively
26
+ * empty response (`data.providers = []`, or providers present with zero
27
+ * models) collapses to `'unknown'` instead — see below — because the
28
+ * operational consequence is identical and downstream consumers should treat
29
+ * both cases the same way.
27
30
  * - `cache`: The API call failed (error envelope, thrown, or timed out) and
28
31
  * the local `models.json` cache was readable. `models` reflects whatever
29
- * OpenCode last wrote to disk.
30
- * - `unknown`: Both the API call and the cache fallback failed (cache
31
- * missing, unreadable, corrupt, or schema-mismatched). Resolution should
32
- * degrade gracefully callers should treat `unknown` as a signal to
33
- * skip source-default model pinning so users do not get agents pinned
34
- * to inaccessible models. `models` is the empty set.
32
+ * OpenCode last wrote to disk. The cache may itself be empty; callers that
33
+ * need to distinguish "cached empty" from "cached with content" should
34
+ * inspect `models.size`.
35
+ * - `unknown`: Either both the API call and the cache fallback failed (cache
36
+ * missing, unreadable, corrupt, or schema-mismatched), OR the API call
37
+ * succeeded with zero usable models. Resolution should degrade gracefully
38
+ * callers should treat `unknown` as a signal to skip source-default model
39
+ * pinning so users do not get agents pinned to inaccessible models.
40
+ * `models` is the empty set.
35
41
  */
36
42
  export type DiscoveryStatus = 'api' | 'cache' | 'unknown';
37
43
  export interface ModelAvailability {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "2.14.1",
3
+ "version": "2.14.3",
4
4
  "description": "Structured engineering workflows for OpenCode",
5
5
  "type": "module",
6
6
  "homepage": "https://fro.bot/systematic",
@@ -68,7 +68,7 @@
68
68
  "@opencode-ai/sdk": "1.14.48",
69
69
  "@types/bun": "latest",
70
70
  "@types/js-yaml": "4.0.9",
71
- "@types/node": "24.12.3",
71
+ "@types/node": "24.12.4",
72
72
  "ajv": "8.20.0",
73
73
  "ajv-formats": "3.0.1",
74
74
  "conventional-changelog-conventionalcommits": "9.3.1",