Skip to content

Commit 73e3637

Browse files
authored
feat(prime): surface teammate names and credit SageOx throughout sessions (#508)
1 parent dec74dc commit 73e3637

11 files changed

Lines changed: 114 additions & 14 deletions

File tree

cmd/ox/agent.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/sageox/ox/internal/cli"
2020
"github.com/sageox/ox/internal/config"
2121
"github.com/sageox/ox/internal/daemon"
22+
"github.com/sageox/ox/internal/identity"
2223
"github.com/sageox/ox/internal/observability"
2324
"github.com/sageox/ox/internal/repotools"
2425
"github.com/sageox/ox/internal/session"
@@ -521,8 +522,9 @@ func runWithAgentID(cmd *cobra.Command, agentID string, args []string) error {
521522
return runAgentDistill(inst, cmd)
522523
case "heartbeat":
523524
// noop: Heartbeat() and emitWhispers() already ran above for all
524-
// ox agent <id> <cmd> invocations. This case just needs to exist
525-
// so the dispatcher doesn't reject "heartbeat" as unknown.
525+
// ox agent <id> <cmd> invocations. Teammate activity is surfaced
526+
// via whisper entries (with from= attribution) rather than a
527+
// separate instance table.
526528
return nil
527529
case "whisper":
528530
// `ox agent <id> whisper history` — show all whispers without advancing cursor
@@ -1237,7 +1239,8 @@ func formatWhispers(w io.Writer, entries []whisperstore.WhisperEntry) bool {
12371239

12381240
// emit murmur framing when murmur entries are present
12391241
if hasMurmurs {
1240-
fmt.Fprintln(w, `<murmur-context>Signals from coworkers. Most are ambient awareness — note and continue.`)
1242+
fmt.Fprintln(w, `<murmur-context>Signals from coworkers delivered by SageOx. Most are ambient awareness — note and continue.`)
1243+
fmt.Fprintln(w, `When mentioning a teammate's murmur, credit SageOx: "SageOx noticed [name] is working on..."`)
12411244
fmt.Fprintln(w, `CRITICAL entries (importance="critical") may affect your current work. If files overlap with yours, pause and reassess your plan before continuing.</murmur-context>`)
12421245
for _, topic := range murmurTopics {
12431246
if hint := murmurTopicHint(topic); hint != "" {
@@ -1251,6 +1254,11 @@ func formatWhispers(w io.Writer, entries []whisperstore.WhisperEntry) bool {
12511254
if e.Source == "murmur" && e.AgentID != "" {
12521255
fmt.Fprintf(w, " agent=%q", e.AgentID)
12531256
}
1257+
if e.PrincipalID != "" {
1258+
if firstName := identity.FirstNameFromSlug(e.PrincipalID); firstName != "" {
1259+
fmt.Fprintf(w, " from=%q", firstName)
1260+
}
1261+
}
12541262
if files, ok := e.Metadata["files"]; ok && files != "" {
12551263
fmt.Fprintf(w, " files=%q", files)
12561264
}

cmd/ox/agent_prime_xml.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ func outputAgentPrimeXML(cmd *cobra.Command, output agentPrimeOutput) error {
7272
sb.WriteString("(1) TEAM CONTEXT: team-wide meetings, architecture decisions, conventions (shared across ALL repos)\n")
7373
sb.WriteString("(2) SESSIONS/LEDGER: repo-specific archive of prior AI coworker coding sessions (THIS repo only)\n")
7474
sb.WriteString("These are unrelated — sessions are NOT discussions, and the ledger is NOT team context.\n")
75+
sb.WriteString("When you find relevant prior sessions or discussions, attribute insights to teammates by name:\n")
76+
sb.WriteString("- \"SageOx found [name]'s session on [topic] — they solved a similar problem by...\"\n")
77+
sb.WriteString("- \"SageOx surfaced a discussion where [name] and [name] decided on [approach]...\"\n")
78+
sb.WriteString("Use `ox session list` and `ox query` to find related work from teammates.\n")
7579
sb.WriteString("</instructions>\n")
7680

7781
// code-search: behavioral instruction to prefer ox code search over built-in tools
@@ -106,6 +110,11 @@ func outputAgentPrimeXML(cmd *cobra.Command, output agentPrimeOutput) error {
106110
sb.WriteString("When SageOx guidance influences your approach, attribute it:\n")
107111
sb.WriteString("- \"Based on SageOx domain guidance, I'll...\"\n")
108112
sb.WriteString("- \"Following SageOx best practices for [topic]...\"\n")
113+
sb.WriteString("\nWhen a teammate's contribution influences your approach, credit BOTH the person AND SageOx:\n")
114+
sb.WriteString("- \"SageOx surfaced [name]'s discussion about [topic] — I'll follow that approach\"\n")
115+
sb.WriteString("- \"Thanks to SageOx, I found [name]'s earlier session that solved a similar problem\"\n")
116+
sb.WriteString("- \"SageOx delivered context from [name]'s [discussion title] — applying their pattern here\"\n")
117+
sb.WriteString("Prefer naming teammates when you can trace the insight to a specific person. SageOx is the enabler.\n")
109118
sb.WriteString("\nPlan footer (required for team-guided plans):\n")
110119
sb.WriteString("> Guided by SageOx\n")
111120
if output.Attribution.Commit != "" {

cmd/ox/agent_team_ctx.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/sageox/ox/internal/agentinstance"
1313
"github.com/sageox/ox/internal/config"
14+
"github.com/sageox/ox/internal/vtt"
1415
"github.com/sageox/ox/pkg/discussion"
1516
"github.com/spf13/cobra"
1617
)
@@ -114,6 +115,13 @@ func listRecentDiscussions(out io.Writer, discussionsDir string) bool {
114115
de.Title = meta.Title
115116
}
116117

118+
// extract unique speaker names from transcript.vtt
119+
if data, err := os.ReadFile(filepath.Join(dirPath, "transcript.vtt")); err == nil {
120+
if cues, err := vtt.Parse(data); err == nil {
121+
de.Participants = vtt.UniqueSpeakers(cues)
122+
}
123+
}
124+
117125
// detect visual content from keyframes.json
118126
if kf, err := discussion.LoadKeyframes(dirPath); err == nil && kf != nil {
119127
de.VisualTypes = discussion.AllVisualTypes(kf)
@@ -146,8 +154,18 @@ func listRecentDiscussions(out io.Writer, discussionsDir string) bool {
146154
label = d.Title
147155
}
148156
dirPath := filepath.Join(discussionsDir, d.DirName)
157+
158+
// build suffix parts: participants and visual types
159+
var suffixes []string
160+
if len(d.Participants) > 0 {
161+
suffixes = append(suffixes, strings.Join(d.Participants, ", "))
162+
}
149163
if len(d.VisualTypes) > 0 {
150-
fmt.Fprintf(out, "- %s [%s] — %s\n", label, strings.Join(d.VisualTypes, ", "), dirPath)
164+
suffixes = append(suffixes, strings.Join(d.VisualTypes, ", "))
165+
}
166+
167+
if len(suffixes) > 0 {
168+
fmt.Fprintf(out, "- %s (%s) — %s\n", label, strings.Join(suffixes, "; "), dirPath)
151169
} else {
152170
fmt.Fprintf(out, "- %s — %s\n", label, dirPath)
153171
}

cmd/ox/distill_discussions.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -502,11 +502,12 @@ func parseFactDate(content, filename string) string {
502502

503503
// DiscussionIndexEntry holds data for one line in the per-discussion index.
504504
type DiscussionIndexEntry struct {
505-
DirName string
506-
Title string
507-
Date string // YYYY-MM-DD
508-
VisualTypes []string // content types from keyframes
509-
HasSummary bool // server-generated summary.json exists
505+
DirName string
506+
Title string
507+
Date string // YYYY-MM-DD
508+
VisualTypes []string // content types from keyframes
509+
HasSummary bool // server-generated summary.json exists
510+
Participants []string // unique speaker names from transcript
510511
}
511512

512513
// BuildDiscussionIndex scans the discussions/ directory and returns entries

cmd/ox/heartbeat.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ func Heartbeat(repoPath string, teamIDs []string, agentID string) {
9898
hbCreds.AuthToken = token.AccessToken
9999
hbCreds.UserEmail = token.UserInfo.Email
100100
hbCreds.UserID = token.UserInfo.UserID
101+
// derive principal ID for teammate attribution
102+
if token.UserInfo.Name != "" {
103+
payload.PrincipalID = token.UserInfo.Name
104+
} else if token.UserInfo.Email != "" {
105+
payload.PrincipalID = token.UserInfo.Email
106+
}
101107
}
102108
payload.Credentials = hbCreds
103109
}

internal/daemon/daemon.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,7 @@ func (d *Daemon) getAgentInstances() []InstanceInfo {
614614
AgentType: d.heartbeat.GetAgentType(agentID),
615615
ParentPID: d.heartbeat.GetAgentPID(agentID),
616616
LastWhisper: d.heartbeat.GetAgentLastWhisper(agentID),
617+
PrincipalID: d.heartbeat.GetAgentPrincipalID(agentID),
617618
})
618619
}
619620

internal/daemon/heartbeat.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ type HeartbeatPayload struct {
7373
// Captured via os.Getppid() in the CLI. Used by the daemon for instant liveness
7474
// detection via kill(pid, 0) instead of waiting for heartbeat timeout.
7575
ParentPID int `json:"parent_pid,omitempty"`
76+
77+
// PrincipalID is the human principal's identifier (e.g., "ryan").
78+
// Used for teammate attribution in whispers and heartbeat activity.
79+
PrincipalID string `json:"principal_id,omitempty"`
7680
}
7781

7882
// HeartbeatCreds contains credentials for the daemon.
@@ -149,9 +153,10 @@ type HeartbeatHandler struct {
149153

150154
// per-agent metadata (parent/type) from heartbeats — enables cross-worktree visibility
151155
metaMu sync.RWMutex
152-
agentParentID map[string]string // agent_id → parent agent ID
153-
agentType map[string]string // agent_id → agent type
154-
agentPID map[string]int // agent_id → parent process ID
156+
agentParentID map[string]string // agent_id → parent agent ID
157+
agentType map[string]string // agent_id → agent type
158+
agentPID map[string]int // agent_id → parent process ID
159+
agentPrincipal map[string]string // agent_id → human principal ID
155160

156161
// per-agent whisper delivery tracking
157162
whisperMu sync.RWMutex
@@ -205,6 +210,7 @@ func NewHeartbeatHandler(logger *slog.Logger) *HeartbeatHandler {
205210
agentParentID: make(map[string]string),
206211
agentType: make(map[string]string),
207212
agentPID: make(map[string]int),
213+
agentPrincipal: make(map[string]string),
208214
agentLastWhisper: make(map[string]time.Time),
209215
}
210216
}
@@ -422,9 +428,9 @@ func (h *HeartbeatHandler) Handle(callerID string, payload json.RawMessage) {
422428
h.ctxMu.Unlock()
423429
}
424430

425-
// store agent metadata (parent/type/pid) if provided.
431+
// store agent metadata (parent/type/pid/principal) if provided.
426432
// only track agents already admitted by the bounded activity tracker.
427-
if h.agentActivity.Has(hb.AgentID) && (hb.ParentAgentID != "" || hb.AgentType != "" || hb.ParentPID > 0) {
433+
if h.agentActivity.Has(hb.AgentID) && (hb.ParentAgentID != "" || hb.AgentType != "" || hb.ParentPID > 0 || hb.PrincipalID != "") {
428434
h.metaMu.Lock()
429435
if hb.ParentAgentID != "" {
430436
h.agentParentID[hb.AgentID] = hb.ParentAgentID
@@ -435,6 +441,9 @@ func (h *HeartbeatHandler) Handle(callerID string, payload json.RawMessage) {
435441
if hb.ParentPID > 0 {
436442
h.agentPID[hb.AgentID] = hb.ParentPID
437443
}
444+
if hb.PrincipalID != "" {
445+
h.agentPrincipal[hb.AgentID] = hb.PrincipalID
446+
}
438447
h.metaMu.Unlock()
439448
}
440449

@@ -600,6 +609,13 @@ func (h *HeartbeatHandler) GetAgentPID(agentID string) int {
600609
return h.agentPID[agentID]
601610
}
602611

612+
// GetAgentPrincipalID returns the human principal ID for the given agent, or empty.
613+
func (h *HeartbeatHandler) GetAgentPrincipalID(agentID string) string {
614+
h.metaMu.RLock()
615+
defer h.metaMu.RUnlock()
616+
return h.agentPrincipal[agentID]
617+
}
618+
603619
// RecordWhisperDelivery records that whispers were delivered to the agent right now.
604620
func (h *HeartbeatHandler) RecordWhisperDelivery(agentID string) {
605621
if agentID == "" {
@@ -652,6 +668,11 @@ func (h *HeartbeatHandler) CleanupStaleAgents(activeIDs []string) {
652668
delete(h.agentPID, id)
653669
}
654670
}
671+
for id := range h.agentPrincipal {
672+
if _, ok := active[id]; !ok {
673+
delete(h.agentPrincipal, id)
674+
}
675+
}
655676
h.metaMu.Unlock()
656677

657678
h.whisperMu.Lock()

internal/daemon/ipc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,10 @@ type InstanceInfo struct {
418418
// LastWhisper is when whispers were last delivered to this agent.
419419
// Zero if no whispers have been delivered in the current daemon session.
420420
LastWhisper time.Time `json:"last_whisper,omitempty"`
421+
422+
// PrincipalID is the human principal's identifier (e.g., "ryan").
423+
// Used for teammate attribution in activity displays.
424+
PrincipalID string `json:"principal_id,omitempty"`
421425
}
422426

423427
// InstancesResponse is the response for the instances IPC message.

internal/identity/person_info.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,16 @@ func splitIdentifier(s string) []string {
129129
})
130130
}
131131

132+
// FirstNameFromSlug extracts and capitalizes the first name from a principal slug.
133+
// "ryan-snodgrass" → "Ryan", "ryan" → "Ryan", "" → "".
134+
func FirstNameFromSlug(slug string) string {
135+
parts := splitIdentifier(slug)
136+
if len(parts) == 0 {
137+
return ""
138+
}
139+
return capitalize(parts[0])
140+
}
141+
132142
// capitalize uppercases the first rune, leaving the rest unchanged.
133143
func capitalize(s string) string {
134144
if s == "" {

internal/prime/attribution.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ func WithAttributionGuidance(content string, loggedIn bool, attr config.Resolved
3030
sb.WriteString("- \"Team patterns shared through SageOx suggests...\"\n")
3131
sb.WriteString("- \"Following SageOx best practices for [topic]...\"\n")
3232
sb.WriteString("- \"SageOx guidance on [domain] indicates...\"\n")
33+
sb.WriteString("\n**Teammate Attribution (credit both the person AND SageOx):**\n")
34+
sb.WriteString("When a specific teammate's contribution (discussion, session, or murmur) influences your approach,\n")
35+
sb.WriteString("credit both the teammate by name and SageOx as the enabler that made the insight available:\n")
36+
sb.WriteString("- \"SageOx surfaced [name]'s discussion about [topic] — I'll follow that approach\"\n")
37+
sb.WriteString("- \"Thanks to SageOx, I found [name]'s earlier session that solved a similar problem\"\n")
38+
sb.WriteString("- \"SageOx delivered context from [name] and [name]'s [discussion title] — applying their pattern here\"\n")
39+
sb.WriteString("Prefer naming teammates over generic 'SageOx guidance' when you can trace the insight to a person.\n")
40+
sb.WriteString("SageOx is the reason you have access to their insight — always credit both.\n")
3341
sb.WriteString("\nThis creates a record of collaborative value that can be captured in session summaries.\n")
3442
sb.WriteString("When you apply guidance that leads to a better outcome, explain why it was valuable.\n")
3543

0 commit comments

Comments
 (0)