feat: add managed skills gating
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
SessionManager,
|
||||
SettingsManager,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||
import {
|
||||
createToolDebouncer,
|
||||
@@ -43,7 +44,13 @@ import {
|
||||
createClawdisCodingTools,
|
||||
sanitizeContentBlocksImages,
|
||||
} from "./pi-tools.js";
|
||||
import { buildWorkspaceSkillsPrompt } from "./skills.js";
|
||||
import {
|
||||
applySkillEnvOverrides,
|
||||
applySkillEnvOverridesFromSnapshot,
|
||||
buildWorkspaceSkillSnapshot,
|
||||
type SkillSnapshot,
|
||||
loadWorkspaceSkillEntries,
|
||||
} from "./skills.js";
|
||||
import { buildAgentSystemPrompt } from "./system-prompt.js";
|
||||
import { loadWorkspaceBootstrapFiles } from "./workspace.js";
|
||||
|
||||
@@ -200,6 +207,8 @@ export async function runEmbeddedPiAgent(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config?: ClawdisConfig;
|
||||
skillsSnapshot?: SkillSnapshot;
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
@@ -244,8 +253,30 @@ export async function runEmbeddedPiAgent(params: {
|
||||
thinkingLevel,
|
||||
});
|
||||
|
||||
let restoreSkillEnv: (() => void) | undefined;
|
||||
process.chdir(resolvedWorkspace);
|
||||
try {
|
||||
const skillEntries = params.skillsSnapshot
|
||||
? undefined
|
||||
: loadWorkspaceSkillEntries(resolvedWorkspace, {
|
||||
config: params.config,
|
||||
});
|
||||
const skillsSnapshot =
|
||||
params.skillsSnapshot ??
|
||||
buildWorkspaceSkillSnapshot(resolvedWorkspace, {
|
||||
config: params.config,
|
||||
entries: skillEntries,
|
||||
});
|
||||
restoreSkillEnv = params.skillsSnapshot
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
snapshot: params.skillsSnapshot,
|
||||
config: params.config,
|
||||
})
|
||||
: applySkillEnvOverrides({
|
||||
skills: skillEntries ?? [],
|
||||
config: params.config,
|
||||
});
|
||||
|
||||
const bootstrapFiles =
|
||||
await loadWorkspaceBootstrapFiles(resolvedWorkspace);
|
||||
const systemPrompt = buildAgentSystemPrompt({
|
||||
@@ -258,8 +289,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
})),
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
});
|
||||
const systemPromptWithSkills =
|
||||
systemPrompt + buildWorkspaceSkillsPrompt(resolvedWorkspace);
|
||||
const systemPromptWithSkills = systemPrompt + skillsSnapshot.prompt;
|
||||
|
||||
const sessionManager = new SessionManager(false, params.sessionFile);
|
||||
const settingsManager = new SettingsManager();
|
||||
@@ -576,6 +606,7 @@ export async function runEmbeddedPiAgent(params: {
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
restoreSkillEnv?.();
|
||||
process.chdir(prevCwd);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,9 +24,42 @@ description: Does demo things
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const prompt = buildWorkspaceSkillsPrompt(workspaceDir);
|
||||
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||
});
|
||||
expect(prompt).toContain("demo-skill");
|
||||
expect(prompt).toContain("Does demo things");
|
||||
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
|
||||
});
|
||||
|
||||
it("filters skills based on env/config gates", async () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-"));
|
||||
const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: nano-banana-pro
|
||||
description: Generates images
|
||||
metadata: {"clawdis":{"requires":{"env":["GEMINI_API_KEY"]},"primaryEnv":"GEMINI_API_KEY"}}
|
||||
---
|
||||
|
||||
# Nano Banana
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const missingPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||
config: { skills: { "nano-banana-pro": { apiKey: "" } } },
|
||||
});
|
||||
expect(missingPrompt).not.toContain("nano-banana-pro");
|
||||
|
||||
const enabledPrompt = buildWorkspaceSkillsPrompt(workspaceDir, {
|
||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||
config: { skills: { "nano-banana-pro": { apiKey: "test-key" } } },
|
||||
});
|
||||
expect(enabledPrompt).toContain("nano-banana-pro");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,395 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
type Skill,
|
||||
type SkillFrontmatter,
|
||||
formatSkillsForPrompt,
|
||||
loadSkillsFromDir,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export function buildWorkspaceSkillsPrompt(workspaceDir: string): string {
|
||||
const skillsDir = path.join(workspaceDir, "skills");
|
||||
const skills = loadSkillsFromDir({
|
||||
dir: skillsDir,
|
||||
source: "clawdis-workspace",
|
||||
});
|
||||
return formatSkillsForPrompt(skills);
|
||||
import type { ClawdisConfig, SkillConfig } from "../config/config.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
|
||||
type ClawdisSkillMetadata = {
|
||||
always?: boolean;
|
||||
skillKey?: string;
|
||||
primaryEnv?: string;
|
||||
requires?: {
|
||||
bins?: string[];
|
||||
env?: string[];
|
||||
config?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
type SkillEntry = {
|
||||
skill: Skill;
|
||||
frontmatter: SkillFrontmatter;
|
||||
clawdis?: ClawdisSkillMetadata;
|
||||
};
|
||||
|
||||
export type SkillSnapshot = {
|
||||
prompt: string;
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
};
|
||||
|
||||
function getFrontmatterValue(
|
||||
frontmatter: SkillFrontmatter,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const raw = frontmatter[key];
|
||||
return typeof raw === "string" ? raw : undefined;
|
||||
}
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): SkillFrontmatter {
|
||||
const frontmatter: SkillFrontmatter = {};
|
||||
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.startsWith("---")) return frontmatter;
|
||||
const endIndex = normalized.indexOf("\n---", 3);
|
||||
if (endIndex === -1) return frontmatter;
|
||||
const block = normalized.slice(4, endIndex);
|
||||
for (const line of block.split("\n")) {
|
||||
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
||||
if (!match) continue;
|
||||
const key = match[1];
|
||||
const value = stripQuotes(match[2].trim());
|
||||
if (!key || !value) continue;
|
||||
frontmatter[key] = value;
|
||||
}
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
function normalizeStringList(input: unknown): string[] {
|
||||
if (!input) return [];
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((value) => String(value).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof input === "string") {
|
||||
return input
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isTruthy(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") return value.trim().length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||
"browser.enabled": true,
|
||||
};
|
||||
|
||||
function resolveConfigPath(config: ClawdisConfig | undefined, pathStr: string) {
|
||||
const parts = pathStr.split(".").filter(Boolean);
|
||||
let current: unknown = config;
|
||||
for (const part of parts) {
|
||||
if (typeof current !== "object" || current === null) return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isConfigPathTruthy(
|
||||
config: ClawdisConfig | undefined,
|
||||
pathStr: string,
|
||||
): boolean {
|
||||
const value = resolveConfigPath(config, pathStr);
|
||||
if (value === undefined && pathStr in DEFAULT_CONFIG_VALUES) {
|
||||
return DEFAULT_CONFIG_VALUES[pathStr] === true;
|
||||
}
|
||||
return isTruthy(value);
|
||||
}
|
||||
|
||||
function resolveSkillConfig(
|
||||
config: ClawdisConfig | undefined,
|
||||
skillKey: string,
|
||||
): SkillConfig | undefined {
|
||||
const skills = config?.skills;
|
||||
if (!skills || typeof skills !== "object") return undefined;
|
||||
const entry = (skills as Record<string, SkillConfig | undefined>)[skillKey];
|
||||
if (!entry || typeof entry !== "object") return undefined;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function hasBinary(bin: string): boolean {
|
||||
const pathEnv = process.env.PATH ?? "";
|
||||
const parts = pathEnv.split(path.delimiter).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
const candidate = path.join(part, bin);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
// keep scanning
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveClawdisMetadata(
|
||||
frontmatter: SkillFrontmatter,
|
||||
): ClawdisSkillMetadata | undefined {
|
||||
const raw = getFrontmatterValue(frontmatter, "metadata");
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { clawdis?: unknown };
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
const clawdis = (parsed as { clawdis?: unknown }).clawdis;
|
||||
if (!clawdis || typeof clawdis !== "object") return undefined;
|
||||
const clawdisObj = clawdis as Record<string, unknown>;
|
||||
const requiresRaw =
|
||||
typeof clawdisObj.requires === "object" && clawdisObj.requires !== null
|
||||
? (clawdisObj.requires as Record<string, unknown>)
|
||||
: undefined;
|
||||
return {
|
||||
always:
|
||||
typeof clawdisObj.always === "boolean"
|
||||
? clawdisObj.always
|
||||
: undefined,
|
||||
skillKey:
|
||||
typeof clawdisObj.skillKey === "string"
|
||||
? clawdisObj.skillKey
|
||||
: undefined,
|
||||
primaryEnv:
|
||||
typeof clawdisObj.primaryEnv === "string"
|
||||
? clawdisObj.primaryEnv
|
||||
: undefined,
|
||||
requires: requiresRaw
|
||||
? {
|
||||
bins: normalizeStringList(requiresRaw.bins),
|
||||
env: normalizeStringList(requiresRaw.env),
|
||||
config: normalizeStringList(requiresRaw.config),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSkillKey(skill: Skill, entry?: SkillEntry): string {
|
||||
return entry?.clawdis?.skillKey ?? skill.name;
|
||||
}
|
||||
|
||||
function shouldIncludeSkill(params: {
|
||||
entry: SkillEntry;
|
||||
config?: ClawdisConfig;
|
||||
}): boolean {
|
||||
const { entry, config } = params;
|
||||
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||
|
||||
if (skillConfig?.enabled === false) return false;
|
||||
if (entry.clawdis?.always === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requiredBins = entry.clawdis?.requires?.bins ?? [];
|
||||
if (requiredBins.length > 0) {
|
||||
for (const bin of requiredBins) {
|
||||
if (!hasBinary(bin)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
const requiredEnv = entry.clawdis?.requires?.env ?? [];
|
||||
if (requiredEnv.length > 0) {
|
||||
for (const envName of requiredEnv) {
|
||||
if (process.env[envName]) continue;
|
||||
if (skillConfig?.env?.[envName]) continue;
|
||||
if (
|
||||
skillConfig?.apiKey &&
|
||||
entry.clawdis?.primaryEnv === envName
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const requiredConfig = entry.clawdis?.requires?.config ?? [];
|
||||
if (requiredConfig.length > 0) {
|
||||
for (const configPath of requiredConfig) {
|
||||
if (!isConfigPathTruthy(config, configPath)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterSkillEntries(
|
||||
entries: SkillEntry[],
|
||||
config?: ClawdisConfig,
|
||||
): SkillEntry[] {
|
||||
return entries.filter((entry) => shouldIncludeSkill({ entry, config }));
|
||||
}
|
||||
|
||||
export function applySkillEnvOverrides(params: {
|
||||
skills: SkillEntry[];
|
||||
config?: ClawdisConfig;
|
||||
}) {
|
||||
const { skills, config } = params;
|
||||
const updates: Array<{ key: string; prev: string | undefined }> = [];
|
||||
|
||||
for (const entry of skills) {
|
||||
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||
if (!skillConfig) continue;
|
||||
|
||||
if (skillConfig.env) {
|
||||
for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
|
||||
if (!envValue || process.env[envKey]) continue;
|
||||
updates.push({ key: envKey, prev: process.env[envKey] });
|
||||
process.env[envKey] = envValue;
|
||||
}
|
||||
}
|
||||
|
||||
const primaryEnv = entry.clawdis?.primaryEnv;
|
||||
if (primaryEnv && skillConfig.apiKey && !process.env[primaryEnv]) {
|
||||
updates.push({ key: primaryEnv, prev: process.env[primaryEnv] });
|
||||
process.env[primaryEnv] = skillConfig.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const update of updates) {
|
||||
if (update.prev === undefined) delete process.env[update.key];
|
||||
else process.env[update.key] = update.prev;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function applySkillEnvOverridesFromSnapshot(params: {
|
||||
snapshot?: SkillSnapshot;
|
||||
config?: ClawdisConfig;
|
||||
}) {
|
||||
const { snapshot, config } = params;
|
||||
if (!snapshot) return () => {};
|
||||
const updates: Array<{ key: string; prev: string | undefined }> = [];
|
||||
|
||||
for (const skill of snapshot.skills) {
|
||||
const skillConfig = resolveSkillConfig(config, skill.name);
|
||||
if (!skillConfig) continue;
|
||||
|
||||
if (skillConfig.env) {
|
||||
for (const [envKey, envValue] of Object.entries(skillConfig.env)) {
|
||||
if (!envValue || process.env[envKey]) continue;
|
||||
updates.push({ key: envKey, prev: process.env[envKey] });
|
||||
process.env[envKey] = envValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (skill.primaryEnv && skillConfig.apiKey && !process.env[skill.primaryEnv]) {
|
||||
updates.push({ key: skill.primaryEnv, prev: process.env[skill.primaryEnv] });
|
||||
process.env[skill.primaryEnv] = skillConfig.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const update of updates) {
|
||||
if (update.prev === undefined) delete process.env[update.key];
|
||||
else process.env[update.key] = update.prev;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function loadSkillEntries(
|
||||
workspaceDir: string,
|
||||
opts?: {
|
||||
config?: ClawdisConfig;
|
||||
managedSkillsDir?: string;
|
||||
},
|
||||
): SkillEntry[] {
|
||||
const managedSkillsDir =
|
||||
opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
||||
const workspaceSkillsDir = path.join(workspaceDir, "skills");
|
||||
|
||||
const managedSkills = loadSkillsFromDir({
|
||||
dir: managedSkillsDir,
|
||||
source: "clawdis-managed",
|
||||
}).skills;
|
||||
const workspaceSkills = loadSkillsFromDir({
|
||||
dir: workspaceSkillsDir,
|
||||
source: "clawdis-workspace",
|
||||
}).skills;
|
||||
|
||||
const merged = new Map<string, Skill>();
|
||||
for (const skill of managedSkills) merged.set(skill.name, skill);
|
||||
for (const skill of workspaceSkills) merged.set(skill.name, skill);
|
||||
|
||||
const skillEntries: SkillEntry[] = Array.from(merged.values()).map((skill) => {
|
||||
let frontmatter: SkillFrontmatter = {};
|
||||
try {
|
||||
const raw = fs.readFileSync(skill.filePath, "utf-8");
|
||||
frontmatter = parseFrontmatter(raw);
|
||||
} catch {
|
||||
// ignore malformed skills
|
||||
}
|
||||
return { skill, frontmatter, clawdis: resolveClawdisMetadata(frontmatter) };
|
||||
});
|
||||
return skillEntries;
|
||||
}
|
||||
|
||||
export function buildWorkspaceSkillSnapshot(
|
||||
workspaceDir: string,
|
||||
opts?: {
|
||||
config?: ClawdisConfig;
|
||||
managedSkillsDir?: string;
|
||||
entries?: SkillEntry[];
|
||||
},
|
||||
): SkillSnapshot {
|
||||
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
||||
const eligible = filterSkillEntries(skillEntries, opts?.config);
|
||||
return {
|
||||
prompt: formatSkillsForPrompt(eligible.map((entry) => entry.skill)),
|
||||
skills: eligible.map((entry) => ({
|
||||
name: entry.skill.name,
|
||||
primaryEnv: entry.clawdis?.primaryEnv,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkspaceSkillsPrompt(
|
||||
workspaceDir: string,
|
||||
opts?: {
|
||||
config?: ClawdisConfig;
|
||||
managedSkillsDir?: string;
|
||||
entries?: SkillEntry[];
|
||||
},
|
||||
): string {
|
||||
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
||||
const eligible = filterSkillEntries(skillEntries, opts?.config);
|
||||
return formatSkillsForPrompt(eligible.map((entry) => entry.skill));
|
||||
}
|
||||
|
||||
export function loadWorkspaceSkillEntries(
|
||||
workspaceDir: string,
|
||||
opts?: {
|
||||
config?: ClawdisConfig;
|
||||
managedSkillsDir?: string;
|
||||
},
|
||||
): SkillEntry[] {
|
||||
return loadSkillEntries(workspaceDir, opts);
|
||||
}
|
||||
|
||||
export function filterWorkspaceSkillEntries(
|
||||
entries: SkillEntry[],
|
||||
config?: ClawdisConfig,
|
||||
): SkillEntry[] {
|
||||
return filterSkillEntries(entries, config);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
ensureAgentWorkspace,
|
||||
} from "../agents/workspace.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
DEFAULT_IDLE_MINUTES,
|
||||
@@ -659,17 +660,48 @@ export async function getReplyFromConfig(
|
||||
sessionId: sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const skillSnapshot =
|
||||
isFirstTurnInSession || !current.skillsSnapshot
|
||||
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
|
||||
: current.skillsSnapshot;
|
||||
sessionEntry = {
|
||||
...current,
|
||||
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
systemSent: true,
|
||||
skillsSnapshot: skillSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
systemSent = true;
|
||||
}
|
||||
|
||||
const skillsSnapshot =
|
||||
sessionEntry?.skillsSnapshot ??
|
||||
(isFirstTurnInSession
|
||||
? undefined
|
||||
: buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }));
|
||||
if (
|
||||
skillsSnapshot &&
|
||||
sessionStore &&
|
||||
sessionKey &&
|
||||
!isFirstTurnInSession &&
|
||||
!sessionEntry?.skillsSnapshot
|
||||
) {
|
||||
const current = sessionEntry ?? {
|
||||
sessionId: sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
sessionEntry = {
|
||||
...current,
|
||||
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
|
||||
updatedAt: Date.now(),
|
||||
skillsSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
|
||||
const prefixedBody = transcribedText
|
||||
? [prefixedBodyBase, `Transcript:\n${transcribedText}`]
|
||||
.filter(Boolean)
|
||||
@@ -709,6 +741,8 @@ export async function getReplyFromConfig(
|
||||
sessionId: sessionIdFinal,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
skillsSnapshot,
|
||||
prompt: commandBody,
|
||||
provider,
|
||||
model,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
ensureAgentWorkspace,
|
||||
} from "../agents/workspace.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import {
|
||||
@@ -205,10 +206,31 @@ export async function agentCommand(
|
||||
persistedVerbose ??
|
||||
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||
|
||||
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
|
||||
const skillsSnapshot = needsSkillsSnapshot
|
||||
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
|
||||
: sessionEntry?.skillsSnapshot;
|
||||
|
||||
if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) {
|
||||
const current = sessionEntry ?? {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const next: SessionEntry = {
|
||||
...current,
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
skillsSnapshot,
|
||||
};
|
||||
sessionStore[sessionKey] = next;
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
sessionEntry = next;
|
||||
}
|
||||
|
||||
// Persist explicit /command overrides to the session store when we have a key.
|
||||
if (sessionStore && sessionKey) {
|
||||
const entry = sessionEntry ??
|
||||
sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now() };
|
||||
const entry =
|
||||
sessionStore[sessionKey] ?? sessionEntry ?? { sessionId, updatedAt: Date.now() };
|
||||
const next: SessionEntry = { ...entry, sessionId, updatedAt: Date.now() };
|
||||
if (thinkOverride) {
|
||||
if (thinkOverride === "off") delete next.thinkingLevel;
|
||||
@@ -245,6 +267,8 @@ export async function agentCommand(
|
||||
sessionId,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
skillsSnapshot,
|
||||
prompt: body,
|
||||
provider,
|
||||
model,
|
||||
|
||||
@@ -120,6 +120,13 @@ export type GatewayConfig = {
|
||||
controlUi?: GatewayControlUiConfig;
|
||||
};
|
||||
|
||||
export type SkillConfig = {
|
||||
enabled?: boolean;
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ClawdisConfig = {
|
||||
identity?: {
|
||||
name?: string;
|
||||
@@ -168,6 +175,7 @@ export type ClawdisConfig = {
|
||||
discovery?: DiscoveryConfig;
|
||||
canvasHost?: CanvasHostConfig;
|
||||
gateway?: GatewayConfig;
|
||||
skills?: Record<string, SkillConfig>;
|
||||
};
|
||||
|
||||
// New branding path (preferred)
|
||||
@@ -349,6 +357,17 @@ const ClawdisSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
skills: z
|
||||
.record(
|
||||
z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type ConfigValidationIssue = {
|
||||
|
||||
@@ -25,6 +25,12 @@ export type SessionEntry = {
|
||||
lastTo?: string;
|
||||
// Optional flag to mirror Mac app UI and future sync states.
|
||||
syncing?: boolean | string;
|
||||
skillsSnapshot?: SessionSkillSnapshot;
|
||||
};
|
||||
|
||||
export type SessionSkillSnapshot = {
|
||||
prompt: string;
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
};
|
||||
|
||||
export function resolveSessionTranscriptsDir(): string {
|
||||
@@ -125,6 +131,7 @@ export async function updateLastRoute(params: {
|
||||
model: existing?.model,
|
||||
contextTokens: existing?.contextTokens,
|
||||
syncing: existing?.syncing,
|
||||
skillsSnapshot: existing?.skillsSnapshot,
|
||||
lastChannel: channel,
|
||||
lastTo: to?.trim() ? to.trim() : undefined,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
ensureAgentWorkspace,
|
||||
} from "../agents/workspace.js";
|
||||
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
|
||||
import { chunkText } from "../auto-reply/chunk.js";
|
||||
import { normalizeThinkLevel } from "../auto-reply/thinking.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
@@ -204,6 +205,21 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
|
||||
const commandBody = base;
|
||||
|
||||
const needsSkillsSnapshot =
|
||||
cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
|
||||
const skillsSnapshot = needsSkillsSnapshot
|
||||
? buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg })
|
||||
: cronSession.sessionEntry.skillsSnapshot;
|
||||
if (needsSkillsSnapshot && skillsSnapshot) {
|
||||
cronSession.sessionEntry = {
|
||||
...cronSession.sessionEntry,
|
||||
updatedAt: Date.now(),
|
||||
skillsSnapshot,
|
||||
};
|
||||
cronSession.store[params.sessionKey] = cronSession.sessionEntry;
|
||||
await saveSessionStore(cronSession.storePath, cronSession.store);
|
||||
}
|
||||
|
||||
// Persist systemSent before the run, mirroring the inbound auto-reply behavior.
|
||||
if (isFirstTurnInSession) {
|
||||
cronSession.sessionEntry.systemSent = true;
|
||||
@@ -223,6 +239,8 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
sessionId: cronSession.sessionEntry.sessionId,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: params.cfg,
|
||||
skillsSnapshot,
|
||||
prompt: commandBody,
|
||||
provider,
|
||||
model,
|
||||
|
||||
@@ -3857,6 +3857,7 @@ export async function startGatewayServer(
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user