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
140 changes: 140 additions & 0 deletions forge-cli/cmd/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"text/tabwriter"

"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/initializ/forge/forge-core/memory"
)

var (
memoryMigrateDryRun bool
memoryMigrateDir string
)

// memoryRoot is the global memory root under ~/.forge.
func memoryRoot() string { return filepath.Join(forgeHome(), "memory") }

var memoryCmd = &cobra.Command{
Use: "memory",
Short: "Manage forge long-term memory",
Long: `Manage forge's global long-term memory store (~/.forge/memory).

Memory is organized into two namespaces:
projects/<project-id> coding-session memory keyed by git remote
agents/<agent-id> deployed-agent operational memory keyed by agent_id`,
}

var memoryMigrateCmd = &cobra.Command{
Use: "migrate",
Short: "Copy repo-local .forge/memory into the global agents namespace",
Long: `Copy a repo-local .forge/memory directory into the global agents
namespace at ~/.forge/memory/agents/<agent-id>/.

Daily logs (YYYY-MM-DD.md) move under sessions/. The source is left intact and
a .migrated sentinel is written so re-runs are no-ops. The agent id comes from
forge.yaml in the target directory.`,
RunE: func(cmd *cobra.Command, _ []string) error {
workDir := memoryMigrateDir
if workDir == "" {
workDir = "."
}
agentID := agentIDFromDir(workDir)
source := filepath.Join(workDir, ".forge", "memory")

res, err := memory.MigrateRepoLocal(memoryRoot(), agentID, source, memoryMigrateDryRun)
if err != nil {
return err
}

out := cmd.OutOrStdout()
if res.Skipped {
fmt.Fprintf(out, "skipped: %s (%s)\n", res.Source, res.SkipNote)

Check failure on line 57 in forge-cli/cmd/memory.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
return nil
}
verb := "migrated"
if res.DryRun {
verb = "would migrate"
}
fmt.Fprintf(out, "%s %d file(s) from %s\n -> %s\n", verb, len(res.Files), res.Source, res.Dest)

Check failure on line 64 in forge-cli/cmd/memory.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
for _, f := range res.Files {
fmt.Fprintf(out, " %s\n", f)

Check failure on line 66 in forge-cli/cmd/memory.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `fmt.Fprintf` is not checked (errcheck)
}
return nil
},
}

var memoryProjectsCmd = &cobra.Command{
Use: "projects",
Short: "List coding projects tracked in global memory",
RunE: func(cmd *cobra.Command, _ []string) error {
return listRegistry(cmd, memory.NamespaceProjects)
},
}

var memoryAgentsCmd = &cobra.Command{
Use: "agents",
Short: "List deployed agents tracked in global memory",
RunE: func(cmd *cobra.Command, _ []string) error {
return listRegistry(cmd, memory.NamespaceAgents)
},
}

func listRegistry(cmd *cobra.Command, namespace string) error {
reg, err := memory.OpenRegistry(memoryRoot(), namespace)
if err != nil {
return err
}
entries, err := reg.List()
if err != nil {
return err
}
if len(entries) == 0 {
fmt.Fprintf(cmd.OutOrStdout(), "no %s tracked in %s\n", namespace, memoryRoot())
return nil
}
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tSOURCE\tPATHS\tLAST SEEN")

Check failure on line 102 in forge-cli/cmd/memory.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `fmt.Fprintln` is not checked (errcheck)
for _, e := range entries {
last := ""
if !e.LastSeen.IsZero() {
last = e.LastSeen.Format("2006-01-02")
}
fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", e.ID, e.Source, len(e.LocalPaths), last)
}
return tw.Flush()
}

// agentIDFromDir reads only the agent_id field from forge.yaml in dir, or ""
// when no config is found (the migrator then falls back to "unknown-agent").
// It deliberately does a lightweight parse rather than a full ParseForgeConfig
// so a partial/invalid config still yields a usable agent id for migration.
func agentIDFromDir(dir string) string {
data, err := os.ReadFile(filepath.Join(dir, "forge.yaml"))
if err != nil {
return ""
}
var cfg struct {
AgentID string `yaml:"agent_id"`
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return ""
}
return cfg.AgentID
}

func init() {
memoryMigrateCmd.Flags().BoolVar(&memoryMigrateDryRun, "dry-run", false, "show what would be migrated without writing")
memoryMigrateCmd.Flags().StringVar(&memoryMigrateDir, "dir", ".", "directory containing .forge/memory and forge.yaml")

memoryCmd.AddCommand(memoryMigrateCmd)
memoryCmd.AddCommand(memoryProjectsCmd)
memoryCmd.AddCommand(memoryAgentsCmd)

rootCmd.AddCommand(memoryCmd)
}
57 changes: 57 additions & 0 deletions forge-core/memory/atomicwrite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package memory

import (
"fmt"
"os"
"path/filepath"
)

// atomicWriteFile writes data to path atomically: it writes to a temp file in
// the same directory, fsyncs it, then renames it over the destination. The
// rename is atomic on POSIX filesystems, so a reader (or a crash mid-write)
// never observes a partially written file. The parent directory is fsynced
// best-effort so the rename itself is durable.
//
// All card, procedure, session, and registry writes go through this helper —
// the FileVectorStore flush is the one pre-existing temp→rename path (without
// fsync), and it is left as-is.
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating dir: %w", err)
}

tmp, err := os.CreateTemp(dir, ".tmp-"+filepath.Base(path)+"-*")
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
tmpName := tmp.Name()
// Best-effort cleanup if we bail before the rename succeeds. After a
// successful rename this simply fails silently (the name is gone).
defer func() { _ = os.Remove(tmpName) }()

if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("writing temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("syncing temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing temp file: %w", err)
}
if err := os.Chmod(tmpName, perm); err != nil {
return fmt.Errorf("chmod temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("renaming temp file: %w", err)
}

// Best-effort: fsync the directory so the rename survives a crash.
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
return nil
}
33 changes: 32 additions & 1 deletion forge-core/memory/filestore.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
Expand All @@ -15,14 +16,44 @@ type FileStore struct {
dir string
}

// NewFileStore creates a FileStore rooted at dir, creating it if needed.
// NewFileStore creates a FileStore rooted at dir, creating it if needed. This
// is the legacy / repo-local constructor; global-namespace callers should use
// NewGlobalFileStore so the id is validated as a single safe path segment.
func NewFileStore(dir string) (*FileStore, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("creating memory dir: %w", err)
}
return &FileStore{dir: dir}, nil
}

// safeSegmentRe matches a single filesystem-safe path segment: it must start
// with an alphanumeric and contain only lowercase alphanumerics, dot, dash, or
// underscore. ProjectID (hex or local-<hex>) and AgentID both satisfy it.
var safeSegmentRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)

// safeSegment reports whether s is safe to use as a single path segment (no
// traversal, no separators).
func safeSegment(s string) bool {
if s == "" || len(s) > 128 || strings.Contains(s, "..") {
return false
}
return safeSegmentRe.MatchString(s)
}

// NewGlobalFileStore creates a FileStore under the global memory root for a
// given namespace ("projects" or "agents") and id, i.e. rooted at
// <root>/<namespace>/<id>/. The id is validated to be a single safe path
// segment so a crafted id cannot escape the namespace.
func NewGlobalFileStore(root, namespace, id string) (*FileStore, error) {
if namespace != NamespaceProjects && namespace != NamespaceAgents {
return nil, fmt.Errorf("invalid namespace %q", namespace)
}
if !safeSegment(id) {
return nil, fmt.Errorf("invalid id segment %q", id)
}
return NewFileStore(filepath.Join(root, namespace, id))
}

// Dir returns the root directory of the file store.
func (fs *FileStore) Dir() string { return fs.dir }

Expand Down
145 changes: 145 additions & 0 deletions forge-core/memory/migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package memory

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"time"
)

// migratedSentinel is the marker written into a repo-local memory directory
// once its contents have been copied into the global namespace. Its presence
// makes migration idempotent and records where the copy went.
const migratedSentinel = ".migrated"

// dailyLogRe matches the legacy daily-log filenames (YYYY-MM-DD.md) that the
// repo-local store wrote; these become episodic sessions under the global
// namespace.
var dailyLogRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}\.md$`)

// MigrateResult reports what a migration did, or would do for a dry run.
type MigrateResult struct {
AgentID string `json:"agent_id"`
Source string `json:"source"`
Dest string `json:"dest"`
Files []string `json:"files"` // destination-relative paths (would be) copied
Skipped bool `json:"skipped"`
DryRun bool `json:"dry_run"`
SkipNote string `json:"skip_note,omitempty"`
}

// sentinelData is the JSON payload written into the .migrated sentinel.
type sentinelData struct {
MigratedAt time.Time `json:"migrated_at"`
Dest string `json:"dest"`
}

// MigrateRepoLocal copies a repo-local .forge/memory directory (sourceDir) into
// the global agents namespace at <root>/agents/<agentID>/. Daily logs
// (YYYY-MM-DD.md) are placed under sessions/; MEMORY.md, index/, and any other
// .md files are copied at their relative position. The source is left intact —
// migration is a copy, not a move — and a .migrated sentinel recording the
// destination is written so a re-run is a no-op.
//
// When dryRun is true nothing is written; the returned result lists the files
// that would be copied.
func MigrateRepoLocal(root, agentID, sourceDir string, dryRun bool) (*MigrateResult, error) {
safeAgent := AgentID(agentID)
dest := filepath.Join(root, NamespaceAgents, safeAgent)

res := &MigrateResult{
AgentID: safeAgent,
Source: sourceDir,
Dest: dest,
DryRun: dryRun,
}

info, err := os.Stat(sourceDir)
if err != nil || !info.IsDir() {
res.Skipped = true
res.SkipNote = "no repo-local memory directory"
return res, nil
}

// Already migrated? The sentinel makes this idempotent.
if _, err := os.Stat(filepath.Join(sourceDir, migratedSentinel)); err == nil {
res.Skipped = true
res.SkipNote = "already migrated (.migrated sentinel present)"
return res, nil
}

// Plan the copy: map each source file to its destination-relative path.
type copyOp struct{ src, relDest string }
var ops []copyOp
err = filepath.WalkDir(sourceDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if rel == migratedSentinel {
return nil
}
relDest := rel
// Daily logs at the top level become episodic sessions.
if filepath.Dir(rel) == "." && dailyLogRe.MatchString(filepath.Base(rel)) {
relDest = filepath.Join("sessions", filepath.Base(rel))
}
ops = append(ops, copyOp{src: path, relDest: relDest})
return nil
})
if err != nil {
return nil, fmt.Errorf("scanning source: %w", err)
}

sort.Slice(ops, func(i, j int) bool { return ops[i].relDest < ops[j].relDest })
for _, op := range ops {
res.Files = append(res.Files, op.relDest)
}

if dryRun {
return res, nil
}

for _, op := range ops {
data, err := os.ReadFile(op.src)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", op.src, err)
}
if err := atomicWriteFile(filepath.Join(dest, op.relDest), data, 0o644); err != nil {
return nil, fmt.Errorf("writing %s: %w", op.relDest, err)
}
}

// Record the migration in both the registry and a source-side sentinel.
reg, err := OpenRegistry(root, NamespaceAgents)
if err != nil {
return nil, err
}
if err := reg.Upsert(RegistryEntry{
ID: safeAgent,
Source: agentID,
Name: agentID,
LocalPaths: []string{sourceDir},
}); err != nil {
return nil, fmt.Errorf("updating registry: %w", err)
}

sentinel, err := json.MarshalIndent(sentinelData{MigratedAt: time.Now().UTC(), Dest: dest}, "", " ")
if err != nil {
return nil, err
}
if err := atomicWriteFile(filepath.Join(sourceDir, migratedSentinel), sentinel, 0o644); err != nil {
return nil, fmt.Errorf("writing sentinel: %w", err)
}

return res, nil
}
Loading
Loading