Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 67 additions & 5 deletions Sources/OrreryCore/Commands/MemoryCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -445,16 +445,51 @@ private func askMigrationChoiceToIsolated() -> Int {
return selector.run()
}

private func applyMigration(merge: Bool, fromDir sourceDir: URL, toDir destDir: URL) throws {
/// Migrate a memory directory's contents from `sourceDir` into `destDir`.
///
/// `MEMORY.md` is special: it's the canonical doc the AI agent auto-loads
/// and reconciles, so migrating it would clobber dest. Instead we wrap the
/// source content as a *fragment* under `destDir/fragments/`, which the
/// agent picks up on the next `orrery_memory_read` and consolidates into
/// the dest's `MEMORY.md`.
///
/// Everything else (user-curated `.md` files, subdirectories, existing
/// fragments) is plain user data — we copy it across with skip-existing
/// semantics so dest's existing files are never overwritten. Files that
/// only live in source land in dest verbatim; collisions preserve dest
/// silently. The source dir itself is left untouched (non-destructive,
/// reversible if the user later switches back).
///
/// `merge=false` is a no-op — the caller chose "discard source" and will
/// confirm separately.
func applyMigration(merge: Bool, fromDir sourceDir: URL, toDir destDir: URL) throws {
guard merge else { return }

let fm = FileManager.default
let sourceFile = sourceDir.appendingPathComponent("MEMORY.md")
guard fm.fileExists(atPath: sourceFile.path),
let content = try? String(contentsOf: sourceFile, encoding: .utf8) else {
return
guard fm.fileExists(atPath: sourceDir.path) else { return }

try fm.createDirectory(at: destDir, withIntermediateDirectories: true)

let entries = (try? fm.contentsOfDirectory(atPath: sourceDir.path)) ?? []
for entry in entries {
let source = sourceDir.appendingPathComponent(entry)
let dest = destDir.appendingPathComponent(entry)

if entry == "MEMORY.md" {
if let content = try? String(contentsOf: source, encoding: .utf8) {
try writeMigrationFragment(content: content, toDir: destDir)
}
} else {
try copyTreeSkipExisting(from: source, to: dest)
}
}
}

/// Write `content` as a new fragment under `destDir/fragments/`. The
/// frontmatter (id / peer / timestamp / action) is what the AI agent reads
/// when consolidating fragments into `MEMORY.md` on the next read pass.
func writeMigrationFragment(content: String, toDir destDir: URL) throws {
let fm = FileManager.default
let fragmentsDir = destDir.appendingPathComponent("fragments")
try fm.createDirectory(at: fragmentsDir, withIntermediateDirectories: true)

Expand All @@ -479,3 +514,30 @@ private func applyMigration(merge: Bool, fromDir sourceDir: URL, toDir destDir:
atomically: true, encoding: .utf8
)
}

/// Recursively copy `source` into `dest`. Files that already exist at the
/// destination are preserved (skip-existing). Used for non-canonical user
/// data during memory migration — see `applyMigration` for the rationale.
func copyTreeSkipExisting(from source: URL, to dest: URL) throws {
let fm = FileManager.default
var isDir: ObjCBool = false
guard fm.fileExists(atPath: source.path, isDirectory: &isDir) else { return }

if isDir.boolValue {
try fm.createDirectory(at: dest, withIntermediateDirectories: true)
let children = (try? fm.contentsOfDirectory(atPath: source.path)) ?? []
for child in children {
try copyTreeSkipExisting(
from: source.appendingPathComponent(child),
to: dest.appendingPathComponent(child)
)
}
} else {
guard !fm.fileExists(atPath: dest.path) else { return }
try fm.createDirectory(
at: dest.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try fm.copyItem(at: source, to: dest)
}
}
173 changes: 173 additions & 0 deletions Tests/OrreryTests/MemoryMigrationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import Testing
import Foundation
@testable import OrreryCore

@Suite("Memory migration")
struct MemoryMigrationTests {
var tmpRoot: URL!

init() throws {
tmpRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("orrery-mem-mig-tests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tmpRoot, withIntermediateDirectories: true)
}

// MARK: - merge=false is a no-op

@Test("merge=false leaves dest untouched")
func mergeFalseIsNoOp() throws {
let (source, dest) = try makeDirs()
try "src content".write(to: source.appendingPathComponent("MEMORY.md"), atomically: true, encoding: .utf8)
try "dest content".write(to: dest.appendingPathComponent("MEMORY.md"), atomically: true, encoding: .utf8)

try applyMigration(merge: false, fromDir: source, toDir: dest)

let destContent = try String(contentsOf: dest.appendingPathComponent("MEMORY.md"), encoding: .utf8)
#expect(destContent == "dest content")
#expect(!FileManager.default.fileExists(atPath: dest.appendingPathComponent("fragments").path))
}

// MARK: - MEMORY.md → fragment

@Test("MEMORY.md from source becomes a fragment in dest")
func memoryBecomesFragment() throws {
let (source, dest) = try makeDirs()
try "src memory body".write(to: source.appendingPathComponent("MEMORY.md"), atomically: true, encoding: .utf8)
try "dest memory unchanged".write(to: dest.appendingPathComponent("MEMORY.md"), atomically: true, encoding: .utf8)

try applyMigration(merge: true, fromDir: source, toDir: dest)

// Dest's MEMORY.md is preserved as-is (not overwritten).
let destBody = try String(contentsOf: dest.appendingPathComponent("MEMORY.md"), encoding: .utf8)
#expect(destBody == "dest memory unchanged")

// A fragment file landed under dest/fragments/.
let fragmentsDir = dest.appendingPathComponent("fragments")
let fragments = try FileManager.default.contentsOfDirectory(atPath: fragmentsDir.path)
#expect(fragments.count == 1)
let fragmentPath = fragmentsDir.appendingPathComponent(fragments[0])
let fragmentBody = try String(contentsOf: fragmentPath, encoding: .utf8)
#expect(fragmentBody.contains("action: migrate"))
#expect(fragmentBody.contains("src memory body"))
}

// MARK: - non-canonical files

@Test("non-canonical .md file copies to dest when missing")
func nonCanonicalFileCopiesWhenMissing() throws {
let (source, dest) = try makeDirs()
try "notes content".write(to: source.appendingPathComponent("notes.md"), atomically: true, encoding: .utf8)

try applyMigration(merge: true, fromDir: source, toDir: dest)

let copied = try String(contentsOf: dest.appendingPathComponent("notes.md"), encoding: .utf8)
#expect(copied == "notes content")
}

@Test("non-canonical file is preserved (skip-existing) on collision")
func nonCanonicalFileSkippedOnCollision() throws {
let (source, dest) = try makeDirs()
try "src version".write(to: source.appendingPathComponent("notes.md"), atomically: true, encoding: .utf8)
try "dest version".write(to: dest.appendingPathComponent("notes.md"), atomically: true, encoding: .utf8)

try applyMigration(merge: true, fromDir: source, toDir: dest)

let preserved = try String(contentsOf: dest.appendingPathComponent("notes.md"), encoding: .utf8)
#expect(preserved == "dest version", "destination must not be overwritten")
}

// MARK: - subdirectories

@Test("subdirectory copies recursively")
func subdirectoryRecursiveCopy() throws {
let (source, dest) = try makeDirs()
let srcSub = source.appendingPathComponent("sub").appendingPathComponent("nested")
try FileManager.default.createDirectory(at: srcSub, withIntermediateDirectories: true)
try "deep".write(to: srcSub.appendingPathComponent("deep.md"), atomically: true, encoding: .utf8)
try "shallow".write(to: source.appendingPathComponent("sub").appendingPathComponent("shallow.md"), atomically: true, encoding: .utf8)

try applyMigration(merge: true, fromDir: source, toDir: dest)

let copiedDeep = try String(contentsOf: dest.appendingPathComponent("sub/nested/deep.md"), encoding: .utf8)
let copiedShallow = try String(contentsOf: dest.appendingPathComponent("sub/shallow.md"), encoding: .utf8)
#expect(copiedDeep == "deep")
#expect(copiedShallow == "shallow")
}

@Test("file inside subdirectory is skip-existing on collision")
func subdirFileSkippedOnCollision() throws {
let (source, dest) = try makeDirs()
let srcSub = source.appendingPathComponent("sub")
let dstSub = dest.appendingPathComponent("sub")
try FileManager.default.createDirectory(at: srcSub, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: dstSub, withIntermediateDirectories: true)
try "src".write(to: srcSub.appendingPathComponent("a.md"), atomically: true, encoding: .utf8)
try "dest-orig".write(to: dstSub.appendingPathComponent("a.md"), atomically: true, encoding: .utf8)
try "src-only".write(to: srcSub.appendingPathComponent("b.md"), atomically: true, encoding: .utf8)

try applyMigration(merge: true, fromDir: source, toDir: dest)

let aPreserved = try String(contentsOf: dstSub.appendingPathComponent("a.md"), encoding: .utf8)
let bCopied = try String(contentsOf: dstSub.appendingPathComponent("b.md"), encoding: .utf8)
#expect(aPreserved == "dest-orig")
#expect(bCopied == "src-only")
}

// MARK: - fragments/ directory

@Test("existing fragments in source's fragments/ are preserved across migration")
func existingFragmentsCopyOver() throws {
let (source, dest) = try makeDirs()
let srcFragments = source.appendingPathComponent("fragments")
try FileManager.default.createDirectory(at: srcFragments, withIntermediateDirectories: true)
try "old fragment body".write(
to: srcFragments.appendingPathComponent("f-abc12345-host.md"),
atomically: true, encoding: .utf8
)

try applyMigration(merge: true, fromDir: source, toDir: dest)

let destFragmentPath = dest.appendingPathComponent("fragments/f-abc12345-host.md")
let copied = try String(contentsOf: destFragmentPath, encoding: .utf8)
#expect(copied == "old fragment body")
}

// MARK: - source non-destructive

@Test("source dir is left intact after migration (reversible)")
func sourceLeftIntact() throws {
let (source, dest) = try makeDirs()
try "src memory".write(to: source.appendingPathComponent("MEMORY.md"), atomically: true, encoding: .utf8)
try "src note".write(to: source.appendingPathComponent("notes.md"), atomically: true, encoding: .utf8)

try applyMigration(merge: true, fromDir: source, toDir: dest)

let memoryStill = try String(contentsOf: source.appendingPathComponent("MEMORY.md"), encoding: .utf8)
let noteStill = try String(contentsOf: source.appendingPathComponent("notes.md"), encoding: .utf8)
#expect(memoryStill == "src memory")
#expect(noteStill == "src note")
}

// MARK: - missing source

@Test("missing source dir is a silent no-op")
func missingSourceNoOp() throws {
let dest = tmpRoot.appendingPathComponent("dest-only")
try FileManager.default.createDirectory(at: dest, withIntermediateDirectories: true)
let nonexistentSource = tmpRoot.appendingPathComponent("nope")

// Must not throw — migrating from a nonexistent dir is fine (env that
// never accumulated any memory before switching modes).
try applyMigration(merge: true, fromDir: nonexistentSource, toDir: dest)
}

// MARK: - helpers

private func makeDirs() throws -> (source: URL, dest: URL) {
let source = tmpRoot.appendingPathComponent("src-\(UUID().uuidString)")
let dest = tmpRoot.appendingPathComponent("dst-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: dest, withIntermediateDirectories: true)
return (source, dest)
}
}
Loading