-
Notifications
You must be signed in to change notification settings - Fork 47
feat: context protocol, HTTP API, and agent-aware handoff #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
|
|
||
| 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
|
||
| // 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
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Language detection iterates only over Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
| } | ||
| for lang := range langSet { | ||
| ctx.Languages = append(ctx.Languages, lang) | ||
| } | ||
|
Comment on lines
+208
to
+219
|
||
|
|
||
| return ctx | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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
|
||||||
|
|
||||||
| 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 | ||||||
|
||||||
| // Cursor sets CURSOR=1 | |
| // Cursor sets CURSOR_SESSION_ID |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RunContextusesflag.NewFlagSet(..., flag.ExitOnError)but then checksif err := fs.Parse(args); err != nil { ... }. WithExitOnError,Parsewill callos.Exit(2)on error, so this error-handling block is effectively unreachable. Consider switching toflag.ContinueOnError(and printing a concise usage), or keepExitOnErrorand remove the redundanterrcheck.Also consider rejecting extra positional args (e.g.
fs.NArg() > 1) instead of silently ignoring them.