lib/mates.js
Rama main · réplica de lectura del repositorio original.
var resolveMateIdentityFile = require('./mate-identity-file').resolveMateIdentityFile;
var fs = require("fs");
var path = require("path");
var crypto = require("crypto");
var config = require("./config");
var workspaceColor = require("./mate-workspace-color");
var avatarCatalog = require("./mate-avatar-catalog");
var avatarMigration = require("./mate-avatar-migration");
var crisisSafety = require("./crisis-safety");
var matesPrompts = require("./mates-prompts");
var matesIdentity = require("./mates-identity");
var { attachKnowledge } = require("./mates-knowledge");
// --- Path resolution ---
function resolveMatesRoot(ctx) {
// OS-users mode: per-linuxUser home directory
if (ctx && ctx.linuxUser) {
return path.join("/home", ctx.linuxUser, ".clay", "mates");
}
// Multi-user mode: per-userId subdirectory
if (ctx && ctx.multiUser && ctx.userId) {
return path.join(config.CONFIG_DIR, "mates", ctx.userId);
}
// Single-user mode: flat directory
return path.join(config.CONFIG_DIR, "mates");
}
// --- Wiring: knowledge module needs resolveMatesRoot ---
var knowledge = attachKnowledge(resolveMatesRoot);
var loadCommonKnowledge = knowledge.loadCommonKnowledge;
var saveCommonKnowledge = knowledge.saveCommonKnowledge;
var promoteKnowledge = knowledge.promoteKnowledge;
var depromoteKnowledge = knowledge.depromoteKnowledge;
var getCommonKnowledgeForMate = knowledge.getCommonKnowledgeForMate;
var readCommonKnowledgeFile = knowledge.readCommonKnowledgeFile;
var isPromoted = knowledge.isPromoted;
// --- Aliases from extracted modules ---
var TEAM_MARKER = matesPrompts.TEAM_MARKER;
var TEAM_SECTION = matesPrompts.TEAM_SECTION;
var SESSION_MEMORY_MARKER = matesPrompts.SESSION_MEMORY_MARKER;
var SESSION_MEMORY_SECTION = matesPrompts.SESSION_MEMORY_SECTION;
var STICKY_NOTES_MARKER = matesPrompts.STICKY_NOTES_MARKER;
var STICKY_NOTES_SECTION = matesPrompts.STICKY_NOTES_SECTION;
var PROJECT_REGISTRY_MARKER = matesPrompts.PROJECT_REGISTRY_MARKER;
var DEBATE_AWARENESS_MARKER = matesPrompts.DEBATE_AWARENESS_MARKER;
var DEBATE_AWARENESS_SECTION = matesPrompts.DEBATE_AWARENESS_SECTION;
var ALL_SYSTEM_MARKERS = matesPrompts.ALL_SYSTEM_MARKERS;
var buildTeamSection = matesPrompts.buildTeamSection;
var enforceTeamAwareness = matesPrompts.enforceTeamAwareness;
var enforceProjectRegistry = matesPrompts.enforceProjectRegistry;
var buildProjectRegistrySection = matesPrompts.buildProjectRegistrySection;
var enforceSessionMemory = matesPrompts.enforceSessionMemory;
var enforceStickyNotes = matesPrompts.enforceStickyNotes;
var enforceDebateAwareness = matesPrompts.enforceDebateAwareness;
var PRIMARY_CAPABILITIES_MARKER = matesIdentity.PRIMARY_CAPABILITIES_MARKER;
var IDENTITY_MIN_LENGTH = matesIdentity.IDENTITY_MIN_LENGTH;
var buildPrimaryCapabilitiesSection = matesIdentity.buildPrimaryCapabilitiesSection;
var backupIdentity = matesIdentity.backupIdentity;
var loadIdentityBackup = matesIdentity.loadIdentityBackup;
var logIdentityChange = matesIdentity.logIdentityChange;
var extractIdentity = function (content) { return matesIdentity.extractIdentity(content, ALL_SYSTEM_MARKERS); };
function buildMateCtx(userId) {
if (!userId) return { userId: null, multiUser: false, linuxUser: null };
// Lazy require to avoid circular dependency
var users = require("./users");
var multiUser = users.isMultiUser();
var linuxUser = null;
if (multiUser && userId) {
var user = users.findUserById(userId);
if (user && user.linuxUser) {
linuxUser = user.linuxUser;
}
}
return { userId: userId, multiUser: multiUser, linuxUser: linuxUser };
}
function isMateIdFormat(id) {
if (!id) return false;
return typeof id === "string" && id.indexOf("mate_") === 0;
}
// --- Default data ---
function defaultData() {
return { mates: [] };
}
// --- Load / Save ---
function matesFilePath(ctx) {
return path.join(resolveMatesRoot(ctx), "mates.json");
}
function loadMates(ctx) {
try {
var raw = fs.readFileSync(matesFilePath(ctx), "utf8");
var data = JSON.parse(raw);
if (!data.mates) data.mates = [];
} catch (e) {
return defaultData();
}
// Migration write failures must propagate, never masquerade as an empty registry.
if (avatarMigration.migrate(data)) saveMates(ctx, data);
return data;
}
function saveMates(ctx, data) {
var filePath = matesFilePath(ctx);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
var tmpPath = filePath + ".tmp";
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
fs.renameSync(tmpPath, filePath);
}
// --- CRUD ---
function generateMateId() {
return "mate_" + crypto.randomUUID();
}
function createMate(ctx, seedData) {
var data = loadMates(ctx);
var id = generateMateId();
var userId = ctx ? ctx.userId : null;
var mate = {
id: id,
name: null,
createdBy: userId,
createdAt: Date.now(),
seedData: seedData || {},
vendor: (seedData && seedData.vendor) || "claude",
model: (seedData && seedData.model) || null,
profile: {
displayName: null,
avatarColor: "#5857fc",
avatarStyle: "imprint",
avatarCatalogId: "B" + String(crypto.randomInt(1, 49)).padStart(2, "0"),
avatarSeed: crypto.randomBytes(4).toString("hex"),
},
bio: null,
status: "interviewing",
interviewProjectPath: null,
};
avatarMigration.normalizeMate(mate);
data.mates.push(mate);
saveMates(ctx, data);
// Create the mate's identity directory
var mateDir = getMateDir(ctx, id);
fs.mkdirSync(mateDir, { recursive: true });
// Write initial mate.yaml
var yaml = "# Mate metadata\n";
yaml += "id: " + id + "\n";
yaml += "name: null\n";
yaml += "status: interviewing\n";
yaml += "createdBy: " + userId + "\n";
yaml += "createdAt: " + mate.createdAt + "\n";
yaml += "relationship: " + (seedData.relationship || "assistant") + "\n";
yaml += "activities: " + JSON.stringify(seedData.activity || []) + "\n";
yaml += "vendor: " + (seedData.vendor || "claude") + "\n";
yaml += "model: " + JSON.stringify(seedData.model || null) + "\n";
fs.writeFileSync(path.join(mateDir, "mate.yaml"), yaml);
// Write initial AGENTS.md (will be replaced by interview)
var claudeMd = "# Mate Identity\n\n";
claudeMd += "This mate is currently being interviewed. Identity will be generated after the interview.\n\n";
claudeMd += "## Seed Data\n\n";
claudeMd += "- Relationship: " + (seedData.relationship || "assistant") + "\n";
if (seedData.activity && seedData.activity.length > 0) {
claudeMd += "- Activities: " + seedData.activity.join(", ") + "\n";
}
if (seedData.communicationStyle && seedData.communicationStyle.length > 0) {
claudeMd += "- Communication: " + seedData.communicationStyle.join(", ") + "\n";
}
var initialIdentity = claudeMd.trimEnd();
claudeMd += TEAM_SECTION;
claudeMd += SESSION_MEMORY_SECTION;
claudeMd += crisisSafety.getSection();
fs.writeFileSync(resolveMateIdentityFile(mateDir), claudeMd);
// Log creation (identity is placeholder, will be replaced by interview)
logIdentityChange(mateDir, "create_custom", initialIdentity, "");
return mate;
}
function getMate(ctx, id) {
var data = loadMates(ctx);
for (var i = 0; i < data.mates.length; i++) {
if (data.mates[i].id === id) return data.mates[i];
}
return null;
}
function updateMate(ctx, id, updates) {
updates = Object.assign({}, updates);
delete updates.id;
delete updates.createdBy;
delete updates.builtinKey;
var data = loadMates(ctx);
for (var i = 0; i < data.mates.length; i++) {
if (data.mates[i].id === id) {
if (Object.prototype.hasOwnProperty.call(updates, "workspaceColor")) {
if (data.mates[i].primary || data.mates[i].builtinKey === "clay") delete updates.workspaceColor;
else if (!workspaceColor.valid(updates.workspaceColor)) return { error: "Choose a supported workspace color." };
}
var avatarError = avatarCatalog.validate(updates.profile, data.mates[i]);
if (avatarError) return { error: avatarError };
// Primary mates: protect name and core identity fields
if (data.mates[i].primary) {
delete updates.name;
delete updates.bio;
delete updates.primary;
delete updates.globalSearch;
delete updates.templateVersion;
if (updates.profile) {
delete updates.profile.displayName;
}
}
// Locked built-in artwork is server-managed. Preserve it even when a
// stale or crafted client submits a replacement profile object.
if (data.mates[i].profile && data.mates[i].profile.avatarLocked && updates.profile) {
updates.profile.avatarCatalogId = data.mates[i].profile.avatarCatalogId || "";
updates.profile.avatarStyle = data.mates[i].profile.avatarStyle;
updates.profile.avatarSeed = data.mates[i].profile.avatarSeed;
updates.profile.avatarColor = data.mates[i].profile.avatarColor;
updates.profile.avatarCustom = data.mates[i].profile.avatarCustom;
updates.profile.avatarLocked = true;
}
var keys = Object.keys(updates);
for (var j = 0; j < keys.length; j++) {
data.mates[i][keys[j]] = updates[keys[j]];
}
avatarMigration.normalizeMate(data.mates[i]);
saveMates(ctx, data);
return data.mates[i];
}
}
return null;
}
function deleteMate(ctx, id, options) {
var data = loadMates(ctx);
// Primary mates cannot be deleted (they are system infrastructure)
for (var di = 0; di < data.mates.length; di++) {
if (data.mates[di].id === id && data.mates[di].primary) {
return { error: "Primary mates cannot be deleted. They are managed by the system." };
}
}
var before = data.mates.length;
data.mates = data.mates.filter(function (m) {
return m.id !== id;
});
if (data.mates.length === before) return { error: "Mate not found" };
if (!options || !options.strictCleanup) saveMates(ctx, data);
// Remove mate directory
var mateDir = getMateDir(ctx, id);
try {
fs.rmSync(mateDir, { recursive: true, force: true });
} catch (e) {
if (options && options.strictCleanup) throw e;
}
if (options && options.strictCleanup) saveMates(ctx, data);
return { ok: true };
}
function getAllMates(ctx) {
var data = loadMates(ctx);
return data.mates;
}
function isMate(ctx, id) {
if (!id) return false;
if (typeof id === "string" && id.indexOf("mate_") === 0) {
// Double check it exists in registry
return !!getMate(ctx, id);
}
return false;
}
function getMateDir(ctx, id) {
return path.join(resolveMatesRoot(ctx), id);
}
// --- Migration ---
function migrateLegacyMates() {
var legacyFile = path.join(config.CONFIG_DIR, "mates.json");
if (!fs.existsSync(legacyFile)) return;
// Check if already migrated
var migratedMarker = legacyFile + ".migrated";
if (fs.existsSync(migratedMarker)) return;
try {
var raw = fs.readFileSync(legacyFile, "utf8");
var data = JSON.parse(raw);
if (!data.mates || data.mates.length === 0) {
// Nothing to migrate, just mark as done
fs.renameSync(legacyFile, migratedMarker);
return;
}
// Group mates by createdBy
var byUser = {};
for (var i = 0; i < data.mates.length; i++) {
var m = data.mates[i];
var key = m.createdBy || "__null__";
if (!byUser[key]) byUser[key] = [];
byUser[key].push(m);
}
// Write each user's mates to their own storage path
var keys = Object.keys(byUser);
for (var k = 0; k < keys.length; k++) {
var userId = keys[k] === "__null__" ? null : keys[k];
var ctx = buildMateCtx(userId);
var userData = { mates: byUser[keys[k]] };
saveMates(ctx, userData);
// Move mate identity directories to new location
var legacyMatesDir = path.join(config.CONFIG_DIR, "mates");
var newRoot = resolveMatesRoot(ctx);
for (var mi = 0; mi < byUser[keys[k]].length; mi++) {
var mateId = byUser[keys[k]][mi].id;
var oldDir = path.join(legacyMatesDir, mateId);
var newDir = path.join(newRoot, mateId);
if (fs.existsSync(oldDir) && oldDir !== newDir) {
fs.mkdirSync(path.dirname(newDir), { recursive: true });
try {
fs.renameSync(oldDir, newDir);
} catch (e) {
// Cross-device or other issue, copy instead
fs.cpSync(oldDir, newDir, { recursive: true });
fs.rmSync(oldDir, { recursive: true, force: true });
}
}
}
}
// Mark legacy file as migrated
fs.renameSync(legacyFile, migratedMarker);
console.log("[mates] Migrated legacy mates.json to per-user storage");
} catch (e) {
console.error("[mates] Legacy migration failed:", e.message);
}
}
/**
* Strip all system sections from AGENTS.md content, returning only identity.
*/
function stripAllSystemSections(content) {
return extractIdentity(content);
}
// Tracks paths we've already warned about missing identity (avoids log spam)
var _warnedPaths = {};
/**
* Atomic enforce: read AGENTS.md once, enforce all system sections, write once.
* Includes identity backup, validation, and change tracking.
* Returns true if the file was modified, false if already correct.
* @param {string} filePath - path to AGENTS.md
* @param {object} opts - optional { ctx, mateId } for dynamic team section
*/
function enforceAllSections(filePath, opts) {
if (!fs.existsSync(filePath)) return false;
opts = opts || {};
var content = fs.readFileSync(filePath, "utf8");
var mateDir = path.dirname(filePath);
// 1. Extract current identity (everything before system markers)
var identity = extractIdentity(content);
var identityMissing = !identity || identity.length < IDENTITY_MIN_LENGTH;
// 2. If identity is empty or suspiciously short, try to restore from backup
if (identityMissing) {
var backup = loadIdentityBackup(mateDir);
if (backup) {
console.log("[mates] Restoring identity from backup: " + filePath + " (" + backup.length + " chars)");
identity = backup;
logIdentityChange(mateDir, "restore_from_backup", identity, "");
identityMissing = false;
}
}
// 3. Backup identity if it's substantive
backupIdentity(mateDir, identity);
// 4. Rebuild the full file: identity + all system sections in order
// Use dynamic team section when ctx is available, static fallback otherwise
var teamSection = (opts.ctx && opts.mateId) ? buildTeamSection(opts.ctx, opts.mateId, loadMates) : TEAM_SECTION;
// Primary mate capabilities (dynamically injected from code)
var capSection = "";
if (opts.ctx && opts.mateId) {
try {
var mate = getMate(opts.ctx, opts.mateId);
capSection = buildPrimaryCapabilitiesSection(mate);
} catch (e) {}
}
// Project registry (dynamically injected when project list is available)
var projSection = "";
if (opts.projects) {
projSection = buildProjectRegistrySection(opts.projects);
}
var rebuilt = (identity || "").trimEnd();
rebuilt += teamSection;
rebuilt += projSection;
rebuilt += capSection;
rebuilt += SESSION_MEMORY_SECTION;
rebuilt += STICKY_NOTES_SECTION;
rebuilt += DEBATE_AWARENESS_SECTION;
rebuilt += crisisSafety.getSection();
// 5. Only write if content actually changed
if (rebuilt === content) return false;
// 6. Warn about missing identity only once per path, and only when actually writing
if (identityMissing && !_warnedPaths[filePath]) {
_warnedPaths[filePath] = true;
console.log("[mates] WARNING: Identity missing in " + filePath + " and no backup available");
}
// 7. Track identity changes (compare stripped versions)
var prevIdentity = stripAllSystemSections(content);
if (identity !== prevIdentity) {
logIdentityChange(mateDir, "enforce", identity, prevIdentity);
}
fs.writeFileSync(filePath, rebuilt, "utf8");
return true;
}
// Format seed data as a human-readable context string
function formatSeedContext(seedData) {
if (!seedData) return "";
var parts = [];
if (seedData.relationship) {
parts.push("The user wants a " + seedData.relationship + " relationship.");
}
if (seedData.activity && seedData.activity.length > 0) {
parts.push("Primary activities: " + seedData.activity.join(", ") + ".");
}
if (seedData.communicationStyle && seedData.communicationStyle.length > 0) {
var styleLabels = {
direct_concise: "direct and concise",
soft_detailed: "soft and detailed",
witty: "witty",
encouraging: "encouraging",
formal: "formal",
no_nonsense: "no-nonsense",
};
var styles = seedData.communicationStyle.map(function (s) { return styleLabels[s] || s.replace(/_/g, " "); });
parts.push("Communication style: " + styles.join(", ") + ".");
}
if (seedData.autonomy) {
var autonomyLabels = {
always_ask: "Always ask before acting",
minor_stuff_ok: "Handle minor stuff without asking",
mostly_autonomous: "Mostly autonomous, ask for big decisions",
fully_autonomous: "Fully autonomous",
};
parts.push("Autonomy: " + (autonomyLabels[seedData.autonomy] || seedData.autonomy) + ".");
}
return parts.join(" ");
}
// --- Built-in mates ---
function createBuiltinMate(ctx, builtinKey) {
var builtinMates = require("./builtin-mates");
var def = builtinMates.getBuiltinByKey(builtinKey);
if (!def) throw new Error("Unknown built-in mate key: " + builtinKey);
var data = loadMates(ctx);
var id = generateMateId();
var userId = ctx ? ctx.userId : null;
var mate = {
id: id,
builtinKey: builtinKey,
name: def.displayName,
createdBy: userId,
createdAt: Date.now(),
seedData: def.seedData,
profile: {
displayName: def.displayName,
avatarColor: def.avatarColor,
avatarStyle: def.avatarStyle,
avatarSeed: def.avatarCustom ? crypto.randomBytes(4).toString("hex") : def.displayName,
avatarCustom: def.avatarCustom || "",
avatarLocked: !!def.avatarLocked,
},
bio: def.bio,
status: "ready",
primary: !!def.primary,
globalSearch: !!def.globalSearch,
templateVersion: def.templateVersion || 0,
interviewProjectPath: null,
};
avatarMigration.normalizeMate(mate);
data.mates.push(mate);
saveMates(ctx, data);
// Create the mate's identity directory
var mateDir = getMateDir(ctx, id);
fs.mkdirSync(mateDir, { recursive: true });
// Create knowledge directory
fs.mkdirSync(path.join(mateDir, "knowledge"), { recursive: true });
// Write mate.yaml
var seedData = def.seedData;
var yaml = "# Mate metadata\n";
yaml += "id: " + id + "\n";
yaml += "name: " + def.displayName + "\n";
yaml += "status: ready\n";
yaml += "builtinKey: " + builtinKey + "\n";
yaml += "createdBy: " + userId + "\n";
yaml += "createdAt: " + mate.createdAt + "\n";
yaml += "relationship: " + (seedData.relationship || "assistant") + "\n";
yaml += "activities: " + JSON.stringify(seedData.activity || []) + "\n";
yaml += "autonomy: " + (seedData.autonomy || "always_ask") + "\n";
fs.writeFileSync(path.join(mateDir, "mate.yaml"), yaml);
// Write AGENTS.md with full template + system sections
var claudeMd = def.getClaudeMd();
var builtinIdentity = claudeMd.trimEnd();
claudeMd += TEAM_SECTION;
claudeMd += SESSION_MEMORY_SECTION;
claudeMd += STICKY_NOTES_SECTION;
claudeMd += DEBATE_AWARENESS_SECTION;
claudeMd += crisisSafety.getSection();
fs.writeFileSync(resolveMateIdentityFile(mateDir), claudeMd);
// Backup identity and log creation
backupIdentity(mateDir, builtinIdentity);
logIdentityChange(mateDir, "create_builtin", builtinIdentity, "");
// Save base template for primary mates (used for 3-way sync comparison)
if (def.primary) {
try {
fs.writeFileSync(path.join(mateDir, "knowledge", "base-template.md"), builtinIdentity, "utf8");
} catch (e) {}
}
return mate;
}
function getInstalledBuiltinKeys(ctx) {
var data = loadMates(ctx);
var keys = [];
for (var i = 0; i < data.mates.length; i++) {
if (data.mates[i].builtinKey) {
keys.push(data.mates[i].builtinKey);
}
}
return keys;
}
function getMissingBuiltinKeys(ctx) {
var builtinMates = require("./builtin-mates");
var allKeys = builtinMates.getBuiltinKeys();
var installed = getInstalledBuiltinKeys(ctx);
var missing = [];
for (var i = 0; i < allKeys.length; i++) {
if (installed.indexOf(allKeys[i]) === -1) {
missing.push(allKeys[i]);
}
}
return missing;
}
function ensureBuiltinMates(ctx, deletedKeys) {
var missing = getMissingBuiltinKeys(ctx);
var excluded = deletedKeys || [];
var created = [];
for (var i = 0; i < missing.length; i++) {
if (excluded.indexOf(missing[i]) === -1) {
created.push(createBuiltinMate(ctx, missing[i]));
}
}
// Demote any archived built-ins that the user already has installed:
// strip primary/globalSearch flags and set archived=true on the mate
// object so the active-list filter hides them. Conversation history is
// untouched. This runs every startup so the transition lands even on
// users who logged in once when Ally was primary.
syncArchivedBuiltinMates(ctx);
return created;
}
function syncArchivedBuiltinMates(ctx) {
var builtinMates = require("./builtin-mates");
var archivedKeys = builtinMates.getArchivedBuiltinKeys();
if (archivedKeys.length === 0) return;
var data = loadMates(ctx);
var changed = false;
for (var i = 0; i < data.mates.length; i++) {
var m = data.mates[i];
if (!m.builtinKey) continue;
if (archivedKeys.indexOf(m.builtinKey) === -1) continue;
if (!m.archived) { m.archived = true; changed = true; }
if (m.primary) { m.primary = false; changed = true; }
if (m.globalSearch) { m.globalSearch = false; changed = true; }
}
if (changed) saveMates(ctx, data);
}
/**
* Sync primary mates with their latest code definition.
*
* Primary mates (def.primary === true) are system infrastructure, not just
* pre-made mates. They are the only mates whose identity, metadata, and
* capabilities are managed by code rather than by the user.
*
* What gets synced:
* - Metadata: bio, name, profile, globalSearch, primary flag
* - AGENTS.md identity: re-applied when templateVersion changes
* - templateVersion is stored on the mate object to track updates
*
* What is NOT synced (regular builtin mates):
* - Arch, Rush, Ward, Pixel, Buzz are left as-is after creation
*
* Returns array of synced mate IDs.
*/
function syncPrimaryMates(ctx) {
var builtinMates = require("./builtin-mates");
var primaryDefs = builtinMates.getPrimaryMates();
if (primaryDefs.length === 0) return [];
var data = loadMates(ctx);
var synced = [];
for (var pi = 0; pi < primaryDefs.length; pi++) {
var def = primaryDefs[pi];
// Find the installed mate matching this primary definition
var mate = null;
for (var mi = 0; mi < data.mates.length; mi++) {
if (data.mates[mi].builtinKey === def.key) { mate = data.mates[mi]; break; }
}
if (!mate) continue; // not installed yet (ensureBuiltinMates handles creation)
var changed = false;
// --- Sync metadata (always, every startup) ---
if (mate.bio !== def.bio) { mate.bio = def.bio; changed = true; }
if (mate.name !== def.displayName) { mate.name = def.displayName; changed = true; }
if (!mate.primary) { mate.primary = true; changed = true; }
if (!mate.globalSearch && def.globalSearch) { mate.globalSearch = true; changed = true; }
if (mate.profile) {
if (mate.profile.displayName !== def.displayName) {
mate.profile.displayName = def.displayName; changed = true;
}
// Locked avatars are code-managed — refresh them from the def so a
// built-in's avatar can be updated by changing the source file
// User uploads are preserved while canonical library artwork follows the definition.
if (def.avatarLocked && !avatarMigration.hasCustom(mate)) {
if (def.avatarCustom != null && mate.profile.avatarCustom !== def.avatarCustom) {
mate.profile.avatarCustom = def.avatarCustom; changed = true;
}
if (def.avatarColor && mate.profile.avatarColor !== def.avatarColor) {
mate.profile.avatarColor = def.avatarColor; changed = true;
}
}
}
// --- Sync AGENTS.md identity (only when templateVersion changes) ---
// Uses 3-way comparison to preserve user/Ally modifications:
// - base-template.md = the template that was last synced (what code wrote)
// - current identity = what's in AGENTS.md now (may have been modified)
// - new template = latest code definition
//
// If current identity === old base → user didn't touch it → safe to replace
// If current identity !== old base → user/Ally modified it → preserve, skip identity sync
var currentVersion = mate.templateVersion || 0;
var latestVersion = def.templateVersion || 0;
if (latestVersion > currentVersion) {
var mateDir = getMateDir(ctx, mate.id);
var claudePath = resolveMateIdentityFile(mateDir);
var basePath = path.join(mateDir, "knowledge", "base-template.md");
var latestIdentity = def.getClaudeMd().trimEnd();
try {
var currentIdentity = "";
if (fs.existsSync(claudePath)) {
currentIdentity = extractIdentity(fs.readFileSync(claudePath, "utf8"));
}
// Load the base template from last sync
var oldBase = "";
try { oldBase = fs.readFileSync(basePath, "utf8").trimEnd(); } catch (e) {}
var shouldUpdateIdentity = false;
if (!currentIdentity || currentIdentity.length < IDENTITY_MIN_LENGTH) {
// No identity at all, always apply
shouldUpdateIdentity = true;
} else if (!oldBase || currentIdentity === oldBase) {
// Identity unchanged from last sync (user didn't modify) → safe to update
shouldUpdateIdentity = true;
} else {
// User/Ally modified the identity since last sync → preserve their changes
console.log("[mates] Primary mate " + mate.name + ": identity modified by user/Ally, preserving (v" + currentVersion + " -> v" + latestVersion + " metadata only)");
}
if (shouldUpdateIdentity) {
// Write just the identity, then let enforceAllSections rebuild system sections
// (including dynamic capabilities section)
fs.writeFileSync(claudePath, latestIdentity, "utf8");
backupIdentity(mateDir, latestIdentity);
logIdentityChange(mateDir, "sync_primary_v" + latestVersion, latestIdentity, currentIdentity);
console.log("[mates] Primary mate identity updated: " + mate.name + " (v" + currentVersion + " -> v" + latestVersion + ")");
} else {
console.log("[mates] Primary mate metadata updated: " + mate.name + " (v" + currentVersion + " -> v" + latestVersion + ", identity preserved)");
}
// Re-run enforceAllSections to inject latest dynamic sections (capabilities, team, etc.)
enforceAllSections(claudePath, { ctx: ctx, mateId: mate.id });
// Save the latest template as base for future 3-way comparison
try {
fs.mkdirSync(path.join(mateDir, "knowledge"), { recursive: true });
fs.writeFileSync(basePath, latestIdentity, "utf8");
} catch (e) {}
} catch (e) {
console.error("[mates] Failed to sync primary mate " + mate.id + ":", e.message);
}
mate.templateVersion = latestVersion;
changed = true;
}
if (changed) synced.push(mate.id);
}
if (synced.length > 0) saveMates(ctx, data);
return synced;
}
module.exports = {
resolveMatesRoot: resolveMatesRoot,
buildMateCtx: buildMateCtx,
isMateIdFormat: isMateIdFormat,
loadMates: loadMates,
saveMates: saveMates,
createMate: createMate,
getMate: getMate,
updateMate: updateMate,
deleteMate: deleteMate,
getAllMates: getAllMates,
isMate: isMate,
getMateDir: getMateDir,
migrateLegacyMates: migrateLegacyMates,
formatSeedContext: formatSeedContext,
enforceTeamAwareness: enforceTeamAwareness,
TEAM_MARKER: TEAM_MARKER,
TEAM_SECTION: TEAM_SECTION,
enforceSessionMemory: enforceSessionMemory,
SESSION_MEMORY_MARKER: SESSION_MEMORY_MARKER,
SESSION_MEMORY_SECTION: SESSION_MEMORY_SECTION,
loadCommonKnowledge: loadCommonKnowledge,
promoteKnowledge: promoteKnowledge,
depromoteKnowledge: depromoteKnowledge,
getCommonKnowledgeForMate: getCommonKnowledgeForMate,
readCommonKnowledgeFile: readCommonKnowledgeFile,
isPromoted: isPromoted,
enforceStickyNotes: enforceStickyNotes,
STICKY_NOTES_MARKER: STICKY_NOTES_MARKER,
STICKY_NOTES_SECTION: STICKY_NOTES_SECTION,
enforceProjectRegistry: enforceProjectRegistry,
buildProjectRegistrySection: buildProjectRegistrySection,
PROJECT_REGISTRY_MARKER: PROJECT_REGISTRY_MARKER,
enforceDebateAwareness: enforceDebateAwareness,
DEBATE_AWARENESS_MARKER: DEBATE_AWARENESS_MARKER,
DEBATE_AWARENESS_SECTION: DEBATE_AWARENESS_SECTION,
enforceAllSections: enforceAllSections,
buildTeamSection: buildTeamSection,
buildPrimaryCapabilitiesSection: buildPrimaryCapabilitiesSection,
PRIMARY_CAPABILITIES_MARKER: PRIMARY_CAPABILITIES_MARKER,
extractIdentity: extractIdentity,
backupIdentity: backupIdentity,
loadIdentityBackup: loadIdentityBackup,
logIdentityChange: logIdentityChange,
createBuiltinMate: createBuiltinMate,
getInstalledBuiltinKeys: getInstalledBuiltinKeys,
getMissingBuiltinKeys: getMissingBuiltinKeys,
ensureBuiltinMates: ensureBuiltinMates,
syncArchivedBuiltinMates: syncArchivedBuiltinMates,
syncPrimaryMates: syncPrimaryMates,
};