The other half-wired seam: Platform's settings 'agent.prompts' switches had no consumer in the agent layer, and their effectivePromptSegments mirrored the PRE-sources-v1 loader — the shared/ fragments dir (the learner-files doctrine) was invisible to it, so a naive delegation would have silently dropped the segment from both agents' prompts. - src/core/prompt-segments.ts (NEW, pure): the mirror-layout path grammar + segment types, defined exactly once; core stays zero-IO. - src/agent/prompt-segments.ts (NEW, the CANONICAL discovery): base + shared/ + per-agent fragments, override resolution, allSegmentPaths. Composition is an agent concern; the agent layer reads its own tree (the architecture law pins agent and store apart — the first direct refactor attempt tripped the enforced import/no-restricted-paths zone, hence the three-layer split). - src/store/prompt-overrides.ts: slimmed to the override-file CRUD behind the core port; shares the core grammar. Its doc no longer claims to be the canonical mirror. - teacher-agent: composeInstructions(kind, switches?) composes through effectivePromptSegments — a segment whose switch is ON and whose override file exists composes the override text. Switches-less calls use the cached pure-source composition (byte-identical to the old loader, pinned by test); AgentBuildOptions.promptSwitches threads effective.agent.prompts per turn, composing fresh so override-file writes flip the very next turn. - server barrel: re-exports the agent module's resolution for the settings routes (server→agent, the lawful direction). Tests: byte-identical source pin, override swap-in (on/off/file-absent), shared/ overridability in both agents, agent-construction threading, updated segment-list pins with shared/ in composition order. This file keeps owning PROMPTS_OVERRIDE_DIR (parallel-test discipline). Gates: 671/671, typecheck, lint, build.
88 lines
3.3 KiB
TypeScript
88 lines
3.3 KiB
TypeScript
/**
|
|
* The prompt-override STORE (settings-storage wave, 2026-09-19; P2
|
|
* reconciliation same day): the override-file CRUD behind the core port —
|
|
* list/read/write/delete under config.PROMPTS_OVERRIDE_DIR.
|
|
*
|
|
* The CANONICAL segment discovery + resolution (listSourceSegments,
|
|
* effectivePromptSegments, allSegmentPaths) lives in the agent layer
|
|
* (src/agent/prompt-segments.ts) — composition is an agent concern, and
|
|
* the architecture law pins the layers apart (store may not import agent;
|
|
* agent may not import store). Both sides share the pure path grammar
|
|
* (src/core/prompt-segments.ts); the store's readOverride and the agent's
|
|
* readPromptOverride read the SAME mirror layout.
|
|
*
|
|
* SEGMENTS: a prompt composes from the SHARED base
|
|
* (src/agent/system-prompt.md), the shared/ fragments (composed by both
|
|
* agents), plus every numeric-prefix *.md fragment in the agent's own
|
|
* directory — see the agent module for the full rules.
|
|
*
|
|
* OVERRIDES: override files mirror the source layout under
|
|
* config.PROMPTS_OVERRIDE_DIR — "system-prompt.md" at the root,
|
|
* "shared/<file>", "teacher/<file>" / "prober/<file>" (POSIX-relative
|
|
* paths, the same keys as the settings record's agent.prompts switches).
|
|
* File presence is NOT the override: the switch is (owner decision) — the
|
|
* user keeps their file and flips back to the source default to compare.
|
|
*
|
|
* Path discipline: only .md under the override dir, no traversal (no "..",
|
|
* absolute, or empty segments) — a hand-crafted path outside the mirror
|
|
* layout is rejected (null / no-op), never written.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { PROMPTS_OVERRIDE_DIR } from "../../config.ts";
|
|
import { isValidSegmentPath } from "../core/prompt-segments.ts";
|
|
import type { PromptOverrideStore } from "../core/ports.ts";
|
|
|
|
/** Absolute path for an override file (unchecked — validate first). */
|
|
function overrideFileFor(segmentPath: string): string {
|
|
return path.join(PROMPTS_OVERRIDE_DIR, ...segmentPath.split("/"));
|
|
}
|
|
|
|
/** The store adapter (core/ports.ts). */
|
|
export const promptOverrides: PromptOverrideStore = {
|
|
listOverrides(): string[] {
|
|
const out: string[] = [];
|
|
const walk = (dir: string, prefix: string) => {
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return; // no dir yet = no overrides
|
|
}
|
|
for (const entry of entries) {
|
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) walk(path.join(dir, entry.name), rel);
|
|
else if (entry.isFile() && entry.name.endsWith(".md") && isValidSegmentPath(rel)) out.push(rel);
|
|
}
|
|
};
|
|
walk(PROMPTS_OVERRIDE_DIR, "");
|
|
return out.sort();
|
|
},
|
|
|
|
readOverride(segmentPath: string): string | null {
|
|
if (!isValidSegmentPath(segmentPath)) return null;
|
|
try {
|
|
return fs.readFileSync(overrideFileFor(segmentPath), "utf-8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
writeOverride(segmentPath: string, text: string): void {
|
|
if (!isValidSegmentPath(segmentPath)) return;
|
|
const file = overrideFileFor(segmentPath);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, text, "utf-8");
|
|
},
|
|
|
|
deleteOverride(segmentPath: string): void {
|
|
if (!isValidSegmentPath(segmentPath)) return;
|
|
try {
|
|
fs.unlinkSync(overrideFileFor(segmentPath));
|
|
} catch {
|
|
// absent already = done
|
|
}
|
|
},
|
|
};
|