Files
ai-workflow-skill/packages/repo-memory-runtime/cmd/repo-memory/main.go
T

778 lines
22 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"ai-workflow-skill/packages/repo-memory-runtime/internal/documents"
"ai-workflow-skill/packages/repo-memory-runtime/internal/store"
)
func main() {
os.Exit(Execute(os.Args[1:], os.Stdout, os.Stderr))
}
type stringSliceFlag []string
func (s *stringSliceFlag) String() string {
return strings.Join(*s, ",")
}
func (s *stringSliceFlag) Set(value string) error {
*s = append(*s, value)
return nil
}
func runInit(args []string) error {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(commandStderr)
setCommandUsage(fs, "init",
"Create or migrate the SQLite schema for one repo-memory database.",
[]string{
"Run init once before first real use on a new database path.",
"init is safe to rerun when you need to ensure the schema exists before another command.",
},
`repo-memory init --db ~/.codex/data/repo-memory.db`,
)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
st, err := store.Open(*dbPath)
if err != nil {
return err
}
defer st.Close()
if err := st.Init(context.Background()); err != nil {
return err
}
_, _ = fmt.Fprintf(commandStdout, "initialized %s\n", *dbPath)
return nil
}
func runIngest(args []string) error {
fs := flag.NewFlagSet("ingest", flag.ContinueOnError)
fs.SetOutput(commandStderr)
setCommandUsage(fs, "ingest",
"Scan markdown knowledge under one repository and import it into repo-memory.",
[]string{
"ingest expects a repository root and scans markdown under the configured relative path.",
"Use ingest for curated docs; use add when you want to record one specific fact manually.",
},
`repo-memory ingest --db ~/.codex/data/repo-memory.db --repo /path/to/repo --path docs/ai`,
)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
repoPath := fs.String("repo", "", "Repository root")
scanPath := fs.String("path", "docs/ai", "Relative path under repo to scan for markdown")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if strings.TrimSpace(*repoPath) == "" {
return fmt.Errorf("--repo is required")
}
absRepo, err := filepath.Abs(*repoPath)
if err != nil {
return err
}
st, err := store.Open(*dbPath)
if err != nil {
return err
}
defer st.Close()
if err := st.Init(context.Background()); err != nil {
return err
}
gitState := detectGitState(absRepo)
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
RootPath: absRepo,
Name: filepath.Base(absRepo),
VCS: "git",
LastSeenBranch: gitState.branch,
LastSeenCommit: gitState.commit,
}); err != nil {
return err
}
docs, err := documents.LoadRepo(absRepo, *scanPath)
if err != nil {
return err
}
if len(docs) == 0 {
return fmt.Errorf("no markdown files found under %s", filepath.Join(absRepo, *scanPath))
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for _, doc := range docs {
if err := st.ImportDocument(ctx, doc, store.RepoState{
RootPath: absRepo,
Name: filepath.Base(absRepo),
VCS: "git",
LastSeenBranch: gitState.branch,
LastSeenCommit: gitState.commit,
}); err != nil {
return fmt.Errorf("ingest %s: %w", doc.DocPath, err)
}
}
_, _ = fmt.Fprintf(commandStdout, "ingested %d docs from %s\n", len(docs), absRepo)
return nil
}
func runAdd(args []string) error {
fs := flag.NewFlagSet("add", flag.ContinueOnError)
fs.SetOutput(commandStderr)
setCommandUsage(fs, "add",
"Insert or update one durable knowledge entry with evidence, aliases, and dependencies.",
[]string{
"add requires a repository root because entries are anchored to one repo.",
"Prefer confirmed only for well-supported facts; use dependencies and evidence fields whenever possible.",
},
`repo-memory add --db ~/.codex/data/repo-memory.db --repo /path/to/repo --kind term --key AITask --summary "Plan task model" --source-path app/AITask.java --source-line 42 --status confirmed --alias "AI Task" --dep file:app/AITask.java:hard`,
)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
repoPath := fs.String("repo", "", "Repository root")
kind := fs.String("kind", "", "Knowledge kind, e.g. term|chain|danger")
key := fs.String("key", "", "Stable entry key")
title := fs.String("title", "", "Display title")
summary := fs.String("summary", "", "Short summary")
detail := fs.String("detail", "", "Detailed markdown text")
status := fs.String("status", "draft", "Entry status")
confidence := fs.Float64("confidence", 0.7, "Confidence from 0 to 1")
scopeBranch := fs.String("scope-branch", "", "Optional branch scope")
scopeModule := fs.String("scope-module", "", "Optional module scope")
scopePath := fs.String("scope-path-prefix", "", "Optional path prefix scope")
sourcePath := fs.String("source-path", "", "Main evidence path")
sourceLine := fs.Int("source-line", 0, "Main evidence line")
var aliases stringSliceFlag
var deps stringSliceFlag
fs.Var(&aliases, "alias", "Alias for this entry (repeatable)")
fs.Var(&deps, "dep", "Dependency in type:locator[:hard|soft] format (repeatable)")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if strings.TrimSpace(*repoPath) == "" {
return fmt.Errorf("--repo is required")
}
absRepo, err := filepath.Abs(*repoPath)
if err != nil {
return err
}
st, err := store.Open(*dbPath)
if err != nil {
return err
}
defer st.Close()
if err := st.Init(context.Background()); err != nil {
return err
}
gitState := detectGitState(absRepo)
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
RootPath: absRepo,
Name: filepath.Base(absRepo),
VCS: "git",
LastSeenBranch: gitState.branch,
LastSeenCommit: gitState.commit,
}); err != nil {
return err
}
input := store.EntryInput{
RepoRoot: absRepo,
Kind: *kind,
Key: *key,
Title: *title,
Summary: *summary,
DetailMD: *detail,
Status: *status,
Confidence: *confidence,
ScopeBranch: *scopeBranch,
ScopeModule: *scopeModule,
ScopePathPrefix: *scopePath,
SourcePath: relativize(absRepo, *sourcePath),
SourceLine: *sourceLine,
VerifiedAt: time.Now().UTC().Format(time.RFC3339),
VerifiedOnCommit: gitState.commit,
Aliases: aliases,
Dependencies: parseDeps(absRepo, deps),
}
entryID, err := st.UpsertEntry(context.Background(), input)
if err != nil {
return err
}
_, _ = fmt.Fprintf(commandStdout, "upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key)
return nil
}
func runSearch(args []string) error {
fs := flag.NewFlagSet("search", flag.ContinueOnError)
fs.SetOutput(commandStderr)
setCommandUsage(fs, "search",
"Search stored repository knowledge before a deeper code dive.",
[]string{
"search requires a non-empty query string.",
"Use repo filtering when you want to narrow results to one repository name or path fragment.",
},
`repo-memory search --db ~/.codex/data/repo-memory.db --repo zeus --query "actionCode fill" --limit 10`,
)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
query := fs.String("query", "", "Search query")
repo := fs.String("repo", "", "Optional repo path filter (substring match)")
limit := fs.Int("limit", 10, "Result limit")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if strings.TrimSpace(*query) == "" {
return fmt.Errorf("--query is required")
}
st, err := store.Open(*dbPath)
if err != nil {
return err
}
defer st.Close()
results, err := st.Search(context.Background(), store.SearchParams{
Query: *query,
RepoFilter: *repo,
Limit: *limit,
})
if err != nil {
return err
}
if len(results) == 0 {
_, _ = fmt.Fprintln(commandStdout, "no results")
return nil
}
for i, r := range results {
_, _ = fmt.Fprintf(commandStdout, "%d. [%s] %s:%s [%s]\n", i+1, filepath.Base(r.RepoPath), r.Kind, r.Key, r.Status)
_, _ = fmt.Fprintf(commandStdout, " %s\n", r.Title)
_, _ = fmt.Fprintf(commandStdout, " %s\n", r.Snippet)
}
return nil
}
func runRepos(args []string) error {
fs := flag.NewFlagSet("repos", flag.ContinueOnError)
fs.SetOutput(commandStderr)
setCommandUsage(fs, "repos",
"List repositories currently tracked in one repo-memory database.",
[]string{
"repos reads only from the memory database; it does not rescan the filesystem.",
},
`repo-memory repos --db ~/.codex/data/repo-memory.db`,
)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
st, err := store.Open(*dbPath)
if err != nil {
return err
}
defer st.Close()
repos, err := st.ListRepos(context.Background())
if err != nil {
return err
}
if len(repos) == 0 {
_, _ = fmt.Fprintln(commandStdout, "no repos")
return nil
}
for _, repo := range repos {
_, _ = fmt.Fprintf(commandStdout, "- %s (%d entries, updated %s)\n", repo.Path, repo.EntryCount, repo.UpdatedAt.Format(time.RFC3339))
}
return nil
}
func runList(args []string) error {
fs := flag.NewFlagSet("list", flag.ContinueOnError)
fs.SetOutput(commandStderr)
setCommandUsage(fs, "list",
"List entries with optional repo, kind, and status filters.",
[]string{
"list is for broad inspection; use search when you need ranked text matching.",
"Filters are optional and can be combined to narrow the result set.",
},
`repo-memory list --db ~/.codex/data/repo-memory.db --repo zeus --kind term --status confirmed --limit 20`,
)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
repo := fs.String("repo", "", "Optional repo path filter (substring match)")
kind := fs.String("kind", "", "Optional knowledge kind filter")
status := fs.String("status", "", "Optional status filter")
limit := fs.Int("limit", 20, "Result limit")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
st, err := store.Open(*dbPath)
if err != nil {
return err
}
defer st.Close()
items, err := st.ListEntries(context.Background(), store.ListParams{
RepoFilter: *repo,
Kind: *kind,
Status: *status,
Limit: *limit,
})
if err != nil {
return err
}
if len(items) == 0 {
_, _ = fmt.Fprintln(commandStdout, "no entries")
return nil
}
for _, item := range items {
_, _ = fmt.Fprintf(commandStdout, "- #%d [%s] %s:%s [%s]\n", item.ID, filepath.Base(item.RepoPath), item.Kind, item.Key, item.Status)
_, _ = fmt.Fprintf(commandStdout, " %s\n", item.Summary)
}
return nil
}
func runEvents(args []string) error {
fs := flag.NewFlagSet("events", flag.ContinueOnError)
fs.SetOutput(commandStderr)
setCommandUsage(fs, "events",
"Show verification and status-change history for one entry.",
[]string{
"Resolve the entry either by --id or by the combination of --repo, --kind, and --key.",
"events is history-only; it does not modify entries.",
},
`repo-memory events --db ~/.codex/data/repo-memory.db --id 1`,
)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
id := fs.Int64("id", 0, "Entry id")
repo := fs.String("repo", "", "Repo root when resolving by kind/key")
kind := fs.String("kind", "", "Knowledge kind when resolving by kind/key")
key := fs.String("key", "", "Knowledge key when resolving by kind/key")
limit := fs.Int("limit", 20, "Result limit")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
st, err := store.Open(*dbPath)
if err != nil {
return err
}
defer st.Close()
ref, err := buildEntryRef(*id, *repo, *kind, *key)
if err != nil {
return err
}
entry, err := st.ResolveEntry(context.Background(), ref)
if err != nil {
return err
}
events, err := st.ListEvents(context.Background(), entry.ID, *limit)
if err != nil {
return err
}
if len(events) == 0 {
_, _ = fmt.Fprintln(commandStdout, "no events")
return nil
}
_, _ = fmt.Fprintf(commandStdout, "%s:%s [%s] #%d\n", entry.Kind, entry.Key, entry.Status, entry.ID)
for _, ev := range events {
line := fmt.Sprintf("- %s %s", ev.CreatedAt.Format(time.RFC3339), ev.EventType)
if ev.FromStatus != "" || ev.ToStatus != "" {
line += fmt.Sprintf(" (%s -> %s)", emptyDash(ev.FromStatus), emptyDash(ev.ToStatus))
}
_, _ = fmt.Fprintln(commandStdout, line)
if ev.Reason != "" {
_, _ = fmt.Fprintf(commandStdout, " reason: %s\n", ev.Reason)
}
}
return nil
}
func runLink(args []string) error {
fs := flag.NewFlagSet("link", flag.ContinueOnError)
fs.SetOutput(commandStderr)
setCommandUsage(fs, "link",
"Create a relationship edge between two stored entries.",
[]string{
"link expects two existing entry IDs.",
"Use links for durable relationships such as related_to, depends_on, or implements.",
},
`repo-memory link --db ~/.codex/data/repo-memory.db --from-id 1 --to-id 2 --relation related_to`,
)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
fromID := fs.Int64("from-id", 0, "From entry id")
toID := fs.Int64("to-id", 0, "To entry id")
relation := fs.String("relation", "", "Link relation")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
st, err := store.Open(*dbPath)
if err != nil {
return err
}
defer st.Close()
if err := st.AddLink(context.Background(), *fromID, *toID, *relation); err != nil {
return err
}
_, _ = fmt.Fprintf(commandStdout, "linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID)
return nil
}
func runVerify(args []string) error {
fs := flag.NewFlagSet("verify", flag.ContinueOnError)
fs.SetOutput(commandStderr)
setCommandUsage(fs, "verify",
"Re-check stored entries against current repository state and downgrade stale knowledge.",
[]string{
"verify uses current git state to detect changed or missing hard dependencies.",
"Pass --repo to verify one repository; omit it to verify every tracked repository in the database.",
},
`repo-memory verify --db ~/.codex/data/repo-memory.db --repo /path/to/repo`,
)
dbPath := fs.String("db", "repo-memory.db", "SQLite database path")
repo := fs.String("repo", "", "Optional repo root to verify; if omitted, verify all known repos")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
st, err := store.Open(*dbPath)
if err != nil {
return err
}
defer st.Close()
var repoRoots []string
if strings.TrimSpace(*repo) != "" {
absRepo, err := filepath.Abs(*repo)
if err != nil {
return err
}
repoRoots = []string{absRepo}
} else {
repos, err := st.ListRepos(context.Background())
if err != nil {
return err
}
for _, repoInfo := range repos {
repoRoots = append(repoRoots, repoInfo.Path)
}
}
if len(repoRoots) == 0 {
_, _ = fmt.Fprintln(commandStdout, "no repos")
return nil
}
for _, repoRoot := range repoRoots {
gitState := detectGitState(repoRoot)
if gitState.commit == "" {
_, _ = fmt.Fprintf(commandStdout, "%s: skipped (not a git repo or no HEAD)\n", repoRoot)
continue
}
if _, err := st.UpsertRepo(context.Background(), store.RepoState{
RootPath: repoRoot,
Name: filepath.Base(repoRoot),
VCS: "git",
LastSeenBranch: gitState.branch,
LastSeenCommit: gitState.commit,
}); err != nil {
return err
}
candidates, err := st.ListVerifyCandidates(context.Background(), repoRoot)
if err != nil {
return err
}
changedCount := 0
staleCount := 0
for _, candidate := range candidates {
nextStatus, reason, err := verifyCandidate(repoRoot, candidate)
if err != nil {
return fmt.Errorf("verify %s:%s: %w", candidate.Kind, candidate.Key, err)
}
if nextStatus != candidate.Status {
switch nextStatus {
case "needs_review":
changedCount++
case "stale":
staleCount++
}
}
if err := st.ApplyVerificationResult(context.Background(), candidate.ID, gitState.commit, nextStatus, reason); err != nil {
return err
}
}
_, _ = fmt.Fprintf(commandStdout, "%s: verified %d entries, %d downgraded, %d stale\n", repoRoot, len(candidates), changedCount, staleCount)
}
return nil
}
func usage() {
_, _ = fmt.Fprintf(commandStderr, `repo-memory: repo memory CLI
Usage:
repo-memory <command> [flags]
Purpose:
Store durable repository knowledge in SQLite so agents can search prior findings,
ingest curated docs, add confirmed facts, inspect history, and verify stale entries.
Constraints:
- Write durable repository knowledge, not short-lived chat conclusions.
- Prefer search before add so repeated work starts from existing knowledge.
- Use verify when code has moved enough that stored entries may be stale.
Commands:
init Initialize or migrate one SQLite database
ingest Import markdown knowledge from docs under one repo
add Insert or update one durable knowledge entry
search Search stored knowledge by query text
list List entries with optional filters
events Show history for one entry
link Link two entries together
verify Re-check entries against current repo state
repos List tracked repositories
Examples:
repo-memory init --db ~/.codex/data/repo-memory.db
repo-memory search --db ~/.codex/data/repo-memory.db --repo zeus --query "router auth"
repo-memory add --db ~/.codex/data/repo-memory.db --repo /path/to/repo --kind term --key AuthRouter --summary "..."
repo-memory verify --db ~/.codex/data/repo-memory.db --repo /path/to/repo
More help:
repo-memory --help
repo-memory help add
repo-memory add --help
`)
}
func setCommandUsage(fs *flag.FlagSet, name, summary string, constraints []string, example string) {
fs.Usage = func() {
_, _ = fmt.Fprintf(commandStderr, "repo-memory %s\n\n", name)
_, _ = fmt.Fprintf(commandStderr, "Usage:\n repo-memory %s [flags]\n\n", name)
_, _ = fmt.Fprintf(commandStderr, "Purpose:\n %s\n\n", summary)
if len(constraints) > 0 {
_, _ = fmt.Fprintln(commandStderr, "Constraints:")
for _, constraint := range constraints {
if strings.TrimSpace(constraint) == "" {
continue
}
_, _ = fmt.Fprintf(commandStderr, " - %s\n", constraint)
}
_, _ = fmt.Fprintln(commandStderr)
}
if strings.TrimSpace(example) != "" {
_, _ = fmt.Fprintf(commandStderr, "Example:\n %s\n\n", example)
}
_, _ = fmt.Fprintln(commandStderr, "Flags:")
fs.PrintDefaults()
}
}
type gitState struct {
branch string
commit string
}
func detectGitState(repo string) gitState {
return gitState{
branch: strings.TrimSpace(run(repo, "git", "-C", repo, "rev-parse", "--abbrev-ref", "HEAD")),
commit: strings.TrimSpace(run(repo, "git", "-C", repo, "rev-parse", "HEAD")),
}
}
func run(dir string, name string, args ...string) string {
cmd := exec.Command(name, args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return ""
}
return string(out)
}
func parseDeps(repoRoot string, values []string) []store.DependencyInput {
var deps []store.DependencyInput
for _, raw := range values {
parts := strings.Split(raw, ":")
if len(parts) < 2 {
continue
}
dep := store.DependencyInput{
Type: strings.TrimSpace(parts[0]),
Locator: strings.TrimSpace(parts[1]),
IsHard: true,
}
if dep.Type == "file" || dep.Type == "dir" || dep.Type == "glob" {
dep.Locator = relativize(repoRoot, dep.Locator)
}
if len(parts) >= 3 {
mode := strings.ToLower(strings.TrimSpace(parts[2]))
if mode == "soft" {
dep.IsHard = false
}
}
if dep.Type != "" && dep.Locator != "" {
deps = append(deps, dep)
}
}
return deps
}
func relativize(repoRoot, maybePath string) string {
if strings.TrimSpace(maybePath) == "" {
return ""
}
if !filepath.IsAbs(maybePath) {
return filepath.ToSlash(maybePath)
}
rel, err := filepath.Rel(repoRoot, maybePath)
if err != nil {
return filepath.ToSlash(maybePath)
}
if strings.HasPrefix(rel, "..") {
return filepath.ToSlash(maybePath)
}
return filepath.ToSlash(rel)
}
func buildEntryRef(id int64, repo, kind, key string) (store.EntryRef, error) {
if id > 0 {
return store.EntryRef{ID: id}, nil
}
if strings.TrimSpace(repo) == "" || strings.TrimSpace(kind) == "" || strings.TrimSpace(key) == "" {
return store.EntryRef{}, fmt.Errorf("either --id or --repo+--kind+--key is required")
}
absRepo, err := filepath.Abs(repo)
if err != nil {
return store.EntryRef{}, err
}
return store.EntryRef{RepoRoot: absRepo, Kind: kind, Key: key}, nil
}
func verifyCandidate(repoRoot string, candidate store.VerifyCandidate) (string, string, error) {
if strings.TrimSpace(candidate.VerifiedOnCommit) == "" {
return "needs_review", "missing verified_on_commit", nil
}
var paths []string
for _, dep := range candidate.Dependencies {
if !dep.IsHard {
continue
}
switch dep.Type {
case "file", "dir", "glob":
paths = append(paths, dep.Locator)
if dep.Type == "file" {
absPath := dep.Locator
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(repoRoot, dep.Locator)
}
if _, err := os.Stat(absPath); err != nil {
if os.IsNotExist(err) {
return "stale", "hard dependency missing: " + dep.Locator, nil
}
return candidate.Status, "", err
}
}
}
}
if len(paths) == 0 {
return candidate.Status, "", nil
}
changed, err := hasCommittedChanges(repoRoot, candidate.VerifiedOnCommit, paths)
if err != nil {
return candidate.Status, "", err
}
dirty, err := hasWorkingTreeChanges(repoRoot, paths)
if err != nil {
return candidate.Status, "", err
}
if changed || dirty {
return "needs_review", "dependency paths changed since verification", nil
}
return candidate.Status, "", nil
}
func hasCommittedChanges(repoRoot, fromCommit string, paths []string) (bool, error) {
args := append([]string{"-C", repoRoot, "diff", "--name-only", fromCommit + "..HEAD", "--"}, paths...)
out, err := exec.Command("git", args...).Output()
if err != nil {
return false, err
}
return strings.TrimSpace(string(out)) != "", nil
}
func hasWorkingTreeChanges(repoRoot string, paths []string) (bool, error) {
args := append([]string{"-C", repoRoot, "status", "--porcelain", "--"}, paths...)
out, err := exec.Command("git", args...).Output()
if err != nil {
return false, err
}
return strings.TrimSpace(string(out)) != "", nil
}
func emptyDash(value string) string {
if strings.TrimSpace(value) == "" {
return "-"
}
return value
}