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.
@@ -11,7 +11,7 @@
11
11
  "author": "",
12
12
  "license": "ISC",
13
13
  "dependencies": {
14
- "@opencode-ai/plugin": "1.0.162"
14
+ "@opencode-ai/plugin": "1.0.164"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/better-sqlite3": "^7.6.13",
@@ -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
- // Get session summary with simple retry
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
- // Send notification
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
+ };