Files
pi-auto-reinject/src/settings.ts
T
grayhook aca68e73ee Phase 14: add requireRegistered setting — opt-out for strict registry filter
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>
2026-06-17 17:27:09 +07:00

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";
}
}