@serkanalgur/opencodev2-slim 1.0.0
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/LICENSE +21 -0
- package/README.md +146 -0
- package/package.json +80 -0
- package/src/index.ts +407 -0
- package/src/lib/compress.ts +117 -0
- package/src/lib/config.ts +126 -0
- package/src/lib/prompts.ts +48 -0
- package/src/lib/state.ts +122 -0
- package/src/lib/strategies.ts +49 -0
- package/src/lib/tui/data.ts +90 -0
- package/src/lib/tui/dialogs.tsx +467 -0
- package/src/lib/tui/modals.tsx +12 -0
- package/src/lib/tui.ts +336 -0
- package/src/lib/types.ts +154 -0
- package/src/tui.tsx +61 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { MessageWithParts } from "./types"
|
|
2
|
+
|
|
3
|
+
// ─── Token Counting ─────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
let tokenizer: any = null
|
|
6
|
+
|
|
7
|
+
async function getTokenizer() {
|
|
8
|
+
if (!tokenizer) {
|
|
9
|
+
try {
|
|
10
|
+
const mod = await import("@anthropic-ai/tokenizer")
|
|
11
|
+
tokenizer = mod
|
|
12
|
+
} catch {
|
|
13
|
+
// Fallback: estimate ~4 chars per token
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return tokenizer
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function countTokens(text: string): Promise<number> {
|
|
21
|
+
if (!text) return 0
|
|
22
|
+
|
|
23
|
+
const tok = await getTokenizer()
|
|
24
|
+
if (tok && tok.encode) {
|
|
25
|
+
try {
|
|
26
|
+
return tok.encode(text).length
|
|
27
|
+
} catch {
|
|
28
|
+
// Fallback to estimation
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Rough estimation: ~4 chars per token for English
|
|
33
|
+
return Math.ceil(text.length / 4)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Message Text Extraction ────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
export function getMessageText(msg: MessageWithParts): string {
|
|
39
|
+
const texts: string[] = []
|
|
40
|
+
|
|
41
|
+
for (const part of msg.parts) {
|
|
42
|
+
if (part.type === "text") {
|
|
43
|
+
const textPart = part as any
|
|
44
|
+
if (textPart.text) {
|
|
45
|
+
texts.push(textPart.text)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return texts.join("\n")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getToolResultContent(msg: MessageWithParts): string {
|
|
54
|
+
const results: string[] = []
|
|
55
|
+
|
|
56
|
+
for (const part of msg.parts) {
|
|
57
|
+
if (part.type === "tool") {
|
|
58
|
+
const toolPart = part as any
|
|
59
|
+
if (toolPart.state?.type === "result" && toolPart.state?.output) {
|
|
60
|
+
results.push(String(toolPart.state.output).slice(0, 500))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return results.join("\n")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getToolName(msg: MessageWithParts): string | null {
|
|
69
|
+
for (const part of msg.parts) {
|
|
70
|
+
if (part.type === "tool") {
|
|
71
|
+
const toolPart = part as any
|
|
72
|
+
return toolPart.tool || null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Compression Decision ───────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export function shouldCompress(
|
|
81
|
+
currentTokens: number,
|
|
82
|
+
maxTokens: number,
|
|
83
|
+
minTokens: number,
|
|
84
|
+
lastCompressionTime: number,
|
|
85
|
+
nudgeFrequency: number,
|
|
86
|
+
messageCount: number,
|
|
87
|
+
): { compress: boolean; reason: string } {
|
|
88
|
+
const usagePercent = (currentTokens / maxTokens) * 100
|
|
89
|
+
const timeSinceLastCompression = Date.now() - lastCompressionTime
|
|
90
|
+
const minutesSinceLast = timeSinceLastCompression / (1000 * 60)
|
|
91
|
+
|
|
92
|
+
// Hard limit: must compress
|
|
93
|
+
if (currentTokens >= maxTokens) {
|
|
94
|
+
return { compress: true, reason: "Context limit reached" }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Soft limit: recommend compression
|
|
98
|
+
if (currentTokens >= minTokens) {
|
|
99
|
+
// Check if enough time has passed since last compression
|
|
100
|
+
if (minutesSinceLast >= nudgeFrequency) {
|
|
101
|
+
return {
|
|
102
|
+
compress: true,
|
|
103
|
+
reason: `Context at ${usagePercent.toFixed(0)}% (${currentTokens}/${maxTokens} tokens)`,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Very high usage: always recommend
|
|
109
|
+
if (usagePercent > 90) {
|
|
110
|
+
return {
|
|
111
|
+
compress: true,
|
|
112
|
+
reason: `Context critically high at ${usagePercent.toFixed(0)}%`,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { compress: false, reason: "" }
|
|
117
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { homedir } from "os"
|
|
4
|
+
import { parse } from "jsonc-parser/lib/esm/main.js"
|
|
5
|
+
import type { SlimConfig } from "./types"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CONFIG: SlimConfig = {
|
|
8
|
+
enabled: true,
|
|
9
|
+
debug: false,
|
|
10
|
+
compress: {
|
|
11
|
+
enabled: true,
|
|
12
|
+
permission: "allow",
|
|
13
|
+
maxContextLimit: "80%",
|
|
14
|
+
minContextLimit: "40%",
|
|
15
|
+
nudgeFrequency: 5,
|
|
16
|
+
protectUserMessages: false,
|
|
17
|
+
protectedTools: ["task", "skill", "todowrite", "todoread"],
|
|
18
|
+
},
|
|
19
|
+
strategies: {
|
|
20
|
+
deduplication: {
|
|
21
|
+
enabled: true,
|
|
22
|
+
protectedTools: [],
|
|
23
|
+
},
|
|
24
|
+
purgeErrors: {
|
|
25
|
+
enabled: true,
|
|
26
|
+
turns: 4,
|
|
27
|
+
protectedTools: [],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
adaptive: {
|
|
31
|
+
enabled: true,
|
|
32
|
+
learningRate: 0.1,
|
|
33
|
+
minCompressionRatio: 0.3,
|
|
34
|
+
},
|
|
35
|
+
costAware: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
cacheBoostFactor: 0.5,
|
|
38
|
+
},
|
|
39
|
+
persistence: {
|
|
40
|
+
enabled: true,
|
|
41
|
+
directory: join(homedir(), ".config", "opencode", "slim"),
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deepMerge(base: SlimConfig, override: Partial<SlimConfig>): SlimConfig {
|
|
46
|
+
return {
|
|
47
|
+
...base,
|
|
48
|
+
...override,
|
|
49
|
+
compress: { ...base.compress, ...override.compress },
|
|
50
|
+
strategies: {
|
|
51
|
+
deduplication: { ...base.strategies.deduplication, ...override.strategies?.deduplication },
|
|
52
|
+
purgeErrors: { ...base.strategies.purgeErrors, ...override.strategies?.purgeErrors },
|
|
53
|
+
},
|
|
54
|
+
adaptive: { ...base.adaptive, ...override.adaptive },
|
|
55
|
+
costAware: { ...base.costAware, ...override.costAware },
|
|
56
|
+
persistence: { ...base.persistence, ...override.persistence },
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadConfig(): SlimConfig {
|
|
61
|
+
let config = { ...DEFAULT_CONFIG }
|
|
62
|
+
|
|
63
|
+
const globalDir = process.env.XDG_CONFIG_HOME
|
|
64
|
+
? join(process.env.XDG_CONFIG_HOME, "opencode")
|
|
65
|
+
: join(homedir(), ".config", "opencode")
|
|
66
|
+
|
|
67
|
+
const globalPath = join(globalDir, "slim.jsonc")
|
|
68
|
+
const globalPathJson = join(globalDir, "slim.json")
|
|
69
|
+
|
|
70
|
+
const configPath = existsSync(globalPath)
|
|
71
|
+
? globalPath
|
|
72
|
+
: existsSync(globalPathJson)
|
|
73
|
+
? globalPathJson
|
|
74
|
+
: null
|
|
75
|
+
|
|
76
|
+
if (configPath) {
|
|
77
|
+
try {
|
|
78
|
+
const content = readFileSync(configPath, "utf-8")
|
|
79
|
+
const parsed = parse(content)
|
|
80
|
+
if (parsed) {
|
|
81
|
+
config = deepMerge(config, parsed)
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Use defaults
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return config
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createDefaultConfig(): void {
|
|
92
|
+
const globalDir = process.env.XDG_CONFIG_HOME
|
|
93
|
+
? join(process.env.XDG_CONFIG_HOME, "opencode")
|
|
94
|
+
: join(homedir(), ".config", "opencode")
|
|
95
|
+
|
|
96
|
+
const configPath = join(globalDir, "slim.jsonc")
|
|
97
|
+
|
|
98
|
+
if (!existsSync(configPath)) {
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(globalDir, { recursive: true })
|
|
101
|
+
writeFileSync(
|
|
102
|
+
configPath,
|
|
103
|
+
`{
|
|
104
|
+
// Slim Configuration
|
|
105
|
+
"enabled": true,
|
|
106
|
+
"compress": {
|
|
107
|
+
"enabled": true,
|
|
108
|
+
"permission": "allow",
|
|
109
|
+
"maxContextLimit": "80%",
|
|
110
|
+
"minContextLimit": "40%",
|
|
111
|
+
"nudgeFrequency": 5
|
|
112
|
+
}
|
|
113
|
+
}`,
|
|
114
|
+
"utf-8",
|
|
115
|
+
)
|
|
116
|
+
} catch {
|
|
117
|
+
// Ignore errors
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resolveTokenLimit(value: number | string, contextLimit: number): number {
|
|
123
|
+
if (typeof value === "number") return value
|
|
124
|
+
const percent = parseFloat(value.replace("%", "")) / 100
|
|
125
|
+
return Math.floor(contextLimit * percent)
|
|
126
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function getSystemPrompt(): string {
|
|
2
|
+
return `
|
|
3
|
+
## Context Management (Slim)
|
|
4
|
+
|
|
5
|
+
You have access to context management tools. Use them wisely:
|
|
6
|
+
|
|
7
|
+
### compress tool
|
|
8
|
+
Use \`compress\` to reduce context size when it gets large. It supports:
|
|
9
|
+
- Auto mode: Intelligently selects what to compress
|
|
10
|
+
- Range mode: Compress specific message range
|
|
11
|
+
- Topic mode: Compress messages matching a topic
|
|
12
|
+
|
|
13
|
+
Example: \`compress({ focus: "old exploration" })\`
|
|
14
|
+
|
|
15
|
+
### panel tool
|
|
16
|
+
Use \`panel\` to view current context usage and statistics.
|
|
17
|
+
|
|
18
|
+
### When to compress
|
|
19
|
+
- Context usage > 80%: Consider compressing
|
|
20
|
+
- Context usage > 90%: Compress immediately
|
|
21
|
+
- After completing a major task: Compress related messages
|
|
22
|
+
- Before starting a new task: Clean up old context
|
|
23
|
+
`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getCompressToolDescription(): string {
|
|
27
|
+
return `Compress context to free up tokens. Supports multiple modes:
|
|
28
|
+
|
|
29
|
+
- Auto mode (default): Intelligently selects what to compress based on age and relevance
|
|
30
|
+
- Range mode: Compress specific message range (start/end indices)
|
|
31
|
+
- Topic mode: Compress messages matching a topic keyword
|
|
32
|
+
|
|
33
|
+
The compression creates a summary preserving key information while removing redundancy.`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getNudgeMessage(reason: string, currentTokens: number, maxTokens: number): string {
|
|
37
|
+
const percent = Math.round((currentTokens / maxTokens) * 100)
|
|
38
|
+
return `💡 **Context Optimization Available**
|
|
39
|
+
|
|
40
|
+
${reason} (${percent}% used)
|
|
41
|
+
|
|
42
|
+
Consider using the \`compress\` tool to free up context space:
|
|
43
|
+
\`\`\`
|
|
44
|
+
compress({ focus: "describe what to compress" })
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
This will create a summary of older messages, preserving key information while freeing tokens.`
|
|
48
|
+
}
|
package/src/lib/state.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import type { SessionState, CompressionRecord, ToolCallInfo } from "./types"
|
|
4
|
+
|
|
5
|
+
const DEFAULT_STATE: SessionState = {
|
|
6
|
+
sessionId: "",
|
|
7
|
+
modelContextLimit: 200000,
|
|
8
|
+
currentTokenCount: 0,
|
|
9
|
+
compressionCount: 0,
|
|
10
|
+
lastCompressionTime: 0,
|
|
11
|
+
manualMode: false,
|
|
12
|
+
compressPermission: null,
|
|
13
|
+
compressionHistory: [],
|
|
14
|
+
averageCompressionRatio: 0,
|
|
15
|
+
toolCalls: new Map(),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadSessionState(sessionId: string, persistenceDir: string): SessionState {
|
|
19
|
+
const statePath = join(persistenceDir, `${sessionId}.json`)
|
|
20
|
+
|
|
21
|
+
if (existsSync(statePath)) {
|
|
22
|
+
try {
|
|
23
|
+
const data = readFileSync(statePath, "utf-8")
|
|
24
|
+
const parsed = JSON.parse(data)
|
|
25
|
+
// Convert toolCalls back to Map
|
|
26
|
+
if (parsed.toolCalls && Array.isArray(parsed.toolCalls)) {
|
|
27
|
+
parsed.toolCalls = new Map(parsed.toolCalls)
|
|
28
|
+
}
|
|
29
|
+
return { ...DEFAULT_STATE, ...parsed, sessionId }
|
|
30
|
+
} catch {
|
|
31
|
+
// Use default
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { ...DEFAULT_STATE, sessionId }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveSessionState(state: SessionState, persistenceDir: string): void {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(persistenceDir)) {
|
|
41
|
+
mkdirSync(persistenceDir, { recursive: true })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const statePath = join(persistenceDir, `${state.sessionId}.json`)
|
|
45
|
+
// Convert Map to array for serialization
|
|
46
|
+
const serializable = {
|
|
47
|
+
...state,
|
|
48
|
+
toolCalls: Array.from(state.toolCalls.entries()),
|
|
49
|
+
}
|
|
50
|
+
writeFileSync(statePath, JSON.stringify(serializable, null, 2), "utf-8")
|
|
51
|
+
} catch {
|
|
52
|
+
// Ignore errors
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function addCompressionRecord(
|
|
57
|
+
state: SessionState,
|
|
58
|
+
record: CompressionRecord,
|
|
59
|
+
learningRate: number,
|
|
60
|
+
): void {
|
|
61
|
+
state.compressionHistory.push(record)
|
|
62
|
+
state.compressionCount++
|
|
63
|
+
state.lastCompressionTime = record.timestamp
|
|
64
|
+
|
|
65
|
+
// Update average ratio with exponential moving average
|
|
66
|
+
if (state.compressionHistory.length === 1) {
|
|
67
|
+
state.averageCompressionRatio = record.ratio
|
|
68
|
+
} else {
|
|
69
|
+
state.averageCompressionRatio =
|
|
70
|
+
state.averageCompressionRatio * (1 - learningRate) + record.ratio * learningRate
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function trackToolCall(
|
|
75
|
+
state: SessionState,
|
|
76
|
+
tool: string,
|
|
77
|
+
args: unknown,
|
|
78
|
+
turn: number,
|
|
79
|
+
): string {
|
|
80
|
+
const id = `${tool}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
81
|
+
state.toolCalls.set(id, {
|
|
82
|
+
tool,
|
|
83
|
+
args,
|
|
84
|
+
timestamp: Date.now(),
|
|
85
|
+
turn,
|
|
86
|
+
})
|
|
87
|
+
return id
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getDuplicateToolCalls(state: SessionState): string[] {
|
|
91
|
+
const seen = new Map<string, string[]>()
|
|
92
|
+
const duplicates: string[] = []
|
|
93
|
+
|
|
94
|
+
for (const [id, info] of state.toolCalls.entries()) {
|
|
95
|
+
const key = `${info.tool}:${JSON.stringify(info.args)}`
|
|
96
|
+
const existing = seen.get(key) || []
|
|
97
|
+
existing.push(id)
|
|
98
|
+
seen.set(key, existing)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const [, ids] of seen.entries()) {
|
|
102
|
+
if (ids.length > 1) {
|
|
103
|
+
// Keep first, mark rest as duplicates
|
|
104
|
+
duplicates.push(...ids.slice(1))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return duplicates
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getErroredToolCalls(state: SessionState, turnsThreshold: number): string[] {
|
|
112
|
+
const errored: string[] = []
|
|
113
|
+
const currentTurn = Math.max(...Array.from(state.toolCalls.values()).map((t) => t.turn), 0)
|
|
114
|
+
|
|
115
|
+
for (const [id, info] of state.toolCalls.entries()) {
|
|
116
|
+
if (info.error && currentTurn - info.turn >= turnsThreshold) {
|
|
117
|
+
errored.push(id)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return errored
|
|
122
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MessageWithParts, SlimConfig } from "./types"
|
|
2
|
+
import { getToolName, getToolResultContent, getMessageText } from "./compress"
|
|
3
|
+
import { getDuplicateToolCalls, getErroredToolCalls } from "./state"
|
|
4
|
+
import type { SessionState } from "./types"
|
|
5
|
+
|
|
6
|
+
export function pruneMessages(
|
|
7
|
+
messages: MessageWithParts[],
|
|
8
|
+
config: SlimConfig,
|
|
9
|
+
_messageCount: number,
|
|
10
|
+
): MessageWithParts[] {
|
|
11
|
+
let pruned = [...messages]
|
|
12
|
+
|
|
13
|
+
// Apply deduplication
|
|
14
|
+
if (config.strategies.deduplication.enabled) {
|
|
15
|
+
pruned = applyDeduplication(pruned, config.strategies.deduplication.protectedTools)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return pruned
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function applyDeduplication(messages: MessageWithParts[], protectedTools: string[]): MessageWithParts[] {
|
|
22
|
+
const seen = new Map<string, number>()
|
|
23
|
+
const toRemove = new Set<number>()
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < messages.length; i++) {
|
|
26
|
+
const msg = messages[i]
|
|
27
|
+
const toolName = getToolName(msg)
|
|
28
|
+
|
|
29
|
+
// Skip protected tools
|
|
30
|
+
if (toolName && protectedTools.includes(toolName)) {
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Create a fingerprint of the message
|
|
35
|
+
const text = getMessageText(msg)
|
|
36
|
+
const toolContent = getToolResultContent(msg)
|
|
37
|
+
const fingerprint = `${msg.info.role}:${text.slice(0, 200)}:${toolContent.slice(0, 200)}`
|
|
38
|
+
|
|
39
|
+
const existingIndex = seen.get(fingerprint)
|
|
40
|
+
if (existingIndex !== undefined) {
|
|
41
|
+
// Mark later duplicate for removal
|
|
42
|
+
toRemove.add(i)
|
|
43
|
+
} else {
|
|
44
|
+
seen.set(fingerprint, i)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return messages.filter((_, i) => !toRemove.has(i))
|
|
49
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import type { SlimConfig } from "../types"
|
|
3
|
+
import { readFileSync, existsSync } from "fs"
|
|
4
|
+
import { join } from "path"
|
|
5
|
+
import { homedir } from "os"
|
|
6
|
+
import { parse } from "jsonc-parser/lib/esm/main.js"
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG: SlimConfig = {
|
|
9
|
+
enabled: true,
|
|
10
|
+
debug: false,
|
|
11
|
+
compress: {
|
|
12
|
+
enabled: true,
|
|
13
|
+
permission: "allow",
|
|
14
|
+
maxContextLimit: "80%",
|
|
15
|
+
minContextLimit: "40%",
|
|
16
|
+
nudgeFrequency: 5,
|
|
17
|
+
protectUserMessages: false,
|
|
18
|
+
protectedTools: ["task", "skill", "todowrite", "todoread"],
|
|
19
|
+
},
|
|
20
|
+
strategies: {
|
|
21
|
+
deduplication: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
protectedTools: [],
|
|
24
|
+
},
|
|
25
|
+
purgeErrors: {
|
|
26
|
+
enabled: true,
|
|
27
|
+
turns: 4,
|
|
28
|
+
protectedTools: [],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
adaptive: {
|
|
32
|
+
enabled: true,
|
|
33
|
+
learningRate: 0.1,
|
|
34
|
+
minCompressionRatio: 0.3,
|
|
35
|
+
},
|
|
36
|
+
costAware: {
|
|
37
|
+
enabled: true,
|
|
38
|
+
cacheBoostFactor: 0.5,
|
|
39
|
+
},
|
|
40
|
+
persistence: {
|
|
41
|
+
enabled: true,
|
|
42
|
+
directory: join(homedir(), ".config", "opencode", "slim"),
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deepMerge(base: SlimConfig, override: Partial<SlimConfig>): SlimConfig {
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
...override,
|
|
50
|
+
compress: { ...base.compress, ...override.compress },
|
|
51
|
+
strategies: {
|
|
52
|
+
deduplication: { ...base.strategies.deduplication, ...override.strategies?.deduplication },
|
|
53
|
+
purgeErrors: { ...base.strategies.purgeErrors, ...override.strategies?.purgeErrors },
|
|
54
|
+
},
|
|
55
|
+
adaptive: { ...base.adaptive, ...override.adaptive },
|
|
56
|
+
costAware: { ...base.costAware, ...override.costAware },
|
|
57
|
+
persistence: { ...base.persistence, ...override.persistence },
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadConfig(_api: TuiPluginApi): SlimConfig {
|
|
62
|
+
let config = { ...DEFAULT_CONFIG }
|
|
63
|
+
|
|
64
|
+
const globalDir = process.env.XDG_CONFIG_HOME
|
|
65
|
+
? join(process.env.XDG_CONFIG_HOME, "opencode")
|
|
66
|
+
: join(homedir(), ".config", "opencode")
|
|
67
|
+
|
|
68
|
+
const globalPath = join(globalDir, "slim.jsonc")
|
|
69
|
+
const globalPathJson = join(globalDir, "slim.json")
|
|
70
|
+
|
|
71
|
+
const configPath = existsSync(globalPath)
|
|
72
|
+
? globalPath
|
|
73
|
+
: existsSync(globalPathJson)
|
|
74
|
+
? globalPathJson
|
|
75
|
+
: null
|
|
76
|
+
|
|
77
|
+
if (configPath) {
|
|
78
|
+
try {
|
|
79
|
+
const content = readFileSync(configPath, "utf-8")
|
|
80
|
+
const parsed = parse(content)
|
|
81
|
+
if (parsed) {
|
|
82
|
+
config = deepMerge(config, parsed)
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Use defaults
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return config
|
|
90
|
+
}
|