localproject

package
v0.2.0-alpha.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package localproject owns the local Project: the stable identity that groups the sessions of one local unit of work across CLI, TUI, and Desktop, and that will own the first cross-session memory scope.

A Project is not a Workspace. Workspace is the directory a session actually executes in and decides containment, hooks, skills, and AGENTS.md; Project is the identity those sessions share, which for a Git repository spans the primary checkout and every linked worktree. See docs/design/local-project-memory.md §6.

Persistence lives in internal/infra/localprojectstore. This package owns what the operations mean; that one owns making them durable.

Index

Constants

View Source
const (
	// MaxMemories bounds the store. It is a starting bound chosen to be raised
	// on evidence rather than lowered after users have filled it.
	MaxMemories = 20
	// MaxDescriptionChars bounds one index line's payload.
	MaxDescriptionChars = 100
	// MaxBodyChars bounds one body. Generous because it is not resident.
	MaxBodyChars = 2000
	// MaxMemoryNameChars bounds a slug, which is also a file name.
	MaxMemoryNameChars = 64
)

Budgets. Each is enforced on write; none truncates silently at render time.

View Source
const IndexFileName = "MEMORY.md"

IndexFileName is the generated index over the memory files.

It is a projection, rebuilt from the files after every write exactly as the Project catalog is rebuilt from Project metadata, and for the same reason: an index that can disagree with its sources is a defect surface with no compensating capability. Users edit memories, not this.

View Source
const MemoryFileExt = ".md"

MemoryFileExt is the extension of a memory file. The file's base name is the slug, which is the memory's identity.

View Source
const MetaVersion = 1

MetaVersion is the meta.json format this build writes and is the only one it reads.

Variables

View Source
var (
	// ErrMemoryNotFound reports that no memory has that name.
	ErrMemoryNotFound = errors.New("localproject: no such memory")
	// ErrMemoryUnread reports an attempt to replace a memory this run has not
	// read. It is not a conflict: there is nothing to merge against, because
	// the writer has not seen what it would be overwriting.
	ErrMemoryUnread = errors.New("localproject: memory was not read by this run")
	// ErrMemoryConflict reports that the body changed since this run read it --
	// another session's write, or a direct user edit.
	ErrMemoryConflict = errors.New("localproject: memory changed since this run read it")
	// ErrMemoryFull reports that the store already holds MaxMemories.
	ErrMemoryFull = errors.New("localproject: project memory is full")
	// ErrMemoryInvalid reports a memory this build will not persist.
	ErrMemoryInvalid = errors.New("localproject: invalid memory")
	// ErrMemorySecret reports a body holding something that looks like a
	// credential. It is a refusal to persist, not proof of harm, and its
	// absence is not proof of safety.
	ErrMemorySecret = errors.New("localproject: memory looks like it holds a credential")
)
View Source
var (
	// ErrNotFound reports that no Project matches the id or key asked for.
	ErrNotFound = errors.New("localproject: not found")
	// ErrDuplicateLocator reports two Projects claiming one locator. It is a
	// refusal, not a choice: picking either would silently join or split a
	// memory domain. See docs/design/local-project-memory.md §7.4.
	ErrDuplicateLocator = errors.New("localproject: duplicate locator")
	// ErrCatalogBusy reports that another process holds the catalog writer
	// lock and did not release it in time.
	ErrCatalogBusy = errors.New("localproject: catalog is busy")
)

Errors a Store reports. They are declared here rather than in the store package because a caller decides what to do about them — resolve, repair, or refuse — and that decision is domain, not persistence.

Functions

func BodyDigest

func BodyDigest(body string) string

BodyDigest identifies a body for the read-then-replace rule.

func FormatIndex

func FormatIndex(memories []Memory) []byte

FormatIndex generates MEMORY.md from the memory files.

It is written for a person browsing the directory, not parsed by anything: the runtime renders its own block from the same memories, and the store rebuilds this file rather than reading it.

func FormatMemory

func FormatMemory(m Memory) []byte

FormatMemory renders one memory to its file form.

func NameForWorkspace

func NameForWorkspace(workspace string) string

NameForWorkspace derives a Project's initial name from the directory it was first opened in. It is presentation only, and a user may rename it at any time.

func ParseVerifiedAt

func ParseVerifiedAt(s string) (*time.Time, error)

ParseVerifiedAt reads a YYYY-MM-DD verification date. An empty string is no date rather than an error: most memories assert nothing they do not hold.

func ScanMemoryForSecrets

func ScanMemoryForSecrets(description, body string) error

ScanMemoryForSecrets refuses a memory holding a recognizable credential.

The description is scanned as well as the body, because it is the part that goes to the model on every call: a token pasted into a one-line summary would be the most exposed place in the store, not the least.

Best effort, and the Agent's own contract -- do not persist credentials or surprising sensitive information -- remains the real guard. The error names the shapes, never the values, so a refusal does not put the credential into a log or back into the model's context.

func SortMemories

func SortMemories(memories []Memory)

SortMemories orders a set by name, which is what makes the generated index stable across writes.

func ValidMemoryName

func ValidMemoryName(s string) bool

ValidMemoryName reports whether s is a usable slug: lowercase letters, digits, and single hyphens between them.

The slug is a file name, so the character set is a containment guard as much as a convention -- a name that could traverse a directory or collide under a case-insensitive filesystem is one the store must never be asked to write.

func ValidMemoryType

func ValidMemoryType(t MemoryType) bool

ValidMemoryType reports whether t is one this build understands.

Types

type Key

type Key struct {
	Kind    Kind
	Locator string
}

Key is what a resolved workspace is looked up by: the kind of identity and the locator that anchors it.

type Kind

type Kind string

Kind says what a Project's identity is anchored to, and therefore what locator resolves it.

const (
	// KindGit is one Git repository, identified by the common Git directory
	// its worktrees share.
	KindGit Kind = "git"
	// KindDirectory is one plain directory, identified by its canonical
	// absolute path.
	KindDirectory Kind = "directory"
)

type Memory

type Memory struct {
	Name        string
	Description string
	Type        MemoryType
	// SessionID and UpdatedAt are this memory's own provenance, which is why
	// there is no sidecar metadata file: a lone Markdown document needed one
	// because it had nowhere to record who wrote it, and per-file frontmatter
	// removes that need rather than moving it.
	SessionID string
	UpdatedAt time.Time
	// VerifiedAt is the date a memory that caches something expensive was last
	// checked against its source of truth. Nil for the ordinary memory that
	// asserts nothing it does not itself hold. Only the date is meaningful.
	VerifiedAt *time.Time
	Body       string
}

Memory is one remembered thing: its own file, its own provenance.

Name is the slug, the file name, and the identity all at once -- there is no second identifier that can disagree with it.

func ParseMemory

func ParseMemory(name string, data []byte) (Memory, error)

ParseMemory reads one memory file. name is the slug taken from the file name, which is authoritative: frontmatter that disagrees with it is an error rather than a second opinion about what this memory is called.

func (Memory) Validate

func (m Memory) Validate() error

Validate rejects a memory this build will not persist or render.

type MemorySet

type MemorySet struct {
	// Memories are the usable ones, ordered by name so the index is stable.
	Memories []Memory
	Skipped  []SkippedMemory
}

MemorySet is what the store found in one Project's memory directory.

func (MemorySet) Find

func (s MemorySet) Find(name string) (Memory, bool)

Find returns the memory with that name.

type MemoryType

type MemoryType string

MemoryType says what kind of knowledge a memory holds.

There is deliberately no user type. Who the user is would be global user memory, which §5.2 keeps out of a Project-scoped store.

const (
	// MemoryTypeFeedback is guidance the user gave about how to work in this
	// Project. It records what the user wants, never what the user is.
	MemoryTypeFeedback MemoryType = "feedback"
	// MemoryTypeProject is ongoing work, goals, decisions, and constraints not
	// derivable from the code or its history.
	MemoryTypeProject MemoryType = "project"
	// MemoryTypeReference points at external resources: dashboards, tickets,
	// specifications.
	MemoryTypeReference MemoryType = "reference"
)

func MemoryTypes

func MemoryTypes() []MemoryType

MemoryTypes returns every valid type, in the order a surface should list them. It returns a fresh slice so no caller can reorder or extend the set the validator trusts.

type MemoryWrite

type MemoryWrite struct {
	Name        string
	Description string
	Type        MemoryType
	Body        string
	SessionID   string
	PriorDigest string
	// VerifiedAt is the date this memory was last checked against the source of
	// truth its body names. Nil leaves an existing date alone -- rewording a
	// body is not re-verifying it -- and clearing one is a hand edit, not
	// something a run does on the way past.
	VerifiedAt *time.Time
}

MemoryWrite creates or replaces exactly one memory.

PriorDigest is supplied by the runtime from the bodies it has handed the model this run, never by the model. Routing a correctness token out to the least reliable component in the loop and expecting it back verbatim would add an omitted-parameter case whose only plausible fallback is an unconditional overwrite. Empty means this run has not read the memory, which is a refusal on replacement and irrelevant on creation.

type Project

type Project struct {
	Version int    `json:"version"`
	ID      string `json:"id"`
	Name    string `json:"name"`
	Kind    Kind   `json:"kind"`

	// DefaultWorkspace is where a new Desktop run opens by default. For a Git
	// Project it is one of possibly several worktrees and may change without
	// identity changing; for a directory Project it is also the locator.
	DefaultWorkspace string `json:"default_workspace"`
	// GitCommonDir is set only when Kind is KindGit.
	GitCommonDir string `json:"git_common_dir,omitempty"`

	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
	LastUsedAt time.Time `json:"last_used_at"`
}

Project is one local unit of work: a stable opaque ID plus the metadata that lets a surface find it again and name it to a user.

ID is the relationship key that sessions and memory hang off. Name is presentation and never identity — renaming a Project moves nothing. The locator (GitCommonDir, or DefaultWorkspace for a directory Project) is how a workspace is matched back to this record, and it is a locator rather than an identity precisely because moving a repository invalidates it: that is a relink, not a new Project.

func Apply

func Apply(p Project, update Update, now time.Time) Project

Apply returns p with update applied and UpdatedAt advanced to now. It does not mutate p and does not validate: the caller persists through a Store, which validates before writing.

func New

func New(key Key, name, defaultWorkspace string, now time.Time) (Project, error)

New returns a Project for key, with a freshly minted opaque ID.

The caller supplies the workspace and name it derived from the filesystem; this constructor owns identity, kind consistency, and timestamps, so a caller cannot produce a record whose locator disagrees with its kind.

func (Project) Key

func (p Project) Key() Key

Key returns what this Project is resolved by.

func (Project) Summarize

func (p Project) Summarize() Summary

Summarize projects p into its catalog row.

func (Project) Validate

func (p Project) Validate() error

Validate rejects a Project this build cannot act on.

type SkippedMemory

type SkippedMemory struct {
	File   string
	Reason string
}

SkippedMemory is a file the store could not use. It is reported rather than guessed at: skipping one memory is a smaller failure than rendering an index line promising a body the read tool cannot return.

type Store

type Store interface {
	// Resolve returns the Project registered for key, registering proposed
	// when there is none.
	//
	// Creation is serialized by the catalog writer lock and repeats the lookup
	// after acquiring it. That second lookup is the point: CLI and Desktop
	// starting in the same new repository at the same moment must agree on one
	// Project ID rather than mint two and split its memory. proposed is used
	// only on that path; when a Project already exists it is discarded and the
	// stored one's last_used_at is advanced.
	//
	// A locator claimed by two Projects is ErrDuplicateLocator: choosing one
	// would silently join or split a memory domain, so repair is the user's.
	Resolve(ctx context.Context, key Key, proposed Project) (Project, error)

	// Find returns the Project registered for key, or ErrNotFound. Unlike
	// Resolve it registers nothing, which is what a diagnostic needs: a command
	// that reports which Project a directory belongs to must not be the thing
	// that decides it belongs to one.
	Find(ctx context.Context, key Key) (Project, error)

	// Get returns one Project by ID, or ErrNotFound. It is the authoritative
	// read: it opens the bundle rather than trusting the catalog projection.
	Get(ctx context.Context, id string) (Project, error)

	// List returns the catalog projection, rebuilding it from the bundles when
	// it is missing or unusable.
	List(ctx context.Context) ([]Summary, error)

	// Update changes presentation or relinks a moved Project. It takes the
	// catalog writer lock, because a locator change has to be visible in the
	// projection at the same moment it is visible in the bundle.
	Update(ctx context.Context, id string, update Update) error

	// Delete removes a Project bundle and its row.
	//
	// It deletes nothing outside that bundle. Sessions are owned by the session
	// store and are not cascaded by a Project going away — a caller that means
	// to remove them says so there, having shown the user which ones. See
	// docs/design/local-project-memory.md §15.
	Delete(ctx context.Context, id string) error

	// Memories returns every memory of the Project, plus the files it could
	// not use. A Project that has never written one reads as empty, not as an
	// error: no memories and no memory directory are the same state to every
	// caller.
	Memories(ctx context.Context, projectID string) (MemorySet, error)

	// WriteMemory creates or replaces exactly one memory under the memory
	// writer lock, and regenerates the index from the files.
	//
	// Creating a name that does not exist is always accepted. Replacing an
	// existing memory requires MemoryWrite.PriorDigest to match the body on
	// disk: an empty one is ErrMemoryUnread, since the writer has not seen what
	// it would overwrite, and a stale one is ErrMemoryConflict. Both refusals
	// return the stored memory so the caller can say which version won.
	WriteMemory(ctx context.Context, projectID string, write MemoryWrite) (Memory, error)

	// DeleteMemory removes one memory and its index line.
	DeleteMemory(ctx context.Context, projectID, name string) error

	// ClearMemories removes every memory file and leaves an empty index,
	// returning how many were removed. It touches nothing else in the bundle.
	ClearMemories(ctx context.Context, projectID string) (int, error)
}

Store is the persistence seam between the runtime and physical storage. It expresses Project semantics, not paths, mirroring session.Store: core owns what these operations mean, infra owns making them durable. See docs/design/local-project-memory.md §14.

type Summary

type Summary struct {
	ID               string    `json:"id"`
	Name             string    `json:"name"`
	Kind             Kind      `json:"kind"`
	Locator          string    `json:"locator"`
	DefaultWorkspace string    `json:"default_workspace"`
	LastUsedAt       time.Time `json:"last_used_at"`
}

Summary is one Project's row in the catalog projection: enough to resolve a workspace and list Projects without opening every bundle. It is rebuildable from the bundles and is never the only copy of anything.

type Update

type Update struct {
	Name *string
	// DefaultWorkspace moves where a Project opens by default. For a directory
	// Project it also relocates the locator, which is what an explicit relink
	// after a move does.
	DefaultWorkspace *string
	// GitCommonDir relinks a Git Project whose repository moved. It is only
	// ever set by an explicit user decision: no heuristic may join two memory
	// domains, so nothing infers this from a remote URL or a directory name.
	GitCommonDir *string
	// TouchLastUsed advances last_used_at, which orders the picker.
	TouchLastUsed bool
}

Update describes a change to a Project's presentation or locator. A nil field leaves that value unchanged.

There is no field for ID or Kind: both are immutable, and a workspace that stopped being a Git checkout is a different Project rather than the same one under a new kind.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL