@stackmemoryai/stackmemory
Version:
Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.
750 lines (749 loc) • 24.7 kB
JavaScript
import { fileURLToPath as __fileURLToPath } from 'url';
import { dirname as __pathDirname } from 'path';
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __pathDirname(__filename);
import { logger } from "../monitoring/logger.js";
import { ValidationError, DatabaseError, ErrorCode } from "../errors/index.js";
import {
validateInput,
StartMergeSessionSchema,
CreateMergePolicySchema,
ConflictResolutionSchema
} from "./validation.js";
class StackMergeResolver {
dualStackManager;
activeSessions = /* @__PURE__ */ new Map();
mergePolicies = /* @__PURE__ */ new Map();
constructor(dualStackManager) {
this.dualStackManager = dualStackManager;
this.initializeDefaultPolicies();
logger.debug("StackMergeResolver initialized", {
policies: Array.from(this.mergePolicies.keys())
});
}
/**
* Start a merge session with conflict analysis
*/
async startMergeSession(sourceStackId, targetStackId, frameIds, policyName = "default") {
const input = validateInput(StartMergeSessionSchema, {
sourceStackId,
targetStackId,
frameIds,
policyName
});
const sessionId = `merge-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
logger.debug("Looking for merge policy", {
policyName: input.policyName,
availablePolicies: Array.from(this.mergePolicies.keys())
});
const policy = this.mergePolicies.get(input.policyName);
if (!policy) {
logger.error("Merge policy not found", {
requested: input.policyName,
available: Array.from(this.mergePolicies.keys())
});
throw new ValidationError(
`Merge policy not found: ${input.policyName}`,
ErrorCode.RESOURCE_NOT_FOUND
);
}
try {
const currentUserId = this.dualStackManager.getCurrentContext().ownerId || "unknown";
await this.dualStackManager.getPermissionManager().enforcePermission(
this.dualStackManager.getPermissionManager().createContext(currentUserId, "merge", "stack", input.sourceStackId)
);
await this.dualStackManager.getPermissionManager().enforcePermission(
this.dualStackManager.getPermissionManager().createContext(currentUserId, "merge", "stack", input.targetStackId)
);
const session = {
sessionId,
sourceStackId: input.sourceStackId,
targetStackId: input.targetStackId,
conflicts: [],
resolutions: [],
policy,
status: "analyzing",
startedAt: /* @__PURE__ */ new Date(),
metadata: {
totalFrames: 0,
conflictFrames: 0,
autoResolvedConflicts: 0,
manualResolvedConflicts: 0
}
};
this.activeSessions.set(sessionId, session);
await this.analyzeConflicts(sessionId, frameIds);
await this.autoResolveConflicts(sessionId);
logger.info(`Merge session started: ${sessionId}`, {
sourceStack: sourceStackId,
targetStack: targetStackId,
conflicts: session.conflicts.length,
policy: policyName
});
return sessionId;
} catch (error) {
logger.error("Failed to start merge session", {
error: error instanceof Error ? error.message : error,
sourceStackId: input.sourceStackId,
targetStackId: input.targetStackId,
policyName: input.policyName
});
throw new DatabaseError(
"Failed to start merge session",
ErrorCode.OPERATION_FAILED,
{ sourceStackId, targetStackId },
error instanceof Error ? error : void 0
);
}
}
/**
* Analyze conflicts between source and target stacks
*/
async analyzeConflicts(sessionId, frameIds) {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new DatabaseError(
`Merge session not found: ${sessionId}`,
ErrorCode.RESOURCE_NOT_FOUND
);
}
try {
const sourceStack = this.getStackManager(session.sourceStackId);
const targetStack = this.getStackManager(session.targetStackId);
const framesToAnalyze = frameIds || (await sourceStack.getActiveFrames()).map((f) => f.frame_id);
session.metadata.totalFrames = framesToAnalyze.length;
for (const frameId of framesToAnalyze) {
const sourceFrame = await sourceStack.getFrame(frameId);
if (!sourceFrame) continue;
const targetFrame = await targetStack.getFrame(frameId);
if (!targetFrame) continue;
const conflicts = await this.analyzeFrameConflicts(
sourceFrame,
targetFrame
);
session.conflicts.push(...conflicts);
}
session.metadata.conflictFrames = new Set(
session.conflicts.map((c) => c.frameId)
).size;
session.status = "resolving";
this.activeSessions.set(sessionId, session);
logger.info(`Conflict analysis completed: ${sessionId}`, {
totalConflicts: session.conflicts.length,
conflictFrames: session.metadata.conflictFrames
});
} catch (error) {
session.status = "failed";
this.activeSessions.set(sessionId, session);
throw error;
}
}
/**
* Analyze conflicts within a single frame
*/
async analyzeFrameConflicts(sourceFrame, targetFrame) {
const conflicts = [];
if (sourceFrame.name !== targetFrame.name) {
conflicts.push({
frameId: sourceFrame.frame_id,
conflictType: "content",
sourceFrame,
targetFrame,
conflictDetails: [
{
field: "name",
sourceValue: sourceFrame.name,
targetValue: targetFrame.name,
lastModified: {
source: new Date(sourceFrame.created_at * 1e3),
target: new Date(targetFrame.created_at * 1e3)
}
}
],
severity: "medium",
autoResolvable: false
});
}
if (sourceFrame.state !== targetFrame.state) {
conflicts.push({
frameId: sourceFrame.frame_id,
conflictType: "metadata",
sourceFrame,
targetFrame,
conflictDetails: [
{
field: "state",
sourceValue: sourceFrame.state,
targetValue: targetFrame.state,
lastModified: {
source: new Date(sourceFrame.created_at * 1e3),
target: new Date(targetFrame.created_at * 1e3)
}
}
],
severity: "high",
autoResolvable: true
// Can auto-resolve based on timestamps
});
}
if (JSON.stringify(sourceFrame.inputs) !== JSON.stringify(targetFrame.inputs)) {
conflicts.push({
frameId: sourceFrame.frame_id,
conflictType: "content",
sourceFrame,
targetFrame,
conflictDetails: [
{
field: "inputs",
sourceValue: sourceFrame.inputs,
targetValue: targetFrame.inputs,
lastModified: {
source: new Date(sourceFrame.created_at * 1e3),
target: new Date(targetFrame.created_at * 1e3)
}
}
],
severity: "medium",
autoResolvable: false
});
}
const eventConflicts = await this.analyzeEventConflicts(
sourceFrame,
targetFrame
);
conflicts.push(...eventConflicts);
const anchorConflicts = await this.analyzeAnchorConflicts(
sourceFrame,
targetFrame
);
conflicts.push(...anchorConflicts);
return conflicts;
}
/**
* Analyze conflicts in frame events
*/
async analyzeEventConflicts(sourceFrame, targetFrame) {
const conflicts = [];
try {
const sourceStack = this.getStackManager(sourceFrame.project_id);
const targetStack = this.getStackManager(targetFrame.project_id);
const sourceEvents = await sourceStack.getFrameEvents(
sourceFrame.frame_id
);
const targetEvents = await targetStack.getFrameEvents(
targetFrame.frame_id
);
if (sourceEvents.length !== targetEvents.length) {
conflicts.push({
frameId: sourceFrame.frame_id,
conflictType: "sequence",
sourceFrame,
targetFrame,
conflictDetails: [
{
field: "event_count",
sourceValue: sourceEvents.length,
targetValue: targetEvents.length,
lastModified: {
source: /* @__PURE__ */ new Date(),
target: /* @__PURE__ */ new Date()
}
}
],
severity: "high",
autoResolvable: true
// Can merge events
});
}
const minLength = Math.min(sourceEvents.length, targetEvents.length);
for (let i = 0; i < minLength; i++) {
const sourceEvent = sourceEvents[i];
const targetEvent = targetEvents[i];
if (sourceEvent.text !== targetEvent.text || JSON.stringify(sourceEvent.metadata) !== JSON.stringify(targetEvent.metadata)) {
conflicts.push({
frameId: sourceFrame.frame_id,
conflictType: "content",
sourceFrame,
targetFrame,
conflictDetails: [
{
field: `event_${i}`,
sourceValue: {
text: sourceEvent.text,
metadata: sourceEvent.metadata
},
targetValue: {
text: targetEvent.text,
metadata: targetEvent.metadata
},
lastModified: {
source: /* @__PURE__ */ new Date(),
target: /* @__PURE__ */ new Date()
}
}
],
severity: "medium",
autoResolvable: false
});
}
}
} catch (error) {
logger.warn(
`Failed to analyze event conflicts for frame: ${sourceFrame.frame_id}`,
error
);
}
return conflicts;
}
/**
* Analyze conflicts in frame anchors
*/
async analyzeAnchorConflicts(sourceFrame, targetFrame) {
const conflicts = [];
try {
const sourceStack = this.getStackManager(sourceFrame.project_id);
const targetStack = this.getStackManager(targetFrame.project_id);
const sourceAnchors = await sourceStack.getFrameAnchors(
sourceFrame.frame_id
);
const targetAnchors = await targetStack.getFrameAnchors(
targetFrame.frame_id
);
const sourceAnchorsByType = this.groupAnchorsByType(sourceAnchors);
const targetAnchorsByType = this.groupAnchorsByType(targetAnchors);
const allTypes = /* @__PURE__ */ new Set([
...Object.keys(sourceAnchorsByType),
...Object.keys(targetAnchorsByType)
]);
for (const type of allTypes) {
const sourceTypeAnchors = sourceAnchorsByType[type] || [];
const targetTypeAnchors = targetAnchorsByType[type] || [];
if (sourceTypeAnchors.length !== targetTypeAnchors.length || !this.anchorsEqual(sourceTypeAnchors, targetTypeAnchors)) {
conflicts.push({
frameId: sourceFrame.frame_id,
conflictType: "content",
sourceFrame,
targetFrame,
conflictDetails: [
{
field: `anchors_${type}`,
sourceValue: sourceTypeAnchors,
targetValue: targetTypeAnchors,
lastModified: {
source: /* @__PURE__ */ new Date(),
target: /* @__PURE__ */ new Date()
}
}
],
severity: "low",
autoResolvable: true
// Can merge anchors
});
}
}
} catch (error) {
logger.warn(
`Failed to analyze anchor conflicts for frame: ${sourceFrame.frame_id}`,
error
);
}
return conflicts;
}
/**
* Auto-resolve conflicts based on merge policy
*/
async autoResolveConflicts(sessionId) {
const session = this.activeSessions.get(sessionId);
if (!session) return;
const autoResolvableConflicts = session.conflicts.filter(
(c) => c.autoResolvable
);
for (const conflict of autoResolvableConflicts) {
const resolution = await this.applyMergePolicy(conflict, session.policy);
if (resolution) {
session.resolutions.push(resolution);
session.metadata.autoResolvedConflicts++;
logger.debug(`Auto-resolved conflict: ${conflict.frameId}`, {
type: conflict.conflictType,
strategy: resolution.strategy
});
}
}
const remainingConflicts = session.conflicts.filter(
(c) => !session.resolutions.find((r) => r.conflictId === c.frameId)
);
if (remainingConflicts.length === 0) {
session.status = "completed";
session.completedAt = /* @__PURE__ */ new Date();
} else if (remainingConflicts.every((c) => !c.autoResolvable)) {
session.status = "manual_review";
}
this.activeSessions.set(sessionId, session);
}
/**
* Apply merge policy to resolve conflicts automatically
*/
async applyMergePolicy(conflict, policy) {
const sortedRules = policy.rules.sort((a, b) => b.priority - a.priority);
for (const rule of sortedRules) {
if (this.evaluateRuleCondition(conflict, rule.condition)) {
return {
conflictId: conflict.frameId,
strategy: rule.action === "require_manual" ? "manual" : rule.action,
resolvedBy: "system",
resolvedAt: /* @__PURE__ */ new Date(),
notes: `Auto-resolved by policy: ${policy.name}`
};
}
}
return null;
}
/**
* Manually resolve a specific conflict
*/
async resolveConflict(sessionId, conflictId, resolution) {
const input = validateInput(ConflictResolutionSchema, {
strategy: resolution.strategy,
resolvedBy: resolution.resolvedBy,
notes: resolution.notes
});
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new ValidationError(
`Merge session not found: ${sessionId}`,
ErrorCode.MERGE_SESSION_INVALID
);
}
const conflict = session.conflicts.find((c) => c.frameId === conflictId);
if (!conflict) {
throw new ValidationError(
`Conflict not found: ${conflictId}`,
ErrorCode.MERGE_CONFLICT_UNRESOLVABLE
);
}
const fullResolution = {
...input,
conflictId,
resolvedAt: /* @__PURE__ */ new Date()
};
session.resolutions.push(fullResolution);
session.metadata.manualResolvedConflicts++;
const resolvedConflictIds = new Set(
session.resolutions.map((r) => r.conflictId)
);
const allResolved = session.conflicts.every(
(c) => resolvedConflictIds.has(c.frameId)
);
if (allResolved) {
session.status = "completed";
session.completedAt = /* @__PURE__ */ new Date();
}
this.activeSessions.set(sessionId, session);
logger.info(`Conflict manually resolved: ${conflictId}`, {
strategy: resolution.strategy,
resolvedBy: resolution.resolvedBy
});
}
/**
* Execute merge with resolved conflicts
*/
async executeMerge(sessionId) {
const session = this.activeSessions.get(sessionId);
if (!session) {
throw new DatabaseError(
`Merge session not found: ${sessionId}`,
ErrorCode.RESOURCE_NOT_FOUND
);
}
if (session.status !== "completed") {
throw new DatabaseError(
`Merge session not ready for execution: ${session.status}`,
ErrorCode.INVALID_STATE
);
}
try {
const resolutionMap = new Map(
session.resolutions.map((r) => [r.conflictId, r])
);
const result = {
success: true,
conflictFrames: [],
mergedFrames: [],
errors: []
};
const sourceStack = this.getStackManager(session.sourceStackId);
const targetStack = this.getStackManager(session.targetStackId);
const conflictsByFrame = /* @__PURE__ */ new Map();
for (const conflict of session.conflicts) {
const existing = conflictsByFrame.get(conflict.frameId) || [];
existing.push(conflict);
conflictsByFrame.set(conflict.frameId, existing);
}
for (const [frameId] of conflictsByFrame) {
try {
const resolution = resolutionMap.get(frameId);
if (!resolution) {
result.errors.push({
frameId,
error: "No resolution found",
resolution: "skipped"
});
result.conflictFrames.push(frameId);
continue;
}
const sourceFrame = await sourceStack.getFrame(frameId);
const targetFrame = await targetStack.getFrame(frameId);
if (!sourceFrame) {
result.errors.push({
frameId,
error: "Source frame not found",
resolution: "skipped"
});
continue;
}
switch (resolution.strategy) {
case "source_wins":
if (targetFrame) await targetStack.deleteFrame(frameId);
await this.copyFrameToStack(
sourceFrame,
sourceStack,
targetStack
);
result.mergedFrames.push(frameId);
break;
case "target_wins":
result.mergedFrames.push(frameId);
break;
case "merge_both":
if (targetFrame) {
await this.mergeFrameContents(
sourceFrame,
targetFrame,
sourceStack,
targetStack
);
} else {
await this.copyFrameToStack(
sourceFrame,
sourceStack,
targetStack
);
}
result.mergedFrames.push(frameId);
break;
case "skip":
result.conflictFrames.push(frameId);
break;
case "manual":
result.errors.push({
frameId,
error: "Manual resolution not applied",
resolution: "skipped"
});
result.conflictFrames.push(frameId);
break;
}
} catch (error) {
result.errors.push({
frameId,
error: error instanceof Error ? error.message : String(error),
resolution: "skipped"
});
result.success = false;
}
}
logger.info(`Merge executed: ${sessionId}`, {
mergedFrames: result.mergedFrames.length,
conflicts: result.conflictFrames.length,
errors: result.errors.length
});
return result;
} catch (error) {
throw new DatabaseError(
"Failed to execute merge",
ErrorCode.OPERATION_FAILED,
{ sessionId },
error instanceof Error ? error : void 0
);
}
}
async copyFrameToStack(frame, sourceStack, targetStack) {
await targetStack.createFrame({
frame_id: frame.frame_id,
name: frame.name,
type: frame.type,
parent_frame_id: frame.parent_frame_id,
inputs: frame.inputs,
outputs: frame.outputs
});
const events = await sourceStack.getFrameEvents(frame.frame_id);
for (const event of events) {
await targetStack.addEvent(frame.frame_id, {
type: event.type,
text: event.text,
metadata: event.metadata
});
}
const anchors = await sourceStack.getFrameAnchors(frame.frame_id);
for (const anchor of anchors) {
await targetStack.addAnchor(frame.frame_id, {
type: anchor.type,
text: anchor.text,
priority: anchor.priority,
metadata: anchor.metadata
});
}
}
async mergeFrameContents(sourceFrame, targetFrame, sourceStack, targetStack) {
const sourceEvents = await sourceStack.getFrameEvents(sourceFrame.frame_id);
const targetEvents = await targetStack.getFrameEvents(targetFrame.frame_id);
const existingSignatures = new Set(
targetEvents.map((e) => `${e.type}:${e.text}`)
);
for (const event of sourceEvents) {
const sig = `${event.type}:${event.text}`;
if (!existingSignatures.has(sig)) {
await targetStack.addEvent(targetFrame.frame_id, {
type: event.type,
text: event.text,
metadata: { ...event.metadata, merged: true }
});
existingSignatures.add(sig);
}
}
const sourceAnchors = await sourceStack.getFrameAnchors(
sourceFrame.frame_id
);
const targetAnchors = await targetStack.getFrameAnchors(
targetFrame.frame_id
);
const existingAnchorSigs = new Set(
targetAnchors.map((a) => `${a.type}:${a.text}:${a.priority}`)
);
for (const anchor of sourceAnchors) {
const sig = `${anchor.type}:${anchor.text}:${anchor.priority}`;
if (!existingAnchorSigs.has(sig)) {
await targetStack.addAnchor(targetFrame.frame_id, {
type: anchor.type,
text: anchor.text,
priority: anchor.priority,
metadata: { ...anchor.metadata, merged: true }
});
existingAnchorSigs.add(sig);
}
}
logger.debug(`Merged frame contents: ${sourceFrame.frame_id}`);
}
/**
* Get merge session details
*/
async getMergeSession(sessionId) {
return this.activeSessions.get(sessionId) || null;
}
/**
* Create custom merge policy
*/
async createMergePolicy(policy) {
const input = validateInput(CreateMergePolicySchema, policy);
this.mergePolicies.set(input.name, input);
logger.info(`Created merge policy: ${input.name}`, {
rules: input.rules.length,
autoApplyThreshold: input.autoApplyThreshold
});
}
/**
* Initialize default merge policies
*/
initializeDefaultPolicies() {
this.mergePolicies.set("conservative", {
name: "conservative",
description: "Prefer manual resolution for most conflicts",
rules: [
{
condition: '$.conflictType == "metadata" && $.severity == "low"',
action: "target_wins",
priority: 1
},
{
condition: '$.severity == "critical"',
action: "require_manual",
priority: 10
}
],
autoApplyThreshold: "never"
});
this.mergePolicies.set("aggressive", {
name: "aggressive",
description: "Auto-resolve conflicts when safe",
rules: [
{
condition: '$.conflictType == "sequence"',
action: "merge_both",
priority: 5
},
{
condition: '$.severity == "low"',
action: "source_wins",
priority: 2
},
{
condition: '$.severity == "medium" && $.autoResolvable',
action: "merge_both",
priority: 4
}
],
autoApplyThreshold: "medium"
});
this.mergePolicies.set("default", {
name: "default",
description: "Balanced conflict resolution",
rules: [
{
condition: '$.conflictType == "sequence" && $.severity == "low"',
action: "merge_both",
priority: 3
},
{
condition: '$.conflictType == "metadata" && $.autoResolvable',
action: "target_wins",
priority: 2
},
{
condition: '$.severity == "critical"',
action: "require_manual",
priority: 10
}
],
autoApplyThreshold: "low"
});
}
// Helper methods
getStackManager(stackId) {
return this.dualStackManager.getStackManager(stackId);
}
groupAnchorsByType(anchors) {
return anchors.reduce(
(groups, anchor) => {
if (!groups[anchor.type]) groups[anchor.type] = [];
groups[anchor.type].push(anchor);
return groups;
},
{}
);
}
anchorsEqual(anchors1, anchors2) {
if (anchors1.length !== anchors2.length) return false;
const sorted1 = [...anchors1].sort((a, b) => a.text.localeCompare(b.text));
const sorted2 = [...anchors2].sort((a, b) => a.text.localeCompare(b.text));
return sorted1.every(
(anchor, i) => anchor.text === sorted2[i].text && anchor.priority === sorted2[i].priority
);
}
evaluateRuleCondition(conflict, condition) {
return condition.includes(conflict.conflictType) || condition.includes(conflict.severity);
}
}
export {
StackMergeResolver
};
//# sourceMappingURL=stack-merge-resolver.js.map