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 +21 -0
- package/README.md +2 -1
- package/examples/local-context-manager.json +1 -0
- package/package.json +1 -1
- package/src/config.ts +115 -8
- package/src/index.ts +128 -30
- package/src/policy.ts +5 -1
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.
|
|
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`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-context-manager",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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] =
|
|
259
|
+
candidate[lowerKey] = numericBase[lowerKey];
|
|
153
260
|
changedNumbers.delete(lowerKey);
|
|
154
261
|
} else if (upperChanged && !lowerChanged) {
|
|
155
|
-
candidate[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] =
|
|
163
|
-
candidate[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
|
|
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(
|
|
264
|
+
const snapshot = telemetry.snapshot(thresholds.compactThresholdTokens);
|
|
247
265
|
const status = formatTelemetryStatus(snapshot);
|
|
248
|
-
return snapshot.contextTokens !== null && snapshot.contextTokens >=
|
|
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(
|
|
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
|
-
):
|
|
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
|
|
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
|
-
|
|
318
|
+
observed: ObservedContext,
|
|
297
319
|
): void {
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
619
|
-
notifySoftWarning(context, config, telemetry, warned,
|
|
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
|
|
624
|
-
notifySoftWarning(context, config, telemetry, warned,
|
|
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,
|
|
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
|
|
641
|
-
notifySoftWarning(context, config, telemetry, warned,
|
|
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,
|
|
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
|
|
841
|
-
const snapshot = telemetry.snapshot(
|
|
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
|
-
`
|
|
845
|
-
`
|
|
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
|
|
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;
|