opencodekit 0.2.7 → 0.3.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/README.md +13 -5
- package/dist/index.js +1 -1
- package/dist/template/.opencode/AGENTS.md +47 -19
- package/dist/template/.opencode/README.md +72 -6
- package/dist/template/.opencode/agent/build.md +6 -2
- package/dist/template/.opencode/agent/rush.md +11 -7
- package/dist/template/.opencode/command/brainstorm.md +5 -3
- package/dist/template/.opencode/command/design.md +6 -4
- package/dist/template/.opencode/dcp.jsonc +41 -41
- package/dist/template/.opencode/opencode.json +443 -431
- package/dist/template/.opencode/package.json +1 -1
- package/dist/template/.opencode/plugin/compactor.ts +183 -0
- package/dist/template/.opencode/plugin/enforcer.ts +140 -0
- package/dist/template/.opencode/plugin/notification.ts +55 -13
- package/dist/template/.opencode/plugin/truncator.ts +190 -0
- package/dist/template/.opencode/tool/ast-grep.ts +245 -0
- package/package.json +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Compactor Plugin
|
|
3
|
+
* Warns at token thresholds before hitting limits - graceful context management
|
|
4
|
+
*
|
|
5
|
+
* Inspired by oh-my-opencode's context-window-monitor and anthropic-auto-compact
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
9
|
+
import { exec } from "child_process";
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
|
|
12
|
+
// Notification helpers
|
|
13
|
+
function isWSL(): boolean {
|
|
14
|
+
try {
|
|
15
|
+
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
|
16
|
+
return release.includes("microsoft") || release.includes("wsl");
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeShell(str: string): string {
|
|
23
|
+
return str.replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function notify(title: string, message: string): void {
|
|
27
|
+
const platform = process.platform;
|
|
28
|
+
const safeTitle = title ? String(title) : "Notification";
|
|
29
|
+
const safeMessage = message ? String(message) : "";
|
|
30
|
+
const escapedTitle = escapeShell(safeTitle);
|
|
31
|
+
const escapedMessage = escapeShell(safeMessage);
|
|
32
|
+
|
|
33
|
+
let command: string;
|
|
34
|
+
|
|
35
|
+
if (platform === "darwin") {
|
|
36
|
+
command = `osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`;
|
|
37
|
+
} else if (platform === "linux") {
|
|
38
|
+
command = `notify-send "${escapedTitle}" "${escapedMessage}"`;
|
|
39
|
+
if (isWSL()) {
|
|
40
|
+
command = `(${command}) 2>&1 || echo "WSL notification failed"`;
|
|
41
|
+
}
|
|
42
|
+
} else if (platform === "win32") {
|
|
43
|
+
command = `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('${escapedMessage}', '${escapedTitle}')"`;
|
|
44
|
+
} else {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
exec(command, () => {});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TokenStats {
|
|
52
|
+
used: number;
|
|
53
|
+
limit: number;
|
|
54
|
+
percentage?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const THRESHOLDS = {
|
|
58
|
+
MODERATE: 70,
|
|
59
|
+
URGENT: 85,
|
|
60
|
+
CRITICAL: 95,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
64
|
+
|
|
65
|
+
export const CompactorPlugin: Plugin = async ({ client }) => {
|
|
66
|
+
const warnedSessions = new Map<string, number>();
|
|
67
|
+
|
|
68
|
+
client.app
|
|
69
|
+
.log({
|
|
70
|
+
body: {
|
|
71
|
+
service: "compactor-plugin",
|
|
72
|
+
level: "info",
|
|
73
|
+
message:
|
|
74
|
+
"📊 Compactor Plugin loaded - context window monitoring active",
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {});
|
|
78
|
+
|
|
79
|
+
function getWarningLevel(
|
|
80
|
+
percentage: number,
|
|
81
|
+
): { level: LogLevel; emoji: string; action: string } | null {
|
|
82
|
+
if (percentage >= THRESHOLDS.CRITICAL) {
|
|
83
|
+
return {
|
|
84
|
+
level: "error",
|
|
85
|
+
emoji: "🚨",
|
|
86
|
+
action:
|
|
87
|
+
"CRITICAL: Prune immediately or start new session. Context nearly exhausted.",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (percentage >= THRESHOLDS.URGENT) {
|
|
91
|
+
return {
|
|
92
|
+
level: "warn",
|
|
93
|
+
emoji: "⚠️",
|
|
94
|
+
action:
|
|
95
|
+
"Consider pruning completed tool outputs or summarizing findings.",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (percentage >= THRESHOLDS.MODERATE) {
|
|
99
|
+
return {
|
|
100
|
+
level: "info",
|
|
101
|
+
emoji: "📈",
|
|
102
|
+
action:
|
|
103
|
+
"Context at 70%. Still plenty of room - no rush, but consider consolidating.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
event: async ({ event }) => {
|
|
111
|
+
const props = event.properties as Record<string, unknown>;
|
|
112
|
+
|
|
113
|
+
if (event.type === "session.updated") {
|
|
114
|
+
const info = props?.info as Record<string, unknown> | undefined;
|
|
115
|
+
const tokenStats = (info?.tokens || props?.tokens) as
|
|
116
|
+
| TokenStats
|
|
117
|
+
| undefined;
|
|
118
|
+
const sessionId = (info?.id || props?.sessionID) as string | undefined;
|
|
119
|
+
|
|
120
|
+
if (!sessionId || !tokenStats?.used || !tokenStats?.limit) return;
|
|
121
|
+
|
|
122
|
+
const pct =
|
|
123
|
+
tokenStats.percentage ||
|
|
124
|
+
Math.round((tokenStats.used / tokenStats.limit) * 100);
|
|
125
|
+
const lastWarned = warnedSessions.get(sessionId) || 0;
|
|
126
|
+
|
|
127
|
+
const warning = getWarningLevel(pct);
|
|
128
|
+
if (!warning) return;
|
|
129
|
+
|
|
130
|
+
let currentThreshold = 0;
|
|
131
|
+
if (pct >= THRESHOLDS.CRITICAL) currentThreshold = THRESHOLDS.CRITICAL;
|
|
132
|
+
else if (pct >= THRESHOLDS.URGENT) currentThreshold = THRESHOLDS.URGENT;
|
|
133
|
+
else if (pct >= THRESHOLDS.MODERATE)
|
|
134
|
+
currentThreshold = THRESHOLDS.MODERATE;
|
|
135
|
+
|
|
136
|
+
if (lastWarned >= currentThreshold) return;
|
|
137
|
+
|
|
138
|
+
warnedSessions.set(sessionId, currentThreshold);
|
|
139
|
+
|
|
140
|
+
const message = `${warning.emoji} Context: ${pct}% (${tokenStats.used.toLocaleString()}/${tokenStats.limit.toLocaleString()} tokens). ${warning.action}`;
|
|
141
|
+
|
|
142
|
+
client.app
|
|
143
|
+
.log({
|
|
144
|
+
body: {
|
|
145
|
+
service: "compactor-plugin",
|
|
146
|
+
level: warning.level,
|
|
147
|
+
message,
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
.catch(() => {});
|
|
151
|
+
|
|
152
|
+
if (pct >= THRESHOLDS.URGENT) {
|
|
153
|
+
notify(`Context ${pct}%`, warning.action);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (event.type === "session.compacted") {
|
|
158
|
+
const sessionId = props?.sessionID as string | undefined;
|
|
159
|
+
|
|
160
|
+
if (sessionId) {
|
|
161
|
+
client.app
|
|
162
|
+
.log({
|
|
163
|
+
body: {
|
|
164
|
+
service: "compactor-plugin",
|
|
165
|
+
level: "info",
|
|
166
|
+
message: "♻️ Session compacted - context freed",
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
.catch(() => {});
|
|
170
|
+
|
|
171
|
+
warnedSessions.set(sessionId, 0);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (event.type === "session.deleted") {
|
|
176
|
+
const sessionId = props?.sessionID as string | undefined;
|
|
177
|
+
if (sessionId) {
|
|
178
|
+
warnedSessions.delete(sessionId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Enforcer Plugin
|
|
3
|
+
* Warns when session goes idle with incomplete TODOs - prevents abandoned work
|
|
4
|
+
*
|
|
5
|
+
* Inspired by oh-my-opencode's todo-continuation-enforcer
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
9
|
+
import { exec } from "child_process";
|
|
10
|
+
import { readFileSync } from "fs";
|
|
11
|
+
|
|
12
|
+
// Notification helpers
|
|
13
|
+
function isWSL(): boolean {
|
|
14
|
+
try {
|
|
15
|
+
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
|
16
|
+
return release.includes("microsoft") || release.includes("wsl");
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeShell(str: string): string {
|
|
23
|
+
return str.replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function notify(title: string, message: string): void {
|
|
27
|
+
const platform = process.platform;
|
|
28
|
+
const safeTitle = title ? String(title) : "Notification";
|
|
29
|
+
const safeMessage = message ? String(message) : "";
|
|
30
|
+
const escapedTitle = escapeShell(safeTitle);
|
|
31
|
+
const escapedMessage = escapeShell(safeMessage);
|
|
32
|
+
|
|
33
|
+
let command: string;
|
|
34
|
+
|
|
35
|
+
if (platform === "darwin") {
|
|
36
|
+
command = `osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`;
|
|
37
|
+
} else if (platform === "linux") {
|
|
38
|
+
command = `notify-send "${escapedTitle}" "${escapedMessage}"`;
|
|
39
|
+
if (isWSL()) {
|
|
40
|
+
command = `(${command}) 2>&1 || echo "WSL notification failed"`;
|
|
41
|
+
}
|
|
42
|
+
} else if (platform === "win32") {
|
|
43
|
+
command = `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('${escapedMessage}', '${escapedTitle}')"`;
|
|
44
|
+
} else {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
exec(command, () => {});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TodoItem {
|
|
52
|
+
status: string;
|
|
53
|
+
content?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const EnforcerPlugin: Plugin = async ({ client }) => {
|
|
57
|
+
const sessionTodos = new Map<string, { pending: number; total: number }>();
|
|
58
|
+
|
|
59
|
+
client.app
|
|
60
|
+
.log({
|
|
61
|
+
body: {
|
|
62
|
+
service: "enforcer-plugin",
|
|
63
|
+
level: "info",
|
|
64
|
+
message:
|
|
65
|
+
"🛡️ Enforcer Plugin loaded - TODO completion enforcement active",
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
.catch(() => {});
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
event: async ({ event }) => {
|
|
72
|
+
const props = event.properties as Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
if (event.type === "todo.updated") {
|
|
75
|
+
const sessionId = props?.sessionID as string | undefined;
|
|
76
|
+
const todos = props?.todos as TodoItem[] | undefined;
|
|
77
|
+
|
|
78
|
+
if (sessionId && todos) {
|
|
79
|
+
const pending = todos.filter(
|
|
80
|
+
(t) => t.status === "pending" || t.status === "in_progress",
|
|
81
|
+
).length;
|
|
82
|
+
const total = todos.length;
|
|
83
|
+
sessionTodos.set(sessionId, { pending, total });
|
|
84
|
+
|
|
85
|
+
if (pending > 0) {
|
|
86
|
+
client.app
|
|
87
|
+
.log({
|
|
88
|
+
body: {
|
|
89
|
+
service: "enforcer-plugin",
|
|
90
|
+
level: "debug",
|
|
91
|
+
message: `📋 Session ${sessionId.slice(0, 8)}: ${pending}/${total} TODOs remaining`,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
.catch(() => {});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (event.type === "session.idle") {
|
|
100
|
+
const sessionId = props?.sessionID as string | undefined;
|
|
101
|
+
if (!sessionId) return;
|
|
102
|
+
|
|
103
|
+
const todoState = sessionTodos.get(sessionId);
|
|
104
|
+
|
|
105
|
+
if (todoState && todoState.pending > 0) {
|
|
106
|
+
const message = `${todoState.pending} incomplete TODO(s) remaining`;
|
|
107
|
+
|
|
108
|
+
client.app
|
|
109
|
+
.log({
|
|
110
|
+
body: {
|
|
111
|
+
service: "enforcer-plugin",
|
|
112
|
+
level: "warn",
|
|
113
|
+
message: `⚠️ Session idle with ${message}. Consider completing before stopping.`,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
.catch(() => {});
|
|
117
|
+
|
|
118
|
+
notify("Incomplete TODOs", message);
|
|
119
|
+
} else if (todoState && todoState.total > 0) {
|
|
120
|
+
client.app
|
|
121
|
+
.log({
|
|
122
|
+
body: {
|
|
123
|
+
service: "enforcer-plugin",
|
|
124
|
+
level: "info",
|
|
125
|
+
message: `✅ All ${todoState.total} TODOs completed`,
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (event.type === "session.deleted") {
|
|
133
|
+
const sessionId = props?.sessionID as string | undefined;
|
|
134
|
+
if (sessionId) {
|
|
135
|
+
sessionTodos.delete(sessionId);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
};
|
|
@@ -1,12 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenCode Notification Plugin
|
|
3
3
|
* Sends native notifications when sessions complete
|
|
4
|
+
*
|
|
5
|
+
* Cross-platform support:
|
|
6
|
+
* - macOS: Built-in (osascript)
|
|
7
|
+
* - Linux: notify-send (install: sudo apt install libnotify-bin)
|
|
8
|
+
* - WSL: notify-send + dunst (requires setup)
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
12
|
+
import { exec } from "child_process";
|
|
13
|
+
import { readFileSync } from "fs";
|
|
14
|
+
|
|
15
|
+
// Notification helpers
|
|
16
|
+
function isWSL(): boolean {
|
|
17
|
+
try {
|
|
18
|
+
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
|
19
|
+
return release.includes("microsoft") || release.includes("wsl");
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function escapeShell(str: string): string {
|
|
26
|
+
return str.replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function notify(title: string, message: string): void {
|
|
30
|
+
const platform = process.platform;
|
|
31
|
+
const safeTitle = title ? String(title) : "Notification";
|
|
32
|
+
const safeMessage = message ? String(message) : "";
|
|
33
|
+
const escapedTitle = escapeShell(safeTitle);
|
|
34
|
+
const escapedMessage = escapeShell(safeMessage);
|
|
35
|
+
|
|
36
|
+
let command: string;
|
|
37
|
+
|
|
38
|
+
if (platform === "darwin") {
|
|
39
|
+
command = `osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`;
|
|
40
|
+
} else if (platform === "linux") {
|
|
41
|
+
command = `notify-send "${escapedTitle}" "${escapedMessage}"`;
|
|
42
|
+
if (isWSL()) {
|
|
43
|
+
command = `(${command}) 2>&1 || echo "WSL notification failed. Ensure dunst is running: dunst >/dev/null 2>&1 &"`;
|
|
44
|
+
}
|
|
45
|
+
} else if (platform === "win32") {
|
|
46
|
+
command = `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('${escapedMessage}', '${escapedTitle}')"`;
|
|
47
|
+
} else {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
exec(command, () => {});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const NotificationPlugin: Plugin = async ({ client }) => {
|
|
55
|
+
const notifiedSessions = new Set<string>();
|
|
7
56
|
|
|
8
|
-
export const NotificationPlugin: Plugin = async ({ client, $ }) => {
|
|
9
|
-
// Log plugin initialization
|
|
10
57
|
client.app
|
|
11
58
|
.log({
|
|
12
59
|
body: {
|
|
@@ -19,16 +66,18 @@ export const NotificationPlugin: Plugin = async ({ client, $ }) => {
|
|
|
19
66
|
|
|
20
67
|
return {
|
|
21
68
|
event: async ({ event }) => {
|
|
22
|
-
// Send notification on session completion
|
|
23
69
|
if (event.type === "session.idle") {
|
|
24
70
|
const sessionId = event.properties?.sessionID;
|
|
25
71
|
|
|
26
|
-
|
|
72
|
+
if (!sessionId || notifiedSessions.has(sessionId)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
notifiedSessions.add(sessionId);
|
|
76
|
+
|
|
27
77
|
setTimeout(async () => {
|
|
28
78
|
try {
|
|
29
79
|
let summary = "Session completed";
|
|
30
80
|
|
|
31
|
-
// Try to fetch session summary
|
|
32
81
|
const messagesResponse = await client.session.messages({
|
|
33
82
|
path: { id: sessionId },
|
|
34
83
|
});
|
|
@@ -38,7 +87,6 @@ export const NotificationPlugin: Plugin = async ({ client, $ }) => {
|
|
|
38
87
|
.filter((m) => m.info.role === "user")
|
|
39
88
|
.pop();
|
|
40
89
|
|
|
41
|
-
// Type guard for summary object
|
|
42
90
|
const messageSummary = lastUserMessage?.info?.summary;
|
|
43
91
|
if (
|
|
44
92
|
messageSummary &&
|
|
@@ -53,13 +101,7 @@ export const NotificationPlugin: Plugin = async ({ client, $ }) => {
|
|
|
53
101
|
}
|
|
54
102
|
}
|
|
55
103
|
|
|
56
|
-
|
|
57
|
-
if (process.platform === "darwin") {
|
|
58
|
-
const script = `display notification "${summary.replace(/"/g, '\\"')}" with title "OpenCode"`;
|
|
59
|
-
await $`osascript -e ${script}`;
|
|
60
|
-
} else {
|
|
61
|
-
await $`notify-send "OpenCode" ${summary}`;
|
|
62
|
-
}
|
|
104
|
+
notify("OpenCode", summary);
|
|
63
105
|
|
|
64
106
|
client.app
|
|
65
107
|
.log({
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Truncator Plugin
|
|
3
|
+
* Dynamic output truncation utilities - smarter than fixed limits
|
|
4
|
+
*
|
|
5
|
+
* Inspired by oh-my-opencode's grep-output-truncator and tool-output-truncator
|
|
6
|
+
*
|
|
7
|
+
* NOTE: OpenCode doesn't currently expose tool.execute.before/after events for
|
|
8
|
+
* output modification. This plugin provides utility functions that can be used
|
|
9
|
+
* by other plugins or integrated when the API supports it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
13
|
+
|
|
14
|
+
// Configuration
|
|
15
|
+
const CONFIG = {
|
|
16
|
+
// Base limits (chars) - adjusted dynamically based on context usage
|
|
17
|
+
BASE_LIMITS: {
|
|
18
|
+
grep: 15000,
|
|
19
|
+
glob: 10000,
|
|
20
|
+
read: 30000,
|
|
21
|
+
bash: 20000,
|
|
22
|
+
default: 25000,
|
|
23
|
+
} as Record<string, number>,
|
|
24
|
+
// Context percentage thresholds for scaling
|
|
25
|
+
SCALE_FACTORS: [
|
|
26
|
+
{ threshold: 95, factor: 0.3 },
|
|
27
|
+
{ threshold: 85, factor: 0.5 },
|
|
28
|
+
{ threshold: 70, factor: 0.7 },
|
|
29
|
+
{ threshold: 50, factor: 1.0 },
|
|
30
|
+
],
|
|
31
|
+
// Minimum output to preserve (chars)
|
|
32
|
+
MIN_OUTPUT: 2000,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get scale factor based on context usage percentage
|
|
37
|
+
*/
|
|
38
|
+
function getScaleFactor(contextPct: number): number {
|
|
39
|
+
for (const { threshold, factor } of CONFIG.SCALE_FACTORS) {
|
|
40
|
+
if (contextPct >= threshold) {
|
|
41
|
+
return factor;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return 1.0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get dynamic limit for a tool based on context usage
|
|
49
|
+
*/
|
|
50
|
+
export function getLimit(tool: string, contextPct: number): number {
|
|
51
|
+
const baseLimit = CONFIG.BASE_LIMITS[tool] || CONFIG.BASE_LIMITS.default;
|
|
52
|
+
const scaleFactor = getScaleFactor(contextPct);
|
|
53
|
+
return Math.max(CONFIG.MIN_OUTPUT, Math.floor(baseLimit * scaleFactor));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ensure output is a string - handles objects, arrays, null, undefined
|
|
58
|
+
*/
|
|
59
|
+
function ensureString(output: unknown): string {
|
|
60
|
+
if (typeof output === "string") {
|
|
61
|
+
return output;
|
|
62
|
+
}
|
|
63
|
+
if (output === null || output === undefined) {
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
if (typeof output === "object") {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.stringify(output, null, 2);
|
|
69
|
+
} catch {
|
|
70
|
+
return String(output);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return String(output);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Smart truncation with context preservation
|
|
78
|
+
*/
|
|
79
|
+
export function smartTruncate(
|
|
80
|
+
output: unknown,
|
|
81
|
+
limit: number,
|
|
82
|
+
tool: string,
|
|
83
|
+
): string {
|
|
84
|
+
// Ensure we're working with a string
|
|
85
|
+
const str = ensureString(output);
|
|
86
|
+
|
|
87
|
+
// Return as-is if within limit or empty
|
|
88
|
+
if (!str || str.length <= limit) return str;
|
|
89
|
+
|
|
90
|
+
const truncatedChars = str.length - limit;
|
|
91
|
+
const truncationMsg = `\n\n[... truncated ${truncatedChars} chars to save context ...]`;
|
|
92
|
+
|
|
93
|
+
// Smart truncation strategies based on tool type
|
|
94
|
+
if (tool === "grep" || tool === "glob") {
|
|
95
|
+
// For search results: keep first and last results
|
|
96
|
+
const lines = str.split("\n");
|
|
97
|
+
const targetLines = Math.floor(limit / 80); // Assume ~80 chars per line
|
|
98
|
+
|
|
99
|
+
if (lines.length > targetLines && targetLines > 0) {
|
|
100
|
+
const keepStart = Math.max(1, Math.floor(targetLines * 0.7));
|
|
101
|
+
const keepEnd = Math.max(1, targetLines - keepStart);
|
|
102
|
+
return [
|
|
103
|
+
...lines.slice(0, keepStart),
|
|
104
|
+
`\n... [${lines.length - keepStart - keepEnd} lines truncated] ...\n`,
|
|
105
|
+
...lines.slice(-keepEnd),
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (tool === "read") {
|
|
111
|
+
// For file reads: keep beginning and end
|
|
112
|
+
const keepStart = Math.floor(limit * 0.7);
|
|
113
|
+
const keepEnd = limit - keepStart;
|
|
114
|
+
if (keepStart > 0 && keepEnd > 0) {
|
|
115
|
+
return str.slice(0, keepStart) + truncationMsg + str.slice(-keepEnd);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (tool === "bash") {
|
|
120
|
+
// For bash: prefer keeping the end (usually most relevant)
|
|
121
|
+
const keepStart = Math.floor(limit * 0.3);
|
|
122
|
+
const keepEnd = limit - keepStart;
|
|
123
|
+
if (keepStart > 0 && keepEnd > 0) {
|
|
124
|
+
return str.slice(0, keepStart) + truncationMsg + str.slice(-keepEnd);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Default: balanced truncation
|
|
129
|
+
const keepStart = Math.floor(limit * 0.6);
|
|
130
|
+
const keepEnd = limit - keepStart;
|
|
131
|
+
if (keepStart > 0 && keepEnd > 0) {
|
|
132
|
+
return str.slice(0, keepStart) + truncationMsg + str.slice(-keepEnd);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Fallback: just take first `limit` chars
|
|
136
|
+
return str.slice(0, limit) + truncationMsg;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const TruncatorPlugin: Plugin = async ({ client }) => {
|
|
140
|
+
// Track current context usage for external access
|
|
141
|
+
let currentContextPct = 0;
|
|
142
|
+
|
|
143
|
+
client.app
|
|
144
|
+
.log({
|
|
145
|
+
body: {
|
|
146
|
+
service: "truncator-plugin",
|
|
147
|
+
level: "info",
|
|
148
|
+
message:
|
|
149
|
+
"✂️ Truncator Plugin loaded - dynamic truncation utilities available",
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
.catch(() => {});
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
event: async ({ event }) => {
|
|
156
|
+
const props = event.properties as Record<string, unknown>;
|
|
157
|
+
|
|
158
|
+
// Track context usage from session updates
|
|
159
|
+
if (event.type === "session.updated") {
|
|
160
|
+
const info = props?.info as Record<string, unknown> | undefined;
|
|
161
|
+
const tokenStats = (info?.tokens || props?.tokens) as
|
|
162
|
+
| { percentage?: number }
|
|
163
|
+
| undefined;
|
|
164
|
+
|
|
165
|
+
if (tokenStats?.percentage) {
|
|
166
|
+
const prevPct = currentContextPct;
|
|
167
|
+
currentContextPct = tokenStats.percentage;
|
|
168
|
+
|
|
169
|
+
// Log when crossing thresholds
|
|
170
|
+
const thresholds = [50, 70, 85, 95];
|
|
171
|
+
for (const t of thresholds) {
|
|
172
|
+
if (prevPct < t && currentContextPct >= t) {
|
|
173
|
+
const limit = getLimit("default", currentContextPct);
|
|
174
|
+
client.app
|
|
175
|
+
.log({
|
|
176
|
+
body: {
|
|
177
|
+
service: "truncator-plugin",
|
|
178
|
+
level: "debug",
|
|
179
|
+
message: `✂️ Context at ${currentContextPct}% - dynamic limit now ${limit} chars`,
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
.catch(() => {});
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
};
|