aca68e73ee
Default false so CLI --skill paths can be re-injected from disk in later Phase 14 items; explicit true restores registered-only behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
188 lines
6.3 KiB
TypeScript
188 lines
6.3 KiB
TypeScript
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
import { dirname, join } from "path";
|
|
import type { AutoCompactIntegration } from "./state";
|
|
|
|
/** JSON key in global/project settings.json (SPEC §7.3). */
|
|
export const SKILL_REINJECT_SETTINGS_KEY = "skillReinject";
|
|
|
|
export type PartialSkillReinjectSettings = Partial<SkillReinjectSettings>;
|
|
|
|
const AUTO_COMPACT_INTEGRATION_VALUES: readonly AutoCompactIntegration[] = [
|
|
"auto",
|
|
"defer",
|
|
"immediate",
|
|
"off",
|
|
];
|
|
|
|
/** Global/project skillReinject.* settings (SPEC §7.3). */
|
|
export interface SkillReinjectSettings {
|
|
enabled: boolean;
|
|
trackReadPaths: boolean;
|
|
triggerTurn: boolean;
|
|
reinjectOnManualCompaction: boolean;
|
|
autoCompactIntegration: AutoCompactIntegration;
|
|
suffix: string;
|
|
/** Soft warn threshold; omit for unlimited with default warn above 3 (SPEC §15). */
|
|
maxSkills?: number;
|
|
/** Verbose reinject filter logging via ui.notify (Phase 14 / B-002). */
|
|
debug: boolean;
|
|
/** When true, only re-inject skills present in resourceLoader (Phase 14 / B-002). */
|
|
requireRegistered: boolean;
|
|
}
|
|
|
|
/** Defaults from SPEC §7.3 — extension off until explicitly enabled. */
|
|
export const DEFAULT_SKILL_REINJECT_SETTINGS: Readonly<SkillReinjectSettings> = {
|
|
enabled: false,
|
|
trackReadPaths: true,
|
|
triggerTurn: false,
|
|
reinjectOnManualCompaction: false,
|
|
autoCompactIntegration: "auto",
|
|
suffix: "[skill-reinject] Re-applied after compaction.",
|
|
debug: false,
|
|
requireRegistered: false,
|
|
};
|
|
|
|
export function createDefaultSettings(): SkillReinjectSettings {
|
|
return { ...DEFAULT_SKILL_REINJECT_SETTINGS };
|
|
}
|
|
|
|
function isAutoCompactIntegration(value: unknown): value is AutoCompactIntegration {
|
|
return (
|
|
typeof value === "string" &&
|
|
(AUTO_COMPACT_INTEGRATION_VALUES as readonly string[]).includes(value)
|
|
);
|
|
}
|
|
|
|
/** Parse unknown JSON; invalid fields are ignored. */
|
|
export function parseSkillReinjectPartial(raw: unknown): PartialSkillReinjectSettings {
|
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
return {};
|
|
}
|
|
const obj = raw as Record<string, unknown>;
|
|
const result: PartialSkillReinjectSettings = {};
|
|
if (typeof obj.enabled === "boolean") {
|
|
result.enabled = obj.enabled;
|
|
}
|
|
if (typeof obj.trackReadPaths === "boolean") {
|
|
result.trackReadPaths = obj.trackReadPaths;
|
|
}
|
|
if (typeof obj.triggerTurn === "boolean") {
|
|
result.triggerTurn = obj.triggerTurn;
|
|
}
|
|
if (typeof obj.reinjectOnManualCompaction === "boolean") {
|
|
result.reinjectOnManualCompaction = obj.reinjectOnManualCompaction;
|
|
}
|
|
if (isAutoCompactIntegration(obj.autoCompactIntegration)) {
|
|
result.autoCompactIntegration = obj.autoCompactIntegration;
|
|
}
|
|
if (typeof obj.suffix === "string") {
|
|
result.suffix = obj.suffix;
|
|
}
|
|
if (typeof obj.maxSkills === "number" && Number.isInteger(obj.maxSkills) && obj.maxSkills > 0) {
|
|
result.maxSkills = obj.maxSkills;
|
|
}
|
|
if (typeof obj.debug === "boolean") {
|
|
result.debug = obj.debug;
|
|
}
|
|
if (typeof obj.requireRegistered === "boolean") {
|
|
result.requireRegistered = obj.requireRegistered;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Project overrides global; both layers merge onto defaults (SPEC §7.3). */
|
|
export function mergeSkillReinjectSettings(
|
|
global: PartialSkillReinjectSettings,
|
|
project: PartialSkillReinjectSettings,
|
|
): SkillReinjectSettings {
|
|
return {
|
|
...createDefaultSettings(),
|
|
...global,
|
|
...project,
|
|
};
|
|
}
|
|
|
|
function extractSkillReinject(settings: object): PartialSkillReinjectSettings {
|
|
return parseSkillReinjectPartial(
|
|
(settings as Record<string, unknown>)[SKILL_REINJECT_SETTINGS_KEY],
|
|
);
|
|
}
|
|
|
|
/** Merged global + project settings (SPEC §7.3). Sync file read; avoids SettingsManager / isProjectTrusted() blocking in RPC hooks. */
|
|
export function readSettings(ctx: ExtensionContext): SkillReinjectSettings {
|
|
const global = extractSkillReinject(readSettingsFile(join(getAgentDir(), "settings.json")));
|
|
const projectPath = join(ctx.cwd, ".pi/settings.json");
|
|
const project = existsSync(projectPath)
|
|
? extractSkillReinject(readSettingsFile(projectPath))
|
|
: {};
|
|
return mergeSkillReinjectSettings(global, project);
|
|
}
|
|
|
|
function readSettingsFile(settingsPath: string): Record<string, unknown> {
|
|
if (!existsSync(settingsPath)) {
|
|
return {};
|
|
}
|
|
try {
|
|
const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return {};
|
|
}
|
|
return parsed as Record<string, unknown>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Merge skillReinject into a settings.json file without replacing unrelated keys (SPEC §7.3). */
|
|
export function mergeSkillReinjectIntoSettingsFile(
|
|
settingsPath: string,
|
|
partial: PartialSkillReinjectSettings,
|
|
): void {
|
|
const current = readSettingsFile(settingsPath);
|
|
const existing = parseSkillReinjectPartial(current[SKILL_REINJECT_SETTINGS_KEY]);
|
|
current[SKILL_REINJECT_SETTINGS_KEY] = { ...existing, ...partial };
|
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
writeFileSync(settingsPath, `${JSON.stringify(current, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
/** Persist partial skillReinject settings to global ~/.pi/agent/settings.json (SPEC §7.3). */
|
|
export function writeGlobalSettings(partial: PartialSkillReinjectSettings): void {
|
|
mergeSkillReinjectIntoSettingsFile(join(getAgentDir(), "settings.json"), partial);
|
|
}
|
|
|
|
/** Resolved re-inject delivery mode after integration settings (SPEC §6.5.3). */
|
|
export type ReinjectDeliveryMode = "defer" | "immediate";
|
|
|
|
/** Session override wins over global enabled default (SPEC §5.1). */
|
|
export function effectiveEnabled(
|
|
sessionOverride: boolean | null,
|
|
settings: SkillReinjectSettings,
|
|
): boolean {
|
|
return sessionOverride ?? settings.enabled;
|
|
}
|
|
|
|
/** Resolve delivery mode from integration setting, pi-auto-compact detect, and triggerTurn (SPEC §6.5.3). */
|
|
export function effectiveIntegration(
|
|
settings: SkillReinjectSettings,
|
|
autoCompactDetected: boolean,
|
|
sessionIntegrationOverride?: AutoCompactIntegration | null,
|
|
): ReinjectDeliveryMode {
|
|
const integration = sessionIntegrationOverride ?? settings.autoCompactIntegration;
|
|
switch (integration) {
|
|
case "defer":
|
|
return "defer";
|
|
case "immediate":
|
|
return "immediate";
|
|
case "off":
|
|
return settings.triggerTurn ? "immediate" : "defer";
|
|
case "auto":
|
|
default:
|
|
if (autoCompactDetected) {
|
|
return "defer";
|
|
}
|
|
return settings.triggerTurn ? "immediate" : "defer";
|
|
}
|
|
}
|