From 94c4b37eed6d53f126d12e13e9e416d97bd19afb Mon Sep 17 00:00:00 2001 From: unraid Date: Wed, 22 Apr 2026 22:38:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20summary=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=20TypeScript=20=E9=87=8D=E5=86=99=E4=B8=8E?= =?UTF-8?q?=E5=85=B6=E4=BB=96=E5=91=BD=E4=BB=A4=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/commands.ts | 8 +- src/commands/bridge/bridge.tsx | 1 - src/commands/effort/index.ts | 2 +- src/commands/force-snip.ts | 2 +- src/commands/insights.ts | 1 - src/commands/model/model.tsx | 2 +- .../summary/__tests__/summary.test.ts | 91 +++++++++++++++++++ src/commands/summary/index.ts | 78 ++++++++++++++++ src/commands/ultraplan.tsx | 2 +- 9 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 src/commands/summary/__tests__/summary.test.ts create mode 100644 src/commands/summary/index.ts diff --git a/src/commands.ts b/src/commands.ts index f0fc6675a..c3ea1804a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -180,6 +180,8 @@ import mockLimits from './commands/mock-limits/index.js' import bridgeKick from './commands/bridge-kick.js' import version from './commands/version.js' import summary from './commands/summary/index.js' +import skillLearning from './commands/skill-learning/index.js' +import skillSearch from './commands/skill-search/index.js' import { resetLimits, resetLimitsNonInteractive, @@ -274,7 +276,6 @@ export const INTERNAL_ONLY_COMMANDS = [ goodClaude, issue, initVerifiers, - ...(forceSnip ? [forceSnip] : []), mockLimits, bridgeKick, version, @@ -283,7 +284,6 @@ export const INTERNAL_ONLY_COMMANDS = [ resetLimitsNonInteractive, onboarding, share, - summary, teleport, antTrace, perfIssue, @@ -397,6 +397,10 @@ const COMMANDS = memoize((): Command[] => [ ...(torch ? [torch] : []), ...(daemonCmd ? [daemonCmd] : []), ...(jobCmd ? [jobCmd] : []), + ...(forceSnip ? [forceSnip] : []), + summary, + skillLearning, + skillSearch, ...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO ? INTERNAL_ONLY_COMMANDS : []), diff --git a/src/commands/bridge/bridge.tsx b/src/commands/bridge/bridge.tsx index a9d9bc5ac..2fe96a282 100644 --- a/src/commands/bridge/bridge.tsx +++ b/src/commands/bridge/bridge.tsx @@ -54,7 +54,6 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode { const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly) const [showDisconnectDialog, setShowDisconnectDialog] = useState(false) - // biome-ignore lint/correctness/useExhaustiveDependencies: bridge starts once, should not restart on state changes useEffect(() => { // If already connected or enabled in full bidirectional mode, show // disconnect confirmation. Outbound-only (CCR mirror) doesn't count — diff --git a/src/commands/effort/index.ts b/src/commands/effort/index.ts index 66cd5110a..6e469c7d3 100644 --- a/src/commands/effort/index.ts +++ b/src/commands/effort/index.ts @@ -5,7 +5,7 @@ export default { type: 'local-jsx', name: 'effort', description: 'Set effort level for model usage', - argumentHint: '[low|medium|high|max|auto]', + argumentHint: '[low|medium|high|xhigh|max|auto]', get immediate() { return shouldInferenceConfigCommandBeImmediate() }, diff --git a/src/commands/force-snip.ts b/src/commands/force-snip.ts index 6d1a355af..14a7e3106 100644 --- a/src/commands/force-snip.ts +++ b/src/commands/force-snip.ts @@ -52,7 +52,7 @@ const forceSnip = { name: 'force-snip', description: 'Force snip conversation history at current point', supportsNonInteractive: true, - isHidden: true, + isHidden: false, load: () => Promise.resolve({ call }), } satisfies Command diff --git a/src/commands/insights.ts b/src/commands/insights.ts index 1e5e40dd5..81fd9edce 100644 --- a/src/commands/insights.ts +++ b/src/commands/insights.ts @@ -3058,7 +3058,6 @@ const usageReport: Command = { // Show collection message if collecting if (collectRemote && hasRemoteHosts) { - // biome-ignore lint/suspicious/noConsole: intentional console.error( `Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`, ) diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index f3523305c..8311fada1 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -160,7 +160,7 @@ function SetModelAndClose({ // @[MODEL LAUNCH]: Update check for 1M access. if (model && isOpus1mUnavailable(model)) { onDone( - `Opus 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`, + `Opus 4.7 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`, { display: 'system' }, ) return diff --git a/src/commands/summary/__tests__/summary.test.ts b/src/commands/summary/__tests__/summary.test.ts new file mode 100644 index 000000000..d4762bbb6 --- /dev/null +++ b/src/commands/summary/__tests__/summary.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect, mock, beforeEach } from 'bun:test' + +const mockManuallyExtract = mock( + (): Promise => Promise.resolve({ success: true }), +) +const mockGetContent = mock( + (): Promise => Promise.resolve('# Session Summary\n\nDid some work.'), +) + +mock.module( + require.resolve('../../../services/SessionMemory/sessionMemory.js'), + () => ({ + manuallyExtractSessionMemory: mockManuallyExtract, + }), +) +mock.module( + require.resolve('../../../services/SessionMemory/sessionMemoryUtils.js'), + () => ({ + getSessionMemoryContent: mockGetContent, + }), +) + +const { default: summaryCommand } = await import('../index.js') + +const baseContext = { + messages: [{ type: 'user', role: 'user', content: 'hello' }], + options: { tools: [], mainLoopModel: 'test' }, + setMessages: () => {}, + onChangeAPIKey: () => {}, +} as any + +async function callSummary(ctx = baseContext) { + const mod = await summaryCommand.load() + return mod.call('', ctx) +} + +beforeEach(() => { + mockManuallyExtract.mockReset() + mockGetContent.mockReset() + mockManuallyExtract.mockImplementation(() => + Promise.resolve({ success: true }), + ) + mockGetContent.mockImplementation(() => + Promise.resolve('# Session Summary\n\nDid some work.'), + ) +}) + +describe('summary command', () => { + test('command metadata', () => { + expect(summaryCommand.name).toBe('summary') + expect(summaryCommand.type).toBe('local') + expect(summaryCommand.isHidden).toBe(false) + expect(typeof summaryCommand.load).toBe('function') + }) + + test('refreshes and displays summary', async () => { + const result = await callSummary() + expect(result.type).toBe('text') + expect((result as any).value).toContain('Session summary updated.') + expect((result as any).value).toContain('Did some work.') + expect(mockManuallyExtract).toHaveBeenCalled() + }) + + test('handles extraction failure', async () => { + mockManuallyExtract.mockImplementation(() => + Promise.resolve({ success: false, error: 'timeout' }), + ) + const result = await callSummary() + expect((result as any).value).toContain( + 'Failed to generate session summary', + ) + expect((result as any).value).toContain('timeout') + }) + + test('handles empty content after extraction', async () => { + mockGetContent.mockImplementation(() => Promise.resolve('')) + const result = await callSummary() + expect((result as any).value).toContain('content is empty') + }) + + test('handles null content after extraction', async () => { + mockGetContent.mockImplementation(() => Promise.resolve(null)) + const result = await callSummary() + expect((result as any).value).toContain('content is empty') + }) + + test('handles no messages', async () => { + const result = await callSummary({ ...baseContext, messages: [] }) + expect((result as any).value).toBe('No messages to summarize.') + }) +}) diff --git a/src/commands/summary/index.ts b/src/commands/summary/index.ts new file mode 100644 index 000000000..d9e98b2eb --- /dev/null +++ b/src/commands/summary/index.ts @@ -0,0 +1,78 @@ +/** + * /summary — Generate and display a session summary. + * + * Triggers a manual Session Memory extraction (bypassing automatic thresholds), + * then reads and displays the updated summary.md file. + */ +import type { Command, LocalCommandCall } from '../../types/command.js' +import type { Message } from '../../types/message.js' + +/** Only user/assistant/system messages are valid for API calls. */ +const API_SAFE_TYPES = new Set(['user', 'assistant', 'system']) + +const call: LocalCommandCall = async (_args, context) => { + const { messages } = context + + // Filter to API-safe message types only. + // context.messages includes progress/attachment/etc. that crash the API + // call chain (normalizeMessagesForAPI → addCacheBreakpoints expects + // only user/assistant). The automatic extraction path uses + // createCacheSafeParams(REPLHookContext) which already has clean + // messages; the manual path via /summary does not. + const safeMessages = (messages ?? []).filter( + (m): m is Message => m != null && API_SAFE_TYPES.has(m.type), + ) + + if (safeMessages.length === 0) { + return { type: 'text', value: 'No messages to summarize.' } + } + + try { + const { manuallyExtractSessionMemory } = await import( + '../../services/SessionMemory/sessionMemory.js' + ) + const { getSessionMemoryContent } = await import( + '../../services/SessionMemory/sessionMemoryUtils.js' + ) + + const safeContext = { ...context, messages: safeMessages } + const result = await manuallyExtractSessionMemory(safeMessages, safeContext) + + if (!result.success) { + return { + type: 'text', + value: `Failed to generate session summary: ${result.error ?? 'unknown error'}`, + } + } + + const content = await getSessionMemoryContent() + + if (!content || content.trim().length === 0) { + return { + type: 'text', + value: 'Session summary was updated, but the content is empty.', + } + } + + return { + type: 'text', + value: `Session summary updated.\n\n${content}`, + } + } catch (error) { + return { + type: 'text', + value: `Failed to generate session summary: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +const summary = { + type: 'local', + name: 'summary', + description: 'Generate and display a session summary', + supportsNonInteractive: true, + isHidden: false, + load: () => Promise.resolve({ call }), +} satisfies Command + +export default summary diff --git a/src/commands/ultraplan.tsx b/src/commands/ultraplan.tsx index c04f3be49..af7f3d5d6 100644 --- a/src/commands/ultraplan.tsx +++ b/src/commands/ultraplan.tsx @@ -65,7 +65,7 @@ export function isUltraplanEnabled(): boolean { // load: the GrowthBook cache is empty at import and `/config` Gates can flip // it between invocations. function getUltraplanModel(): string { - return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus46.firstParty); + return getFeatureValue_CACHED_MAY_BE_STALE('tengu_ultraplan_model', ALL_MODEL_CONFIGS.opus47.firstParty); } // prompt.txt is wrapped in so the CCR browser hides