diff --git a/package-lock.json b/package-lock.json index 9094525..99c2ead 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "planeteer", "version": "0.1.0", "dependencies": { - "@github/copilot-sdk": "^0.1.24", + "@github/copilot-sdk": "^0.1.26", "ink": "^5.1.0", "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", @@ -642,26 +642,26 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.411.tgz", - "integrity": "sha512-I3/7gw40Iu1O+kTyNPKJHNqDRyOebjsUW6wJsvSVrOpT0TNa3/lfm8xdS2XUuJWkp+PgEG/PRwF7u3DVNdP7bQ==", + "version": "0.0.414", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.414.tgz", + "integrity": "sha512-jseJ2S02CLWrFks5QK22zzq7as2ErY5m1wMCFBOE6sro1uACq1kvqqM1LwM4qy58YSZFrM1ZAn1s7UOVd9zhIA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.411", - "@github/copilot-darwin-x64": "0.0.411", - "@github/copilot-linux-arm64": "0.0.411", - "@github/copilot-linux-x64": "0.0.411", - "@github/copilot-win32-arm64": "0.0.411", - "@github/copilot-win32-x64": "0.0.411" + "@github/copilot-darwin-arm64": "0.0.414", + "@github/copilot-darwin-x64": "0.0.414", + "@github/copilot-linux-arm64": "0.0.414", + "@github/copilot-linux-x64": "0.0.414", + "@github/copilot-win32-arm64": "0.0.414", + "@github/copilot-win32-x64": "0.0.414" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.411.tgz", - "integrity": "sha512-dtr+iHxTS4f8HlV2JT9Fp0FFoxuiPWCnU3XGmrHK+rY6bX5okPC2daU5idvs77WKUGcH8yHTZtfbKYUiMxKosw==", + "version": "0.0.414", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.414.tgz", + "integrity": "sha512-PW4v89v41i4Mg/NYl4+gEhwnDaVz+olNL+RbqtiQI3IV89gZdS+RZQbUEJfOwMaFcT2GfiUK1OuB+YDv5GrkBg==", "cpu": [ "arm64" ], @@ -675,9 +675,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.411.tgz", - "integrity": "sha512-zhdbQCbPi1L4iHClackSLx8POfklA+NX9RQLuS48HlKi/0KI/JlaDA/bdbIeMR79wjif5t9gnc/m+RTVmHlRtA==", + "version": "0.0.414", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.414.tgz", + "integrity": "sha512-NyPYm0NovQTwtuI42WJIi4cjYd2z0wBHEvWlUSczRsSuYEyImAClmZmBPegUU63e5JdZd1PdQkQ7FqrrfL2fZQ==", "cpu": [ "x64" ], @@ -691,9 +691,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.411.tgz", - "integrity": "sha512-oZYZ7oX/7O+jzdTUcHkfD1A8YnNRW6mlUgdPjUg+5rXC43bwIdyatAnc0ObY21m9h8ghxGqholoLhm5WnGv1LQ==", + "version": "0.0.414", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.414.tgz", + "integrity": "sha512-VgdRsvA1FiZ1lcU/AscSvyJWEUWZzoXv2tSZ6WV3NE0uUTuO1Qoq4xuqbKZ/+vKJmn1b8afe7sxAAOtCoWPBHQ==", "cpu": [ "arm64" ], @@ -707,9 +707,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.411.tgz", - "integrity": "sha512-nnXrKANmmGnkwa3ROlKdAhVNOx8daeMSE8Xh0o3ybKckFv4s38blhKdcxs0RJQRxgAk4p7XXGlDDKNRhurqF1g==", + "version": "0.0.414", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.414.tgz", + "integrity": "sha512-3HyZsbZqYTF5jcT7/e+nDIYBCQXo8UCVWjBI3raOE4lzAw9b2ucL290IhtA23s1+EiquMxJ4m3FnjwFmwlQ12A==", "cpu": [ "x64" ], @@ -723,12 +723,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.25.tgz", - "integrity": "sha512-hIgYLPXzWw9bNgrsD5BLKmgVH20ow5Or5UyVXfVe3YgeiaTgFxC4jWSAVHLGB6ufHZUrvbjppcq2dWK63FmDRA==", + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.26.tgz", + "integrity": "sha512-5YsApwYa/k2VqaNGvB+ngvWIRxyBR+AY17wCX3ceo+0UcwR7RbW0Ld8l8c5wFOh8Qa/UN4/kXb10HRUTYelGUA==", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.411", + "@github/copilot": "^0.0.414", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -737,9 +737,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.411.tgz", - "integrity": "sha512-h+Bovb2YVCQSeELZOO7zxv8uht45XHcvAkFbRsc1gf9dl109sSUJIcB4KAhs8Aznk28qksxz7kvdSgUWyQBlIA==", + "version": "0.0.414", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.414.tgz", + "integrity": "sha512-8gdaoF4MPpeV0h8UnCZ8TKI5l274EP0fvAaV9BGjsdyEydDcEb+DHqQiXgitWVKKiHAAaPi12aH8P5OsEDUneQ==", "cpu": [ "arm64" ], @@ -753,9 +753,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.411", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.411.tgz", - "integrity": "sha512-xmOgi1lGvUBHQJWmq5AK1EP95+Y8xR4TFoK9OCSOaGbQ+LFcX2jF7iavnMolfWwddabew/AMQjsEHlXvbgMG8Q==", + "version": "0.0.414", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.414.tgz", + "integrity": "sha512-E1Oq1jXHaL1oWNsmmiCd4G30/CfgVdswg/T5oDFUxx3Ri+6uBekciIzdyCDelsP1kn2+fC1EYz2AerQ6F+huzg==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index f1b17cf..4dc48bf 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:watch": "vitest" }, "dependencies": { - "@github/copilot-sdk": "^0.1.24", + "@github/copilot-sdk": "^0.1.26", "ink": "^5.1.0", "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", diff --git a/src/components/status-bar.tsx b/src/components/status-bar.tsx index 7bbc89a..1e1b471 100644 --- a/src/components/status-bar.tsx +++ b/src/components/status-bar.tsx @@ -7,16 +7,18 @@ interface StatusBarProps { screen: string; hint?: string; model?: string; + extra?: string; } -export default function StatusBar({ screen, hint, model }: StatusBarProps): React.ReactElement { +export default function StatusBar({ screen, hint, model, extra }: StatusBarProps): React.ReactElement { const displayModel = model || getModelLabel(); const { stdout } = useStdout(); // Parent App uses padding={1} → 2 cols consumed; border chars │…│ take 2 more const innerWidth = (stdout?.columns ?? 80) - 4; - // Build right-side content: "model hint q: quit" + // Build right-side content: "model extra hint q: quit" const rightParts: string[] = [displayModel]; + if (extra) rightParts.push(extra); if (hint) rightParts.push(hint); rightParts.push('q: quit'); const rightText = rightParts.join(' '); diff --git a/src/screens/clarify.tsx b/src/screens/clarify.tsx index 1b0f349..88e1b20 100644 --- a/src/screens/clarify.tsx +++ b/src/screens/clarify.tsx @@ -26,6 +26,7 @@ export default function ClarifyScreen({ onScopeConfirmed, onBack }: ClarifyScree const [codebaseContext, setCodebaseContext] = useState(''); const [inspecting, setInspecting] = useState(false); const [inspectDone, setInspectDone] = useState(false); + const [compactionMsg, setCompactionMsg] = useState(''); useEffect(() => { loadHistory().then(setHistory); @@ -108,6 +109,19 @@ export default function ClarifyScreen({ onScopeConfirmed, onBack }: ClarifyScree ]); setStreaming(false); }, + onSessionEvent: (event) => { + if (event.type === 'session.compaction_start') { + setCompactionMsg('⟳ Compacting session history…'); + } else if (event.type === 'session.compaction_complete') { + if (event.data.success) { + const removed = event.data.messagesRemoved ?? 0; + setCompactionMsg(`✓ Session history compacted (${removed} messages removed)`); + } else { + setCompactionMsg(`⚠ Compaction failed: ${event.data.error ?? 'unknown error'}`); + } + setTimeout(() => setCompactionMsg(''), 5000); + } + }, }, codebaseContext || undefined).catch((error: Error) => { setMessages((prev) => [ ...prev, @@ -232,6 +246,13 @@ export default function ClarifyScreen({ onScopeConfirmed, onBack }: ClarifyScree )} + {/* Compaction notification */} + {compactionMsg !== '' && ( + + {compactionMsg} + + )} + ([]); const [taskContexts, setTaskContexts] = useState>({}); + const [tokenUsage, setTokenUsage] = useState<{ tokenLimit: number; currentTokens: number } | null>(null); + const [compactionMsg, setCompactionMsg] = useState(''); const { batches } = computeBatches(plan.tasks); // Total display batches: init batch (index 0) + real batches @@ -211,6 +213,20 @@ export default function ExecuteScreen({ ...prev, [taskId]: { cwd, repository, branch }, })); + } else if (event.type === 'session.usage_info') { + setTokenUsage({ tokenLimit: event.data.tokenLimit, currentTokens: event.data.currentTokens }); + } else if (event.type === 'session.compaction_start') { + setCompactionMsg('⟳ Compacting session history…'); + } else if (event.type === 'session.compaction_complete') { + if (event.data.success) { + const removed = event.data.messagesRemoved ?? 0; + const pre = event.data.preCompactionTokens ?? 0; + const post = event.data.postCompactionTokens ?? 0; + setCompactionMsg(`✓ Compacted: ${removed} messages removed (${pre}→${post} tokens)`); + } else { + setCompactionMsg(`⚠ Compaction failed: ${event.data.error ?? 'unknown error'}`); + } + setTimeout(() => setCompactionMsg(''), 5000); } }, }, execOptions); @@ -456,6 +472,13 @@ export default function ExecuteScreen({ )} + {/* Compaction notification */} + {compactionMsg !== '' && ( + + {compactionMsg} + + )} + ); diff --git a/src/screens/refine.tsx b/src/screens/refine.tsx index 57aeb71..c03c353 100644 --- a/src/screens/refine.tsx +++ b/src/screens/refine.tsx @@ -41,6 +41,7 @@ export default function RefineScreen({ const [viewMode, setViewMode] = useState('tree'); const [editingTask, setEditingTask] = useState(null); const [commandMode, setCommandMode] = useState(false); + const [compactionMsg, setCompactionMsg] = useState(''); const toggleSkill = useCallback( (skillName: string) => { @@ -164,7 +165,19 @@ export default function RefineScreen({ .then((skillOptions) => refineWBS(currentPlan.tasks, value, (_delta, fullText) => { setStreamText(fullText); - }, skillOptions) + }, skillOptions, (event) => { + if (event.type === 'session.compaction_start') { + setCompactionMsg('⟳ Compacting session history…'); + } else if (event.type === 'session.compaction_complete') { + if (event.data.success) { + const removed = event.data.messagesRemoved ?? 0; + setCompactionMsg(`✓ Session history compacted (${removed} messages removed)`); + } else { + setCompactionMsg(`⚠ Compaction failed: ${event.data.error ?? 'unknown error'}`); + } + setTimeout(() => setCompactionMsg(''), 5000); + } + }) ) .then((tasks) => { const updated = { ...currentPlan, tasks, updatedAt: new Date().toISOString() }; @@ -301,6 +314,13 @@ export default function RefineScreen({ {saved && ✓ Plan saved} + {/* Compaction notification */} + {compactionMsg !== '' && ( + + {compactionMsg} + + )} + { } } +/** Default context utilization threshold (0.0–1.0) at which background compaction starts. */ +export const COMPACTION_THRESHOLD = 0.8; + export interface StreamCallbacks { onDelta: (text: string) => void; onDone: (fullText: string) => void; @@ -205,11 +208,13 @@ export async function sendPrompt( streaming: boolean; skillDirectories?: string[]; disabledSkills?: string[]; + infiniteSessions?: { backgroundCompactionThreshold?: number }; } const sessionConfig: SessionConfigWithSkills = { model: currentModel, streaming: true, + infiniteSessions: { backgroundCompactionThreshold: COMPACTION_THRESHOLD }, }; if (skillOptions?.skillDirectories && skillOptions.skillDirectories.length > 0) { diff --git a/src/services/executor.test.ts b/src/services/executor.test.ts index d154c82..b5778b2 100644 --- a/src/services/executor.test.ts +++ b/src/services/executor.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import type { ExecutionCallbacks, SessionEventWithTask } from './executor.js'; import type { SessionEvent } from './copilot.js'; +import { COMPACTION_THRESHOLD } from './copilot.js'; describe('SessionEventWithTask type', () => { it('should correctly structure context change events with task ID', () => { @@ -64,6 +65,81 @@ describe('SessionEventWithTask type', () => { expect(eventWithTask.event.data.context.repository).toBe('test/repo'); } }); + + it('should handle session.usage_info events with task ID', () => { + const mockEvent: SessionEvent = { + id: 'evt-usage-1', + timestamp: new Date().toISOString(), + parentId: null, + ephemeral: true, + type: 'session.usage_info', + data: { + tokenLimit: 128000, + currentTokens: 102400, + messagesLength: 42, + }, + }; + + const eventWithTask: SessionEventWithTask = { + taskId: 'task-abc', + event: mockEvent, + }; + + expect(eventWithTask.taskId).toBe('task-abc'); + expect(eventWithTask.event.type).toBe('session.usage_info'); + if (eventWithTask.event.type === 'session.usage_info') { + expect(eventWithTask.event.data.tokenLimit).toBe(128000); + expect(eventWithTask.event.data.currentTokens).toBe(102400); + expect(eventWithTask.event.data.messagesLength).toBe(42); + } + }); + + it('should handle session.compaction_start events', () => { + const mockEvent: SessionEvent = { + id: 'evt-compact-start', + timestamp: new Date().toISOString(), + parentId: null, + type: 'session.compaction_start', + data: {}, + }; + + const eventWithTask: SessionEventWithTask = { + taskId: 'task-xyz', + event: mockEvent, + }; + + expect(eventWithTask.taskId).toBe('task-xyz'); + expect(eventWithTask.event.type).toBe('session.compaction_start'); + }); + + it('should handle session.compaction_complete events', () => { + const mockEvent: SessionEvent = { + id: 'evt-compact-done', + timestamp: new Date().toISOString(), + parentId: null, + type: 'session.compaction_complete', + data: { + success: true, + preCompactionTokens: 102400, + postCompactionTokens: 8192, + messagesRemoved: 45, + tokensRemoved: 94208, + }, + }; + + const eventWithTask: SessionEventWithTask = { + taskId: 'task-xyz', + event: mockEvent, + }; + + expect(eventWithTask.event.type).toBe('session.compaction_complete'); + if (eventWithTask.event.type === 'session.compaction_complete') { + expect(eventWithTask.event.data.success).toBe(true); + expect(eventWithTask.event.data.messagesRemoved).toBe(45); + expect(eventWithTask.event.data.preCompactionTokens).toBe(102400); + expect(eventWithTask.event.data.postCompactionTokens).toBe(8192); + } + }); }); describe('ExecutionCallbacks with session events', () => { @@ -113,4 +189,60 @@ describe('ExecutionCallbacks with session events', () => { event: mockEvent, }); }); + + it('should receive and forward compaction events via onSessionEvent', () => { + const sessionEventHandler = vi.fn(); + const callbacks: ExecutionCallbacks = { + onTaskStart: vi.fn(), + onTaskDelta: vi.fn(), + onTaskDone: vi.fn(), + onTaskFailed: vi.fn(), + onBatchComplete: vi.fn(), + onAllDone: vi.fn(), + onSessionEvent: sessionEventHandler, + }; + + const compactionStartEvent: SessionEvent = { + id: 'compact-start-1', + timestamp: new Date().toISOString(), + parentId: null, + type: 'session.compaction_start', + data: {}, + }; + + const compactionCompleteEvent: SessionEvent = { + id: 'compact-done-1', + timestamp: new Date().toISOString(), + parentId: null, + type: 'session.compaction_complete', + data: { + success: true, + preCompactionTokens: 100000, + postCompactionTokens: 5000, + messagesRemoved: 30, + tokensRemoved: 95000, + }, + }; + + callbacks.onSessionEvent?.({ taskId: 'task-1', event: compactionStartEvent }); + callbacks.onSessionEvent?.({ taskId: 'task-1', event: compactionCompleteEvent }); + + expect(sessionEventHandler).toHaveBeenCalledTimes(2); + expect(sessionEventHandler.mock.calls[0]![0].event.type).toBe('session.compaction_start'); + expect(sessionEventHandler.mock.calls[1]![0].event.type).toBe('session.compaction_complete'); + if (sessionEventHandler.mock.calls[1]![0].event.type === 'session.compaction_complete') { + expect(sessionEventHandler.mock.calls[1]![0].event.data.messagesRemoved).toBe(30); + } + }); +}); + +describe('COMPACTION_THRESHOLD', () => { + it('should be 0.8 (80% context utilization)', () => { + expect(COMPACTION_THRESHOLD).toBe(0.8); + }); + + it('should be in the valid range 0.0–1.0', () => { + expect(COMPACTION_THRESHOLD).toBeGreaterThan(0); + expect(COMPACTION_THRESHOLD).toBeLessThan(1); + }); }); diff --git a/src/services/planner.ts b/src/services/planner.ts index 1804bed..dd44ba5 100644 --- a/src/services/planner.ts +++ b/src/services/planner.ts @@ -179,13 +179,14 @@ export async function refineWBS( refinementRequest: string, onDelta?: (delta: string, fullText: string) => void, skillOptions?: SkillOptions, + onSessionEvent?: (event: import('./copilot.js').SessionEvent) => void, ): Promise { const result = await sendPromptSync(REFINE_SYSTEM_PROMPT, [ { role: 'user', content: `Current tasks:\n${JSON.stringify(currentTasks, null, 2)}\n\nRefinement request: ${refinementRequest}`, }, - ], { onDelta, skillOptions }); + ], { onDelta, skillOptions, onSessionEvent }); const jsonStr = extractJsonArray(result); const tasks = JSON.parse(jsonStr) as Task[];