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
9 changes: 9 additions & 0 deletions esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ const codexHooks = [
{ entry: "dist/src/hooks/codex/stop.js", out: "stop" },
{ entry: "dist/src/hooks/codex/wiki-worker.js", out: "wiki-worker" },
{ entry: "dist/src/skillify/skillify-worker.js", out: "skillify-worker" },
// SkillOpt worker — codex's capture spawns it on a user reaction to judge + improve a
// recently-used org skill (judging runs on the codex CLI). Same shared module CC uses.
{ entry: "dist/src/skillify/skillopt-worker.js", out: "skillopt-worker" },
{ entry: "dist/src/hooks/graph-pull-worker.js", out: "graph-pull-worker" },
// G3: code-graph auto-build parity for Codex (same shared hook as CC/Cursor).
{ entry: "dist/src/hooks/graph-on-stop.js", out: "graph-on-stop" },
Expand Down Expand Up @@ -161,6 +164,9 @@ const hermesHooks = [
{ entry: "dist/src/hooks/hermes/pre-tool-use.js", out: "pre-tool-use" },
{ entry: "dist/src/hooks/hermes/wiki-worker.js", out: "wiki-worker" },
{ entry: "dist/src/skillify/skillify-worker.js", out: "skillify-worker" },
// SkillOpt worker — hermes capture spawns it on a reaction to judge + improve a recently-used
// org skill (judging runs on the hermes CLI). Same shared module CC uses.
{ entry: "dist/src/skillify/skillopt-worker.js", out: "skillopt-worker" },
{ entry: "dist/src/hooks/graph-pull-worker.js", out: "graph-pull-worker" },
// G3: code-graph auto-build parity for Hermes (registered on on_session_end).
{ entry: "dist/src/hooks/graph-on-stop.js", out: "graph-on-stop" },
Expand Down Expand Up @@ -267,6 +273,9 @@ const piWorker = [
{ entry: "dist/src/hooks/pi/wiki-worker.js", out: "wiki-worker" },
{ entry: "dist/src/skillify/skillify-worker.js", out: "skillify-worker" },
{ entry: "dist/src/skillify/autopull-worker.js", out: "autopull-worker" },
// SkillOpt worker — pi spawns it on a user reaction (the extension can't import the
// raw-.ts trigger, so it shells this bundle like the others). Same shared module CC uses.
{ entry: "dist/src/skillify/skillopt-worker.js", out: "skillopt-worker" },
];
await build({
entryPoints: Object.fromEntries(piWorker.map(h => [h.out, h.entry])),
Expand Down
93 changes: 93 additions & 0 deletions pi/extension-source/hivemind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,94 @@ function runAutopullWorker(): void {
}
}

// ---------- SkillOpt: arm on org-skill use, react on the next user message ------------
// Mirrors the CC PreToolUse/UserPromptSubmit wiring, inlined because this extension is raw
// .ts with zero non-builtin deps (it can't import the skillify trigger). pi has no first-class
// `Skill` tool — it USES a skill by READING its SKILL.md — so we arm on a tool_result whose
// path is .../skills/<name--author>/SKILL.md, then on the next user prompt (the reaction) spawn
// the bundled skillopt-worker to judge + improve. Env-var names are the cross-process contract
// with the worker (SKILLOPT_ENV in src/skillify/skillopt-env.ts) — kept as literals here since
// the extension can't import. Fully swallowed; never blocks pi. Both call sites sit AFTER the
// handler's captureEnabled check, so the worker's own pi-judge subprocess (HIVEMIND_CAPTURE=false)
// can't re-arm/re-react — that's the recursion guard.
const PI_SKILLOPT_WORKER_PATH = join(homedir(), ".pi", "agent", "hivemind", "skillopt-worker.js");
// Mirror getStateDir()'s contract: a non-empty (trimmed) HIVEMIND_STATE_DIR overrides the default
// ~/.deeplake/state/skillify root, so pi's pending state co-locates with the rest of Skillify
// (and test-isolation overrides apply here too, not just in the shared trigger).
const SKILLOPT_STATE_ROOT = (typeof process.env.HIVEMIND_STATE_DIR === "string" && process.env.HIVEMIND_STATE_DIR.trim())
? process.env.HIVEMIND_STATE_DIR.trim()
: join(homedir(), ".deeplake", "state", "skillify");
const SKILLOPT_PENDING_DIR = join(SKILLOPT_STATE_ROOT, "skillopt", "pending");
const SKILLOPT_JUDGE_WINDOW = 3; // K reactions to keep judging after a skill use (DEFAULT_JUDGE_WINDOW)

/** Recover an org-skill ref (name--author) from a path that loads a skill's SKILL.md, else null. */
function skilloptRefFromPath(p: unknown): string | null {
if (typeof p !== "string") return null;
const m = p.match(/\/skills\/([^/]+)\/SKILL\.md$/);
if (!m) return null;
const ref = m[1];
// org shape only: name--author, no plugin namespace / path separators / traversal.
if (ref.includes(":") || ref.includes("/") || ref.includes("\\") || ref.includes("..")) return null;
const i = ref.lastIndexOf("--");
return i > 0 && i + 2 < ref.length ? ref : null;
}

function skilloptPendingFile(sessionId: string): string {
const safe = sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 200);
return join(SKILLOPT_PENDING_DIR, `${safe}.json`);
}

/** tool_result: pi read an org skill's SKILL.md → open a K-message judgment window. */
function skilloptArm(sessionId: string, toolName: unknown, toolInput: any, toolCallId: unknown): void {
try {
if (process.env.HIVEMIND_SKILLOPT_DISABLED === "1") return;
// Arm only on a READ of the SKILL.md — USING a skill is reading it. An edit/write of a
// SKILL.md (even one whose input carries a matching path) must NOT open a judgment window.
if (!/^read/i.test(String(toolName ?? ""))) return;
const ref = skilloptRefFromPath(toolInput?.path ?? toolInput?.file ?? toolInput?.filePath);
if (!ref) return;
mkdirSync(SKILLOPT_PENDING_DIR, { recursive: true });
const f = skilloptPendingFile(sessionId);
const tmp = `${f}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify({ skill: ref, budget: SKILLOPT_JUDGE_WINDOW, toolUseId: typeof toolCallId === "string" ? toolCallId : undefined }));
renameSync(tmp, f);
logHm(`skillopt: armed ${ref} for ${sessionId}`);
} catch (e: any) { logHm(`skillopt arm swallowed: ${e?.message ?? e}`); }
}

/** input: the user's reaction → spawn the detached worker to judge the pending skill; spend budget. */
function skilloptReact(sessionId: string, reaction: string): void {
try {
if (process.env.HIVEMIND_SKILLOPT_DISABLED === "1" || process.env.HIVEMIND_WIKI_WORKER === "1") return;
if (!reaction.trim()) return;
const f = skilloptPendingFile(sessionId);
let p: { skill?: string; budget?: number; toolUseId?: string };
try { p = JSON.parse(readFileSync(f, "utf8")); } catch { return; } // no window open → no-op
if (!p?.skill || typeof p.budget !== "number") return;
if (!existsSync(PI_SKILLOPT_WORKER_PATH)) { logHm(`skillopt: worker bundle missing at ${PI_SKILLOPT_WORKER_PATH} — run 'hivemind pi install'`); return; }
// Spend one message of the budget; close the window when exhausted.
try {
if (p.budget - 1 <= 0) { unlinkSync(f); }
else { const tmp = `${f}.${process.pid}.tmp`; writeFileSync(tmp, JSON.stringify({ ...p, budget: p.budget - 1 })); renameSync(tmp, f); }
} catch { /* best-effort */ }
const child = spawn(process.execPath, [PI_SKILLOPT_WORKER_PATH], {
detached: true,
stdio: "ignore",
env: {
...process.env,
HIVEMIND_SKILLOPT_WORKER: "1", // recursion guard (worker won't re-fire the trigger)
HIVEMIND_SKILLOPT_SESSION: sessionId,
HIVEMIND_SKILLOPT_SKILL: p.skill,
HIVEMIND_SKILLOPT_REACTION: reaction.slice(0, 8000),
HIVEMIND_SKILLOPT_AGENT: "pi", // judge/proposer run on pi (the user's own agent)
...(p.toolUseId ? { HIVEMIND_SKILLOPT_TOOL_USE_ID: p.toolUseId } : {}),
},
});
child.unref();
logHm(`skillopt: spawned worker for ${p.skill} in ${sessionId} (agent=pi)`);
} catch (e: any) { logHm(`skillopt react swallowed: ${e?.message ?? e}`); }
}

interface SummaryState {
lastSummaryAt: number;
lastSummaryCount: number;
Expand Down Expand Up @@ -1255,6 +1343,8 @@ export default function hivemindExtension(pi: ExtensionAPI): void {
} catch (e: any) {
logHm(`input: writeSessionRow swallowed: ${e?.message ?? e}`);
}
// SkillOpt: this prompt is the user's reaction to a recently-used org skill. Swallowed.
skilloptReact(sessionId, text);
maybeTriggerPeriodicSummary(creds, sessionId, cwd);
});

Expand Down Expand Up @@ -1286,6 +1376,9 @@ export default function hivemindExtension(pi: ExtensionAPI): void {
} catch (e: any) {
logHm(`tool_result: writeSessionRow swallowed: ${e?.message ?? e}`);
}
// SkillOpt: pi USES an org skill by reading its SKILL.md — arm the judgment window on
// a successful such read (skip errored reads). Swallowed.
if (event.isError !== true) skilloptArm(sessionId, event.toolName, event.input, event.toolCallId);
maybeTriggerPeriodicSummary(creds, sessionId, cwd);
});

Expand Down
15 changes: 15 additions & 0 deletions src/cli/install-pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const SKILLIFY_WORKER_PATH = join(WIKI_WORKER_DIR, "skillify-worker.js");
// shared autoPullSkills() codex/cursor/hermes call directly; pi can't
// import the TS module so it routes through this child process.
const AUTOPULL_WORKER_PATH = join(WIKI_WORKER_DIR, "autopull-worker.js");
// SkillOpt worker bundle, spawned by the pi extension on a user reaction to judge a
// recently-used org skill and publish an improvement. Same shared module CC ships; pi
// can't import the raw-.ts trigger so it shells this bundle. Sibling of the others.
const SKILLOPT_WORKER_PATH = join(WIKI_WORKER_DIR, "skillopt-worker.js");

const HIVEMIND_BLOCK_START = "<!-- BEGIN hivemind-memory -->";
const HIVEMIND_BLOCK_END = "<!-- END hivemind-memory -->";
Expand Down Expand Up @@ -148,6 +152,14 @@ export function installPi(): void {
copyFileSync(srcAutopullWorker, AUTOPULL_WORKER_PATH);
}

// 6. SkillOpt-worker bundle (spawned by extension on a user reaction to judge +
// improve a recently-used org skill). Same dir, same cleanup.
const srcSkilloptWorker = join(pkgRoot(), "pi", "bundle", "skillopt-worker.js");
if (existsSync(srcSkilloptWorker)) {
ensureDir(WIKI_WORKER_DIR);
copyFileSync(srcSkilloptWorker, SKILLOPT_WORKER_PATH);
}

ensureDir(VERSION_DIR);
writeVersionStamp(VERSION_DIR, getVersion());

Expand All @@ -162,6 +174,9 @@ export function installPi(): void {
if (existsSync(AUTOPULL_WORKER_PATH)) {
log(` pi autopull-worker installed -> ${AUTOPULL_WORKER_PATH}`);
}
if (existsSync(SKILLOPT_WORKER_PATH)) {
log(` pi skillopt-worker installed -> ${SKILLOPT_WORKER_PATH}`);
}
}

export function uninstallPi(): void {
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/codex/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "../summary-state.js";
import { bundleDirFromImportMeta, spawnCodexWikiWorker, wikiLog } from "./spawn-wiki-worker.js";
import { getInstalledVersion } from "../../utils/version-check.js";
import { reactSkillOpt } from "../shared/skillopt-hook.js";
const log = (msg: string) => _log("codex-capture", msg);

function resolveEmbedDaemonPath(): string {
Expand Down Expand Up @@ -151,6 +152,10 @@ async function main(): Promise<void> {

log("capture ok");

// SkillOpt: a UserPromptSubmit prompt is the user's reaction to a recently-used org skill.
// Swallowed; no-op unless a judgment window is open for this session.
reactSkillOpt(input.session_id, input.prompt, "codex");

maybeTriggerPeriodicSummary(input.session_id, input.cwd ?? "", config);
}

Expand Down
7 changes: 7 additions & 0 deletions src/hooks/codex/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { log as _log } from "../../utils/debug.js";
import { isDirectRun } from "../../utils/direct-run.js";
import { isSafe, touchesMemory, rewritePaths } from "../memory-path-utils.js";
import { armSkillOptOnSkillUse } from "../shared/skillopt-hook.js";

export { isSafe, touchesMemory, rewritePaths };

Expand Down Expand Up @@ -368,6 +369,12 @@ export async function processCodexPreToolUse(
/* c8 ignore start */
async function main(): Promise<void> {
const input = await readStdin<CodexPreToolUseInput>();
// SkillOpt: codex USES an org skill by shelling a read of its SKILL.md — arm the judgment
// window on that command. Guarded at the call site too (armSkillOptOnSkillUse is already
// internally swallowed): a throw here must NOT short-circuit the memory-path gate below, whose
// top-level catch exits 0 (fail-open). Fail-closed for the SkillOpt side-effect.
try { armSkillOptOnSkillUse(input.session_id, input.tool_name, input.tool_input, input.tool_use_id); }
catch { /* never let the SkillOpt arm affect the tool decision */ }
const decision = await processCodexPreToolUse(input);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (decision.action === "pass") return;
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/hermes/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { bundleDirFromImportMeta, spawnHermesWikiWorker, wikiLog } from "./spawn
import { tryStopCounterTrigger } from "../../skillify/triggers.js";
import type { Config } from "../../config.js";
import { getInstalledVersion } from "../../utils/version-check.js";
import { reactSkillOpt } from "../shared/skillopt-hook.js";
const log = (msg: string) => _log("hermes-capture", msg);

function resolveEmbedDaemonPath(): string {
Expand Down Expand Up @@ -96,12 +97,14 @@ async function main(): Promise<void> {
};

let entry: Record<string, unknown> | null = null;
let reactPrompt: string | undefined; // the user's prompt = the SkillOpt reaction (fired after capture)

if (event === "pre_llm_call") {
const prompt = pickString(extra.prompt, extra.user_message, (extra.message as Record<string, unknown> | undefined)?.content);
if (!prompt) { log(`pre_llm_call: no prompt found in extra`); return; }
log(`user session=${sessionId}`);
entry = { id: crypto.randomUUID(), ...meta, type: "user_message", content: prompt };
reactPrompt = prompt;
} else if (event === "post_tool_call" && typeof input.tool_name === "string") {
const toolResponse = extra.tool_result ?? extra.tool_output ?? extra.result ?? extra.output;
log(`tool=${input.tool_name} session=${sessionId}`);
Expand Down Expand Up @@ -158,6 +161,10 @@ async function main(): Promise<void> {

log("capture ok → cloud");

// SkillOpt: a pre_llm_call prompt is the user's reaction to a recently-used org skill.
// Swallowed; no-op unless a judgment window is open for this session.
reactSkillOpt(sessionId, reactPrompt, "hermes");

maybeTriggerPeriodicSummary(sessionId, cwd, config);

// Skillify Stop counter — post_llm_call is the assistant-complete event.
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/hermes/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { log as _log } from "../../utils/debug.js";
import { parseBashGrep, handleGrepDirect } from "../grep-direct.js";
import { touchesMemory, rewritePaths } from "../memory-path-utils.js";
import { tryGraphRead } from "../../graph/graph-command.js";
import { armSkillOptOnSkillUse } from "../shared/skillopt-hook.js";
const log = (msg: string) => _log("hermes-pre-tool-use", msg);

interface HermesPreToolUseInput {
Expand All @@ -40,6 +41,9 @@ interface HermesPreToolUseInput {

async function main(): Promise<void> {
const input = await readStdin<HermesPreToolUseInput>();
// SkillOpt: hermes USES an org skill by shelling a read of its SKILL.md (the path is in the
// terminal command). Arm the judgment window on it. Swallowed; never affects the decision below.
armSkillOptOnSkillUse(input.session_id ?? "", input.tool_name ?? "", input.tool_input);
// Hermes' shell-hook tool name for terminal commands is "terminal".
if (input.tool_name !== "terminal") return;

Expand Down
32 changes: 27 additions & 5 deletions src/hooks/shared/skillopt-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,39 @@
* whether a tool runs or a prompt is captured.
*/
import { markSkillPending, runEventTrigger } from "../../skillify/skillopt-trigger.js";
import { pathToSkillRef } from "../../skillify/skill-invocations.js";
import { SKILLOPT_ENV } from "../../skillify/skillopt-env.js";

/**
* PreToolUse: if the agent invoked a `Skill` tool on an ORG skill (`name--author`),
* open its K-message judgment window. Non-org skills (bare / plugin) are ignored.
* Recover an org-skill ref from a tool call that LOADS a skill's SKILL.md — how agents without
* a first-class `Skill` tool use skills: pi `read`s `.../skills/<dir>/SKILL.md` (structured
* `path`), codex/hermes SHELL a read of it (path inside `command`). The `<dir>` segment is the
* ref. Returns null when it isn't a SKILL.md load. markSkillPending still gates the ref
* (org-shape + manifest), so a bare/non-org dir is rejected there.
*/
export function skillRefFromSkillFileRead(toolName: string, toolInput: unknown): string | null {
// read tool with a structured path (pi)
if (/^read$/i.test(toolName)) return pathToSkillRef((toolInput as { path?: unknown })?.path);
// shell tool with the path inside the command (codex Bash, hermes terminal)
return pathToSkillRef((toolInput as { command?: unknown })?.command);
}

/**
* PreToolUse: open a skill's K-message judgment window when the agent USES an org skill —
* either via a first-class `Skill` tool (claude) or by reading its SKILL.md file (pi/codex).
* Org-skill gating (shape + pull manifest) happens in markSkillPending.
*/
export function armSkillOptOnSkillUse(sessionId: string, toolName: string, toolInput: unknown, toolUseId?: string): void {
try {
if (toolName !== "Skill" || process.env[SKILLOPT_ENV.DISABLED] === "1") return;
const ref = (toolInput as { skill?: unknown })?.skill;
if (typeof ref === "string") markSkillPending(sessionId, ref, toolUseId);
if (process.env[SKILLOPT_ENV.DISABLED] === "1") return;
let ref: string | null = null;
if (toolName === "Skill") {
const s = (toolInput as { skill?: unknown })?.skill;
ref = typeof s === "string" ? s : null;
} else {
ref = skillRefFromSkillFileRead(toolName, toolInput); // pi/codex: read of …/skills/<ref>/SKILL.md
}
if (ref) markSkillPending(sessionId, ref, toolUseId);
} catch { /* never break PreToolUse */ }
}

Expand Down
Loading
Loading