From 3a998da1fef7a0ff323bc010ff974d24d996ecd7 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Fri, 1 May 2026 14:12:25 -0700 Subject: [PATCH 1/6] chronicle skill --- .../assets/prompts/skills/chronicle/SKILL.md | 100 +++ extensions/copilot/package.json | 40 +- extensions/copilot/package.nls.json | 4 - .../copilot/src/extension/common/constants.ts | 5 - .../src/extension/intents/node/agentIntent.ts | 1 - .../src/extension/intents/node/allIntents.ts | 2 - .../extension/intents/node/chronicleIntent.ts | 652 ------------------ .../prompts/node/panel/chroniclePrompt.tsx | 41 -- .../tools/node/sessionStoreSqlTool.ts | 298 +++++++- 9 files changed, 405 insertions(+), 738 deletions(-) create mode 100644 extensions/copilot/assets/prompts/skills/chronicle/SKILL.md delete mode 100644 extensions/copilot/src/extension/intents/node/chronicleIntent.ts delete mode 100644 extensions/copilot/src/extension/prompts/node/panel/chroniclePrompt.tsx diff --git a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md new file mode 100644 index 0000000000000..fe2869050932d --- /dev/null +++ b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md @@ -0,0 +1,100 @@ +--- +name: chronicle +description: Analyze Copilot session history for standup reports, usage tips, and session reindexing. Use when the user asks for a standup, daily summary, usage tips, workflow recommendations, or wants to reindex their session store. +context: fork +--- + +# Chronicle + +Analyze the user's Copilot session history using the `session_store_sql` tool. This skill handles standup reports, usage analysis, and session store maintenance. + +## Available Tool Actions + +The `session_store_sql` tool supports three actions: + +| Action | Purpose | `query` param | +|--------|---------|---------------| +| `standup` | Pre-fetch last 24h sessions, turns, files, refs | Not needed | +| `query` | Execute a read-only SQL query | Required | +| `reindex` | Rebuild local session index from debug logs | Not needed | + +## Workflows + +### Standup + +When the user asks for a standup, daily summary, or "what did I do": + +1. Call `session_store_sql` with `action: "standup"` and `description: "Generate standup"`. +2. The tool returns pre-fetched session data (sessions, turns, files, refs from the last 24 hours). +3. Format the returned data as a standup report grouped by work stream (branch/feature): + +``` +**✅ Done** + +**Feature name** (`branch-name` branch, `repo-name`) + - Summary of what was accomplished (grounded in user messages and assistant responses) + - Key files: 2-3 most important files changed + - PR: [#123](link) (if applicable) + +**🚧 In Progress** + +**Feature name** (`branch-name` branch, `repo-name`) + - Summary of current work + - Key files: 2-3 most important files being worked on +``` + +Rules: +- Use turn data (user messages AND assistant responses) to understand WHAT was done +- Use file paths to identify which components/areas were affected +- Group related sessions on the same branch into one entry +- Link PRs and issues using markdown link syntax +- Classify as Done if work appears complete, In Progress otherwise + +### Tips + +When the user asks for tips, workflow recommendations, or how to improve: + +1. IMMEDIATELY call `session_store_sql` with `action: "query"` to query sessions from the last 7 days. Do not explain what you will do first. +2. Query the turns table to understand prompting patterns and conversation flow. +3. Query session_files to see which files and tools are used most frequently. +4. Query session_refs to see PR/issue/commit activity patterns. +5. Based on ALL this data, provide 3-5 specific, actionable tips grounded in actual usage patterns. + +Analysis dimensions to explore: +- **Prompting patterns**: Are user messages vague or specific? Do they provide context? Average turns per session? +- **Tool usage**: Which tools are used most? Are there underutilized tools that could help? +- **Session patterns**: How long are sessions? Are there many short abandoned sessions? +- **File patterns**: Which areas of the codebase get the most attention? Any repeated edits to the same files? +- **Workflow**: Is the user leveraging agent mode, inline chat, custom instructions, prompt files? + +### Reindex + +When the user asks to reindex, rebuild, or refresh their session store: + +1. Call `session_store_sql` with `action: "reindex"` and `description: "Reindex sessions"`. +2. The tool returns before/after stats showing sessions, turns, files, and refs counts. +3. Present the stats to the user. + +## Query Guidelines + +When using `action: "query"`: +- Only one query per call — do not combine multiple statements with semicolons +- Always use LIMIT (max 100) and prefer aggregations (COUNT, GROUP BY) over raw row dumps +- Query the **turns** table for conversation content — it gives the richest insight into what happened +- Query **session_files** for file paths and tool usage patterns +- Query **session_refs** for PR/issue/commit links +- Join tables using session_id for complete analysis +- Always filter on **updated_at** (not created_at) for time ranges +- Always JOIN sessions with turns to get session content — do not rely on sessions.summary alone + +## Database Schema + +### Tables (SQLite syntax — local) + +- **sessions**: id, cwd (workspace folder path), repository, branch, summary, host_type, agent_name, agent_description, created_at, updated_at +- **turns**: session_id, turn_index, user_message, assistant_response (first ~1000 chars, may be truncated), timestamp +- **session_files**: session_id, file_path, tool_name, turn_index +- **session_refs**: session_id, ref_type (commit/pr/issue), ref_value, turn_index +- **search_index**: FTS5 table. Use `WHERE search_index MATCH 'query'` for full-text search + +Date math: `datetime('now', '-1 day')`, `datetime('now', '-7 days')` diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 112aca191a8a8..408636a483db2 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1234,24 +1234,29 @@ "name": "copilot_sessionStoreSql", "displayName": "Session Store SQL", "toolReferenceName": "sessionStoreSql", + "when": "github.copilot.sessionSearch.enabled", "userDescription": "Query your Copilot session history using SQL", - "modelDescription": "Execute read-only SQL queries against the global session store containing history from ALL past coding sessions. Use this proactively when the user asks about:\n- What they've worked on recently or in the past\n- Prior approaches to similar problems\n- Project history and file changes\n- Sessions linked to PRs, issues, or commits\n- Temporal queries ('what was I doing yesterday?')\n\nSupports SQLite SQL including JOINs, FTS5 MATCH queries, aggregations, and subqueries.\n\n**Only one query per call — do not combine multiple statements with semicolons.**\n\nSchema:\n- sessions — id, cwd, repository, branch, summary, created_at, updated_at\n- turns — session_id, turn_index, user_message, assistant_response, timestamp\n- session_files — session_id, file_path, tool_name (edit/create), turn_index\n- session_refs — session_id, ref_type (commit/pr/issue), ref_value, turn_index\n- search_index — FTS5 virtual table (content, session_id, source_type). Use WHERE search_index MATCH 'query' for full-text search.", + "modelDescription": "Interact with the global session store containing history from ALL past coding sessions.\n\nSupports three actions via the `action` parameter:\n\n**action: 'query' (default)** — Execute a read-only SQL query. Use this proactively when the user asks about what they've worked on, prior approaches, project history, sessions linked to PRs/issues/commits, or temporal queries ('what was I doing yesterday?'). Supports SQLite SQL including JOINs, FTS5 MATCH queries, aggregations, and subqueries. Only one query per call — do not combine multiple statements with semicolons.\n\nSchema:\n- sessions — id, cwd, repository, branch, summary, agent_name, agent_description, created_at, updated_at\n- turns — session_id, turn_index, user_message, assistant_response, timestamp\n- session_files — session_id, file_path, tool_name (edit/create), turn_index\n- session_refs — session_id, ref_type (commit/pr/issue), ref_value, turn_index\n- search_index — FTS5 virtual table (content, session_id, source_type). Use WHERE search_index MATCH 'query' for full-text search.\n\n**action: 'standup'** — Pre-fetches last 24 hours of sessions, turns, files, and refs (merging local and cloud data). Returns a formatted data blob ready for standup summarisation. No `query` parameter needed.\n\n**action: 'reindex'** — Rebuilds the local session store by re-reading debug logs from disk. Returns before/after stats. No `query` parameter needed.", "tags": [], "canBeReferencedInPrompt": false, "inputSchema": { "type": "object", "properties": { + "action": { + "type": "string", + "enum": ["query", "standup", "reindex"], + "description": "The action to perform. 'query' (default) executes a SQL query. 'standup' pre-fetches last 24h session data for standup reports. 'reindex' rebuilds the local session index." + }, "query": { "type": "string", - "description": "A single read-only SQL query to execute. Supports SELECT, WITH, JOINs, aggregations, and FTS5 MATCH. Only one statement per call — do not combine multiple queries with semicolons." + "description": "A single read-only SQL query to execute. Required when action is 'query'. Supports SELECT, WITH, JOINs, aggregations, and FTS5 MATCH. Only one statement per call — do not combine multiple queries with semicolons." }, "description": { "type": "string", - "description": "A 2-5 word summary of what this query does (e.g. 'Recent sessions overview', 'Find PR sessions')." + "description": "A 2-5 word summary of what this call does (e.g. 'Recent sessions overview', 'Generate standup', 'Reindex sessions')." } }, "required": [ - "query", "description" ] } @@ -1557,26 +1562,6 @@ "name": "compact", "description": "%copilot.agent.compact.description%" }, - { - "name": "chronicle", - "description": "%copilot.chronicle.description%", - "when": "github.copilot.sessionSearch.enabled" - }, - { - "name": "chronicle:standup", - "description": "%copilot.chronicle.standup.description%", - "when": "github.copilot.sessionSearch.enabled" - }, - { - "name": "chronicle:tips", - "description": "%copilot.chronicle.tips.description%", - "when": "github.copilot.sessionSearch.enabled" - }, - { - "name": "chronicle:reindex", - "description": "%copilot.chronicle.reindex.description%", - "when": "github.copilot.sessionSearch.enabled" - }, { "name": "explain", "description": "%copilot.workspace.explain.description%" @@ -6453,6 +6438,13 @@ "sessionTypes": [ "local" ] + }, + { + "path": "./assets/prompts/skills/chronicle/SKILL.md", + "when": "github.copilot.sessionSearch.enabled", + "sessionTypes": [ + "local" + ] } ], "terminal": { diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 36eb47115313a..1072df4f00548 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -168,10 +168,6 @@ "copilot.edits.description": "Edit files in your workspace", "copilot.agent.description": "Edit files in your workspace in agent mode", "copilot.agent.compact.description": "Free up context by compacting the conversation history. Optionally include extra instructions for compaction.", - "copilot.chronicle.description": "Session history tools and insights", - "copilot.chronicle.standup.description": "Generate a standup report from recent chat sessions", - "copilot.chronicle.tips.description": "Get personalized tips based on your chat session usage patterns", - "copilot.chronicle.reindex.description": "Rebuild the local session index from stored session logs. Add 'force' to re-process already indexed sessions.", "github.copilot.config.sessionSearch.enabled": "Enable session search and /chronicle commands. This is a team-internal setting.", "github.copilot.config.sessionSearch.localIndex.enabled": "Enable local session tracking. When enabled, Copilot tracks session data locally for /chronicle commands.", "github.copilot.config.localIndex.enabled": "Enable local session tracking. When enabled, session data is tracked locally for /chronicle commands.", diff --git a/extensions/copilot/src/extension/common/constants.ts b/extensions/copilot/src/extension/common/constants.ts index 0251d33c8b8e2..a7798aec49709 100644 --- a/extensions/copilot/src/extension/common/constants.ts +++ b/extensions/copilot/src/extension/common/constants.ts @@ -29,7 +29,6 @@ export const enum Intent { SearchPanel = 'searchPanel', SearchKeywords = 'searchKeywords', AskAgent = 'askAgent', - Chronicle = 'chronicle', } export const GITHUB_PLATFORM_AGENT = 'github.copilot-dynamic.platform'; @@ -47,10 +46,6 @@ export const agentsToCommands: Partial>> = 'semanticSearch': Intent.SemanticSearch, 'setupTests': Intent.SetupTests, 'compact': Intent.Agent, - 'chronicle': Intent.Chronicle, - 'chronicle:standup': Intent.Chronicle, - 'chronicle:tips': Intent.Chronicle, - 'chronicle:reindex': Intent.Chronicle, }, [Intent.VSCode]: { 'search': Intent.Search, diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 1533bc779a16b..e28129961f5c6 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -134,7 +134,6 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. allowTools['task_complete'] = request.permissionLevel === 'autopilot'; allowTools[ToolName.EditFilesPlaceholder] = false; - allowTools[ToolName.SessionStoreSql] = false; // Only available via /chronicle // todo@connor4312: string check here is for back-compat for 1.109 Insiders if (Iterable.some(request.tools, ([t, enabled]) => (typeof t === 'string' ? t : t.name) === ContributedToolName.EditFilesPlaceholder && enabled === false)) { allowTools[ToolName.ApplyPatch] = false; diff --git a/extensions/copilot/src/extension/intents/node/allIntents.ts b/extensions/copilot/src/extension/intents/node/allIntents.ts index 1a14e3a38defb..291433a69ea71 100644 --- a/extensions/copilot/src/extension/intents/node/allIntents.ts +++ b/extensions/copilot/src/extension/intents/node/allIntents.ts @@ -9,7 +9,6 @@ import { InlineChatIntent } from '../../inlineChat2/node/inlineChatIntent'; import { IntentRegistry } from '../../prompt/node/intentRegistry'; import { AgentIntent } from './agentIntent'; import { AskAgentIntent } from './askAgentIntent'; -import { ChronicleIntent } from './chronicleIntent'; import { EditCodeIntent } from './editCodeIntent'; import { ExplainIntent } from './explainIntent'; import { FixIntent } from './fixIntent'; @@ -49,5 +48,4 @@ IntentRegistry.setIntents([ new SyncDescriptor(AskAgentIntent), new SyncDescriptor(NotebookEditorIntent), new SyncDescriptor(InlineChatIntent), - new SyncDescriptor(ChronicleIntent), ]); diff --git a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts b/extensions/copilot/src/extension/intents/node/chronicleIntent.ts deleted file mode 100644 index 307315fc8f016..0000000000000 --- a/extensions/copilot/src/extension/intents/node/chronicleIntent.ts +++ /dev/null @@ -1,652 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as l10n from '@vscode/l10n'; -import type * as vscode from 'vscode'; -import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; -import { ICopilotTokenManager } from '../../../platform/authentication/common/copilotTokenManager'; -import { ChatLocation } from '../../../platform/chat/common/commonTypes'; -import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; -import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; -import { type SessionRow, type RefRow, ISessionStore } from '../../../platform/chronicle/common/sessionStore'; -import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; -import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; -import { IChatEndpoint } from '../../../platform/networking/common/networking'; -import { IGitService } from '../../../platform/git/common/gitService'; -import { CancellationToken } from '../../../util/vs/base/common/cancellation'; -import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { LanguageModelChatMessage } from '../../../vscodeTypes'; -import { type AnnotatedRef, type AnnotatedSession, type SessionFileInfo, type SessionTurnInfo, SESSIONS_QUERY_SQLITE, buildRefsQuery, buildFilesQuery, buildTurnsQuery, buildStandupPrompt } from '../../chronicle/common/standupPrompt'; -import { SessionIndexingPreference } from '../../chronicle/common/sessionIndexingPreference'; -import { CloudSessionStoreClient } from '../../chronicle/node/cloudSessionStoreClient'; -import { IFetcherService } from '../../../platform/networking/common/fetcherService'; -import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; -import { IToolsService } from '../../tools/common/toolsService'; -import { ToolName } from '../../tools/common/toolNames'; -import { Conversation } from '../../prompt/common/conversation'; -import { IBuildPromptContext } from '../../prompt/common/intents'; -import { ChatTelemetryBuilder } from '../../prompt/node/chatParticipantTelemetry'; -import { IDocumentContext } from '../../prompt/node/documentContext'; -import { DefaultIntentRequestHandler } from '../../prompt/node/defaultIntentRequestHandler'; -import { IIntent, IIntentInvocation, IIntentInvocationContext, IIntentSlashCommandInfo, IntentLinkificationOptions } from '../../prompt/node/intents'; -import { PromptRenderer, RendererIntentInvocation } from '../../prompts/node/base/promptRenderer'; -import { ChroniclePrompt } from '../../prompts/node/panel/chroniclePrompt'; -import { reindexSessions } from '../../chronicle/node/sessionReindexer'; - -/** Cloud SQL dialect sessions query. */ -const SESSIONS_QUERY_CLOUD = `SELECT * - FROM sessions - WHERE updated_at >= now() - INTERVAL '1 day' - ORDER BY updated_at DESC - LIMIT 100`; - -const SUBCOMMANDS = ['standup', 'tips', 'improve', 'reindex'] as const; -type ChronicleSubcommand = typeof SUBCOMMANDS[number]; - -export class ChronicleIntent implements IIntent { - - static readonly ID = 'chronicle'; - readonly id = ChronicleIntent.ID; - readonly description = l10n.t('Session history tools and insights (standup, tips, improve)'); - get locations(): ChatLocation[] { - return this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService) ? [ChatLocation.Panel] : []; - } - - readonly commandInfo: IIntentSlashCommandInfo = { - allowsEmptyArgs: true, - }; - - constructor( - @IEndpointProvider private readonly endpointProvider: IEndpointProvider, - @ISessionStore private readonly _sessionStore: ISessionStore, - @ICopilotTokenManager private readonly _tokenManager: ICopilotTokenManager, - @IAuthenticationService private readonly _authService: IAuthenticationService, - @IGitService _gitService: IGitService, - @IConfigurationService private readonly _configService: IConfigurationService, - @IInstantiationService private readonly _instantiationService: IInstantiationService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, - @IExperimentationService private readonly _expService: IExperimentationService, - @IFetcherService private readonly _fetcherService: IFetcherService, - @IChatDebugFileLoggerService private readonly _debugLogService: IChatDebugFileLoggerService, - ) { - this._indexingPreference = new SessionIndexingPreference(this._configService); - } - - private readonly _indexingPreference: SessionIndexingPreference; - - /** Stashed system prompt for tool-calling subcommands (tips, free-form). */ - private _pendingSystemPrompt: string | undefined; - - async handleRequest( - conversation: Conversation, - request: vscode.ChatRequest, - stream: vscode.ChatResponseStream, - token: CancellationToken, - documentContext: IDocumentContext | undefined, - _agentName: string, - location: ChatLocation, - chatTelemetry: ChatTelemetryBuilder, - ): Promise { - if (!this._configService.getExperimentBasedConfig(ConfigKey.LocalIndexEnabled, this._expService)) { - stream.markdown(l10n.t('Session search is not available yet.')); - return {}; - } - - // Route by command name (e.g. 'chronicle:standup') or fall back to parsing the prompt - const { subcommand, rest } = this._resolveSubcommand(request); - - switch (subcommand) { - case 'standup': - return this._handleStandup(rest, stream, request, token); - case 'tips': - return this._handleTips(rest, stream, request, token, conversation, documentContext, location, chatTelemetry); - case 'reindex': - return this._handleReindex(rest, stream, token); - case 'improve': - stream.markdown(l10n.t('`/chronicle {0}` is not yet implemented. Try `/chronicle:standup` or `/chronicle:tips`.', subcommand)); - return {}; - default: - return this._handleFreeForm(request.prompt ?? '', stream, request, token, conversation, documentContext, location, chatTelemetry); - } - } - - /** - * Resolve the subcommand from the request command (e.g. 'chronicle:standup') - * or fall back to parsing the prompt text for backwards compatibility. - */ - private _resolveSubcommand(request: vscode.ChatRequest): { subcommand: ChronicleSubcommand | string; rest: string | undefined } { - // Prefer explicit command routing (e.g. /chronicle:standup) - if (request.command) { - const colonIdx = request.command.indexOf(':'); - if (colonIdx !== -1) { - return { - subcommand: request.command.slice(colonIdx + 1).toLowerCase(), - rest: request.prompt?.trim() || undefined, - }; - } - } - - // Fall back to parsing the prompt (for bare /chronicle or /chronicle standup) - const trimmed = request.prompt?.trim() ?? ''; - if (!trimmed) { - return { subcommand: 'standup', rest: undefined }; - } - const spaceIdx = trimmed.indexOf(' '); - if (spaceIdx === -1) { - return { subcommand: trimmed.toLowerCase(), rest: undefined }; - } - return { - subcommand: trimmed.slice(0, spaceIdx).toLowerCase(), - rest: trimmed.slice(spaceIdx + 1).trim() || undefined, - }; - } - - private async _handleReindex( - rest: string | undefined, - stream: vscode.ChatResponseStream, - token: CancellationToken, - ): Promise { - const force = rest?.toLowerCase().includes('force') ?? false; - const statsBefore = this._sessionStore.getStats(); - const startTime = Date.now(); - - stream.progress(l10n.t('Discovering sessions...')); - - const result = await reindexSessions( - this._sessionStore, - this._debugLogService, - (message: string) => stream.progress(message), - token, - force, - ); - - const statsAfter = this._sessionStore.getStats(); - - const lines: string[] = []; - if (result.cancelled) { - lines.push(l10n.t('Reindex cancelled.')); - } else { - lines.push(l10n.t('Reindex complete.')); - } - - lines.push(''); - lines.push(`| | ${l10n.t('Before')} | ${l10n.t('After')} | ${l10n.t('Delta')} |`); - lines.push('|---|---|---|---|'); - lines.push(`| ${l10n.t('Sessions')} | ${statsBefore.sessions} | ${statsAfter.sessions} | +${statsAfter.sessions - statsBefore.sessions} |`); - lines.push(`| ${l10n.t('Turns')} | ${statsBefore.turns} | ${statsAfter.turns} | +${statsAfter.turns - statsBefore.turns} |`); - lines.push(`| ${l10n.t('Files')} | ${statsBefore.files} | ${statsAfter.files} | +${statsAfter.files - statsBefore.files} |`); - lines.push(`| ${l10n.t('Refs')} | ${statsBefore.refs} | ${statsAfter.refs} | +${statsAfter.refs - statsBefore.refs} |`); - lines.push(''); - lines.push(l10n.t('{0} session(s) processed, {1} skipped.', result.processed, result.skipped)); - - stream.markdown(lines.join('\n')); - - this._telemetryService.sendMSFTTelemetryEvent('chronicle', { - subcommand: 'reindex', - querySource: 'local', - force: String(force), - cancelled: String(result.cancelled), - }, { - localSessionCount: result.processed, - cloudSessionCount: 0, - totalSessionCount: result.processed + result.skipped, - skippedCount: result.skipped, - durationMs: Date.now() - startTime, - }); - - return {}; - } - - private async _handleStandup( - extra: string | undefined, - stream: vscode.ChatResponseStream, - request: vscode.ChatRequest, - token: CancellationToken, - ): Promise { - // Always query local SQLite (has current machine's sessions) - const localSessions = this._queryLocalStore(); - - // Query cloud if user has cloud consent for any repo - let cloudSessions: { sessions: AnnotatedSession[]; refs: AnnotatedRef[] } = { sessions: [], refs: [] }; - if (this._indexingPreference.hasCloudConsent()) { - cloudSessions = await this._queryCloudStore(); - } - - // Merge and dedup by session ID (cloud wins on conflict since it has cross-machine data) - const seenIds = new Set(); - const sessions: AnnotatedSession[] = []; - const refs: AnnotatedRef[] = []; - - // Add cloud sessions first (higher priority) - for (const s of cloudSessions.sessions) { - if (!seenIds.has(s.id)) { - seenIds.add(s.id); - sessions.push(s); - } - } - // Add local sessions not already in cloud - for (const s of localSessions.sessions) { - if (!seenIds.has(s.id)) { - seenIds.add(s.id); - sessions.push(s); - } - } - // Merge refs (dedup by session_id + ref_type + ref_value) - const seenRefs = new Set(); - for (const r of [...cloudSessions.refs, ...localSessions.refs]) { - const key = `${r.session_id}:${r.ref_type}:${r.ref_value}`; - if (!seenRefs.has(key)) { - seenRefs.add(key); - refs.push(r); - } - } - - // Sort by updated_at descending, cap to 20 - sessions.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? '')); - const capped = sessions.slice(0, 20); - const cappedIds = new Set(capped.map(s => s.id)); - const cappedRefs = refs.filter(r => cappedIds.has(r.session_id)); - - // Fetch turns and files for capped sessions - let cappedTurns: SessionTurnInfo[] = []; - let cappedFiles: SessionFileInfo[] = []; - if (capped.length > 0) { - const ids = capped.map(s => s.id); - try { - cappedTurns = this._sessionStore.executeReadOnlyFallback(buildTurnsQuery(ids)) as unknown as SessionTurnInfo[]; - } catch { /* non-fatal */ } - try { - cappedFiles = this._sessionStore.executeReadOnlyFallback(buildFilesQuery(ids)) as unknown as SessionFileInfo[]; - } catch { /* non-fatal */ } - - // Fetch and merge cloud turns and files (only for capped sessions) - if (this._indexingPreference.hasCloudConsent()) { - const cloudDetail = await this._queryCloudTurnsAndFiles(ids); - - // Merge cloud turns (dedup by session_id + turn_index) - if (cloudDetail.turns.length > 0) { - const seenTurns = new Set(cappedTurns.map(t => `${t.session_id}:${t.turn_index}`)); - for (const t of cloudDetail.turns) { - if (!seenTurns.has(`${t.session_id}:${t.turn_index}`)) { - cappedTurns.push(t); - } - } - } - - // Merge cloud files (dedup by session_id + file_path) - if (cloudDetail.files.length > 0) { - const seenFiles = new Set(cappedFiles.map(f => `${f.session_id}:${f.file_path}`)); - for (const f of cloudDetail.files) { - if (!seenFiles.has(`${f.session_id}:${f.file_path}`)) { - cappedFiles.push(f); - } - } - } - } - } - - const standupPrompt = buildStandupPrompt(capped, cappedRefs, cappedTurns, cappedFiles, extra); - - if (capped.length === 0) { - stream.markdown(l10n.t('No sessions found. There\'s nothing to report for a standup.')); - return {}; - } - - const localCount = capped.filter(s => s.source !== 'cloud').length; - const cloudCount = capped.filter(s => s.source === 'cloud').length; - - this._sendTelemetry('standup', localCount, cloudCount); - - if (cloudCount > 0 && localCount > 0) { - stream.progress(l10n.t('Generating standup from {0} cloud and {1} local session(s)...', cloudCount, localCount)); - } else if (cloudCount > 0) { - stream.progress(l10n.t('Generating standup from {0} cloud session(s)...', cloudCount)); - } else { - stream.progress(l10n.t('Generating standup from {0} local session(s)...', localCount)); - } - - const model = request.model; - const messages = [ - LanguageModelChatMessage.User(standupPrompt), - ]; - - try { - const response = await model.sendRequest(messages, {}, token); - - for await (const part of response.text) { - stream.markdown(part); - } - } catch (err) { - stream.markdown(l10n.t('Failed to generate standup. Please try again.')); - } - - return {}; - } - - private async _handleTips( - extra: string | undefined, - stream: vscode.ChatResponseStream, - request: vscode.ChatRequest, - token: CancellationToken, - conversation: Conversation, - documentContext: IDocumentContext | undefined, - location: ChatLocation, - chatTelemetry: ChatTelemetryBuilder, - ): Promise { - const hasCloud = this._indexingPreference.hasCloudConsent(); - const schema = this._getSchemaDescription(hasCloud); - - let prompt = `You have access to the session_store_sql tool that can execute read-only SQL queries against the user's Copilot session database. - -Your task: Analyze the user's Copilot usage patterns and provide personalized, actionable recommendations. - -Database schema: - -${schema} - -Instructions: -1. IMMEDIATELY call the session_store_sql tool to query sessions from the last 7 days. Do not explain what you will do first. -2. Query the turns table to understand what kinds of prompts the user writes and how conversations flow. -3. Query session_files to see which files and tools are used most frequently. -4. Query session_refs to see PR/issue/commit activity patterns. -5. Based on ALL this data, provide 3-5 specific, actionable tips grounded in actual usage patterns. - -Analysis dimensions to explore: -- **Prompting patterns**: Are user messages vague or specific? Do they provide context? Average turns per session? -- **Tool usage**: Which tools are used most? Are there underutilized tools that could help? -- **Session patterns**: How long are sessions? Are there many short abandoned sessions? -- **File patterns**: Which areas of the codebase get the most attention? Any repeated edits to the same files? -- **Workflow**: Is the user leveraging agent mode, inline chat, custom instructions, prompt files? - -Query guidelines: -- Only one query per call — do not combine multiple statements with semicolons. -- Always use LIMIT (max 100) in your queries and prefer aggregations (COUNT, GROUP BY) over raw row dumps. -- Use the turns table to understand conversation quality, not just session metadata.`; - - if (extra) { - prompt += `\n\nThe user is especially interested in: ${extra}`; - } - - this._pendingSystemPrompt = prompt; - this._sendTelemetry('tips', 0, 0); - return this._delegateToToolCallingHandler(conversation, request, stream, token, documentContext, location, chatTelemetry); - } - - private async _handleFreeForm( - userQuery: string, - stream: vscode.ChatResponseStream, - request: vscode.ChatRequest, - token: CancellationToken, - conversation: Conversation, - documentContext: IDocumentContext | undefined, - location: ChatLocation, - chatTelemetry: ChatTelemetryBuilder, - ): Promise { - const hasCloud = this._indexingPreference.hasCloudConsent(); - const schema = this._getSchemaDescription(hasCloud); - - this._pendingSystemPrompt = `The user is asking about their Copilot session history. Use the session_store_sql tool to query the data and answer their question. - -${schema} - -User's question: ${userQuery} - -Use the session_store_sql tool to run queries. Start with a broad query, then drill down as needed. -- Only SELECT queries are allowed -- Only one query per call — do not combine multiple statements with semicolons -- Always use LIMIT (max 100) and prefer aggregations (COUNT, GROUP BY) over raw row dumps -- Query the **turns** table for conversation content (user_message, assistant_response) — this gives the richest insight into what happened -- Query **session_files** for file paths and tool usage patterns -- Query **session_refs** for PR/issue/commit links -- Join tables to correlate sessions with their turns, files, and refs for complete answers -- Present results in a clear, readable format with markdown tables or bullet points`; - - this._sendTelemetry('freeform', 0, 0); - return this._delegateToToolCallingHandler(conversation, request, stream, token, documentContext, location, chatTelemetry); - } - - private async _delegateToToolCallingHandler( - conversation: Conversation, - request: vscode.ChatRequest, - stream: vscode.ChatResponseStream, - token: CancellationToken, - documentContext: IDocumentContext | undefined, - location: ChatLocation, - chatTelemetry: ChatTelemetryBuilder, - ): Promise { - const handler = this._instantiationService.createInstance( - DefaultIntentRequestHandler, - this, - conversation, - request, - stream, - token, - documentContext, - location, - chatTelemetry, - { maxToolCallIterations: 8, temperature: 0, confirmOnMaxToolIterations: false }, - undefined, - ); - return handler.getResult(); - } - - private _sendTelemetry(subcommand: string, localSessionCount: number, cloudSessionCount: number): void { - const hasCloudConsent = this._indexingPreference.hasCloudConsent(); - const querySource = hasCloudConsent ? (localSessionCount > 0 ? 'both' : 'cloud') : 'local'; - /* __GDPR__ -"chronicle" : { -"owner": "vijayu", -"comment": "Tracks chronicle subcommand usage, data sources, and query failures", -"subcommand": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The chronicle subcommand: standup, tips, freeform, or reindex." }, -"querySource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The data source used: local, cloud, both, or cloudRefs." }, -"error": { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "comment": "Truncated error message." }, -"force": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether force mode was used (reindex only)." }, -"cancelled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the operation was cancelled (reindex only)." }, -"localSessionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of local sessions used." }, -"cloudSessionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of cloud sessions used." }, -"totalSessionCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Total sessions used." }, -"skippedCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Number of sessions skipped during reindex." }, -"durationMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "Duration of the reindex operation in milliseconds." } -} -*/ - this._telemetryService.sendMSFTTelemetryEvent('chronicle', { - subcommand, - querySource, - }, { - localSessionCount, - cloudSessionCount, - totalSessionCount: localSessionCount + cloudSessionCount, - }); - } - - private _getSchemaDescription(hasCloud: boolean): string { - return hasCloud - ? `Available tables (cloud SQL syntax): -- **sessions**: id, repository, branch, summary, agent_name (who created the session, e.g. 'VS Code', 'cli', 'Copilot Coding Agent', 'Copilot Code Review'), agent_description, created_at, updated_at (TIMESTAMP). NOTE: cwd is always NULL in the cloud. IMPORTANT: Always filter on **updated_at** (not created_at) for time ranges — some session types have created_at set to epoch zero. NOTE: summary and repository/branch may be NULL — always JOIN with turns to get actual content. -- **turns**: session_id, turn_index, user_message, assistant_response, timestamp (TIMESTAMP). The richest and most reliable source of what actually happened — the first turn (turn_index=0) user_message is effectively the session summary. Always JOIN sessions with turns for meaningful results. -- **session_files**: session_id, file_path, tool_name, turn_index. Tracks which files were read/edited and which tools were used. -- **session_refs**: session_id, ref_type (commit/pr/issue), ref_value, turn_index. Tracks PRs created, issues referenced, commits made. - -Use \`now() - INTERVAL '1 day'\` for date math, \`ILIKE\` for text search. -Always JOIN sessions with turns to get session content — do not rely on sessions.summary alone.` - : `Available tables (SQLite syntax — local): -- **sessions**: id, cwd (workspace folder path), repository, branch, summary, host_type, agent_name, agent_description, created_at, updated_at. NOTE: agent_name and agent_description may be empty for older sessions. summary may contain raw JSON — prefer JOINing with turns.user_message for text search. -- **turns**: session_id, turn_index, user_message, assistant_response (first ~1000 characters of the assistant reply, with an ellipsis if truncated — not the full response; may be empty for older sessions), timestamp. The richest source of what actually happened — always JOIN sessions with turns for meaningful results. -- **session_files**: session_id, file_path, tool_name, turn_index. Tracks which files were read/edited and which tools were used. May be empty for older sessions. -- **session_refs**: session_id, ref_type (commit/pr/issue), ref_value, turn_index. Tracks PRs created, issues referenced, commits made. May be empty for older sessions. -- **search_index**: FTS5 table. Use \`WHERE search_index MATCH 'query'\` - -Use \`datetime('now', '-1 day')\` for date math. -Join sessions with turns/files/refs using session_id for complete analysis.`; - } - - /** - * Query the local SQLite session store for sessions and refs. - */ - private _queryLocalStore(): { sessions: AnnotatedSession[]; refs: AnnotatedRef[] } { - try { - // Use fallback (no authorizer) since these are known-safe SELECT queries - const rawSessions = this._sessionStore.executeReadOnlyFallback(SESSIONS_QUERY_SQLITE) as unknown as SessionRow[]; - const sessions: AnnotatedSession[] = rawSessions.map(s => ({ ...s, source: 'vscode' as const })); - - let refs: AnnotatedRef[] = []; - if (sessions.length > 0) { - const ids = sessions.map(s => s.id); - const rawRefs = this._sessionStore.executeReadOnlyFallback(buildRefsQuery(ids)) as unknown as RefRow[]; - refs = rawRefs.map(r => ({ ...r, source: 'vscode' as const })); - } - - return { sessions, refs }; - } catch (err) { - - this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle', { - subcommand: 'standup', - querySource: 'local', - error: err instanceof Error ? err.message.substring(0, 100) : 'unknown', - }, {}); - return { sessions: [], refs: [] }; - } - } - - private async _queryCloudStore(): Promise<{ sessions: AnnotatedSession[]; refs: AnnotatedRef[] }> { - const empty = { sessions: [] as AnnotatedSession[], refs: [] as AnnotatedRef[] }; - try { - const client = new CloudSessionStoreClient(this._tokenManager, this._authService, this._fetcherService); - - const sessionsResult = await client.executeQuery(SESSIONS_QUERY_CLOUD); - if (!sessionsResult || sessionsResult.rows.length === 0) { - return empty; - } - - const sessions: AnnotatedSession[] = sessionsResult.rows.map(r => ({ - id: r.id as string, - summary: r.summary as string | undefined, - branch: r.branch as string | undefined, - repository: r.repository as string | undefined, - agent_name: r.agent_name as string | undefined, - agent_description: r.agent_description as string | undefined, - created_at: r.created_at as string | undefined, - updated_at: r.updated_at as string | undefined, - source: 'cloud' as const, - })); - - // Query refs for these sessions - const ids = sessions.map(s => s.id); - let refs: AnnotatedRef[] = []; - try { - const refsQuery = `SELECT session_id, ref_type, ref_value FROM session_refs WHERE session_id IN (${ids.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(',')})`; - const refsResult = await client.executeQuery(refsQuery); - if (refsResult && refsResult.rows.length > 0) { - refs = refsResult.rows.map(r => ({ - session_id: r.session_id as string, - ref_type: r.ref_type as 'commit' | 'pr' | 'issue', - ref_value: r.ref_value as string, - source: 'cloud' as const, - })); - } - } catch (refsErr) { - - this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle', { - subcommand: 'standup', - querySource: 'cloudRefs', - error: refsErr instanceof Error ? refsErr.message.substring(0, 100) : 'unknown', - }, {}); - } - - return { sessions, refs }; - } catch (err) { - - this._telemetryService.sendMSFTTelemetryErrorEvent('chronicle', { - subcommand: 'standup', - querySource: 'cloud', - error: err instanceof Error ? err.message.substring(0, 100) : 'unknown', - }, {}); - return empty; - } - } - - /** - * Query cloud turns and files for a specific set of session IDs (called after capping). - */ - private async _queryCloudTurnsAndFiles(sessionIds: string[]): Promise<{ turns: SessionTurnInfo[]; files: SessionFileInfo[] }> { - const empty = { turns: [] as SessionTurnInfo[], files: [] as SessionFileInfo[] }; - try { - const client = new CloudSessionStoreClient(this._tokenManager, this._authService, this._fetcherService); - const inClause = sessionIds.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(','); - - let turns: SessionTurnInfo[] = []; - try { - const turnsQuery = `SELECT session_id, turn_index, substring(user_message, 1, 120) as user_message, substring(assistant_response, 1, 200) as assistant_response FROM turns WHERE session_id IN (${inClause}) AND (user_message IS NOT NULL OR assistant_response IS NOT NULL) ORDER BY session_id, turn_index LIMIT 200`; - const turnsResult = await client.executeQuery(turnsQuery); - if (turnsResult && turnsResult.rows.length > 0) { - turns = turnsResult.rows.map(r => ({ - session_id: r.session_id as string, - turn_index: r.turn_index as number, - user_message: r.user_message as string | undefined, - assistant_response: r.assistant_response as string | undefined, - })); - } - } catch { /* non-fatal */ } - - let files: SessionFileInfo[] = []; - try { - const filesQuery = `SELECT session_id, file_path, tool_name FROM session_files WHERE session_id IN (${inClause}) LIMIT 200`; - const filesResult = await client.executeQuery(filesQuery); - if (filesResult && filesResult.rows.length > 0) { - files = filesResult.rows.map(r => ({ - session_id: r.session_id as string, - file_path: r.file_path as string, - tool_name: r.tool_name as string | undefined, - })); - } - } catch { /* non-fatal */ } - - return { turns, files }; - } catch { - return empty; - } - } - - async invoke(invocationContext: IIntentInvocationContext): Promise { - const { location, request } = invocationContext; - const endpoint = await this.endpointProvider.getChatEndpoint(request); - const systemPrompt = this._pendingSystemPrompt ?? ''; - this._pendingSystemPrompt = undefined; - return this._instantiationService.createInstance( - ChronicleIntentInvocation, this, location, endpoint, request, systemPrompt - ); - } -} - -class ChronicleIntentInvocation extends RendererIntentInvocation implements IIntentInvocation { - - readonly linkification: IntentLinkificationOptions = { disable: false }; - - constructor( - intent: IIntent, - location: ChatLocation, - endpoint: IChatEndpoint, - private readonly request: vscode.ChatRequest, - private readonly systemPrompt: string, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IToolsService private readonly toolsService: IToolsService, - ) { - super(intent, location, endpoint); - } - - async createRenderer(promptContext: IBuildPromptContext, endpoint: IChatEndpoint, _progress: vscode.Progress, _token: vscode.CancellationToken) { - return PromptRenderer.create(this.instantiationService, endpoint, ChroniclePrompt, { - endpoint, - promptContext, - systemPrompt: this.systemPrompt, - }); - } - - getAvailableTools(): vscode.LanguageModelToolInformation[] | Promise | undefined { - return this.toolsService.getEnabledTools(this.request, this.endpoint, - tool => tool.name === ToolName.SessionStoreSql - ); - } -} diff --git a/extensions/copilot/src/extension/prompts/node/panel/chroniclePrompt.tsx b/extensions/copilot/src/extension/prompts/node/panel/chroniclePrompt.tsx deleted file mode 100644 index 1d9a9d21db503..0000000000000 --- a/extensions/copilot/src/extension/prompts/node/panel/chroniclePrompt.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { BasePromptElementProps, PromptElement, SystemMessage, UserMessage } from '@vscode/prompt-tsx'; -import { SafetyRules } from '../base/safetyRules'; -import { CopilotIdentityRules } from '../base/copilotIdentity'; -import { IBuildPromptContext } from '../../../prompt/common/intents'; -import { IChatEndpoint } from '../../../../platform/networking/common/networking'; -import { ChatToolCalls } from './toolCalling'; - -export interface ChroniclePromptProps extends BasePromptElementProps { - promptContext: IBuildPromptContext; - endpoint: IChatEndpoint; - systemPrompt: string; -} - -export class ChroniclePrompt extends PromptElement { - render() { - const userQuery = this.props.promptContext.query || 'Go ahead.'; - return ( - <> - - - - {this.props.systemPrompt} - - {userQuery} - - - ); - } -} diff --git a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts index 033aa0fdc2a93..281f7e020f915 100644 --- a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts +++ b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts @@ -7,11 +7,14 @@ import * as l10n from '@vscode/l10n'; import type * as vscode from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { ICopilotTokenManager } from '../../../platform/authentication/common/copilotTokenManager'; -import { ISessionStore } from '../../../platform/chronicle/common/sessionStore'; +import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService'; +import { type SessionRow, type RefRow, ISessionStore } from '../../../platform/chronicle/common/sessionStore'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { LanguageModelTextPart, LanguageModelToolResult } from '../../../vscodeTypes'; +import { type AnnotatedSession, type AnnotatedRef, type SessionFileInfo, type SessionTurnInfo, SESSIONS_QUERY_SQLITE, buildRefsQuery, buildFilesQuery, buildTurnsQuery, buildStandupPrompt } from '../../chronicle/common/standupPrompt'; import { SessionIndexingPreference } from '../../chronicle/common/sessionIndexingPreference'; import { CloudSessionStoreClient } from '../../chronicle/node/cloudSessionStoreClient'; +import { reindexSessions } from '../../chronicle/node/sessionReindexer'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; @@ -30,10 +33,18 @@ const BLOCKED_PATTERNS = [ ]; export interface SessionStoreSqlParams { - readonly query: string; + readonly action?: 'query' | 'standup' | 'reindex'; + readonly query?: string; readonly description: string; } +/** Cloud SQL dialect sessions query. */ +const SESSIONS_QUERY_CLOUD = `SELECT * + FROM sessions + WHERE updated_at >= now() - INTERVAL '1 day' + ORDER BY updated_at DESC + LIMIT 100`; + class SessionStoreSqlTool implements ICopilotTool { public static readonly toolName = ToolName.SessionStoreSql; public static readonly nonDeferred = true; @@ -47,6 +58,7 @@ class SessionStoreSqlTool implements ICopilotTool { @IConfigurationService configService: IConfigurationService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IFetcherService private readonly _fetcherService: IFetcherService, + @IChatDebugFileLoggerService private readonly _debugLogService: IChatDebugFileLoggerService, ) { this._indexingPreference = new SessionIndexingPreference(configService); } @@ -55,8 +67,20 @@ class SessionStoreSqlTool implements ICopilotTool { options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken, ): Promise { + const action = options.input.action ?? 'query'; + + switch (action) { + case 'standup': + return this._invokeStandup(token); + case 'reindex': + return this._invokeReindex(token); + default: + return this._invokeQuery(options.input.query ?? '', token); + } + } + private async _invokeQuery(rawQuery: string, token: CancellationToken): Promise { // Strip trailing semicolons — models often append them - const sql = options.input.query.trim().replace(/;+\s*$/, ''); + const sql = rawQuery.trim().replace(/;+\s*$/, ''); if (!sql) { return new LanguageModelToolResult([new LanguageModelTextPart('Error: Empty query provided.')]); @@ -67,7 +91,7 @@ class SessionStoreSqlTool implements ICopilotTool { if (pattern.test(sql)) { this._sendTelemetry('blocked', 0, 0, false, 'blocked_mutating_sql'); return new LanguageModelToolResult([ - new LanguageModelTextPart(`Error: Blocked SQL statement. Only SELECT queries are allowed.`), + new LanguageModelTextPart('Error: Blocked SQL statement. Only SELECT queries are allowed.'), ]); } } @@ -130,6 +154,248 @@ class SessionStoreSqlTool implements ICopilotTool { } } + /** + * Standup action: pre-fetch last 24h sessions + turns + files + refs, + * merge local/cloud, dedup, and return formatted data for the model to summarise. + */ + private async _invokeStandup(_token: CancellationToken): Promise { + const startTime = Date.now(); + + try { + // Always query local SQLite (has current machine's sessions) + const localSessions = this._queryLocalStore(); + + // Query cloud if user has cloud consent + let cloudSessions: { sessions: AnnotatedSession[]; refs: AnnotatedRef[] } = { sessions: [], refs: [] }; + if (this._indexingPreference.hasCloudConsent()) { + cloudSessions = await this._queryCloudStore(); + } + + // Merge and dedup by session ID (cloud wins on conflict) + const seenIds = new Set(); + const sessions: AnnotatedSession[] = []; + const refs: AnnotatedRef[] = []; + + for (const s of cloudSessions.sessions) { + if (!seenIds.has(s.id)) { + seenIds.add(s.id); + sessions.push(s); + } + } + for (const s of localSessions.sessions) { + if (!seenIds.has(s.id)) { + seenIds.add(s.id); + sessions.push(s); + } + } + + const seenRefs = new Set(); + for (const r of [...cloudSessions.refs, ...localSessions.refs]) { + const key = `${r.session_id}:${r.ref_type}:${r.ref_value}`; + if (!seenRefs.has(key)) { + seenRefs.add(key); + refs.push(r); + } + } + + // Sort by updated_at descending, cap to 20 + sessions.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? '')); + const capped = sessions.slice(0, 20); + const cappedIds = new Set(capped.map(s => s.id)); + const cappedRefs = refs.filter(r => cappedIds.has(r.session_id)); + + // Fetch turns and files for capped sessions + let cappedTurns: SessionTurnInfo[] = []; + let cappedFiles: SessionFileInfo[] = []; + if (capped.length > 0) { + const ids = capped.map(s => s.id); + try { + cappedTurns = this._sessionStore.executeReadOnlyFallback(buildTurnsQuery(ids)) as unknown as SessionTurnInfo[]; + } catch { /* non-fatal */ } + try { + cappedFiles = this._sessionStore.executeReadOnlyFallback(buildFilesQuery(ids)) as unknown as SessionFileInfo[]; + } catch { /* non-fatal */ } + + if (this._indexingPreference.hasCloudConsent()) { + const cloudDetail = await this._queryCloudTurnsAndFiles(ids); + + if (cloudDetail.turns.length > 0) { + const seenTurns = new Set(cappedTurns.map(t => `${t.session_id}:${t.turn_index}`)); + for (const t of cloudDetail.turns) { + if (!seenTurns.has(`${t.session_id}:${t.turn_index}`)) { + cappedTurns.push(t); + } + } + } + + if (cloudDetail.files.length > 0) { + const seenFiles = new Set(cappedFiles.map(f => `${f.session_id}:${f.file_path}`)); + for (const f of cloudDetail.files) { + if (!seenFiles.has(`${f.session_id}:${f.file_path}`)) { + cappedFiles.push(f); + } + } + } + } + } + + const prompt = buildStandupPrompt(capped, cappedRefs, cappedTurns, cappedFiles); + this._sendTelemetry('standup', capped.length, Date.now() - startTime, true); + return new LanguageModelToolResult([new LanguageModelTextPart(prompt)]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this._sendTelemetry('standup', 0, Date.now() - startTime, false, message.substring(0, 100)); + return new LanguageModelToolResult([new LanguageModelTextPart(`Error fetching standup data: ${message}`)]); + } + } + + /** + * Reindex action: rebuild the local session store from debug logs. + */ + private async _invokeReindex(token: CancellationToken): Promise { + const startTime = Date.now(); + + try { + const statsBefore = this._sessionStore.getStats(); + + const result = await reindexSessions( + this._sessionStore, + this._debugLogService, + () => { /* progress not streamed for tool results */ }, + token, + ); + + const statsAfter = this._sessionStore.getStats(); + + const lines: string[] = []; + if (result.cancelled) { + lines.push('Reindex cancelled.'); + } else { + lines.push('Reindex complete.'); + } + + lines.push(''); + lines.push('| | Before | After | Delta |'); + lines.push('|---|---|---|---|'); + lines.push(`| Sessions | ${statsBefore.sessions} | ${statsAfter.sessions} | +${statsAfter.sessions - statsBefore.sessions} |`); + lines.push(`| Turns | ${statsBefore.turns} | ${statsAfter.turns} | +${statsAfter.turns - statsBefore.turns} |`); + lines.push(`| Files | ${statsBefore.files} | ${statsAfter.files} | +${statsAfter.files - statsBefore.files} |`); + lines.push(`| Refs | ${statsBefore.refs} | ${statsAfter.refs} | +${statsAfter.refs - statsBefore.refs} |`); + lines.push(''); + lines.push(`${result.processed} session(s) processed, ${result.skipped} skipped.`); + + this._sendTelemetry('reindex', result.processed, Date.now() - startTime, true); + return new LanguageModelToolResult([new LanguageModelTextPart(lines.join('\n'))]); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this._sendTelemetry('reindex', 0, Date.now() - startTime, false, message.substring(0, 100)); + return new LanguageModelToolResult([new LanguageModelTextPart(`Error during reindex: ${message}`)]); + } + } + + /** + * Query the local SQLite session store for sessions and refs. + */ + private _queryLocalStore(): { sessions: AnnotatedSession[]; refs: AnnotatedRef[] } { + try { + const rawSessions = this._sessionStore.executeReadOnlyFallback(SESSIONS_QUERY_SQLITE) as unknown as SessionRow[]; + const sessions: AnnotatedSession[] = rawSessions.map(s => ({ ...s, source: 'vscode' as const })); + + let refs: AnnotatedRef[] = []; + if (sessions.length > 0) { + const ids = sessions.map(s => s.id); + const rawRefs = this._sessionStore.executeReadOnlyFallback(buildRefsQuery(ids)) as unknown as RefRow[]; + refs = rawRefs.map(r => ({ ...r, source: 'vscode' as const })); + } + + return { sessions, refs }; + } catch { + return { sessions: [], refs: [] }; + } + } + + private async _queryCloudStore(): Promise<{ sessions: AnnotatedSession[]; refs: AnnotatedRef[] }> { + const empty = { sessions: [] as AnnotatedSession[], refs: [] as AnnotatedRef[] }; + try { + const client = new CloudSessionStoreClient(this._tokenManager, this._authService, this._fetcherService); + + const sessionsResult = await client.executeQuery(SESSIONS_QUERY_CLOUD); + if (!sessionsResult || sessionsResult.rows.length === 0) { + return empty; + } + + const sessions: AnnotatedSession[] = sessionsResult.rows.map(r => ({ + id: r.id as string, + summary: r.summary as string | undefined, + branch: r.branch as string | undefined, + repository: r.repository as string | undefined, + agent_name: r.agent_name as string | undefined, + agent_description: r.agent_description as string | undefined, + created_at: r.created_at as string | undefined, + updated_at: r.updated_at as string | undefined, + source: 'cloud' as const, + })); + + const ids = sessions.map(s => s.id); + let refs: AnnotatedRef[] = []; + try { + const refsQuery = `SELECT session_id, ref_type, ref_value FROM session_refs WHERE session_id IN (${ids.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(',')})`; + const refsResult = await client.executeQuery(refsQuery); + if (refsResult && refsResult.rows.length > 0) { + refs = refsResult.rows.map(r => ({ + session_id: r.session_id as string, + ref_type: r.ref_type as 'commit' | 'pr' | 'issue', + ref_value: r.ref_value as string, + source: 'cloud' as const, + })); + } + } catch { /* non-fatal */ } + + return { sessions, refs }; + } catch { + return empty; + } + } + + private async _queryCloudTurnsAndFiles(sessionIds: string[]): Promise<{ turns: SessionTurnInfo[]; files: SessionFileInfo[] }> { + const empty = { turns: [] as SessionTurnInfo[], files: [] as SessionFileInfo[] }; + try { + const client = new CloudSessionStoreClient(this._tokenManager, this._authService, this._fetcherService); + const inClause = sessionIds.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(','); + + let turns: SessionTurnInfo[] = []; + try { + const turnsQuery = `SELECT session_id, turn_index, substring(user_message, 1, 120) as user_message, substring(assistant_response, 1, 200) as assistant_response FROM turns WHERE session_id IN (${inClause}) AND (user_message IS NOT NULL OR assistant_response IS NOT NULL) ORDER BY session_id, turn_index LIMIT 200`; + const turnsResult = await client.executeQuery(turnsQuery); + if (turnsResult && turnsResult.rows.length > 0) { + turns = turnsResult.rows.map(r => ({ + session_id: r.session_id as string, + turn_index: r.turn_index as number, + user_message: r.user_message as string | undefined, + assistant_response: r.assistant_response as string | undefined, + })); + } + } catch { /* non-fatal */ } + + let files: SessionFileInfo[] = []; + try { + const filesQuery = `SELECT session_id, file_path, tool_name FROM session_files WHERE session_id IN (${inClause}) LIMIT 200`; + const filesResult = await client.executeQuery(filesQuery); + if (filesResult && filesResult.rows.length > 0) { + files = filesResult.rows.map(r => ({ + session_id: r.session_id as string, + file_path: r.file_path as string, + tool_name: r.tool_name as string | undefined, + })); + } + } catch { /* non-fatal */ } + + return { turns, files }; + } catch { + return empty; + } + } + private _sendTelemetry(source: string, rowCount: number, durationMs: number, success: boolean, error?: string): void { if (success) { /* __GDPR__ @@ -161,13 +427,27 @@ class SessionStoreSqlTool implements ICopilotTool { } prepareInvocation( - _options: vscode.LanguageModelToolInvocationPrepareOptions, + options: vscode.LanguageModelToolInvocationPrepareOptions, _token: CancellationToken, ) { - return { - invocationMessage: l10n.t('Querying session store'), - pastTenseMessage: l10n.t('Queried session store'), - }; + const action = options.input.action ?? 'query'; + switch (action) { + case 'standup': + return { + invocationMessage: l10n.t('Fetching standup data'), + pastTenseMessage: l10n.t('Fetched standup data'), + }; + case 'reindex': + return { + invocationMessage: l10n.t('Reindexing session store'), + pastTenseMessage: l10n.t('Reindexed session store'), + }; + default: + return { + invocationMessage: l10n.t('Querying session store'), + pastTenseMessage: l10n.t('Queried session store'), + }; + } } } From 0a9f3ed5788f7bfae899d2eb8fc095a592e011c5 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sat, 2 May 2026 23:04:04 -0700 Subject: [PATCH 2/6] chronicle skill updates Co-authored-by: Copilot --- .../prompts/chronicle-reindex.prompt.md | 5 + .../prompts/chronicle-standup.prompt.md | 5 + .../assets/prompts/chronicle-tips.prompt.md | 5 + .../assets/prompts/skills/chronicle/SKILL.md | 105 ++++++++++++----- extensions/copilot/package.json | 29 ++++- .../chronicle/node/cloudSessionStoreClient.ts | 12 +- .../src/extension/intents/node/agentIntent.ts | 2 + .../tools/node/sessionStoreSqlTool.ts | 110 +++++++++++++++--- 8 files changed, 228 insertions(+), 45 deletions(-) create mode 100644 extensions/copilot/assets/prompts/chronicle-reindex.prompt.md create mode 100644 extensions/copilot/assets/prompts/chronicle-standup.prompt.md create mode 100644 extensions/copilot/assets/prompts/chronicle-tips.prompt.md diff --git a/extensions/copilot/assets/prompts/chronicle-reindex.prompt.md b/extensions/copilot/assets/prompts/chronicle-reindex.prompt.md new file mode 100644 index 0000000000000..fcb1eac903bdf --- /dev/null +++ b/extensions/copilot/assets/prompts/chronicle-reindex.prompt.md @@ -0,0 +1,5 @@ +--- +name: chronicle:reindex +description: Rebuild the local session index and sync to cloud +--- +Reindex my session store to pick up any missing sessions. Add 'force' to re-process already indexed sessions. diff --git a/extensions/copilot/assets/prompts/chronicle-standup.prompt.md b/extensions/copilot/assets/prompts/chronicle-standup.prompt.md new file mode 100644 index 0000000000000..1bb052fd73394 --- /dev/null +++ b/extensions/copilot/assets/prompts/chronicle-standup.prompt.md @@ -0,0 +1,5 @@ +--- +name: chronicle:standup +description: Generate a standup report from recent chat sessions +--- +Generate a standup report from my recent coding sessions. diff --git a/extensions/copilot/assets/prompts/chronicle-tips.prompt.md b/extensions/copilot/assets/prompts/chronicle-tips.prompt.md new file mode 100644 index 0000000000000..fe9f9a0888d21 --- /dev/null +++ b/extensions/copilot/assets/prompts/chronicle-tips.prompt.md @@ -0,0 +1,5 @@ +--- +name: chronicle:tips +description: Get personalized tips based on your chat session usage patterns +--- +Analyze my recent chat session history and give me personalized tips to improve my workflow. diff --git a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md index fe2869050932d..b7e18342abef3 100644 --- a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md @@ -1,22 +1,25 @@ --- name: chronicle -description: Analyze Copilot session history for standup reports, usage tips, and session reindexing. Use when the user asks for a standup, daily summary, usage tips, workflow recommendations, or wants to reindex their session store. -context: fork +description: Analyze Copilot session history for standup reports, usage tips, and session reindexing. Use when the user asks for a standup, daily summary, usage tips, workflow recommendations, wants to reindex their session store, or asks about deleting session data. --- # Chronicle -Analyze the user's Copilot session history using the `session_store_sql` tool. This skill handles standup reports, usage analysis, and session store maintenance. +Analyze the user's Copilot session history using the `copilot_sessionStoreSql` tool. This skill handles standup reports, usage analysis, and session store maintenance. + +Sessions may be stored locally (SQLite) and optionally synced to the cloud for cross-device access. Cloud sync is controlled by the `chat.sessionSync.enabled` setting. + +**Prerequisite:** Chronicle requires the `github.copilot.chat.localIndex.enabled` setting to be `true`. If the `copilot_sessionStoreSql` tool is not available, tell the user to enable this setting in VS Code Settings. ## Available Tool Actions -The `session_store_sql` tool supports three actions: +The `copilot_sessionStoreSql` tool supports three actions: | Action | Purpose | `query` param | |--------|---------|---------------| | `standup` | Pre-fetch last 24h sessions, turns, files, refs | Not needed | | `query` | Execute a read-only SQL query | Required | -| `reindex` | Rebuild local session index from debug logs | Not needed | +| `reindex` | Rebuild local session index + cloud sync | Not needed | ## Workflows @@ -24,29 +27,37 @@ The `session_store_sql` tool supports three actions: When the user asks for a standup, daily summary, or "what did I do": -1. Call `session_store_sql` with `action: "standup"` and `description: "Generate standup"`. +1. Call `copilot_sessionStoreSql` with `action: "standup"` and `description: "Generate standup"`. 2. The tool returns pre-fetched session data (sessions, turns, files, refs from the last 24 hours). -3. Format the returned data as a standup report grouped by work stream (branch/feature): +3. For any PR references in the data, check their current status (open, merged, draft) if possible. +4. Format the returned data as a standup report grouped by work stream (branch/feature): ``` +Standup for : + **✅ Done** **Feature name** (`branch-name` branch, `repo-name`) - - Summary of what was accomplished (grounded in user messages and assistant responses) + - 3-7 words describing the status - Key files: 2-3 most important files changed - - PR: [#123](link) (if applicable) + - Merged: [#123](link) + - Session: `session-id` **🚧 In Progress** **Feature name** (`branch-name` branch, `repo-name`) - - Summary of current work + - 3-7 words describing the current state of work - Key files: 2-3 most important files being worked on + - Draft: [#789](link) + - Session: `session-id` ``` Rules: +- Keep it concise and succinct — the user can always ask follow-up questions - Use turn data (user messages AND assistant responses) to understand WHAT was done - Use file paths to identify which components/areas were affected - Group related sessions on the same branch into one entry +- For sessions, only show the most recent session per feature/branch - Link PRs and issues using markdown link syntax - Classify as Done if work appears complete, In Progress otherwise @@ -54,26 +65,53 @@ Rules: When the user asks for tips, workflow recommendations, or how to improve: -1. IMMEDIATELY call `session_store_sql` with `action: "query"` to query sessions from the last 7 days. Do not explain what you will do first. -2. Query the turns table to understand prompting patterns and conversation flow. -3. Query session_files to see which files and tools are used most frequently. -4. Query session_refs to see PR/issue/commit activity patterns. -5. Based on ALL this data, provide 3-5 specific, actionable tips grounded in actual usage patterns. +**Step 1: Investigate how the user works** + +Use `copilot_sessionStoreSql` with `action: "query"` to explore their recent sessions. The goal is to understand their patterns — how they prompt, what tools they use, and where they spend time. + +Queries to run (do not explain what you will do first — start querying immediately): +- Sessions from the last 7 days: counts, durations, repositories +- Turn data: read actual user messages to understand prompting patterns +- session_files: which files and tools are used most frequently +- session_refs: PR/issue/commit activity patterns + +**Step 2: Consider available features** + +Check `.github/skills/`, `.github/agents/`, and `.github/copilot-instructions.md` to see what custom configuration the user has. Look for gaps between what's available and what the user actually uses. + +**Step 3: Provide tips** + +Based on what you learned, provide 3-5 specific, actionable tips. Each tip should: +- Be grounded in actual usage data — reference specific patterns you observed +- Be non-obvious — skip basic features that any regular user would already know +- Focus on gaps where a feature, workflow change, or different approach would meaningfully improve their experience Analysis dimensions to explore: -- **Prompting patterns**: Are user messages vague or specific? Do they provide context? Average turns per session? +- **Prompting patterns**: Are user messages vague or specific? Do they provide context? Do they correct or redirect the agent frequently? - **Tool usage**: Which tools are used most? Are there underutilized tools that could help? - **Session patterns**: How long are sessions? Are there many short abandoned sessions? - **File patterns**: Which areas of the codebase get the most attention? Any repeated edits to the same files? -- **Workflow**: Is the user leveraging agent mode, inline chat, custom instructions, prompt files? +- **Workflow**: Is the user leveraging agent mode, custom instructions, prompt files, skills? + +If the session store has little data, acknowledge that and suggest features to try based on what configuration you found in the workspace. ### Reindex When the user asks to reindex, rebuild, or refresh their session store: -1. Call `session_store_sql` with `action: "reindex"` and `description: "Reindex sessions"`. -2. The tool returns before/after stats showing sessions, turns, files, and refs counts. -3. Present the stats to the user. +1. Call `copilot_sessionStoreSql` with `action: "reindex"` and `description: "Reindex sessions"`. +2. The tool rebuilds the local session store from debug logs and, if cloud sync is enabled, uploads new sessions to the cloud. +3. Present the before/after stats and cloud sync results to the user. + +If the user says "force reindex" or wants to re-process already-indexed sessions, add `force: true` to the call. By default, already-indexed sessions are skipped for speed. + +### Delete Sessions + +When the user asks to delete session data or clear their history: + +- Guide them to run the **Delete Session Sync Data** command from the Command Palette (`github.copilot.sessionSync.deleteSessions`). +- This command lets them choose which sessions to delete from both local storage and the cloud. +- The tool itself does NOT support deletion — this is intentional to prevent accidental data loss. ## Query Guidelines @@ -87,14 +125,29 @@ When using `action: "query"`: - Always filter on **updated_at** (not created_at) for time ranges - Always JOIN sessions with turns to get session content — do not rely on sessions.summary alone +### Query routing + +The tool automatically routes queries based on the user's cloud sync settings: +- **Cloud enabled**: Queries go to the cloud DuckDB backend which contains ALL sessions across devices and agents (VS Code, CLI, Copilot Coding Agent, PR reviews). The tool description will show DuckDB SQL syntax — follow it. +- **Cloud disabled**: Queries go to local SQLite which only contains sessions from this device. The tool description will show SQLite syntax. + +The tool's description dynamically changes based on the active backend. **Always follow the SQL syntax shown in the tool description** — it matches the active backend. + ## Database Schema -### Tables (SQLite syntax — local) +### Tables (both local and cloud unless noted) -- **sessions**: id, cwd (workspace folder path), repository, branch, summary, host_type, agent_name, agent_description, created_at, updated_at +- **sessions**: id, cwd (workspace folder path — always NULL in cloud), repository, branch, host_type, summary, agent_name, agent_description, created_at, updated_at - **turns**: session_id, turn_index, user_message, assistant_response (first ~1000 chars, may be truncated), timestamp -- **session_files**: session_id, file_path, tool_name, turn_index -- **session_refs**: session_id, ref_type (commit/pr/issue), ref_value, turn_index -- **search_index**: FTS5 table. Use `WHERE search_index MATCH 'query'` for full-text search +- **checkpoints**: session_id, checkpoint_number, title, overview, history, work_done, technical_details, important_files, next_steps, created_at — compaction checkpoints storing summarized state. Note: cloud has fewer columns (no history/work_done/technical_details). +- **session_files**: session_id, file_path, tool_name, turn_index, first_seen_at +- **session_refs**: session_id, ref_type (commit/pr/issue), ref_value, turn_index, created_at +- **search_index**: FTS5 table (local only). Use `WHERE search_index MATCH 'query'` for full-text search + +### Cloud-only tables + +- **events**: Raw event table (~90 columns). Key columns: session_id, timestamp, type, user_content, assistant_content, tool_start_name, tool_complete_success, tool_complete_result_content, usage_model, usage_input_tokens, usage_output_tokens +- **tool_requests**: session_id, tool_call_id, name, arguments_json -Date math: `datetime('now', '-1 day')`, `datetime('now', '-7 days')` +Date math (SQLite): `datetime('now', '-1 day')`, `datetime('now', '-7 days')` +Date math (Cloud/DuckDB): `now() - INTERVAL '1 day'`, `now() - INTERVAL '7 days'`. Use `ILIKE` for text search (no FTS5/MATCH), `date_diff('minute', start, end)` for durations. diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 3106dfca84c05..3added83ebad6 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1270,7 +1270,7 @@ "toolReferenceName": "sessionStoreSql", "when": "github.copilot.sessionSearch.enabled", "userDescription": "Query your Copilot session history using SQL", - "modelDescription": "Interact with the global session store containing history from ALL past coding sessions.\n\nSupports three actions via the `action` parameter:\n\n**action: 'query' (default)** — Execute a read-only SQL query. Use this proactively when the user asks about what they've worked on, prior approaches, project history, sessions linked to PRs/issues/commits, or temporal queries ('what was I doing yesterday?'). Supports SQLite SQL including JOINs, FTS5 MATCH queries, aggregations, and subqueries. Only one query per call — do not combine multiple statements with semicolons.\n\nSchema:\n- sessions — id, cwd, repository, branch, summary, agent_name, agent_description, created_at, updated_at\n- turns — session_id, turn_index, user_message, assistant_response, timestamp\n- session_files — session_id, file_path, tool_name (edit/create), turn_index\n- session_refs — session_id, ref_type (commit/pr/issue), ref_value, turn_index\n- search_index — FTS5 virtual table (content, session_id, source_type). Use WHERE search_index MATCH 'query' for full-text search.\n\n**action: 'standup'** — Pre-fetches last 24 hours of sessions, turns, files, and refs (merging local and cloud data). Returns a formatted data blob ready for standup summarisation. No `query` parameter needed.\n\n**action: 'reindex'** — Rebuilds the local session store by re-reading debug logs from disk. Returns before/after stats. No `query` parameter needed.", + "modelDescription": "Interact with the global session store containing history from ALL past coding sessions. Sessions may be stored locally and optionally synced to the cloud.\n\nSupports three actions via the `action` parameter:\n\n**action: 'query' (default)** — Execute a read-only SQL query. Use this proactively when the user asks about what they've worked on, prior approaches, project history, sessions linked to PRs/issues/commits, or temporal queries ('what was I doing yesterday?'). Supports SQLite SQL including JOINs, FTS5 MATCH queries, aggregations, and subqueries. Only one query per call — do not combine multiple statements with semicolons.\n\nSchema:\n- sessions — id, cwd, repository, branch, host_type, summary, agent_name, agent_description, created_at, updated_at\n- turns — session_id, turn_index, user_message, assistant_response, timestamp\n- checkpoints — session_id, checkpoint_number, title, overview, history, work_done, technical_details, important_files, next_steps, created_at (compaction summaries)\n- session_files — session_id, file_path, tool_name (edit/create), turn_index, first_seen_at\n- session_refs — session_id, ref_type (commit/pr/issue), ref_value, turn_index, created_at\n- search_index — FTS5 virtual table (content, session_id, source_type). Use WHERE search_index MATCH 'query' for full-text search.\n\n**action: 'standup'** — Pre-fetches last 24 hours of sessions, turns, files, and refs (merging local and cloud data). Returns a formatted data blob ready for standup summarisation. No `query` parameter needed.\n\n**action: 'reindex'** — Rebuilds the local session store by re-reading debug logs from disk, then syncs to cloud if enabled. Returns before/after stats. No `query` parameter needed.", "tags": [], "canBeReferencedInPrompt": false, "inputSchema": { @@ -1279,12 +1279,16 @@ "action": { "type": "string", "enum": ["query", "standup", "reindex"], - "description": "The action to perform. 'query' (default) executes a SQL query. 'standup' pre-fetches last 24h session data for standup reports. 'reindex' rebuilds the local session index." + "description": "The action to perform. 'query' (default) executes a SQL query. 'standup' pre-fetches last 24h session data for standup reports. 'reindex' rebuilds the local session index and syncs to cloud if enabled." }, "query": { "type": "string", "description": "A single read-only SQL query to execute. Required when action is 'query'. Supports SELECT, WITH, JOINs, aggregations, and FTS5 MATCH. Only one statement per call — do not combine multiple queries with semicolons." }, + "force": { + "type": "boolean", + "description": "When true with action 'reindex', re-processes all sessions including already-indexed ones. Default false (skips already-indexed sessions)." + }, "description": { "type": "string", "description": "A 2-5 word summary of what this call does (e.g. 'Recent sessions overview', 'Generate standup', 'Reindex sessions')." @@ -6355,6 +6359,27 @@ "sessionTypes": [ "local" ] + }, + { + "path": "./assets/prompts/chronicle-standup.prompt.md", + "when": "github.copilot.sessionSearch.enabled", + "sessionTypes": [ + "local" + ] + }, + { + "path": "./assets/prompts/chronicle-tips.prompt.md", + "when": "github.copilot.sessionSearch.enabled", + "sessionTypes": [ + "local" + ] + }, + { + "path": "./assets/prompts/chronicle-reindex.prompt.md", + "when": "github.copilot.sessionSearch.enabled", + "sessionTypes": [ + "local" + ] } ], "chatSkills": [ diff --git a/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts b/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts index 548774b5c110e..8b3f6524a8e7f 100644 --- a/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts +++ b/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts @@ -56,9 +56,9 @@ export class CloudSessionStoreClient { /** * Execute a SQL query against the cloud session store (user-scoped). - * Returns an array of row objects on success, or undefined on failure. + * Returns rows on success, an error string on query failure, or undefined on auth/network failure. */ - async executeQuery(sql: string): Promise<{ rows: Record[]; truncated: boolean } | undefined> { + async executeQuery(sql: string): Promise<{ rows: Record[]; truncated: boolean } | { error: string } | undefined> { try { const copilotToken = await this._tokenManager.getCopilotToken(); const baseUrl = copilotToken.endpoints?.api; @@ -85,7 +85,13 @@ export class CloudSessionStoreClient { }); if (!res.ok) { - return undefined; + try { + const body = await res.json() as { error?: string; message?: string }; + const msg = body?.error ?? body?.message ?? `HTTP ${res.status}`; + return { error: msg }; + } catch { + return { error: `HTTP ${res.status}` }; + } } const data = await res.json() as CloudQueryResponse; diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 6b57d1de64262..5267133620e02 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -125,6 +125,8 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. const skillToolEnabled = configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SkillToolEnabled, experimentationService); allowTools[ToolName.Skill] = skillToolEnabled; + allowTools[ToolName.SessionStoreSql] = true; + allowTools[CUSTOM_TOOL_SEARCH_NAME] = !!model.supportsToolSearch; if (model.family.includes('grok-code')) { diff --git a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts index 281f7e020f915..dbf4c9ebe5c26 100644 --- a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts +++ b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts @@ -15,6 +15,7 @@ import { type AnnotatedSession, type AnnotatedRef, type SessionFileInfo, type Se import { SessionIndexingPreference } from '../../chronicle/common/sessionIndexingPreference'; import { CloudSessionStoreClient } from '../../chronicle/node/cloudSessionStoreClient'; import { reindexSessions } from '../../chronicle/node/sessionReindexer'; +import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; @@ -35,6 +36,7 @@ const BLOCKED_PATTERNS = [ export interface SessionStoreSqlParams { readonly action?: 'query' | 'standup' | 'reindex'; readonly query?: string; + readonly force?: boolean; readonly description: string; } @@ -45,6 +47,36 @@ const SESSIONS_QUERY_CLOUD = `SELECT * ORDER BY updated_at DESC LIMIT 100`; +/** Model description when cloud sync is enabled — uses DuckDB SQL syntax. */ +const CLOUD_MODEL_DESCRIPTION = `Interact with the cloud session store containing history from ALL past coding sessions across all devices and agents (VS Code, CLI, Copilot Coding Agent, PR reviews). + +Supports three actions via the \`action\` parameter: + +**action: 'query' (default)** — Execute a read-only DuckDB SQL query. Use this proactively when the user asks about what they've worked on, prior approaches, project history, sessions linked to PRs/issues/commits, or temporal queries. + +**IMPORTANT: Uses DuckDB SQL syntax.** +- Date arithmetic: \`now() - INTERVAL '1 day'\`, \`now() - INTERVAL '7 days'\` +- Use \`ILIKE\` (case-insensitive) for text search — no FTS5/MATCH +- Use \`date_diff('minute', start, end)\` for duration calculations +- Always use \`COALESCE()\` or \`WHERE column IS NOT NULL\` to guard against NULL values — many columns are nullable +- GROUP BY is strict: every non-aggregated column in SELECT must appear in GROUP BY, or use \`ANY_VALUE(col)\` for columns where the exact value is not important +- When using expressions like \`date_diff()\` in both SELECT and WHERE/HAVING, repeat the full expression — DuckDB does not allow aliases in WHERE +- Only one query per call — do not combine multiple statements with semicolons + +Schema: +- sessions — id, repository, branch, summary, agent_name (e.g. 'VS Code', 'cli', 'Copilot Coding Agent', 'Copilot Code Review'), agent_description, created_at, updated_at (TIMESTAMP). NOTE: cwd is always NULL in cloud. Always filter on updated_at (not created_at) for time ranges. +- turns — session_id, turn_index, user_message, assistant_response, timestamp (TIMESTAMP). The richest source of what happened — always JOIN with sessions. +- checkpoints — session_id, checkpoint_number, title, overview, created_at (TIMESTAMP) +- session_files — session_id, file_path, tool_name (edit/create), turn_index, first_seen_at (TIMESTAMP) +- session_refs — session_id, ref_type (commit/pr/issue), ref_value, turn_index, created_at (TIMESTAMP) +- events — raw event table. Key columns: session_id, timestamp, type, user_content, assistant_content, tool_start_name, tool_complete_success, tool_complete_result_content, usage_model, usage_input_tokens, usage_output_tokens +- tool_requests — session_id, tool_call_id, name, arguments_json +- search_index — not available in cloud. Use ILIKE for text search instead. + +**action: 'standup'** — Pre-fetches last 24 hours of sessions, turns, files, and refs (merging local and cloud data). Returns a formatted data blob ready for standup summarisation. No \`query\` parameter needed. + +**action: 'reindex'** — Rebuilds the local session store by re-reading debug logs from disk, then syncs to cloud if enabled. Returns before/after stats. No \`query\` parameter needed.`; + class SessionStoreSqlTool implements ICopilotTool { public static readonly toolName = ToolName.SessionStoreSql; public static readonly nonDeferred = true; @@ -59,6 +91,7 @@ class SessionStoreSqlTool implements ICopilotTool { @ITelemetryService private readonly _telemetryService: ITelemetryService, @IFetcherService private readonly _fetcherService: IFetcherService, @IChatDebugFileLoggerService private readonly _debugLogService: IChatDebugFileLoggerService, + @IRunCommandExecutionService private readonly _runCommandService: IRunCommandExecutionService, ) { this._indexingPreference = new SessionIndexingPreference(configService); } @@ -73,7 +106,7 @@ class SessionStoreSqlTool implements ICopilotTool { case 'standup': return this._invokeStandup(token); case 'reindex': - return this._invokeReindex(token); + return this._invokeReindex(options.input.force ?? false, token); default: return this._invokeQuery(options.input.query ?? '', token); } @@ -114,15 +147,33 @@ class SessionStoreSqlTool implements ICopilotTool { const startTime = Date.now(); if (hasCloud) { + // Cloud is enabled — model receives DuckDB description via alternativeDefinition source = 'cloud'; const client = new CloudSessionStoreClient(this._tokenManager, this._authService, this._fetcherService); - const result = await client.executeQuery(sql); - if (!result) { - this._sendTelemetry(source, 0, Date.now() - startTime, false, 'empty_result'); - return new LanguageModelToolResult([new LanguageModelTextPart('Error: Cloud query returned no result.')]); + const cloudResult = await client.executeQuery(sql); + + if (cloudResult && 'error' in cloudResult) { + // Cloud query failed — surface the error so model can fix its query + this._sendTelemetry('cloud', 0, Date.now() - startTime, false, cloudResult.error.substring(0, 100)); + return new LanguageModelToolResult([new LanguageModelTextPart( + `Error from cloud: ${cloudResult.error}\n\nReminder: Cloud uses DuckDB SQL syntax. Use \`now() - INTERVAL '1 day'\` for date math, \`ILIKE\` for text search (no FTS5/MATCH).` + )]); + } else if (!cloudResult) { + // Auth/network failure — fall back to local + source = 'local_fallback'; + try { + rows = this._sessionStore.executeReadOnly(sql); + } catch (authErr) { + if (authErr instanceof Error && authErr.message.includes('authorizer')) { + rows = this._sessionStore.executeReadOnlyFallback(sql); + } else { + throw authErr; + } + } + } else { + rows = cloudResult.rows; + truncated = cloudResult.truncated; } - rows = result.rows; - truncated = result.truncated; } else { source = 'local'; try { @@ -250,9 +301,10 @@ class SessionStoreSqlTool implements ICopilotTool { } /** - * Reindex action: rebuild the local session store from debug logs. + * Reindex action: rebuild the local session store from debug logs, + * then trigger cloud sync if enabled. */ - private async _invokeReindex(token: CancellationToken): Promise { + private async _invokeReindex(force: boolean, token: CancellationToken): Promise { const startTime = Date.now(); try { @@ -263,6 +315,7 @@ class SessionStoreSqlTool implements ICopilotTool { this._debugLogService, () => { /* progress not streamed for tool results */ }, token, + force, ); const statsAfter = this._sessionStore.getStats(); @@ -271,7 +324,7 @@ class SessionStoreSqlTool implements ICopilotTool { if (result.cancelled) { lines.push('Reindex cancelled.'); } else { - lines.push('Reindex complete.'); + lines.push('Local reindex complete.'); } lines.push(''); @@ -284,6 +337,22 @@ class SessionStoreSqlTool implements ICopilotTool { lines.push(''); lines.push(`${result.processed} session(s) processed, ${result.skipped} skipped.`); + // Cloud reindex phase — gated by cloud sync settings in RemoteSessionExporter + if (!result.cancelled && !token.isCancellationRequested) { + try { + const cloudResult = await this._runCommandService.executeCommand( + 'github.copilot.sessionSync.reindex', + () => { /* progress not streamed for tool results */ }, + token, + ) as { created: number; eventsUploaded: number; failed: number; backfillQueued: number } | undefined; + if (cloudResult && cloudResult.created > 0) { + lines.push(`${cloudResult.created} session(s) synced to cloud.`); + } + } catch { + // Cloud phase failure is non-fatal — local reindex already succeeded + } + } + this._sendTelemetry('reindex', result.processed, Date.now() - startTime, true); return new LanguageModelToolResult([new LanguageModelTextPart(lines.join('\n'))]); } catch (err) { @@ -320,7 +389,7 @@ class SessionStoreSqlTool implements ICopilotTool { const client = new CloudSessionStoreClient(this._tokenManager, this._authService, this._fetcherService); const sessionsResult = await client.executeQuery(SESSIONS_QUERY_CLOUD); - if (!sessionsResult || sessionsResult.rows.length === 0) { + if (!sessionsResult || 'error' in sessionsResult || sessionsResult.rows.length === 0) { return empty; } @@ -341,7 +410,7 @@ class SessionStoreSqlTool implements ICopilotTool { try { const refsQuery = `SELECT session_id, ref_type, ref_value FROM session_refs WHERE session_id IN (${ids.map(s => `'${s.replace(/'/g, '\'\'')}'`).join(',')})`; const refsResult = await client.executeQuery(refsQuery); - if (refsResult && refsResult.rows.length > 0) { + if (refsResult && !('error' in refsResult) && refsResult.rows.length > 0) { refs = refsResult.rows.map(r => ({ session_id: r.session_id as string, ref_type: r.ref_type as 'commit' | 'pr' | 'issue', @@ -367,7 +436,7 @@ class SessionStoreSqlTool implements ICopilotTool { try { const turnsQuery = `SELECT session_id, turn_index, substring(user_message, 1, 120) as user_message, substring(assistant_response, 1, 200) as assistant_response FROM turns WHERE session_id IN (${inClause}) AND (user_message IS NOT NULL OR assistant_response IS NOT NULL) ORDER BY session_id, turn_index LIMIT 200`; const turnsResult = await client.executeQuery(turnsQuery); - if (turnsResult && turnsResult.rows.length > 0) { + if (turnsResult && !('error' in turnsResult) && turnsResult.rows.length > 0) { turns = turnsResult.rows.map(r => ({ session_id: r.session_id as string, turn_index: r.turn_index as number, @@ -381,7 +450,7 @@ class SessionStoreSqlTool implements ICopilotTool { try { const filesQuery = `SELECT session_id, file_path, tool_name FROM session_files WHERE session_id IN (${inClause}) LIMIT 200`; const filesResult = await client.executeQuery(filesQuery); - if (filesResult && filesResult.rows.length > 0) { + if (filesResult && !('error' in filesResult) && filesResult.rows.length > 0) { files = filesResult.rows.map(r => ({ session_id: r.session_id as string, file_path: r.file_path as string, @@ -449,6 +518,19 @@ class SessionStoreSqlTool implements ICopilotTool { }; } } + + alternativeDefinition(tool: vscode.LanguageModelToolInformation): vscode.LanguageModelToolInformation { + const hasCloud = this._indexingPreference.hasCloudConsent(); + if (!hasCloud) { + return tool; + } + + // When cloud is enabled, swap the description to use DuckDB syntax + return { + ...tool, + description: CLOUD_MODEL_DESCRIPTION, + }; + } } /** Max total characters for the formatted result to avoid blowing up the context window. */ From 3f1a8e952aa8f47317d17b07b1a04ac6f1ce7827 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sun, 3 May 2026 09:07:30 -0700 Subject: [PATCH 3/6] Feedback updates Co-authored-by: Copilot --- .../chronicle/node/cloudSessionStoreClient.ts | 9 +++++++- .../tools/node/sessionStoreSqlTool.ts | 22 ++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts b/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts index 8b3f6524a8e7f..aa36589aae5b2 100644 --- a/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts +++ b/extensions/copilot/src/extension/chronicle/node/cloudSessionStoreClient.ts @@ -56,7 +56,8 @@ export class CloudSessionStoreClient { /** * Execute a SQL query against the cloud session store (user-scoped). - * Returns rows on success, an error string on query failure, or undefined on auth/network failure. + * Returns rows on success, an error string on query errors (4xx bad SQL), + * or undefined on auth/network/infrastructure failures (401, 403, network errors). */ async executeQuery(sql: string): Promise<{ rows: Record[]; truncated: boolean } | { error: string } | undefined> { try { @@ -85,6 +86,12 @@ export class CloudSessionStoreClient { }); if (!res.ok) { + // Auth/permission failures → return undefined so callers fall back to local + if (res.status === 401 || res.status === 403) { + return undefined; + } + + // Query errors (bad SQL, etc.) → surface error to model try { const body = await res.json() as { error?: string; message?: string }; const msg = body?.error ?? body?.message ?? `HTTP ${res.status}`; diff --git a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts index dbf4c9ebe5c26..e25a518b4798a 100644 --- a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts +++ b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts @@ -139,16 +139,15 @@ class SessionStoreSqlTool implements ICopilotTool { // Determine query target based on consent const hasCloud = this._indexingPreference.hasCloudConsent(); + const startTime = Date.now(); + let source = hasCloud ? 'cloud' : 'local'; try { let rows: Record[]; let truncated = false; - let source: string; - const startTime = Date.now(); if (hasCloud) { // Cloud is enabled — model receives DuckDB description via alternativeDefinition - source = 'cloud'; const client = new CloudSessionStoreClient(this._tokenManager, this._authService, this._fetcherService); const cloudResult = await client.executeQuery(sql); @@ -175,7 +174,6 @@ class SessionStoreSqlTool implements ICopilotTool { truncated = cloudResult.truncated; } } else { - source = 'local'; try { rows = this._sessionStore.executeReadOnly(sql); } catch (authErr) { @@ -200,7 +198,7 @@ class SessionStoreSqlTool implements ICopilotTool { return new LanguageModelToolResult([new LanguageModelTextPart(result)]); } catch (err) { const message = err instanceof Error ? err.message : String(err); - this._sendTelemetry(hasCloud ? 'cloud' : 'local', 0, 0, false, message.substring(0, 100)); + this._sendTelemetry(source, 0, Date.now() - startTime, false, message.substring(0, 100)); return new LanguageModelToolResult([new LanguageModelTextPart(`Error: ${message}`)]); } } @@ -525,10 +523,22 @@ class SessionStoreSqlTool implements ICopilotTool { return tool; } - // When cloud is enabled, swap the description to use DuckDB syntax + // When cloud is enabled, swap the description and inputSchema to use DuckDB syntax + const cloudInputSchema = { + ...tool.inputSchema, + properties: { + ...(tool.inputSchema as Record).properties as Record, + query: { + type: 'string', + description: 'A single DuckDB SQL query to execute. Required when action is \'query\'. Read-only queries only (SELECT, WITH). Use now() - INTERVAL for date math, ILIKE for text search. Only one statement per call — do not combine multiple queries with semicolons.', + }, + }, + }; + return { ...tool, description: CLOUD_MODEL_DESCRIPTION, + inputSchema: cloudInputSchema, }; } } From 46cd49bc695ed2b70a89e9c8872a0137768d4c96 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sun, 3 May 2026 15:32:30 -0700 Subject: [PATCH 4/6] tests update --- .../node/test/cloudSessionStoreClient.spec.ts | 205 ++++++++++ .../node/test/sessionStoreSqlTool.spec.ts | 384 ++++++++++++++++++ 2 files changed, 589 insertions(+) create mode 100644 extensions/copilot/src/extension/chronicle/node/test/cloudSessionStoreClient.spec.ts create mode 100644 extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts diff --git a/extensions/copilot/src/extension/chronicle/node/test/cloudSessionStoreClient.spec.ts b/extensions/copilot/src/extension/chronicle/node/test/cloudSessionStoreClient.spec.ts new file mode 100644 index 0000000000000..d6ddc275fc5b1 --- /dev/null +++ b/extensions/copilot/src/extension/chronicle/node/test/cloudSessionStoreClient.spec.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { ICopilotTokenManager } from '../../../../platform/authentication/common/copilotTokenManager'; +import type { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import type { IFetcherService } from '../../../../platform/networking/common/fetcherService'; +import { CloudSessionStoreClient } from '../cloudSessionStoreClient'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function createMockServices() { + const tokenManager: ICopilotTokenManager = { + _serviceBrand: undefined as any, + getCopilotToken: vi.fn(async () => ({ + token: 'test-token', + endpoints: { api: 'https://api.test.com' }, + })), + } as any; + + const authService: IAuthenticationService = { + _serviceBrand: undefined as any, + anyGitHubSession: { accessToken: 'gh-token' }, + } as any; + + const fetcherService: IFetcherService = { + _serviceBrand: undefined as any, + fetch: vi.fn(), + } as any; + + return { tokenManager, authService, fetcherService }; +} + +function makeFetchResponse(status: number, body: unknown): { ok: boolean; status: number; json: () => Promise } { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('CloudSessionStoreClient', () => { + describe('executeQuery', () => { + it('returns rows on success', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(200, { + columns: ['id', 'summary'], + column_types: ['VARCHAR', 'VARCHAR'], + data: [['session-1', 'Test session'], ['session-2', 'Another']], + row_count: 2, + truncated: false, + })); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT * FROM sessions'); + + expect(result).toBeDefined(); + expect(result).not.toBeUndefined(); + expect('rows' in result!).toBe(true); + if (result && 'rows' in result) { + expect(result.rows).toHaveLength(2); + expect(result.rows[0]).toEqual({ id: 'session-1', summary: 'Test session' }); + expect(result.rows[1]).toEqual({ id: 'session-2', summary: 'Another' }); + expect(result.truncated).toBe(false); + } + }); + + it('returns truncated flag when set', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(200, { + columns: ['id'], + column_types: ['VARCHAR'], + data: [['session-1']], + row_count: 1, + truncated: true, + })); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT * FROM sessions LIMIT 10000'); + + expect(result).toBeDefined(); + if (result && 'rows' in result) { + expect(result.truncated).toBe(true); + } + }); + + it('returns error for 400 bad SQL', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(400, { + error: 'Binder Error: column "foo" not found', + })); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT foo FROM sessions'); + + expect(result).toBeDefined(); + expect(result).not.toBeUndefined(); + expect('error' in result!).toBe(true); + if (result && 'error' in result) { + expect(result.error).toContain('Binder Error'); + } + }); + + it('returns undefined for 401 auth failure', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(401, { message: 'Unauthorized' })); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT * FROM sessions'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for 403 forbidden', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(403, { message: 'Forbidden' })); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT * FROM sessions'); + + expect(result).toBeUndefined(); + }); + + it('returns error with HTTP status for 500 server error', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(500, {})); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT * FROM sessions'); + + expect(result).toBeDefined(); + if (result && 'error' in result) { + expect(result.error).toContain('500'); + } + }); + + it('returns undefined on network error', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (fetcherService.fetch as any).mockRejectedValue(new Error('ECONNREFUSED')); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT * FROM sessions'); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when no API endpoint is configured', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (tokenManager.getCopilotToken as any).mockResolvedValue({ token: 'test', endpoints: {} }); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT * FROM sessions'); + + expect(result).toBeUndefined(); + }); + + it('converts columnar response to row objects', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(200, { + columns: ['id', 'repository', 'branch', 'updated_at'], + column_types: ['VARCHAR', 'VARCHAR', 'VARCHAR', 'TIMESTAMP'], + data: [ + ['s1', 'microsoft/vscode', 'main', '2026-05-01T10:00:00Z'], + ['s2', 'microsoft/vscode', 'feature', '2026-05-01T11:00:00Z'], + ], + row_count: 2, + truncated: false, + })); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT * FROM sessions'); + + if (result && 'rows' in result) { + expect(result.rows[0]).toEqual({ + id: 's1', + repository: 'microsoft/vscode', + branch: 'main', + updated_at: '2026-05-01T10:00:00Z', + }); + } + }); + + it('returns empty rows for empty data', async () => { + const { tokenManager, authService, fetcherService } = createMockServices(); + (fetcherService.fetch as any).mockResolvedValue(makeFetchResponse(200, { + columns: ['id'], + column_types: ['VARCHAR'], + data: [], + row_count: 0, + truncated: false, + })); + + const client = new CloudSessionStoreClient(tokenManager, authService, fetcherService); + const result = await client.executeQuery('SELECT * FROM sessions WHERE 1=0'); + + if (result && 'rows' in result) { + expect(result.rows).toHaveLength(0); + } + }); + }); +}); diff --git a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts new file mode 100644 index 0000000000000..9d395d676c1d3 --- /dev/null +++ b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts @@ -0,0 +1,384 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { ISessionStore } from '../../../../platform/chronicle/common/sessionStore'; +import type { ICopilotTokenManager } from '../../../../platform/authentication/common/copilotTokenManager'; +import type { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; +import type { IConfigurationService } from '../../../../platform/configuration/common/configurationService'; +import type { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; +import type { IFetcherService } from '../../../../platform/networking/common/fetcherService'; +import type { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService'; +import type { IRunCommandExecutionService } from '../../../../platform/commands/common/runCommandExecutionService'; +import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation'; +import { LanguageModelTextPart } from '../../../../vscodeTypes'; +import { ToolName } from '../../common/toolNames'; +import { ToolRegistry } from '../../common/toolsRegistry'; + +// Side-effect registration +import '../sessionStoreSqlTool'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function createMockStore(): ISessionStore { + return { + _serviceBrand: undefined as any, + getPath: () => '/tmp/test.db', + upsertSession: () => { }, + insertTurn: () => { }, + insertCheckpoint: () => { }, + insertFile: () => { }, + insertRef: () => { }, + indexWorkspaceArtifact: () => { }, + deleteSession: () => { }, + search: () => [], + getSession: () => undefined, + getTurns: () => [], + getFiles: () => [], + getRefs: () => [], + getMaxTurnIndex: () => -1, + getStats: () => ({ sessions: 5, turns: 20, checkpoints: 0, files: 10, refs: 3 }), + executeReadOnly: vi.fn(() => [{ id: 'local-1', summary: 'test' }]), + executeReadOnlyFallback: vi.fn(() => [{ id: 'local-1', summary: 'test' }]), + runInTransaction: (fn: () => void) => fn(), + close: () => { }, + } as any; +} + +function createMockServices() { + const store = createMockStore(); + + const tokenManager: ICopilotTokenManager = { + _serviceBrand: undefined as any, + getCopilotToken: vi.fn(async () => ({ token: 'test-token', endpoints: { api: 'https://api.test.com' } })), + } as any; + + const authService: IAuthenticationService = { + _serviceBrand: undefined as any, + anyGitHubSession: { accessToken: 'gh-token' }, + } as any; + + const configService: IConfigurationService = { + _serviceBrand: undefined as any, + getConfig: vi.fn(() => false), + getNonExtensionConfig: vi.fn(() => false), + getExperimentBasedConfig: vi.fn(() => false), + getExperimentBasedConfigObservable: vi.fn(() => ({ read: () => false })), + } as any; + + const telemetryService: ITelemetryService = { + _serviceBrand: undefined as any, + sendMSFTTelemetryEvent: vi.fn(), + sendMSFTTelemetryErrorEvent: vi.fn(), + } as any; + + const fetcherService: IFetcherService = { + _serviceBrand: undefined as any, + fetch: vi.fn(), + } as any; + + const debugLogService: IChatDebugFileLoggerService = { + _serviceBrand: undefined as any, + listSessionIds: vi.fn(async () => []), + streamEntries: vi.fn(), + } as any; + + const runCommandService: IRunCommandExecutionService = { + _serviceBrand: undefined as any, + executeCommand: vi.fn(async () => undefined), + } as any; + + return { store, tokenManager, authService, configService, telemetryService, fetcherService, debugLogService, runCommandService }; +} + +function createToolInstance(overrides: Partial> = {}) { + const services = { ...createMockServices(), ...overrides }; + const toolCtor = ToolRegistry.getTools().find(t => t.toolName === ToolName.SessionStoreSql)!; + + const tool = new (toolCtor as any)( + services.store, + services.tokenManager, + services.authService, + services.configService, + services.telemetryService, + services.fetcherService, + services.debugLogService, + services.runCommandService, + ); + + return { tool, ...services }; +} + +function makeOptions(input: T): vscode.LanguageModelToolInvocationOptions { + return { input, toolInvocationToken: undefined as any, model: undefined as any, chatRequestId: '' } as any; +} + +function makeToolInfo(overrides: Partial = {}): vscode.LanguageModelToolInformation { + return { + name: ToolName.SessionStoreSql, + description: 'base description', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'SQLite query with FTS5 MATCH support' }, + action: { type: 'string' }, + description: { type: 'string' }, + }, + }, + tags: [], + parametersSchema: {}, + ...overrides, + } as unknown as vscode.LanguageModelToolInformation; +} + +function extractText(result: vscode.LanguageModelToolResult): string { + const parts: string[] = []; + for (const part of result.content) { + if (part instanceof LanguageModelTextPart) { + parts.push(part.value); + } + } + return parts.join(''); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('SessionStoreSqlTool', () => { + it('is registered', () => { + const isRegistered = ToolRegistry.getTools().some(t => t.toolName === ToolName.SessionStoreSql); + expect(isRegistered).toBe(true); + }); + + describe('action routing', () => { + it('defaults to query action', async () => { + const { tool, store } = createToolInstance(); + const cts = new CancellationTokenSource(); + + const result = await tool.invoke( + makeOptions({ description: 'test', query: 'SELECT COUNT(*) FROM sessions' }), + cts.token, + ); + + expect(store.executeReadOnly).toHaveBeenCalled(); + expect(extractText(result)).toContain('Results:'); + }); + + it('routes standup action correctly', async () => { + const { tool } = createToolInstance(); + const cts = new CancellationTokenSource(); + + const result = await tool.invoke( + makeOptions({ action: 'standup', description: 'Generate standup' }), + cts.token, + ); + + const text = extractText(result); + // Standup should return either session data or an error — not a SQL result + expect(text).not.toContain('Blocked SQL'); + }); + + it('routes reindex action correctly', async () => { + const { tool, debugLogService } = createToolInstance(); + (debugLogService.listSessionIds as any).mockResolvedValue([]); + const cts = new CancellationTokenSource(); + + const result = await tool.invoke( + makeOptions({ action: 'reindex', description: 'Reindex sessions' }), + cts.token, + ); + + const text = extractText(result); + expect(text).toContain('reindex'); + }); + }); + + describe('query security', () => { + it('blocks mutating SQL statements', async () => { + const { tool } = createToolInstance(); + const cts = new CancellationTokenSource(); + + const mutations = [ + 'DROP TABLE sessions', + 'DELETE FROM sessions WHERE 1=1', + 'INSERT INTO sessions VALUES (1)', + 'UPDATE sessions SET summary = "hacked"', + 'CREATE TABLE evil (id INT)', + 'ATTACH DATABASE "evil.db" AS evil', + ]; + + for (const sql of mutations) { + const result = await tool.invoke( + makeOptions({ action: 'query', query: sql, description: 'test' }), + cts.token, + ); + expect(extractText(result)).toContain('Blocked SQL'); + } + }); + + it('blocks multiple statements', async () => { + const { tool } = createToolInstance(); + const cts = new CancellationTokenSource(); + + const result = await tool.invoke( + makeOptions({ action: 'query', query: 'SELECT 1; SELECT 2', description: 'test' }), + cts.token, + ); + + expect(extractText(result)).toContain('Only one SQL statement'); + }); + + it('blocks empty queries', async () => { + const { tool } = createToolInstance(); + const cts = new CancellationTokenSource(); + + const result = await tool.invoke( + makeOptions({ action: 'query', query: '', description: 'test' }), + cts.token, + ); + + expect(extractText(result)).toContain('Empty query'); + }); + + it('strips trailing semicolons', async () => { + const { tool, store } = createToolInstance(); + const cts = new CancellationTokenSource(); + + await tool.invoke( + makeOptions({ action: 'query', query: 'SELECT * FROM sessions;', description: 'test' }), + cts.token, + ); + + // Should have called executeReadOnly with the semicolon stripped + expect(store.executeReadOnly).toHaveBeenCalledWith('SELECT * FROM sessions'); + }); + }); + + describe('local query', () => { + it('returns formatted results', async () => { + const { tool } = createToolInstance(); + const cts = new CancellationTokenSource(); + + const result = await tool.invoke( + makeOptions({ action: 'query', query: 'SELECT * FROM sessions LIMIT 1', description: 'test' }), + cts.token, + ); + + const text = extractText(result); + expect(text).toContain('Results: 1 rows'); + expect(text).toContain('source: local'); + }); + + it('falls back to executeReadOnlyFallback on authorizer error', async () => { + const store = createMockStore(); + (store.executeReadOnly as any).mockImplementation(() => { + throw new Error('authorizer denied'); + }); + const { tool } = createToolInstance({ store }); + const cts = new CancellationTokenSource(); + + const result = await tool.invoke( + makeOptions({ action: 'query', query: 'SELECT * FROM sessions', description: 'test' }), + cts.token, + ); + + expect(store.executeReadOnlyFallback).toHaveBeenCalled(); + expect(extractText(result)).toContain('Results:'); + }); + }); + + describe('alternativeDefinition', () => { + it('returns tool unchanged when cloud is not enabled', () => { + const { tool } = createToolInstance(); + const base = makeToolInfo(); + const result = tool.alternativeDefinition(base); + expect(result).toBe(base); + }); + + it('swaps description and inputSchema when cloud is enabled', () => { + const configService = { + _serviceBrand: undefined as any, + getConfig: vi.fn(() => false), + getNonExtensionConfig: vi.fn((key: string) => { + if (key === 'chat.sessionSync.enabled') { + return true; + } + return false; + }), + getExperimentBasedConfig: vi.fn(() => false), + getExperimentBasedConfigObservable: vi.fn(() => ({ read: () => false })), + } as any; + + const { tool } = createToolInstance({ configService }); + const base = makeToolInfo(); + const result = tool.alternativeDefinition(base); + + expect(result).not.toBe(base); + expect(result.description).toContain('DuckDB'); + expect(result.description).toContain('cloud'); + // inputSchema query description should reference DuckDB + const props = (result.inputSchema as any).properties; + expect(props.query.description).toContain('DuckDB'); + expect(props.query.description).not.toContain('FTS5'); + }); + }); + + describe('reindex with force', () => { + it('passes force=false by default', async () => { + const { tool, debugLogService } = createToolInstance(); + (debugLogService.listSessionIds as any).mockResolvedValue([]); + const cts = new CancellationTokenSource(); + + await tool.invoke( + makeOptions({ action: 'reindex', description: 'Reindex sessions' }), + cts.token, + ); + + // reindexSessions is called via the module — verify through the result + const result = await tool.invoke( + makeOptions({ action: 'reindex', description: 'Reindex' }), + cts.token, + ); + expect(extractText(result)).toContain('reindex'); + }); + + it('passes force=true when specified', async () => { + const { tool, debugLogService } = createToolInstance(); + (debugLogService.listSessionIds as any).mockResolvedValue([]); + const cts = new CancellationTokenSource(); + + const result = await tool.invoke( + makeOptions({ action: 'reindex', force: true, description: 'Force reindex' }), + cts.token, + ); + expect(extractText(result)).toContain('reindex'); + }); + }); + + describe('prepareInvocation', () => { + it('returns correct messages for each action', () => { + const { tool } = createToolInstance(); + const cts = new CancellationTokenSource(); + + const standup = tool.prepareInvocation( + { input: { action: 'standup', description: 'test' } } as any, + cts.token, + ); + expect(standup.invocationMessage).toContain('standup'); + + const reindex = tool.prepareInvocation( + { input: { action: 'reindex', description: 'test' } } as any, + cts.token, + ); + expect(reindex.invocationMessage).toContain('eindex'); + + const query = tool.prepareInvocation( + { input: { action: 'query', description: 'test' } } as any, + cts.token, + ); + expect(query.invocationMessage).toContain('uerying'); + }); + }); +}); From f7de618283e7195d76a59495bf14667a679c4027 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Sun, 3 May 2026 23:15:52 -0700 Subject: [PATCH 5/6] Fix tsc err Co-authored-by: Copilot --- .../chronicle/node/test/cloudSessionStoreClient.spec.ts | 2 +- .../src/extension/tools/node/test/sessionStoreSqlTool.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/src/extension/chronicle/node/test/cloudSessionStoreClient.spec.ts b/extensions/copilot/src/extension/chronicle/node/test/cloudSessionStoreClient.spec.ts index d6ddc275fc5b1..a2bb70e011a9a 100644 --- a/extensions/copilot/src/extension/chronicle/node/test/cloudSessionStoreClient.spec.ts +++ b/extensions/copilot/src/extension/chronicle/node/test/cloudSessionStoreClient.spec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ICopilotTokenManager } from '../../../../platform/authentication/common/copilotTokenManager'; import type { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; import type { IFetcherService } from '../../../../platform/networking/common/fetcherService'; diff --git a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts index 9d395d676c1d3..dde4eae88171c 100644 --- a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ISessionStore } from '../../../../platform/chronicle/common/sessionStore'; import type { ICopilotTokenManager } from '../../../../platform/authentication/common/copilotTokenManager'; import type { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; From eb176f847c72fa5544ff975ce3ef4fd178cacdcd Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Mon, 4 May 2026 14:26:04 -0700 Subject: [PATCH 6/6] few updates --- .../assets/prompts/skills/chronicle/SKILL.md | 2 +- extensions/copilot/package.json | 2 +- .../tools/node/sessionStoreSqlTool.ts | 63 ++++++------------- .../node/test/sessionStoreSqlTool.spec.ts | 1 - 4 files changed, 20 insertions(+), 48 deletions(-) diff --git a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md index b7e18342abef3..e81682e0aa477 100644 --- a/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/chronicle/SKILL.md @@ -77,7 +77,7 @@ Queries to run (do not explain what you will do first — start querying immedia **Step 2: Consider available features** -Check `.github/skills/`, `.github/agents/`, and `.github/copilot-instructions.md` to see what custom configuration the user has. Look for gaps between what's available and what the user actually uses. +If the current workspace has a `.github/` folder, check for `.github/copilot-instructions.md`, `.github/skills/`, and `.github/agents/` to see what custom configuration exists. Do NOT look outside the workspace. Look for gaps between what's available and what the user actually uses. **Step 3: Provide tips** diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 3added83ebad6..82dc34e48455f 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1270,7 +1270,7 @@ "toolReferenceName": "sessionStoreSql", "when": "github.copilot.sessionSearch.enabled", "userDescription": "Query your Copilot session history using SQL", - "modelDescription": "Interact with the global session store containing history from ALL past coding sessions. Sessions may be stored locally and optionally synced to the cloud.\n\nSupports three actions via the `action` parameter:\n\n**action: 'query' (default)** — Execute a read-only SQL query. Use this proactively when the user asks about what they've worked on, prior approaches, project history, sessions linked to PRs/issues/commits, or temporal queries ('what was I doing yesterday?'). Supports SQLite SQL including JOINs, FTS5 MATCH queries, aggregations, and subqueries. Only one query per call — do not combine multiple statements with semicolons.\n\nSchema:\n- sessions — id, cwd, repository, branch, host_type, summary, agent_name, agent_description, created_at, updated_at\n- turns — session_id, turn_index, user_message, assistant_response, timestamp\n- checkpoints — session_id, checkpoint_number, title, overview, history, work_done, technical_details, important_files, next_steps, created_at (compaction summaries)\n- session_files — session_id, file_path, tool_name (edit/create), turn_index, first_seen_at\n- session_refs — session_id, ref_type (commit/pr/issue), ref_value, turn_index, created_at\n- search_index — FTS5 virtual table (content, session_id, source_type). Use WHERE search_index MATCH 'query' for full-text search.\n\n**action: 'standup'** — Pre-fetches last 24 hours of sessions, turns, files, and refs (merging local and cloud data). Returns a formatted data blob ready for standup summarisation. No `query` parameter needed.\n\n**action: 'reindex'** — Rebuilds the local session store by re-reading debug logs from disk, then syncs to cloud if enabled. Returns before/after stats. No `query` parameter needed.", + "modelDescription": "Query the local session store containing history from past coding sessions. Uses SQLite SQL syntax.\n\nActions: 'query' (execute SQL — supports JOINs, FTS5 MATCH, aggregations), 'standup' (pre-fetch last 24h data), 'reindex' (rebuild index from debug logs).", "tags": [], "canBeReferencedInPrompt": false, "inputSchema": { diff --git a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts index e25a518b4798a..d08f0e37040ed 100644 --- a/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts +++ b/extensions/copilot/src/extension/tools/node/sessionStoreSqlTool.ts @@ -48,34 +48,9 @@ const SESSIONS_QUERY_CLOUD = `SELECT * LIMIT 100`; /** Model description when cloud sync is enabled — uses DuckDB SQL syntax. */ -const CLOUD_MODEL_DESCRIPTION = `Interact with the cloud session store containing history from ALL past coding sessions across all devices and agents (VS Code, CLI, Copilot Coding Agent, PR reviews). +const CLOUD_MODEL_DESCRIPTION = `Query the cloud session store containing ALL past coding sessions across devices and agents. Uses DuckDB SQL syntax (not SQLite). Use \`now() - INTERVAL '1 day'\` for date math, \`ILIKE\` for text search. -Supports three actions via the \`action\` parameter: - -**action: 'query' (default)** — Execute a read-only DuckDB SQL query. Use this proactively when the user asks about what they've worked on, prior approaches, project history, sessions linked to PRs/issues/commits, or temporal queries. - -**IMPORTANT: Uses DuckDB SQL syntax.** -- Date arithmetic: \`now() - INTERVAL '1 day'\`, \`now() - INTERVAL '7 days'\` -- Use \`ILIKE\` (case-insensitive) for text search — no FTS5/MATCH -- Use \`date_diff('minute', start, end)\` for duration calculations -- Always use \`COALESCE()\` or \`WHERE column IS NOT NULL\` to guard against NULL values — many columns are nullable -- GROUP BY is strict: every non-aggregated column in SELECT must appear in GROUP BY, or use \`ANY_VALUE(col)\` for columns where the exact value is not important -- When using expressions like \`date_diff()\` in both SELECT and WHERE/HAVING, repeat the full expression — DuckDB does not allow aliases in WHERE -- Only one query per call — do not combine multiple statements with semicolons - -Schema: -- sessions — id, repository, branch, summary, agent_name (e.g. 'VS Code', 'cli', 'Copilot Coding Agent', 'Copilot Code Review'), agent_description, created_at, updated_at (TIMESTAMP). NOTE: cwd is always NULL in cloud. Always filter on updated_at (not created_at) for time ranges. -- turns — session_id, turn_index, user_message, assistant_response, timestamp (TIMESTAMP). The richest source of what happened — always JOIN with sessions. -- checkpoints — session_id, checkpoint_number, title, overview, created_at (TIMESTAMP) -- session_files — session_id, file_path, tool_name (edit/create), turn_index, first_seen_at (TIMESTAMP) -- session_refs — session_id, ref_type (commit/pr/issue), ref_value, turn_index, created_at (TIMESTAMP) -- events — raw event table. Key columns: session_id, timestamp, type, user_content, assistant_content, tool_start_name, tool_complete_success, tool_complete_result_content, usage_model, usage_input_tokens, usage_output_tokens -- tool_requests — session_id, tool_call_id, name, arguments_json -- search_index — not available in cloud. Use ILIKE for text search instead. - -**action: 'standup'** — Pre-fetches last 24 hours of sessions, turns, files, and refs (merging local and cloud data). Returns a formatted data blob ready for standup summarisation. No \`query\` parameter needed. - -**action: 'reindex'** — Rebuilds the local session store by re-reading debug logs from disk, then syncs to cloud if enabled. Returns before/after stats. No \`query\` parameter needed.`; +Actions: 'query' (execute DuckDB SQL), 'standup' (pre-fetch last 24h data), 'reindex' (rebuild index + cloud sync).`; class SessionStoreSqlTool implements ICopilotTool { public static readonly toolName = ToolName.SessionStoreSql; @@ -160,29 +135,13 @@ class SessionStoreSqlTool implements ICopilotTool { } else if (!cloudResult) { // Auth/network failure — fall back to local source = 'local_fallback'; - try { - rows = this._sessionStore.executeReadOnly(sql); - } catch (authErr) { - if (authErr instanceof Error && authErr.message.includes('authorizer')) { - rows = this._sessionStore.executeReadOnlyFallback(sql); - } else { - throw authErr; - } - } + rows = this._executeLocal(sql); } else { rows = cloudResult.rows; truncated = cloudResult.truncated; } } else { - try { - rows = this._sessionStore.executeReadOnly(sql); - } catch (authErr) { - if (authErr instanceof Error && authErr.message.includes('authorizer')) { - rows = this._sessionStore.executeReadOnlyFallback(sql); - } else { - throw authErr; - } - } + rows = this._executeLocal(sql); } // Cap rows @@ -203,6 +162,20 @@ class SessionStoreSqlTool implements ICopilotTool { } } + /** + * Execute a read-only SQL query against the local SQLite session store. + */ + private _executeLocal(sql: string): Record[] { + try { + return this._sessionStore.executeReadOnly(sql); + } catch (authErr) { + if (authErr instanceof Error && authErr.message.includes('authorizer')) { + return this._sessionStore.executeReadOnlyFallback(sql); + } + throw authErr; + } + } + /** * Standup action: pre-fetch last 24h sessions + turns + files + refs, * merge local/cloud, dedup, and return formatted data for the model to summarise. diff --git a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts index dde4eae88171c..fe47340235506 100644 --- a/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts +++ b/extensions/copilot/src/extension/tools/node/test/sessionStoreSqlTool.spec.ts @@ -321,7 +321,6 @@ describe('SessionStoreSqlTool', () => { // inputSchema query description should reference DuckDB const props = (result.inputSchema as any).properties; expect(props.query.description).toContain('DuckDB'); - expect(props.query.description).not.toContain('FTS5'); }); });