src/server/modules/service.ts
Rama main · réplica de lectura del repositorio original.
/** Data-plane module service — installs/reconciles declarative module folders into data/.
*
* A module folder: module.json + skill templates (*.md / *.md.tmpl) + seeds/<data-relative-path>.
* Applied source lives at data/modules/<name>/ (origin-agnostic); installed records in
* data/modules/state.json (atomic tmp+rename).
*
* Invariants:
* - Module schedules use createdBy.uid `module:<name>` — NEVER the system uid: scheduler/builtins.ts
* purges system-scope schedules with the system uid not in the builtins list on every boot.
* - Seeds are memory, not status: create-if-missing, never overwritten, survive uninstall.
* Only state.json answers "is the module on".
* - Reconcile is idempotent: write-if-changed skills, upserts preserve enabled/runCount/lastRun.
*/
import { foldLegacyRuntimePin } from '../scheduler/builtins.ts';
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, rmSync, copyFileSync, statSync, appendFileSync } from 'node:fs';
import path from 'node:path';
import { dataPath, DATA_DIR, PACKAGE_ROOT } from '../paths.ts';
import { dataSync } from '../data-sync.ts';
import * as scheduler from '../scheduler/index.ts';
import type { Schedule } from '../scheduler/types.ts';
import { parseSkillFrontmatter, getDefaultSkills, setDefaultSkills } from '../skills.ts';
import type { ModuleManifest, InstalledModule, ModulesState, ModuleScheduleDef } from './types.ts';
const MODULES_DIR = () => dataPath('modules');
const STATE_FILE = () => dataPath('modules/state.json');
const BUILTIN_MODULES_DIR = path.join(PACKAGE_ROOT, 'defaults', 'modules');
// ── State ───────────────────────────────────────────────────────────────────
export function loadState(): ModulesState {
const file = STATE_FILE();
if (!existsSync(file)) return { installed: [] };
try {
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
return parsed && Array.isArray(parsed.installed) ? parsed : { installed: [] };
} catch (err) {
console.error('[modules] failed to parse state.json:', err);
return { installed: [] };
}
}
function saveState(state: ModulesState): void {
mkdirSync(MODULES_DIR(), { recursive: true });
const tmp = `${STATE_FILE()}.tmp`;
writeFileSync(tmp, JSON.stringify(state, null, 2));
renameSync(tmp, STATE_FILE());
dataSync.trackWrite('modules/state.json');
}
export function getInstalled(name: string): InstalledModule | undefined {
return loadState().installed.find((m) => m.name === name);
}
// ── Manifest ────────────────────────────────────────────────────────────────
export function readManifest(folder: string): ModuleManifest {
const file = path.join(folder, 'module.json');
if (!existsSync(file)) throw new Error(`No module.json in ${folder}`);
const m = JSON.parse(readFileSync(file, 'utf-8')) as ModuleManifest;
if (!m.name || !/^[\w-]+$/.test(m.name)) throw new Error('Manifest: invalid or missing "name"');
if (!m.version) throw new Error('Manifest: missing "version"');
for (const def of m.schedules ?? []) {
if (!def.def || !/^[\w-]+$/.test(def.def)) throw new Error(`Manifest: schedule def key invalid: "${def.def}"`);
if (!def.name || !def.trigger || !def.task) throw new Error(`Manifest: schedule "${def.def}" needs name/trigger/task`);
}
for (const s of m.skills ?? []) {
if (!/\.md(\.tmpl)?$/.test(s)) throw new Error(`Manifest: skill file must be .md or .md.tmpl: "${s}"`);
}
for (const seed of m.seeds ?? []) {
if (path.isAbsolute(seed) || seed.split(/[\\/]/).includes('..')) throw new Error(`Manifest: seed path must be data-relative: "${seed}"`);
}
return m;
}
function moduleDir(name: string): string {
return dataPath('modules', name);
}
/** Adoption backups are USER data (their original artifacts) — kept OUTSIDE the module
* folder so reinstall/upgrade (copyDir rm+recreate) and uninstall (rmSync) never touch them. */
function backupDir(name: string): string {
return dataPath('modules', '.backups', name);
}
function installedManifest(name: string): ModuleManifest {
return readManifest(moduleDir(name));
}
/** README.md content of a module folder (installed or builtin), capped at 32KB. */
export function readModuleReadme(name: string, from: 'installed' | 'builtin'): string | undefined {
if (!/^[\w-]+$/.test(name)) return undefined; // defense-in-depth: never path-join a raw name
const file = path.join(from === 'builtin' ? path.join(BUILTIN_MODULES_DIR, name) : moduleDir(name), 'README.md');
if (!existsSync(file)) return undefined;
try { return readFileSync(file, 'utf-8').slice(0, 32 * 1024); }
catch (err) { console.warn(`[modules] readme read failed for ${name}:`, (err as Error).message); return undefined; }
}
/** Built-ins shipped in defaults/modules/<name>/ — "available" = readdir. */
export function listAvailableModules(): ModuleManifest[] {
if (!existsSync(BUILTIN_MODULES_DIR)) return [];
const out: ModuleManifest[] = [];
for (const entry of readdirSync(BUILTIN_MODULES_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
try { out.push(readManifest(path.join(BUILTIN_MODULES_DIR, entry.name))); }
catch (err) { console.warn(`[modules] skipping builtin ${entry.name}:`, (err as Error).message); }
}
return out;
}
// ── Templating ──────────────────────────────────────────────────────────────
/** `{{key}}` → config[key]. Unknown keys warn and are left literal. Strings only. */
export function renderTemplate(tpl: string, config: Record<string, unknown>, ctx = ''): string {
return tpl.replace(/\{\{(\w+)\}\}/g, (whole, key: string) => {
if (key in config) return String(config[key]);
console.warn(`[modules] unknown template key {{${key}}}${ctx ? ` in ${ctx}` : ''}`);
return whole;
});
}
/** Deep-walk any JSON value, rendering string leaves. */
export function renderDeep<T>(value: T, config: Record<string, unknown>, ctx = ''): T {
if (typeof value === 'string') return renderTemplate(value, config, ctx) as T;
if (Array.isArray(value)) return value.map((v) => renderDeep(v, config, ctx)) as T;
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = renderDeep(v, config, ctx);
return out as T;
}
return value;
}
export function effectiveConfig(manifest: ModuleManifest, stored: Record<string, unknown> = {}): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {};
for (const [key, field] of Object.entries(manifest.configSchema ?? {})) {
if (field.default !== undefined) out[key] = field.default;
}
for (const [key, val] of Object.entries(stored)) {
if (val !== undefined && val !== null) out[key] = val as string | number | boolean;
}
return out;
}
// ── Skills ──────────────────────────────────────────────────────────────────
function skillNameFor(file: string): string {
return path.basename(file).replace(/\.tmpl$/, '').replace(/\.md$/, '');
}
/** Render a skill template and stamp `managed-by: <module>@<ver>` into its frontmatter. */
function renderSkill(mod: ModuleManifest, file: string, config: Record<string, unknown>): { name: string; content: string } {
const raw = readFileSync(path.join(moduleDir(mod.name), file), 'utf-8');
const rendered = renderTemplate(raw, config, `${mod.name}/${file}`);
const marker = `managed-by: ${mod.name}@${mod.version}`;
let content: string;
if (rendered.startsWith('---')) {
content = rendered.replace('---', `---\n${marker}`);
} else {
content = `---\n${marker}\n---\n\n${rendered}`;
}
return { name: skillNameFor(file), content };
}
function skillPath(name: string): string {
return dataPath('skills', `${name}.md`);
}
/** True when the on-disk skill of this name is owned by `moduleName` (or absent). */
function skillOwnedBy(name: string, moduleName: string): boolean {
const file = skillPath(name);
if (!existsSync(file)) return true;
const { meta } = parseSkillFrontmatter(readFileSync(file, 'utf-8'));
return (meta.managedBy ?? '').split('@')[0] === moduleName;
}
// ── Schedules ───────────────────────────────────────────────────────────────
function scheduleIdFor(rec: InstalledModule, def: ModuleScheduleDef): string {
return rec.adoptedScheduleIds?.[def.def] ?? `mod-${rec.name}-${def.def}`;
}
function managedSchedules(name: string): Schedule[] {
return scheduler.listSchedules().filter((s) => s.managedBy === name);
}
function globToRegex(glob: string): RegExp {
return new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
}
/** Unmanaged schedules the module's doctrine caused the agent to create (name glob match). */
function offspringSchedules(manifest: ModuleManifest): Schedule[] {
const glob = manifest.offspring?.schedules;
if (!glob) return [];
const re = globToRegex(glob);
return scheduler.listSchedules().filter((s) => !s.managedBy && re.test(s.name));
}
// ── Seeds & dormancy ────────────────────────────────────────────────────────
function dormancyLine(moduleName: string): string {
return `> [${moduleName} disabled ${new Date().toISOString().slice(0, 10)} — retained as state; no proactive cadence active]`;
}
const dormancyPrefix = (moduleName: string) => `> [${moduleName} disabled `;
/** Stamp a dormancy header on declared seeds — idempotent (never double-stamps). */
function stampSeeds(manifest: ModuleManifest): void {
for (const seed of manifest.seeds ?? []) {
const file = dataPath(seed);
if (!existsSync(file)) continue;
const content = readFileSync(file, 'utf-8');
if (content.startsWith(dormancyPrefix(manifest.name))) continue;
writeFileSync(file, `${dormancyLine(manifest.name)}\n\n${content}`);
dataSync.trackWrite(seed);
}
}
function unstampSeeds(manifest: ModuleManifest): void {
for (const seed of manifest.seeds ?? []) {
const file = dataPath(seed);
if (!existsSync(file)) continue;
const content = readFileSync(file, 'utf-8');
if (!content.startsWith(dormancyPrefix(manifest.name))) continue;
const nl = content.indexOf('\n');
writeFileSync(file, content.slice(nl + 1).replace(/^\n+/, ''));
dataSync.trackWrite(seed);
}
}
/** Seeds are state: create-if-missing (rendered once), never overwritten. */
function applySeeds(manifest: ModuleManifest, config: Record<string, unknown>): void {
for (const seed of manifest.seeds ?? []) {
const dest = dataPath(seed);
if (existsSync(dest)) continue;
const src = path.join(moduleDir(manifest.name), 'seeds', seed);
if (!existsSync(src)) { console.warn(`[modules] ${manifest.name}: seed source missing: seeds/${seed}`); continue; }
mkdirSync(path.dirname(dest), { recursive: true });
writeFileSync(dest, renderTemplate(readFileSync(src, 'utf-8'), config, `${manifest.name}/seeds/${seed}`));
dataSync.trackWrite(seed);
}
}
// ── Journal ─────────────────────────────────────────────────────────────────
/** One lifecycle line into the workspace journal so the agent's self-knowledge matches reality. */
function journal(line: string): void {
try {
const file = dataPath('workspace', 'journal.md');
mkdirSync(path.dirname(file), { recursive: true });
appendFileSync(file, `- ${new Date().toISOString()} ${line}\n`);
dataSync.trackWrite('workspace/journal.md');
} catch (err) {
console.warn('[modules] journal append failed:', (err as Error).message);
}
}
// ── skills-defaults ─────────────────────────────────────────────────────────
/** Add on install/enable only — reconcile never re-adds (a user's removal sticks). */
function addDefaultSkills(manifest: ModuleManifest): void {
const names = manifest.defaultSkills ?? [];
if (!names.length) return;
const existing = getDefaultSkills();
const have = new Set(existing.map((e) => (typeof e === 'string' ? e : e.name)));
const added = names.filter((n) => !have.has(n));
if (added.length) setDefaultSkills([...existing, ...added]);
}
function removeDefaultSkills(manifest: ModuleManifest): void {
const names = new Set(manifest.defaultSkills ?? []);
if (!names.size) return;
const existing = getDefaultSkills();
const kept = existing.filter((e) => !names.has(typeof e === 'string' ? e : e.name));
if (kept.length !== existing.length) setDefaultSkills(kept);
}
// ── Reconcile ───────────────────────────────────────────────────────────────
/** Apply one enabled module's folder into data/ — idempotent. */
function applyModule(rec: InstalledModule): void {
const manifest = installedManifest(rec.name);
const config = effectiveConfig(manifest, rec.config);
// Skills: render → write-if-changed
for (const file of manifest.skills ?? []) {
const { name, content } = renderSkill(manifest, file, config);
if (!skillOwnedBy(name, rec.name)) {
console.warn(`[modules] ${rec.name}: skill "${name}" is owned elsewhere — not overwriting`);
continue;
}
const dest = skillPath(name);
if (existsSync(dest) && readFileSync(dest, 'utf-8') === content) continue;
mkdirSync(path.dirname(dest), { recursive: true });
writeFileSync(dest, content);
dataSync.trackWrite(`skills/${name}.md`);
}
// Schedules: upsert preserving runtime state; module uid — NOT the system uid (builtins purge trap)
const expectedIds = new Set<string>();
for (const def of manifest.schedules ?? []) {
const id = scheduleIdFor(rec, def);
expectedIds.add(id);
const rendered = renderDeep({ name: def.name, trigger: def.trigger, task: def.task }, config, `${rec.name}/schedules/${def.def}`);
// A templated runtime knob rendered to "" must not ship a bogus empty pin — omit the field.
for (const k of ['model', 'engine'] as const) {
const t = rendered.task as Record<string, unknown>;
if (k in t && !t[k]) delete t[k];
}
// A runtime knob is now expressed as the prompt's own `[engine:…,model:…]` directive, not as a
// shadow field — fold whatever the manifest templated into it.
foldLegacyRuntimePin(rendered.task as Record<string, any>);
const existing = scheduler.getSchedule(id);
const now = Date.now();
const schedule: Schedule = {
id,
name: rendered.name,
enabled: existing ? existing.enabled : (def.enabled ?? true),
trigger: rendered.trigger,
task: rendered.task,
scope: 'system',
createdBy: existing?.createdBy ?? { uid: `module:${rec.name}`, email: 'module@shraga.local' },
createdAt: existing?.createdAt ?? now,
updatedAt: now,
lastRun: existing?.lastRun,
runCount: existing?.runCount ?? 0,
managedBy: rec.name,
};
const result = scheduler.upsertSchedule(schedule);
if (!result.ok) console.error(`[modules] ${rec.name}: schedule "${def.def}" rejected: ${result.error}`);
}
// Remove managed schedules whose def vanished
for (const s of managedSchedules(rec.name)) {
if (!expectedIds.has(s.id)) scheduler.deleteSchedule(s.id);
}
applySeeds(manifest, config);
}
/** Boot-time reconcile of all installed modules. Builtin version bump → auto-reapply folder. */
export function reconcileInstalledModules(): void {
const state = loadState();
let dirty = false;
for (const rec of state.installed) {
try {
if (rec.source === 'builtin') {
const builtinDir = path.join(BUILTIN_MODULES_DIR, rec.name);
if (existsSync(builtinDir)) {
const shipped = readManifest(builtinDir);
if (shipped.version !== rec.version) {
copyDir(builtinDir, moduleDir(rec.name));
rec.version = shipped.version;
dirty = true;
console.log(`[modules] ${rec.name}: builtin updated → ${shipped.version}`);
}
}
}
if (rec.enabled) applyModule(rec);
} catch (err) {
console.error(`[modules] reconcile failed for ${rec.name}:`, (err as Error).message);
}
}
if (dirty) saveState(state);
}
// ── Install / enable / disable / uninstall ──────────────────────────────────
function copyDir(src: string, dest: string): void {
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
if (entry.isDirectory()) copyDir(s, d);
else copyFileSync(s, d);
}
}
/** Install from a builtin name or an explicit folder path. Idempotent re-install = upgrade
* (keeps config/enabled/adoptions). Adopts unmanaged same-name artifacts, with backup. */
export function installModule(opts: { name?: string; path?: string }): InstalledModule {
let srcDir: string;
let source: string;
if (opts.path) {
// Relative paths are data-root-relative (skills document e.g. "workspace/modules-dev/<name>").
srcDir = path.isAbsolute(opts.path) ? opts.path : path.resolve(DATA_DIR, opts.path);
source = srcDir;
} else if (opts.name) {
srcDir = path.join(BUILTIN_MODULES_DIR, opts.name);
source = 'builtin';
} else {
throw new Error('install requires "name" (builtin) or "path"');
}
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) throw new Error(`Module folder not found: ${srcDir}`);
const manifest = readManifest(srcDir);
if (opts.name && manifest.name !== opts.name) throw new Error(`Manifest name "${manifest.name}" ≠ requested "${opts.name}"`);
const destDir = moduleDir(manifest.name);
if (path.resolve(srcDir) !== path.resolve(destDir)) copyDir(srcDir, destDir);
for (const rel of walkFiles(destDir)) dataSync.trackWrite(`modules/${manifest.name}/${rel}`);
const state = loadState();
let rec = state.installed.find((m) => m.name === manifest.name);
const fresh = !rec;
if (!rec) {
rec = {
name: manifest.name,
version: manifest.version,
enabled: true,
config: effectiveConfig(manifest),
installedAt: Date.now(),
source,
};
state.installed.push(rec);
} else {
rec.version = manifest.version;
rec.source = source;
rec.config = { ...effectiveConfig(manifest), ...rec.config };
}
if (fresh) adoptExisting(rec, manifest);
saveState(state);
if (rec.enabled) {
applyModule(rec);
addDefaultSkills(manifest);
}
journal(`module "${manifest.name}"@${manifest.version} ${fresh ? 'installed' : 'reinstalled'}${rec.enabled ? '' : ' (disabled)'}`);
return rec;
}
/** Adoption (install-time, fresh installs only): unmanaged exact-name matches become managed.
* Skills: back up original to data/modules/.backups/<name>/*.orig before first overwrite
* (outside the module folder — survives reinstall/upgrade/uninstall).
* Schedules: stamp managedBy, keep the existing id, preserve enabled/runCount/lastRun. */
function adoptExisting(rec: InstalledModule, manifest: ModuleManifest): void {
for (const file of manifest.skills ?? []) {
const name = skillNameFor(file);
const existing = skillPath(name);
if (!existsSync(existing)) continue;
const { meta } = parseSkillFrontmatter(readFileSync(existing, 'utf-8'));
if (meta.managedBy) continue;
const bakDir = backupDir(rec.name);
mkdirSync(bakDir, { recursive: true });
copyFileSync(existing, path.join(bakDir, `${name}.md.orig`));
dataSync.trackWrite(`modules/.backups/${rec.name}/${name}.md.orig`);
// Adoption = install-time consent to take the name over: with the backup safe,
// remove the unmanaged original so applyModule's ownership guard lets the rendered
// skill in (the guard otherwise protects unmanaged user skills from module clobber).
unlinkSync(existing);
dataSync.trackWrite(`skills/${name}.md`);
console.log(`[modules] ${rec.name}: adopted existing skill "${name}" (backup in modules/.backups/${rec.name}/)`);
}
for (const def of manifest.schedules ?? []) {
const renderedName = renderTemplate(def.name, effectiveConfig(manifest, rec.config));
const match = scheduler.listSchedules().find((s) => !s.managedBy && s.name === renderedName);
if (!match) continue;
(rec.adoptedScheduleIds ??= {})[def.def] = match.id;
console.log(`[modules] ${rec.name}: adopted existing schedule "${renderedName}" (id ${match.id})`);
}
}
function* walkFiles(dir: string, prefix = ''): Generator<string> {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) yield* walkFiles(path.join(dir, entry.name), rel);
else yield rel;
}
}
export function enableModule(name: string): InstalledModule {
const state = loadState();
const rec = state.installed.find((m) => m.name === name);
if (!rec) throw new Error(`Module "${name}" not installed`);
const manifest = installedManifest(name);
rec.enabled = true;
applyModule(rec);
// Restore per-schedule enabled states captured at disable
const snapshot = rec.scheduleEnabledSnapshot;
if (snapshot) {
for (const [id, enabled] of Object.entries(snapshot)) scheduler.toggleSchedule(id, enabled);
delete rec.scheduleEnabledSnapshot;
}
unstampSeeds(manifest);
addDefaultSkills(manifest);
saveState(state);
journal(`module "${name}" enabled — schedules restored, skills active`);
return rec;
}
export function disableModule(name: string): InstalledModule {
const state = loadState();
const rec = state.installed.find((m) => m.name === name);
if (!rec) throw new Error(`Module "${name}" not installed`);
const manifest = installedManifest(name);
// Snapshot + disable managed schedules
const snapshot: Record<string, boolean> = {};
for (const s of managedSchedules(name)) {
snapshot[s.id] = s.enabled;
scheduler.toggleSchedule(s.id, false);
}
// Offspring: agent-booked continuations — disable (never delete) so they can't fire into a missing skill
for (const s of offspringSchedules(manifest)) {
snapshot[s.id] = s.enabled;
scheduler.toggleSchedule(s.id, false);
}
// Remove rendered skill files (only ones we own) + drop module entries from skills-defaults
for (const file of manifest.skills ?? []) {
const skillName = skillNameFor(file);
const dest = skillPath(skillName);
if (existsSync(dest) && skillOwnedBy(skillName, name)) {
unlinkSync(dest);
dataSync.trackWrite(`skills/${skillName}.md`);
}
}
removeDefaultSkills(manifest);
stampSeeds(manifest);
rec.enabled = false;
rec.scheduleEnabledSnapshot = snapshot;
saveState(state);
journal(`module "${name}" disabled — schedules paused, skills removed, seeds retained as dormant state`);
return rec;
}
export function setModuleConfig(name: string, config: Record<string, unknown>): InstalledModule {
const state = loadState();
const rec = state.installed.find((m) => m.name === name);
if (!rec) throw new Error(`Module "${name}" not installed`);
const manifest = installedManifest(name);
const schema = manifest.configSchema ?? {};
for (const [key, val] of Object.entries(config)) {
const field = schema[key];
if (!field) throw new Error(`Unknown config key "${key}"`);
if (typeof val !== field.type) throw new Error(`Config "${key}" must be ${field.type}`);
}
rec.config = { ...rec.config, ...(config as Record<string, string | number | boolean>) };
saveState(state);
if (rec.enabled) applyModule(rec);
return rec;
}
/** Uninstall: rendered skills + module-created schedules + folder + state entry go; SEEDS STAY
* (stamped dormant). Adopted schedules are released (managedBy cleared, disabled), never deleted.
* Offspring schedules are disabled, never deleted. */
export function uninstallModule(name: string): void {
const state = loadState();
const idx = state.installed.findIndex((m) => m.name === name);
if (idx < 0) throw new Error(`Module "${name}" not installed`);
const rec = state.installed[idx];
let manifest: ModuleManifest | null = null;
try { manifest = installedManifest(name); } catch { /* folder gone — best-effort cleanup below */ }
if (manifest) {
for (const file of manifest.skills ?? []) {
const skillName = skillNameFor(file);
const dest = skillPath(skillName);
if (existsSync(dest) && skillOwnedBy(skillName, name)) {
unlinkSync(dest);
dataSync.trackWrite(`skills/${skillName}.md`);
}
}
removeDefaultSkills(manifest);
for (const s of offspringSchedules(manifest)) scheduler.toggleSchedule(s.id, false);
stampSeeds(manifest);
}
const adoptedIds = new Set(Object.values(rec.adoptedScheduleIds ?? {}));
for (const s of managedSchedules(name)) {
if (adoptedIds.has(s.id)) {
delete s.managedBy;
scheduler.toggleSchedule(s.id, false); // persists the un-stamp too
} else {
scheduler.deleteSchedule(s.id);
}
}
rmSync(moduleDir(name), { recursive: true, force: true });
dataSync.trackWrite(`modules/${name}`);
state.installed.splice(idx, 1);
saveState(state);
journal(`module "${name}" uninstalled — skills/schedules removed, seeds retained as dormant state`);
}