local-context-manager 0.3.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to `local-context-manager` are documented here. Version numbers also mark the project milestones represented by the merged pull requests.
4
4
 
5
+ ## [0.3.1] - 2026-09-04
6
+
7
+ This release makes context tuning intent-based for normal users while keeping numeric controls available for advanced setups.
8
+
9
+ ### Added
10
+
11
+ - `balanced`, `aggressive`, and `relaxed` context profiles, with `balanced` as the zero-configuration default.
12
+ - `/context-mode [aggressive|balanced|relaxed]` for symptom-based, session-local tuning.
13
+ - Automatic downward adaptation for constrained model context windows; large advertised windows never expand the configured policy.
14
+ - Effective profile and threshold reporting through `/context-stats`.
15
+
16
+ ### Changed
17
+
18
+ - Numeric threshold settings remain supported as advanced overrides and are applied after profile selection.
19
+ - The compaction gate and custom compaction hook now use the active, context-window-aware thresholds.
20
+
21
+ ### Boundaries
22
+
23
+ - This release does not infer thresholds from hardware, learn a performance knee from latency, or retune profiles autonomously.
24
+
5
25
  ## [0.3.0] - 2026-09-04
6
26
 
7
27
  This release completes the public, npm-distributed extension workflow.
@@ -48,6 +68,7 @@ Initial extension milestone delivered by [PR #1](https://github.com/SaehwanPark/
48
68
  - Reviewed `/handoff <objective>` continuation prompts and fresh-session initialization.
49
69
  - Package metadata, examples, tests, and build/typecheck configuration.
50
70
 
71
+ [0.3.1]: https://github.com/SaehwanPark/local-context-manager/pull/5
51
72
  [0.3.0]: https://github.com/SaehwanPark/local-context-manager/releases/tag/v0.3.0
52
73
  [0.2.0]: https://github.com/SaehwanPark/local-context-manager/pull/2
53
74
  [0.1.0]: https://github.com/SaehwanPark/local-context-manager/pull/1
package/README.md CHANGED
@@ -18,11 +18,12 @@ Start (or reload) Pi in your project, then try:
18
18
  /context-stats
19
19
  ```
20
20
 
21
- The extension works with Pi's existing models and configuration. It does not install a model or change Pi's emergency compaction authority. To pin this release, use `npm:local-context-manager@0.3.0`.
21
+ The extension works with Pi's existing models and configuration. It does not install a model or change Pi's emergency compaction authority. The latest published release can be pinned with `npm:local-context-manager@0.3.0`.
22
22
 
23
23
  ## What it adds
24
24
 
25
25
  - telemetry for context size, compaction, and reduced tool output;
26
+ - balanced-by-default context profiles (`aggressive`, `balanced`, `relaxed`) with automatic downward adaptation for small model windows;
26
27
  - guarded proactive compaction at safe idle boundaries;
27
28
  - conservative reduction of only new oversized tool results, with a recovery path;
28
29
  - intentional phase compaction with `/compact-phase`;
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "enabled": true,
3
+ "contextProfile": "balanced",
3
4
  "softWarningTokens": 24000,
4
5
  "compactThresholdTokens": 32000,
5
6
  "hardCeilingTokens": 48000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-context-manager",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A Pi extension for cache-friendly context control in long-running local-LLM workflows",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package", "pi-extension", "local-llm", "context-management"],
package/src/config.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
 
3
+ export type ContextProfile = "aggressive" | "balanced" | "relaxed";
4
+
5
+ export interface ContextThresholds {
6
+ softWarningTokens: number;
7
+ compactThresholdTokens: number;
8
+ hardCeilingTokens: number;
9
+ keepRecentTokens: number;
10
+ }
11
+
3
12
  export interface LocalContextManagerConfig {
4
13
  enabled: boolean;
14
+ contextProfile: ContextProfile;
5
15
  softWarningTokens: number;
6
16
  compactThresholdTokens: number;
7
17
  hardCeilingTokens: number;
@@ -14,12 +24,34 @@ export interface LocalContextManagerConfig {
14
24
  debug: boolean;
15
25
  }
16
26
 
27
+ export const CONTEXT_PROFILE_THRESHOLDS: Readonly<Record<ContextProfile, Readonly<ContextThresholds>>> = Object.freeze({
28
+ aggressive: Object.freeze({
29
+ keepRecentTokens: 8_000,
30
+ softWarningTokens: 16_000,
31
+ compactThresholdTokens: 24_000,
32
+ hardCeilingTokens: 36_000,
33
+ }),
34
+ balanced: Object.freeze({
35
+ keepRecentTokens: 10_000,
36
+ softWarningTokens: 24_000,
37
+ compactThresholdTokens: 32_000,
38
+ hardCeilingTokens: 48_000,
39
+ }),
40
+ relaxed: Object.freeze({
41
+ keepRecentTokens: 12_000,
42
+ softWarningTokens: 36_000,
43
+ compactThresholdTokens: 48_000,
44
+ hardCeilingTokens: 72_000,
45
+ }),
46
+ });
47
+
17
48
  export const DEFAULT_CONFIG: Readonly<LocalContextManagerConfig> = Object.freeze({
18
49
  enabled: true,
19
- softWarningTokens: 24_000,
20
- compactThresholdTokens: 32_000,
21
- hardCeilingTokens: 48_000,
22
- keepRecentTokens: 10_000,
50
+ contextProfile: "balanced",
51
+ softWarningTokens: CONTEXT_PROFILE_THRESHOLDS.balanced.softWarningTokens,
52
+ compactThresholdTokens: CONTEXT_PROFILE_THRESHOLDS.balanced.compactThresholdTokens,
53
+ hardCeilingTokens: CONTEXT_PROFILE_THRESHOLDS.balanced.hardCeilingTokens,
54
+ keepRecentTokens: CONTEXT_PROFILE_THRESHOLDS.balanced.keepRecentTokens,
23
55
  toolOutputReduction: true,
24
56
  semanticCompaction: true,
25
57
  handoff: true,
@@ -40,6 +72,7 @@ export interface LoadConfigOptions {
40
72
  allowProjectConfig?: boolean;
41
73
  }
42
74
 
75
+ const CONTEXT_PROFILE_KEYS = ["aggressive", "balanced", "relaxed"] as const;
43
76
  const BOOLEAN_KEYS = [
44
77
  "enabled",
45
78
  "toolOutputReduction",
@@ -58,10 +91,67 @@ const NUMBER_KEYS = [
58
91
  type RecordValue = Record<string, unknown>;
59
92
  type NumberConfigKey = (typeof NUMBER_KEYS)[number];
60
93
 
94
+ function isContextProfile(value: unknown): value is ContextProfile {
95
+ return typeof value === "string" && (CONTEXT_PROFILE_KEYS as readonly string[]).includes(value);
96
+ }
97
+
61
98
  function isRecord(value: unknown): value is RecordValue {
62
99
  return typeof value === "object" && value !== null && !Array.isArray(value);
63
100
  }
64
101
 
102
+ // Four ordered positive thresholds need a little room; real model windows are far larger.
103
+ const MIN_ADAPTIVE_CONTEXT_WINDOW = 8;
104
+ const CONTEXT_WINDOW_FRACTIONS = Object.freeze({
105
+ keepRecentTokens: 0.125,
106
+ softWarningTokens: 0.25,
107
+ compactThresholdTokens: 0.5,
108
+ hardCeilingTokens: 0.75,
109
+ });
110
+
111
+ /**
112
+ * Keep the configured policy as-is for normal and large windows, while reserving
113
+ * room for the next turn on constrained models. The fractions deliberately only
114
+ * lower thresholds; a large advertised window must not silently expand a user's
115
+ * preferred working context.
116
+ */
117
+ export function getEffectiveThresholds(
118
+ config: LocalContextManagerConfig,
119
+ contextWindow?: number,
120
+ ): ContextThresholds {
121
+ const configured: ContextThresholds = {
122
+ keepRecentTokens: config.keepRecentTokens,
123
+ softWarningTokens: config.softWarningTokens,
124
+ compactThresholdTokens: config.compactThresholdTokens,
125
+ hardCeilingTokens: config.hardCeilingTokens,
126
+ };
127
+ if (
128
+ typeof contextWindow !== "number" ||
129
+ !Number.isFinite(contextWindow) ||
130
+ contextWindow < MIN_ADAPTIVE_CONTEXT_WINDOW
131
+ ) {
132
+ return configured;
133
+ }
134
+
135
+ return {
136
+ keepRecentTokens: Math.min(
137
+ configured.keepRecentTokens,
138
+ Math.floor(contextWindow * CONTEXT_WINDOW_FRACTIONS.keepRecentTokens),
139
+ ),
140
+ softWarningTokens: Math.min(
141
+ configured.softWarningTokens,
142
+ Math.floor(contextWindow * CONTEXT_WINDOW_FRACTIONS.softWarningTokens),
143
+ ),
144
+ compactThresholdTokens: Math.min(
145
+ configured.compactThresholdTokens,
146
+ Math.floor(contextWindow * CONTEXT_WINDOW_FRACTIONS.compactThresholdTokens),
147
+ ),
148
+ hardCeilingTokens: Math.min(
149
+ configured.hardCeilingTokens,
150
+ Math.floor(contextWindow * CONTEXT_WINDOW_FRACTIONS.hardCeilingTokens),
151
+ ),
152
+ };
153
+ }
154
+
65
155
  function configObject(value: unknown): RecordValue | undefined {
66
156
  if (!isRecord(value)) {
67
157
  return undefined;
@@ -90,6 +180,23 @@ function applyLayer(
90
180
  const candidate = { ...base };
91
181
  const changedNumbers = new Set<NumberConfigKey>();
92
182
 
183
+ if ("contextProfile" in values) {
184
+ const value = values.contextProfile;
185
+ if (!isContextProfile(value)) {
186
+ errors.push(`Ignoring contextProfile${describeSource(source)}: expected aggressive, balanced, or relaxed`);
187
+ } else {
188
+ candidate.contextProfile = value;
189
+ Object.assign(candidate, CONTEXT_PROFILE_THRESHOLDS[value]);
190
+ }
191
+ }
192
+
193
+ const numericBase: ContextThresholds = {
194
+ keepRecentTokens: candidate.keepRecentTokens,
195
+ softWarningTokens: candidate.softWarningTokens,
196
+ compactThresholdTokens: candidate.compactThresholdTokens,
197
+ hardCeilingTokens: candidate.hardCeilingTokens,
198
+ };
199
+
93
200
  for (const key of BOOLEAN_KEYS) {
94
201
  if (!(key in values)) {
95
202
  continue;
@@ -149,18 +256,18 @@ function applyLayer(
149
256
  const lowerChanged = changedNumbers.has(lowerKey);
150
257
  const upperChanged = changedNumbers.has(upperKey);
151
258
  if (lowerChanged && !upperChanged) {
152
- candidate[lowerKey] = base[lowerKey];
259
+ candidate[lowerKey] = numericBase[lowerKey];
153
260
  changedNumbers.delete(lowerKey);
154
261
  } else if (upperChanged && !lowerChanged) {
155
- candidate[upperKey] = base[upperKey];
262
+ candidate[upperKey] = numericBase[upperKey];
156
263
  changedNumbers.delete(upperKey);
157
264
  } else {
158
265
  if (!lowerChanged && !upperChanged) {
159
266
  changed = false;
160
267
  break;
161
268
  }
162
- candidate[lowerKey] = base[lowerKey];
163
- candidate[upperKey] = base[upperKey];
269
+ candidate[lowerKey] = numericBase[lowerKey];
270
+ candidate[upperKey] = numericBase[upperKey];
164
271
  changedNumbers.delete(lowerKey);
165
272
  changedNumbers.delete(upperKey);
166
273
  }
package/src/index.ts CHANGED
@@ -23,8 +23,12 @@ import {
23
23
  } from "./checkpoint-reset.js";
24
24
  import { getRearmTokens, CompactionGate, shouldTriggerThresholdCompaction } from "./policy.js";
25
25
  import {
26
+ CONTEXT_PROFILE_THRESHOLDS,
26
27
  DEFAULT_CONFIG,
28
+ getEffectiveThresholds,
27
29
  loadConfig,
30
+ type ContextProfile,
31
+ type ContextThresholds,
28
32
  type LocalContextManagerConfig,
29
33
  } from "./config.js";
30
34
  import {
@@ -48,6 +52,11 @@ const SEMANTIC_PARAMETERS = Type.Object({
48
52
 
49
53
  type CompactionRequestReason = "threshold" | "semantic";
50
54
 
55
+ interface ObservedContext {
56
+ tokens: number | null;
57
+ thresholds: ContextThresholds;
58
+ }
59
+
51
60
  interface PiPathSettings {
52
61
  agentDir: string;
53
62
  configDirName: string;
@@ -239,13 +248,22 @@ async function saveRecoveryCopy(text: string): Promise<string | undefined> {
239
248
  }
240
249
  }
241
250
 
242
- function statusWithCeiling(
251
+ function resolveThresholds(
252
+ context: ExtensionContext,
243
253
  config: LocalContextManagerConfig,
244
254
  telemetry: ContextTelemetry,
255
+ ): ContextThresholds {
256
+ const contextWindow = telemetry.snapshot(config.compactThresholdTokens).contextWindow ?? context.model?.contextWindow;
257
+ return getEffectiveThresholds(config, contextWindow);
258
+ }
259
+
260
+ function statusWithCeiling(
261
+ telemetry: ContextTelemetry,
262
+ thresholds: ContextThresholds,
245
263
  ): string {
246
- const snapshot = telemetry.snapshot(config.compactThresholdTokens);
264
+ const snapshot = telemetry.snapshot(thresholds.compactThresholdTokens);
247
265
  const status = formatTelemetryStatus(snapshot);
248
- return snapshot.contextTokens !== null && snapshot.contextTokens >= config.hardCeilingTokens
266
+ return snapshot.contextTokens !== null && snapshot.contextTokens >= thresholds.hardCeilingTokens
249
267
  ? `${status} · hard ceiling`
250
268
  : status;
251
269
  }
@@ -254,13 +272,15 @@ function updateStatus(
254
272
  context: ExtensionContext,
255
273
  config: LocalContextManagerConfig,
256
274
  telemetry: ContextTelemetry,
275
+ thresholds?: ContextThresholds,
257
276
  ): void {
258
277
  if (!context.hasUI) {
259
278
  return;
260
279
  }
280
+ const activeThresholds = thresholds ?? resolveThresholds(context, config, telemetry);
261
281
  context.ui.setStatus(
262
282
  EXTENSION_STATUS_KEY,
263
- config.enabled ? statusWithCeiling(config, telemetry) : "off",
283
+ config.enabled ? statusWithCeiling(telemetry, activeThresholds) : "off",
264
284
  );
265
285
  }
266
286
 
@@ -269,7 +289,7 @@ function observeContext(
269
289
  config: LocalContextManagerConfig,
270
290
  telemetry: ContextTelemetry,
271
291
  gate: CompactionGate,
272
- ): number | null {
292
+ ): ObservedContext {
273
293
  const usage = context.getContextUsage();
274
294
  telemetry.observe(usage);
275
295
  if (usage?.tokens == null) {
@@ -282,10 +302,12 @@ function observeContext(
282
302
  debugLog(config, "could not estimate active context", error);
283
303
  }
284
304
  }
285
- const snapshot = telemetry.snapshot(config.compactThresholdTokens);
305
+ const thresholds = resolveThresholds(context, config, telemetry);
306
+ gate.setRearmTokens(getRearmTokens(thresholds.softWarningTokens, thresholds.compactThresholdTokens));
307
+ const snapshot = telemetry.snapshot(thresholds.compactThresholdTokens);
286
308
  gate.observe(snapshot.contextTokens);
287
- updateStatus(context, config, telemetry);
288
- return snapshot.contextTokens;
309
+ updateStatus(context, config, telemetry, thresholds);
310
+ return { tokens: snapshot.contextTokens, thresholds };
289
311
  }
290
312
 
291
313
  function notifySoftWarning(
@@ -293,9 +315,10 @@ function notifySoftWarning(
293
315
  config: LocalContextManagerConfig,
294
316
  telemetry: ContextTelemetry,
295
317
  warned: { value: boolean },
296
- tokens: number | null,
318
+ observed: ObservedContext,
297
319
  ): void {
298
- if (tokens === null || warned.value || tokens < config.softWarningTokens) {
320
+ const { tokens, thresholds } = observed;
321
+ if (tokens === null || warned.value || tokens < thresholds.softWarningTokens) {
299
322
  return;
300
323
  }
301
324
  warned.value = true;
@@ -306,7 +329,23 @@ function notifySoftWarning(
306
329
  );
307
330
  }
308
331
  debugLog(config, `soft warning at ${tokens} tokens`);
309
- updateStatus(context, config, telemetry);
332
+ updateStatus(context, config, telemetry, thresholds);
333
+ }
334
+
335
+ function parseContextProfile(value: string): ContextProfile | undefined {
336
+ if (value === "aggressive" || value === "balanced" || value === "relaxed") {
337
+ return value;
338
+ }
339
+ return undefined;
340
+ }
341
+
342
+ function formatThresholdSummary(thresholds: ContextThresholds): string {
343
+ return [
344
+ `keep ${thresholds.keepRecentTokens.toLocaleString()}`,
345
+ `warn ${thresholds.softWarningTokens.toLocaleString()}`,
346
+ `compact ${thresholds.compactThresholdTokens.toLocaleString()}`,
347
+ `ceiling ${thresholds.hardCeilingTokens.toLocaleString()}`,
348
+ ].join(" · ");
310
349
  }
311
350
 
312
351
  function buildCompactionOptions(
@@ -326,6 +365,7 @@ async function buildCustomCompaction(
326
365
  event: SessionBeforeCompactEvent,
327
366
  context: ExtensionContext,
328
367
  config: LocalContextManagerConfig,
368
+ thresholds: ContextThresholds,
329
369
  ): Promise<{ compaction: CompactionResult } | undefined> {
330
370
  const model = context.model;
331
371
  const nativeKeepRecentTokens = event.preparation.settings.keepRecentTokens;
@@ -333,7 +373,7 @@ async function buildCustomCompaction(
333
373
  !config.enabled ||
334
374
  !model ||
335
375
  !Number.isFinite(nativeKeepRecentTokens) ||
336
- config.keepRecentTokens >= nativeKeepRecentTokens
376
+ thresholds.keepRecentTokens >= nativeKeepRecentTokens
337
377
  ) {
338
378
  return undefined;
339
379
  }
@@ -375,7 +415,7 @@ async function buildCustomCompaction(
375
415
  event.branchEntries,
376
416
  boundaryStart,
377
417
  event.branchEntries.length,
378
- config.keepRecentTokens,
418
+ thresholds.keepRecentTokens,
379
419
  );
380
420
  const firstKeptEntry = event.branchEntries[cutPoint.firstKeptEntryIndex];
381
421
  if (!firstKeptEntry?.id) {
@@ -415,7 +455,7 @@ async function buildCustomCompaction(
415
455
  fileOps,
416
456
  settings: {
417
457
  ...event.preparation.settings,
418
- keepRecentTokens: config.keepRecentTokens,
458
+ keepRecentTokens: thresholds.keepRecentTokens,
419
459
  },
420
460
  };
421
461
 
@@ -558,8 +598,9 @@ export default function (pi: ExtensionAPI): void {
558
598
  checkpointReset?.createdAt ?? null,
559
599
  checkpointReset?.path ?? null,
560
600
  );
601
+ const initialThresholds = getEffectiveThresholds(config, context.model?.contextWindow);
561
602
  gate = new CompactionGate({
562
- rearmTokens: getRearmTokens(config.softWarningTokens, config.compactThresholdTokens),
603
+ rearmTokens: getRearmTokens(initialThresholds.softWarningTokens, initialThresholds.compactThresholdTokens),
563
604
  });
564
605
  warned.value = false;
565
606
  turnSerial = 0;
@@ -615,13 +656,13 @@ export default function (pi: ExtensionAPI): void {
615
656
  pi.on("turn_start", (_event, context) => {
616
657
  turnSerial += 1;
617
658
  telemetry.markTurn(turnSerial);
618
- const tokens = observeContext(context, config, telemetry, gate);
619
- notifySoftWarning(context, config, telemetry, warned, tokens);
659
+ const observed = observeContext(context, config, telemetry, gate);
660
+ notifySoftWarning(context, config, telemetry, warned, observed);
620
661
  });
621
662
 
622
663
  pi.on("turn_end", (_event, context) => {
623
- const tokens = observeContext(context, config, telemetry, gate);
624
- notifySoftWarning(context, config, telemetry, warned, tokens);
664
+ const observed = observeContext(context, config, telemetry, gate);
665
+ notifySoftWarning(context, config, telemetry, warned, observed);
625
666
 
626
667
  // turn_end is the first boundary after all tool results have landed. Only use
627
668
  // it when the host reports idle; a continuing tool loop is handled at
@@ -630,15 +671,15 @@ export default function (pi: ExtensionAPI): void {
630
671
  config.enabled &&
631
672
  !semanticRequested &&
632
673
  context.isIdle() &&
633
- shouldTriggerThresholdCompaction(tokens, config.compactThresholdTokens)
674
+ shouldTriggerThresholdCompaction(observed.tokens, observed.thresholds.compactThresholdTokens)
634
675
  ) {
635
676
  requestCompaction(context, "threshold");
636
677
  }
637
678
  });
638
679
 
639
680
  pi.on("agent_settled", (_event, context) => {
640
- const tokens = observeContext(context, config, telemetry, gate);
641
- notifySoftWarning(context, config, telemetry, warned, tokens);
681
+ const observed = observeContext(context, config, telemetry, gate);
682
+ notifySoftWarning(context, config, telemetry, warned, observed);
642
683
 
643
684
  if (checkpointResetRequested) {
644
685
  const reason = checkpointResetReason;
@@ -662,13 +703,14 @@ export default function (pi: ExtensionAPI): void {
662
703
  );
663
704
  return;
664
705
  }
665
- if (config.enabled && shouldTriggerThresholdCompaction(tokens, config.compactThresholdTokens)) {
706
+ if (config.enabled && shouldTriggerThresholdCompaction(observed.tokens, observed.thresholds.compactThresholdTokens)) {
666
707
  requestCompaction(context, "threshold");
667
708
  }
668
709
  });
669
710
 
670
711
  pi.on("session_compact", (event, context) => {
671
712
  const usage = context.getContextUsage();
713
+ telemetry.observe(usage);
672
714
  let activeEntries: SessionEntry[] = [];
673
715
  let activeToolOutputTokens = 0;
674
716
  try {
@@ -686,12 +728,14 @@ export default function (pi: ExtensionAPI): void {
686
728
  postTokens,
687
729
  activeToolOutputTokens,
688
730
  );
731
+ const thresholds = resolveThresholds(context, config, telemetry);
732
+ gate.setRearmTokens(getRearmTokens(thresholds.softWarningTokens, thresholds.compactThresholdTokens));
689
733
  gate.complete(postTokens, turnSerial);
690
734
  requestedCompaction = undefined;
691
735
  semanticRequested = false;
692
736
  semanticReason = undefined;
693
737
  warned.value = false;
694
- updateStatus(context, config, telemetry);
738
+ updateStatus(context, config, telemetry, thresholds);
695
739
  debugLog(config, `compaction completed (${event.reason})`);
696
740
  });
697
741
 
@@ -766,7 +810,7 @@ export default function (pi: ExtensionAPI): void {
766
810
  });
767
811
 
768
812
  pi.on("session_before_compact", async (event, context) => {
769
- return buildCustomCompaction(event, context, config);
813
+ return buildCustomCompaction(event, context, config, resolveThresholds(context, config, telemetry));
770
814
  });
771
815
 
772
816
  pi.registerTool({
@@ -837,14 +881,68 @@ export default function (pi: ExtensionAPI): void {
837
881
  pi.registerCommand("context-stats", {
838
882
  description: "Show local context telemetry",
839
883
  handler: async (_args, context) => {
840
- const tokens = observeContext(context, config, telemetry, gate);
841
- const snapshot = telemetry.snapshot(config.compactThresholdTokens);
884
+ const observed = observeContext(context, config, telemetry, gate);
885
+ const snapshot = telemetry.snapshot(observed.thresholds.compactThresholdTokens);
842
886
  const details = [
843
887
  formatTelemetryDetails(snapshot),
844
- `Soft warning: ${config.softWarningTokens.toLocaleString()} tokens`,
845
- `Hard ceiling: ${config.hardCeilingTokens.toLocaleString()} tokens`,
888
+ `Context mode: ${config.contextProfile}`,
889
+ `Effective thresholds: ${formatThresholdSummary(observed.thresholds)}`,
890
+ `Soft warning: ${observed.thresholds.softWarningTokens.toLocaleString()} tokens`,
891
+ `Hard ceiling: ${observed.thresholds.hardCeilingTokens.toLocaleString()} tokens`,
846
892
  `Enabled: ${config.enabled ? "yes" : "no"}`,
847
- `Current reading: ${tokens === null ? "unknown" : `${Math.round(tokens).toLocaleString()} tokens`}`,
893
+ `Current reading: ${observed.tokens === null ? "unknown" : `${Math.round(observed.tokens).toLocaleString()} tokens`}`,
894
+ ].join("\n");
895
+ if (context.hasUI) {
896
+ context.ui.notify(details, "info");
897
+ } else if (config.debug) {
898
+ console.error(details);
899
+ }
900
+ },
901
+ });
902
+
903
+ pi.registerCommand("context-mode", {
904
+ description: "Show or set context mode: aggressive, balanced, or relaxed",
905
+ handler: async (args, context) => {
906
+ const requested = args.trim().toLowerCase();
907
+ if (!requested) {
908
+ const observed = observeContext(context, config, telemetry, gate);
909
+ const snapshot = telemetry.snapshot(observed.thresholds.compactThresholdTokens);
910
+ const details = [
911
+ `Context mode: ${config.contextProfile}`,
912
+ `Effective thresholds: ${formatThresholdSummary(observed.thresholds)}`,
913
+ `Context window: ${snapshot.contextWindow === null ? "not reported" : `${Math.round(snapshot.contextWindow).toLocaleString()} tokens`}`,
914
+ ].join("\n");
915
+ if (context.hasUI) {
916
+ context.ui.notify(details, "info");
917
+ } else if (config.debug) {
918
+ console.error(details);
919
+ }
920
+ return;
921
+ }
922
+
923
+ const profile = parseContextProfile(requested);
924
+ if (!profile) {
925
+ if (context.hasUI) {
926
+ context.ui.notify("Usage: /context-mode [aggressive|balanced|relaxed]", "error");
927
+ } else {
928
+ debugLog(config, "Usage: /context-mode [aggressive|balanced|relaxed]");
929
+ }
930
+ return;
931
+ }
932
+
933
+ config = {
934
+ ...config,
935
+ contextProfile: profile,
936
+ ...CONTEXT_PROFILE_THRESHOLDS[profile],
937
+ };
938
+ warned.value = false;
939
+ const observed = observeContext(context, config, telemetry, gate);
940
+ const snapshot = telemetry.snapshot(observed.thresholds.compactThresholdTokens);
941
+ const details = [
942
+ `Context mode set to ${profile} for this session.`,
943
+ `Effective thresholds: ${formatThresholdSummary(observed.thresholds)}`,
944
+ `Context window: ${snapshot.contextWindow === null ? "not reported" : `${Math.round(snapshot.contextWindow).toLocaleString()} tokens`}`,
945
+ `To make it persistent, set \"contextProfile\": \"${profile}\" in local-context-manager.json.`,
848
946
  ].join("\n");
849
947
  if (context.hasUI) {
850
948
  context.ui.notify(details, "info");
package/src/policy.ts CHANGED
@@ -11,7 +11,7 @@ export interface CompactionGateOptions {
11
11
  * threshold gate when the caller has deliberately asked for a new epoch.
12
12
  */
13
13
  export class CompactionGate {
14
- private readonly rearmTokens: number;
14
+ private rearmTokens: number;
15
15
  private readonly minimumTurnGap: number;
16
16
  private armed = true;
17
17
  private inFlight = false;
@@ -23,6 +23,10 @@ export class CompactionGate {
23
23
  this.minimumTurnGap = Number.isFinite(minimumTurnGap) ? Math.max(0, Math.floor(minimumTurnGap)) : MIN_COMPACTION_TURN_GAP;
24
24
  }
25
25
 
26
+ setRearmTokens(rearmTokens: number): void {
27
+ this.rearmTokens = Number.isFinite(rearmTokens) ? Math.max(1, rearmTokens) : 1;
28
+ }
29
+
26
30
  observe(tokens: number | null): void {
27
31
  if (tokens !== null && Number.isFinite(tokens) && tokens <= this.rearmTokens) {
28
32
  this.armed = true;