pi-kimi-keepalive 0.1.0 → 0.2.1

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.
package/README.md CHANGED
@@ -30,18 +30,33 @@ After the package is published to npm: `pi install npm:pi-kimi-keepalive`.
30
30
 
31
31
  ## Setup
32
32
 
33
- On the first start with no `~/.pi/cache-keepalive/state.json` present and an interactive UI, the extension runs a setup wizard that configures the four guardrails below. Leaving a prompt empty or pressing Esc keeps the default. The wizard can be rerun with `/keepalive setup`; in headless sessions it is skipped and defaults are kept.
33
+ On the first start with no `~/.pi/cache-keepalive/state.json` present and an interactive UI, the extension runs a setup wizard that configures the five settings below. Leaving a prompt empty or pressing Esc keeps the default. The wizard can be rerun with `/keepalive setup`; in headless sessions it is skipped and defaults are kept.
34
34
 
35
35
  | Step | Setting | Command | Default | Description |
36
36
  | --- | --- | --- | --- | --- |
37
37
  | 1 | Max idle cutoff | `maxidle` | `30m` | Probing stops after this much idle time; `0` disables the cutoff. |
38
- | 2 | Miss pause threshold | `miss` | `1` | Pause after N consecutive probes that do not hit the prompt cache. A hit resets the count. |
38
+ | 2 | Miss pause threshold | `miss` | `1` | Pause after N consecutive probes that do not hit the prompt cache. A hit resets the count. Applies in `default` mode. |
39
39
  | 3 | Error circuit breaker | `errors` | `3` | Pause after N consecutive probe failures (network errors, HTTP 5xx). HTTP 401/403 always pauses immediately. |
40
40
  | 4 | Session spend cap | `cap` | `$1.00` | Ceiling on estimated USD probe spend per session; `0` removes the cap. |
41
+ | 5 | Probing mode | `mode` | `default` | `default` keeps the fixed cadence; `smart` self-tunes it (below). |
41
42
 
42
43
  The wizard ends with a prompt to enable keepalive. Probing starts after the next real turn, which provides the captured request.
43
44
 
44
- Two operating points are worth knowing. The default cadence is **7 minutes** — deliberately past the ~5-minute cache TTL. A 7-minute probe never hits the cache: it runs once at full input price, confirms the cache has expired, and the default `miss=1` stops probing immediately, so the worst case is a single cold read per idle period. For an always-warm session instead, set a cadence inside the TTL (`/keepalive interval=4m45s`): every probe is then billed at cache-read rates (~1/10 of full price) and the loop keeps running until `maxidle` cuts it off.
45
+ ## Probing modes
46
+
47
+ Two modes share the same guardrails; they differ in how the cadence is chosen.
48
+
49
+ **`default` (the starting mode)** — the cadence is fixed at whatever `interval=` says, **8 minutes** by default — deliberately past the ~5-minute nominal cache TTL. An 8-minute probe rarely hits the cache: it runs once at full input price, confirms the cache has expired, and the default `miss=1` stops probing immediately, so the worst case is a single cold read per idle period. For an always-warm session instead, set a cadence inside the observed TTL (`/keepalive interval=4m45s`…`7m`): every probe is then billed at cache-read rates (~1/10 of full price) and the loop keeps running until `maxidle` cuts it off.
50
+
51
+ **`smart` (`/keepalive mode=smart`)** — self-tunes the cadence toward the real cache TTL instead of guessing it:
52
+
53
+ 1. Starts at **8 minutes**.
54
+ 2. After **5 consecutive hits** the cadence is confirmed and the value grows by **+30s**. Only confirmed values are persisted, so `~/.pi/cache-keepalive/state.json` always holds the largest cadence with observed consecutive hits.
55
+ 3. A **miss** immediately parks probing: the cadence steps back **30s** to the last confirmed value (never below the 8m floor), the value is persisted, and probing stays **stopped**. A fresh real turn does **not** resume it — only re-selecting smart mode (`/keepalive mode=smart`) continues from the parked cadence.
56
+ 4. **Context guard:** growth only happens while the last probe saw ≤ **200k prompt tokens**; the first probe above that immediately reverts the cadence to the 8m floor and keeps it there (a missed probe on a 200k+ context is too expensive to risk).
57
+ 5. Guardrails (`maxidle`, `cap`, `errors`, HTTP 401/403) still apply while smart is probing.
58
+
59
+ Per learn cycle the spend is minimal: 5 cache-read probes confirm a step, and one full-price probe ends the cycle — the parked cadence keeps every future session at the highest value the cache has proven to hold.
45
60
 
46
61
  ## Commands
47
62
 
@@ -51,7 +66,9 @@ Two operating points are worth knowing. The default cadence is **7 minutes** —
51
66
  /keepalive on|off enable / disable (persisted)
52
67
  /keepalive now one manual probe (bypasses pauses)
53
68
  /keepalive resume clear a sticky pause
54
- /keepalive interval=4m45s probe cadence ( 30s; 5m to stay inside the cache TTL)
69
+ /keepalive mode=smart adaptive cadence (8m floor; +30s per 5-hit confirmation; a miss parks probing, mode=smart resumes)
70
+ /keepalive mode=default fixed cadence (the interval= value)
71
+ /keepalive interval=4m45s probe cadence in default mode (≥ 30s; ≤ 5m to stay inside the cache TTL)
55
72
  /keepalive maxidle=30m idle cutoff (0 = disabled)
56
73
  /keepalive miss=1 pause after N consecutive cache misses
57
74
  /keepalive errors=3 pause after N consecutive probe failures
package/README.zh-CN.md CHANGED
@@ -30,17 +30,34 @@ npm 发布后可使用 `pi install npm:pi-kimi-keepalive`。
30
30
 
31
31
  ## 初始化
32
32
 
33
- 首次启动(`~/.pi/cache-keepalive/state.json` 不存在)且有交互 UI 时,扩展运行初始化向导配置四项护栏。提示符留空或按 Esc 保留默认值;之后可用 `/keepalive setup` 重新运行;headless 会话自动跳过并保留默认值。
33
+ 首次启动(`~/.pi/cache-keepalive/state.json` 不存在)且有交互 UI 时,扩展运行初始化向导配置五项设置。提示符留空或按 Esc 保留默认值;之后可用 `/keepalive setup` 重新运行;headless 会话自动跳过并保留默认值。
34
34
 
35
35
  | 步骤 | 设置项 | 命令 | 默认值 | 说明 |
36
36
  | --- | --- | --- | --- | --- |
37
37
  | 1 | Max idle cutoff | `maxidle` | `30m` | 空闲超过该时长后停止探测;`0` 表示不设限 |
38
- | 2 | Miss pause threshold | `miss` | `2` | 连续 N 次探测未命中前缀缓存后暂停;命中会重置计数 |
38
+ | 2 | Miss pause threshold | `miss` | `1` | 连续 N 次探测未命中前缀缓存后暂停;命中会重置计数。仅在 `default` 模式下生效 |
39
39
  | 3 | Error circuit breaker | `errors` | `3` | 连续 N 次探测失败(网络错误、HTTP 5xx)后暂停;HTTP 401/403 不受此值约束,直接暂停 |
40
40
  | 4 | Session spend cap | `cap` | `$1.00` | 会话探测花费(估算 USD)上限;`0` 表示不设上限 |
41
+ | 5 | Probing mode | `mode` | `default` | `default` 固定间隔;`smart` 自适应(见下文) |
41
42
 
42
43
  向导结束时询问是否立即启用 keepalive。探测在下一次真实请求(完成捕获)之后开始。全部配置持久化到 `~/.pi/cache-keepalive/state.json`。
43
44
 
45
+ ## 探测模式
46
+
47
+ 两种模式共用一套护栏,区别仅在间隔的确定方式。
48
+
49
+ **`default`(默认模式)**——间隔固定为 `interval=` 所设值,默认 **8 分钟**——刻意超过 ~5 分钟的名义缓存 TTL。8 分钟的探测通常无法命中:以全价输入运行一次、确认缓存已过期,默认 `miss=1` 随即停止探测,因此最坏情况是每个空闲期仅一次全价冷读。若想保持会话常热,可把间隔设在实测 TTL 之内(`/keepalive interval=4m45s`…`7m`):每次探测按缓存读价计费(约为全价的 1/10),循环持续到 `maxidle` 截断。
50
+
51
+ **`smart`(`/keepalive mode=smart`)**——不猜测 TTL,而是自适应逼近真实值:
52
+
53
+ 1. 从 **8 分钟**起跳。
54
+ 2. **连续 5 次命中**后该间隔即被确认,档位 **+30s**。只有确认过的值才会持久化,因此 `~/.pi/cache-keepalive/state.json` 中始终保存的是实测可连续命中的最大档位。
55
+ 3. 一次 **miss** 立即停靠探测:档位回退 **30s** 到最近确认值(至多退到 8m 下限)并持久化,随后探测**停止**。下一次真实轮次**不会**自动恢复——只有重新选择 smart 模式(`/keepalive mode=smart`)才会从停靠档位继续探测。
56
+ 4. **上下文保护:** 只有最近一次探测的 prompt tokens ≤ **200k** 才允许升档;一旦超过,档位立即回退至 8m 下限并冻结升档(200k+ 上下文一次全价 miss 代价太高,不冒这个险)。
57
+ 5. smart 探测运行期间通用护栏(`maxidle`、`cap`、`errors`、HTTP 401/403)仍然生效。
58
+
59
+ 每个学习周期的花费极小:5 次缓存读价探测确认一档,一次全价探测结束本轮——停靠后的档位让之后的每个会话都直接运行在实测可行的最高值上。
60
+
44
61
  ## 命令
45
62
 
46
63
  ```
@@ -49,7 +66,9 @@ npm 发布后可使用 `pi install npm:pi-kimi-keepalive`。
49
66
  /keepalive on|off 启用 / 停用(持久化)
50
67
  /keepalive now 手动探测一次(绕过暂停)
51
68
  /keepalive resume 清除 sticky 暂停
52
- /keepalive interval=4m45s 探测间隔(≥ 30s;应 5m 以保持缓存命中)
69
+ /keepalive mode=smart 自适应间隔(下限 8m;每 5 连中 +30s;一次 miss 即停靠,mode=smart 恢复探测)
70
+ /keepalive mode=default 固定间隔(即 interval= 的值)
71
+ /keepalive interval=4m45s default 模式下的探测间隔(≥ 30s;应 ≤ 5m 以保持缓存命中)
53
72
  /keepalive maxidle=30m 空闲上限(0 = 不设限)
54
73
  /keepalive miss=1 连续 N 次缓存 miss 后暂停
55
74
  /keepalive errors=3 连续 N 次探测失败后熔断
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-kimi-keepalive",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Prompt-cache keepalive for Kimi (kimi-coding) sessions in the Pi coding agent. Replays the last real provider request while idle so the automatic prefix cache never expires.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,9 @@
23
23
  "keepalive"
24
24
  ],
25
25
  "pi": {
26
- "extensions": ["./src/index.ts"]
26
+ "extensions": [
27
+ "./src/index.ts"
28
+ ]
27
29
  },
28
30
  "files": [
29
31
  "src",
@@ -43,4 +45,4 @@
43
45
  "@types/node": "^24",
44
46
  "typescript": "^5"
45
47
  }
46
- }
48
+ }
package/src/index.ts CHANGED
@@ -68,6 +68,8 @@ interface PersistedConfig {
68
68
  maxMissStreak: number;
69
69
  /** Consecutive failed probes (network/server errors) before probing pauses. */
70
70
  maxErrorStreak: number;
71
+ /** "default" = fixed interval; "smart" = adaptive cadence that grows while probes keep hitting. */
72
+ mode: "default" | "smart";
71
73
  /** True once the first-run setup wizard has completed (or been skipped). */
72
74
  initialized: boolean;
73
75
  }
@@ -79,16 +81,23 @@ const DEFAULT_CONFIG: Readonly<PersistedConfig> = Object.freeze({
79
81
  // dead, and the default miss=1 stops probing right after — worst-case total
80
82
  // spend is one cold read per session. Use /keepalive interval=4m45s for a
81
83
  // hit-mode heartbeat (every probe is a cache read, ~10x cheaper).
82
- intervalMs: 7 * 60_000,
84
+ intervalMs: 8 * 60_000,
83
85
  maxIdleMs: 30 * 60_000,
84
86
  minPromptTokens: 512,
85
87
  maxOutputTokens: 16,
86
88
  spendCapUsd: 1.0,
87
89
  maxMissStreak: 1,
88
90
  maxErrorStreak: 3,
91
+ mode: "default",
89
92
  initialized: false,
90
93
  });
91
94
 
95
+ // smart-mode constants
96
+ const SMART_BASE_MS = 8 * 60_000; // smart starting/floor cadence (matches the default interval)
97
+ const SMART_STEP_MS = 30_000; // +30s per 5-hit confirmation
98
+ const SMART_CONFIRM_HITS = 5;
99
+ const SMART_MAX_CONTEXT_TOKENS = 200_000; // context cap: grow only below this
100
+
92
101
  const MIN_INTERVAL_MS = 30_000;
93
102
  const PROBE_TIMEOUT_MS = 30_000;
94
103
 
@@ -99,7 +108,9 @@ const HELP_TEXT = [
99
108
  " /keepalive on|off enable / disable (persisted)",
100
109
  " /keepalive now one manual probe (bypasses pauses)",
101
110
  " /keepalive resume clear a sticky pause",
102
- " /keepalive interval=4m45s probe cadence (>= 30s; <= 5m stays inside the cache TTL)",
111
+ " /keepalive mode=smart adaptive cadence (8m floor; +30s per 5-hit confirmation; a miss pauses probing, mode=smart resumes)",
112
+ " /keepalive mode=default fixed cadence (the interval= value)",
113
+ " /keepalive interval=4m45s probe cadence (default mode; >= 30s; <= 5m stays inside the cache TTL)",
103
114
  " /keepalive maxidle=30m stop probing after this idle time (0 = never stop)",
104
115
  " /keepalive miss=1 pause after N consecutive cache misses",
105
116
  " /keepalive errors=3 pause after N consecutive probe failures",
@@ -144,7 +155,12 @@ export default function (pi: ExtensionAPI) {
144
155
  let pausedReason: string | null = null;
145
156
  let missStreak = 0;
146
157
  let errorStreak = 0;
147
-
158
+ // smart-mode runtime state (cadence itself lives in config.intervalMs, persisted)
159
+ let smartHitStreak = 0;
160
+ // Set when a smart-mode miss pauses probing; only re-selecting smart mode
161
+ // clears it (a fresh real turn does NOT resume probing in this case).
162
+ let smartPaused = false;
163
+ let lastProbeInputTokens = 0;
148
164
  const stats: Stats = { probes: 0, hits: 0, misses: 0, errors: 0, savedUsd: 0, spendUsd: 0 };
149
165
 
150
166
  // ---------- persistence ----------
@@ -177,6 +193,7 @@ export default function (pi: ExtensionAPI) {
177
193
  : DEFAULT_CONFIG.spendCapUsd,
178
194
  maxMissStreak: int(raw.maxMissStreak, DEFAULT_CONFIG.maxMissStreak, 1),
179
195
  maxErrorStreak: int(raw.maxErrorStreak, DEFAULT_CONFIG.maxErrorStreak, 1),
196
+ mode: raw.mode === "smart" ? "smart" : "default",
180
197
  initialized: raw.initialized === true,
181
198
  };
182
199
  } catch {
@@ -217,7 +234,7 @@ export default function (pi: ExtensionAPI) {
217
234
  config.initialized = true;
218
235
  persistConfig();
219
236
  notify(
220
- "Setup skipped — defaults kept (interval 7m, maxidle 30m, miss 1, errors 3, cap $1.00). " +
237
+ "Setup skipped — defaults kept (mode default/8m, maxidle 30m, miss 1, errors 3, cap $1.00). " +
221
238
  "Run /keepalive setup to configure later, /keepalive on to enable.",
222
239
  "info",
223
240
  );
@@ -225,9 +242,9 @@ export default function (pi: ExtensionAPI) {
225
242
  return;
226
243
  }
227
244
 
228
- // 1/4 — max idle cutoff
245
+ // 1/5 — max idle cutoff
229
246
  const maxIdleRaw = await wizardCtx.ui.input(
230
- "Step 1/4 — Max idle cutoff (now " + formatDuration(config.maxIdleMs) + ")\n" +
247
+ "Step 1/5 — Max idle cutoff (now " + formatDuration(config.maxIdleMs) + ")\n" +
231
248
  "Stop probing once you have been idle longer than this, so a session left overnight " +
232
249
  "does not keep spending quota. Examples: 30m, 1h, 2h — or 0 to never stop.\n" +
233
250
  "Leave empty / press Esc to keep the default (30m).",
@@ -246,9 +263,9 @@ export default function (pi: ExtensionAPI) {
246
263
  }
247
264
  }
248
265
 
249
- // 2/4 — miss pause threshold
266
+ // 2/5 — miss pause threshold
250
267
  const missRaw = await wizardCtx.ui.input(
251
- "Step 2/4 — Miss pause threshold (now " + config.maxMissStreak + ")\n" +
268
+ "Step 2/5 — Miss pause threshold (now " + config.maxMissStreak + ")\n" +
252
269
  "Pause probing after this many consecutive probes that did NOT hit the prompt cache " +
253
270
  "(a cache hit resets the count). A high cache-read price with no hits means the " +
254
271
  "provider's caching behaviour changed; pausing keeps you from burning quota blindly.",
@@ -263,9 +280,9 @@ export default function (pi: ExtensionAPI) {
263
280
  }
264
281
  }
265
282
 
266
- // 3/4 — error circuit breaker
283
+ // 3/5 — error circuit breaker
267
284
  const errorRaw = await wizardCtx.ui.input(
268
- "Step 3/4 — Error circuit breaker (default " + config.maxErrorStreak + ")\n" +
285
+ "Step 3/5 — Error circuit breaker (default " + config.maxErrorStreak + ")\n" +
269
286
  "Pause probing after this many consecutive failed probes (network errors, HTTP 5xx). " +
270
287
  "Auth failures (HTTP 401/403) always pause immediately regardless of this value.",
271
288
  String(config.maxErrorStreak),
@@ -279,9 +296,9 @@ export default function (pi: ExtensionAPI) {
279
296
  }
280
297
  }
281
298
 
282
- // 4/4 — spend cap
299
+ // 4/5 — spend cap
283
300
  const spendRaw = await wizardCtx.ui.input(
284
- "Step 4/4 — Session spend cap in USD (default " +
301
+ "Step 4/5 — Session spend cap in USD (default " +
285
302
  formatUsd(DEFAULT_CONFIG.spendCapUsd ?? 1.0) +
286
303
  ")\n" +
287
304
  "Ceiling on the estimated USD cost of probes in this session. A probe costs roughly " +
@@ -298,6 +315,27 @@ export default function (pi: ExtensionAPI) {
298
315
  }
299
316
  }
300
317
 
318
+ // 5/5 — probing mode
319
+ const modeRaw = await wizardCtx.ui.input(
320
+ "Step 5/5 — Probing mode (now " + config.mode + ")\n" +
321
+ "default: probes run at the fixed interval above.\n" +
322
+ "smart: starts at 8m; after 5 consecutive hits (context ≤ 200k) the cadence grows by 30s; " +
323
+ "one miss steps back to the last confirmed value and parks probing until you re-select smart mode — " +
324
+ "it self-tunes toward the real cache TTL to minimize probe spend.\n" +
325
+ "Type smart to enable; leave empty / press Esc for default.",
326
+ "",
327
+ );
328
+ if (modeRaw !== undefined) {
329
+ const v = modeRaw.trim().toLowerCase();
330
+ if (v === "smart" || v === "default") {
331
+ config.mode = v;
332
+ smartPaused = false;
333
+ if (v === "smart" && config.intervalMs < SMART_BASE_MS) config.intervalMs = SMART_BASE_MS;
334
+ } else if (v !== "") {
335
+ notify(`Unknown mode "${modeRaw}" — keeping ${config.mode}`, "error");
336
+ }
337
+ }
338
+
301
339
  config.initialized = true;
302
340
  persistConfig();
303
341
 
@@ -314,6 +352,7 @@ export default function (pi: ExtensionAPI) {
314
352
  persistConfig();
315
353
  notify(
316
354
  "pi-kimi-keepalive configured:\n" +
355
+ ` mode: ${config.mode}${config.mode === "smart" ? ` (starting at ${formatDuration(config.intervalMs)})` : ` (fixed ${formatDuration(config.intervalMs)})`}\n` +
317
356
  ` maxidle: ${config.maxIdleMs === 0 ? "never stop" : formatDuration(config.maxIdleMs)}\n` +
318
357
  ` miss pause: after ${config.maxMissStreak} consecutive cache misses\n` +
319
358
  ` error breaker: ${config.maxErrorStreak} consecutive failures\n` +
@@ -507,6 +546,7 @@ export default function (pi: ExtensionAPI) {
507
546
  if (!capture) return;
508
547
  stats.probes += 1;
509
548
  stats.spendUsd += estimateProbeSpendUsd(usage, capture.cost);
549
+ lastProbeInputTokens = usage.inputTokens;
510
550
 
511
551
  if (!isCacheMiss(usage.inputTokens, usage.cacheReadTokens, config.minPromptTokens)) {
512
552
  stats.hits += 1;
@@ -516,12 +556,20 @@ export default function (pi: ExtensionAPI) {
516
556
  debug(
517
557
  `probe hit: cache_read=${usage.cacheReadTokens} input=${usage.inputTokens} saved=${stats.savedUsd.toFixed(4)}`,
518
558
  );
559
+ if (config.mode === "smart") {
560
+ smartAdaptAfterHit(usage.inputTokens);
561
+ }
519
562
  } else {
520
563
  stats.misses += 1;
521
- missStreak += 1;
522
- debug(`probe miss #${missStreak}: cache_read=0 input=${usage.inputTokens}`);
523
- if (missStreak >= config.maxMissStreak) {
524
- pause("probes stopped hitting the prefix cache; waiting for your next real turn");
564
+ debug(`probe miss: cache_read=0 input=${usage.inputTokens}`);
565
+ if (config.mode === "smart") {
566
+ smartAdaptAfterMiss();
567
+ } else {
568
+ missStreak += 1;
569
+ debug(`probe miss #${missStreak}: cache_read=0 input=${usage.inputTokens}`);
570
+ if (missStreak >= config.maxMissStreak) {
571
+ pause("probes stopped hitting the prefix cache; waiting for your next real turn");
572
+ }
525
573
  }
526
574
  }
527
575
 
@@ -536,6 +584,65 @@ export default function (pi: ExtensionAPI) {
536
584
  }
537
585
  }
538
586
 
587
+ /**
588
+ * smart-mode cadence adaptation.
589
+ *
590
+ * config.intervalMs doubles as the persisted "last confirmed cadence":
591
+ * promotion only ever happens after SMART_CONFIRM_HITS consecutive hits, so
592
+ * the value on disk is always one the cache has actually held for. A miss
593
+ * steps back 30s to that last confirmed value (never below the 8m floor),
594
+ * persists it, and pauses probing — a fresh real turn does NOT resume;
595
+ * only re-selecting smart mode (`/keepalive mode=smart`) continues.
596
+ * Contexts above 200k tokens are never pushed upward and immediately revert
597
+ * to the floor cadence, because a miss there costs too much full-price
598
+ * input to risk.
599
+ */
600
+ function smartAdaptAfterHit(inputTokens: number): void {
601
+ if (inputTokens > SMART_MAX_CONTEXT_TOKENS) {
602
+ // Too expensive to experiment; drop to floor and stop growing.
603
+ if (config.intervalMs > SMART_BASE_MS) {
604
+ config.intervalMs = SMART_BASE_MS;
605
+ persistConfig();
606
+ notify(
607
+ `smart: context ${inputTokens} tokens exceeds ${SMART_MAX_CONTEXT_TOKENS / 1000}k — cadence back to the 8m floor`,
608
+ "info",
609
+ );
610
+ }
611
+ smartHitStreak = 0;
612
+ updateUi();
613
+ return;
614
+ }
615
+ smartHitStreak += 1;
616
+ if (smartHitStreak < SMART_CONFIRM_HITS) return;
617
+ const roomUnderMaxIdle =
618
+ config.maxIdleMs === 0 || config.intervalMs + SMART_STEP_MS < config.maxIdleMs;
619
+ if (roomUnderMaxIdle) {
620
+ config.intervalMs += SMART_STEP_MS; // 5-hit-confirmed value stays on disk
621
+ persistConfig();
622
+ debug(
623
+ `smart: ${smartHitStreak} consecutive hits — cadence grows to ${formatDuration(config.intervalMs)}`,
624
+ );
625
+ }
626
+ smartHitStreak = 0;
627
+ updateUi();
628
+ }
629
+
630
+ function smartAdaptAfterMiss(): void {
631
+ smartHitStreak = 0;
632
+ const fellBack = config.intervalMs > SMART_BASE_MS;
633
+ if (fellBack) {
634
+ // Step back to the last confirmed cadence before pausing.
635
+ config.intervalMs = Math.max(SMART_BASE_MS, config.intervalMs - SMART_STEP_MS);
636
+ persistConfig();
637
+ }
638
+ smartPaused = true;
639
+ pause(
640
+ `cache miss in smart mode — cadence ${fellBack ? `back to ${formatDuration(config.intervalMs)}` : "already at the floor"}; ` +
641
+ `probing stopped, run /keepalive mode=smart to resume`,
642
+ );
643
+ updateUi();
644
+ }
645
+
539
646
  function recordFailure(message: string): void {
540
647
  stats.errors += 1;
541
648
  errorStreak += 1;
@@ -600,6 +707,7 @@ export default function (pi: ExtensionAPI) {
600
707
  "pi-kimi-keepalive",
601
708
  ` state: ${config.enabled ? "on" : "off"}${pausedReason ? ` (paused: ${pausedReason})` : ""}`,
602
709
  ` capture: ${route}`,
710
+ ` mode: ${config.mode}${config.mode === "smart" ? ` · cadence ${formatDuration(config.intervalMs)}${lastProbeInputTokens > SMART_MAX_CONTEXT_TOKENS ? ` · context ${lastProbeInputTokens.toLocaleString()} > cap, frozen at floor` : ""}` : " (fixed via interval=)"}`,
603
711
  ` interval: ${formatDuration(config.intervalMs)} · maxidle ${config.maxIdleMs === 0 ? "off" : formatDuration(config.maxIdleMs)} · minPromptTokens ${config.minPromptTokens} · maxOutput ${config.maxOutputTokens}`,
604
712
  ` spend cap: ${config.spendCapUsd === null ? "none" : formatUsd(config.spendCapUsd)} · est. probe spend ${formatUsd(stats.spendUsd)}`,
605
713
  ` probes: ${stats.probes} (hits ${stats.hits}, misses ${stats.misses}, errors ${stats.errors})`,
@@ -653,12 +761,44 @@ export default function (pi: ExtensionAPI) {
653
761
  }
654
762
  break;
655
763
  case "resume":
764
+ if (smartPaused) {
765
+ notify("smart-mode pause is intentional — run /keepalive mode=smart to resume probing", "warning");
766
+ break;
767
+ }
656
768
  pausedReason = null;
657
769
  missStreak = 0;
658
770
  errorStreak = 0;
771
+ smartHitStreak = 0;
659
772
  notify("keepalive resumed", "info");
660
773
  break;
774
+ case "mode": {
775
+ const next = value === "smart" || value === "default" ? value : null;
776
+ if (next === null) {
777
+ notify("usage: /keepalive mode=smart (adaptive) or mode=default (fixed)", "error");
778
+ break;
779
+ }
780
+ config.mode = next;
781
+ smartHitStreak = 0;
782
+ smartPaused = false; // re-selecting the mode is the documented way out of a smart miss-pause
783
+ if (next === "smart") {
784
+ // smart manages the cadence itself; snap back to its floor.
785
+ if (config.intervalMs < SMART_BASE_MS) config.intervalMs = SMART_BASE_MS;
786
+ notify(
787
+ `mode=smart — probing resumes at ${formatDuration(config.intervalMs)}; +30s per ${SMART_CONFIRM_HITS} hits (context ≤ ${SMART_MAX_CONTEXT_TOKENS / 1000}k), one miss parks probing`,
788
+ "info",
789
+ );
790
+ } else {
791
+ notify("mode=default — fixed cadence via /keepalive interval=<duration>", "info");
792
+ }
793
+ persistConfig();
794
+ updateUi();
795
+ break;
796
+ }
661
797
  case "interval": {
798
+ if (config.mode === "smart") {
799
+ notify("smart mode manages the cadence itself — use /keepalive mode=default to set it manually", "warning");
800
+ break;
801
+ }
662
802
  const ms = value !== undefined ? parseDurationMs(value) : null;
663
803
  if (ms === null || ms < MIN_INTERVAL_MS) {
664
804
  notify(`/keepalive interval=${value ?? "?"} rejected — minimum 30s, e.g. interval=4m`, "error");
@@ -791,12 +931,23 @@ export default function (pi: ExtensionAPI) {
791
931
  cost: (model.cost ?? undefined) as Partial<CostPerM> | undefined,
792
932
  };
793
933
  // A fresh real request means fresh credentials and a warm prefix cache;
794
- // automatically recover from any sticky pause.
934
+ // automatically recover from any sticky pause. Exception: a smart-mode
935
+ // miss intentionally parks probing until the user re-selects smart mode.
795
936
  if (pausedReason !== null || missStreak > 0 || errorStreak > 0) {
796
- pausedReason = null;
937
+ if (smartPaused) {
938
+ if (pausedReason !== null) {
939
+ debug("fresh real request observed — smart-mode miss pause kept; /keepalive mode=smart to resume");
940
+ missStreak = 0;
941
+ errorStreak = 0;
942
+ updateUi();
943
+ return;
944
+ }
945
+ } else {
946
+ pausedReason = null;
947
+ debug("fresh real request observed — keepalive unpaused");
948
+ }
797
949
  missStreak = 0;
798
950
  errorStreak = 0;
799
- debug("fresh real request observed — keepalive unpaused");
800
951
  }
801
952
  updateUi();
802
953
  });