Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
abf813c
Parse model-group, model, and thinking frontmatter in readonly-cache
ofriw Jul 28, 2026
af358d8
Add pendingModelGroupCommands state queue with reset logic
ofriw Jul 28, 2026
d6099c6
Add notification builders for model, model-group, and thinking frontm…
ofriw Jul 28, 2026
b5957f2
Implement consumePendingModelGroupToggle with 3-priority resolution a…
ofriw Jul 28, 2026
83ec0d6
Add integration tests for frontmatter-driven model selection pipeline
ofriw Jul 28, 2026
56526e7
Update architecture docs for skill/prompt frontmatter model-group, mo…
ofriw Jul 28, 2026
1989c03
Rename ReadonlyCache* types and identifiers to FrontmatterCache*
ofriw Jul 28, 2026
6a5c4d8
Unify frontmatter issue entry type for readonly and model-group
ofriw Jul 29, 2026
311d575
Warn on invalid thinking while applying valid model from frontmatter
ofriw Jul 29, 2026
c24622a
Retain valid frontmatter fields during independent cache validation
ofriw Jul 29, 2026
3484ac4
Remove obsolete pendingModelGroupCommands queue from state
ofriw Jul 29, 2026
b79ba5a
Move model selection frontmatter to input preflight
ofriw Jul 29, 2026
0865afd
Support multi-slash model IDs in model-selection frontmatter
ofriw Jul 30, 2026
2a1f33e
Block model-selection frontmatter during streaming with extracted hel…
ofriw Jul 30, 2026
bcb1e80
Rename readonly-copy.ts to notifications.ts
ofriw Aug 3, 2026
e9e7d48
Remove dead getCommands() call and fix indentation in pending-command…
ofriw Aug 3, 2026
2ab901d
Split setModel rejection notifications from auth errors
ofriw Aug 3, 2026
3a33913
Merge branch 'main' into feat/models-frontmatter
ofriw Aug 5, 2026
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
14 changes: 11 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ pi-agenticoding is a Pi extension. It registers tools and hooks into the agent l

| Hook | Role |
|---|---|
| `before_agent_start` | Refreshes Model Groups, injects the context-management primer, names-only group guidance, and live notebook index; resolves deferred `readonly:` frontmatter |
| `before_agent_start` | Refreshes Model Groups, resolves deferred readonly frontmatter, then injects the context-management primer, names-only group guidance, and live notebook index |
| `context` | Advisory watchdog reminders when context is elevated; readonly toggle nudges |
| `input` | Queues skill/prompt names for deferred readonly frontmatter resolution |
| `input` | Resolves model-selection frontmatter during idle input; blocks it during streaming; queues readonly resolution |
| `tool_call` | Readonly blocks write/edit/unguarded bash; blocks handoff unless a requested bypass is active |
| `session_start` | Rehydrates notebook pages and readonly state; loads and validates Model Groups, registers group autocomplete, reports config issues, and resets session state on `/new` |
| `turn_end` | Updates TUI indicators (context %, notebook count, topic, readonly) |
Expand Down Expand Up @@ -48,7 +48,15 @@ interface AgenticodingState {

**Handoff** — Requires a real brief and a meaningful context load (rejects empty briefs, very small sessions, or missing usage). Notebook bodies are not inlined into the brief; the next context in this work stream fetches pages by name. Under readonly, handoff is blocked unless the user runs `/handoff` or crosses an eligible human topic boundary; readonly can resume after compaction. Compaction replaces the prior transcript with the brief: the next turns see a small context again (quality), and providers start a new input prefix for billing/cache (the dropped history is no longer in that prefix). Spawn runs children in separate context so their token use does not permanently inflate the parent. This extension does not configure provider cache TTLs or breakpoints.

**Readonly** — Session-persisted research posture. Toggle via `/readonly`, Ctrl+Shift+R, or `--readonly`. Skills/prompts may set `readonly: true` in frontmatter to defer-enable when invoked. Write/edit always blocked at the tool boundary. Bash uses a two-layer guard:
**Skill and prompt frontmatter** — Interactive TUI invocations resolve model selection during idle `input`; headless/RPC invocations ignore it. `readonly: true|false` alone is deferred to `before_agent_start`, where complete skill metadata is available.

- `model: <provider>/<model-id>` selects a configured, authenticated model. Only the first slash separates the provider, so model IDs may contain slashes.
- `model-group: <group-name>` routes through a configured Model Group. An explicit `model` takes precedence.
- `thinking: off|minimal|low|medium|high|xhigh|max` is capability-clamped for the selected or current model and overrides group thinking.

A failed model change visibly blocks command expansion and records no success entry. During streaming steer/follow-up, commands with model-selection frontmatter are visibly blocked without model or thinking mutation; commands without it continue. Invalid frontmatter is ignored with a TUI warning.

**Readonly** — Session-persisted research posture. Toggle via `/readonly`, Ctrl+Shift+R, or `--readonly`. Write/edit always blocked at the tool boundary. Bash uses a two-layer guard:

| Platform | Enforcement |
|---|---|
Expand Down
311 changes: 311 additions & 0 deletions frontmatter-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
/**
* Cache for skill/prompt-template frontmatter.
*
* Populated on-demand:
* - In the `input` handler from resolved commands (`pi.getCommands()`) for model preflight.
* - In `before_agent_start` from loaded skills (`systemPromptOptions.skills`) for readonly resolution.
* - Also from standard prompt directories as a partial fallback:
* `~/.pi/agent/prompts/` and trusted `cwd/.pi/prompts/`.
*
* All production prompt-resolution happens through
* `populatePromptCacheFromResolvedCommandsAndDirs`. The narrower
* `populateFromPromptDirs`, `populateFromPromptCommands`, and
* `populateFromPromptTemplates` have been removed as dead code —
* they duplicated the logic of the production path. Any test that
* needs to exercise prompt caching should go through the production
* function with an empty command list.
*/

import { readFileSync, statSync, readdirSync } from "node:fs";
import { join, extname, basename } from "node:path";
import { homedir } from "node:os";
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
import type { Skill, SlashCommandInfo } from "@earendil-works/pi-coding-agent";
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";

export interface FrontmatterEntry {
readonly: boolean | null;
modelGroup: string | null;
explicitModel: string | null;
explicitThinking: ModelThinkingLevel | null;
mtimeMs: number;
filePath: string;
}

export interface FrontmatterIssue {
kind: "invalid-readonly-value" | "invalid-model-group-value" | "invalid-explicit-model-value" | "invalid-thinking-value" | "malformed-frontmatter" | "unreadable-file";
filePath: string;
}

export interface FrontmatterCache {
frontmatterSkillCache: Map<string, FrontmatterEntry>;
frontmatterPromptCache: Map<string, FrontmatterEntry>;
frontmatterSkillIssues: Map<string, FrontmatterIssue>;
frontmatterPromptIssues: Map<string, FrontmatterIssue>;
}

interface CacheReadResult {
entry: FrontmatterEntry | null;
issue: FrontmatterIssue | null;
}

const THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];

function parseExplicitModel(raw: unknown): string | null {
if (typeof raw !== "string" || raw.trim().length === 0) return null;
const trimmed = raw.trim();
// The first slash separates provider; the model ID may contain slashes.
const slashIdx = trimmed.indexOf("/");
if (slashIdx === -1 || slashIdx === 0 || slashIdx === trimmed.length - 1) return null;
return trimmed;
}

function parseExplicitThinking(raw: unknown): ModelThinkingLevel | null {
if (typeof raw !== "string") return null;
const trimmed = raw.trim().toLowerCase() as ModelThinkingLevel;
return THINKING_LEVELS.includes(trimmed) ? trimmed : null;
}

function readCacheEntry(
filePath: string,
previous?: FrontmatterEntry,
previousIssue?: FrontmatterIssue,
): CacheReadResult {
let st;
try {
st = statSync(filePath);
} catch (error: any) {
return error?.code === "ENOENT" || error?.code === "ENOTDIR"
? { entry: null, issue: null }
: { entry: null, issue: { kind: "unreadable-file", filePath } };
}

if (previous && previous.filePath === filePath && st.mtimeMs === previous.mtimeMs) {
return { entry: previous, issue: previousIssue ?? null };
}

let content: string;
try {
content = readFileSync(filePath, "utf-8");
} catch {
return { entry: null, issue: { kind: "unreadable-file", filePath } };
}

try {
const { frontmatter } = parseFrontmatter<Record<string, unknown>>(content);
const readonly = frontmatter["readonly"];
const modelGroupRaw = frontmatter["model-group"];
const modelRaw = frontmatter["model"];
const thinkingRaw = frontmatter["thinking"];

const validReadonly = readonly === undefined || typeof readonly === "boolean";
const validModelGroup = modelGroupRaw === undefined || (typeof modelGroupRaw === "string" && modelGroupRaw.trim().length > 0);
const explicitModel = modelRaw !== undefined ? parseExplicitModel(modelRaw) : null;
const explicitThinking = thinkingRaw !== undefined ? parseExplicitThinking(thinkingRaw) : null;
const issue = !validReadonly
? "invalid-readonly-value"
: !validModelGroup
? "invalid-model-group-value"
: modelRaw !== undefined && explicitModel === null
? "invalid-explicit-model-value"
: thinkingRaw !== undefined && explicitThinking === null
? "invalid-thinking-value"
: null;

return {
entry: {
readonly: validReadonly ? readonly ?? null : null,
modelGroup: validModelGroup && typeof modelGroupRaw === "string" ? modelGroupRaw.trim() : null,
explicitModel,
explicitThinking,
mtimeMs: st.mtimeMs,
filePath,
},
issue: issue ? { kind: issue, filePath } : null,
};
} catch {
return {
entry: null,
issue: { kind: "malformed-frontmatter", filePath },
};
}
}

function replaceCache(target: Map<string, FrontmatterEntry>, next: Map<string, FrontmatterEntry>): void {
target.clear();
for (const [name, entry] of next) target.set(name, entry);
}

function replaceIssues(target: Map<string, FrontmatterIssue>, next: Map<string, FrontmatterIssue>): void {
target.clear();
for (const [name, issue] of next) target.set(name, issue);
}

function setEntry(
nextCache: Map<string, FrontmatterEntry>,
nextIssues: Map<string, FrontmatterIssue>,
name: string,
result: CacheReadResult,
): void {
if (result.entry) nextCache.set(name, result.entry);
if (result.issue) nextIssues.set(name, result.issue);
}

export function cacheLookupSkill(store: FrontmatterCache, name: string): boolean | null {
return store.frontmatterSkillCache.get(name)?.readonly ?? null;
}

export function cacheLookupPrompt(store: FrontmatterCache, name: string): boolean | null {
return store.frontmatterPromptCache.get(name)?.readonly ?? null;
}

export function cacheLookupCommand(store: FrontmatterCache, name: string): boolean | null {
return cacheLookupPrompt(store, name);
}

export function cacheLookupSkillModelGroup(store: FrontmatterCache, name: string): string | null {
return store.frontmatterSkillCache.get(name)?.modelGroup ?? null;
}

export function cacheLookupCommandModelGroup(store: FrontmatterCache, name: string): string | null {
return store.frontmatterPromptCache.get(name)?.modelGroup ?? null;
}

export function cacheLookupSkillExplicitModel(store: FrontmatterCache, name: string): string | null {
return store.frontmatterSkillCache.get(name)?.explicitModel ?? null;
}

export function cacheLookupCommandExplicitModel(store: FrontmatterCache, name: string): string | null {
return store.frontmatterPromptCache.get(name)?.explicitModel ?? null;
}

export function cacheLookupSkillExplicitThinking(store: FrontmatterCache, name: string): ModelThinkingLevel | null {
return store.frontmatterSkillCache.get(name)?.explicitThinking ?? null;
}

export function cacheLookupCommandExplicitThinking(store: FrontmatterCache, name: string): ModelThinkingLevel | null {
return store.frontmatterPromptCache.get(name)?.explicitThinking ?? null;
}

export function cacheLookupSkillIssue(store: FrontmatterCache, name: string): FrontmatterIssue | null {
return store.frontmatterSkillIssues.get(name) ?? null;
}

export function cacheLookupCommandIssue(store: FrontmatterCache, name: string): FrontmatterIssue | null {
return store.frontmatterPromptIssues.get(name) ?? null;
}

/**
* Format a user-facing warning message for a frontmatter issue.
* Covers invalid `readonly`, `model-group`, `model`, `thinking`,
* malformed YAML, and unreadable source files. Missing fields are normal no-ops.
*/
export function formatFrontmatterIssue(commandRef: string, issue: FrontmatterIssue): string {
const detail = issue.kind === "invalid-readonly-value"
? "`readonly` frontmatter must be `true` or `false`"
: issue.kind === "invalid-model-group-value"
? "`model-group` frontmatter must be a non-empty string"
: issue.kind === "invalid-explicit-model-value"
? "`model` frontmatter must be in `<provider>/<model-id>` format"
: issue.kind === "invalid-thinking-value"
? "`thinking` frontmatter must be one of: off, minimal, low, medium, high, xhigh, max"
: issue.kind === "malformed-frontmatter"
? "frontmatter could not be parsed"
: "prompt/skill file could not be read";
return `Frontmatter ignored for \`${commandRef}\`: ${detail} at \`${issue.filePath}\`.`;
}

export function populateFromSkills(store: FrontmatterCache, skills: Array<Pick<Skill, "name" | "filePath">>): void {
const nextCache = new Map<string, FrontmatterEntry>();
const nextIssues = new Map<string, FrontmatterIssue>();
for (const skill of skills) {
const result = readCacheEntry(
skill.filePath,
store.frontmatterSkillCache.get(skill.name),
store.frontmatterSkillIssues.get(skill.name),
);
setEntry(nextCache, nextIssues, skill.name, result);
}
replaceCache(store.frontmatterSkillCache, nextCache);
replaceIssues(store.frontmatterSkillIssues, nextIssues);
}

/** Populate skill frontmatter before command expansion from Pi's command registry. */
export function populateSkillCacheFromResolvedCommands(store: FrontmatterCache, commands: SlashCommandInfo[]): void {
populateFromSkills(store, commands.flatMap((command) => {
if (command.source !== "skill" || !command.name.startsWith("skill:")) return [];
return [{ name: command.name.slice("skill:".length), filePath: command.sourceInfo.path }];
}));
}

function collectPromptFilesFromDir(
store: FrontmatterCache,
dir: string,
nextCache: Map<string, FrontmatterEntry>,
nextIssues: Map<string, FrontmatterIssue>,
blockedNames: Set<string>,
): void {
let files: string[];
try {
files = readdirSync(dir);
} catch {
return;
}
for (const file of files) {
if (extname(file) !== ".md") continue;
const name = basename(file, ".md");
if (!name || blockedNames.has(name) || nextCache.has(name) || nextIssues.has(name)) continue;
const result = readCacheEntry(
join(dir, file),
store.frontmatterPromptCache.get(name),
store.frontmatterPromptIssues.get(name),
);
setEntry(nextCache, nextIssues, name, result);
}
}

/**
* Populate the prompt cache from resolved prompt commands plus the standard
* prompt directories that Pi also uses on disk.
*
* Priority for duplicate names:
* 1. Resolved prompt commands from `pi.getCommands()`.
* 2. Trusted project prompts in `cwd/.pi/prompts/`.
* 3. Global prompts in `~/.pi/agent/prompts/`.
*
* This is intentionally not a full prompt-source resolver. Anything outside
* those standard dirs must already be surfaced by `pi.getCommands()`.
*/
export function populatePromptCacheFromResolvedCommandsAndDirs(
store: FrontmatterCache,
commands: SlashCommandInfo[],
cwd: string,
projectTrusted: boolean,
): void {
const nextCache = new Map<string, FrontmatterEntry>();
const nextIssues = new Map<string, FrontmatterIssue>();
const blockedNames = new Set<string>();

for (const command of commands) {
if (!command.name) continue;
if (command.source !== "prompt") {
blockedNames.add(command.name);
continue;
}
if (nextCache.has(command.name) || nextIssues.has(command.name)) continue;
const result = readCacheEntry(
command.sourceInfo.path,
store.frontmatterPromptCache.get(command.name),
store.frontmatterPromptIssues.get(command.name),
);
setEntry(nextCache, nextIssues, command.name, result);
}

if (projectTrusted) {
collectPromptFilesFromDir(store, join(cwd, ".pi", "prompts"), nextCache, nextIssues, blockedNames);
}
collectPromptFilesFromDir(store, join(homedir(), ".pi", "agent", "prompts"), nextCache, nextIssues, blockedNames);

replaceCache(store.frontmatterPromptCache, nextCache);
replaceIssues(store.frontmatterPromptIssues, nextIssues);
}
2 changes: 1 addition & 1 deletion handoff/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { isHandoffEligible } from "./eligibility.js";
import {
READONLY_HANDOFF_EXCEPTION_NOTIFICATION,
buildReadonlyHandoffCommandNotice,
} from "../readonly-copy.js";
} from "../notifications.js";
import type { AgenticodingState } from "../state.js";
import { STATUS_KEY_HANDOFF } from "../tui.js";

Expand Down
2 changes: 1 addition & 1 deletion handoff/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
READONLY_BYPASS_CLEARED,
READONLY_NEXT_CONTEXT_RESUMES,
READONLY_NON_TEMP_MUTATION_SCOPE,
} from "../readonly-copy.js";
} from "../notifications.js";

/**
* Build the enriched task that becomes the compaction summary.
Expand Down
Loading