Skip to content
Closed
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
223 changes: 223 additions & 0 deletions cmd/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package cmd

import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"

"codemap/config"
"codemap/handoff"
"codemap/scanner"
"codemap/skills"
"codemap/watch"
)

// ContextEnvelope is the standardized output format that any AI tool can consume.
type ContextEnvelope struct {
Version int `json:"version"`
GeneratedAt time.Time `json:"generated_at"`
Project ProjectContext `json:"project"`
Intent *TaskIntent `json:"intent,omitempty"`
WorkingSet *WorkingSetContext `json:"working_set,omitempty"`
Skills []SkillRef `json:"skills,omitempty"`
Handoff *HandoffRef `json:"handoff,omitempty"`
Budget BudgetInfo `json:"budget"`
}

// ProjectContext contains high-level project metadata.
type ProjectContext struct {
Root string `json:"root"`
Branch string `json:"branch"`
FileCount int `json:"file_count"`
Languages []string `json:"languages"`
HubCount int `json:"hub_count"`
TopHubs []string `json:"top_hubs,omitempty"`
}

// WorkingSetContext is a summary of the current working set.
type WorkingSetContext struct {
FileCount int `json:"file_count"`
HubCount int `json:"hub_count"`
TopFiles []WorkingFileContext `json:"top_files,omitempty"`
}

// WorkingFileContext is a single file in the working set summary.
type WorkingFileContext struct {
Path string `json:"path"`
EditCount int `json:"edit_count"`
NetDelta int `json:"net_delta"`
IsHub bool `json:"is_hub,omitempty"`
}

// SkillRef is a lightweight reference to a matched skill.
type SkillRef struct {
Name string `json:"name"`
Score int `json:"score"`
Reason string `json:"reason,omitempty"`
}

// HandoffRef points to the latest handoff artifact.
type HandoffRef struct {
Path string `json:"path"`
GeneratedAt time.Time `json:"generated_at,omitempty"`
ChangedFiles int `json:"changed_files"`
RiskFiles int `json:"risk_files"`
}

// BudgetInfo reports how much context was generated.
type BudgetInfo struct {
TotalBytes int `json:"total_bytes"`
Compact bool `json:"compact"`
}

// RunContext handles the "codemap context" subcommand.
func RunContext(args []string, root string) {
fs := flag.NewFlagSet("context", flag.ExitOnError)
forPrompt := fs.String("for", "", "Pre-classify intent for this prompt")
compact := fs.Bool("compact", false, "Minimal output for token-constrained agents")
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
Comment on lines +78 to +84
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RunContext uses flag.NewFlagSet(..., flag.ExitOnError) but then checks if err := fs.Parse(args); err != nil { ... }. With ExitOnError, Parse will call os.Exit(2) on error, so this error-handling block is effectively unreachable. Consider switching to flag.ContinueOnError (and printing a concise usage), or keep ExitOnError and remove the redundant err check.

Also consider rejecting extra positional args (e.g. fs.NArg() > 1) instead of silently ignoring them.

Copilot uses AI. Check for mistakes.

if fs.NArg() > 0 {
root = fs.Arg(0)
}

absRoot, err := filepath.Abs(root)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

envelope := buildContextEnvelope(absRoot, *forPrompt, *compact)

enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(envelope); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding context: %v\n", err)
os.Exit(1)
}
}

func buildContextEnvelope(root, prompt string, compact bool) ContextEnvelope {
projCfg := config.Load(root)
info := getHubInfoNoFallback(root)

envelope := ContextEnvelope{
Version: 1,
GeneratedAt: time.Now(),
Project: buildProjectContext(root, info),
Budget: BudgetInfo{Compact: compact},
}

Comment on lines +106 to +116
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New codemap context output paths/filters and compact vs full behavior aren't covered by tests in cmd (this package already has substantial test coverage). Consider adding unit tests around buildContextEnvelope to lock down: (1) compact omits skills/handoff, (2) intent classification is included when --for is provided, and (3) output fields like Languages are stable.

Copilot uses AI. Check for mistakes.
// Intent classification (if prompt provided)
if prompt != "" {
topK := projCfg.RoutingTopKOrDefault()
files := extractMentionedFiles(prompt, topK)
intent := classifyIntent(prompt, files, info, projCfg)
envelope.Intent = &intent

// Match skills against intent
if !compact {
if idx, err := skills.LoadSkills(root); err == nil && idx != nil {
var langs []string
for _, f := range files {
if lang := scanner.DetectLanguage(f); lang != "" {
langs = append(langs, lang)
}
}
matches := idx.MatchSkills(intent.Category, files, langs, 3)
for _, m := range matches {
envelope.Skills = append(envelope.Skills, SkillRef{
Name: m.Skill.Meta.Name,
Score: m.Score,
Reason: m.Reason,
})
}
}
}
}

// Working set from daemon
if state := watch.ReadState(root); state != nil && state.WorkingSet != nil {
ws := state.WorkingSet
wsCtx := &WorkingSetContext{
FileCount: ws.Size(),
HubCount: ws.HubCount(),
}
limit := 10
if compact {
limit = 3
}
for _, wf := range ws.HotFiles(limit) {
wsCtx.TopFiles = append(wsCtx.TopFiles, WorkingFileContext{
Path: wf.Path,
EditCount: wf.EditCount,
NetDelta: wf.NetDelta,
IsHub: wf.IsHub,
})
}
envelope.WorkingSet = wsCtx
}

// Handoff reference
if !compact {
if artifact, err := handoff.ReadLatest(root); err == nil && artifact != nil {
ref := &HandoffRef{
Path: handoff.LatestPath(root),
GeneratedAt: artifact.GeneratedAt,
}
ref.ChangedFiles = len(artifact.Delta.Changed)
ref.RiskFiles = len(artifact.Delta.RiskFiles)
envelope.Handoff = ref
}
}

// Calculate budget
data, _ := json.Marshal(envelope)
envelope.Budget.TotalBytes = len(data)

Comment on lines +180 to +183
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Budget.TotalBytes is computed via json.Marshal(envelope) (no indentation), but RunContext outputs the envelope using an indented encoder. That makes total_bytes systematically under-report the actual bytes written to stdout. Consider computing the budget from the same encoding format you output (e.g., encode into a bytes.Buffer with the same indent settings), and handle the marshal/encode error instead of ignoring it.

Copilot uses AI. Check for mistakes.
return envelope
}

func buildProjectContext(root string, info *hubInfo) ProjectContext {
ctx := ProjectContext{
Root: root,
}

// Get branch
if branch, ok := gitCurrentBranch(root); ok {
ctx.Branch = branch
}

// Count files and detect languages from daemon state
if state := watch.ReadState(root); state != nil {
ctx.FileCount = state.FileCount
ctx.HubCount = len(state.Hubs)
if len(state.Hubs) > 5 {
ctx.TopHubs = state.Hubs[:5]
} else {
ctx.TopHubs = state.Hubs
}
}

// Detect languages from file extensions
langSet := make(map[string]bool)
if info != nil {
for file := range info.Importers {
if lang := scanner.DetectLanguage(file); lang != "" {
langSet[lang] = true
Comment on lines +211 to +213
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive project languages from more than importer keys

Language detection iterates only over info.Importers keys, but that map only contains files that are imported by at least one other file. Repos with entrypoints/scripts or sparse dependency edges (for example a single-file project) will report languages: [] even when source files exist, making the context envelope inaccurate for downstream routing and tool selection.

Useful? React with 👍 / 👎.

}
}
}
for lang := range langSet {
ctx.Languages = append(ctx.Languages, lang)
}
Comment on lines +208 to +219
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Languages is built by ranging over a map (langSet), which yields a nondeterministic order. Since this command is meant to be consumed by tools, unstable ordering can cause noisy diffs/caches and make tests flaky. Consider sorting ctx.Languages before returning.

Copilot uses AI. Check for mistakes.

return ctx
}

53 changes: 53 additions & 0 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -1164,9 +1164,62 @@ func writeSessionHandoff(root string, state *watch.State) error {
if err != nil {
return err
}

// Record this agent session in handoff history
agentEntry := handoff.AgentEntry{
AgentID: detectAgentID(),
StartedAt: sessionStartTime(state),
EndedAt: time.Now(),
}
if state != nil {
seen := make(map[string]bool)
for _, e := range state.RecentEvents {
if !seen[e.Path] {
seen[e.Path] = true
agentEntry.FilesEdited = append(agentEntry.FilesEdited, e.Path)
}
}
}

// Carry over history from previous artifact and append current session
if prev, err := handoff.ReadLatest(root); err == nil && prev != nil {
artifact.AgentHistory = prev.AgentHistory
}
artifact.AgentHistory = append(artifact.AgentHistory, agentEntry)

// Cap history to last 20 entries
if len(artifact.AgentHistory) > 20 {
artifact.AgentHistory = artifact.AgentHistory[len(artifact.AgentHistory)-20:]
}
Comment on lines +1168 to +1193
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent history recording/capping logic in writeSessionHandoff is new behavior but doesn’t appear to have test coverage (the cmd package has extensive hook tests). Consider adding tests that verify: (1) agent ID detection from env vars, (2) FilesEdited deduping from RecentEvents, and (3) history is capped to the last 20 entries when prior artifacts already contain history.

Copilot uses AI. Check for mistakes.

return handoff.WriteLatest(root, artifact)
}

// detectAgentID returns the current AI agent based on environment signals.
func detectAgentID() string {
// Claude Code sets CLAUDE_CODE=1
if os.Getenv("CLAUDE_CODE") == "1" || os.Getenv("CLAUDE_CODE_ENTRYPOINT") != "" {
return "claude-code"
}
// Codex sets CODEX=1
if os.Getenv("CODEX") == "1" {
return "codex"
}
// Cursor sets CURSOR=1
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Cursor sets CURSOR=1", but the detection logic actually checks CURSOR_SESSION_ID. Please update the comment to match the environment signal being used (or update the detection if CURSOR=1 is the intended contract).

Suggested change
// Cursor sets CURSOR=1
// Cursor sets CURSOR_SESSION_ID

Copilot uses AI. Check for mistakes.
if os.Getenv("CURSOR_SESSION_ID") != "" {
return "cursor"
}
return "unknown"
}

// sessionStartTime estimates when this session started from daemon events.
func sessionStartTime(state *watch.State) time.Time {
if state != nil && len(state.RecentEvents) > 0 {
return state.RecentEvents[0].Time
}
return time.Now()
}

func resolveHandoffBaseRef(root string) string {
if remoteDefault, ok := gitSymbolicRef(root, "refs/remotes/origin/HEAD"); ok && remoteDefault != "" {
if gitRefExists(root, remoteDefault) {
Expand Down
Loading
Loading