diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 1732e414..1d25f9d6 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -7,23 +7,160 @@ import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promises'; import { constants } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; -import { getOpenClawResolvedDir } from './paths'; +import { getOpenClawConfigDir, getOpenClawResolvedDir } from './paths'; import * as logger from './logger'; import { proxyAwareFetch } from './proxy-fetch'; -const OPENCLAW_DIR = join(homedir(), '.openclaw'); +const OPENCLAW_DIR = getOpenClawConfigDir(); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); // Channels that are managed as plugins (config goes under plugins.entries, not channels) const PLUGIN_CHANNELS = ['whatsapp']; +const FEISHU_DEFAULT_HISTORY_LIMIT = 20; +const MODEL_AWARE_RESERVE_RATIO = 0.05; +const MODEL_AWARE_SOFT_FLUSH_RATIO = 0.025; + // ── Helpers ────────────────────────────────────────────────────── async function fileExists(p: string): Promise { try { await access(p, constants.F_OK); return true; } catch { return false; } } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function ensureRecord(root: Record, key: string): Record { + const existing = root[key]; + if (isRecord(existing)) { + return existing; + } + const next: Record = {}; + root[key] = next; + return next; +} + +function normalizeNonNegativeInt(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return Math.floor(value); + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + return undefined; +} + +function clampInt(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, Math.floor(value))); +} + +function resolveConfiguredDefaultModelContextWindow(config: OpenClawConfig): number | undefined { + const root = config as Record; + const agents = root.agents; + if (!isRecord(agents)) return undefined; + + const defaults = agents.defaults; + if (!isRecord(defaults)) return undefined; + + const modelRef = defaults.model; + if (typeof modelRef !== 'string') return undefined; + + const slashIndex = modelRef.indexOf('/'); + if (slashIndex <= 0 || slashIndex >= modelRef.length - 1) return undefined; + + const provider = modelRef.slice(0, slashIndex); + const modelId = modelRef.slice(slashIndex + 1); + + const models = root.models; + if (!isRecord(models)) return undefined; + + const providers = models.providers; + if (!isRecord(providers)) return undefined; + + const providerConfig = providers[provider]; + if (!isRecord(providerConfig)) return undefined; + + const configuredModels = providerConfig.models; + if (!Array.isArray(configuredModels)) return undefined; + + for (const model of configuredModels) { + if (!isRecord(model)) continue; + if (model.id !== modelId) continue; + + const contextWindow = model.contextWindow; + if (typeof contextWindow === 'number' && Number.isFinite(contextWindow) && contextWindow > 0) { + return Math.floor(contextWindow); + } + } + + return undefined; +} + +function applyFeishuAutoContextStrategy(config: OpenClawConfig): void { + const root = config as Record; + const agents = ensureRecord(root, 'agents'); + const defaults = ensureRecord(agents, 'defaults'); + + const contextWindow = resolveConfiguredDefaultModelContextWindow(config); + + const compaction = ensureRecord(defaults, 'compaction'); + if (compaction.mode === undefined) { + compaction.mode = 'safeguard'; + } + if (compaction.reserveTokensFloor === undefined) { + compaction.reserveTokensFloor = contextWindow != null + ? clampInt(contextWindow * MODEL_AWARE_RESERVE_RATIO, 2000, 24000) + : 10000; + } + + const memoryFlush = ensureRecord(compaction, 'memoryFlush'); + if (memoryFlush.enabled === undefined) { + memoryFlush.enabled = true; + } + if (memoryFlush.softThresholdTokens === undefined) { + memoryFlush.softThresholdTokens = contextWindow != null + ? clampInt(contextWindow * MODEL_AWARE_SOFT_FLUSH_RATIO, 1000, 8000) + : 4000; + } + + const contextPruning = ensureRecord(defaults, 'contextPruning'); + if (contextPruning.mode === undefined) { + contextPruning.mode = 'cache-ttl'; + } + if (contextPruning.ttl === undefined) { + contextPruning.ttl = '20m'; + } + if (contextPruning.keepLastAssistants === undefined) { + contextPruning.keepLastAssistants = 3; + } + if (contextPruning.minPrunableToolChars === undefined) { + contextPruning.minPrunableToolChars = 12000; + } + + const softTrim = ensureRecord(contextPruning, 'softTrim'); + if (softTrim.maxChars === undefined) { + softTrim.maxChars = 3000; + } + if (softTrim.headChars === undefined) { + softTrim.headChars = 1200; + } + if (softTrim.tailChars === undefined) { + softTrim.tailChars = 1200; + } + + const hardClear = ensureRecord(contextPruning, 'hardClear'); + if (hardClear.enabled === undefined) { + hardClear.enabled = true; + } + if (hardClear.placeholder === undefined) { + hardClear.placeholder = '[Old tool result content cleared]'; + } +} + // ── Types ──────────────────────────────────────────────────────── export interface ChannelConfigData { @@ -198,7 +335,8 @@ export async function saveChannelConfig( } } - // Special handling for Feishu: default to open DM policy with wildcard allowlist + // Special handling for Feishu: default to open DM policy with wildcard allowlist, + // normalize optional history limit, and auto-enable lightweight context protection. if (channelType === 'feishu') { const existingConfig = currentConfig.channels[channelType] || {}; transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingConfig.dmPolicy ?? 'open'; @@ -213,6 +351,13 @@ export async function saveChannelConfig( } transformedConfig.allowFrom = allowFrom; + + const normalizedHistoryLimit = normalizeNonNegativeInt( + transformedConfig.historyLimit ?? existingConfig.historyLimit + ); + transformedConfig.historyLimit = normalizedHistoryLimit ?? FEISHU_DEFAULT_HISTORY_LIMIT; + + applyFeishuAutoContextStrategy(currentConfig); } // Merge with existing config @@ -272,6 +417,16 @@ export async function getChannelFormValues(channelType: string): Promise { // Special handling for WhatsApp credentials if (channelType === 'whatsapp') { try { - const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); + const whatsappDir = join(getOpenClawConfigDir(), 'credentials', 'whatsapp'); if (await fileExists(whatsappDir)) { await rm(whatsappDir, { recursive: true, force: true }); console.log('Deleted WhatsApp credentials directory'); @@ -330,7 +485,7 @@ export async function listConfiguredChannels(): Promise { // Check for WhatsApp credentials directory try { - const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); + const whatsappDir = join(getOpenClawConfigDir(), 'credentials', 'whatsapp'); if (await fileExists(whatsappDir)) { const entries = await readdir(whatsappDir); const hasSession = await (async () => { diff --git a/src/i18n/locales/en/channels.json b/src/i18n/locales/en/channels.json index 6f141a64..6ec5b7b0 100644 --- a/src/i18n/locales/en/channels.json +++ b/src/i18n/locales/en/channels.json @@ -169,6 +169,11 @@ "appSecret": { "label": "App Secret", "placeholder": "Your app secret" + }, + "historyLimit": { + "label": "Context history limit (optional)", + "placeholder": "e.g. 20 (0 disables)", + "description": "Caps how many history messages are injected per reply; lower values reduce token usage." } }, "instructions": [ diff --git a/src/i18n/locales/ja/channels.json b/src/i18n/locales/ja/channels.json index a9245687..cd94b9a3 100644 --- a/src/i18n/locales/ja/channels.json +++ b/src/i18n/locales/ja/channels.json @@ -164,6 +164,11 @@ "appSecret": { "label": "App Secret", "placeholder": "アプリのシークレット" + }, + "historyLimit": { + "label": "コンテキスト履歴上限(任意)", + "placeholder": "例: 20(0で無効)", + "description": "1回の返信で注入する履歴メッセージ数の上限。小さいほどトークン消費を抑えます。" } }, "instructions": [ diff --git a/src/i18n/locales/zh/channels.json b/src/i18n/locales/zh/channels.json index 9bd0c396..bf861357 100644 --- a/src/i18n/locales/zh/channels.json +++ b/src/i18n/locales/zh/channels.json @@ -169,6 +169,11 @@ "appSecret": { "label": "应用密钥 (App Secret)", "placeholder": "您的应用密钥" + }, + "historyLimit": { + "label": "上下文历史条数上限(可选)", + "placeholder": "例如 20(填 0 表示禁用)", + "description": "限制每次回复注入的历史消息数量,值越小越省 token。" } }, "instructions": [ diff --git a/src/types/channel.ts b/src/types/channel.ts index 26afe814..51c52e78 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -303,6 +303,14 @@ export const CHANNEL_META: Record = { required: true, envVar: 'FEISHU_APP_SECRET', }, + { + key: 'historyLimit', + label: 'channels:meta.feishu.fields.historyLimit.label', + type: 'text', + placeholder: 'channels:meta.feishu.fields.historyLimit.placeholder', + description: 'channels:meta.feishu.fields.historyLimit.description', + required: false, + }, ], instructions: [ 'channels:meta.feishu.instructions.0',