Compare commits

...

17 Commits

Author SHA1 Message Date
grayhook 884fee99a5 TODO: mark phase 2 complete — settings types, read, write, effective, tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:14:05 +07:00
grayhook 36cafc4405 Phase 2: add settings tests — defaults, merge, effective helpers.
Covers layered settings merge, incremental global file writes, and
delivery-mode resolution per SPEC §6.5.3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:13:56 +07:00
grayhook 00ffdff578 Phase 2: add effective helpers — enabled and delivery mode resolution.
Implements SPEC §5.1 session/global layering and §6.5.3 integration
table for defer vs immediate delivery decisions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:12:21 +07:00
grayhook 9b0264f687 Phase 2: add writeGlobalSettings — merge into global settings.json.
Preserves unrelated keys in ~/.pi/agent/settings.json when persisting
/skill-reinject global on|off and other skillReinject fields.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:12:04 +07:00
grayhook d8abd46f90 Phase 2: add settings read — merge global and project via SettingsManager.
Parses skillReinject from Pi settings layers with defaults and project-trust
aware loading so extension code shares one merged settings view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:11:43 +07:00
grayhook 04dcab5b42 Phase 2: add settings types — SPEC §7.3 contract and defaults.
Defines SkillReinjectSettings and default values so read/merge/effective
helpers share one source of truth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:08:04 +07:00
grayhook 6050ca98ea TODO: mark phase 1 complete — state types, persist, load, trackSkill.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:05:02 +07:00
grayhook a671a6dc35 Phase 1: add trackSkill — dedupe tracked skills by name.
Upsert merges sources without reordering the skills list per SPEC §6.1
dedupe and insertion-order rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:04:55 +07:00
grayhook edcf5352c4 Phase 1: add loadStateFromBranch — restore last state entry.
Walk branch backwards for skill-reinject:state custom entries per SPEC §6.3;
return structuredClone of valid v1 snapshot or null for full rescan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:04:11 +07:00
grayhook db04a1dd01 Phase 1: add saveState — persist ExtensionState via appendEntry.
STATE_ENTRY_TYPE skill-reinject:state matches SPEC §6.1 so state survives
/resume and branch reload without a full rescan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:02:42 +07:00
grayhook 195ed026ff Phase 1: add state factories — predictable session bootstrap.
createInitialState and createRuntimeFlags return SPEC §6.1 defaults
before load, detect, and settings merge on session_start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 10:00:46 +07:00
grayhook 57d96e8b46 Phase 1: add state.ts types — shared SPEC §6.1 contract.
TrackedSkill, ExtensionState (version 1), and RuntimeFlags with union
aliases for skill sources, compaction source, and auto-compact integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 09:59:51 +07:00
grayhook 645a46c0e9 TODO: mark phase 0 complete — package manifest, tsconfig, scripts, index shell.
AGENTS status table updated; next up is phase 1 (state and persistence).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 09:56:31 +07:00
grayhook 2ddb9431b5 Phase 0: add src/index.ts shell — minimal ExtensionAPI entry for pi -e smoke.
Register empty session_start handler; real tracking and reinject logic follow in later phases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 09:56:20 +07:00
grayhook 3ed38d7e1e Phase 0: add npm scripts — typecheck, test, and build entry points.
Vitest with passWithNoTests keeps CI green before test files land in later phases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 09:54:57 +07:00
grayhook 311c329f87 Phase 0: add tsconfig.json — strict typecheck aligned with jiti ESM load.
Use ESNext/Bundler resolution so extension imports match Pi's jiti runtime.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 09:53:47 +07:00
grayhook 2e3f909527 Phase 0: add package.json — pi.extensions manifest for pi -e load.
Declare devDependencies for typecheck against Pi's public ExtensionAPI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 09:52:35 +07:00
9 changed files with 4047 additions and 15 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
|---|---| |---|---|
| Продукт | Extension `skill-reinject` для [Pi Coding Agent](https://github.com/earendil-works/pi) | | Продукт | Extension `skill-reinject` для [Pi Coding Agent](https://github.com/earendil-works/pi) |
| Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction | | Цель | Отслеживать вызванные skills и повторно инжектить их после **auto** compaction |
| Статус | Спецификация готова; реализация — по фазам в `TODO.md` | | Статус | Спецификация готова; **фаза 0** завершена; реализация — фазы 1+ в `TODO.md` |
| Целевой API | Публичный `ExtensionAPI` Pi (`extensions.md`), без приватных internal imports | | Целевой API | Публичный `ExtensionAPI` Pi (`extensions.md`), без приватных internal imports |
| Совместимость | [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact) — режим `defer` по умолчанию (см. SPEC §16) | | Совместимость | [@capyup/pi-auto-compact](https://github.com/capyup/pi-auto-compact) — режим `defer` по умолчанию (см. SPEC §16) |
+14 -14
View File
@@ -112,30 +112,30 @@
### Фаза 0 — Каркас репозитория ### Фаза 0 — Каркас репозитория
- [ ] **package.json** — manifest с `pi.extensions`, devDependencies (`@earendil-works/pi-coding-agent`, `typescript`); зачем: загрузка extension через `pi -e` (SPEC §9.1, §10) - [x] **package.json** — manifest с `pi.extensions`, devDependencies (`@earendil-works/pi-coding-agent`, `typescript`); зачем: загрузка extension через `pi -e` (SPEC §9.1, §10)
- [ ] **tsconfig.json** — strict TS, module resolution под Pi extension runtime; зачем: `tsc --noEmit` в цикле AGENTS - [x] **tsconfig.json** — strict TS, module resolution под Pi extension runtime; зачем: `tsc --noEmit` в цикле AGENTS
- [ ] **npm scripts**`typecheck`, `test`, `build` (минимально); зачем: единая проверка в каждом пункте - [x] **npm scripts**`typecheck`, `test`, `build` (минимально); зачем: единая проверка в каждом пункте
- [ ] **src/index.ts shell**`export default function(pi: ExtensionAPI)`, пустой `session_start`; зачем: smoke `pi -e ./src/index.ts` без логики - [x] **src/index.ts shell**`export default function(pi: ExtensionAPI)`, пустой `session_start`; зачем: smoke `pi -e ./src/index.ts` без логики
--- ---
### Фаза 1 — Состояние и персистенция ### Фаза 1 — Состояние и персистенция
- [ ] **state.ts types**`TrackedSkill`, `ExtensionState` (version 1), `RuntimeFlags`; зачем: единый контракт §6.1 - [x] **state.ts types**`TrackedSkill`, `ExtensionState` (version 1), `RuntimeFlags`; зачем: единый контракт §6.1
- [ ] **state.ts initial**`createInitialState()`, `createRuntimeFlags()`; зачем: предсказуемый старт сессии - [x] **state.ts initial**`createInitialState()`, `createRuntimeFlags()`; зачем: предсказуемый старт сессии
- [ ] **state.ts persist**`saveState(pi, state)` через `appendEntry("skill-reinject:state", …)`; зачем: пережить `/resume` (§6.1) - [x] **state.ts persist**`saveState(pi, state)` через `appendEntry("skill-reinject:state", …)`; зачем: пережить `/resume` (§6.1)
- [ ] **state.ts load**`loadStateFromBranch(branch)` из последнего custom entry; зачем: восстановление без полного rescan - [x] **state.ts load**`loadStateFromBranch(branch)` из последнего custom entry; зачем: восстановление без полного rescan
- [ ] **state.ts trackSkill** — upsert по `name`, merge `sources`, preserve insertion order; зачем: дедуп §6.1 - [x] **state.ts trackSkill** — upsert по `name`, merge `sources`, preserve insertion order; зачем: дедуп §6.1
--- ---
### Фаза 2 — Настройки ### Фаза 2 — Настройки
- [ ] **settings.ts types**`SkillReinjectSettings` + defaults из §7.3 (`enabled`, `trackReadPaths`, `triggerTurn`, `reinjectOnManualCompaction`, `autoCompactIntegration`, `suffix`) - [x] **settings.ts types**`SkillReinjectSettings` + defaults из §7.3 (`enabled`, `trackReadPaths`, `triggerTurn`, `reinjectOnManualCompaction`, `autoCompactIntegration`, `suffix`)
- [ ] **settings.ts read** — merge global + project из `ctx` / Pi settings API; зачем: не читать файл вручную, если API даёт merged view - [x] **settings.ts read** — merge global + project из `ctx` / Pi settings API; зачем: не читать файл вручную, если API даёт merged view
- [ ] **settings.ts writeGlobal** — merge в `~/.pi/agent/settings.json` без затирания чужих ключей; зачем: `/skill-reinject global on` - [x] **settings.ts writeGlobal** — merge в `~/.pi/agent/settings.json` без затирания чужих ключей; зачем: `/skill-reinject global on`
- [ ] **settings.ts effective**`effectiveEnabled(sessionOverride, global)`, `effectiveIntegration(...)`; зачем: три слоя §5.1 - [x] **settings.ts effective**`effectiveEnabled(sessionOverride, global)`, `effectiveIntegration(...)`; зачем: три слоя §5.1
- [ ] **test/settings.test.ts** — defaults, merge write (mock/temp file); зачем: §12.1 - [x] **test/settings.test.ts** — defaults, merge write (mock/temp file); зачем: §12.1
--- ---
+3538
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "pi-skill-reinject",
"version": "0.0.0",
"private": true,
"description": "Pi Coding Agent extension: re-inject skills after auto compaction",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"build": "npm run typecheck"
},
"pi": {
"extensions": [
"./src/index.ts"
]
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "^0.79.6",
"typescript": "^5",
"vitest": "^3"
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function skillReinject(pi: ExtensionAPI): void {
pi.on("session_start", () => {
// Phase 0 shell — hooks wired in later phases
});
}
+171
View File
@@ -0,0 +1,171 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { SettingsManager, 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;
}
/** 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.",
};
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;
}
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 via Pi SettingsManager (SPEC §7.3). */
export function readSettings(ctx: ExtensionContext): SkillReinjectSettings {
const manager = SettingsManager.create(ctx.cwd, getAgentDir(), {
projectTrusted: ctx.isProjectTrusted(),
});
return mergeSkillReinjectSettings(
extractSkillReinject(manager.getGlobalSettings()),
extractSkillReinject(manager.getProjectSettings()),
);
}
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";
}
}
+119
View File
@@ -0,0 +1,119 @@
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
/** How a skill was first observed in the session (SPEC §6.2). */
export type SkillSource = "slash" | "skill-block" | "read";
/** Compaction that triggered or skipped re-inject (SPEC §8). */
export type CompactionSource = "auto" | "manual";
/** pi-auto-compact delivery mode (SPEC §6.5, §16). */
export type AutoCompactIntegration = "auto" | "defer" | "immediate" | "off";
export interface TrackedSkill {
name: string;
filePath: string;
baseDir: string;
firstSeenAt: number;
lastSeenAt: number;
sources: SkillSource[];
}
export interface ExtensionState {
version: 1;
sessionOverride: boolean | null;
skills: TrackedSkill[];
lastCompactionSource: CompactionSource | null;
/** Skill names awaiting re-inject on the next before_agent_start (SPEC §6.5). */
pendingReinject: string[];
}
/** Runtime-only; not persisted via appendEntry (SPEC §6.1). */
export interface RuntimeFlags {
autoCompactDetected: boolean;
autoCompactIntegration: AutoCompactIntegration;
}
export const STATE_ENTRY_TYPE = "skill-reinject:state";
export function saveState(pi: ExtensionAPI, state: ExtensionState): void {
pi.appendEntry<ExtensionState>(STATE_ENTRY_TYPE, state);
}
function isExtensionState(data: unknown): data is ExtensionState {
if (!data || typeof data !== "object") {
return false;
}
const candidate = data as ExtensionState;
return (
candidate.version === 1 &&
(candidate.sessionOverride === null || typeof candidate.sessionOverride === "boolean") &&
Array.isArray(candidate.skills) &&
(candidate.lastCompactionSource === null ||
candidate.lastCompactionSource === "auto" ||
candidate.lastCompactionSource === "manual") &&
Array.isArray(candidate.pendingReinject)
);
}
/** Latest persisted state on the branch, or null if none (SPEC §6.3). */
export function loadStateFromBranch(branch: SessionEntry[]): ExtensionState | null {
for (let i = branch.length - 1; i >= 0; i--) {
const entry = branch[i];
if (entry.type !== "custom" || entry.customType !== STATE_ENTRY_TYPE || entry.data === undefined) {
continue;
}
if (!isExtensionState(entry.data)) {
continue;
}
return structuredClone(entry.data);
}
return null;
}
export function createInitialState(): ExtensionState {
return {
version: 1,
sessionOverride: null,
skills: [],
lastCompactionSource: null,
pendingReinject: [],
};
}
export function createRuntimeFlags(): RuntimeFlags {
return {
autoCompactDetected: false,
autoCompactIntegration: "auto",
};
}
export interface TrackSkillInput {
name: string;
filePath: string;
baseDir: string;
source: SkillSource;
seenAt?: number;
}
/** Upsert by name, merge sources, preserve insertion order (SPEC §6.1). */
export function trackSkill(state: ExtensionState, input: TrackSkillInput): void {
const seenAt = input.seenAt ?? Date.now();
const existing = state.skills.find((skill) => skill.name === input.name);
if (existing) {
existing.filePath = input.filePath;
existing.baseDir = input.baseDir;
existing.lastSeenAt = seenAt;
if (!existing.sources.includes(input.source)) {
existing.sources.push(input.source);
}
return;
}
state.skills.push({
name: input.name,
filePath: input.filePath,
baseDir: input.baseDir,
firstSeenAt: seenAt,
lastSeenAt: seenAt,
sources: [input.source],
});
}
+161
View File
@@ -0,0 +1,161 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_SKILL_REINJECT_SETTINGS,
SKILL_REINJECT_SETTINGS_KEY,
createDefaultSettings,
effectiveEnabled,
effectiveIntegration,
mergeSkillReinjectIntoSettingsFile,
mergeSkillReinjectSettings,
parseSkillReinjectPartial,
} from "../src/settings";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function tempSettingsPath(): string {
const dir = mkdtempSync(join(tmpdir(), "pi-skill-reinject-settings-"));
tempDirs.push(dir);
return join(dir, "settings.json");
}
describe("createDefaultSettings", () => {
it("matches SPEC §7.3 defaults", () => {
expect(createDefaultSettings()).toEqual(DEFAULT_SKILL_REINJECT_SETTINGS);
});
});
describe("parseSkillReinjectPartial", () => {
it("ignores invalid shapes and fields", () => {
expect(parseSkillReinjectPartial(null)).toEqual({});
expect(parseSkillReinjectPartial([])).toEqual({});
expect(
parseSkillReinjectPartial({
enabled: "yes",
autoCompactIntegration: "bogus",
extra: true,
}),
).toEqual({});
});
it("keeps valid fields only", () => {
expect(
parseSkillReinjectPartial({
enabled: true,
suffix: "custom",
}),
).toEqual({ enabled: true, suffix: "custom" });
});
});
describe("mergeSkillReinjectSettings", () => {
it("applies global then project overrides on defaults", () => {
expect(
mergeSkillReinjectSettings({ enabled: true }, { trackReadPaths: false }),
).toEqual({
...DEFAULT_SKILL_REINJECT_SETTINGS,
enabled: true,
trackReadPaths: false,
});
});
it("lets project win over global for the same field", () => {
expect(
mergeSkillReinjectSettings({ enabled: true }, { enabled: false }),
).toEqual({
...DEFAULT_SKILL_REINJECT_SETTINGS,
enabled: false,
});
});
});
describe("mergeSkillReinjectIntoSettingsFile", () => {
it("merges skillReinject fields across successive writes", () => {
const path = tempSettingsPath();
mergeSkillReinjectIntoSettingsFile(path, { enabled: true });
mergeSkillReinjectIntoSettingsFile(path, { triggerTurn: true });
const saved = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
expect(saved[SKILL_REINJECT_SETTINGS_KEY]).toEqual({
enabled: true,
triggerTurn: true,
});
});
it("preserves existing top-level keys", () => {
const path = tempSettingsPath();
writeFileSync(
path,
`${JSON.stringify({ theme: "light", compaction: { enabled: false } }, null, 2)}\n`,
"utf8",
);
mergeSkillReinjectIntoSettingsFile(path, { enabled: true, suffix: "x" });
const saved = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
expect(saved.theme).toBe("light");
expect(saved.compaction).toEqual({ enabled: false });
expect(saved[SKILL_REINJECT_SETTINGS_KEY]).toEqual({
enabled: true,
suffix: "x",
});
});
});
describe("effectiveEnabled", () => {
const settings = createDefaultSettings();
it("uses session override when set", () => {
expect(effectiveEnabled(true, settings)).toBe(true);
expect(effectiveEnabled(false, { ...settings, enabled: true })).toBe(false);
});
it("falls back to global enabled when override is null", () => {
expect(effectiveEnabled(null, settings)).toBe(false);
expect(effectiveEnabled(null, { ...settings, enabled: true })).toBe(true);
});
});
describe("effectiveIntegration", () => {
const base = createDefaultSettings();
it("honors explicit defer and immediate", () => {
expect(
effectiveIntegration({ ...base, autoCompactIntegration: "defer" }, false),
).toBe("defer");
expect(
effectiveIntegration({ ...base, autoCompactIntegration: "immediate" }, true),
).toBe("immediate");
});
it("uses triggerTurn when integration is off", () => {
expect(
effectiveIntegration({ ...base, autoCompactIntegration: "off", triggerTurn: false }, true),
).toBe("defer");
expect(
effectiveIntegration({ ...base, autoCompactIntegration: "off", triggerTurn: true }, true),
).toBe("immediate");
});
it("defers when auto mode detects pi-auto-compact", () => {
expect(effectiveIntegration(base, true)).toBe("defer");
});
it("uses triggerTurn in auto mode without pi-auto-compact", () => {
expect(effectiveIntegration({ ...base, triggerTurn: false }, false)).toBe("defer");
expect(effectiveIntegration({ ...base, triggerTurn: true }, false)).toBe("immediate");
});
it("prefers session integration override", () => {
expect(
effectiveIntegration({ ...base, autoCompactIntegration: "immediate" }, false, "defer"),
).toBe("defer");
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}