Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reliable-runtime-mcp-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@selftune/desktop": patch
---

Make packaged runtimes discover bundled DuckDB resources without Desktop-only environment setup, let npm and Bun CLI installs follow a verified same-or-newer Desktop-managed runtime, remove unpublished workspace packages from registry resolution during installation, and add the bounded read-only `selftune mcp serve` local skill registry.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ Your agent runs these — you just say what you want ("improve my skills", "show
| | `selftune service status --json` | Inspect registration, process state, version, and durable daemon health |
| | `selftune service doctor --json` | Diagnose the fixed current-user Windows service-lock compatibility state |
| | `selftune service repair-lock --json` | Repair only a proven stale pre-SQLite Windows service lock |
| | `selftune mcp serve` | Serve the local skill catalog over MCP stdio with Skills extension discovery |
| | `selftune doctor` | Health check: logs, hooks, config, permissions |
| **skills** | `selftune skills audit` | Inventory installed skills and recommend keep, repair, consolidate, or quarantine review |
| | `selftune skills consolidate --skill <name>` | Preview or apply reversible archive-and-link cleanup for duplicate installations |
Expand Down
12 changes: 12 additions & 0 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
* selftune dashboard — Open visual data dashboard
* selftune daemon <sub> — Run and inspect the local service
* selftune service <sub> — Manage the OS-supervised background service
* selftune mcp serve — Serve local skills over MCP stdio
* selftune last — Show last session details
* selftune cron — Scheduling & automation (setup, list, remove)
* selftune badge — Generate skill health badges for READMEs
Expand Down Expand Up @@ -104,6 +105,7 @@ Primary Lifecycle:
dashboard Open visual data dashboard
daemon <sub> Run and inspect the local SelfTune service
service <sub> Manage the OS-supervised background service
mcp serve Serve the local skill registry over MCP stdio

Advanced / Stage Commands:
evolve [target] Evolve skill descriptions (body, rollback)
Expand Down Expand Up @@ -152,6 +154,7 @@ const FAST_COMMANDS: ReadonlySet<string> = new Set([
"pi",
"daemon",
"service",
"mcp",
]);

// Track command usage (lazy import — skip for hooks and --help to avoid loading crypto/os)
Expand Down Expand Up @@ -186,6 +189,15 @@ if (!internalCommand && !command) {
const { runEffectCliMain } = await import("./effect-cli/runtime.js");
runEffectCliMain("status", []);
} else if (!internalCommand) {
if (command === "mcp") {
const subcommand = process.argv[3];
if (subcommand !== "serve") {
throw new CLIError("Usage: selftune mcp serve", "INVALID_ARGUMENT");
}
const { runSkillRegistryStdio } = await import("@selftune/runtime/mcp/skill-registry");
await runSkillRegistryStdio();
process.exit(0);
}
if (!isSkillSearch) {
const { startDashboardActionStream } =
await import("@selftune/runtime/dashboard-action-stream");
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@selftune/desktop",
"version": "0.4.10",
"version": "0.4.11",
"private": true,
"description": "Native desktop control plane for SelfTune skill observability and improvement",
"homepage": "https://selftune.dev",
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/scripts/smoke-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ function isolatedRuntimeEnvironment(paths: RuntimePaths): NodeJS.ProcessEnv {
SELFTUNE_HOME: paths.homeDir,
SELFTUNE_OPENCLAW_DIR: join(paths.homeDir, ".openclaw"),
SELFTUNE_PI_DIR: join(paths.homeDir, ".pi"),
SELFTUNE_DESKTOP_RESOURCE_DIR: undefined,
VIBE_HOME: join(paths.homeDir, ".vibe"),
XDG_CONFIG_HOME: join(paths.homeDir, ".config"),
};
Expand Down Expand Up @@ -249,7 +250,6 @@ const startRuntime = Effect.fn("SelfTuneSidecar.smoke.start")(function* (paths:
...isolatedRuntimeEnvironment(paths),
SELFTUNE_BIN_PATH: paths.binary,
SELFTUNE_DESKTOP: "1",
SELFTUNE_DESKTOP_RESOURCE_DIR: paths.root,
SELFTUNE_RUNTIME_OWNER: "desktop",
SELFTUNE_SUPERVISED: "0",
SELFTUNE_VERSION: process.env.npm_package_version ?? "0.0.0-smoke",
Expand Down
24 changes: 18 additions & 6 deletions apps/local/src/cloud-billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,26 +189,38 @@ function decodeBillingStatus(body: unknown): DesktopBillingStatus {
name: "Community",
price: "$0",
period: null,
description: "The complete local-first skill manager.",
features: ["Local library", "Skill Sets", "Project-scoped installs"],
description: "Find, review, package, and install skills on your machine.",
features: [
"Unlimited local skills and Skill Sets",
"Discovery across supported agents and projects",
"Local evaluation and reviewable proposals",
],
highlighted: false,
},
{
id: "pro",
name: "Pro",
price: "$19",
period: "/month",
description: "Subscription access and secure hosted sharing.",
features: ["Secure sharing", "Update summaries", "Device management"],
description: "Publish and manage reviewed Skill Sets as one owner.",
features: [
"Explicit skill and Skill Set sharing",
"Expiring and revocable share links",
"Linked-device and revision status",
],
highlighted: true,
},
{
id: "team",
name: "Team",
price: "$49",
period: "/month",
description: "Shared access for teams managing the same skill estate.",
features: ["Everything in Pro", "Team workspace"],
description: "Distribute reviewed Skill Sets across a workspace.",
features: [
"Everything in Pro",
"3 members included",
"Workspace membership and access roles",
],
highlighted: false,
seats: { minimum: 3, label: "members" },
},
Expand Down
11 changes: 10 additions & 1 deletion apps/local/src/local-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
readFileSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
Expand Down Expand Up @@ -333,10 +334,18 @@ export function removeDaemonManifestIfOwned(
): void {
const manifest = readServerManifest(configDir);
if (manifest?.pid === pid && (instanceId === undefined || manifest.instance_id === instanceId)) {
rmSync(daemonManifestPath(configDir), { force: true });
try {
unlinkSync(daemonManifestPath(configDir));
} catch (cause) {
if (!isMissingFileError(cause)) throw cause;
}
}
}

function isMissingFileError(cause: unknown): boolean {
return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT";
}

export function isProcessAlive(pid: number): boolean {
if (!Number.isSafeInteger(pid) || pid <= 1) return false;
try {
Expand Down
67 changes: 67 additions & 0 deletions bin/desktop-runtime.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const { existsSync, readFileSync, realpathSync } = require("node:fs");
const { homedir } = require("node:os");
const { basename, dirname, join, relative, resolve, sep } = require("node:path");

function numericVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version);
return match ? match.slice(1).map(Number) : null;
}

function versionAtLeast(candidate, installed) {
const left = numericVersion(candidate);
const right = numericVersion(installed);
if (!left || !right) return candidate === installed;
for (let index = 0; index < 3; index += 1) {
if (left[index] !== right[index]) return left[index] > right[index];
}
return true;
}

function desktopDataRoots(environment = process.env, platform = process.platform) {
if (platform === "darwin") {
return [join(homedir(), "Library", "Application Support", "SelfTune")];
}
if (platform === "win32" && environment.APPDATA) {
return [join(environment.APPDATA, "SelfTune"), join(environment.APPDATA, "selftune")];
}
const configRoot = environment.XDG_CONFIG_HOME || join(homedir(), ".config");
return [join(configRoot, "SelfTune"), join(configRoot, "selftune")];
}

function isInside(parent, child) {
const path = relative(parent, child);
return path !== "" && path !== ".." && !path.startsWith(`..${sep}`);
}

function resolveDesktopRuntime(installedVersion, options = {}) {
if (options.environment?.SELFTUNE_DISABLE_DESKTOP_RUNTIME === "1") return null;
const roots = options.dataRoots ?? desktopDataRoots(options.environment, options.platform);
const executableName =
(options.platform ?? process.platform) === "win32" ? "selftune.exe" : "selftune";

for (const dataRoot of roots) {
try {
const runtimeRoot = realpathSync(join(dataRoot, "runtime"));
const pointer = JSON.parse(readFileSync(join(runtimeRoot, "current.json"), "utf8"));
if (
!pointer ||
typeof pointer.version !== "string" ||
typeof pointer.path !== "string" ||
!versionAtLeast(pointer.version, installedVersion)
) {
continue;
}
const selectedRoot = realpathSync(resolve(pointer.path));
if (!isInside(runtimeRoot, selectedRoot) || basename(selectedRoot) !== pointer.version)
continue;
const executable = realpathSync(join(selectedRoot, executableName));
if (dirname(executable) !== selectedRoot || !existsSync(executable)) continue;
return executable;
} catch {
// An absent, incomplete, or untrusted Desktop runtime is not a CLI failure.
}
}
return null;
}

module.exports = { desktopDataRoots, resolveDesktopRuntime, versionAtLeast };
12 changes: 12 additions & 0 deletions bin/selftune.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

const { execFileSync } = require("child_process");
const { join } = require("path");
const { resolveDesktopRuntime } = require("./desktop-runtime.cjs");

const installedVersion = require("../package.json").version;
const desktopRuntime = resolveDesktopRuntime(installedVersion, { environment: process.env });
if (desktopRuntime) {
try {
execFileSync(desktopRuntime, process.argv.slice(2), { stdio: "inherit" });
process.exit(0);
} catch (error) {
process.exit(error.status ?? 1);
}
}

const entrypoint = join(__dirname, "..", "apps", "cli", "src", "main.ts");

Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "selftune",
"version": "0.4.10",
"version": "0.4.11",
"description": "Skill-level observability and self-improvement for AI agents — monitors skill routing, detects missed triggers, and evolves descriptions automatically",
"keywords": [
"agent",
Expand Down
1 change: 1 addition & 0 deletions packages/observability/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"./otlp": "./src/otlp.ts",
"./duckdb-store": "./src/duckdb-store.ts",
"./duckdb-node-api": "./src/duckdb-node-api.ts",
"./duckdb-module-resolution": "./src/duckdb-module-resolution.ts",
"./evidence-cohort": "./src/evidence-cohort.ts",
"./historical-backfill": "./src/historical-backfill.ts",
"./reader-parity": "./src/reader-parity.ts"
Expand Down
37 changes: 37 additions & 0 deletions packages/observability/src/duckdb-module-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";

export interface DuckDbModuleResolutionOptions {
readonly desktopResourceDirectory?: string;
readonly executablePath?: string;
readonly pathExists?: (path: string) => boolean;
}

export interface PackagedDuckDbModuleResolution {
readonly modulePath: string;
readonly resourceDirectory: string;
}

export function resolvePackagedDuckDbModule(
options: DuckDbModuleResolutionOptions = {},
): PackagedDuckDbModuleResolution | undefined {
const pathExists = options.pathExists ?? existsSync;
const explicitResourceDirectory = options.desktopResourceDirectory?.trim();
const candidates = [
explicitResourceDirectory,
options.executablePath ? dirname(options.executablePath) : undefined,
];

for (const resourceDirectory of candidates) {
if (!resourceDirectory) continue;
const modulePath = join(resourceDirectory, "node_modules/@duckdb/node-api/lib/index.js");
if (pathExists(modulePath)) return { modulePath, resourceDirectory };
}
return undefined;
}

export function resolvePackagedDuckDbModulePath(
options: DuckDbModuleResolutionOptions = {},
): string | undefined {
return resolvePackagedDuckDbModule(options)?.modulePath;
}
24 changes: 11 additions & 13 deletions packages/observability/src/duckdb-node-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,18 @@ import {
type DuckDbConnection,
type DuckDbInstanceFactory,
} from "./duckdb-store.js";
import { resolvePackagedDuckDbModule } from "./duckdb-module-resolution.js";

const desktopResourceDirectory = process.env.SELFTUNE_DESKTOP_RESOURCE_DIR;
const packagedDesktopResourceDirectory =
desktopResourceDirectory === undefined || desktopResourceDirectory.length === 0
? undefined
: desktopResourceDirectory;
const duckDbModule =
packagedDesktopResourceDirectory === undefined
? await import(["@duckdb", "node-api"].join("/"))
: await import(
pathToFileURL(
join(packagedDesktopResourceDirectory, "node_modules/@duckdb/node-api/lib/index.js"),
).href
);
const packagedDuckDbModule = resolvePackagedDuckDbModule({
desktopResourceDirectory: process.env.SELFTUNE_DESKTOP_RESOURCE_DIR,
executablePath: process.execPath,
});
if (packagedDuckDbModule && !process.env.SELFTUNE_DESKTOP_RESOURCE_DIR) {
process.env.SELFTUNE_DESKTOP_RESOURCE_DIR = packagedDuckDbModule.resourceDirectory;
}
const duckDbModule = packagedDuckDbModule
? await import(pathToFileURL(packagedDuckDbModule.modulePath).href)
: await import(["@duckdb", "node-api"].join("/"));
const { DuckDBInstance, DuckDBTimestampValue } = duckDbModule as typeof import("@duckdb/node-api");

/** Bounded desktop analytical-store memory; DuckDB never receives an unlimited process budget. */
Expand Down
Loading
Loading