summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 14:58:40 +0900
committerAdam Malczewski <[email protected]>2026-06-22 14:58:40 +0900
commite6e2d31b987043d0325a93d23295af0408b723ec (patch)
treed3208766c1e8123b8ca81c7a7c5a91c2c269e536
parent519b79999d49156bbfaecc91a2d882fba2475fef (diff)
downloaddispatch-e6e2d31b987043d0325a93d23295af0408b723ec.tar.gz
dispatch-e6e2d31b987043d0325a93d23295af0408b723ec.zip
feat: context window from model endpoints + percentage-based auto-compact
ModelInfo (kernel contract): - Add contextWindow?: number field OpenAI-stream listModels: - Parse contextWindow from common field names (context_length, context_window, max_context_length, max_tokens) Transport-contract: - ModelsResponse: add optional modelInfo map (model name → { contextWindow? }) - Add ModelMetadata type - Rename CompactThresholdResponse → CompactPercentResponse - Rename SetCompactThresholdRequest → SetCompactPercentRequest Credential store: - Add getModelInfo(modelName) method — resolves full ModelInfo (including contextWindow) for a <credential>/<model> string Transport-http: - GET /models now includes modelInfo with contextWindow per model - Rename compact-threshold endpoints → compact-percent Session-orchestrator: - Auto-compact now uses contextSize (not overcounted usage.inputTokens) compared against contextWindow * (percent / 100) - Default percent: 85 (was flat 350000) - resolveModelInfo dep added to look up contextWindow - Passes modelName from the settled turn to the compaction service Conversation store: - Rename getCompactThreshold/setCompactThreshold → getCompactPercent/setCompactPercent - compactThresholdKey → compact-percent key
-rw-r--r--backend-to-fe-handoff.md14
-rw-r--r--frontend-compaction-handoff.md36
-rw-r--r--packages/conversation-store/src/keys.ts2
-rw-r--r--packages/conversation-store/src/store.ts16
-rw-r--r--packages/credential-store/src/registry.ts26
-rw-r--r--packages/kernel/src/contracts/provider.ts2
-rw-r--r--packages/openai-stream/src/listModels.ts14
-rw-r--r--packages/session-orchestrator/src/extension.ts8
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts16
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts41
-rw-r--r--packages/session-orchestrator/src/queue.test.ts4
-rw-r--r--packages/transport-contract/src/index.ts21
-rw-r--r--packages/transport-http/src/app.test.ts26
-rw-r--r--packages/transport-http/src/app.ts32
-rw-r--r--packages/transport-http/src/extension.ts2
-rw-r--r--packages/transport-http/src/server.bun.test.ts7
-rw-r--r--tasks.md4
17 files changed, 184 insertions, 87 deletions
diff --git a/backend-to-fe-handoff.md b/backend-to-fe-handoff.md
index 1e9ffb2..dcaaa51 100644
--- a/backend-to-fe-handoff.md
+++ b/backend-to-fe-handoff.md
@@ -45,7 +45,7 @@ after `runTurn` returns — backward compatible.
|---|---|---|
| `POST` | `/chat` | Stream a turn (NDJSON response, `X-Conversation-Id` header) |
| `POST` | `/chat/warm` | Cache-warm probe |
-| `GET` | `/models` | List available models |
+| `GET` | `/models` | Model catalog (now includes `modelInfo` with `contextWindow` per model) |
| `GET` | `/conversations` | List conversations (`?q=` prefix filter, `?status=active,idle` status filter) |
| `GET` | `/conversations/:id` | Conversation history (`?sinceSeq=`, `?beforeSeq=`, `?limit=` windowing) |
| `GET` | `/conversations/:id/metrics` | Per-turn metrics (tokens, timing) |
@@ -55,8 +55,8 @@ after `runTurn` returns — backward compatible.
| `GET` | `/conversations/:id/reasoning-effort` | Per-conversation reasoning effort |
| `PUT` | `/conversations/:id/reasoning-effort` | Set reasoning effort |
| `GET` | `/conversations/:id/lsp` | LSP server status |
-| `GET` | `/conversations/:id/compact-threshold` | Auto-compact threshold (0=manual, null=default 350k) |
-| `PUT` | `/conversations/:id/compact-threshold` | Set auto-compact threshold |
+| `GET` | `/conversations/:id/compact-percent` | Auto-compact percent (0=manual, null=default 85%) |
+| `PUT` | `/conversations/:id/compact-percent` | Set auto-compact percent |
| `GET` | `/conversations/:id/title` | Read conversation title |
| `PUT` | `/conversations/:id/title` | Set conversation title |
| `POST` | `/conversations/:id/close` | Close tab (abort turn + mark `closed`) |
@@ -119,13 +119,13 @@ interface CompactResponse {
messagesKept: number;
}
-interface CompactThresholdResponse {
+interface CompactPercentResponse {
conversationId: string;
- threshold: number; // 0 = manual; null = default 350000
+ percent: number; // 0 = manual; null = default 85
}
-interface SetCompactThresholdRequest {
- threshold: number;
+interface SetCompactPercentRequest {
+ percent: number;
}
```
diff --git a/frontend-compaction-handoff.md b/frontend-compaction-handoff.md
index 6979c29..572b25f 100644
--- a/frontend-compaction-handoff.md
+++ b/frontend-compaction-handoff.md
@@ -11,7 +11,7 @@ Creates a linked chain of archives you can walk backward.
Two modes:
- **Manual**: `POST /conversations/:id/compact` — triggers immediately.
- **Automatic**: after each turn settles, the backend checks if the last turn's
- input tokens exceeded the per-conversation `compactThreshold` (default 350000).
+ input tokens exceeded the per-conversation `compactThreshold` (default 85).
If so, compaction runs automatically (fire-and-forget, non-blocking).
## How compaction works — non-destructive, chained
@@ -84,13 +84,13 @@ export interface CompactResponse {
readonly messagesKept: number;
}
-export interface CompactThresholdResponse {
+export interface CompactPercentResponse {
readonly conversationId: string;
- readonly threshold: number; // 0 = manual only; null = default 350000
+ readonly percent: number; // 0 = manual only; null = default 85
}
-export interface SetCompactThresholdRequest {
- readonly threshold: number;
+export interface SetCompactPercentRequest {
+ readonly percent: number;
}
```
@@ -107,24 +107,24 @@ Triggers compaction on demand. Optional JSON body:
The conversation ID in the response is the same as the request — the ID doesn't
change. The FE should reload the conversation history.
-409: `{ error: string }` — conversation is generating, too short, threshold not exceeded, etc.
+409: `{ error: string }` — conversation is generating, too short, percent not exceeded, etc.
503: compaction service not available.
-## `GET /conversations/:id/compact-threshold` — read threshold
+## `GET /conversations/:id/compact-percent` — read percent
-200: `CompactThresholdResponse { conversationId, threshold }`
-- `threshold: 0` — auto-compact explicitly disabled (manual only).
-- `threshold: null` (not stored) — **default: 350000** (350k tokens). The FE
- should display 350000 as the default value in the settings UI.
+200: `CompactPercentResponse { conversationId, percent }`
+- `percent: 0` — auto-compact explicitly disabled (manual only).
+- `percent: null` (not stored) — **default: 85** (85% tokens). The FE
+ should display 85 as the default value in the settings UI.
- Any positive number — auto-compact triggers when the last turn's input tokens
exceed this value.
-## `PUT /conversations/:id/compact-threshold` — set threshold
+## `PUT /conversations/:id/compact-percent` — set percent
-Body: `SetCompactThresholdRequest { threshold: number }`
+Body: `SetCompactPercentRequest { percent: number }`
- `0` explicitly disables auto-compact.
-- Any positive number sets the trigger threshold.
-- To "reset to default", set it to 350000.
+- Any positive number sets the trigger percent.
+- To "reset to default", set it to 85.
## `conversation.compacted` WS message
@@ -141,9 +141,9 @@ No tab switching needed — the ID is the same.
Show a loading indicator while waiting. On success, reload the conversation
history (same ID — just re-fetch).
-2. **Settings UI** for compact threshold: `PUT /conversations/:id/compact-threshold`
- with `{ threshold: number }`. A number input (0 = manual only, default 350000).
- Read the current value via `GET /conversations/:id/compact-threshold`.
+2. **Settings UI** for compact percent: `PUT /conversations/:id/compact-percent`
+ with `{ percent: number }`. A number input (0 = manual only, default 85).
+ Read the current value via `GET /conversations/:id/compact-percent`.
3. **Handle `conversation.compacted` WS messages**: reload the conversation
history via `GET /conversations/:id` (same ID, no tab switch).
diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts
index a609f82..18c683f 100644
--- a/packages/conversation-store/src/keys.ts
+++ b/packages/conversation-store/src/keys.ts
@@ -55,7 +55,7 @@ export function reasoningEffortKey(conversationId: string): string {
}
export function compactThresholdKey(conversationId: string): string {
- return `conv:${conversationId}:compact-threshold`;
+ return `conv:${conversationId}:compact-percent`;
}
export function metaKey(conversationId: string): string {
diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts
index 700be1e..8c287ae 100644
--- a/packages/conversation-store/src/store.ts
+++ b/packages/conversation-store/src/store.ts
@@ -105,10 +105,10 @@ export interface ConversationStore {
* non-destructively before replacing it with a summary.
*/
readonly forkHistory: (sourceId: string, targetId: string) => Promise<void>;
- /** Get the compact threshold (token count, 0 = manual only), or null if unset. */
- readonly getCompactThreshold: (conversationId: string) => Promise<number | null>;
- /** Set the compact threshold (token count, 0 = manual only). */
- readonly setCompactThreshold: (conversationId: string, threshold: number) => Promise<void>;
+ /** Get the compact percent (0-100, 0 = manual only), or null if unset. */
+ readonly getCompactPercent: (conversationId: string) => Promise<number | null>;
+ /** Set the compact percent (0-100, 0 = manual only). */
+ readonly setCompactPercent: (conversationId: string, percent: number) => Promise<void>;
/**
* Set the `compactedFrom` field on a conversation's metadata, pointing to
* the archive conversation that holds the pre-compaction history.
@@ -620,17 +620,17 @@ export function createConversationStore(
if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort);
},
- async getCompactThreshold(conversationId) {
+ async getCompactPercent(conversationId) {
const raw = await storage.get(compactThresholdKey(conversationId));
if (raw === null) return null;
const n = Number.parseInt(raw, 10);
return Number.isNaN(n) ? null : n;
},
- async setCompactThreshold(conversationId, threshold) {
- await storage.set(compactThresholdKey(conversationId), String(threshold));
+ async setCompactPercent(conversationId, percent) {
+ await storage.set(compactThresholdKey(conversationId), String(percent));
if (logger !== undefined) {
- logger.debug("compact-threshold set", { conversationId, threshold });
+ logger.debug("compact-percent set", { conversationId, percent });
}
},
diff --git a/packages/credential-store/src/registry.ts b/packages/credential-store/src/registry.ts
index c1ba771..89d8084 100644
--- a/packages/credential-store/src/registry.ts
+++ b/packages/credential-store/src/registry.ts
@@ -1,4 +1,4 @@
-import type { ProviderContract } from "@dispatch/kernel";
+import type { ModelInfo, ProviderContract } from "@dispatch/kernel";
export interface Credential {
readonly name: string;
@@ -24,6 +24,13 @@ export interface CredentialStore {
* missing or has no listModels.
*/
listCatalog(): Promise<readonly string[]>;
+
+ /**
+ * Returns the full `ModelInfo` for a `<credentialName>/<model>` string, or
+ * undefined if unknown. Caches the result of `listModels` per credential.
+ * Used to look up `contextWindow` for auto-compaction.
+ */
+ getModelInfo(modelName: string): Promise<ModelInfo | undefined>;
}
export interface CredentialStoreDeps {
@@ -76,5 +83,22 @@ export function createCredentialStore(deps: CredentialStoreDeps): CredentialStor
return results;
},
+
+ async getModelInfo(modelName: string): Promise<ModelInfo | undefined> {
+ const slashIndex = modelName.indexOf("/");
+ if (slashIndex === -1) return undefined;
+ const credentialName = modelName.slice(0, slashIndex);
+ const modelId = modelName.slice(slashIndex + 1);
+ if (!modelId) return undefined;
+
+ const providerId = credentialMap.get(credentialName);
+ if (providerId === undefined) return undefined;
+
+ const provider = deps.getProvider(providerId);
+ if (provider?.listModels === undefined) return undefined;
+
+ const models = await provider.listModels();
+ return models.find((m) => m.id === modelId);
+ },
};
}
diff --git a/packages/kernel/src/contracts/provider.ts b/packages/kernel/src/contracts/provider.ts
index 7f920c5..52d853b 100644
--- a/packages/kernel/src/contracts/provider.ts
+++ b/packages/kernel/src/contracts/provider.ts
@@ -112,6 +112,8 @@ export interface ProviderStreamOptions {
export interface ModelInfo {
readonly id: string;
readonly displayName?: string;
+ /** The model's max context window in tokens (e.g. 200000). Optional — providers that don't report it leave it undefined. */
+ readonly contextWindow?: number;
}
/**
diff --git a/packages/openai-stream/src/listModels.ts b/packages/openai-stream/src/listModels.ts
index 3f783f0..8a76c67 100644
--- a/packages/openai-stream/src/listModels.ts
+++ b/packages/openai-stream/src/listModels.ts
@@ -13,6 +13,10 @@ import type { FetchLike } from "@dispatch/trace-replay";
interface OpenAIModelEntry {
readonly id: string;
+ readonly context_length?: number;
+ readonly context_window?: number;
+ readonly max_context_length?: number;
+ readonly max_tokens?: number;
}
interface OpenAIModelListResponse {
@@ -21,10 +25,18 @@ interface OpenAIModelListResponse {
/**
* Pure mapping: raw OpenAI-compatible model list → ModelInfo[].
+ * Extracts `contextWindow` from common field names (providers vary).
* Extracted for direct unit testing with no I/O.
*/
export function parseModelList(data: readonly OpenAIModelEntry[]): readonly ModelInfo[] {
- return data.map((entry) => ({ id: entry.id }));
+ return data.map((entry) => {
+ const contextWindow =
+ entry.context_length ?? entry.context_window ?? entry.max_context_length ?? entry.max_tokens;
+ return {
+ id: entry.id,
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
+ };
+ });
}
export interface ListModelsConfig {
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts
index ae90902..fca8ddb 100644
--- a/packages/session-orchestrator/src/extension.ts
+++ b/packages/session-orchestrator/src/extension.ts
@@ -53,6 +53,10 @@ export function activate(host: HostAPI): void {
const provider = host.getProviders().get(r.providerId);
return provider ? { provider, model: r.model } : undefined;
},
+ resolveModelInfo: async (modelName: string) => {
+ const store = host.getService(credentialStoreHandle);
+ return store.getModelInfo(modelName);
+ },
applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly),
runTurn,
logger: host.logger,
@@ -116,6 +120,10 @@ export function activate(host: HostAPI): void {
const provider = host.getProviders().get(r.providerId);
return provider ? { provider, model: r.model } : undefined;
},
+ resolveModelInfo: async (modelName: string) => {
+ const store = host.getService(credentialStoreHandle);
+ return store.getModelInfo(modelName);
+ },
applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly),
runTurn,
logger: host.logger,
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index 012ac5c..ce134f4 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -91,10 +91,10 @@ function createInMemoryStore(): ConversationStore & {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
@@ -548,10 +548,10 @@ describe("turn-sealed event", () => {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
@@ -621,10 +621,10 @@ describe("turn-sealed event", () => {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
@@ -983,10 +983,10 @@ describe("turn metrics persistence", () => {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index 22a5c11..adf5680 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -6,6 +6,7 @@ import type {
ConversationStatus,
EventHookDescriptor,
Logger,
+ ModelInfo,
ProviderContract,
ProviderEvent,
ProviderStreamOptions,
@@ -248,6 +249,12 @@ export interface SessionOrchestratorDeps {
readonly resolveModel?: (
modelName: string,
) => { provider: ProviderContract; model: string } | undefined;
+ /**
+ * Resolve full `ModelInfo` (including `contextWindow`) for a model name.
+ * Used by the compaction service to calculate the auto-compact threshold
+ * as a percentage of the context window.
+ */
+ readonly resolveModelInfo?: (modelName: string) => Promise<ModelInfo | undefined>;
readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>;
/**
* Lazily resolves the message-queue service (the steering queue), or
@@ -519,7 +526,12 @@ export function createSessionOrchestrator(
// starts fresh either way.
const compaction = deps.resolveCompaction?.();
if (compaction !== undefined) {
- void compaction.compact(conversationId, { auto: true }).catch(() => {});
+ void compaction
+ .compact(conversationId, {
+ auto: true,
+ ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}),
+ })
+ .catch(() => {});
}
}
});
@@ -730,7 +742,7 @@ export function createWarmService(
}
const DEFAULT_KEEP_LAST_N = 10;
-const DEFAULT_COMPACT_THRESHOLD = 350000;
+const DEFAULT_COMPACT_PERCENT = 85;
const COMPACTION_SYSTEM_PROMPT =
"You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " +
@@ -772,17 +784,28 @@ export function createCompactionService(
return { error: "conversation too short to compact" };
}
- // Auto mode: check threshold (default 350k if not explicitly set;
- // 0 explicitly disables).
+ // Auto mode: check if contextSize exceeds percent of contextWindow.
if (opts?.auto === true) {
- const stored = await deps.conversationStore.getCompactThreshold(conversationId);
- const threshold = stored ?? DEFAULT_COMPACT_THRESHOLD;
- if (threshold <= 0) return { error: "auto-compact disabled" };
+ const stored = await deps.conversationStore.getCompactPercent(conversationId);
+ const percent = stored ?? DEFAULT_COMPACT_PERCENT;
+ if (percent <= 0) return { error: "auto-compact disabled" };
const metrics = await deps.conversationStore.loadMetrics(conversationId);
const lastTurn = metrics[metrics.length - 1];
if (lastTurn === undefined) return { error: "no metrics" };
- const lastInputTokens = lastTurn.usage.inputTokens + (lastTurn.usage.cacheReadTokens ?? 0);
- if (lastInputTokens < threshold) return { error: "threshold not exceeded" };
+ const contextSize = lastTurn.contextSize;
+ if (contextSize === undefined) return { error: "no context size" };
+
+ // Resolve the model's context window.
+ const modelName = opts.modelName;
+ if (modelName === undefined || deps.resolveModelInfo === undefined) {
+ return { error: "cannot resolve model info" };
+ }
+ const info = await deps.resolveModelInfo(modelName);
+ if (info?.contextWindow === undefined) {
+ return { error: "model context window unknown" };
+ }
+ const threshold = Math.floor(info.contextWindow * (percent / 100));
+ if (contextSize < threshold) return { error: "threshold not exceeded" };
}
// Split: old messages to summarize + recent messages to keep.
diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts
index 346f9c4..58de4a9 100644
--- a/packages/session-orchestrator/src/queue.test.ts
+++ b/packages/session-orchestrator/src/queue.test.ts
@@ -87,10 +87,10 @@ function createInMemoryStore(): ConversationStore & {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 5745672..02b48a0 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -85,11 +85,20 @@ export interface ChatRequest {
/**
* Response body for `GET /models` — the model catalog.
*
- * Each entry is a model name in `<credentialName>/<model>` form: exactly the
- * string a client passes back as `ChatRequest.model`.
+ * Each entry in `models` is a model name in `<credentialName>/<model>` form:
+ * exactly the string a client passes back as `ChatRequest.model`.
+ * `modelInfo` is an optional map from the same `<credentialName>/<model>` key
+ * to model metadata (e.g. `contextWindow`). Additive — clients that only
+ * read `models` are unaffected.
*/
export interface ModelsResponse {
readonly models: readonly string[];
+ readonly modelInfo?: Readonly<Record<string, ModelMetadata>>;
+}
+
+/** Per-model metadata returned alongside the model catalog. */
+export interface ModelMetadata {
+ readonly contextWindow?: number;
}
/**
@@ -583,17 +592,17 @@ export interface CompactResponse {
}
/**
- * Response for `GET /conversations/:id/compact-threshold` — the token count
+ * Response for `GET /conversations/:id/compact-percent` — the token count
* at which automatic compaction triggers (0 = manual only).
*/
-export interface CompactThresholdResponse {
+export interface CompactPercentResponse {
readonly conversationId: string;
readonly threshold: number;
}
/**
- * Request body for `PUT /conversations/:id/compact-threshold`.
+ * Request body for `PUT /conversations/:id/compact-percent`.
*/
-export interface SetCompactThresholdRequest {
+export interface SetCompactPercentRequest {
readonly threshold: number;
}
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index 674fd94..11f74f5 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -139,10 +139,10 @@ function createFakeConversationStore(
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
@@ -241,6 +241,9 @@ function createFakeCredentialStore(models: string[]): CredentialStore {
resolve() {
return undefined;
},
+ async getModelInfo() {
+ return undefined;
+ },
async listCatalog() {
return models;
},
@@ -252,6 +255,9 @@ function createThrowingCredentialStore(error: Error): CredentialStore {
resolve() {
return undefined;
},
+ async getModelInfo() {
+ return undefined;
+ },
async listCatalog() {
throw error;
},
@@ -876,10 +882,10 @@ describe("GET /conversations/:id", () => {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
@@ -951,10 +957,10 @@ describe("GET /conversations/:id", () => {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
@@ -1095,10 +1101,10 @@ describe("GET /conversations/:id/metrics", () => {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
@@ -2072,10 +2078,10 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index a2ee18c..f917846 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -1,8 +1,8 @@
import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel";
import type {
CloseConversationResponse,
+ CompactPercentResponse,
CompactResponse,
- CompactThresholdResponse,
ConversationHistoryResponse,
ConversationListResponse,
ConversationMetricsResponse,
@@ -14,7 +14,7 @@ import type {
OpenConversationResponse,
QueueResponse,
ReasoningEffortResponse,
- SetCompactThresholdRequest,
+ SetCompactPercentRequest,
ThroughputResponse,
TitleResponse,
WarmResponse,
@@ -233,7 +233,17 @@ export function createApp(opts: CreateServerOptions): Hono {
app.get("/models", async (c) => {
try {
const models = await opts.credentialStore.listCatalog();
- const body: ModelsResponse = { models };
+ const modelInfo: Record<string, { contextWindow?: number }> = {};
+ for (const modelName of models) {
+ const info = await opts.credentialStore.getModelInfo(modelName);
+ if (info?.contextWindow !== undefined) {
+ modelInfo[modelName] = { contextWindow: info.contextWindow };
+ }
+ }
+ const body: ModelsResponse = {
+ models,
+ ...(Object.keys(modelInfo).length > 0 ? { modelInfo } : {}),
+ };
return c.json(body, 200);
} catch (err) {
log.error("models: failed to retrieve catalog", { err });
@@ -739,14 +749,14 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json(response, 200);
});
- app.get("/conversations/:id/compact-threshold", async (c) => {
+ app.get("/conversations/:id/compact-percent", async (c) => {
const conversationId = c.req.param("id");
- const threshold = (await opts.conversationStore.getCompactThreshold(conversationId)) ?? 0;
- const response: CompactThresholdResponse = { conversationId, threshold };
+ const threshold = (await opts.conversationStore.getCompactPercent(conversationId)) ?? 0;
+ const response: CompactPercentResponse = { conversationId, threshold };
return c.json(response, 200);
});
- app.put("/conversations/:id/compact-threshold", async (c) => {
+ app.put("/conversations/:id/compact-percent", async (c) => {
const conversationId = c.req.param("id");
let body: unknown;
try {
@@ -754,7 +764,7 @@ export function createApp(opts: CreateServerOptions): Hono {
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
- const parsed = body as SetCompactThresholdRequest;
+ const parsed = body as SetCompactPercentRequest;
if (
typeof parsed.threshold !== "number" ||
!Number.isFinite(parsed.threshold) ||
@@ -763,9 +773,9 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json({ error: "threshold must be a non-negative number" }, 400);
}
const threshold = Math.floor(parsed.threshold);
- await opts.conversationStore.setCompactThreshold(conversationId, threshold);
- log.info("conversations: compact-threshold set", { conversationId, threshold });
- const response: CompactThresholdResponse = { conversationId, threshold };
+ await opts.conversationStore.setCompactPercent(conversationId, threshold);
+ log.info("conversations: compact-percent set", { conversationId, threshold });
+ const response: CompactPercentResponse = { conversationId, threshold };
return c.json(response, 200);
});
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index f7af615..36314e6 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -32,7 +32,7 @@ export const manifest: Manifest = {
"/conversations/:id",
"/conversations/:id/close",
"/conversations/:id/compact",
- "/conversations/:id/compact-threshold",
+ "/conversations/:id/compact-percent",
"/conversations/:id/cwd",
"/conversations/:id/last",
"/conversations/:id/lsp",
diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts
index fa519af..86ae6bc 100644
--- a/packages/transport-http/src/server.bun.test.ts
+++ b/packages/transport-http/src/server.bun.test.ts
@@ -66,10 +66,10 @@ function fakeConversationStore(): ConversationStore {
},
async setConversationStatus() {},
async replaceHistory() {},
- async getCompactThreshold() {
+ async getCompactPercent() {
return null;
},
- async setCompactThreshold() {},
+ async setCompactPercent() {},
async forkHistory() {},
async setCompactedFrom() {},
};
@@ -104,6 +104,9 @@ function fakeCredentialStore(): CredentialStore {
resolve() {
return undefined;
},
+ async getModelInfo() {
+ return undefined;
+ },
async listCatalog() {
return [];
},
diff --git a/tasks.md b/tasks.md
index ec90cf2..b2c2b87 100644
--- a/tasks.md
+++ b/tasks.md
@@ -589,8 +589,8 @@ conversation tab. Short-ID prefix resolution (4+ chars → full ID via `GET /con
history with `[system: summary] + recent N` (ID stays the same so messaging
is unaffected). `compactedFrom` chains backward: A → Y → X. Manual via
`POST /conversations/:id/compact`; automatic after turn settles if
- `compactThreshold` (default 350k) is exceeded. `GET/PUT
- /conversations/:id/compact-threshold` for the setting. `conversation.compacted`
+ `compactThreshold` (default 85%) is exceeded. `GET/PUT
+ /conversations/:id/compact-percent` for the setting. `conversation.compacted`
WS broadcast. CLI `dispatch compact <id>`. FE handoff:
`frontend-compaction-handoff.md`.