thatgfsj-code 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/app/index.d.ts +6 -0
  2. package/dist/app/index.d.ts.map +1 -1
  3. package/dist/app/index.js +8 -0
  4. package/dist/app/index.js.map +1 -1
  5. package/dist/cache/index.d.ts +1 -1
  6. package/dist/cache/index.d.ts.map +1 -1
  7. package/dist/cache/index.js +1 -1
  8. package/dist/cache/index.js.map +1 -1
  9. package/dist/cache/smartModel.d.ts +75 -20
  10. package/dist/cache/smartModel.d.ts.map +1 -1
  11. package/dist/cache/smartModel.js +91 -20
  12. package/dist/cache/smartModel.js.map +1 -1
  13. package/dist/config/index.js +4 -4
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/config/types.d.ts +5 -1
  16. package/dist/config/types.d.ts.map +1 -1
  17. package/dist/llm/anthropic.d.ts +17 -0
  18. package/dist/llm/anthropic.d.ts.map +1 -1
  19. package/dist/llm/anthropic.js +25 -1
  20. package/dist/llm/anthropic.js.map +1 -1
  21. package/dist/llm/index.d.ts +9 -0
  22. package/dist/llm/index.d.ts.map +1 -1
  23. package/dist/llm/index.js +37 -1
  24. package/dist/llm/index.js.map +1 -1
  25. package/dist/llm/provider.d.ts +6 -1
  26. package/dist/llm/provider.d.ts.map +1 -1
  27. package/dist/tui/app.d.ts.map +1 -1
  28. package/dist/tui/app.js +7 -1
  29. package/dist/tui/app.js.map +1 -1
  30. package/dist/tui/components/Header.d.ts +7 -0
  31. package/dist/tui/components/Header.d.ts.map +1 -1
  32. package/dist/tui/components/Header.js +2 -2
  33. package/dist/tui/components/Header.js.map +1 -1
  34. package/dist/tui/components/InitWizard.d.ts +1 -1
  35. package/dist/tui/components/InitWizard.d.ts.map +1 -1
  36. package/dist/tui/components/InitWizard.js +10 -19
  37. package/dist/tui/components/InitWizard.js.map +1 -1
  38. package/dist/tui/hooks/useCommands.d.ts.map +1 -1
  39. package/dist/tui/hooks/useCommands.js +36 -0
  40. package/dist/tui/hooks/useCommands.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/app/index.ts +8 -0
  43. package/src/cache/index.ts +7 -1
  44. package/src/cache/smartModel.ts +101 -20
  45. package/src/config/index.ts +4 -4
  46. package/src/config/types.ts +5 -1
  47. package/src/llm/anthropic.ts +26 -1
  48. package/src/llm/index.ts +39 -1
  49. package/src/llm/provider.ts +6 -1
  50. package/src/tui/app.tsx +7 -0
  51. package/src/tui/components/Header.tsx +12 -2
  52. package/src/tui/components/InitWizard.tsx +13 -30
  53. package/src/tui/hooks/useCommands.ts +37 -0
  54. package/tests/cache/smartModel.test.ts +100 -0
@@ -1,24 +1,35 @@
1
1
  /**
2
- * Smart-model routing (P3 of the Reasonix plan).
3
- *
4
- * Idea: classify the user's input as "simple" or "complex" and route
5
- * simple queries to a cheaper / faster model while keeping the main model
6
- * for complex work. The classification is intentionally trivial — prompt
7
- * length + presence of recent tool calls — because the cost of a
8
- * misclassification (a slightly worse answer on a simple query) is much
9
- * lower than the cost of running a frontier model on every greeting.
10
- *
11
- * Trade-off: each routing decision adds one model swap (different
12
- * `model` field on the wire). If the cache prefix is keyed on the model
13
- * string, the cache miss rate can spike on every simple query. To keep
14
- * the prefix stable, callers should pass a *family* (e.g. "anthropic" or
15
- * "deepseek") rather than a specific model name when forwarding the
16
- * prompt to the downstream provider — that's outside the scope of this
17
- * hook; this module only answers the question "should I downgrade?".
18
- *
19
- * v3.0.0: shipped as a stub. Real decision logic is conservative
20
- * (downgrade only when prompt is short AND no recent tool activity).
21
- * Future iterations can add heuristic or classifier-based routing.
2
+ * Smart-model routing + auto TTL selection (P3 of the Reasonix plan,
3
+ * v3.0.3 onwards).
4
+ *
5
+ * Idea: classify the conversation as "short" / "medium" / "long" and
6
+ * route accordingly. Two levers:
7
+ *
8
+ * 1. Model downgrade
9
+ * Short follow-ups ("yes", "thanks") mini model
10
+ * Tool-heavy turns → main model
11
+ *
12
+ * 2. Cache TTL
13
+ * v3.0.4: default to 1h (long-task TTL). We cannot predict task
14
+ * length at round 0, so we choose the TTL that cannot expire
15
+ * mid-task. 5m is opt-in only (user pins it via /ttl 5m or the
16
+ * init wizard).
17
+ *
18
+ * Why TTL is decided per-ROUND but CHANGES per-CONVERSATION:
19
+ * The Anthropic cache_control marker is part of the request body.
20
+ * Changing the TTL between rounds would push the wire bytes to a
21
+ * different Anthropic cache bucket, blowing the cache on every round
22
+ * and *costing* more in cache_creation_input_tokens than what we save.
23
+ *
24
+ * So we compute the TTL once per conversation (lazily, on the first
25
+ * round) and never change it. Subsequent rounds get the same TTL,
26
+ * which keeps the cache prefix stable and lets Anthropic match it.
27
+ *
28
+ * Trade-off: 1h write costs 2x input price (vs 1.25x for 5m), but
29
+ * reads are the same 0.1x. A long task hitting cache 2-3 times pays
30
+ * back the extra write cost; a short task pays it once and moves on.
31
+ * That is strictly better than guessing 5m and having the cache expire
32
+ * mid-task.
22
33
  */
23
34
 
24
35
  import type { ChatMessage } from '../types.js';
@@ -30,6 +41,13 @@ export interface SmartModelDecision {
30
41
  reason: string;
31
42
  }
32
43
 
44
+ export interface SmartTTLDecision {
45
+ ttl: '5m' | '1h';
46
+ reason: string;
47
+ /** Computed per-conversation. Once we pick a TTL, we keep it. */
48
+ isFirstDecision: boolean;
49
+ }
50
+
33
51
  /**
34
52
  * Heuristic: downgrade when
35
53
  * 1. The latest user message is short (< 200 chars), AND
@@ -50,3 +68,66 @@ export function shouldDowngrade(messages: ChatMessage[], lastUserInput: string):
50
68
  }
51
69
  return { downgrade: true, reason: 'short-no-tools' };
52
70
  }
71
+
72
+ /**
73
+ * Total character count of all message content. Used as a cheap
74
+ * proxy for "is this conversation long enough to justify 1h TTL?"
75
+ * (Anthropic charges cache_creation at 1.25x normal input, so we
76
+ * need to be sure the conversation will hit cache ~10+ times to
77
+ * break even on 1h vs 5m.)
78
+ */
79
+ export function totalConversationChars(messages: ChatMessage[]): number {
80
+ let total = 0;
81
+ for (const m of messages) {
82
+ if (typeof m.content === 'string') {
83
+ total += m.content.length;
84
+ } else if (Array.isArray(m.content)) {
85
+ for (const blk of m.content) {
86
+ if (blk.type === 'text') total += (blk as any).text.length;
87
+ }
88
+ }
89
+ }
90
+ return total;
91
+ }
92
+
93
+ /**
94
+ * Decide the cache TTL for the current round.
95
+ *
96
+ * v3.0.4: The previous implementation tried to *predict* the task length
97
+ * from the conversation so far (short sessions → 5m, long → 1h). That
98
+ * was wrong: at the moment the decision is made (first round), the task
99
+ * hasn't started yet — there is no way to know whether it will be a
100
+ * 30-second question or a 3-hour refactor. Predicting 5m for a task
101
+ * that turns out to be long means the cache expires mid-task (5-minute
102
+ * TTL counts from the last HIT, and agent work often has >5m gaps while
103
+ * tools run / user reads output), so every round after the gap re-writes
104
+ * the whole prefix at full price.
105
+ *
106
+ * Correct rule: DEFAULT TO THE LONG TTL (1h) and don't guess.
107
+ * - 1h costs 2x input price to WRITE (vs 1.25x for 5m), but reads are
108
+ * the same 0.1x for both.
109
+ * - A long task that hits cache even 2-3 times pays back the extra
110
+ * write cost. A short task only pays the extra write once.
111
+ * - Users who are SURE the session is short can pin 5m via
112
+ * /ttl 5m or the init wizard — the default stays 1h.
113
+ *
114
+ * Stability rule (unchanged): once a TTL is chosen, it is reused for
115
+ * every subsequent round in the same session. Changing TTL between
116
+ * rounds would invalidate the Anthropic cache prefix and cost more
117
+ * than it saves.
118
+ *
119
+ * The `isFirstDecision` flag tells the caller whether to apply the
120
+ * decision (first round) or reuse the previous one (later rounds).
121
+ */
122
+ export function decideTTL(
123
+ messages: ChatMessage[],
124
+ previousTTL: '5m' | '1h' | null,
125
+ ): SmartTTLDecision {
126
+ if (previousTTL) {
127
+ return { ttl: previousTTL, reason: 'reused-from-previous-round', isFirstDecision: false };
128
+ }
129
+ // Default: long-task TTL. We cannot know the task length upfront, so
130
+ // we pick the TTL that cannot expire mid-task. 5m is only used when
131
+ // the user explicitly pins it.
132
+ return { ttl: '1h', reason: 'default-long-task', isFirstDecision: true };
133
+ }
@@ -16,12 +16,12 @@ const DEFAULT_CONFIG: Config = {
16
16
  maxTokens: 4096,
17
17
  contextLength: 50,
18
18
  provider: 'siliconflow',
19
- // v3.0.0: prompt cache policy. Default = enabled, 5m TTL, auto strategy
20
- // (Anthropic gets explicit cache_control markers; everything else falls
21
- // through to the provider's built-in caching).
19
+ // v3.0.0: prompt cache policy. v3.0.4: default TTL is 1h (long-task).
20
+ // We cannot predict task length at round 0, so we default to the TTL
21
+ // that cannot expire mid-task. 5m is opt-in via /ttl 5m or init wizard.
22
22
  cache: {
23
23
  enabled: true,
24
- ttl: '5m',
24
+ ttl: '1h',
25
25
  strategy: 'auto',
26
26
  },
27
27
  };
@@ -43,10 +43,14 @@ export interface Config {
43
43
  * Note: enabling/disabling only affects Anthropic; other providers do
44
44
  * not expose a programmatic cache toggle, so the flag is purely
45
45
  * advisory there.
46
+ *
47
+ * v3.0.3: `ttl` accepts '5m' | '1h' | 'auto'. 'auto' lets the runtime
48
+ * pick the TTL based on conversation length (decideTTL in smartModel.ts).
49
+ * The wizard's default is 'auto' so users don't have to choose.
46
50
  */
47
51
  cache?: {
48
52
  enabled: boolean;
49
- ttl?: '5m' | '1h';
53
+ ttl?: '5m' | '1h' | 'auto';
50
54
  strategy?: 'auto' | 'manual' | 'off';
51
55
  };
52
56
  }
@@ -46,11 +46,31 @@ const DEFAULT_CACHE_TTL: '5m' | '1h' = '5m';
46
46
  export class AnthropicProvider implements LLMProvider {
47
47
  readonly name = 'anthropic';
48
48
  protected config: ProviderConfig;
49
+ /**
50
+ * v3.0.3: TTL resolved from the user's config + smart routing. This is
51
+ * the *actual* TTL attached to every cache_control marker we send this
52
+ * session. Stays stable across rounds to keep the Anthropic cache
53
+ * prefix intact.
54
+ *
55
+ * Computed once by LLMService.chatStream when ttl='auto', then
56
+ * persisted via setResolvedTTL. Subsequent rounds call chatStream
57
+ * without re-deciding (ChatOptions.resolvedTtl is sticky per session).
58
+ */
59
+ protected resolvedTtl: '5m' | '1h' = DEFAULT_CACHE_TTL;
49
60
 
50
61
  constructor(config: ProviderConfig) {
51
62
  this.config = config;
52
63
  }
53
64
 
65
+ /**
66
+ * Called by LLMService after decideTTL(). Persists the TTL on the
67
+ * provider instance so buildRequest() writes it consistently across
68
+ * every round in this session.
69
+ */
70
+ setResolvedTTL(ttl: '5m' | '1h'): void {
71
+ this.resolvedTtl = ttl;
72
+ }
73
+
54
74
  buildTools(tools: Tool[]): any[] {
55
75
  return tools.map(tool => ({
56
76
  name: tool.name,
@@ -247,7 +267,12 @@ export class AnthropicProvider implements LLMProvider {
247
267
  // final block (and only the final block, per Anthropic convention)
248
268
  // carries the cache_control marker.
249
269
  const systemMessages = messages.filter(m => m.role === 'system');
250
- const cacheTtl = (this.config.cache?.ttl) || DEFAULT_CACHE_TTL;
270
+ // v3.0.3: TTL is sticky per session. The provider's resolvedTtl is
271
+ // set once by LLMService after decideTTL() and never changed within
272
+ // a session (changing it would invalidate the Anthropic cache
273
+ // prefix and cost more in cache_creation_input_tokens than it
274
+ // saves).
275
+ const cacheTtl = this.resolvedTtl;
251
276
  const cacheEnabled = this.config.cache?.enabled !== false; // default on for Anthropic
252
277
 
253
278
  const systemBlocks = systemMessages.length === 0 ? undefined : systemMessages.map((m, i, arr) => {
package/src/llm/index.ts CHANGED
@@ -20,6 +20,7 @@ import { PROVIDERS } from '../config/providers.js';
20
20
  import { OpenAIProvider } from './openai.js';
21
21
  import { AnthropicProvider } from './anthropic.js';
22
22
  import { GeminiProvider } from './gemini.js';
23
+ import { decideTTL } from '../cache/smartModel.js';
23
24
 
24
25
  export class LLMService {
25
26
  private provider: LLMProvider;
@@ -31,6 +32,19 @@ export class LLMService {
31
32
  this.apiKey = apiKey;
32
33
  }
33
34
 
35
+ /**
36
+ * v3.0.3: TTL resolved from config + smart routing. Stays null until
37
+ * the first round, then never changes for the session. Anthropic
38
+ * provider's setResolvedTTL is called at the same time, so the wire
39
+ * prefix is consistent across rounds.
40
+ */
41
+ private resolvedTtl: '5m' | '1h' | null = null;
42
+
43
+ /** Public accessor used by App.streamResponse to surface TTL in the UI. */
44
+ getResolvedTTL(): '5m' | '1h' | null {
45
+ return this.resolvedTtl;
46
+ }
47
+
34
48
  static fromConfig(config: AIConfig & { cache?: Config['cache'] }): LLMService {
35
49
  const providerName = config.provider || 'siliconflow';
36
50
  const providerConfig = PROVIDERS[providerName];
@@ -43,7 +57,9 @@ export class LLMService {
43
57
  maxTokens: config.maxTokens ?? 4096,
44
58
  // v3.0.0: forward cache policy. Anthropic reads this to decide
45
59
  // whether to attach cache_control markers; other providers ignore it.
46
- cache: config.cache ?? { enabled: true, ttl: '5m', strategy: 'auto' as const },
60
+ // v3.0.3: ttl accepts '5m' | '1h' | 'auto'. 'auto' is resolved
61
+ // per-session by decideTTL() inside chatStream.
62
+ cache: config.cache ?? { enabled: true, ttl: 'auto' as const, strategy: 'auto' as const },
47
63
  };
48
64
 
49
65
  const format = providerConfig.format;
@@ -98,6 +114,28 @@ export class LLMService {
98
114
  ): AsyncGenerator<StreamChunk, ChatResponse> {
99
115
  if (!this.hasApiKey()) throw new Error(this.getNoKeyMessage());
100
116
 
117
+ // v3.0.3: Resolve TTL once per session.
118
+ // v3.0.4: default is 1h (long-task). 'auto' (legacy config value)
119
+ // resolves via decideTTL which now always returns 1h — we cannot
120
+ // predict task length at round 0, so we default to the TTL that
121
+ // cannot expire mid-task. '5m'/'1h' are explicit user pins.
122
+ const configTtl = (this.provider as any).config?.cache?.ttl;
123
+ if (configTtl === 'auto' && this.resolvedTtl === null) {
124
+ const decision = decideTTL(messages, null);
125
+ this.resolvedTtl = decision.ttl;
126
+ if (typeof (this.provider as any).setResolvedTTL === 'function') {
127
+ (this.provider as any).setResolvedTTL(decision.ttl);
128
+ }
129
+ } else if (configTtl === '5m' || configTtl === '1h') {
130
+ // User pinned a specific TTL — apply it once and keep it.
131
+ if (this.resolvedTtl === null) {
132
+ this.resolvedTtl = configTtl;
133
+ if (typeof (this.provider as any).setResolvedTTL === 'function') {
134
+ (this.provider as any).setResolvedTTL(configTtl);
135
+ }
136
+ }
137
+ }
138
+
101
139
  const maxIterations = options?.maxIterations ?? 10;
102
140
  let currentMessages = [...messages];
103
141
  let iterations = 0;
@@ -52,10 +52,15 @@ export interface ProviderConfig {
52
52
  * Optional cache control policy. When omitted, providers fall back to their
53
53
  * default behavior (Anthropic: explicit cache_control markers; OpenAI / Gemini
54
54
  * / DeepSeek: automatic prefix cache, no markers needed).
55
+ *
56
+ * v3.0.3: `ttl` accepts '5m' | '1h' | 'auto'. 'auto' lets the runtime
57
+ * decide per-session via decideTTL() in cache/smartModel.ts. The actual
58
+ * value used at request time is what gets stored on the provider's
59
+ * resolvedTtl field (set by LLMService.chatStream).
55
60
  */
56
61
  cache?: {
57
62
  enabled: boolean;
58
- ttl?: '5m' | '1h';
63
+ ttl?: '5m' | '1h' | 'auto';
59
64
  /**
60
65
  * 'auto' — provider default (Anthropic: explicit, OpenAI/DeepSeek: auto)
61
66
  * 'manual' — always emit cache_control markers regardless of provider
package/src/tui/app.tsx CHANGED
@@ -48,8 +48,14 @@ export function TuiApp({ app }: Props) {
48
48
  // change (i.e. a round just completed and recorded new usage). Polling on
49
49
  // an interval would be wasteful; React re-render is the trigger.
50
50
  const [cacheSnapshot, setCacheSnapshot] = useState(() => app.cacheStats.snapshot());
51
+ // v3.0.3: resolved TTL (sticky per session). null until first round.
52
+ const [resolvedTtl, setResolvedTtl] = useState<'5m' | '1h' | null>(app.resolvedTtl);
53
+ // The user-pinned config TTL ('auto' | '5m' | '1h') — used to render the
54
+ // Header chip BEFORE the first round, when resolvedTtl is still null.
55
+ const configTtl = (app.config.get() as any).cache?.ttl as 'auto' | '5m' | '1h' | undefined;
51
56
  useEffect(() => {
52
57
  setCacheSnapshot(app.cacheStats.snapshot());
58
+ setResolvedTtl(app.resolvedTtl);
53
59
  }, [messages.length]);
54
60
 
55
61
  const addMsg = useCallback((content: string) => {
@@ -99,6 +105,7 @@ export function TuiApp({ app }: Props) {
99
105
  model={app.config.get().model}
100
106
  cacheHitRate={cacheSnapshot.hitRate > 0 ? cacheSnapshot.hitRate : null}
101
107
  cacheSavingsCNY={cacheSnapshot.estimatedSavingsCNY}
108
+ cacheTtl={resolvedTtl ?? configTtl ?? null}
102
109
  />
103
110
  <ChatList
104
111
  messages={allMessages}
@@ -13,6 +13,13 @@ interface Props {
13
13
  */
14
14
  cacheHitRate?: number | null;
15
15
  cacheSavingsCNY?: number;
16
+ /**
17
+ * v3.0.3: cache TTL marker. '5m' (default for short sessions) or '1h'
18
+ * (auto-decided for long sessions, or pinned by the user). 'auto' is
19
+ * the user-pinned setting and shows up as ⏱ auto. null = not yet
20
+ * decided (waiting for first round).
21
+ */
22
+ cacheTtl?: '5m' | '1h' | 'auto' | null;
16
23
  }
17
24
 
18
25
  /**
@@ -27,7 +34,7 @@ function hitRateColor(rate: number): string {
27
34
  return '#6B7280';
28
35
  }
29
36
 
30
- export const Header = React.memo(function Header({ provider, model, cacheHitRate, cacheSavingsCNY }: Props) {
37
+ export const Header = React.memo(function Header({ provider, model, cacheHitRate, cacheSavingsCNY, cacheTtl }: Props) {
31
38
  const showCache = typeof cacheHitRate === 'number' && cacheHitRate > 0;
32
39
  return (
33
40
  <Box flexDirection="column" marginBottom={0}>
@@ -35,7 +42,7 @@ export const Header = React.memo(function Header({ provider, model, cacheHitRate
35
42
  <Box>
36
43
  <Text color="#06B6D4" bold> ⚡ </Text>
37
44
  <Text color="#22D3EE" bold>THATGFSJ CODE</Text>
38
- <Text dimColor> v3.0.0</Text>
45
+ <Text dimColor> v3.0.4</Text>
39
46
  </Box>
40
47
  <Box>
41
48
  {showCache && (
@@ -45,6 +52,9 @@ export const Header = React.memo(function Header({ provider, model, cacheHitRate
45
52
  <Text dimColor> · </Text>
46
53
  </>
47
54
  )}
55
+ {cacheTtl && (
56
+ <Text color="#A78BFA" bold> ⏱ {cacheTtl} </Text>
57
+ )}
48
58
  <Text color="#06B6D4" bold> {provider} </Text>
49
59
  <Text dimColor>/</Text>
50
60
  <Text color="#22D3EE"> {model} </Text>
@@ -14,7 +14,7 @@ interface Props {
14
14
  onCancel: () => void;
15
15
  }
16
16
 
17
- type InitStep = 'provider' | 'custom_url' | 'api_key' | 'model' | 'custom_model' | 'cache_strategy' | 'cache_ttl';
17
+ type InitStep = 'provider' | 'custom_url' | 'api_key' | 'model' | 'custom_model' | 'cache_strategy';
18
18
 
19
19
  interface CacheChoice {
20
20
  enabled: boolean;
@@ -208,46 +208,29 @@ export function InitWizard({ onComplete, onCancel }: Props) {
208
208
  );
209
209
  }
210
210
 
211
- // v3.0.0: prompt-cache strategy step
211
+ // v3.0.4: prompt-cache strategy step.
212
+ // Default is 1h (long-task TTL): we cannot predict how long a task
213
+ // will run, so we pick the TTL that cannot expire mid-task. 5m is
214
+ // for users who are sure the session is short.
212
215
  if (step === 'cache_strategy') {
213
216
  const items = [
214
- { label: ' 开启(推荐,节省 token 成本)', value: 'on' },
217
+ { label: '🕐 1 小时(推荐:默认长任务,缓存不中途失效)', value: '1h' },
218
+ { label: '⏱ 5 分钟(只适合你确认是短会话)', value: '5m' },
215
219
  { label: '✗ 关闭(每次请求都重新计算)', value: 'off' },
216
220
  ];
217
221
  return (
218
222
  <Box flexDirection="column" paddingLeft={1}>
219
- <Text color="#06B6D4" bold>是否开启 Prompt Caching?</Text>
220
- <Text dimColor>对 Anthropic / DeepSeek / Gemini 有效,OpenAI 兼容接口默认自动缓存。</Text>
223
+ <Text color="#06B6D4" bold>Prompt Caching 策略:</Text>
224
+ <Text dimColor>对 Anthropic / DeepSeek / Gemini 有效。</Text>
225
+ <Text dimColor>推荐 1 小时:任务开始前无法预知长度,5 分钟 TTL 会在长任务中途过期。</Text>
221
226
  <SelectInput
222
227
  items={items}
223
228
  onSelect={(item) => {
224
- if (item.value === 'on') {
225
- setCacheChoice(c => ({ ...c, enabled: true }));
226
- setStep('cache_ttl');
229
+ if (item.value === 'off') {
230
+ setCacheChoice({ enabled: false, ttl: '1h' });
227
231
  } else {
228
- setCacheChoice(c => ({ ...c, enabled: false }));
229
- saveConfig(selectedProvider, selectedModel, apiKey, customUrl || undefined);
230
- onComplete(selectedProvider, selectedModel, apiKey, customUrl || undefined);
232
+ setCacheChoice({ enabled: true, ttl: item.value as '5m' | '1h' });
231
233
  }
232
- }}
233
- />
234
- </Box>
235
- );
236
- }
237
-
238
- // v3.0.0: TTL choice
239
- if (step === 'cache_ttl') {
240
- const items = [
241
- { label: '5 分钟(写入成本低,适合短会话)', value: '5m' },
242
- { label: '1 小时(写入成本高,适合长会话)', value: '1h' },
243
- ];
244
- return (
245
- <Box flexDirection="column" paddingLeft={1}>
246
- <Text color="#06B6D4" bold>Cache TTL:</Text>
247
- <SelectInput
248
- items={items}
249
- onSelect={(item) => {
250
- setCacheChoice(c => ({ ...c, ttl: item.value as '5m' | '1h' }));
251
234
  saveConfig(selectedProvider, selectedModel, apiKey, customUrl || undefined);
252
235
  onComplete(selectedProvider, selectedModel, apiKey, customUrl || undefined);
253
236
  }}
@@ -20,6 +20,8 @@ const CMD_ALIASES: Record<string, string> = {
20
20
  '/服务商': '/provider',
21
21
  '/思考': '/thinking',
22
22
  '/缓存': '/cache',
23
+ '/ttl': '/ttl',
24
+ '/TTL': '/ttl',
23
25
  };
24
26
 
25
27
  export const COMMAND_LIST = [
@@ -28,6 +30,7 @@ export const COMMAND_LIST = [
28
30
  { name: '/新建', desc: '新建会话' },
29
31
  { name: '/压缩', desc: '压缩上下文' },
30
32
  { name: '/缓存', desc: '缓存命中率' },
33
+ { name: '/ttl', desc: '查看/设置 Cache TTL' },
31
34
  { name: '/技能', desc: '管理技能' },
32
35
  { name: '/mcp', desc: 'MCP 设置' },
33
36
  { name: '/帮助', desc: '查看帮助' },
@@ -142,6 +145,40 @@ export function useCommands(app: App) {
142
145
  return { handled: true, output: lines.join('\n') };
143
146
  }
144
147
 
148
+ // v3.0.3: /ttl — view or pin the cache TTL.
149
+ // v3.0.4: default is 1h (long-task). We cannot predict task length
150
+ // at round 0, so the default TTL is the one that cannot expire
151
+ // mid-task. Pinning 5m is only for users who are sure the session
152
+ // is short.
153
+ //
154
+ // /ttl show current effective TTL + decision reason
155
+ // /ttl 5m|1h pin TTL for this session (sticky, no cache reset)
156
+ // /ttl 1h back to the long-task default
157
+ if (name === '/ttl') {
158
+ const configTtl = (app.config.get() as any).cache?.ttl as 'auto' | '5m' | '1h' | undefined;
159
+ const resolved = app.resolvedTtl;
160
+ if (arg === '5m' || arg === '1h') {
161
+ app.config.save({ cache: { ...(app.config.get() as any).cache, ttl: arg } });
162
+ app.resolvedTtl = arg;
163
+ return { handled: true, output: `✓ Cache TTL 已固定为 ${arg}(首次请求会重建缓存)` };
164
+ }
165
+ // No arg: show current state
166
+ const effective = resolved ?? (configTtl === 'auto' ? '1h' : (configTtl ?? '1h'));
167
+ const lines = [
168
+ '⏱ Cache TTL',
169
+ '─────────────────────────',
170
+ ` 配置: ${configTtl ?? '1h'}`,
171
+ ` 当前会话: ${resolved ?? '尚未评估'}`,
172
+ '',
173
+ '说明: 默认 1 小时(长任务)。任务开始前无法预知长度,',
174
+ ' 5 分钟 TTL 会在长任务中途过期;1 小时缓存命中即可回本。',
175
+ ' 只有你确定会话很短时才建议 /ttl 5m。',
176
+ '',
177
+ '/ttl 5m|1h 调整 TTL',
178
+ ];
179
+ return { handled: true, output: lines.join('\n') };
180
+ }
181
+
145
182
  // ── /skills [id] ────────────────────────────────────
146
183
  if (name === '/skills') {
147
184
  if (arg) {
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { decideTTL, totalConversationChars, shouldDowngrade } from '../../src/cache/smartModel.js';
3
+ import type { ChatMessage } from '../../src/types.js';
4
+
5
+ function userMsg(s: string): ChatMessage {
6
+ return { role: 'user', content: s };
7
+ }
8
+ function assistantMsg(s: string): ChatMessage {
9
+ return { role: 'assistant', content: s };
10
+ }
11
+
12
+ describe('decideTTL', () => {
13
+ it('defaults to 1h (long-task) on round 0 — task length unknown', () => {
14
+ const d = decideTTL([], null);
15
+ expect(d.ttl).toBe('1h');
16
+ expect(d.isFirstDecision).toBe(true);
17
+ expect(d.reason).toBe('default-long-task');
18
+ });
19
+
20
+ it('defaults to 1h even for a small conversation — cannot predict future length', () => {
21
+ const messages = [
22
+ userMsg('hello'),
23
+ assistantMsg('hi'),
24
+ userMsg('how are you?'),
25
+ assistantMsg('good'),
26
+ ];
27
+ const d = decideTTL(messages, null);
28
+ expect(d.ttl).toBe('1h');
29
+ });
30
+
31
+ it('defaults to 1h for a large conversation too', () => {
32
+ const messages: ChatMessage[] = [];
33
+ for (let i = 0; i < 16; i++) {
34
+ messages.push(userMsg(`question ${i}`));
35
+ messages.push(assistantMsg(`answer ${i} long enough to be content`));
36
+ }
37
+ const d = decideTTL(messages, null);
38
+ expect(d.ttl).toBe('1h');
39
+ });
40
+
41
+ it('reuses previous TTL (stability rule)', () => {
42
+ const messages = [userMsg('hello'), assistantMsg('hi')];
43
+ const d1 = decideTTL(messages, null);
44
+ expect(d1.ttl).toBe('1h');
45
+ expect(d1.isFirstDecision).toBe(true);
46
+
47
+ // Once pinned to 5m by the user, later rounds keep 5m.
48
+ const d2 = decideTTL(messages, '5m');
49
+ expect(d2.ttl).toBe('5m');
50
+ expect(d2.isFirstDecision).toBe(false);
51
+ expect(d2.reason).toBe('reused-from-previous-round');
52
+
53
+ // And once pinned to 1h, later rounds keep 1h.
54
+ const d3 = decideTTL(messages, '1h');
55
+ expect(d3.ttl).toBe('1h');
56
+ expect(d3.isFirstDecision).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe('totalConversationChars', () => {
61
+ it('sums string content', () => {
62
+ const total = totalConversationChars([
63
+ userMsg('abc'),
64
+ assistantMsg('defgh'),
65
+ ]);
66
+ expect(total).toBe(8);
67
+ });
68
+
69
+ it('handles array content blocks', () => {
70
+ const total = totalConversationChars([
71
+ { role: 'user', content: [
72
+ { type: 'text', text: 'hello' },
73
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'XXXX' } },
74
+ ] as any },
75
+ ]);
76
+ expect(total).toBe(5);
77
+ });
78
+ });
79
+
80
+ describe('shouldDowngrade', () => {
81
+ it('downgrades short prompts without tool calls', () => {
82
+ const d = shouldDowngrade([], 'hi');
83
+ expect(d.downgrade).toBe(true);
84
+ });
85
+
86
+ it('does not downgrade long prompts', () => {
87
+ const d = shouldDowngrade([], 'x'.repeat(300));
88
+ expect(d.downgrade).toBe(false);
89
+ expect(d.reason).toBe('prompt-too-long');
90
+ });
91
+
92
+ it('does not downgrade when recent tool calls exist', () => {
93
+ const messages: ChatMessage[] = [
94
+ { role: 'assistant', content: '...', tool_calls: [{ id: '1', type: 'function', function: { name: 'f', arguments: '{}' } }] },
95
+ ];
96
+ const d = shouldDowngrade(messages, 'hi');
97
+ expect(d.downgrade).toBe(false);
98
+ expect(d.reason).toBe('recent-tool-call');
99
+ });
100
+ });