Skill Tracer
Skills › HyperFrames › media-use › prefs-store.mjs

prefs-store.mjs

198 lines · skills/media-use/scripts/lib/prefs-store.mjs at a595755

1import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2import * as fs from "node:fs";
3import { basename, dirname, join, resolve } from "node:path";
4import { withFileLock } from "./config-lock.mjs";
5import { globalMediaDir } from "./media-home.mjs";
6
7/**
8 * Remembered defaults — the lightweight tier of HyperFrames user memory.
9 *
10 * Two files, same shape as the rest of media-use's storage split:
11 * - project `.media/preferences.json` — committed with the repo, so the whole
12 * team inherits it; written every time a brief answer is confirmed.
13 * - user `~/.media/preferences.json` — personal, cross-repo. A key is promoted
14 * here only once the same value has been confirmed in two different projects
15 * (`PROMOTE_AT`), so a one-off choice never pollutes the global defaults.
16 * Pre-promotion evidence accumulates in the user file's `sightings` ledger —
17 * project files can't see each other, so the cross-project count has to live
18 * user-side.
19 *
20 * Consumption contract (media-use references/memory.md): a remembered
21 * value becomes the recommended option with a receipt naming its source — it
22 * never skips a question, and explicit request content always wins.
23 */
25const PREFS_FILE = "preferences.json";
27/** Keys the brief contract records; `style_preset` is stored per workflow. */
28export const PREFERENCE_KEYS = [
29 "destination",
30 "aspect",
31 "language",
32 "flow",
33 "storyboard",
34 "voice",
35 "style_preset",
36];
38/** A value must be confirmed in this many distinct projects to go user-tier. */
39export const PROMOTE_AT = 2;
41export function projectPrefsPath(projectDir) {
42 return join(resolve(projectDir), ".media", PREFS_FILE);
45export function userPrefsPath() {
46 return join(globalMediaDir(), PREFS_FILE);
49function emptyFile() {
50 return { version: 1, preferences: {}, sightings: {} };
53function isRecord(value) {
54 return typeof value === "object" && value !== null && !Array.isArray(value);
57/** Tolerant read — a missing or malformed file counts as empty. */
58function readPrefsFile(path) {
59 try {
60 if (!existsSync(path)) return emptyFile();
61 const parsed = JSON.parse(readFileSync(path, "utf8"));
62 if (!isRecord(parsed)) return emptyFile();
63 return {
64 version: 1,
65 preferences: isRecord(parsed.preferences) ? parsed.preferences : {},
66 sightings: isRecord(parsed.sightings) ? parsed.sightings : {},
67 };
68 } catch {
69 return emptyFile();
70 }
73/** Atomic write (tmp + rename) so a crash never leaves a torn file. */
74function writePrefsFile(path, file) {
75 const tmp = `${path}.${process.pid}.tmp`;
76 try {
77 writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`);
78 renameSync(tmp, path);
79 } finally {
80 try {
81 fs.rmSync(tmp, { force: true });
82 } catch {}
83 }
86function updatePrefsFile(path, update) {
87 mkdirSync(dirname(path), { recursive: true });
88 return withFileLock(`${path}.lock`, fs, () => {
89 const file = readPrefsFile(path);
90 const result = update(file);
91 writePrefsFile(path, file);
92 return result;
93 });
96/** `style_preset` entries are stored per workflow as `style_preset.<workflow>`. */
97export function preferenceKeyFor(key, workflow) {
98 return key === "style_preset" && workflow ? `style_preset.${workflow}` : key;
101function validEntry(entry) {
102 return isRecord(entry) && typeof entry.value === "string" && entry.value.length > 0;
105/**
106 * The merged view the brief reads: user-tier promoted entries first, project
107 * entries on top (project wins). Each entry carries `source` plus the receipt
108 * material (`confirmed_in`, `updated_at`).
109 */
110export function mergedPreferences(projectDir) {
111 const user = readPrefsFile(userPrefsPath());
112 const project = readPrefsFile(projectPrefsPath(projectDir));
113 const merged = {};
114 for (const [key, entry] of Object.entries(user.preferences)) {
115 if (validEntry(entry)) merged[key] = { ...entry, source: "user" };
116 }
117 for (const [key, entry] of Object.entries(project.preferences)) {
118 if (validEntry(entry)) merged[key] = { ...entry, source: "project" };
119 }
120 return merged;
123function dedupe(list) {
124 return [...new Set(list)];
127/**
128 * Project tier: same value accumulates confirmations; a changed value starts
129 * provenance over (the old confirmations vouched for the old value).
130 */
131function recordProjectTier(projectDir, fullKey, value, projectName, now) {
132 return updatePrefsFile(projectPrefsPath(projectDir), (file) => {
133 const previous = file.preferences[fullKey];
134 const keepProvenance = validEntry(previous) && previous.value === value;
135 const confirmedIn = keepProvenance
136 ? dedupe([
137 ...(Array.isArray(previous.confirmed_in) ? previous.confirmed_in : []),
138 projectName,
139 ])
140 : [projectName];
141 file.preferences[fullKey] = { value, confirmed_in: confirmedIn, updated_at: now };
142 return confirmedIn;
143 });
146/**
147 * User tier: accumulate this sighting in the ledger, and promote the key once
148 * the same value has been confirmed in PROMOTE_AT distinct projects.
149 */
150function recordUserSighting(fullKey, value, projectName, now) {
151 return updatePrefsFile(userPrefsPath(), (file) => {
152 const keySightings = isRecord(file.sightings[fullKey]) ? file.sightings[fullKey] : {};
153 const seenIn = dedupe([
154 ...(Array.isArray(keySightings[value]) ? keySightings[value] : []),
155 projectName,
156 ]);
157 keySightings[value] = seenIn;
158 file.sightings[fullKey] = keySightings;
159 const promoted = seenIn.length >= PROMOTE_AT;
160 if (promoted) {
161 file.preferences[fullKey] = { value, confirmed_in: seenIn, updated_at: now };
162 }
163 return promoted;
164 });
167/**
168 * Record one confirmed brief answer. Always writes the project tier; feeds the
169 * user tier's sightings ledger and promotes once the same value has been
170 * confirmed in PROMOTE_AT distinct projects. Idempotent per project.
171 */
172export function recordPreference({ projectDir, key, value, workflow }) {
173 if (!PREFERENCE_KEYS.includes(key)) {
174 throw new Error(`unknown preference key: "${key}" (known: ${PREFERENCE_KEYS.join(", ")})`);
175 }
176 if (typeof value !== "string" || !value.trim()) {
177 throw new Error("a preference needs a non-empty string value");
178 }
179 if (key === "style_preset" && (!workflow || !String(workflow).trim())) {
180 throw new Error("style_preset is stored per workflow — pass --workflow <w>");
181 }
182 const fullKey = preferenceKeyFor(key, workflow);
183 const projectName = basename(resolve(projectDir));
184 const trimmed = value.trim();
185 const now = new Date().toISOString();
187 const confirmedIn = recordProjectTier(projectDir, fullKey, trimmed, projectName, now);
189 // Best-effort — a read-only home directory must never fail a brief.
190 let promoted = false;
191 try {
192 promoted = recordUserSighting(fullKey, trimmed, projectName, now);
193 } catch {
194 // The project record already landed; promotion just waits for next time.
195 }
197 return { key: fullKey, value: trimmed, confirmed_in: confirmedIn, promoted };