From 6adcba6c0b9bd233449a4250f33b314318b58bbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Don=C4=8Devi=C4=87?= Date: Fri, 13 Feb 2026 17:09:01 +0100 Subject: [PATCH 1/6] feat: show total instructions summary in metadata.loaded Replace tool title modification (not rendered by TUI for built-in tools) with a summary entry appended to metadata.loaded array. The summary includes both repo-wide and path-specific instructions in the total count. Also upgrades @opencode-ai/plugin and @opencode-ai/sdk to v1.1.65, fixing breaking type changes (model required on system.transform, args required on tool.execute.after). --- package-lock.json | 21 ++- package.json | 4 +- src/index.test.ts | 267 +++++++++++++++++++++++++++++++++++--- src/index.ts | 22 +++- src/integration.test.ts | 3 + src/session-state.test.ts | 23 +++- src/session-state.ts | 19 +-- 7 files changed, 310 insertions(+), 49 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4b161a1..1e82ed3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "picomatch": "^4.0.0" }, "devDependencies": { - "@opencode-ai/plugin": "latest", - "@opencode-ai/sdk": "^1.1.3", + "@opencode-ai/plugin": "^1.1.65", + "@opencode-ai/sdk": "^1.1.65", "@types/node": "^22.0.0", "@types/picomatch": "^3.0.0", "typescript": "^5.0.0", @@ -473,20 +473,20 @@ "license": "MIT" }, "node_modules/@opencode-ai/plugin": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.3.tgz", - "integrity": "sha512-CVsaUv+ZOiObbRdVS/2cvU0pUwmLKZHlMRTdLi1r2fVWPxCuQFWTdWH+0wTdynUEQ+WyqCy8wt9gTC7QRx2WyA==", + "version": "1.1.65", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.1.65.tgz", + "integrity": "sha512-wG9B4HtD3cOAM36UEeWgrZ0A6BCRr6pYbH2XG7/AWF6wEJVvz8TT256+4TNUuNaIDE3RP9X5Sf7g0hKFviGD0g==", "dev": true, "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.1.3", + "@opencode-ai/sdk": "1.1.65", "zod": "4.1.8" } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.3.tgz", - "integrity": "sha512-P4ERbfuT7CilZYyB1l6J/DM6KD0i5V15O+xvsjUitxSS3S2Gr0YsA4bmXU+EsBQGHryUHc81bhJF49a8wSU+tw==", + "version": "1.1.65", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.65.tgz", + "integrity": "sha512-XlpgQJQ5WwO4tYgyyHoTT0NAB5/1StXonabVUAYVpW0JdtbwWFSdFEaLkWx6CU7MNW6ELP+SMC4n6wWO6zRW8Q==", "dev": true, "license": "MIT" }, @@ -871,7 +871,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1298,7 +1297,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1518,7 +1516,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index a54c3e9..da8adba 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,8 @@ "@opencode-ai/plugin": "*" }, "devDependencies": { - "@opencode-ai/plugin": "latest", - "@opencode-ai/sdk": "^1.1.3", + "@opencode-ai/plugin": "^1.1.65", + "@opencode-ai/sdk": "^1.1.65", "@types/node": "^22.0.0", "@types/picomatch": "^3.0.0", "typescript": "^5.0.0", diff --git a/src/index.test.ts b/src/index.test.ts index 188fe9d..cddad82 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -8,6 +8,8 @@ import { CopilotInstructionsPlugin } from './index' // to avoid OpenCode treating it as a plugin. It is tested indirectly // through the tool.execute.before hook tests which use various path formats. +const mockModel = { providerID: 'test', modelID: 'test-model' } as any + describe('CopilotInstructionsPlugin', () => { let tempDir: string let mockClient: any @@ -208,7 +210,7 @@ TS rules.` fs.writeFileSync(path.join(githubDir, 'copilot-instructions.md'), repoContent) const hooks = await CopilotInstructionsPlugin(createPluginInput()) - const input = { sessionID: 'session-1' } + const input = { sessionID: 'session-1', model: mockModel } const output = { system: [] as string[] } // Act @@ -223,7 +225,7 @@ TS rules.` // Arrange - no .github directory const hooks = await CopilotInstructionsPlugin(createPluginInput()) - const input = { sessionID: 'session-1' } + const input = { sessionID: 'session-1', model: mockModel } const output = { system: [] as string[] } // Act @@ -240,7 +242,7 @@ TS rules.` fs.writeFileSync(path.join(githubDir, 'copilot-instructions.md'), 'Instructions') const hooks = await CopilotInstructionsPlugin(createPluginInput()) - const input = { sessionID: 'session-1' } + const input = { sessionID: 'session-1', model: mockModel } const output = { system: ['Existing system prompt'] } // Act @@ -260,7 +262,7 @@ TS rules.` fs.writeFileSync(path.join(githubDir, 'copilot-instructions.md'), repoContent) const hooks = await CopilotInstructionsPlugin(createPluginInput()) - const input = { sessionID: 'session-1' } + const input = { sessionID: 'session-1', model: mockModel } const output = { system: [] as string[] } // Act @@ -281,7 +283,6 @@ TS rules.` }) describe('session.compacted event handling', () => { - // Helper to simulate the full tool execution flow async function executeToolWithHooks( hooks: any, input: { tool: string; sessionID: string; callID: string }, @@ -291,7 +292,7 @@ TS rules.` const beforeOutput = { args } as any await hooks['tool.execute.before']!(input, beforeOutput) - const afterOutput = { title: '', output: existingOutput, metadata: {} } + const afterOutput: { title: string; output: string; metadata: Record } = { title: '', output: existingOutput, metadata: {} } await hooks['tool.execute.after']!(input, afterOutput) return { beforeOutput, afterOutput } @@ -418,7 +419,7 @@ TypeScript rules.` // Act - First experimental.chat.system.transform call const output1 = { system: [] as string[] } - await hooks['experimental.chat.system.transform']!({ sessionID }, output1) + await hooks['experimental.chat.system.transform']!({ sessionID, model: mockModel }, output1) // Assert - verify instructions are in output.system expect(output1.system).toHaveLength(1) @@ -435,7 +436,7 @@ TypeScript rules.` // Act - Second experimental.chat.system.transform call const output2 = { system: [] as string[] } - await hooks['experimental.chat.system.transform']!({ sessionID }, output2) + await hooks['experimental.chat.system.transform']!({ sessionID, model: mockModel }, output2) // Assert - verify instructions are STILL in output.system expect(output2.system).toHaveLength(1) @@ -456,7 +457,7 @@ TypeScript rules.` // Act - First experimental.chat.system.transform call const output1 = { system: [] as string[] } - await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1' }, output1) + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, output1) // Assert - verify instructions are in output.system expect(output1.system).toHaveLength(1) @@ -468,7 +469,7 @@ TypeScript rules.` // Act - Second experimental.chat.system.transform call const output2 = { system: [] as string[] } - await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1' }, output2) + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, output2) // Assert - verify instructions are STILL in output.system expect(output2.system).toHaveLength(1) @@ -477,7 +478,6 @@ TypeScript rules.` }) describe('tool.execute hooks', () => { - // Helper to simulate the full tool execution flow async function executeToolWithHooks( hooks: any, input: { tool: string; sessionID: string; callID: string }, @@ -487,7 +487,7 @@ TypeScript rules.` const beforeOutput = { args } as any await hooks['tool.execute.before']!(input, beforeOutput) - const afterOutput = { title: '', output: existingOutput, metadata: {} } + const afterOutput: { title: string; output: string; metadata: Record } = { title: '', output: existingOutput, metadata: {} } await hooks['tool.execute.after']!(input, afterOutput) return { beforeOutput, afterOutput } @@ -506,7 +506,7 @@ Use TypeScript strict mode.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) - const input = { tool: 'read', sessionID: 'session-1', callID: 'call-1' } + const input = { tool: 'read', sessionID: 'session-1', callID: 'call-1', args: {} } const args = { filePath: path.join(tempDir, 'src/index.ts') } // Act @@ -529,7 +529,7 @@ TypeScript editing rules.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) - const input = { tool: 'edit', sessionID: 'session-1', callID: 'call-1' } + const input = { tool: 'edit', sessionID: 'session-1', callID: 'call-1', args: {} } const args = { filePath: path.join(tempDir, 'src/utils.ts') } // Act @@ -552,7 +552,7 @@ TypeScript writing rules.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) - const input = { tool: 'write', sessionID: 'session-1', callID: 'call-1' } + const input = { tool: 'write', sessionID: 'session-1', callID: 'call-1', args: {} } const args = { filePath: path.join(tempDir, 'src/new-file.ts') } // Act @@ -575,7 +575,7 @@ TypeScript rules.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) - const input = { tool: 'read', sessionID: 'session-1', callID: 'call-1' } + const input = { tool: 'read', sessionID: 'session-1', callID: 'call-1', args: {} } const args = { filePath: path.join(tempDir, 'readme.md') } const originalOutput = 'File contents' @@ -624,7 +624,7 @@ TypeScript rules.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) - const input = { tool: 'read', sessionID: 'session-1', callID: 'call-1' } + const input = { tool: 'read', sessionID: 'session-1', callID: 'call-1', args: {} } const args = {} const originalOutput = 'Some output' @@ -1022,6 +1022,239 @@ TypeScript rules.` expect(afterOutput2.output).toBe('Other file contents') }) + it('should populate metadata.loaded with instruction file paths when injecting', async () => { + // Arrange + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const { afterOutput } = await executeToolWithHooks( + hooks, + { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, + { filePath: path.join(tempDir, 'src/index.ts') } + ) + + // Assert - metadata.loaded should contain the instruction file path + summary + expect(afterOutput.metadata.loaded).toBeDefined() + expect(afterOutput.metadata.loaded).toBeInstanceOf(Array) + expect(afterOutput.metadata.loaded).toHaveLength(2) + expect(afterOutput.metadata.loaded[0]).toContain('typescript.instructions.md') + expect(afterOutput.metadata.loaded[1]).toBe('Total: 1 instruction active') + }) + + it('should populate metadata.loaded with multiple instruction file paths', async () => { + // Arrange + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + fs.writeFileSync( + path.join(instructionsDir, 'src.instructions.md'), + `--- +applyTo: "src/**/*" +--- +Source directory rules.` + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const { afterOutput } = await executeToolWithHooks( + hooks, + { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, + { filePath: path.join(tempDir, 'src/index.ts') } + ) + + // Assert + expect(afterOutput.metadata.loaded).toHaveLength(3) + const loadedPaths = afterOutput.metadata.loaded as string[] + expect(loadedPaths.some((p: string) => p.includes('typescript.instructions.md'))).toBe(true) + expect(loadedPaths.some((p: string) => p.includes('src.instructions.md'))).toBe(true) + expect(loadedPaths[2]).toBe('Total: 2 instructions active') + }) + + it('should not set metadata.loaded when no instructions match', async () => { + // Arrange + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const { afterOutput } = await executeToolWithHooks( + hooks, + { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, + { filePath: path.join(tempDir, 'readme.md') }, + 'File contents' + ) + + // Assert - metadata.loaded should not be set + expect(afterOutput.metadata.loaded).toBeUndefined() + }) + + it('should preserve existing metadata.loaded entries', async () => { + // Arrange + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const input = { tool: 'read', sessionID: 'session-1', callID: 'call-1', args: {} } + const beforeOutput = { args: { filePath: path.join(tempDir, 'src/index.ts') } } as any + await hooks['tool.execute.before']!(input, beforeOutput) + + // Simulate existing metadata.loaded from OpenCode (e.g., AGENTS.md) + const afterOutput = { + title: '', + output: 'File contents here', + metadata: { loaded: ['.github/AGENTS.md'] } + } + await hooks['tool.execute.after']!(input, afterOutput) + + // Assert - should preserve existing and add new + summary + expect(afterOutput.metadata.loaded).toHaveLength(3) + expect(afterOutput.metadata.loaded[0]).toBe('.github/AGENTS.md') + expect(afterOutput.metadata.loaded[1]).toContain('typescript.instructions.md') + expect(afterOutput.metadata.loaded[2]).toBe('Total: 1 instruction active') + }) + + it('should include total summary in metadata.loaded with singular count', async () => { + // Arrange + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const { afterOutput } = await executeToolWithHooks( + hooks, + { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, + { filePath: path.join(tempDir, 'src/index.ts') } + ) + + // Assert + const loaded = afterOutput.metadata.loaded as string[] + const summary = loaded[loaded.length - 1] + expect(summary).toBe('Total: 1 instruction active') + }) + + it('should include total summary with plural count for multiple instructions', async () => { + // Arrange + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + fs.writeFileSync( + path.join(instructionsDir, 'src.instructions.md'), + `--- +applyTo: "src/**/*" +--- +Source directory rules.` + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const { afterOutput } = await executeToolWithHooks( + hooks, + { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, + { filePath: path.join(tempDir, 'src/index.ts') } + ) + + // Assert + const loaded = afterOutput.metadata.loaded as string[] + const summary = loaded[loaded.length - 1] + expect(summary).toBe('Total: 2 instructions active') + }) + + it('should include repo-wide instruction in total summary count', async () => { + // Arrange + const githubDir = path.join(tempDir, '.github') + const instructionsDir = path.join(githubDir, 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(githubDir, 'copilot-instructions.md'), + '# Repo Instructions' + ) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const { afterOutput } = await executeToolWithHooks( + hooks, + { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, + { filePath: path.join(tempDir, 'src/index.ts') } + ) + + // Assert - total should be 2 (1 repo-wide + 1 path-specific) + const loaded = afterOutput.metadata.loaded as string[] + const summary = loaded[loaded.length - 1] + expect(summary).toBe('Total: 2 instructions active') + }) + + it('should not add summary to metadata.loaded when no instructions match', async () => { + // Arrange + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const input = { tool: 'read', sessionID: 'session-1', callID: 'call-1', args: {} } + const beforeOutput = { args: { filePath: path.join(tempDir, 'readme.md') } } as any + await hooks['tool.execute.before']!(input, beforeOutput) + + const afterOutput = { + title: 'Read readme.md', + output: 'File contents', + metadata: {} as Record + } + await hooks['tool.execute.after']!(input, afterOutput) + + // Assert + expect(afterOutput.metadata.loaded).toBeUndefined() + expect(afterOutput.title).toBe('Read readme.md') + }) + it('should re-inject path instructions after multiple consecutive undos', async () => { // Arrange const instructionsDir = path.join(tempDir, '.github', 'instructions') diff --git a/src/index.ts b/src/index.ts index 7422c70..b76b054 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,6 +94,7 @@ export const CopilotInstructionsPlugin: Plugin = async (ctx) => { const state = new SessionState() return { + // Listen for session events event: async ({ event }) => { // Log all events for debugging @@ -163,17 +164,26 @@ export const CopilotInstructionsPlugin: Plugin = async (ctx) => { }) .join('\n\n') - state.setPending(input.callID, instructionText) + const loadedFiles = matchingInstructions.map(i => i.file) + state.setPending(input.callID, instructionText, loadedFiles) log(`Queued ${matchingInstructions.length} path instructions for ${relativePath}`, 'debug') } }, 'tool.execute.after': async (input, output) => { - // Check if we have pending instructions for this tool call - const instructionText = state.consumePending(input.callID) - if (instructionText) { - // Append instructions to the tool output - output.output = `${output.output}\n\n${instructionText}` + const pending = state.consumePending(input.callID) + if (pending) { + output.output = `${output.output}\n\n${pending.text}` + + const pathCount = pending.loadedFiles.length + if (pathCount > 0) { + const existing = (output.metadata as Record)?.loaded + const existingArray = Array.isArray(existing) ? existing : [] + const totalCount = pathCount + (repoInstructions ? 1 : 0) + const summary = `Total: ${totalCount} instruction${totalCount > 1 ? 's' : ''} active` + ;(output.metadata as Record).loaded = [...existingArray, ...pending.loadedFiles, summary] + } + log(`Injected path instructions for call ${input.callID}`, 'debug') } }, diff --git a/src/integration.test.ts b/src/integration.test.ts index 961c91e..7f91539 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -17,6 +17,9 @@ describe('Integration Tests', () => { log: vi.fn((options: { body: { service: string; level: string; message: string } }) => { logMessages.push(options.body.message) }) + }, + tui: { + showToast: vi.fn() } } }) diff --git a/src/session-state.test.ts b/src/session-state.test.ts index af06f73..b857eaf 100644 --- a/src/session-state.test.ts +++ b/src/session-state.test.ts @@ -115,18 +115,35 @@ describe('SessionState', () => { expect(state.getPending('call-2')).toBe('instruction 2') }) - it('should consume and clear pending instructions', () => { - state.setPending('call-1', 'instruction text') + it('should consume and return text and loadedFiles together', () => { + state.setPending('call-1', 'instruction text', ['/path/to/file.md']) const result = state.consumePending('call-1') - expect(result).toBe('instruction text') + expect(result).toEqual({ text: 'instruction text', loadedFiles: ['/path/to/file.md'] }) expect(state.getPending('call-1')).toBeUndefined() }) + it('should return empty loadedFiles when none provided', () => { + state.setPending('call-1', 'instruction text') + + const result = state.consumePending('call-1') + + expect(result).toEqual({ text: 'instruction text', loadedFiles: [] }) + }) + it('should return undefined when consuming non-existent call', () => { expect(state.consumePending('nonexistent')).toBeUndefined() }) + + it('should store multiple loadedFiles', () => { + const files = ['/path/to/file1.md', '/path/to/file2.md'] + state.setPending('call-1', 'instruction text', files) + + const result = state.consumePending('call-1') + + expect(result?.loadedFiles).toEqual(files) + }) }) describe('getInjectedFiles', () => { diff --git a/src/session-state.ts b/src/session-state.ts index 728f9d7..c72de0c 100644 --- a/src/session-state.ts +++ b/src/session-state.ts @@ -13,8 +13,8 @@ export class SessionState { private injectedPerSession = new Map>() // Track pending instructions to inject per tool call (ephemeral) - // Map - private pendingInstructions = new Map() + // Map + private pendingInstructions = new Map() // --- Path instruction tracking --- @@ -87,26 +87,27 @@ export class SessionState { /** * Store pending instructions to inject after a tool call completes. */ - setPending(callId: string, text: string): void { - this.pendingInstructions.set(callId, text) + setPending(callId: string, text: string, loadedFiles: string[] = []): void { + this.pendingInstructions.set(callId, { text, loadedFiles }) } /** * Get pending instructions for a tool call without consuming them. */ getPending(callId: string): string | undefined { - return this.pendingInstructions.get(callId) + return this.pendingInstructions.get(callId)?.text } /** * Consume and return pending instructions for a tool call. * The instructions are deleted after retrieval. + * Returns both the instruction text and the loaded file paths. */ - consumePending(callId: string): string | undefined { - const text = this.pendingInstructions.get(callId) - if (text !== undefined) { + consumePending(callId: string): { text: string; loadedFiles: string[] } | undefined { + const entry = this.pendingInstructions.get(callId) + if (entry !== undefined) { this.pendingInstructions.delete(callId) } - return text + return entry } } From b305ac086a83c81cc1982157705bff5888cbb0b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Don=C4=8Devi=C4=87?= Date: Fri, 13 Feb 2026 17:18:54 +0100 Subject: [PATCH 2/6] fix: cleanup --- src/index.ts | 1 - src/integration.test.ts | 3 --- 2 files changed, 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index b76b054..863c454 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,7 +94,6 @@ export const CopilotInstructionsPlugin: Plugin = async (ctx) => { const state = new SessionState() return { - // Listen for session events event: async ({ event }) => { // Log all events for debugging diff --git a/src/integration.test.ts b/src/integration.test.ts index 7f91539..961c91e 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -17,9 +17,6 @@ describe('Integration Tests', () => { log: vi.fn((options: { body: { service: string; level: string; message: string } }) => { logMessages.push(options.body.message) }) - }, - tui: { - showToast: vi.fn() } } }) From a59f37e6740a2d21522e76bd0d4082385920ba5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Don=C4=8Devi=C4=87?= Date: Fri, 13 Feb 2026 21:32:25 +0100 Subject: [PATCH 3/6] feat: add token counting module Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/tokens.test.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++ src/tokens.ts | 50 ++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 src/tokens.test.ts create mode 100644 src/tokens.ts diff --git a/src/tokens.test.ts b/src/tokens.test.ts new file mode 100644 index 0000000..0a58f6c --- /dev/null +++ b/src/tokens.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest' +import { countTokens, formatTokenCount, formatTokenPercentage } from './tokens' + +describe('tokens', () => { + describe('countTokens', () => { + it('should return 0 for empty string', () => { + expect(countTokens('')).toBe(0) + }) + + it('should count tokens for simple text', () => { + const count = countTokens('hello world') + expect(count).toBeGreaterThan(0) + expect(count).toBe(2) // "hello" and " world" are 2 tokens in cl100k_base + }) + + it('should count tokens for markdown content', () => { + const markdown = `# Repository Instructions + +This is the opencode-copilot-instructions plugin repository. + +When working on this codebase: +- Follow TDD principles (write tests first) +- Use TypeScript strict mode +- All exports should be documented` + + const count = countTokens(markdown) + expect(count).toBeGreaterThan(20) + expect(count).toBeLessThan(100) + }) + + it('should count tokens for code content', () => { + const code = `export function main(): void { + console.log('Hello, world!') +}` + const count = countTokens(code) + expect(count).toBeGreaterThan(5) + }) + + it('should handle multi-line content consistently', () => { + const text = 'line one\nline two\nline three' + const count = countTokens(text) + expect(count).toBeGreaterThan(0) + expect(typeof count).toBe('number') + }) + + it('should handle special characters', () => { + const text = '**/*.ts, **/*.tsx' + const count = countTokens(text) + expect(count).toBeGreaterThan(0) + }) + + it('should handle XML-like tags (instruction markers)', () => { + const text = '\n## Instructions\nDo stuff.\n' + const count = countTokens(text) + expect(count).toBeGreaterThan(5) + }) + }) + + describe('formatTokenCount', () => { + it('should show raw number for 0', () => { + expect(formatTokenCount(0)).toBe('0') + }) + + it('should show raw number for counts below 2000', () => { + expect(formatTokenCount(50)).toBe('50') + expect(formatTokenCount(500)).toBe('500') + expect(formatTokenCount(1000)).toBe('1000') + expect(formatTokenCount(1999)).toBe('1999') + }) + + it('should switch to shortened format at exactly 2000 with no trailing zeros', () => { + expect(formatTokenCount(2000)).toBe('2k') + }) + + it('should use dot as decimal separator and drop trailing zeros', () => { + expect(formatTokenCount(2500)).toBe('2.5k') + expect(formatTokenCount(2250)).toBe('2.25k') + expect(formatTokenCount(10500)).toBe('10.5k') + expect(formatTokenCount(12500)).toBe('12.5k') + }) + + it('should handle very large counts in shortened format', () => { + expect(formatTokenCount(200000)).toBe('200k') + }) + }) + + describe('formatTokenPercentage', () => { + it('should format percentage with 1 decimal place', () => { + expect(formatTokenPercentage(1000, 200000)).toBe('0.5%') + }) + + it('should format small percentages', () => { + expect(formatTokenPercentage(500, 200000)).toBe('0.3%') + }) + + it('should format larger percentages', () => { + expect(formatTokenPercentage(20000, 200000)).toBe('10.0%') + }) + + it('should handle 100%', () => { + expect(formatTokenPercentage(200000, 200000)).toBe('100.0%') + }) + + it('should handle zero context size gracefully', () => { + expect(formatTokenPercentage(1000, 0)).toBe('N/A') + }) + + it('should show <0.1% for very small values instead of 0.0%', () => { + expect(formatTokenPercentage(1, 200000)).toBe('<0.1%') + }) + }) +}) diff --git a/src/tokens.ts b/src/tokens.ts new file mode 100644 index 0000000..bbd2d94 --- /dev/null +++ b/src/tokens.ts @@ -0,0 +1,50 @@ +import { getEncoding } from 'js-tiktoken' +import type { Tiktoken } from 'js-tiktoken' + +let encoder: Tiktoken | undefined + +function getEncoder(): Tiktoken { + if (!encoder) { + encoder = getEncoding('cl100k_base') + } + return encoder +} + +/** + * Count the number of tokens in a text string using cl100k_base encoding. + * Uses lazy initialization — the encoder is created on first use. + */ +export function countTokens(text: string): number { + if (text.length === 0) { + return 0 + } + return getEncoder().encode(text).length +} + +/** + * Format a token count for display. + * Below 2000: show the raw number (e.g. "500", "1999"). + * At 2000 and above: show shortened thousands (e.g. "2.00k", "12.50k"). + */ +export function formatTokenCount(tokens: number): string { + if (tokens < 2000) { + return `${tokens}` + } + const thousands = (tokens / 1000).toFixed(2).replace(/\.?0+$/, '') + return `${thousands}k` +} + +/** + * Format token usage as a percentage of context size. + * Returns "N/A" if contextSize is 0, "<0.1%" for very small percentages. + */ +export function formatTokenPercentage(tokens: number, contextSize: number): string { + if (contextSize === 0) { + return 'N/A' + } + const percentage = (tokens / contextSize) * 100 + if (percentage > 0 && percentage < 0.1) { + return '<0.1%' + } + return `${percentage.toFixed(1)}%` +} From c55018cf99c3d0c5d3e63336422c422f510ebfec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Don=C4=8Devi=C4=87?= Date: Fri, 13 Feb 2026 21:32:30 +0100 Subject: [PATCH 4/6] feat: add token counts to instruction loader Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- package-lock.json | 30 +++++++++++++++++++++++ package.json | 1 + src/loader.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++-- src/loader.ts | 28 +++++++++++++++------ 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e82ed3..77acb8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.5.0", "dependencies": { "front-matter": "^4.0.2", + "js-tiktoken": "^1.0.21", "picomatch": "^4.0.0" }, "devDependencies": { @@ -1016,6 +1017,26 @@ "node": ">=12" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1205,6 +1226,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", diff --git a/package.json b/package.json index da8adba..5f49208 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "front-matter": "^4.0.2", + "js-tiktoken": "^1.0.21", "picomatch": "^4.0.0" }, "peerDependencies": { diff --git a/src/loader.test.ts b/src/loader.test.ts index cfaa66b..1f413a5 100644 --- a/src/loader.test.ts +++ b/src/loader.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { loadRepoInstructions, loadPathInstructions } from './loader' +import { countTokens } from './tokens' describe('loader', () => { let tempDir: string @@ -28,7 +29,7 @@ describe('loader', () => { const result = loadRepoInstructions(tempDir) // Assert - expect(result).toBe(content) + expect(result!.content).toBe(content) }) it('should return null when file does not exist', () => { @@ -66,7 +67,23 @@ describe('loader', () => { const result = loadRepoInstructions(tempDir) // Assert - expect(result).toBe(content) + expect(result!.content).toBe(content) + }) + + it('should include tokenCount in result', () => { + // Arrange + const githubDir = path.join(tempDir, '.github') + fs.mkdirSync(githubDir) + const filePath = path.join(githubDir, 'copilot-instructions.md') + const content = 'Repository instructions with some content.' + fs.writeFileSync(filePath, content) + + // Act + const result = loadRepoInstructions(tempDir) + + // Assert + expect(result!.tokenCount).toBeGreaterThan(0) + expect(typeof result!.tokenCount).toBe('number') }) }) @@ -278,5 +295,45 @@ Web rules.`) // Assert expect(result).toEqual([]) // Should be skipped - no applyTo }) + + it('should include tokenCount on each path instruction', () => { + // Arrange + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + + const file = path.join(instructionsDir, 'test.instructions.md') + fs.writeFileSync(file, `--- +applyTo: "**/*.ts" +--- +Use strict TypeScript.`) + + // Act + const result = loadPathInstructions(tempDir) + + // Assert + expect(result).toHaveLength(1) + expect(result[0].tokenCount).toBeGreaterThan(0) + expect(typeof result[0].tokenCount).toBe('number') + }) + + it('should include tokenCount matching the body content', () => { + // Arrange + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + + const bodyText = 'Use strict TypeScript.' + const file = path.join(instructionsDir, 'strict.instructions.md') + fs.writeFileSync(file, `--- +applyTo: "**/*.ts" +--- +${bodyText}`) + + // Act + const result = loadPathInstructions(tempDir) + + // Assert + expect(result).toHaveLength(1) + expect(result[0].tokenCount).toBe(countTokens(bodyText)) + }) }) }) diff --git a/src/loader.ts b/src/loader.ts index 7fd0d99..ee6ac05 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -2,25 +2,36 @@ import * as fs from 'node:fs' import * as path from 'node:path' import { parseFrontmatter } from './frontmatter' import { createMatcher, normalizePatterns, type Matcher } from './matcher' +import { countTokens } from './tokens' export interface PathInstruction { - file: string // Original file path - applyTo: string[] // Glob patterns from frontmatter - content: string // Instruction content (without frontmatter) - matcher: Matcher // Compiled glob matcher function + file: string + applyTo: string[] + content: string + matcher: Matcher + tokenCount: number +} + +export interface RepoInstruction { + content: string + tokenCount: number } /** * Load repository-wide Copilot instructions from .github/copilot-instructions.md * * @param directory - The root directory to search in - * @returns The file content as a string if found, null otherwise + * @returns A RepoInstruction with content and token count if found, null otherwise */ -export function loadRepoInstructions(directory: string): string | null { +export function loadRepoInstructions(directory: string): RepoInstruction | null { const filePath = path.join(directory, '.github', 'copilot-instructions.md') try { - return fs.readFileSync(filePath, 'utf-8') + const content = fs.readFileSync(filePath, 'utf-8') + return { + content, + tokenCount: countTokens(content) + } } catch { return null } @@ -71,7 +82,8 @@ export function loadPathInstructions(directory: string): PathInstruction[] { file: filePath, applyTo: patterns, content: parsed.body, - matcher: createMatcher(patterns) + matcher: createMatcher(patterns), + tokenCount: countTokens(parsed.body) }) } From 860ad4c7e232d68c1fa03cadf3ddba3cbecb9f30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Don=C4=8Devi=C4=87?= Date: Fri, 13 Feb 2026 21:32:37 +0100 Subject: [PATCH 5/6] feat: track token usage and context percentage in session state Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/session-state.test.ts | 197 ++++++++++++++++++++++++++++++++++---- src/session-state.ts | 84 +++++++++++++--- 2 files changed, 247 insertions(+), 34 deletions(-) diff --git a/src/session-state.test.ts b/src/session-state.test.ts index b857eaf..aab4380 100644 --- a/src/session-state.test.ts +++ b/src/session-state.test.ts @@ -14,20 +14,20 @@ describe('SessionState', () => { }) it('should return true after marking file as injected', () => { - state.markFileInjected('session-1', '/path/to/file.md') + state.markFileInjected('session-1', '/path/to/file.md', 100) expect(state.isFileInjected('session-1', '/path/to/file.md')).toBe(true) }) it('should track files per session independently', () => { - state.markFileInjected('session-1', '/path/to/file.md') + state.markFileInjected('session-1', '/path/to/file.md', 100) expect(state.isFileInjected('session-1', '/path/to/file.md')).toBe(true) expect(state.isFileInjected('session-2', '/path/to/file.md')).toBe(false) }) it('should track multiple files per session', () => { - state.markFileInjected('session-1', '/path/to/file1.md') - state.markFileInjected('session-1', '/path/to/file2.md') + state.markFileInjected('session-1', '/path/to/file1.md', 50) + state.markFileInjected('session-1', '/path/to/file2.md', 75) expect(state.isFileInjected('session-1', '/path/to/file1.md')).toBe(true) expect(state.isFileInjected('session-1', '/path/to/file2.md')).toBe(true) @@ -35,8 +35,8 @@ describe('SessionState', () => { }) it('should clear file marker for specific session', () => { - state.markFileInjected('session-1', '/path/to/file.md') - state.markFileInjected('session-2', '/path/to/file.md') + state.markFileInjected('session-1', '/path/to/file.md', 100) + state.markFileInjected('session-2', '/path/to/file.md', 100) state.clearFileMarker('session-1', '/path/to/file.md') @@ -58,8 +58,8 @@ describe('SessionState', () => { describe('syncWithMarkers', () => { it('should clear files whose markers are not present', () => { - state.markFileInjected('session-1', '/path/to/file1.md') - state.markFileInjected('session-1', '/path/to/file2.md') + state.markFileInjected('session-1', '/path/to/file1.md', 50) + state.markFileInjected('session-1', '/path/to/file2.md', 75) // Only file1.md marker is present (basename match) const presentMarkers = new Set(['file1.md']) @@ -70,8 +70,8 @@ describe('SessionState', () => { }) it('should sync across all sessions', () => { - state.markFileInjected('session-1', '/path/to/file.md') - state.markFileInjected('session-2', '/path/to/file.md') + state.markFileInjected('session-1', '/path/to/file.md', 100) + state.markFileInjected('session-2', '/path/to/file.md', 100) // No markers present - should clear from all sessions const presentMarkers = new Set() @@ -82,7 +82,7 @@ describe('SessionState', () => { }) it('should keep files whose markers are present', () => { - state.markFileInjected('session-1', '/path/to/keep.md') + state.markFileInjected('session-1', '/path/to/keep.md', 100) const presentMarkers = new Set(['keep.md']) state.syncWithMarkers(presentMarkers) @@ -116,11 +116,11 @@ describe('SessionState', () => { }) it('should consume and return text and loadedFiles together', () => { - state.setPending('call-1', 'instruction text', ['/path/to/file.md']) + state.setPending('call-1', 'instruction text', [{ file: '/path/to/file.md', tokenCount: 100 }]) const result = state.consumePending('call-1') - expect(result).toEqual({ text: 'instruction text', loadedFiles: ['/path/to/file.md'] }) + expect(result).toEqual({ text: 'instruction text', loadedFiles: [{ file: '/path/to/file.md', tokenCount: 100 }] }) expect(state.getPending('call-1')).toBeUndefined() }) @@ -137,7 +137,7 @@ describe('SessionState', () => { }) it('should store multiple loadedFiles', () => { - const files = ['/path/to/file1.md', '/path/to/file2.md'] + const files = [{ file: '/path/to/file1.md', tokenCount: 50 }, { file: '/path/to/file2.md', tokenCount: 75 }] state.setPending('call-1', 'instruction text', files) const result = state.consumePending('call-1') @@ -153,8 +153,8 @@ describe('SessionState', () => { }) it('should return all injected files for session', () => { - state.markFileInjected('session-1', '/path/to/file1.md') - state.markFileInjected('session-1', '/path/to/file2.md') + state.markFileInjected('session-1', '/path/to/file1.md', 50) + state.markFileInjected('session-1', '/path/to/file2.md', 75) const files = state.getInjectedFiles('session-1') @@ -164,7 +164,7 @@ describe('SessionState', () => { }) it('should return a copy, not the internal set', () => { - state.markFileInjected('session-1', '/path/to/file.md') + state.markFileInjected('session-1', '/path/to/file.md', 100) const files = state.getInjectedFiles('session-1') files.delete('/path/to/file.md') // Modify the returned set @@ -174,10 +174,165 @@ describe('SessionState', () => { }) }) + describe('getInjectedTokens', () => { + it('should return 0 for non-existent session', () => { + expect(state.getInjectedTokens('session-1')).toBe(0) + }) + + it('should return token count for single injected file', () => { + state.markFileInjected('session-1', '/path/to/file.md', 150) + expect(state.getInjectedTokens('session-1')).toBe(150) + }) + + it('should sum token counts across multiple files', () => { + state.markFileInjected('session-1', '/path/to/file1.md', 100) + state.markFileInjected('session-1', '/path/to/file2.md', 250) + state.markFileInjected('session-1', '/path/to/file3.md', 50) + expect(state.getInjectedTokens('session-1')).toBe(400) + }) + + it('should track tokens per session independently', () => { + state.markFileInjected('session-1', '/path/to/file.md', 100) + state.markFileInjected('session-2', '/path/to/file.md', 200) + + expect(state.getInjectedTokens('session-1')).toBe(100) + expect(state.getInjectedTokens('session-2')).toBe(200) + }) + + it('should decrease after clearing a file marker', () => { + state.markFileInjected('session-1', '/path/to/file1.md', 100) + state.markFileInjected('session-1', '/path/to/file2.md', 200) + + state.clearFileMarker('session-1', '/path/to/file1.md') + + expect(state.getInjectedTokens('session-1')).toBe(200) + }) + + it('should return 0 after clearing session', () => { + state.markFileInjected('session-1', '/path/to/file1.md', 100) + state.markFileInjected('session-1', '/path/to/file2.md', 200) + + state.clearSession('session-1') + + expect(state.getInjectedTokens('session-1')).toBe(0) + }) + + it('should update after syncWithMarkers removes files', () => { + state.markFileInjected('session-1', '/path/to/file1.md', 100) + state.markFileInjected('session-1', '/path/to/file2.md', 200) + + // Only keep file1.md + state.syncWithMarkers(new Set(['file1.md'])) + + expect(state.getInjectedTokens('session-1')).toBe(100) + }) + }) + + describe('context size tracking', () => { + it('should return 0 for session without context size', () => { + expect(state.getContextSize('session-1')).toBe(0) + }) + + it('should store and retrieve context size per session', () => { + state.setContextSize('session-1', 200000) + expect(state.getContextSize('session-1')).toBe(200000) + }) + + it('should track context size per session independently', () => { + state.setContextSize('session-1', 200000) + state.setContextSize('session-2', 128000) + + expect(state.getContextSize('session-1')).toBe(200000) + expect(state.getContextSize('session-2')).toBe(128000) + }) + + it('should overwrite context size on repeated set', () => { + state.setContextSize('session-1', 200000) + state.setContextSize('session-1', 128000) + + expect(state.getContextSize('session-1')).toBe(128000) + }) + + it('should be cleared when session is cleared', () => { + state.setContextSize('session-1', 200000) + state.clearSession('session-1') + + expect(state.getContextSize('session-1')).toBe(0) + }) + }) + + describe('contextLogged tracking', () => { + it('should return false for session not yet logged', () => { + expect(state.isContextLogged('session-1', 200000)).toBe(false) + }) + + it('should return true after marking context as logged with same context size', () => { + state.markContextLogged('session-1', 200000) + expect(state.isContextLogged('session-1', 200000)).toBe(true) + }) + + it('should return false when context size changes', () => { + state.markContextLogged('session-1', 200000) + expect(state.isContextLogged('session-1', 200000)).toBe(true) + expect(state.isContextLogged('session-1', 128000)).toBe(false) + }) + + it('should track per session independently', () => { + state.markContextLogged('session-1', 200000) + expect(state.isContextLogged('session-1', 200000)).toBe(true) + expect(state.isContextLogged('session-2', 200000)).toBe(false) + }) + + it('should be cleared when session is cleared', () => { + state.markContextLogged('session-1', 200000) + state.clearSession('session-1') + expect(state.isContextLogged('session-1', 200000)).toBe(false) + }) + + it('should not affect other sessions when one is cleared', () => { + state.markContextLogged('session-1', 200000) + state.markContextLogged('session-2', 200000) + state.clearSession('session-1') + expect(state.isContextLogged('session-1', 200000)).toBe(false) + expect(state.isContextLogged('session-2', 200000)).toBe(true) + }) + }) + + describe('repoInfoShown tracking', () => { + it('should return false for session not yet shown', () => { + expect(state.isRepoInfoShown('session-1')).toBe(false) + }) + + it('should return true after marking repo info as shown', () => { + state.markRepoInfoShown('session-1') + expect(state.isRepoInfoShown('session-1')).toBe(true) + }) + + it('should track per session independently', () => { + state.markRepoInfoShown('session-1') + expect(state.isRepoInfoShown('session-1')).toBe(true) + expect(state.isRepoInfoShown('session-2')).toBe(false) + }) + + it('should be cleared when session is cleared', () => { + state.markRepoInfoShown('session-1') + state.clearSession('session-1') + expect(state.isRepoInfoShown('session-1')).toBe(false) + }) + + it('should not affect other sessions when one is cleared', () => { + state.markRepoInfoShown('session-1') + state.markRepoInfoShown('session-2') + state.clearSession('session-1') + expect(state.isRepoInfoShown('session-1')).toBe(false) + expect(state.isRepoInfoShown('session-2')).toBe(true) + }) + }) + describe('clearSession', () => { it('should clear all file injection state for a session', () => { - state.markFileInjected('session-1', '/path/to/file1.md') - state.markFileInjected('session-1', '/path/to/file2.md') + state.markFileInjected('session-1', '/path/to/file1.md', 50) + state.markFileInjected('session-1', '/path/to/file2.md', 75) state.clearSession('session-1') @@ -186,8 +341,8 @@ describe('SessionState', () => { }) it('should not affect other sessions', () => { - state.markFileInjected('session-1', '/path/to/file.md') - state.markFileInjected('session-2', '/path/to/file.md') + state.markFileInjected('session-1', '/path/to/file.md', 100) + state.markFileInjected('session-2', '/path/to/file.md', 100) state.clearSession('session-1') diff --git a/src/session-state.ts b/src/session-state.ts index c72de0c..d454de2 100644 --- a/src/session-state.ts +++ b/src/session-state.ts @@ -8,13 +8,20 @@ import * as path from 'node:path' * 2. Pending instructions for tool call lifecycle (ephemeral) */ export class SessionState { - // Track which instruction files have been injected per session - // Map> - private injectedPerSession = new Map>() + // Map> + private injectedPerSession = new Map>() - // Track pending instructions to inject per tool call (ephemeral) - // Map - private pendingInstructions = new Map() + // Map }> + private pendingInstructions = new Map }>() + + // Map + private contextSizePerSession = new Map() + + // Map — tracks what context size was last logged + private contextLoggedSessions = new Map() + + // Sessions where repo instruction info has been shown in metadata.loaded + private repoInfoShownSessions = new Set() // --- Path instruction tracking --- @@ -29,13 +36,13 @@ export class SessionState { /** * Mark a file as injected in a session. */ - markFileInjected(sessionId: string, file: string): void { + markFileInjected(sessionId: string, file: string, tokenCount: number): void { let sessionFiles = this.injectedPerSession.get(sessionId) if (!sessionFiles) { - sessionFiles = new Set() + sessionFiles = new Map() this.injectedPerSession.set(sessionId, sessionFiles) } - sessionFiles.add(file) + sessionFiles.set(file, tokenCount) } /** @@ -53,7 +60,20 @@ export class SessionState { */ getInjectedFiles(sessionId: string): Set { const sessionFiles = this.injectedPerSession.get(sessionId) - return new Set(sessionFiles ?? []) + return new Set(sessionFiles?.keys() ?? []) + } + + /** + * Sum token counts for all instructions currently injected in a session. + */ + getInjectedTokens(sessionId: string): number { + const sessionFiles = this.injectedPerSession.get(sessionId) + if (!sessionFiles) return 0 + let total = 0 + for (const tokens of sessionFiles.values()) { + total += tokens + } + return total } /** @@ -64,7 +84,7 @@ export class SessionState { */ syncWithMarkers(presentMarkers: Set): void { for (const [_sessionId, injectedFiles] of this.injectedPerSession) { - for (const file of injectedFiles) { + for (const file of injectedFiles.keys()) { const filename = path.basename(file) if (!presentMarkers.has(filename)) { injectedFiles.delete(file) @@ -80,6 +100,44 @@ export class SessionState { */ clearSession(sessionId: string): void { this.injectedPerSession.delete(sessionId) + this.contextSizePerSession.delete(sessionId) + this.contextLoggedSessions.delete(sessionId) + this.repoInfoShownSessions.delete(sessionId) + } + + // --- Context size tracking --- + + setContextSize(sessionId: string, contextSize: number): void { + this.contextSizePerSession.set(sessionId, contextSize) + } + + getContextSize(sessionId: string): number { + return this.contextSizePerSession.get(sessionId) ?? 0 + } + + // --- Context logging tracking --- + + /** + * Check if context has been logged for a session with a specific context size. + * Returns true only if previously logged with the same context size, + * allowing re-logging when the model (and its context window) changes. + */ + isContextLogged(sessionId: string, contextSize: number): boolean { + return this.contextLoggedSessions.get(sessionId) === contextSize + } + + markContextLogged(sessionId: string, contextSize: number): void { + this.contextLoggedSessions.set(sessionId, contextSize) + } + + // --- Repo info display tracking --- + + isRepoInfoShown(sessionId: string): boolean { + return this.repoInfoShownSessions.has(sessionId) + } + + markRepoInfoShown(sessionId: string): void { + this.repoInfoShownSessions.add(sessionId) } // --- Pending instructions (tool call lifecycle) --- @@ -87,7 +145,7 @@ export class SessionState { /** * Store pending instructions to inject after a tool call completes. */ - setPending(callId: string, text: string, loadedFiles: string[] = []): void { + setPending(callId: string, text: string, loadedFiles: Array<{ file: string; tokenCount: number }> = []): void { this.pendingInstructions.set(callId, { text, loadedFiles }) } @@ -103,7 +161,7 @@ export class SessionState { * The instructions are deleted after retrieval. * Returns both the instruction text and the loaded file paths. */ - consumePending(callId: string): { text: string; loadedFiles: string[] } | undefined { + consumePending(callId: string): { text: string; loadedFiles: Array<{ file: string; tokenCount: number }> } | undefined { const entry = this.pendingInstructions.get(callId) if (entry !== undefined) { this.pendingInstructions.delete(callId) From bab123201f4237cf66fb7f03bab4c5ff2e25349f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Don=C4=8Devi=C4=87?= Date: Fri, 13 Feb 2026 21:32:42 +0100 Subject: [PATCH 6/6] feat: display token counts and context usage in plugin output Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/index.test.ts | 270 +++++++++++++++++++++++++++++++++++++++++++--- src/index.ts | 52 +++++++-- 2 files changed, 296 insertions(+), 26 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index cddad82..bbeb5d7 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -8,7 +8,7 @@ import { CopilotInstructionsPlugin } from './index' // to avoid OpenCode treating it as a plugin. It is tested indirectly // through the tool.execute.before hook tests which use various path formats. -const mockModel = { providerID: 'test', modelID: 'test-model' } as any +const mockModel = { providerID: 'test', modelID: 'test-model', limit: { context: 200000, output: 16000 } } as any describe('CopilotInstructionsPlugin', () => { let tempDir: string @@ -57,7 +57,7 @@ describe('CopilotInstructionsPlugin', () => { // Assert expect(mockClient.app.log).toHaveBeenCalled() - expect(logMessages.some(msg => msg.includes('copilot-instructions.md'))).toBe(true) + expect(logMessages.some(msg => msg.includes('copilot-instructions.md') && msg.includes('tokens'))).toBe(true) }) it('should load and log path-specific instructions', async () => { @@ -77,7 +77,7 @@ Use TypeScript strict mode.` // Assert expect(mockClient.app.log).toHaveBeenCalled() - expect(logMessages.some(msg => msg.includes('typescript.instructions.md'))).toBe(true) + expect(logMessages.some(msg => msg.includes('typescript.instructions.md') && msg.includes('tokens'))).toBe(true) }) it('should handle no instructions gracefully', async () => { @@ -118,6 +118,164 @@ TS rules.` expect(logMessages.some(msg => msg.includes('copilot-instructions.md'))).toBe(true) expect(logMessages.some(msg => msg.includes('ts.instructions.md'))).toBe(true) }) + + it('should log total instruction tokens on startup', async () => { + // Arrange + const githubDir = path.join(tempDir, '.github') + const instructionsDir = path.join(githubDir, 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + + fs.writeFileSync( + path.join(githubDir, 'copilot-instructions.md'), + '# Repo Instructions\n\nFollow these rules.' + ) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + + // Act + await CopilotInstructionsPlugin(createPluginInput()) + + // Assert + expect(logMessages.some(msg => msg.includes('Total instruction tokens:') && msg.includes('k'))).toBe(true) + }) + }) + + describe('context percentage logging', () => { + it('should log context percentage on first system.transform call', async () => { + // Arrange + const githubDir = path.join(tempDir, '.github') + fs.mkdirSync(githubDir, { recursive: true }) + fs.writeFileSync( + path.join(githubDir, 'copilot-instructions.md'), + '# Repo Instructions\n\nFollow these rules.' + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const output = { system: [] as string[] } + + // Act + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, output) + + // Assert + expect(logMessages.some(msg => msg.includes('Instructions:') && msg.includes('%') && msg.includes('context'))).toBe(true) + }) + + it('should NOT log context percentage on second system.transform call for same session', async () => { + // Arrange + const githubDir = path.join(tempDir, '.github') + fs.mkdirSync(githubDir, { recursive: true }) + fs.writeFileSync( + path.join(githubDir, 'copilot-instructions.md'), + '# Repo Instructions\n\nFollow these rules.' + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const sessionID = 'session-1' + + // Act + await hooks['experimental.chat.system.transform']!({ sessionID, model: mockModel }, { system: [] }) + const messageCountBeforeSecondCall = logMessages.filter(msg => msg.includes('Instructions:')).length + + await hooks['experimental.chat.system.transform']!({ sessionID, model: mockModel }, { system: [] }) + const messageCountAfterSecondCall = logMessages.filter(msg => msg.includes('Instructions:')).length + + // Assert + expect(messageCountAfterSecondCall).toBe(messageCountBeforeSecondCall) + }) + + it('should log context percentage again for a different session', async () => { + // Arrange + const githubDir = path.join(tempDir, '.github') + fs.mkdirSync(githubDir, { recursive: true }) + fs.writeFileSync( + path.join(githubDir, 'copilot-instructions.md'), + '# Repo Instructions\n\nFollow these rules.' + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + + // Act + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, { system: [] }) + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-2', model: mockModel }, { system: [] }) + + // Assert + const contextLogCount = logMessages.filter(msg => msg.includes('Instructions:')).length + expect(contextLogCount).toBe(2) + }) + + it('should NOT log context percentage when model has no context size', async () => { + // Arrange + const githubDir = path.join(tempDir, '.github') + fs.mkdirSync(githubDir, { recursive: true }) + fs.writeFileSync( + path.join(githubDir, 'copilot-instructions.md'), + '# Repo Instructions\n\nFollow these rules.' + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const modelWithoutContext = { providerID: 'test', modelID: 'test-model', limit: {} } as any + + // Act + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: modelWithoutContext }, { system: [] }) + + // Assert + const contextLogCount = logMessages.filter(msg => msg.includes('Instructions:')).length + expect(contextLogCount).toBe(0) + }) + + it('should log context percentage once model info becomes available', async () => { + // Arrange + const githubDir = path.join(tempDir, '.github') + fs.mkdirSync(githubDir, { recursive: true }) + fs.writeFileSync( + path.join(githubDir, 'copilot-instructions.md'), + '# Repo Instructions\n\nFollow these rules.' + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const modelWithoutContext = { providerID: 'test', modelID: 'test-model', limit: {} } as any + + // Act - first call without context size + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: modelWithoutContext }, { system: [] }) + expect(logMessages.filter(msg => msg.includes('Instructions:')).length).toBe(0) + + // Act - second call with context size + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, { system: [] }) + + // Assert + const contextLogCount = logMessages.filter(msg => msg.includes('Instructions:')).length + expect(contextLogCount).toBe(1) + expect(logMessages.some(msg => msg.includes('200k context'))).toBe(true) + }) + + it('should re-log context percentage when model changes', async () => { + // Arrange + const githubDir = path.join(tempDir, '.github') + fs.mkdirSync(githubDir, { recursive: true }) + fs.writeFileSync( + path.join(githubDir, 'copilot-instructions.md'), + '# Repo Instructions\n\nFollow these rules.' + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const smallerModel = { providerID: 'test', modelID: 'smaller-model', limit: { context: 128000, output: 8000 } } as any + + // Act - first call with 200k model + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, { system: [] }) + // Act - second call with 128k model + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: smallerModel }, { system: [] }) + + // Assert - should have logged twice (once per distinct context size) + const contextLogs = logMessages.filter(msg => msg.includes('Instructions:')) + expect(contextLogs.length).toBe(2) + expect(contextLogs[0]).toContain('200k context') + expect(contextLogs[1]).toContain('128k context') + }) }) describe('experimental.session.compacting hook', () => { @@ -1035,18 +1193,19 @@ TypeScript rules.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, { system: [] }) const { afterOutput } = await executeToolWithHooks( hooks, { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, { filePath: path.join(tempDir, 'src/index.ts') } ) - // Assert - metadata.loaded should contain the instruction file path + summary + // Assert - metadata.loaded should contain the instruction file path with token count + summary expect(afterOutput.metadata.loaded).toBeDefined() expect(afterOutput.metadata.loaded).toBeInstanceOf(Array) expect(afterOutput.metadata.loaded).toHaveLength(2) - expect(afterOutput.metadata.loaded[0]).toContain('typescript.instructions.md') - expect(afterOutput.metadata.loaded[1]).toBe('Total: 1 instruction active') + expect(afterOutput.metadata.loaded[0]).toMatch(/^typescript\.instructions\.md \(\d+ tokens\)$/) + expect(afterOutput.metadata.loaded[1]).toMatch(/^Total: 1 instruction active \((\d+|\d+\.\d+k) tokens, (<0\.1|\d+\.\d+)% of context\)$/) }) it('should populate metadata.loaded with multiple instruction file paths', async () => { @@ -1069,6 +1228,7 @@ Source directory rules.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, { system: [] }) const { afterOutput } = await executeToolWithHooks( hooks, { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, @@ -1078,9 +1238,9 @@ Source directory rules.` // Assert expect(afterOutput.metadata.loaded).toHaveLength(3) const loadedPaths = afterOutput.metadata.loaded as string[] - expect(loadedPaths.some((p: string) => p.includes('typescript.instructions.md'))).toBe(true) - expect(loadedPaths.some((p: string) => p.includes('src.instructions.md'))).toBe(true) - expect(loadedPaths[2]).toBe('Total: 2 instructions active') + expect(loadedPaths.some((p: string) => p.match(/^src\.instructions\.md \(\d+ tokens\)$/))).toBe(true) + expect(loadedPaths.some((p: string) => p.match(/^typescript\.instructions\.md \(\d+ tokens\)$/))).toBe(true) + expect(loadedPaths[2]).toMatch(/^Total: 2 instructions active \((\d+|\d+\.\d+k) tokens, (<0\.1|\d+\.\d+)% of context\)$/) }) it('should not set metadata.loaded when no instructions match', async () => { @@ -1120,6 +1280,7 @@ TypeScript rules.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, { system: [] }) const input = { tool: 'read', sessionID: 'session-1', callID: 'call-1', args: {} } const beforeOutput = { args: { filePath: path.join(tempDir, 'src/index.ts') } } as any await hooks['tool.execute.before']!(input, beforeOutput) @@ -1135,8 +1296,8 @@ TypeScript rules.` // Assert - should preserve existing and add new + summary expect(afterOutput.metadata.loaded).toHaveLength(3) expect(afterOutput.metadata.loaded[0]).toBe('.github/AGENTS.md') - expect(afterOutput.metadata.loaded[1]).toContain('typescript.instructions.md') - expect(afterOutput.metadata.loaded[2]).toBe('Total: 1 instruction active') + expect(afterOutput.metadata.loaded[1]).toMatch(/^typescript\.instructions\.md \(\d+ tokens\)$/) + expect(afterOutput.metadata.loaded[2]).toMatch(/^Total: 1 instruction active \((\d+|\d+\.\d+k) tokens, (<0\.1|\d+\.\d+)% of context\)$/) }) it('should include total summary in metadata.loaded with singular count', async () => { @@ -1152,6 +1313,7 @@ TypeScript rules.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, { system: [] }) const { afterOutput } = await executeToolWithHooks( hooks, { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, @@ -1161,7 +1323,7 @@ TypeScript rules.` // Assert const loaded = afterOutput.metadata.loaded as string[] const summary = loaded[loaded.length - 1] - expect(summary).toBe('Total: 1 instruction active') + expect(summary).toMatch(/^Total: 1 instruction active \((\d+|\d+\.\d+k) tokens, (<0\.1|\d+\.\d+)% of context\)$/) }) it('should include total summary with plural count for multiple instructions', async () => { @@ -1184,6 +1346,7 @@ Source directory rules.` ) const hooks = await CopilotInstructionsPlugin(createPluginInput()) + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, { system: [] }) const { afterOutput } = await executeToolWithHooks( hooks, { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, @@ -1193,7 +1356,7 @@ Source directory rules.` // Assert const loaded = afterOutput.metadata.loaded as string[] const summary = loaded[loaded.length - 1] - expect(summary).toBe('Total: 2 instructions active') + expect(summary).toMatch(/^Total: 2 instructions active \((\d+|\d+\.\d+k) tokens, (<0\.1|\d+\.\d+)% of context\)$/) }) it('should include repo-wide instruction in total summary count', async () => { @@ -1213,6 +1376,84 @@ applyTo: "**/*.ts" TypeScript rules.` ) + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + await hooks['experimental.chat.system.transform']!({ sessionID: 'session-1', model: mockModel }, { system: [] }) + const { afterOutput } = await executeToolWithHooks( + hooks, + { tool: 'read', sessionID: 'session-1', callID: 'call-1' }, + { filePath: path.join(tempDir, 'src/index.ts') } + ) + + // Assert - total should be 2 (1 repo-wide + 1 path-specific), repo info shown first + const loaded = afterOutput.metadata.loaded as string[] + expect(loaded).toHaveLength(3) + expect(loaded[0]).toMatch(/^copilot-instructions\.md \(\d+ tokens\)$/) + expect(loaded[1]).toMatch(/^typescript\.instructions\.md \(\d+ tokens\)$/) + expect(loaded[2]).toMatch(/^Total: 2 instructions active \((\d+|\d+\.\d+k) tokens, (<0\.1|\d+\.\d+)% of context\)$/) + }) + + it('should accumulate total count across multiple tool calls', async () => { + const githubDir = path.join(tempDir, '.github') + const instructionsDir = path.join(githubDir, 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(githubDir, 'copilot-instructions.md'), + '# Repo Instructions' + ) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + fs.writeFileSync( + path.join(instructionsDir, 'python.instructions.md'), + `--- +applyTo: "**/*.py" +--- +Python rules.` + ) + + const hooks = await CopilotInstructionsPlugin(createPluginInput()) + const sessionID = 'session-1' + await hooks['experimental.chat.system.transform']!({ sessionID, model: mockModel }, { system: [] }) + + // First call - loads typescript instruction + const { afterOutput: afterOutput1 } = await executeToolWithHooks( + hooks, + { tool: 'read', sessionID, callID: 'call-1' }, + { filePath: path.join(tempDir, 'src/index.ts') } + ) + const loaded1 = afterOutput1.metadata.loaded as string[] + expect(loaded1).toHaveLength(3) // repo entry + ts entry + summary + expect(loaded1[0]).toMatch(/^copilot-instructions\.md \(\d+ tokens\)$/) + expect(loaded1[1]).toMatch(/^typescript\.instructions\.md \(\d+ tokens\)$/) + expect(loaded1[2]).toMatch(/^Total: 2 instructions active/) // 1 repo + 1 ts + + // Second call - loads python instruction (repo entry NOT shown again) + const { afterOutput: afterOutput2 } = await executeToolWithHooks( + hooks, + { tool: 'read', sessionID, callID: 'call-2' }, + { filePath: path.join(tempDir, 'src/script.py') } + ) + const loaded2 = afterOutput2.metadata.loaded as string[] + expect(loaded2).toHaveLength(2) // py entry + summary (no repo entry) + expect(loaded2[0]).toMatch(/^python\.instructions\.md \(\d+ tokens\)$/) + expect(loaded2[1]).toMatch(/^Total: 3 instructions active/) // 1 repo + 1 ts + 1 py (accumulated) + }) + + it('should omit context percentage when model context size is unknown', async () => { + const instructionsDir = path.join(tempDir, '.github', 'instructions') + fs.mkdirSync(instructionsDir, { recursive: true }) + fs.writeFileSync( + path.join(instructionsDir, 'typescript.instructions.md'), + `--- +applyTo: "**/*.ts" +--- +TypeScript rules.` + ) + const hooks = await CopilotInstructionsPlugin(createPluginInput()) const { afterOutput } = await executeToolWithHooks( hooks, @@ -1220,10 +1461,9 @@ TypeScript rules.` { filePath: path.join(tempDir, 'src/index.ts') } ) - // Assert - total should be 2 (1 repo-wide + 1 path-specific) const loaded = afterOutput.metadata.loaded as string[] const summary = loaded[loaded.length - 1] - expect(summary).toBe('Total: 2 instructions active') + expect(summary).toMatch(/^Total: 1 instruction active \((\d+|\d+\.\d+k) tokens\)$/) }) it('should not add summary to metadata.loaded when no instructions match', async () => { diff --git a/src/index.ts b/src/index.ts index 863c454..b8e3429 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import type { Plugin } from '@opencode-ai/plugin' import type { Event, EventSessionCompacted } from '@opencode-ai/sdk' import { loadRepoInstructions, loadPathInstructions, type PathInstruction } from './loader' import { SessionState } from './session-state' +import { formatTokenCount, formatTokenPercentage } from './tokens' /** * Type guard to check if an event is a session.compacted event. @@ -74,15 +75,15 @@ export const CopilotInstructionsPlugin: Plugin = async (ctx) => { }) } - // Log what was loaded + // Log what was loaded with token stats if (repoInstructions) { - log('Loaded repo instructions from .github/copilot-instructions.md') + log(`Loaded repo instructions from .github/copilot-instructions.md (${formatTokenCount(repoInstructions.tokenCount)} tokens)`) } if (pathInstructions.length > 0) { for (const instruction of pathInstructions) { const filename = path.basename(instruction.file) - log(`Loaded path instructions from ${filename}`) + log(`Loaded path instructions from ${filename} (${formatTokenCount(instruction.tokenCount)} tokens)`) } } @@ -90,6 +91,13 @@ export const CopilotInstructionsPlugin: Plugin = async (ctx) => { log('No Copilot instructions found') } + const totalTokens = (repoInstructions?.tokenCount ?? 0) + + pathInstructions.reduce((sum, i) => sum + i.tokenCount, 0) + + if (totalTokens > 0) { + log(`Total instruction tokens: ${formatTokenCount(totalTokens)}`) + } + // Encapsulated state management const state = new SessionState() @@ -109,16 +117,28 @@ export const CopilotInstructionsPlugin: Plugin = async (ctx) => { }, // Inject repo-wide instructions into the system prompt on every LLM call - 'experimental.chat.system.transform': async (_input, output) => { + 'experimental.chat.system.transform': async (input, output) => { if (repoInstructions) { - output.system.push(`\n${repoInstructions.trimEnd()}\n`) + output.system.push(`\n${repoInstructions.content.trimEnd()}\n`) + } + + if (input.sessionID) { + const contextSize = (input.model as { limit?: { context?: number } })?.limit?.context ?? 0 + if (contextSize > 0) { + state.setContextSize(input.sessionID, contextSize) + } + + if (totalTokens > 0 && contextSize > 0 && !state.isContextLogged(input.sessionID, contextSize)) { + state.markContextLogged(input.sessionID, contextSize) + log(`Instructions: ${formatTokenCount(totalTokens)} tokens (${formatTokenPercentage(totalTokens, contextSize)} of ${formatTokenCount(contextSize)} context)`) + } } }, // Preserve repo-wide instructions during compaction 'experimental.session.compacting': async (_input, output) => { if (repoInstructions) { - output.context.push(`\n${repoInstructions.trimEnd()}\n`) + output.context.push(`\n${repoInstructions.content.trimEnd()}\n`) } }, @@ -149,7 +169,7 @@ export const CopilotInstructionsPlugin: Plugin = async (ctx) => { // Check if file matches this instruction's patterns if (instruction.matcher(relativePath)) { matchingInstructions.push(instruction) - state.markFileInjected(input.sessionID, instruction.file) + state.markFileInjected(input.sessionID, instruction.file, instruction.tokenCount) } } @@ -163,7 +183,7 @@ export const CopilotInstructionsPlugin: Plugin = async (ctx) => { }) .join('\n\n') - const loadedFiles = matchingInstructions.map(i => i.file) + const loadedFiles = matchingInstructions.map(i => ({ file: i.file, tokenCount: i.tokenCount })) state.setPending(input.callID, instructionText, loadedFiles) log(`Queued ${matchingInstructions.length} path instructions for ${relativePath}`, 'debug') } @@ -178,9 +198,19 @@ export const CopilotInstructionsPlugin: Plugin = async (ctx) => { if (pathCount > 0) { const existing = (output.metadata as Record)?.loaded const existingArray = Array.isArray(existing) ? existing : [] - const totalCount = pathCount + (repoInstructions ? 1 : 0) - const summary = `Total: ${totalCount} instruction${totalCount > 1 ? 's' : ''} active` - ;(output.metadata as Record).loaded = [...existingArray, ...pending.loadedFiles, summary] + const totalCount = state.getInjectedFiles(input.sessionID).size + (repoInstructions ? 1 : 0) + const activeTokens = state.getInjectedTokens(input.sessionID) + (repoInstructions?.tokenCount ?? 0) + const contextSize = state.getContextSize(input.sessionID) + const pct = contextSize > 0 ? `, ${formatTokenPercentage(activeTokens, contextSize)} of context` : '' + const summary = `Total: ${totalCount} instruction${totalCount > 1 ? 's' : ''} active (${formatTokenCount(activeTokens)} tokens${pct})` + const repoEntry = repoInstructions && !state.isRepoInfoShown(input.sessionID) + ? [`copilot-instructions.md (${formatTokenCount(repoInstructions.tokenCount)} tokens)`] + : [] + if (repoInstructions) { + state.markRepoInfoShown(input.sessionID) + } + const loadedEntries = pending.loadedFiles.map(f => `${path.basename(f.file)} (${formatTokenCount(f.tokenCount)} tokens)`) + ;(output.metadata as Record).loaded = [...existingArray, ...repoEntry, ...loadedEntries, summary] } log(`Injected path instructions for call ${input.callID}`, 'debug')