Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

864 lines
21 KiB
Go

package parser
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// vscodeCopilotSession is the top-level JSON structure of a
// VSCode Copilot chat session file (chatSessions/<uuid>.json).
type vscodeCopilotSession struct {
Version int `json:"version"`
SessionID string `json:"sessionId"`
CreationDate jsonMillis `json:"creationDate"`
LastMessageDate jsonMillis `json:"lastMessageDate"`
CustomTitle string `json:"customTitle"`
Requests []vscodeCopilotRequest `json:"requests"`
}
// jsonMillis is a unix-millisecond timestamp that
// unmarshals from a JSON number.
type jsonMillis int64
func (m jsonMillis) Time() time.Time {
if m == 0 {
return time.Time{}
}
return time.UnixMilli(int64(m))
}
// vscodeCopilotRequest is one turn (user prompt + response).
type vscodeCopilotRequest struct {
RequestID string `json:"requestId"`
Message vscodeCopilotMessage `json:"message"`
Response []json.RawMessage `json:"response"`
Agent *vscodeCopilotAgent `json:"agent,omitempty"`
ModelID string `json:"modelId"`
Timestamp jsonMillis `json:"timestamp"`
Result *vscodeCopilotResult `json:"result,omitempty"`
FollowUps []json.RawMessage `json:"followups,omitempty"`
}
// vscodeCopilotMessage is the user prompt.
type vscodeCopilotMessage struct {
Text string `json:"text"`
Parts json.RawMessage `json:"parts,omitempty"`
}
// vscodeCopilotAgent identifies the agent that handled the request.
type vscodeCopilotAgent struct {
ID string `json:"id"`
Name string `json:"name"`
FullName string `json:"fullName"`
ExtensionID json.RawMessage `json:"extensionId,omitempty"`
}
// vscodeCopilotResult holds timing and metadata.
type vscodeCopilotResult struct {
Timings *vscodeCopilotTimings `json:"timings,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
// vscodeCopilotMetadata holds the per-request token accounting
// found in result.metadata. VSCode Copilot records the full
// prompt size (promptTokens, cumulative context for that turn)
// and the generated output (outputTokens), plus the resolved
// model id already in pricing-catalog form (e.g. "claude-opus-4-8").
type vscodeCopilotMetadata struct {
PromptTokens int `json:"promptTokens"`
OutputTokens int `json:"outputTokens"`
ResolvedModel string `json:"resolvedModel"`
}
type vscodeCopilotTimings struct {
FirstProgress int64 `json:"firstProgress"`
TotalElapsed int64 `json:"totalElapsed"`
}
// vscodeCopilotResponseItem is a single element of the
// response array, with flexible typing.
type vscodeCopilotResponseItem struct {
Kind string `json:"kind,omitempty"`
Value string `json:"value,omitempty"`
ToolID string `json:"toolId,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
IsConfirmed bool `json:"isConfirmed,omitempty"`
IsComplete bool `json:"isComplete,omitempty"`
InvocationMessage json.RawMessage `json:"invocationMessage,omitempty"`
PastTenseMessage json.RawMessage `json:"pastTenseMessage,omitempty"`
ToolName string `json:"toolName,omitempty"`
InlineReference json.RawMessage `json:"inlineReference,omitempty"`
ToolSpecificData json.RawMessage `json:"toolSpecificData,omitempty"`
}
// vscodeCopilotToolData holds terminal-specific tool data.
type vscodeCopilotToolData struct {
Kind string `json:"kind"`
Language string `json:"language,omitempty"`
Command string `json:"command,omitempty"`
}
// vscodeCopilotInvocationMsg holds a structured invocation message.
type vscodeCopilotInvocationMsg struct {
Value string `json:"value"`
}
// vscodeCopilotWorkspace holds the workspace.json manifest.
type vscodeCopilotWorkspace struct {
Folder string `json:"folder"`
Workspace string `json:"workspace"`
}
// parseSession parses a VSCode Copilot chat session file (.json or .jsonl).
// Returns (nil, nil, nil) if the file is empty or contains no meaningful
// content.
func (p *vscodeCopilotProvider) parseSession(
path, project, machine string,
) (*ParsedSession, []ParsedMessage, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, nil
}
return nil, nil, fmt.Errorf("stat %s: %w", path, err)
}
var data []byte
if strings.HasSuffix(path, ".jsonl") {
data, err = reconstructJSONL(path)
} else {
data, err = os.ReadFile(path)
}
if err != nil {
return nil, nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) == 0 {
return nil, nil, nil
}
sess, msgs, err := parseVSCodeCopilotData(
data, path, project, machine,
)
if err != nil {
return nil, nil, err
}
if sess == nil {
return nil, nil, nil
}
sess.Agent = AgentVSCodeCopilot
sess.ID = "vscode-copilot:" + strings.TrimPrefix(
sess.ID, "vscode-copilot:",
)
sess.File = FileInfo{
Path: path,
Size: info.Size(),
Mtime: info.ModTime().UnixNano(),
}
return sess, msgs, nil
}
// parseVSCodeCopilotData parses VSCode-style chat session JSON
// data. Used by both VSCode Copilot and Positron parsers since
// the formats are identical.
func parseVSCodeCopilotData(
data []byte, path, project, machine string,
) (*ParsedSession, []ParsedMessage, error) {
var session vscodeCopilotSession
if err := json.Unmarshal(data, &session); err != nil {
return nil, nil, fmt.Errorf(
"unmarshal %s: %w", path, err,
)
}
if len(session.Requests) == 0 {
return nil, nil, nil
}
var messages []ParsedMessage
var firstMessage string
ordinal := 0
var usageEvents []ParsedUsageEvent
totalOutput := 0
peakContext := 0
sawTokens := false
startedAt := session.CreationDate.Time()
for _, req := range session.Requests {
// User message
text := strings.TrimSpace(req.Message.Text)
if text != "" {
if firstMessage == "" {
firstMessage = truncate(
strings.ReplaceAll(text, "\n", " "), 300,
)
}
messages = append(messages, ParsedMessage{
Ordinal: ordinal,
Role: RoleUser,
Content: text,
Timestamp: req.Timestamp.Time(),
ContentLength: len(text),
})
ordinal++
}
// Token accounting: VSCode records prompt/output tokens and
// the resolved model in result.metadata. Emit one usage event
// per turn so the cost gets catalog-priced downstream.
if ev, ok := vscodeCopilotUsageEvent(req, startedAt); ok {
usageEvents = append(usageEvents, ev)
totalOutput += ev.OutputTokens
// promptTokens is the full context billed for the turn,
// so the largest one is the session's peak context.
peakContext = max(peakContext, ev.InputTokens)
sawTokens = true
}
// Assistant response: parse response items
respText, toolCalls := parseVSCodeCopilotResponse(
req.Response,
)
hasToolUse := len(toolCalls) > 0
displayContent := respText
if hasToolUse {
toolText := formatVSCodeCopilotToolCalls(toolCalls)
if respText == "" {
displayContent = toolText
} else {
displayContent = toolText + "\n\n" + respText
}
}
if displayContent == "" && !hasToolUse {
continue
}
messages = append(messages, ParsedMessage{
Ordinal: ordinal,
Role: RoleAssistant,
Content: displayContent,
Timestamp: req.Timestamp.Time(),
HasToolUse: hasToolUse,
ContentLength: len(displayContent),
ToolCalls: toolCalls,
})
ordinal++
}
if len(messages) == 0 {
return nil, nil, nil
}
sessionID := session.SessionID
if sessionID == "" {
// Fall back to filename (strip .json or .jsonl)
base := filepath.Base(path)
sessionID = strings.TrimSuffix(
strings.TrimSuffix(base, ".jsonl"), ".json",
)
}
// Use customTitle as first message if we have no user text
if firstMessage == "" && session.CustomTitle != "" {
firstMessage = session.CustomTitle
}
userCount := 0
for _, m := range messages {
if m.Role == RoleUser && m.Content != "" {
userCount++
}
}
endedAt := session.LastMessageDate.Time()
if endedAt.IsZero() && len(session.Requests) > 0 {
last := session.Requests[len(session.Requests)-1]
endedAt = last.Timestamp.Time()
}
sess := &ParsedSession{
ID: sessionID,
Project: project,
Machine: machine,
FirstMessage: firstMessage,
StartedAt: startedAt,
EndedAt: endedAt,
MessageCount: len(messages),
UserMessageCount: userCount,
UsageEvents: usageEvents,
}
if sawTokens {
sess.TotalOutputTokens = totalOutput
sess.HasTotalOutputTokens = true
sess.PeakContextTokens = peakContext
sess.HasPeakContextTokens = true
}
return sess, messages, nil
}
// vscodeCopilotUsageEvent builds a per-turn usage event from a
// request's result.metadata token accounting. Returns ok=false
// when the request carries no usable token data. The prompt size
// (promptTokens) is the full context billed for that turn; without
// a cache breakdown it is treated as input tokens, so the derived
// cost is an upper-bound estimate that ignores prompt-cache discounts.
func vscodeCopilotUsageEvent(
req vscodeCopilotRequest, sessionStart time.Time,
) (ParsedUsageEvent, bool) {
if req.Result == nil || len(req.Result.Metadata) == 0 {
return ParsedUsageEvent{}, false
}
var md vscodeCopilotMetadata
if err := json.Unmarshal(req.Result.Metadata, &md); err != nil {
return ParsedUsageEvent{}, false
}
if md.PromptTokens <= 0 && md.OutputTokens <= 0 {
return ParsedUsageEvent{}, false
}
// resolvedModel is already in pricing-catalog form
// (e.g. "claude-opus-4-8"). Fall back to the prefixed modelId
// (e.g. "copilot/claude-opus-4.8") and normalize it.
model := md.ResolvedModel
if model == "" {
model = strings.TrimPrefix(req.ModelID, "copilot/")
}
model = normalizeCopilotModel(model)
return ParsedUsageEvent{
Source: "vscode-copilot",
Model: model,
InputTokens: md.PromptTokens,
OutputTokens: md.OutputTokens,
OccurredAt: timeString(req.Timestamp.Time(), sessionStart),
}, true
}
// parseVSCodeCopilotResponse extracts text and tool calls
// from the response items array.
func parseVSCodeCopilotResponse(
raw []json.RawMessage,
) (string, []ParsedToolCall) {
var textParts []string
var toolCalls []ParsedToolCall
for _, r := range raw {
var item vscodeCopilotResponseItem
if err := json.Unmarshal(r, &item); err != nil {
continue
}
switch item.Kind {
case "toolInvocationSerialized":
if item.ToolID != "" {
tc := ParsedToolCall{
ToolUseID: item.ToolCallID,
ToolName: item.ToolID,
Category: NormalizeToolCategory(
normalizeVSCodeToolName(item.ToolID),
),
}
tc.InputJSON = extractVSCopilotInputJSON(
item.InvocationMessage,
item.PastTenseMessage,
item.ToolSpecificData,
)
toolCalls = append(toolCalls, tc)
}
case "prepareToolInvocation":
// Skip, the actual invocation comes later.
case "inlineReference", "undoStop",
"codeblockUri", "textEditGroup":
// Skip non-text items.
case "":
// Items without a kind are markdown text
if item.Value != "" {
textParts = append(textParts, item.Value)
}
default:
// Unknown kind, try to extract value
if item.Value != "" {
textParts = append(textParts, item.Value)
}
}
}
text := strings.TrimSpace(strings.Join(textParts, ""))
return text, toolCalls
}
// normalizeVSCodeToolName maps VSCode Copilot tool IDs to
// names that the taxonomy can categorize.
func normalizeVSCodeToolName(toolID string) string {
switch toolID {
// File reading
case "copilot_readFile",
"copilot_getNotebookSummary",
"copilot_readNotebookCellOutput",
"copilot_getVSCodeAPI",
"copilot_getChangedFiles",
"copilot_listCodeUsages",
"copilot_getErrors":
return "read_file"
// Editing
case "copilot_replaceString",
"copilot_multiReplaceString",
"copilot_applyPatch",
"copilot_editNotebook",
"vscode_editFile_internal":
return "edit_file"
// File insertion / creation
case "copilot_insertEdit":
return "edit_file"
case "copilot_createFile",
"copilot_createDirectory":
return "create_file"
case "copilot_deleteFile":
return "write"
// Terminal / shell execution
case "copilot_runInTerminal",
"copilot_runTerminalLastCommand",
"copilot_runCommand",
"copilot_terminalCommand",
"copilot_getTerminalOutput",
"copilot_getTerminalLastCommand",
"copilot_getTerminalSelection",
"copilot_runTests",
"copilot_runNotebookCell",
"copilot_runVscodeCommand",
"run_in_terminal",
"runTests",
"terminal_last_command",
"get_terminal_output":
return "shell"
// Search / grep
case "copilot_searchFiles",
"copilot_findFilesByName",
"copilot_findTextInFiles",
"copilot_searchCodebase":
return "grep"
// Directory listing
case "copilot_listDir",
"copilot_listDirectory",
"copilot_findFiles":
return "glob"
// Web
case "copilot_fetchWebPage",
"vscode_fetchWebPage_internal",
"copilot_openSimpleBrowser":
return "read_web_page"
// GitHub
case "copilot_githubRepo":
return "read_file"
// Subagent
case "runSubagent":
return "Task"
// Todo
case "manage_todo_list":
return "Task"
// Thinking (treated as tool)
case "copilot_think":
return "Tool"
default:
return toolID
}
}
func formatVSCodeCopilotToolCalls(
calls []ParsedToolCall,
) string {
var parts []string
for _, tc := range calls {
header := formatToolHeader(tc.Category, tc.ToolName)
body := extractVSCopilotToolBody(tc)
if body != "" {
parts = append(parts, header+"\n"+body)
} else {
parts = append(parts, header)
}
}
return strings.Join(parts, "\n\n")
}
// extractVSCopilotToolBody returns a human-readable body
// line for the tool block, derived from InputJSON.
func extractVSCopilotToolBody(tc ParsedToolCall) string {
if tc.InputJSON == "" {
return ""
}
var m map[string]any
if err := json.Unmarshal(
[]byte(tc.InputJSON), &m,
); err != nil {
return ""
}
if cmd, ok := m["command"].(string); ok && cmd != "" {
return "$ " + cmd
}
if msg, ok := m["message"].(string); ok && msg != "" {
return msg
}
return ""
}
// extractVSCopilotInputJSON builds an InputJSON string from
// the invocationMessage and toolSpecificData fields.
func extractVSCopilotInputJSON(
invocationMsg, pastTenseMsg, toolData json.RawMessage,
) string {
result := make(map[string]any)
// Extract message from invocationMessage (string or object)
msg := extractInvocationText(pastTenseMsg)
if msg == "" {
msg = extractInvocationText(invocationMsg)
}
if msg != "" {
result["message"] = msg
}
// Extract command from toolSpecificData
if len(toolData) > 0 {
var td vscodeCopilotToolData
if err := json.Unmarshal(toolData, &td); err == nil {
if td.Command != "" {
result["command"] = td.Command
}
}
}
if len(result) == 0 {
return ""
}
data, err := json.Marshal(result)
if err != nil {
return ""
}
return string(data)
}
// extractInvocationText extracts a human-readable string
// from an invocationMessage field which can be a plain
// string or a {"value": "..."} object.
func extractInvocationText(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
// Try string first
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
// Try object with value field
var obj vscodeCopilotInvocationMsg
if err := json.Unmarshal(raw, &obj); err == nil {
return obj.Value
}
return ""
}
// readVSCodeWorkspaceManifest indirects ReadVSCodeWorkspaceManifest so the
// VSCode-Copilot and Positron discovery paths can resolve the manifest once
// per workspace dir and tests can observe how often it runs.
var readVSCodeWorkspaceManifest = ReadVSCodeWorkspaceManifest
// ReadVSCodeWorkspaceManifest reads the workspace.json file
// in a workspaceStorage hash directory and extracts the
// project folder path.
func ReadVSCodeWorkspaceManifest(hashDir string) string {
path := filepath.Join(hashDir, "workspace.json")
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var ws vscodeCopilotWorkspace
if err := json.Unmarshal(data, &ws); err != nil {
return ""
}
uri := ws.Folder
if uri == "" {
uri = ws.Workspace
}
if uri == "" {
return ""
}
// Extract path from file:// URI
return extractProjectFromURI(uri)
}
// extractProjectFromURI extracts a human-readable project
// name from a file URI like "file:///Users/dev/projects/myapp".
func extractProjectFromURI(uri string) string {
path := strings.TrimPrefix(uri, "file://")
if path == uri {
// Not a file URI, return as-is
return filepath.Base(uri)
}
// On Windows the path might start with /C:/ - trim the
// leading slash for windows paths.
if len(path) > 2 && path[0] == '/' &&
path[2] == ':' {
path = path[1:]
}
return filepath.Base(path)
}
// jsonlOp represents a single operation in a VSCode JSONL
// session operation log.
type jsonlOp struct {
Kind int `json:"kind"`
K []json.RawMessage `json:"k,omitempty"`
V json.RawMessage `json:"v,omitempty"`
I *int `json:"i,omitempty"`
}
// reconstructJSONL reads a VSCode JSONL operation log and
// replays mutations to reconstruct the full session JSON.
//
// Format:
// - kind=0 (Initial): first line, contains full snapshot
// - kind=1 (Set): update property at path k
// - kind=2 (Push): append/splice items into array at path k
// - kind=3 (Delete): remove property at path k
func reconstructJSONL(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 64*1024*1024)
var state any
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var op jsonlOp
if err := json.Unmarshal(line, &op); err != nil {
continue
}
switch op.Kind {
case 0: // Initial
if err := json.Unmarshal(op.V, &state); err != nil {
return nil, fmt.Errorf(
"jsonl initial: %w", err,
)
}
case 1: // Set
if state == nil || len(op.K) == 0 {
continue
}
keys := decodeJSONLKeys(op.K)
var val any
if err := json.Unmarshal(op.V, &val); err != nil {
continue
}
jsonlSet(state, keys, val)
case 2: // Push
if state == nil || len(op.K) == 0 {
continue
}
keys := decodeJSONLKeys(op.K)
var items []any
if err := json.Unmarshal(op.V, &items); err != nil {
continue
}
jsonlPush(state, keys, items, op.I)
case 3: // Delete
if state == nil || len(op.K) == 0 {
continue
}
keys := decodeJSONLKeys(op.K)
jsonlDelete(state, keys)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scan %s: %w", path, err)
}
if state == nil {
return nil, nil
}
return json.Marshal(state)
}
// decodeJSONLKeys converts raw JSON key elements to strings.
// Keys can be strings (object keys) or numbers (array indices).
func decodeJSONLKeys(raw []json.RawMessage) []string {
keys := make([]string, len(raw))
for i, r := range raw {
var s string
if err := json.Unmarshal(r, &s); err == nil {
keys[i] = s
continue
}
// Must be a number (array index)
keys[i] = strings.TrimSpace(string(r))
}
return keys
}
// jsonlNavigate traverses the state tree to the parent of
// the target, returning the parent and the final key.
func jsonlNavigate(
state any, keys []string,
) (any, string) {
current := state
for _, k := range keys[:len(keys)-1] {
current = jsonlChild(current, k)
if current == nil {
return nil, ""
}
}
return current, keys[len(keys)-1]
}
func jsonlChild(node any, key string) any {
switch n := node.(type) {
case map[string]any:
return n[key]
case []any:
idx, err := strconv.Atoi(key)
if err != nil || idx < 0 || idx >= len(n) {
return nil
}
return n[idx]
}
return nil
}
func jsonlSet(
state any, keys []string, val any,
) {
parent, lastKey := jsonlNavigate(state, keys)
if parent == nil {
return
}
switch p := parent.(type) {
case map[string]any:
p[lastKey] = val
case []any:
idx, err := strconv.Atoi(lastKey)
if err != nil || idx < 0 || idx >= len(p) {
return
}
p[idx] = val
}
}
func jsonlPush(
state any, keys []string,
items []any, spliceIdx *int,
) {
// Navigate to the array
var target any
if len(keys) == 0 {
return
}
if len(keys) == 1 {
target = state
} else {
target = state
for _, k := range keys[:len(keys)-1] {
target = jsonlChild(target, k)
if target == nil {
return
}
}
}
lastKey := keys[len(keys)-1]
switch p := target.(type) {
case map[string]any:
arr, ok := p[lastKey].([]any)
if !ok {
return
}
if spliceIdx != nil {
idx := max(0, min(*spliceIdx, len(arr)))
end := min(idx+len(items), len(arr))
newArr := make(
[]any, 0, len(arr)-(end-idx)+len(items),
)
newArr = append(newArr, arr[:idx]...)
newArr = append(newArr, items...)
newArr = append(newArr, arr[end:]...)
p[lastKey] = newArr
} else {
p[lastKey] = append(arr, items...)
}
case []any:
idx, err := strconv.Atoi(lastKey)
if err != nil || idx < 0 || idx >= len(p) {
return
}
arr, ok := p[idx].([]any)
if !ok {
return
}
if spliceIdx != nil {
si := max(0, min(*spliceIdx, len(arr)))
end := min(si+len(items), len(arr))
newArr := make(
[]any, 0, len(arr)-(end-si)+len(items),
)
newArr = append(newArr, arr[:si]...)
newArr = append(newArr, items...)
newArr = append(newArr, arr[end:]...)
p[idx] = newArr
} else {
p[idx] = append(arr, items...)
}
}
}
func jsonlDelete(state any, keys []string) {
parent, lastKey := jsonlNavigate(state, keys)
if parent == nil {
return
}
if m, ok := parent.(map[string]any); ok {
delete(m, lastKey)
}
}