Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 42 additions & 4 deletions extensions/iterator.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
autoCompleteMessage,
AUTO_PHASE_FOR_STEP,
bundleExists,
classifyRoleModel,
featuresDirEntries,
composeAmbientContext,
completeFeatureWaveAbort,
Expand Down Expand Up @@ -365,20 +366,57 @@ export default function iteratorExtension(pi) {
const modelOptions = async () => {
try {
const models = (await lastCtx?.modelRegistry?.getAvailable?.()) || [];
const out = models.map((m) => ({
id: `${m.provider}/${m.id}`,
label: `${m.provider}/${m.id}`,
}));
// Carry the same verdict the save gate applies, so the form can show
// an unusable choice instead of letting it fail at provider call time.
const out = models.map((m) => {
const id = `${m.provider}/${m.id}`;
const verdict = classifyRoleModel(id, lastCtx?.model, models);
return {
id,
label: id,
...(verdict.ok ? {} : { unusable: true, note: verdict.detail }),
};
});
return out.length ? out : null;
} catch {
return null;
}
};

/**
* Refuse role models this session cannot route before anything is written —
* an unusable choice is otherwise invisible until the provider rejects it
* mid-turn, far from the settings form that caused it.
*/
const unusableRoleModels = async (values) => {
const keys = Object.keys(values || {}).filter((k) =>
k.endsWith("_model"),
);
if (!keys.length) return [];
const available = (await lastCtx?.modelRegistry?.getAvailable?.()) || [];
if (!available.length) return [];
return keys
.map((key) => ({
key,
verdict: classifyRoleModel(values[key], lastCtx?.model, available),
}))
.filter((r) => !r.verdict.ok)
.map(
({ key, verdict }) =>
`${key}: ${verdict.detail}` +
(verdict.suggestion ? ` — did you mean ${verdict.suggestion}?` : ""),
);
};

/** Deterministically write the settings op and refresh the dashboard. */
const saveSettings = async (values) => {
const cwd = ctxCwd();
try {
// Throw rather than return: the catch below is the one place that
// reports a failed save, so a headless caller cannot silently lose
// the write the way an early return would let it.
const unusable = await unusableRoleModels(values);
if (unusable.length) throw new Error(unusable.join("; "));
const result = await runJson(scriptPath("write"), [], {
cwd,
stdin: JSON.stringify({ op: "settings", values }),
Expand Down
51 changes: 51 additions & 0 deletions lib/bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,57 @@ export const OKF_AREAS = {

export const OKF_AREA_NAMES = Object.keys(OKF_AREAS);

// ---------------------------------------------------------------------------
// The agent-facing bundle contract (memory/EXTENSIONS.md)

/**
* The body of memory/EXTENSIONS.md. It lives here rather than in write.mjs so
* gather can diff a bundle's stored copy against it (`contractStale`) without
* importing the writer — write.mjs already imports gather, so the reverse
* direction would be a cycle.
*
* This is the one document that must make the bundle usable by an agent with
* no iterator extension loaded, so it states the read order, which files are
* machine-owned, and how to discover the writer's ops.
*/
export const EXTENSIONS_BODY = `Guidance for agents and extensions reading or updating this bundle.

# Reading

* Start at \`memory/index.md\`, follow the area indexes, then open only the
relevant concept files (progressive disclosure — never bulk-read the bundle).
* For work in progress, read \`memory/plan.md\` and \`memory/features/index.md\`
first: they say what is being built and where each feature stands.
* Before changing code, read the concepts whose \`files:\` anchors match the
files you are about to touch — \`decisions/\` first (new work must not
silently contradict one), then architecture, patterns, pitfalls, and setup.
* A concept ID is the bundle-relative path without \`.md\`; feature IDs/slugs
are their filenames without \`.md\` and are the stable identity used by tools.
* Non-reserved concept files require YAML frontmatter with a non-empty
\`type\`; preserve unknown keys and tolerate unknown concept types.
* \`memory/format.md\` documents the metadata schema for every document type in
this bundle; read it before authoring or parsing frontmatter.

# Writing

* Safe writes create or update one concept file at a time, keep markdown
human-readable and diffable, update \`timestamp\`, regenerate affected
indexes, and append a newest-first \`memory/log.md\` entry for meaningful
changes.
* Knowledge writes should go through the iterator writer (\`write.mjs\` ops
\`memorize\` / \`apply-review\`); plan/feature writes through its plan/feature ops.
* These files are machine-owned — never hand-edit them: \`index.md\` and every
area \`index.md\`, \`features/*.md\` frontmatter, \`features/index.md\`,
\`log.md\`, \`state.md\`, \`usage.md\`, \`settings.md\`, and the plan's
\`# Features\` section. Prose in a feature body is yours to write.
* Discover the writer's contract instead of guessing: \`write.mjs --schema\`
lists every op and \`write.mjs --schema <op>\` prints one op's payload. The
writer validates before writing and writes nothing on failure.
* Derived state (a feature's readiness, what it is waiting on, the plan's
stage) comes from \`gather.mjs --step <step>\`. Render what it reports; do
not recompute it from raw statuses.
`;

/** Rebuild an area index's bullet list, preserving its heading and prose. */
export function regenerateAreaIndex(memDir, area) {
const dir = join(memDir, area);
Expand Down
18 changes: 16 additions & 2 deletions lib/gather.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { planStage, readiness, satisfiedSet } from "./status.mjs";
import {
backlogItems,
body,
EXTENSIONS_BODY,
frontmatter,
globToRegExp,
listy,
Expand Down Expand Up @@ -1225,14 +1226,24 @@ export function gatherKnowledge(startDir) {
}));

// memory/format.md is copied from the template once (on the first plan
// write) and drifts as the template evolves — surface that.
// write or at init) and drifts as the template evolves — surface that.
const template = resolveTemplate("format.md");
const formatFile = join(b.memDir, "format.md");
const formatMissing = !existsSync(formatFile);
const formatStale =
!!template &&
existsSync(formatFile) &&
!formatMissing &&
readFileSync(template, "utf8") !== readFileSync(formatFile, "utf8");

// EXTENSIONS.md is generated boilerplate, so it drifts the same way once
// the contract gains rules. Compare the stored body against the current
// one, ignoring the frontmatter and any project-specific preamble above it.
const contractFile = join(b.memDir, "EXTENSIONS.md");
const contractMissing = !existsSync(contractFile);
const contractStale =
!contractMissing &&
!body(readFileSync(contractFile, "utf8")).includes(EXTENSIONS_BODY.trim());

const staleCount = memories.filter((m) => m.stale).length;
// The forcing sentence consolidate follows: the review round ALWAYS opens
// (even all-keep) so the run produces a visible outcome — the model must
Expand Down Expand Up @@ -1303,6 +1314,9 @@ export function gatherKnowledge(startDir) {
}
: null,
formatStale,
formatMissing,
contractStale,
contractMissing,
};
}

Expand Down
62 changes: 62 additions & 0 deletions lib/pi-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,68 @@ export function resolveRoleModel(
return model ? { model, switchRequired: true } : null;
}

/**
* Judge a configured role model against what this session can actually reach,
* so a bad choice is refused where it is made instead of surfacing later as a
* provider authentication error.
*
* Structural only — identity and the registry listing decide, never a probe
* and never a credential sentinel. Two verdicts are refused:
*
* `unknown` the registry does not list it at all, so no route exists.
* `route` the registry lists it, but the SAME model id is also listed
* under the active runtime model's provider. That duplicate is
* the trap: both entries look valid in a dropdown while only the
* active provider carries the host's routing and credentials.
*
* A model on some other provider with no such duplicate is left alone — it may
* be perfectly usable with its own credentials, and refusing it would make
* legitimate models unsettable.
*/
export function classifyRoleModel(spec, activeModel, available) {
if (!spec || spec === "active") return { ok: true };
const identity = parseModelSpec(spec);
if (!identity)
return {
ok: false,
reason: "malformed",
detail: `"${spec}" is not "active" or "<provider>/<model-id>"`,
};
if (modelMatchesSpec(activeModel, spec)) return { ok: true };

const list = Array.isArray(available) ? available : [];
// No registry to check against — stay out of the way rather than guess.
if (!list.length) return { ok: true };

const listed = list.some(
(m) => m?.provider === identity.provider && m?.id === identity.id,
);
if (!listed) {
const sameId = list.find((m) => m?.id === identity.id);
return {
ok: false,
reason: "unknown",
detail: `${spec} is not available in this session`,
suggestion: sameId ? `${sameId.provider}/${sameId.id}` : null,
};
}

const activeProvider = activeModel?.provider;
if (activeProvider && activeProvider !== identity.provider) {
const onActiveProvider = list.some(
(m) => m?.provider === activeProvider && m?.id === identity.id,
);
if (onActiveProvider)
return {
ok: false,
reason: "route",
detail: `${spec} is listed, but ${identity.id} is also available on the active provider "${activeProvider}" — the configured one routes elsewhere`,
suggestion: `${activeProvider}/${identity.id}`,
};
}
return { ok: true };
}

/** Modern Pi resolves void on success; older runtimes may return a boolean. */
export function modelSwitchSucceeded(result) {
return result !== false;
Expand Down
45 changes: 39 additions & 6 deletions lib/views/settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
* input: { step:"settings", branch, plan,
* settings:{<key>:<effective value>}, // defaults merged
* defined, // memory/settings.md exists
* models?: [{id,label}] } // pi model registry, for model dropdowns
* models?: [{id,label,unusable?,note?}] } // pi model registry, for
* // model dropdowns; the
* // unusable verdict is
* // supplied, never derived
* output: one JSON line to stdout —
* { type:"settings", values:{<changed key>:<value>} }
* | { type:"settings-close" } when the shell-owned modal is dismissed.
Expand All @@ -30,6 +33,10 @@ h1{font-family:var(--font-display);font-size:var(--fs-xl);font-weight:600;margin
select.ctl,input.ctl{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);
color:var(--text);font-size:var(--fs-sm);font-family:var(--font-mono);padding:6px 10px;outline:none;min-width:140px}
select.ctl:focus,input.ctl:focus{border-color:var(--accent)}
.ctlwrap{display:flex;flex-direction:column;gap:4px;align-items:flex-end}
.ctlwarn{font-size:var(--fs-xs);color:var(--dot-yellow);max-width:280px;text-align:right;line-height:1.4}
.ctlwarn:empty{display:none}
select.ctl.bad{border-color:var(--dot-yellow)}
input.ctl[type=number]{width:90px;min-width:90px}
.toggle{display:inline-flex;border:1px solid var(--border);border-radius:var(--radius-sm);overflow:hidden}
.toggle button{font-size:var(--fs-sm);padding:6px 16px;border:none;cursor:pointer;background:var(--bg);color:var(--text-muted);font-family:inherit}
Expand Down Expand Up @@ -152,22 +159,48 @@ function makeControl(key, def){
}
// model: dropdown when the pi registry rode along, free text otherwise.
if(MODELS && MODELS.length){
const wrap = document.createElement('div'); wrap.className='ctlwrap';
const s = document.createElement('select'); s.className='ctl';
const act = document.createElement('option'); act.value='active'; act.textContent='active (session model)';
s.appendChild(act);
MODELS.forEach(m => { const o=document.createElement('option'); o.value=m.id; o.textContent=m.label||m.id; s.appendChild(o); });
MODELS.forEach(m => {
const o=document.createElement('option'); o.value=m.id;
// The verdict rides along with the option — a model this session cannot
// route reads as unusable before it is ever saved.
o.textContent=(m.label||m.id)+(m.unusable?' \\u2014 unusable':'');
if(m.unusable){ o.dataset.unusable='1'; if(m.note) o.dataset.note=m.note; }
s.appendChild(o);
});
if(![...s.options].some(o => o.value === String(cur[key]))){
const o=document.createElement('option'); o.value=String(cur[key]); o.textContent=String(cur[key])+' (unlisted)'; s.appendChild(o);
const o=document.createElement('option'); o.value=String(cur[key]);
o.textContent=String(cur[key])+' (unlisted)'; o.dataset.unusable='1';
o.dataset.note='not offered by this session\\u2019s model registry';
s.appendChild(o);
}
s.value = String(cur[key]);
s.addEventListener('change', () => { cur[key]=s.value; refresh(); });
return s;
const warn = document.createElement('div'); warn.className='ctlwarn';
const paint = () => {
const sel = s.options[s.selectedIndex];
const bad = sel && sel.dataset.unusable === '1';
warn.textContent = bad ? (sel.dataset.note || 'this session cannot use this model') : '';
s.classList.toggle('bad', !!bad);
};
paint();
s.addEventListener('change', () => { cur[key]=s.value; paint(); refresh(); });
wrap.appendChild(s); wrap.appendChild(warn);
return wrap;
}
const wrap = document.createElement('div'); wrap.className='ctlwrap';
const i = document.createElement('input'); i.className='ctl';
i.placeholder = 'active or provider/model-id';
i.value = String(cur[key]);
i.addEventListener('input', () => { cur[key]=i.value.trim(); refresh(); });
return i;
// No registry rode along, so nothing here is checked against real models —
// say so rather than letting a plain box pass for a validated field.
const note = document.createElement('div'); note.className='ctlwarn';
note.textContent = 'model list unavailable — this value is not checked against this session';
wrap.appendChild(i); wrap.appendChild(note);
return wrap;
}

function syncToggle(t, key){
Expand Down
Loading
Loading