diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 44042e2..007a803 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -9,7 +9,7 @@ One install wires **all of**: | Host tools | Capability groups **ops / topology** (one group ↔ one skill). Default ops in Ops preset; topology and all public off. Other agents may mount `dsh-netxops/tools-ops|topology` | | Companion plugins | **dsh-im-ops** + **dsh-ops-cron** — add as **direct** profile deps in the same command (DSH/pnpm blocks `github:` as transitive deps) | | Plugins card | Settings → Plugins → **Netx Ops** (URL / API lang / thinking+reply language / token / capability groups) | -| Agent preset + skills | Settings → Agent presets → **Netx Ops**. Host composes from shipped **standard** + Netx Ops overlays (no preset inheritance): web uses `~/.dsh/.agent-presets/netxops`; Desktop 0.2 also syncs a declarative `@deepseek-ai/dsh-agent-preset` row into the profile patch. Restart Desktop once after upgrade. Playbooks follow the same group toggles | +| Agent preset + skills | Settings → Agent presets → **Netx Ops**. Host composes from **this host's** shipped **standard** + Netx Ops overlays (no preset inheritance): Desktop 0.2 prefers `@deepseek-ai/dsh-web-app/presets/standard.patch.yml` (`workflow-ptc`); web/CLI use `dsh-agent-presets` directory standard. Web installs land in `~/.dsh/.agent-presets/netxops`; Desktop also syncs a declarative `@deepseek-ai/dsh-agent-preset` row into the profile patch. Restart Desktop once after upgrade. Playbooks follow the same group toggles | You do **not** run `link-preset.ps1` for normal use. That script is only a manual fallback. diff --git a/lib/index.js b/lib/index.js index 4aef007..d614f16 100644 --- a/lib/index.js +++ b/lib/index.js @@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url"; var NETXOPS_PRESET_ID = "netxops"; var DESKTOP_PATCH_BEGIN = "# BEGIN dsh-netxops-preset (managed)"; var DESKTOP_PATCH_END = "# END dsh-netxops-preset (managed)"; +var PATCH_PLUGIN_INDENT = " "; function packageRoot() { return join(dirname(fileURLToPath(import.meta.url)), ".."); } @@ -31,22 +32,72 @@ function resolveDshHome() { return fromEnv; return join(homedir(), ".dsh"); } +function electronResourcesRoots() { + const roots = []; + const rp = process.resourcesPath; + if (typeof rp === "string" && rp.length > 0) + roots.push(rp); + roots.push(join(dirname(process.execPath), "resources")); + return roots; +} +function readableFile(path) { + try { + readFileSync(path, { encoding: "utf8", flag: "r" }); + return true; + } catch { + return false; + } +} +function resolveDesktopStandardPatchPath() { + const dshHome = resolveDshHome(); + const envOverride = process.env.NETXOPS_STANDARD_PATCH?.trim(); + const candidates = []; + if (envOverride) + candidates.push(envOverride); + for (const resources of electronResourcesRoots()) { + candidates.push(join(resources, "app.asar", "dsh", "node_modules", "@deepseek-ai", "dsh-web-app", "presets", "standard.patch.yml"), join(resources, "app.asar.unpacked", "dsh", "node_modules", "@deepseek-ai", "dsh-web-app", "presets", "standard.patch.yml")); + } + const requireAnchors = [ + join(dshHome, "profiles", "desktop", "package.json"), + join(dshHome, "profiles", "desktop", "node_modules", "dsh-netxops", "package.json"), + join(packageRoot(), "package.json"), + join(dshHome, "profiles", "node_modules", "@deepseek-ai", "dsh-web-app", "package.json"), + join(dshHome, "profiles", "web", "package.json") + ]; + for (const anchor of requireAnchors) { + if (!existsSync(anchor)) + continue; + try { + const req = createRequire2(anchor); + try { + candidates.push(req.resolve("@deepseek-ai/dsh-web-app/presets/standard.patch.yml")); + } catch {} + const pkg = dirname(req.resolve("@deepseek-ai/dsh-web-app/package.json")); + candidates.push(join(pkg, "presets", "standard.patch.yml")); + } catch {} + } + for (const path of candidates) { + if (readableFile(path)) + return path; + } + return null; +} function resolveStandardPresetDir() { const dshHome = resolveDshHome(); const candidates = [ - join(dshHome, "profiles", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard"), - join(dshHome, "profiles", "web", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard"), join(dshHome, "profiles", "desktop", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard"), - join(dirname(fileURLToPath(import.meta.url)), "..", "..", "DeepSeekHarness", "packages", "preset", "agent-presets", "presets", "standard") + join(dshHome, "profiles", "web", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard"), + join(dirname(fileURLToPath(import.meta.url)), "..", "..", "DeepSeekHarness", "packages", "preset", "agent-presets", "presets", "standard"), + join(dshHome, "profiles", "node_modules", "@deepseek-ai", "dsh-agent-presets", "presets", "standard") ]; for (const dir of candidates) { if (existsSync(join(dir, "agent.cordis.yml"))) return dir; } for (const nm of [ - join(dshHome, "profiles", "node_modules"), + join(dshHome, "profiles", "desktop", "node_modules"), join(dshHome, "profiles", "web", "node_modules"), - join(dshHome, "profiles", "desktop", "node_modules") + join(dshHome, "profiles", "node_modules") ]) { const pkgJson = join(nm, "@deepseek-ai", "dsh-agent-presets", "package.json"); if (!existsSync(pkgJson)) @@ -68,6 +119,155 @@ function resolveStandardPresetDir() { } catch {} return null; } +function desktopRuntimeLikelyPresent() { + if (typeof process.versions.electron === "string") + return true; + for (const resources of electronResourcesRoots()) { + if (existsSync(join(resources, "app.asar"))) + return true; + } + const localAppData = process.env.LOCALAPPDATA?.trim(); + if (localAppData) { + if (existsSync(join(localAppData, "Programs", "DeepSeek Harness", "resources", "app.asar"))) { + return true; + } + } + return false; +} +function hostResolveBases() { + const dshHome = resolveDshHome(); + const bases = []; + for (const resources of electronResourcesRoots()) { + bases.push(join(resources, "app.asar", "dsh", "package.json")); + bases.push(join(resources, "app.asar", "dsh", "node_modules")); + } + const localAppData = process.env.LOCALAPPDATA?.trim(); + if (localAppData) { + const asarDsh = join(localAppData, "Programs", "DeepSeek Harness", "resources", "app.asar", "dsh"); + bases.push(join(asarDsh, "package.json"), join(asarDsh, "node_modules")); + } + bases.push(join(dshHome, "profiles", "desktop", "package.json"), join(dshHome, "profiles", "desktop", "node_modules"), join(dshHome, "profiles", "web", "package.json"), join(dshHome, "profiles", "web", "node_modules"), join(dshHome, "profiles", "node_modules"), join(packageRoot(), "package.json")); + return [...new Set(bases.filter((path) => path.includes("app.asar") || existsSync(path)))]; +} +function packageInstalledAbove(name, bases) { + if (name.startsWith("cordis:") || name.startsWith("node:")) + return true; + const pkg = name.split("/").slice(0, name.startsWith("@") ? 2 : 1).join("/"); + for (const base of bases) { + let dir = base.endsWith("package.json") ? dirname(base) : base; + for (;; ) { + if (readableFile(join(dir, "node_modules", pkg, "package.json"))) + return true; + if (readableFile(join(dir, pkg, "package.json"))) + return true; + const parent = dirname(dir); + if (parent === dir) + break; + dir = parent; + } + const requireAnchor = base.endsWith("package.json") ? base : existsSync(join(base, "package.json")) ? join(base, "package.json") : null; + if (requireAnchor !== null) { + try { + createRequire2(requireAnchor).resolve(`${pkg}/package.json`); + return true; + } catch {} + } + } + return false; +} +function collectCompositionPackageNames(yaml) { + const names = []; + for (const match of yaml.matchAll(/^\s*name:\s*(.+)$/gm)) { + let raw = match[1].trim(); + if (raw.startsWith("'") && raw.endsWith("'") || raw.startsWith('"') && raw.endsWith('"')) { + raw = raw.slice(1, -1); + } + if (raw.length === 0) + continue; + if (raw.startsWith("./") || raw.startsWith("../") || raw.startsWith("file:")) + continue; + names.push(raw); + } + return [...new Set(names)]; +} +function unresolvableCompositionPackages(yaml, bases) { + return collectCompositionPackageNames(yaml).filter((name) => !packageInstalledAbove(name, bases)); +} +function remapWorkflowToPtc(cordis) { + return cordis.replaceAll("- id: workflow-worker-thread", "- id: workflow-ptc").replaceAll("@deepseek-ai/dsh-workflow-worker-thread", "@deepseek-ai/dsh-workflow-ptc"); +} +function remapWorkflowToWorkerThread(cordis) { + return cordis.replaceAll("- id: workflow-ptc", "- id: workflow-worker-thread").replaceAll("@deepseek-ai/dsh-workflow-ptc", "@deepseek-ai/dsh-workflow-worker-thread"); +} +function alignWorkflowEngineForHost(cordis, bases, host = "auto") { + const hasPtc = packageInstalledAbove("@deepseek-ai/dsh-workflow-ptc", bases); + const hasWorker = packageInstalledAbove("@deepseek-ai/dsh-workflow-worker-thread", bases); + const desktop = host === "desktop" || host === "auto" && desktopRuntimeLikelyPresent(); + if (hasPtc && !hasWorker) + return remapWorkflowToPtc(cordis); + if (hasPtc && hasWorker && desktop) + return remapWorkflowToPtc(cordis); + if (!hasPtc && hasWorker && desktop) { + return remapWorkflowToPtc(cordis); + } + if (!hasPtc && !hasWorker && desktop && cordis.includes("workflow-worker-thread")) { + return remapWorkflowToPtc(cordis); + } + if (hasWorker && !hasPtc && !desktop) + return remapWorkflowToWorkerThread(cordis); + if (hasWorker && hasPtc && host === "web") + return remapWorkflowToWorkerThread(cordis); + return cordis; +} +function extractPluginsBodyFromStandardPatch(patchYaml) { + const text = patchYaml.replace(/\r\n/g, ` +`); + const marker = /^([ \t]*)plugins:\s*$/m; + const match = marker.exec(text); + if (match === null) { + throw new Error("standard.patch.yml has no plugins: block"); + } + const pluginsKeyIndent = match[1].length; + const start = match.index + match[0].length; + const rest = text.slice(start).replace(/^\n/, ""); + const lines = rest.split(` +`); + const body = []; + for (const line of lines) { + if (line.trim() === "") { + body.push(line); + continue; + } + const indent = line.match(/^[ \t]*/)?.[0].length ?? 0; + if (indent <= pluginsKeyIndent) + break; + body.push(line); + } + while (body.length > 0 && body[body.length - 1].trim() === "") + body.pop(); + return body.join(` +`); +} +function pluginsBodyToAgentCordis(pluginsBody) { + const prefix = PATCH_PLUGIN_INDENT; + return `${pluginsBody.replace(/\r\n/g, ` +`).split(` +`).map((line) => { + if (line.length === 0) + return ""; + if (line.startsWith(prefix)) + return line.slice(prefix.length); + return line.trimStart(); + }).join(` +`).trimEnd()} +`; +} +function agentCordisToPluginsBody(agentCordis) { + return agentCordis.replace(/\r\n/g, ` +`).split(` +`).map((line) => line.length === 0 ? "" : `${PATCH_PLUGIN_INDENT}${line}`).join(` +`); +} function replaceTopLevelEntry(cordis, id, replacement) { const lines = cordis.replace(/\r\n/g, ` `).split(` @@ -146,6 +346,9 @@ function readPresetMeta(metaPath) { order: (metaText.match(/^order:\s*(\d+)/m) || [])[1] || "50" }; } +function desktopProfilePresent() { + return existsSync(join(resolveDshHome(), "profiles", "desktop")); +} function composeNetxopsPresetArtifacts() { const overlayRoot = join(packageRoot(), "presets", NETXOPS_PRESET_ID); const personaPath = join(overlayRoot, "PERSONA.md"); @@ -153,21 +356,43 @@ function composeNetxopsPresetArtifacts() { if (!existsSync(personaPath) || !existsSync(metaPath)) { throw new Error(`bundled preset overlay missing under ${overlayRoot}`); } - const standardDir = resolveStandardPresetDir(); - if (standardDir === null) { - throw new Error("cannot find shipped standard preset (@deepseek-ai/dsh-agent-presets)"); - } const meta = readPresetMeta(metaPath); - let agentCordis = replaceTopLevelEntry(readFileSync(join(standardDir, "agent.cordis.yml"), "utf8"), "persona", personaEntryFromMarkdown(readFileSync(personaPath, "utf8"))); + const bases = hostResolveBases(); + const patchPath = resolveDesktopStandardPatchPath(); + const usePatch = patchPath !== null && process.env.NETXOPS_PREFER_DESKTOP_PATCH !== "0" && (typeof process.versions.electron === "string" || process.env.NETXOPS_PREFER_DESKTOP_PATCH === "1" || desktopProfilePresent() || desktopRuntimeLikelyPresent()); + let standardSource; + let standardKind; + let standardDir; + let agentCordis; + if (usePatch && patchPath !== null) { + standardSource = patchPath; + standardKind = "desktop-patch"; + standardDir = null; + agentCordis = pluginsBodyToAgentCordis(extractPluginsBodyFromStandardPatch(readFileSync(patchPath, "utf8"))); + } else { + const dir = resolveStandardPresetDir(); + if (dir === null) { + throw new Error("cannot find host standard preset (Desktop standard.patch.yml or @deepseek-ai/dsh-agent-presets)"); + } + standardSource = dir; + standardKind = "directory"; + standardDir = dir; + agentCordis = readFileSync(join(dir, "agent.cordis.yml"), "utf8"); + } + agentCordis = replaceTopLevelEntry(agentCordis, "persona", personaEntryFromMarkdown(readFileSync(personaPath, "utf8"))); agentCordis = ensureNetxopsToolsRow(agentCordis); + agentCordis = alignWorkflowEngineForHost(agentCordis, bases); if (!agentCordis.endsWith(` `)) agentCordis += ` `; - const pluginsBody = agentCordis.replace(/\r\n/g, ` -`).split(` -`).map((line) => line.length === 0 ? "" : ` ${line}`).join(` -`); + const missing = unresolvableCompositionPackages(agentCordis, bases).filter((name) => !name.startsWith("dsh-netxops")); + const desktopAsar = desktopRuntimeLikelyPresent(); + const blocking = missing.filter((name) => !(desktopAsar && name.startsWith("@deepseek-ai/"))); + if (blocking.length > 0) { + throw new Error(`composed Netx Ops preset names packages the host cannot resolve: ${blocking.join(", ")} ` + `(standard-source: ${standardSource})`); + } + const pluginsBody = agentCordisToPluginsBody(agentCordis); const declarativePatch = `# Declarative Netx Ops preset for Desktop 0.2 (@deepseek-ai/dsh-agent-preset). # Generated from host \`standard\` + presets/netxops overlays — do not hand-edit. # Regenerate: bun run scripts/gen-preset-patch.mjs @@ -183,7 +408,7 @@ function composeNetxopsPresetArtifacts() { plugins: ${pluginsBody} `; - return { standardDir, ...meta, agentCordis, declarativePatch }; + return { standardSource, standardKind, standardDir, ...meta, agentCordis, declarativePatch }; } function installDirectoryPreset(artifacts, logger) { const overlayRoot = join(packageRoot(), "presets", NETXOPS_PRESET_ID); @@ -192,7 +417,10 @@ function installDirectoryPreset(artifacts, logger) { mkdirSync(destParent, { recursive: true }); if (existsSync(dest)) rmSync(dest, { recursive: true, force: true }); - cpSync(artifacts.standardDir, dest, { recursive: true }); + mkdirSync(dest, { recursive: true }); + if (artifacts.standardDir !== null && existsSync(artifacts.standardDir)) { + cpSync(artifacts.standardDir, dest, { recursive: true }); + } writeFileSync(join(dest, "preset.yml"), readFileSync(join(overlayRoot, "preset.yml"), "utf8")); writeFileSync(join(dest, "PERSONA.md"), readFileSync(join(overlayRoot, "PERSONA.md"), "utf8")); writeFileSync(join(dest, "agent.cordis.yml"), artifacts.agentCordis); @@ -200,13 +428,13 @@ function installDirectoryPreset(artifacts, logger) { if (existsSync(skillsSrc)) cpSync(skillsSrc, join(dest, "skills"), { recursive: true }); writeFileSync(join(dest, ".dsh-netxops-managed"), [ - "composed-from: standard", - `standard-source: ${artifacts.standardDir}`, + `composed-from: ${artifacts.standardKind}`, + `standard-source: ${artifacts.standardSource}`, `at: ${new Date().toISOString()}`, "" ].join(` `), "utf8"); - logger.info("netxops: directory preset composed from standard → %s", dest); + logger.info("netxops: directory preset composed from %s → %s", artifacts.standardSource, dest); } function syncDeclarativeIntoProfilePatch(profileName, declarativePatch, logger) { if (profileName !== "desktop") diff --git a/package.json b/package.json index 2f0f08d..3c42576 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-netxops", - "version": "0.1.48", + "version": "0.1.49", "description": "DeepSeek Harness Netx Ops: ops/topology + IM delivery + session export + knowledge-base MANIFEST/_skills + localSkills", "license": "MIT", "type": "module", diff --git a/presets/netxops.preset.patch.yml b/presets/netxops.preset.patch.yml index ecb4a27..c187d3a 100644 --- a/presets/netxops.preset.patch.yml +++ b/presets/netxops.preset.patch.yml @@ -314,8 +314,8 @@ backgroundMode: one-shot maxDepth: provider-managed - - id: workflow-worker-thread - name: '@deepseek-ai/dsh-workflow-worker-thread' + - id: workflow-ptc + name: '@deepseek-ai/dsh-workflow-ptc' config: provider: spawn diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index 56e1eb1..0e2622c 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -1,14 +1,14 @@ # Netx Ops delta only — NOT a full agent composition. # -# At Host apply, `ensureAgentPresetInstalled` copies the Harness shipped -# `standard` preset into `$DSH_HOME/.agent-presets/netxops`, then: +# At Host apply, `ensureAgentPresetInstalled` composes the host's shipped +# `standard` (Desktop 0.2: dsh-web-app `standard.patch.yml`; web/CLI: +# dsh-agent-presets directory) into `$DSH_HOME/.agent-presets/netxops`, then: # 1. replaces `persona` with presets/netxops/PERSONA.md # 2. ensures the `netxops-tools` row below is present -# 3. copies presets/netxops/skills/ -# -# DSH has no preset inheritance; composing from `standard` at install time is -# how Netx Ops stays aligned with the running Harness without embedding (or -# declaring via `@deepseek-ai/dsh-agent-preset`) a full standard tree. +# 3. aligns workflow engine package to what the host can resolve +# 4. copies presets/netxops/skills/ +# Desktop also syncs a declarative `@deepseek-ai/dsh-agent-preset` row into +# the profile patch. DSH has no preset inheritance. - id: netxops-tools name: dsh-netxops/tools diff --git a/scripts/gen-preset-patch.mjs b/scripts/gen-preset-patch.mjs index 5a3183f..96a238b 100644 --- a/scripts/gen-preset-patch.mjs +++ b/scripts/gen-preset-patch.mjs @@ -20,7 +20,10 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..') const artifacts = composeNetxopsPresetArtifacts() const declarativePath = join(root, 'presets', 'netxops.preset.patch.yml') writeFileSync(declarativePath, artifacts.declarativePatch) -console.log(`wrote ${declarativePath} (${artifacts.declarativePatch.length} bytes) from ${artifacts.standardDir}`) +console.log( + `wrote ${declarativePath} (${artifacts.declarativePatch.length} bytes) ` + + `from ${artifacts.standardKind}: ${artifacts.standardSource}`, +) const host = readFileSync(join(root, 'cordis.patch.yml'), 'utf8') const bundle = `# Host-only bundle patch (web-safe). diff --git a/src/agent-preset-install.ts b/src/agent-preset-install.ts index 38be89f..9fd3454 100644 --- a/src/agent-preset-install.ts +++ b/src/agent-preset-install.ts @@ -4,11 +4,15 @@ * - Web / source CLI (`@deepseek-ai/dsh-agent-presets`): directory under * `$DSH_HOME/.agent-presets/netxops`. * - Desktop 0.2 (`@deepseek-ai/dsh-agent-preset` + registry): declarative - * insert row in `presets/netxops.preset.patch.yml`, synced into the desktop - * profile `cordis.patch.yml` on apply (not the package bundle — keeps web safe). + * insert row synced into the desktop profile `cordis.patch.yml` on apply. * - * Both are composed from the host shipped `standard` + Netx Ops overlays - * (DSH has no preset `extends`). + * Both are composed from the **host's** shipped `standard` + Netx Ops overlays + * (DSH has no preset `extends`). Desktop 0.2 ships standard as + * `@deepseek-ai/dsh-web-app/presets/standard.patch.yml` (`workflow-ptc`); the + * CLI heal tree under `$DSH_HOME/profiles/node_modules` may still carry an + * older `dsh-agent-presets` standard (`workflow-worker-thread`). Prefer the + * Desktop patch when present, and refuse to write a composition whose package + * names cannot resolve from the host anchors. */ import { @@ -30,6 +34,9 @@ export const NETXOPS_PRESET_ID = 'netxops' const DESKTOP_PATCH_BEGIN = '# BEGIN dsh-netxops-preset (managed)' const DESKTOP_PATCH_END = '# END dsh-netxops-preset (managed)' +/** Indent of plugin rows inside `dsh-web-app/presets/*.patch.yml`. */ +const PATCH_PLUGIN_INDENT = ' ' + function packageRoot(): string { return join(dirname(fileURLToPath(import.meta.url)), '..') } @@ -40,23 +47,93 @@ function resolveDshHome(): string { return join(homedir(), '.dsh') } -/** Locate the shipped `standard` preset directory on this machine. */ +function electronResourcesRoots(): string[] { + const roots: string[] = [] + const rp = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath + if (typeof rp === 'string' && rp.length > 0) roots.push(rp) + // Packaged Desktop: `/DeepSeek Harness.exe` beside `resources/`. + roots.push(join(dirname(process.execPath), 'resources')) + return roots +} + +function readableFile(path: string): boolean { + try { + readFileSync(path, { encoding: 'utf8', flag: 'r' }) + return true + } catch { + return false + } +} + +/** + * Locate Desktop 0.2's declarative `standard` patch (`workflow-ptc`). + * Prefer Electron `app.asar` (what the running Desktop actually loads) over + * `$DSH_HOME/profiles/node_modules` (often the older CLI heal tree). + */ +export function resolveDesktopStandardPatchPath(): string | null { + const dshHome = resolveDshHome() + const envOverride = process.env.NETXOPS_STANDARD_PATCH?.trim() + const candidates: string[] = [] + if (envOverride) candidates.push(envOverride) + + for (const resources of electronResourcesRoots()) { + candidates.push( + join(resources, 'app.asar', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml'), + join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml'), + ) + } + + const requireAnchors = [ + join(dshHome, 'profiles', 'desktop', 'package.json'), + join(dshHome, 'profiles', 'desktop', 'node_modules', 'dsh-netxops', 'package.json'), + join(packageRoot(), 'package.json'), + join(dshHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh-web-app', 'package.json'), + join(dshHome, 'profiles', 'web', 'package.json'), + ] + for (const anchor of requireAnchors) { + if (!existsSync(anchor)) continue + try { + const req = createRequire(anchor) + try { + candidates.push(req.resolve('@deepseek-ai/dsh-web-app/presets/standard.patch.yml')) + } catch { + // older web-app builds do not export the subpath + } + const pkg = dirname(req.resolve('@deepseek-ai/dsh-web-app/package.json')) + candidates.push(join(pkg, 'presets', 'standard.patch.yml')) + } catch { + // try next anchor + } + } + + for (const path of candidates) { + if (readableFile(path)) return path + } + return null +} + +/** + * Locate the shipped directory `standard` (`agent.cordis.yml`). + * Profile-local copies win over the shared `$DSH_HOME/profiles/node_modules` + * heal tree, which is frequently an older CLI generation than Desktop. + */ export function resolveStandardPresetDir(): string | null { const dshHome = resolveDshHome() const candidates = [ - join(dshHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'), - join(dshHome, 'profiles', 'web', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'), join(dshHome, 'profiles', 'desktop', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'), + join(dshHome, 'profiles', 'web', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'), // Source checkout used by `dsh` CLI / local Harness. join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'DeepSeekHarness', 'packages', 'preset', 'agent-presets', 'presets', 'standard'), + // Shared heal tree last — often lags Desktop 0.2. + join(dshHome, 'profiles', 'node_modules', '@deepseek-ai', 'dsh-agent-presets', 'presets', 'standard'), ] for (const dir of candidates) { if (existsSync(join(dir, 'agent.cordis.yml'))) return dir } for (const nm of [ - join(dshHome, 'profiles', 'node_modules'), - join(dshHome, 'profiles', 'web', 'node_modules'), join(dshHome, 'profiles', 'desktop', 'node_modules'), + join(dshHome, 'profiles', 'web', 'node_modules'), + join(dshHome, 'profiles', 'node_modules'), ]) { const pkgJson = join(nm, '@deepseek-ai', 'dsh-agent-presets', 'package.json') if (!existsSync(pkgJson)) continue @@ -80,6 +157,193 @@ export function resolveStandardPresetDir(): string | null { return null } +/** True when a packaged Desktop install is on disk (asar may be opaque to plain Node). */ +export function desktopRuntimeLikelyPresent(): boolean { + if (typeof process.versions.electron === 'string') return true + for (const resources of electronResourcesRoots()) { + if (existsSync(join(resources, 'app.asar'))) return true + } + const localAppData = process.env.LOCALAPPDATA?.trim() + if (localAppData) { + if (existsSync(join(localAppData, 'Programs', 'DeepSeek Harness', 'resources', 'app.asar'))) { + return true + } + } + return false +} + +/** Host roots used for bare package health checks (same walk shape as DSH discovery). */ +export function hostResolveBases(): string[] { + const dshHome = resolveDshHome() + const bases: string[] = [] + for (const resources of electronResourcesRoots()) { + bases.push(join(resources, 'app.asar', 'dsh', 'package.json')) + bases.push(join(resources, 'app.asar', 'dsh', 'node_modules')) + } + const localAppData = process.env.LOCALAPPDATA?.trim() + if (localAppData) { + const asarDsh = join(localAppData, 'Programs', 'DeepSeek Harness', 'resources', 'app.asar', 'dsh') + bases.push(join(asarDsh, 'package.json'), join(asarDsh, 'node_modules')) + } + bases.push( + join(dshHome, 'profiles', 'desktop', 'package.json'), + join(dshHome, 'profiles', 'desktop', 'node_modules'), + join(dshHome, 'profiles', 'web', 'package.json'), + join(dshHome, 'profiles', 'web', 'node_modules'), + join(dshHome, 'profiles', 'node_modules'), + join(packageRoot(), 'package.json'), + ) + // Keep asar paths even when plain Node cannot stat them — Electron can. + return [...new Set(bases.filter((path) => path.includes('app.asar') || existsSync(path)))] +} + +/** + * Whether a package (optionally with subpath) is installed above any base. + * Mirrors `@deepseek-ai/dsh-agent-presets` discovery `packageInstalled`. + */ +export function packageInstalledAbove(name: string, bases: readonly string[]): boolean { + if (name.startsWith('cordis:') || name.startsWith('node:')) return true + const pkg = name.split('/').slice(0, name.startsWith('@') ? 2 : 1).join('/') + for (const base of bases) { + let dir = base.endsWith('package.json') ? dirname(base) : base + for (;;) { + if (readableFile(join(dir, 'node_modules', pkg, 'package.json'))) return true + // Electron asar: packages live under `…/app.asar/dsh/node_modules`. + if (readableFile(join(dir, pkg, 'package.json'))) return true + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + const requireAnchor = base.endsWith('package.json') + ? base + : existsSync(join(base, 'package.json')) + ? join(base, 'package.json') + : null + if (requireAnchor !== null) { + try { + createRequire(requireAnchor).resolve(`${pkg}/package.json`) + return true + } catch { + // try next base + } + } + } + return false +} + +/** Package names referenced by top-level / nested `name:` rows in a composition. */ +export function collectCompositionPackageNames(yaml: string): string[] { + const names: string[] = [] + for (const match of yaml.matchAll(/^\s*name:\s*(.+)$/gm)) { + let raw = match[1]!.trim() + if ((raw.startsWith("'") && raw.endsWith("'")) || (raw.startsWith('"') && raw.endsWith('"'))) { + raw = raw.slice(1, -1) + } + if (raw.length === 0) continue + if (raw.startsWith('./') || raw.startsWith('../') || raw.startsWith('file:')) continue + names.push(raw) + } + return [...new Set(names)] +} + +export function unresolvableCompositionPackages( + yaml: string, + bases: readonly string[], +): string[] { + return collectCompositionPackageNames(yaml).filter((name) => !packageInstalledAbove(name, bases)) +} + +function remapWorkflowToPtc(cordis: string): string { + return cordis + .replaceAll('- id: workflow-worker-thread', '- id: workflow-ptc') + .replaceAll('@deepseek-ai/dsh-workflow-worker-thread', '@deepseek-ai/dsh-workflow-ptc') +} + +function remapWorkflowToWorkerThread(cordis: string): string { + return cordis + .replaceAll('- id: workflow-ptc', '- id: workflow-worker-thread') + .replaceAll('@deepseek-ai/dsh-workflow-ptc', '@deepseek-ai/dsh-workflow-worker-thread') +} + +export type WorkflowHostKind = 'desktop' | 'web' | 'auto' + +/** + * Align workflow engine id/name with what the host can resolve. + * Desktop 0.2 ships `dsh-workflow-ptc`; older CLI standards name + * `dsh-workflow-worker-thread`. Pass `host: 'desktop' | 'web'` when composing + * for one surface so a dual-install machine does not poison the other. + */ +export function alignWorkflowEngineForHost( + cordis: string, + bases: readonly string[], + host: WorkflowHostKind = 'auto', +): string { + const hasPtc = packageInstalledAbove('@deepseek-ai/dsh-workflow-ptc', bases) + const hasWorker = packageInstalledAbove('@deepseek-ai/dsh-workflow-worker-thread', bases) + const desktop = host === 'desktop' || (host === 'auto' && desktopRuntimeLikelyPresent()) + if (hasPtc && !hasWorker) return remapWorkflowToPtc(cordis) + if (hasPtc && hasWorker && desktop) return remapWorkflowToPtc(cordis) + if (!hasPtc && hasWorker && desktop) { + // Plain Node cannot see into app.asar; Desktop 0.2 still only has ptc there. + return remapWorkflowToPtc(cordis) + } + if (!hasPtc && !hasWorker && desktop && cordis.includes('workflow-worker-thread')) { + return remapWorkflowToPtc(cordis) + } + if (hasWorker && !hasPtc && !desktop) return remapWorkflowToWorkerThread(cordis) + if (hasWorker && hasPtc && host === 'web') return remapWorkflowToWorkerThread(cordis) + return cordis +} + +/** Pull the `plugins:` list body (already indented) out of a standard.patch.yml. */ +export function extractPluginsBodyFromStandardPatch(patchYaml: string): string { + const text = patchYaml.replace(/\r\n/g, '\n') + const marker = /^([ \t]*)plugins:\s*$/m + const match = marker.exec(text) + if (match === null) { + throw new Error('standard.patch.yml has no plugins: block') + } + const pluginsKeyIndent = match[1]!.length + const start = match.index + match[0].length + const rest = text.slice(start).replace(/^\n/, '') + const lines = rest.split('\n') + const body: string[] = [] + for (const line of lines) { + if (line.trim() === '') { + body.push(line) + continue + } + const indent = line.match(/^[ \t]*/)?.[0].length ?? 0 + if (indent <= pluginsKeyIndent) break + body.push(line) + } + while (body.length > 0 && body[body.length - 1]!.trim() === '') body.pop() + return body.join('\n') +} + +/** Convert patch-indented plugins body into a top-level `agent.cordis.yml` list. */ +export function pluginsBodyToAgentCordis(pluginsBody: string): string { + const prefix = PATCH_PLUGIN_INDENT + return `${pluginsBody + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => { + if (line.length === 0) return '' + if (line.startsWith(prefix)) return line.slice(prefix.length) + return line.trimStart() + }) + .join('\n') + .trimEnd()}\n` +} + +function agentCordisToPluginsBody(agentCordis: string): string { + return agentCordis + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => (line.length === 0 ? '' : `${PATCH_PLUGIN_INDENT}${line}`)) + .join('\n') +} + function replaceTopLevelEntry(cordis: string, id: string, replacement: string): string { const lines = cordis.replace(/\r\n/g, '\n').split('\n') const start = lines.findIndex((line) => line === `- id: ${id}`) @@ -122,7 +386,12 @@ function personaEntryFromMarkdown(md: string): string { } export interface NetxopsPresetArtifacts { - standardDir: string + /** Human-readable source path (directory or patch file). */ + standardSource: string + /** Kind of host standard used for composition. */ + standardKind: 'desktop-patch' | 'directory' + /** Directory to seed `.agent-presets/netxops` from; null when patch-only. */ + standardDir: string | null name: string description: string order: string @@ -141,6 +410,10 @@ function readPresetMeta(metaPath: string): { name: string; description: string; } } +function desktopProfilePresent(): boolean { + return existsSync(join(resolveDshHome(), 'profiles', 'desktop')) +} + /** Compose standard + Netx Ops overlays into directory + declarative artifacts. */ export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts { const overlayRoot = join(packageRoot(), 'presets', NETXOPS_PRESET_ID) @@ -149,28 +422,64 @@ export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts { if (!existsSync(personaPath) || !existsSync(metaPath)) { throw new Error(`bundled preset overlay missing under ${overlayRoot}`) } - const standardDir = resolveStandardPresetDir() - if (standardDir === null) { - throw new Error('cannot find shipped standard preset (@deepseek-ai/dsh-agent-presets)') - } const meta = readPresetMeta(metaPath) - let agentCordis = replaceTopLevelEntry( - readFileSync(join(standardDir, 'agent.cordis.yml'), 'utf8'), + const bases = hostResolveBases() + const patchPath = resolveDesktopStandardPatchPath() + const usePatch = patchPath !== null && ( + process.env.NETXOPS_PREFER_DESKTOP_PATCH !== '0' + ) && ( + typeof process.versions.electron === 'string' + || process.env.NETXOPS_PREFER_DESKTOP_PATCH === '1' + || desktopProfilePresent() + || desktopRuntimeLikelyPresent() + ) + + let standardSource: string + let standardKind: 'desktop-patch' | 'directory' + let standardDir: string | null + let agentCordis: string + + if (usePatch && patchPath !== null) { + standardSource = patchPath + standardKind = 'desktop-patch' + standardDir = null + agentCordis = pluginsBodyToAgentCordis(extractPluginsBodyFromStandardPatch(readFileSync(patchPath, 'utf8'))) + } else { + const dir = resolveStandardPresetDir() + if (dir === null) { + throw new Error( + 'cannot find host standard preset (Desktop standard.patch.yml or @deepseek-ai/dsh-agent-presets)', + ) + } + standardSource = dir + standardKind = 'directory' + standardDir = dir + agentCordis = readFileSync(join(dir, 'agent.cordis.yml'), 'utf8') + } + + agentCordis = replaceTopLevelEntry( + agentCordis, 'persona', personaEntryFromMarkdown(readFileSync(personaPath, 'utf8')), ) agentCordis = ensureNetxopsToolsRow(agentCordis) + agentCordis = alignWorkflowEngineForHost(agentCordis, bases) if (!agentCordis.endsWith('\n')) agentCordis += '\n' - // Match dsh-web-app/presets/*.patch.yml: plugins items are indented 10 spaces - // under ` plugins:` (8). Same-level `- id:` makes YAML treat them as - // siblings of `plugins` and the declaration fails to mount. - const pluginsBody = agentCordis - .replace(/\r\n/g, '\n') - .split('\n') - .map((line) => (line.length === 0 ? '' : ` ${line}`)) - .join('\n') + const missing = unresolvableCompositionPackages(agentCordis, bases) + .filter((name) => !name.startsWith('dsh-netxops')) + // Packaged Desktop keeps most `@deepseek-ai/*` only inside app.asar; plain + // Node cannot probe them, but the Host can. Do not block the write for those. + const desktopAsar = desktopRuntimeLikelyPresent() + const blocking = missing.filter((name) => !(desktopAsar && name.startsWith('@deepseek-ai/'))) + if (blocking.length > 0) { + throw new Error( + `composed Netx Ops preset names packages the host cannot resolve: ${blocking.join(', ')} ` + + `(standard-source: ${standardSource})`, + ) + } + const pluginsBody = agentCordisToPluginsBody(agentCordis) const declarativePatch = `# Declarative Netx Ops preset for Desktop 0.2 (@deepseek-ai/dsh-agent-preset). # Generated from host \`standard\` + presets/netxops overlays — do not hand-edit. # Regenerate: bun run scripts/gen-preset-patch.mjs @@ -186,7 +495,7 @@ export function composeNetxopsPresetArtifacts(): NetxopsPresetArtifacts { plugins: ${pluginsBody} ` - return { standardDir, ...meta, agentCordis, declarativePatch } + return { standardSource, standardKind, standardDir, ...meta, agentCordis, declarativePatch } } function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Context['logger']): void { @@ -195,7 +504,10 @@ function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Conte const dest = join(destParent, NETXOPS_PRESET_ID) mkdirSync(destParent, { recursive: true }) if (existsSync(dest)) rmSync(dest, { recursive: true, force: true }) - cpSync(artifacts.standardDir, dest, { recursive: true }) + mkdirSync(dest, { recursive: true }) + if (artifacts.standardDir !== null && existsSync(artifacts.standardDir)) { + cpSync(artifacts.standardDir, dest, { recursive: true }) + } writeFileSync(join(dest, 'preset.yml'), readFileSync(join(overlayRoot, 'preset.yml'), 'utf8')) writeFileSync(join(dest, 'PERSONA.md'), readFileSync(join(overlayRoot, 'PERSONA.md'), 'utf8')) writeFileSync(join(dest, 'agent.cordis.yml'), artifacts.agentCordis) @@ -204,14 +516,18 @@ function installDirectoryPreset(artifacts: NetxopsPresetArtifacts, logger: Conte writeFileSync( join(dest, '.dsh-netxops-managed'), [ - 'composed-from: standard', - `standard-source: ${artifacts.standardDir}`, + `composed-from: ${artifacts.standardKind}`, + `standard-source: ${artifacts.standardSource}`, `at: ${new Date().toISOString()}`, '', ].join('\n'), 'utf8', ) - logger.info('netxops: directory preset composed from standard → %s', dest) + logger.info( + 'netxops: directory preset composed from %s → %s', + artifacts.standardSource, + dest, + ) } /** diff --git a/test/agent-preset-install.test.mjs b/test/agent-preset-install.test.mjs new file mode 100644 index 0000000..1ebc8a6 --- /dev/null +++ b/test/agent-preset-install.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, it } from 'node:test' +import { + alignWorkflowEngineForHost, + collectCompositionPackageNames, + extractPluginsBodyFromStandardPatch, + packageInstalledAbove, + pluginsBodyToAgentCordis, +} from '../src/agent-preset-install.ts' + +const SAMPLE_PATCH = `# comment +- insert: + - id: preset-standard + name: '@deepseek-ai/dsh-agent-preset' + config: + id: standard + order: 1 + plugins: + - id: persona + name: '@deepseek-ai/dsh-persona' + config: + prefix: hello + - id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + - id: workflow-worker-thread + name: '@deepseek-ai/dsh-workflow-worker-thread' +` + +function seedPackage(root, name) { + const dir = join(root, 'node_modules', ...name.split('/')) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: '0.0.0' })) +} + +describe('extractPluginsBodyFromStandardPatch', () => { + it('keeps indented plugin rows and drops the insert wrapper', () => { + const body = extractPluginsBodyFromStandardPatch(SAMPLE_PATCH) + assert.match(body, /^ - id: persona$/m) + assert.match(body, /workflow-worker-thread/) + assert.doesNotMatch(body, /^- insert:/m) + assert.doesNotMatch(body, /preset-standard/) + }) +}) + +describe('pluginsBodyToAgentCordis', () => { + it('strips the declarative plugin indent', () => { + const body = extractPluginsBodyFromStandardPatch(SAMPLE_PATCH) + const cordis = pluginsBodyToAgentCordis(body) + assert.match(cordis, /^- id: persona$/m) + assert.match(cordis, /^ name: '@deepseek-ai\/dsh-persona'$/m) + assert.doesNotMatch(cordis, /^ - id:/m) + }) +}) + +describe('alignWorkflowEngineForHost', () => { + const worker = '- id: workflow-worker-thread\n name: \'@deepseek-ai/dsh-workflow-worker-thread\'\n' + + it('rewrites worker-thread → ptc when only ptc is installed above bases', () => { + const root = mkdtempSync(join(tmpdir(), 'netxops-preset-')) + seedPackage(root, '@deepseek-ai/dsh-workflow-ptc') + assert.equal(packageInstalledAbove('@deepseek-ai/dsh-workflow-ptc', [root]), true) + assert.equal(packageInstalledAbove('@deepseek-ai/dsh-workflow-worker-thread', [root]), false) + const out = alignWorkflowEngineForHost(worker, [root]) + assert.match(out, /- id: workflow-ptc/) + assert.match(out, /dsh-workflow-ptc/) + assert.doesNotMatch(out, /workflow-worker-thread/) + }) + + it('collectCompositionPackageNames reads quoted names', () => { + const names = collectCompositionPackageNames( + '- id: a\n name: \'@deepseek-ai/dsh-workflow-ptc\'\n- id: b\n name: "@deepseek-ai/dsh-tool-workflow"\n', + ) + assert.deepEqual( + names.sort(), + ['@deepseek-ai/dsh-tool-workflow', '@deepseek-ai/dsh-workflow-ptc'].sort(), + ) + }) +})