mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-04 06:30:53 +00:00
Merge branch 'main' into feat/support-permission
This commit is contained in:
commit
b59864f554
57 changed files with 14025 additions and 619 deletions
|
|
@ -1461,6 +1461,109 @@ const SETTINGS_SCHEMA = {
|
|||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
items: HOOK_DEFINITION_ITEMS,
|
||||
},
|
||||
Notification: {
|
||||
type: 'array',
|
||||
label: 'Notification Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute when notifications are sent.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PreToolUse: {
|
||||
type: 'array',
|
||||
label: 'Pre Tool Use Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute before tool execution.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PostToolUse: {
|
||||
type: 'array',
|
||||
label: 'Post Tool Use Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute after successful tool execution.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PostToolUseFailure: {
|
||||
type: 'array',
|
||||
label: 'Post Tool Use Failure Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute when tool execution fails. ',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
SessionStart: {
|
||||
type: 'array',
|
||||
label: 'Session Start Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute when a new session starts or resumes.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
SessionEnd: {
|
||||
type: 'array',
|
||||
label: 'Session End Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute when a session ends.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PreCompact: {
|
||||
type: 'array',
|
||||
label: 'Pre Compact Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute before conversation compaction.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
SubagentStart: {
|
||||
type: 'array',
|
||||
label: 'Subagent Start Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute when a subagent (Task tool call) is started.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
SubagentStop: {
|
||||
type: 'array',
|
||||
label: 'Subagent Stop Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute right before a subagent (Task tool call) concludes its response.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PermissionRequest: {
|
||||
type: 'array',
|
||||
label: 'Permission Request Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute when a permission dialog is displayed.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export function generateCodingPlanTemplate(
|
|||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
contextWindowSize: 196608,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -222,7 +222,7 @@ export function generateCodingPlanTemplate(
|
|||
extra_body: {
|
||||
enable_thinking: true,
|
||||
},
|
||||
contextWindowSize: 1000000,
|
||||
contextWindowSize: 196608,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,6 +58,17 @@ vi.mock('../ui/commands/permissionsCommand.js', async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/hooksCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
hooksCommand: {
|
||||
name: 'hooks',
|
||||
description: 'Hooks command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -110,6 +121,7 @@ describe('BuiltinCommandLoader', () => {
|
|||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
getUseModelRouter: () => false,
|
||||
getEnableHooks: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
restoreCommandMock.mockReturnValue({
|
||||
|
|
@ -194,4 +206,19 @@ describe('BuiltinCommandLoader', () => {
|
|||
expect(modelCmd).toBeDefined();
|
||||
expect(modelCmd?.name).toBe('model');
|
||||
});
|
||||
|
||||
it('should include hooks command when enableHooks is true', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const hooksCmd = commands.find((c) => c.name === 'hooks');
|
||||
expect(hooksCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude hooks command when enableHooks is false', async () => {
|
||||
(mockConfig.getEnableHooks as Mock).mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const hooksCmd = commands.find((c) => c.name === 'hooks');
|
||||
expect(hooksCmd).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
exportCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
hooksCommand,
|
||||
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
languageCommand,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ import {
|
|||
getAllGeminiMdFilenames,
|
||||
ShellExecutionService,
|
||||
Storage,
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
|
|
@ -295,7 +297,42 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
);
|
||||
historyManager.loadHistory(historyItems);
|
||||
}
|
||||
|
||||
// Fire SessionStart event after config is initialized
|
||||
const sessionStartSource = resumedSessionData
|
||||
? SessionStartSource.Resume
|
||||
: SessionStartSource.Startup;
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
|
||||
if (hookSystem) {
|
||||
hookSystem
|
||||
.fireSessionStartEvent(sessionStartSource, config.getModel() ?? '')
|
||||
.then(() => {
|
||||
debugLogger.debug('SessionStart event completed successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
debugLogger.warn(`SessionStart hook failed: ${err}`);
|
||||
});
|
||||
} else {
|
||||
debugLogger.debug(
|
||||
'SessionStart: HookSystem not available, skipping event',
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
// Register SessionEnd cleanup for process exit
|
||||
registerCleanup(async () => {
|
||||
try {
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.fireSessionEndEvent(SessionEndReason.PromptInputExit);
|
||||
debugLogger.debug('SessionEnd event completed successfully!!!');
|
||||
} catch (err) {
|
||||
debugLogger.error(`SessionEnd hook failed: ${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
registerCleanup(async () => {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.disconnect();
|
||||
|
|
@ -1077,6 +1114,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
streamingState,
|
||||
elapsedTime,
|
||||
settings,
|
||||
config,
|
||||
});
|
||||
|
||||
// Dialog close functionality
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
|
|||
import { clearCommand } from './clearCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import {
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Mock the telemetry service
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
|
|
@ -26,10 +30,19 @@ describe('clearCommand', () => {
|
|||
let mockContext: CommandContext;
|
||||
let mockResetChat: ReturnType<typeof vi.fn>;
|
||||
let mockStartNewSession: ReturnType<typeof vi.fn>;
|
||||
let mockFireSessionEndEvent: ReturnType<typeof vi.fn>;
|
||||
let mockFireSessionStartEvent: ReturnType<typeof vi.fn>;
|
||||
let mockGetHookSystem: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResetChat = vi.fn().mockResolvedValue(undefined);
|
||||
mockStartNewSession = vi.fn().mockReturnValue('new-session-id');
|
||||
mockFireSessionEndEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mockFireSessionStartEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetHookSystem = vi.fn().mockReturnValue({
|
||||
fireSessionEndEvent: mockFireSessionEndEvent,
|
||||
fireSessionStartEvent: mockFireSessionStartEvent,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
|
|
@ -40,6 +53,11 @@ describe('clearCommand', () => {
|
|||
resetChat: mockResetChat,
|
||||
}) as unknown as GeminiClient,
|
||||
startNewSession: mockStartNewSession,
|
||||
getHookSystem: mockGetHookSystem,
|
||||
getDebugLogger: () => ({
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
getModel: () => 'test-model',
|
||||
getToolRegistry: () => undefined,
|
||||
},
|
||||
},
|
||||
|
|
@ -76,6 +94,50 @@ describe('clearCommand', () => {
|
|||
expect(mockContext.ui.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fire SessionEnd event before clearing and SessionStart event after clearing', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
}
|
||||
|
||||
await clearCommand.action(mockContext, '');
|
||||
|
||||
expect(mockGetHookSystem).toHaveBeenCalled();
|
||||
expect(mockFireSessionEndEvent).toHaveBeenCalledWith(
|
||||
SessionEndReason.Clear,
|
||||
);
|
||||
expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
|
||||
SessionStartSource.Clear,
|
||||
'test-model',
|
||||
);
|
||||
|
||||
// SessionEnd should be called before SessionStart
|
||||
const sessionEndCallOrder =
|
||||
mockFireSessionEndEvent.mock.invocationCallOrder[0];
|
||||
const sessionStartCallOrder =
|
||||
mockFireSessionStartEvent.mock.invocationCallOrder[0];
|
||||
expect(sessionEndCallOrder).toBeLessThan(sessionStartCallOrder);
|
||||
});
|
||||
|
||||
it('should handle hook errors gracefully and continue execution', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
}
|
||||
|
||||
mockFireSessionEndEvent.mockRejectedValue(
|
||||
new Error('SessionEnd hook failed'),
|
||||
);
|
||||
mockFireSessionStartEvent.mockRejectedValue(
|
||||
new Error('SessionStart hook failed'),
|
||||
);
|
||||
|
||||
await clearCommand.action(mockContext, '');
|
||||
|
||||
// Should still complete the clear operation despite hook errors
|
||||
expect(mockStartNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(mockResetChat).toHaveBeenCalledTimes(1);
|
||||
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not attempt to reset chat if config service is not available', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { CommandKind } from './types.js';
|
|||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
uiTelemetryService,
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
ToolNames,
|
||||
SkillTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -24,6 +26,15 @@ export const clearCommand: SlashCommand = {
|
|||
const { config } = context.services;
|
||||
|
||||
if (config) {
|
||||
// Fire SessionEnd event before clearing (current session ends)
|
||||
try {
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.fireSessionEndEvent(SessionEndReason.Clear);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
|
||||
}
|
||||
|
||||
const newSessionId = config.startNewSession();
|
||||
|
||||
// Reset UI telemetry metrics for the new session
|
||||
|
|
@ -53,6 +64,18 @@ export const clearCommand: SlashCommand = {
|
|||
} else {
|
||||
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
|
||||
}
|
||||
|
||||
// Fire SessionStart event after clearing (new session starts)
|
||||
try {
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.fireSessionStartEvent(
|
||||
SessionStartSource.Clear,
|
||||
config.getModel() ?? '',
|
||||
);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
|
||||
}
|
||||
} else {
|
||||
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import {
|
|||
AttentionNotificationReason,
|
||||
} from '../../utils/attentionNotification.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
fireNotificationHook,
|
||||
NotificationType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
|
||||
|
||||
|
|
@ -19,6 +24,7 @@ interface UseAttentionNotificationsOptions {
|
|||
streamingState: StreamingState;
|
||||
elapsedTime: number;
|
||||
settings: LoadedSettings;
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
export const useAttentionNotifications = ({
|
||||
|
|
@ -26,10 +32,12 @@ export const useAttentionNotifications = ({
|
|||
streamingState,
|
||||
elapsedTime,
|
||||
settings,
|
||||
config,
|
||||
}: UseAttentionNotificationsOptions) => {
|
||||
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
|
||||
const awaitingNotificationSentRef = useRef(false);
|
||||
const respondingElapsedRef = useRef(0);
|
||||
const idleNotificationSentRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
|
@ -51,6 +59,8 @@ export const useAttentionNotifications = ({
|
|||
useEffect(() => {
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
respondingElapsedRef.current = elapsedTime;
|
||||
// Reset idle notification flag when responding
|
||||
idleNotificationSentRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +75,28 @@ export const useAttentionNotifications = ({
|
|||
}
|
||||
// Reset tracking for next task
|
||||
respondingElapsedRef.current = 0;
|
||||
|
||||
// Fire idle_prompt notification hook when entering idle state
|
||||
if (config && !idleNotificationSentRef.current) {
|
||||
const messageBus = config.getMessageBus();
|
||||
const hooksEnabled = config.getEnableHooks();
|
||||
if (hooksEnabled && messageBus) {
|
||||
fireNotificationHook(
|
||||
messageBus,
|
||||
'Qwen Code is waiting for your input',
|
||||
NotificationType.IdlePrompt,
|
||||
'Waiting for input',
|
||||
).catch(() => {
|
||||
// Silently ignore errors - fireNotificationHook has internal error handling
|
||||
// and notification hooks should not block the idle flow
|
||||
});
|
||||
}
|
||||
idleNotificationSentRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
|
||||
|
||||
// Reset idle notification flag when in WaitingForConfirmation state
|
||||
idleNotificationSentRef.current = false;
|
||||
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled, config]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -142,6 +142,11 @@ describe('useResumeCommand', () => {
|
|||
getTargetDir: () => '/tmp',
|
||||
getGeminiClient: () => geminiClient,
|
||||
startNewSession: vi.fn(),
|
||||
getDebugLogger: () => ({
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
} as unknown as import('@qwen-code/qwen-code-core').Config;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
SessionService,
|
||||
type Config,
|
||||
SessionStartSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
|
||||
|
|
@ -67,6 +71,18 @@ export function useResumeCommand(
|
|||
config.startNewSession(sessionId, sessionData);
|
||||
await config.getGeminiClient()?.initialize?.();
|
||||
|
||||
// Fire SessionStart event after resuming session
|
||||
try {
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.fireSessionStartEvent(
|
||||
SessionStartSource.Resume,
|
||||
config.getModel() ?? '',
|
||||
);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
|
||||
}
|
||||
|
||||
// Refresh terminal UI.
|
||||
remount?.();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -68,6 +68,15 @@ const mockConfig = {
|
|||
getGeminiClient: () => null, // No client needed for these tests
|
||||
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
|
||||
getChatRecordingService: () => undefined,
|
||||
getMessageBus: vi.fn().mockReturnValue(undefined),
|
||||
getEnableHooks: vi.fn().mockReturnValue(false),
|
||||
getHookSystem: vi.fn().mockReturnValue(undefined),
|
||||
getDebugLogger: vi.fn().mockReturnValue({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockTool = new MockTool({
|
||||
|
|
|
|||
|
|
@ -6,10 +6,395 @@
|
|||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { SessionContext } from '../../../acp-integration/session/types.js';
|
||||
import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk';
|
||||
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
|
||||
import type { ExportMessage, ExportSessionData } from './types.js';
|
||||
import type {
|
||||
ExportMessage,
|
||||
ExportSessionData,
|
||||
ExportMetadata,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* File operation statistics extracted from tool calls.
|
||||
*/
|
||||
interface FileOperationStats {
|
||||
filesWritten: number;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
writtenFilePaths: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call arguments index for matching tool_result records.
|
||||
*/
|
||||
interface ToolCallArgsIndex {
|
||||
byId: Map<string, Record<string, unknown>>;
|
||||
byName: Map<string, Array<Record<string, unknown>>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool name from a ChatRecord's function response.
|
||||
*/
|
||||
function extractToolNameFromRecord(record: ChatRecord): string | undefined {
|
||||
if (!record.message?.parts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of record.message.parts) {
|
||||
if ('functionResponse' in part && part.functionResponse?.name) {
|
||||
return part.functionResponse.name;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts call ID from a ChatRecord's function response.
|
||||
*/
|
||||
function extractFunctionResponseId(record: ChatRecord): string | undefined {
|
||||
if (!record.message?.parts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of record.message.parts) {
|
||||
if ('functionResponse' in part && part.functionResponse?.id) {
|
||||
return part.functionResponse.id;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes function call args into a plain object.
|
||||
*/
|
||||
function normalizeFunctionCallArgs(
|
||||
args: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (args && typeof args === 'object') {
|
||||
return args as Record<string, unknown>;
|
||||
}
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(args) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors and treat as unavailable args
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an index of assistant tool calls for later tool_result arg resolution.
|
||||
*/
|
||||
function buildToolCallArgsIndex(records: ChatRecord[]): ToolCallArgsIndex {
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
const byName = new Map<string, Array<Record<string, unknown>>>();
|
||||
|
||||
for (const record of records) {
|
||||
if (record.type !== 'assistant' || !record.message?.parts) continue;
|
||||
|
||||
for (const part of record.message.parts) {
|
||||
if (!('functionCall' in part) || !part.functionCall?.name) continue;
|
||||
|
||||
const normalizedArgs = normalizeFunctionCallArgs(part.functionCall.args);
|
||||
if (!normalizedArgs) continue;
|
||||
|
||||
const toolName = part.functionCall.name;
|
||||
const callId =
|
||||
typeof part.functionCall.id === 'string' ? part.functionCall.id : null;
|
||||
|
||||
if (callId) {
|
||||
byId.set(callId, normalizedArgs);
|
||||
}
|
||||
|
||||
const queue = byName.get(toolName) ?? [];
|
||||
queue.push(normalizedArgs);
|
||||
byName.set(toolName, queue);
|
||||
}
|
||||
}
|
||||
|
||||
return { byId, byName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate file operation statistics from ChatRecords.
|
||||
* Uses toolCallResult from tool_result records for accurate statistics.
|
||||
*/
|
||||
function calculateFileStats(records: ChatRecord[]): FileOperationStats {
|
||||
const argsIndex = buildToolCallArgsIndex(records);
|
||||
const byNameCursor = new Map<string, number>();
|
||||
|
||||
const stats: FileOperationStats = {
|
||||
filesWritten: 0,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
writtenFilePaths: new Set(),
|
||||
};
|
||||
|
||||
for (const record of records) {
|
||||
if (record.type !== 'tool_result' || !record.toolCallResult) continue;
|
||||
|
||||
const toolName = extractToolNameFromRecord(record);
|
||||
const callId =
|
||||
record.toolCallResult.callId ?? extractFunctionResponseId(record);
|
||||
const argsFromId =
|
||||
callId && argsIndex.byId.has(callId)
|
||||
? argsIndex.byId.get(callId)
|
||||
: undefined;
|
||||
let args = argsFromId;
|
||||
if (!args && toolName) {
|
||||
const queue = argsIndex.byName.get(toolName);
|
||||
if (queue && queue.length > 0) {
|
||||
const cursor = byNameCursor.get(toolName) ?? 0;
|
||||
args = queue[cursor];
|
||||
byNameCursor.set(toolName, cursor + 1);
|
||||
}
|
||||
}
|
||||
const { resultDisplay } = record.toolCallResult;
|
||||
|
||||
// Track file locations from resultDisplay
|
||||
if (
|
||||
resultDisplay &&
|
||||
typeof resultDisplay === 'object' &&
|
||||
'fileName' in resultDisplay
|
||||
) {
|
||||
const display = resultDisplay as {
|
||||
fileName: string;
|
||||
fileDiff?: string;
|
||||
originalContent?: string | null;
|
||||
newContent?: string;
|
||||
diffStat?: { model_added_lines?: number; model_removed_lines?: number };
|
||||
};
|
||||
|
||||
// Determine operation type based on content fields
|
||||
const hasOriginalContent = 'originalContent' in display;
|
||||
const hasNewContent = 'newContent' in display;
|
||||
|
||||
// For write/edit operations, use full path from args if available
|
||||
let filePath: string;
|
||||
if (typeof display.fileName === 'string') {
|
||||
// Prefer args.file_path for full path, fallback to fileName (which may be basename)
|
||||
filePath =
|
||||
(args?.['file_path'] as string) ||
|
||||
(args?.['absolute_path'] as string) ||
|
||||
display.fileName;
|
||||
} else {
|
||||
// Fallback if fileName is not a string
|
||||
filePath = 'unknown';
|
||||
}
|
||||
|
||||
if (hasOriginalContent || hasNewContent) {
|
||||
// This is a write/edit operation
|
||||
stats.filesWritten++;
|
||||
stats.writtenFilePaths.add(filePath);
|
||||
|
||||
// Calculate line changes
|
||||
if (display.diffStat) {
|
||||
// Use diffStat if available for accurate counts
|
||||
stats.linesAdded += display.diffStat.model_added_lines ?? 0;
|
||||
stats.linesRemoved += display.diffStat.model_removed_lines ?? 0;
|
||||
} else {
|
||||
// Fallback: count lines in content
|
||||
const oldText = String(display.originalContent ?? '');
|
||||
const newText = String(display.newContent ?? '');
|
||||
|
||||
// Count non-empty lines
|
||||
const oldLines = oldText
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0).length;
|
||||
const newLines = newText
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0).length;
|
||||
|
||||
stats.linesAdded += newLines;
|
||||
stats.linesRemoved += oldLines;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts token usage from TaskResultDisplay executionSummary.
|
||||
*/
|
||||
function extractTaskToolTokens(record: ChatRecord): number {
|
||||
if (record.type !== 'tool_result' || !record.toolCallResult?.resultDisplay) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const { resultDisplay } = record.toolCallResult;
|
||||
if (
|
||||
typeof resultDisplay === 'object' &&
|
||||
'type' in resultDisplay &&
|
||||
resultDisplay.type === 'task_execution' &&
|
||||
'executionSummary' in resultDisplay
|
||||
) {
|
||||
const summary = resultDisplay.executionSummary as {
|
||||
totalTokens?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
thoughtTokens?: number;
|
||||
cachedTokens?: number;
|
||||
};
|
||||
// Use totalTokens if available, otherwise sum individual token counts
|
||||
if (typeof summary.totalTokens === 'number') {
|
||||
return summary.totalTokens;
|
||||
}
|
||||
// Fallback: sum available token counts
|
||||
return (
|
||||
(summary.inputTokens ?? 0) +
|
||||
(summary.outputTokens ?? 0) +
|
||||
(summary.thoughtTokens ?? 0) +
|
||||
(summary.cachedTokens ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate token statistics from ChatRecords.
|
||||
* Aggregates usageMetadata from assistant records and TaskTool executionSummary to get total token usage.
|
||||
* Uses the last assistant record that has both totalTokenCount and contextWindowSize for calculating context usage percent.
|
||||
*/
|
||||
function calculateTokenStats(records: ChatRecord[]): {
|
||||
totalTokens: number;
|
||||
contextUsagePercent?: number;
|
||||
contextWindowSize?: number;
|
||||
} {
|
||||
let totalTokens = 0;
|
||||
// Track the last assistant record that has BOTH totalTokenCount and contextWindowSize
|
||||
// to ensure the percentage calculation uses values from the same record
|
||||
let lastValidRecord: {
|
||||
totalTokenCount: number;
|
||||
contextWindowSize: number;
|
||||
} | null = null;
|
||||
|
||||
// Aggregate usageMetadata from all assistant records
|
||||
for (const record of records) {
|
||||
if (record.type === 'assistant') {
|
||||
if (record.usageMetadata) {
|
||||
totalTokens += record.usageMetadata.totalTokenCount ?? 0;
|
||||
}
|
||||
// Only update lastValidRecord when BOTH values are present in the same record
|
||||
if (
|
||||
record.usageMetadata?.totalTokenCount !== undefined &&
|
||||
record.contextWindowSize !== undefined
|
||||
) {
|
||||
lastValidRecord = {
|
||||
totalTokenCount: record.usageMetadata.totalTokenCount,
|
||||
contextWindowSize: record.contextWindowSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Include TaskTool token usage from executionSummary
|
||||
const taskTokens = extractTaskToolTokens(record);
|
||||
if (taskTokens > 0) {
|
||||
totalTokens += taskTokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Use last valid record's values for context usage calculation
|
||||
// This represents how much of the context window is being used by the total tokens
|
||||
if (lastValidRecord) {
|
||||
const percent =
|
||||
(lastValidRecord.totalTokenCount / lastValidRecord.contextWindowSize) *
|
||||
100;
|
||||
return {
|
||||
totalTokens,
|
||||
contextUsagePercent: Math.round(percent * 10) / 10,
|
||||
contextWindowSize: lastValidRecord.contextWindowSize,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: return the contextWindowSize from the last assistant record even if no valid pair found
|
||||
// (for display purposes only, without percentage)
|
||||
const lastAssistantRecord = [...records]
|
||||
.reverse()
|
||||
.find((r) => r.type === 'assistant' && r.contextWindowSize !== undefined);
|
||||
|
||||
return {
|
||||
totalTokens,
|
||||
contextWindowSize: lastAssistantRecord?.contextWindowSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract session metadata from ChatRecords.
|
||||
*/
|
||||
async function extractMetadata(
|
||||
conversation: {
|
||||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ChatRecord[];
|
||||
},
|
||||
config: Config,
|
||||
): Promise<ExportMetadata> {
|
||||
const { sessionId, startTime, messages } = conversation;
|
||||
|
||||
// Extract basic info from the first record
|
||||
const firstRecord = messages[0];
|
||||
const cwd = firstRecord?.cwd ?? '';
|
||||
const gitBranch = firstRecord?.gitBranch;
|
||||
|
||||
// Get git repository name
|
||||
let gitRepo: string | undefined;
|
||||
if (cwd) {
|
||||
const { getGitRepoName } = await import('@qwen-code/qwen-code-core');
|
||||
gitRepo = getGitRepoName(cwd);
|
||||
}
|
||||
|
||||
// Try to get model from assistant messages
|
||||
let model: string | undefined;
|
||||
for (const record of messages) {
|
||||
if (record.type === 'assistant' && record.model) {
|
||||
model = record.model;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get channel from config
|
||||
const channel = config.getChannel?.();
|
||||
|
||||
// Count user prompts
|
||||
const promptCount = messages.filter((m) => m.type === 'user').length;
|
||||
|
||||
// Calculate file stats from original ChatRecords
|
||||
const fileStats = calculateFileStats(messages);
|
||||
|
||||
// Calculate token stats from original ChatRecords
|
||||
// contextWindowSize is retrieved from the last assistant record for accuracy
|
||||
const tokenStats = calculateTokenStats(messages);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
startTime,
|
||||
exportTime: new Date().toISOString(),
|
||||
cwd,
|
||||
gitRepo,
|
||||
gitBranch,
|
||||
model,
|
||||
channel,
|
||||
promptCount,
|
||||
contextUsagePercent: tokenStats.contextUsagePercent,
|
||||
contextWindowSize: tokenStats.contextWindowSize,
|
||||
totalTokens: tokenStats.totalTokens,
|
||||
filesWritten: fileStats.writtenFilePaths.size,
|
||||
linesAdded: fileStats.linesAdded,
|
||||
linesRemoved: fileStats.linesRemoved,
|
||||
uniqueFiles: Array.from(fileStats.writtenFilePaths),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export session context that captures session updates into export messages.
|
||||
|
|
@ -24,6 +409,7 @@ class ExportSessionContext implements SessionContext {
|
|||
role: 'user' | 'assistant' | 'thinking';
|
||||
parts: Array<{ text: string }>;
|
||||
timestamp: number;
|
||||
usageMetadata?: GenerateContentResponseUsageMetadata;
|
||||
} | null = null;
|
||||
private activeRecordId: string | null = null;
|
||||
private activeRecordTimestamp: string | null = null;
|
||||
|
|
@ -39,9 +425,37 @@ class ExportSessionContext implements SessionContext {
|
|||
case 'user_message_chunk':
|
||||
this.handleMessageChunk('user', update.content);
|
||||
break;
|
||||
case 'agent_message_chunk':
|
||||
this.handleMessageChunk('assistant', update.content);
|
||||
case 'agent_message_chunk': {
|
||||
// Extract usageMetadata from _meta if available
|
||||
const usageMeta = update._meta as
|
||||
| {
|
||||
usage?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
thoughtTokens?: number;
|
||||
cachedReadTokens?: number;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const usageMetadata: GenerateContentResponseUsageMetadata | undefined =
|
||||
usageMeta?.usage
|
||||
? {
|
||||
promptTokenCount: usageMeta.usage.inputTokens,
|
||||
candidatesTokenCount: usageMeta.usage.outputTokens,
|
||||
totalTokenCount: usageMeta.usage.totalTokens,
|
||||
thoughtsTokenCount: usageMeta.usage.thoughtTokens,
|
||||
cachedContentTokenCount: usageMeta.usage.cachedReadTokens,
|
||||
}
|
||||
: undefined;
|
||||
this.handleMessageChunk(
|
||||
'assistant',
|
||||
update.content,
|
||||
'assistant',
|
||||
usageMetadata,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'agent_thought_chunk':
|
||||
this.handleMessageChunk('assistant', update.content, 'thinking');
|
||||
break;
|
||||
|
|
@ -79,6 +493,7 @@ class ExportSessionContext implements SessionContext {
|
|||
role: 'user' | 'assistant',
|
||||
content: { type: string; text?: string },
|
||||
messageRole: 'user' | 'assistant' | 'thinking' = role,
|
||||
usageMetadata?: GenerateContentResponseUsageMetadata,
|
||||
): void {
|
||||
if (content.type !== 'text' || !content.text) return;
|
||||
|
||||
|
|
@ -98,12 +513,17 @@ class ExportSessionContext implements SessionContext {
|
|||
this.currentMessage.role === messageRole
|
||||
) {
|
||||
this.currentMessage.parts.push({ text: content.text });
|
||||
// Merge usageMetadata if provided (for assistant messages)
|
||||
if (usageMetadata && role === 'assistant') {
|
||||
this.currentMessage.usageMetadata = usageMetadata;
|
||||
}
|
||||
} else {
|
||||
this.currentMessage = {
|
||||
type: role,
|
||||
role: messageRole,
|
||||
parts: [{ text: content.text }],
|
||||
timestamp: Date.now(),
|
||||
...(usageMetadata && role === 'assistant' ? { usageMetadata } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -205,7 +625,7 @@ class ExportSessionContext implements SessionContext {
|
|||
if (!this.currentMessage) return;
|
||||
|
||||
const uuid = this.getMessageUuid();
|
||||
this.messages.push({
|
||||
const exportMessage: ExportMessage = {
|
||||
uuid,
|
||||
sessionId: this.sessionId,
|
||||
timestamp: this.getMessageTimestamp(),
|
||||
|
|
@ -214,7 +634,17 @@ class ExportSessionContext implements SessionContext {
|
|||
role: this.currentMessage.role,
|
||||
parts: this.currentMessage.parts,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Add usageMetadata for assistant messages
|
||||
if (
|
||||
this.currentMessage.type === 'assistant' &&
|
||||
this.currentMessage.usageMetadata
|
||||
) {
|
||||
exportMessage.usageMetadata = this.currentMessage.usageMetadata;
|
||||
}
|
||||
|
||||
this.messages.push(exportMessage);
|
||||
|
||||
this.currentMessage = null;
|
||||
}
|
||||
|
|
@ -258,9 +688,13 @@ export async function collectSessionData(
|
|||
// Get the export messages
|
||||
const messages = exportContext.getMessages();
|
||||
|
||||
// Extract metadata from conversation
|
||||
const metadata = await extractMetadata(conversation, config);
|
||||
|
||||
return {
|
||||
sessionId: conversation.sessionId,
|
||||
startTime: conversation.startTime,
|
||||
messages,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function injectDataIntoHtmlTemplate(
|
|||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: unknown[];
|
||||
metadata?: unknown;
|
||||
},
|
||||
): string {
|
||||
const jsonData = JSON.stringify(data, null, 2);
|
||||
|
|
|
|||
|
|
@ -12,15 +12,60 @@ import type { ExportSessionData } from '../types.js';
|
|||
*/
|
||||
export function toJsonl(sessionData: ExportSessionData): string {
|
||||
const lines: string[] = [];
|
||||
const sourceMetadata = sessionData.metadata;
|
||||
|
||||
// Add session metadata as the first line
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: 'session_metadata',
|
||||
sessionId: sessionData.sessionId,
|
||||
startTime: sessionData.startTime,
|
||||
}),
|
||||
);
|
||||
const metadata: Record<string, unknown> = {
|
||||
type: 'session_metadata',
|
||||
sessionId: sessionData.sessionId,
|
||||
startTime: sessionData.startTime,
|
||||
};
|
||||
|
||||
// Add all metadata fields if available
|
||||
if (sourceMetadata?.exportTime) {
|
||||
metadata['exportTime'] = sourceMetadata.exportTime;
|
||||
}
|
||||
if (sourceMetadata?.cwd) {
|
||||
metadata['cwd'] = sourceMetadata.cwd;
|
||||
}
|
||||
if (sourceMetadata?.gitRepo) {
|
||||
metadata['gitRepo'] = sourceMetadata.gitRepo;
|
||||
}
|
||||
if (sourceMetadata?.gitBranch) {
|
||||
metadata['gitBranch'] = sourceMetadata.gitBranch;
|
||||
}
|
||||
if (sourceMetadata?.model) {
|
||||
metadata['model'] = sourceMetadata.model;
|
||||
}
|
||||
if (sourceMetadata?.channel) {
|
||||
metadata['channel'] = sourceMetadata.channel;
|
||||
}
|
||||
if (sourceMetadata?.promptCount !== undefined) {
|
||||
metadata['promptCount'] = sourceMetadata.promptCount;
|
||||
}
|
||||
if (sourceMetadata?.contextUsagePercent !== undefined) {
|
||||
metadata['contextUsagePercent'] = sourceMetadata.contextUsagePercent;
|
||||
}
|
||||
if (sourceMetadata?.contextWindowSize !== undefined) {
|
||||
metadata['contextWindowSize'] = sourceMetadata.contextWindowSize;
|
||||
}
|
||||
if (sourceMetadata?.totalTokens !== undefined) {
|
||||
metadata['totalTokens'] = sourceMetadata.totalTokens;
|
||||
}
|
||||
if (sourceMetadata?.filesWritten !== undefined) {
|
||||
metadata['filesWritten'] = sourceMetadata.filesWritten;
|
||||
}
|
||||
if (sourceMetadata?.linesAdded !== undefined) {
|
||||
metadata['linesAdded'] = sourceMetadata.linesAdded;
|
||||
}
|
||||
if (sourceMetadata?.linesRemoved !== undefined) {
|
||||
metadata['linesRemoved'] = sourceMetadata.linesRemoved;
|
||||
}
|
||||
if (sourceMetadata?.uniqueFiles && sourceMetadata.uniqueFiles.length > 0) {
|
||||
metadata['uniqueFiles'] = sourceMetadata.uniqueFiles;
|
||||
}
|
||||
|
||||
lines.push(JSON.stringify(metadata));
|
||||
|
||||
// Add each message as a separate line
|
||||
for (const message of sessionData.messages) {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,82 @@ import type { ExportSessionData, ExportMessage } from '../types.js';
|
|||
*/
|
||||
export function toMarkdown(sessionData: ExportSessionData): string {
|
||||
const lines: string[] = [];
|
||||
const metadata = sessionData.metadata;
|
||||
|
||||
// Add header with metadata
|
||||
lines.push('# Chat Session Export\n');
|
||||
lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``);
|
||||
lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`);
|
||||
lines.push(`- **Exported**: ${new Date().toISOString()}`);
|
||||
lines.push(
|
||||
`- **Exported**: ${sanitizeText(metadata?.exportTime ?? new Date().toISOString())}`,
|
||||
);
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Add context info
|
||||
if (metadata?.cwd) {
|
||||
lines.push(`- **Working Directory**: \`${sanitizeText(metadata.cwd)}\``);
|
||||
}
|
||||
if (metadata?.gitRepo) {
|
||||
lines.push(`- **Git Repository**: ${sanitizeText(metadata.gitRepo)}`);
|
||||
}
|
||||
if (metadata?.gitBranch) {
|
||||
lines.push(`- **Git Branch**: \`${sanitizeText(metadata.gitBranch)}\``);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Add model info
|
||||
if (metadata?.model) {
|
||||
lines.push(`- **Model**: ${sanitizeText(metadata.model)}`);
|
||||
}
|
||||
if (metadata?.channel) {
|
||||
lines.push(`- **Channel**: ${sanitizeText(metadata.channel)}`);
|
||||
}
|
||||
if (metadata?.promptCount !== undefined) {
|
||||
lines.push(`- **Prompt Count**: ${metadata.promptCount}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Add token stats
|
||||
if (metadata?.totalTokens !== undefined) {
|
||||
lines.push(`- **Total Tokens**: ${metadata.totalTokens}`);
|
||||
}
|
||||
if (metadata?.contextWindowSize !== undefined) {
|
||||
lines.push(`- **Context Window Size**: ${metadata.contextWindowSize}`);
|
||||
}
|
||||
if (metadata?.contextUsagePercent !== undefined) {
|
||||
lines.push(`- **Context Usage**: ${metadata.contextUsagePercent}%`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Add file operation stats
|
||||
if (metadata?.filesWritten !== undefined) {
|
||||
lines.push(`- **Files Written**: ${metadata.filesWritten}`);
|
||||
}
|
||||
if (metadata?.linesAdded !== undefined) {
|
||||
lines.push(`- **Lines Added**: ${metadata.linesAdded}`);
|
||||
}
|
||||
if (metadata?.linesRemoved !== undefined) {
|
||||
lines.push(`- **Lines Removed**: ${metadata.linesRemoved}`);
|
||||
}
|
||||
|
||||
// Add unique files list if available
|
||||
if (metadata?.uniqueFiles && metadata.uniqueFiles.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('<details>');
|
||||
lines.push(
|
||||
`<summary><strong>Unique Files Referenced (${metadata.uniqueFiles.length})</strong></summary>`,
|
||||
);
|
||||
lines.push('');
|
||||
for (const file of metadata.uniqueFiles) {
|
||||
lines.push(`- \`${sanitizeText(file)}\``);
|
||||
}
|
||||
lines.push('</details>');
|
||||
}
|
||||
|
||||
lines.push('\n---\n');
|
||||
|
||||
// Process each message
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ export function normalizeSessionData(
|
|||
}
|
||||
});
|
||||
|
||||
// Build index of assistant messages by uuid for usageMetadata merging
|
||||
const assistantMessageIndexByUuid = new Map<string, number>();
|
||||
normalized.forEach((message, index) => {
|
||||
if (message.type === 'assistant') {
|
||||
assistantMessageIndexByUuid.set(message.uuid, index);
|
||||
}
|
||||
});
|
||||
|
||||
// Merge tool result information into tool call messages
|
||||
for (const record of originalRecords) {
|
||||
if (record.type !== 'tool_result') continue;
|
||||
|
|
@ -58,6 +66,20 @@ export function normalizeSessionData(
|
|||
mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall);
|
||||
}
|
||||
|
||||
// Merge usageMetadata from assistant records
|
||||
for (const record of originalRecords) {
|
||||
if (record.type !== 'assistant') continue;
|
||||
if (!record.usageMetadata) continue;
|
||||
|
||||
const existingIndex = assistantMessageIndexByUuid.get(record.uuid);
|
||||
if (existingIndex !== undefined) {
|
||||
// Only set if not already present from collect phase
|
||||
if (!normalized[existingIndex].usageMetadata) {
|
||||
normalized[existingIndex].usageMetadata = record.usageMetadata;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...sessionData,
|
||||
messages: normalized,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
|
||||
/**
|
||||
* Universal export message format - SSOT for all export formats.
|
||||
* This is format-agnostic and contains all information needed for any export type.
|
||||
|
|
@ -25,6 +27,9 @@ export interface ExportMessage {
|
|||
/** Model used for assistant messages */
|
||||
model?: string;
|
||||
|
||||
/** Token usage for this message (mainly for assistant messages) */
|
||||
usageMetadata?: GenerateContentResponseUsageMetadata;
|
||||
|
||||
/** For tool_call messages */
|
||||
toolCall?: {
|
||||
toolCallId: string;
|
||||
|
|
@ -44,6 +49,44 @@ export interface ExportMessage {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for export session - contains aggregated statistics and session context.
|
||||
*/
|
||||
export interface ExportMetadata {
|
||||
/** Session ID */
|
||||
sessionId: string;
|
||||
/** ISO timestamp when session started */
|
||||
startTime: string;
|
||||
/** Export timestamp */
|
||||
exportTime: string;
|
||||
/** Current working directory */
|
||||
cwd: string;
|
||||
/** Git repository name, if available */
|
||||
gitRepo?: string;
|
||||
/** Git branch name, if available */
|
||||
gitBranch?: string;
|
||||
/** Model used in the session */
|
||||
model?: string;
|
||||
/** Channel/source identifier */
|
||||
channel?: string;
|
||||
/** Number of user prompts in the session */
|
||||
promptCount: number;
|
||||
/** Context window utilization percentage (0-100) */
|
||||
contextUsagePercent?: number;
|
||||
/** Context window size in tokens (used for calculating percentage) */
|
||||
contextWindowSize?: number;
|
||||
/** Total tokens used (prompt + completion) */
|
||||
totalTokens?: number;
|
||||
/** Number of files written/edited */
|
||||
filesWritten?: number;
|
||||
/** Lines of code added */
|
||||
linesAdded?: number;
|
||||
/** Lines of code removed */
|
||||
linesRemoved?: number;
|
||||
/** Unique files referenced in the session (written files only) */
|
||||
uniqueFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete export session data - the single source of truth.
|
||||
*/
|
||||
|
|
@ -51,4 +94,6 @@ export interface ExportSessionData {
|
|||
sessionId: string;
|
||||
startTime: string;
|
||||
messages: ExportMessage[];
|
||||
/** Session metadata and statistics */
|
||||
metadata?: ExportMetadata;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue