@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.
@@ -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
+ }
@@ -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
+ }