@haposoft/cafekit 0.8.17 → 0.9.1

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,253 @@
1
+ /**
2
+ * Copyright (c) 2026 Haposoft. MIT License.
3
+ *
4
+ * OpenCode Plugin — state.ts
5
+ *
6
+ * Port of .claude/hooks/state.cjs (partial).
7
+ *
8
+ * Behavior:
9
+ * - On session.created: read .opencode/session-state/latest.md (if fresh)
10
+ * and surface it via stderr + banner file so the assistant sees prior
11
+ * execution context.
12
+ * - On tool.execute.after for state-shaping tools (todowrite, task,
13
+ * taskcreate, taskupdate): refresh snapshot.
14
+ * - On session.idle (≈ Claude Stop): persist snapshot + archive.
15
+ *
16
+ * Dropped:
17
+ * - SubagentStop branch (no equivalent in OpenCode).
18
+ * - Transcript-based todo extraction (OpenCode plugin context lacks
19
+ * transcript_path). Snapshot now captures modifiedFiles only.
20
+ *
21
+ * Storage: .opencode/session-state/latest.md + archive/.
22
+ */
23
+
24
+ import type { Plugin } from "@opencode-ai/plugin";
25
+ import {
26
+ existsSync,
27
+ readFileSync,
28
+ writeFileSync,
29
+ mkdirSync,
30
+ copyFileSync,
31
+ readdirSync,
32
+ unlinkSync,
33
+ renameSync,
34
+ appendFileSync,
35
+ } from "node:fs";
36
+ import { join, dirname } from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import { execSync } from "node:child_process";
39
+
40
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
41
+ const EXPIRY_DAYS = 7;
42
+ const MAX_ARCHIVES = 5;
43
+ const STATE_REFRESH_TOOLS = new Set(["todowrite", "task", "taskcreate", "taskupdate"]);
44
+ const STATE_SECTION_START = "<!-- CAFEKIT STATE START -->";
45
+ const STATE_SECTION_END = "<!-- CAFEKIT STATE END -->";
46
+
47
+ interface SnapshotData {
48
+ timestamp: string;
49
+ branch: string;
50
+ modifiedFiles: string[];
51
+ }
52
+
53
+ function stateDir(cwd: string): string | null {
54
+ try {
55
+ const dir = join(cwd, ".opencode", "session-state");
56
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
57
+ return dir;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function loadLatest(dir: string): string | null {
64
+ try {
65
+ const file = join(dir, "latest.md");
66
+ if (!existsSync(file)) return null;
67
+ const text = readFileSync(file, "utf8");
68
+ const match = text.match(/<!-- Generated: (.+?) -->/);
69
+ if (match) {
70
+ const generatedAt = new Date(match[1]).getTime();
71
+ if (Number.isNaN(generatedAt)) return null;
72
+ if (Date.now() - generatedAt > EXPIRY_DAYS * 24 * 60 * 60 * 1000) return null;
73
+ }
74
+ return text;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeAtomic(filePath: string, content: string): void {
81
+ const tempFile = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
82
+ writeFileSync(tempFile, content);
83
+ renameSync(tempFile, filePath);
84
+ }
85
+
86
+ function archive(dir: string): void {
87
+ try {
88
+ const latestFile = join(dir, "latest.md");
89
+ if (!existsSync(latestFile)) return;
90
+
91
+ const archiveDir = join(dir, "archive");
92
+ if (!existsSync(archiveDir)) mkdirSync(archiveDir);
93
+
94
+ const now = new Date();
95
+ const pad = (value: number) => String(value).padStart(2, "0");
96
+ const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
97
+
98
+ copyFileSync(latestFile, join(archiveDir, `${stamp}.md`));
99
+
100
+ const files = readdirSync(archiveDir)
101
+ .filter((file) => file.endsWith(".md"))
102
+ .sort();
103
+ while (files.length > MAX_ARCHIVES) {
104
+ const oldest = files.shift();
105
+ if (oldest) {
106
+ try {
107
+ unlinkSync(join(archiveDir, oldest));
108
+ } catch {
109
+ // fail-open
110
+ }
111
+ }
112
+ }
113
+ } catch {
114
+ // fail-open
115
+ }
116
+ }
117
+
118
+ function extractSnapshot(cwd: string): SnapshotData {
119
+ const data: SnapshotData = {
120
+ timestamp: new Date().toISOString(),
121
+ branch: "",
122
+ modifiedFiles: [],
123
+ };
124
+
125
+ try {
126
+ data.branch = execSync("git branch --show-current", {
127
+ cwd,
128
+ encoding: "utf8",
129
+ timeout: 3000,
130
+ stdio: ["pipe", "pipe", "pipe"],
131
+ }).trim();
132
+ } catch {
133
+ // fail-open
134
+ }
135
+
136
+ try {
137
+ const diff = execSync("git diff --name-only HEAD", {
138
+ cwd,
139
+ encoding: "utf8",
140
+ timeout: 3000,
141
+ stdio: ["pipe", "pipe", "pipe"],
142
+ }).trim();
143
+ if (diff) data.modifiedFiles = diff.split("\n").slice(0, 20);
144
+ } catch {
145
+ // fail-open
146
+ }
147
+
148
+ return data;
149
+ }
150
+
151
+ function buildStateContent(data: SnapshotData): string {
152
+ return [
153
+ "# Session State",
154
+ `<!-- Generated: ${data.timestamp} -->`,
155
+ `<!-- Branch: ${data.branch || "unknown"} -->`,
156
+ "",
157
+ "## Key Files Modified",
158
+ ...(data.modifiedFiles.length
159
+ ? data.modifiedFiles.map((file) => `- ${file}`)
160
+ : ["- (No file changes detected)"]),
161
+ "",
162
+ ].join("\n");
163
+ }
164
+
165
+ function writeBannerSection(cwd: string, content: string | null): void {
166
+ const target = join(cwd, ".opencode", "session-banner.md");
167
+ const dir = dirname(target);
168
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
169
+
170
+ const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
171
+ const pattern = new RegExp(`${STATE_SECTION_START}[\\s\\S]*?${STATE_SECTION_END}\\n?`);
172
+ const cleaned = existing.replace(pattern, "").trimEnd();
173
+
174
+ if (!content) {
175
+ if (cleaned !== existing.trimEnd()) {
176
+ writeFileSync(target, cleaned ? `${cleaned}\n` : "");
177
+ }
178
+ return;
179
+ }
180
+
181
+ const block = [STATE_SECTION_START, content, STATE_SECTION_END].join("\n");
182
+ const next = cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`;
183
+ writeFileSync(target, next);
184
+ }
185
+
186
+ function persistSnapshot(dir: string, data: SnapshotData, options: { archive?: boolean } = {}): void {
187
+ const file = join(dir, "latest.md");
188
+ writeAtomic(file, buildStateContent(data));
189
+ if (options.archive) archive(dir);
190
+ }
191
+
192
+ function logCrash(error: unknown): void {
193
+ try {
194
+ const logDir = join(PLUGIN_DIR, ".logs");
195
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
196
+ appendFileSync(
197
+ join(logDir, "hook-log.jsonl"),
198
+ JSON.stringify({
199
+ ts: new Date().toISOString(),
200
+ hook: "state",
201
+ status: "crash",
202
+ error: error instanceof Error ? error.message : String(error),
203
+ }) + "\n",
204
+ );
205
+ } catch {
206
+ // fail-open
207
+ }
208
+ }
209
+
210
+ export const State: Plugin = async ({ directory }) => ({
211
+ event: async ({ event }) => {
212
+ try {
213
+ const dir = stateDir(directory);
214
+ if (!dir) return;
215
+
216
+ if (event.type === "session.created") {
217
+ const previous = loadLatest(dir);
218
+ if (previous) {
219
+ const banner = [
220
+ "=== Prior Execution Context ===",
221
+ previous.trim(),
222
+ "=== End of Prior Context ===",
223
+ ].join("\n");
224
+ writeBannerSection(directory, banner);
225
+ console.error(banner);
226
+ } else {
227
+ writeBannerSection(directory, null);
228
+ }
229
+ return;
230
+ }
231
+
232
+ if (event.type === "session.idle") {
233
+ const snapshot = extractSnapshot(directory);
234
+ persistSnapshot(dir, snapshot, { archive: true });
235
+ return;
236
+ }
237
+ } catch (error) {
238
+ logCrash(error);
239
+ }
240
+ },
241
+
242
+ "tool.execute.after": async (input) => {
243
+ try {
244
+ if (!STATE_REFRESH_TOOLS.has(input.tool)) return;
245
+ const dir = stateDir(directory);
246
+ if (!dir) return;
247
+ const snapshot = extractSnapshot(directory);
248
+ persistSnapshot(dir, snapshot);
249
+ } catch (error) {
250
+ logCrash(error);
251
+ }
252
+ },
253
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Copyright (c) 2026 Haposoft. MIT License.
3
+ *
4
+ * OpenCode Plugin — usage.ts
5
+ *
6
+ * Port of .claude/hooks/usage.cjs adapted to OpenCode's plugin surface.
7
+ *
8
+ * Claude's version polls the Anthropic OAuth API for plan usage so the
9
+ * statusline can render quota. OpenCode has no statusline injection point
10
+ * and no OAuth plumbing, so this port instead tracks per-session token
11
+ * usage by inspecting `message.updated` payloads and writing the rolling
12
+ * totals to `.opencode/session-state/usage.json` for downstream tooling.
13
+ *
14
+ * Disable via .opencode/runtime.json: { "usage": { "enabled": false } }.
15
+ */
16
+
17
+ import type { Plugin } from "@opencode-ai/plugin";
18
+ import {
19
+ existsSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ mkdirSync,
23
+ } from "node:fs";
24
+ import { join, dirname } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
28
+
29
+ interface UsageTotals {
30
+ inputTokens: number;
31
+ outputTokens: number;
32
+ cacheReadTokens: number;
33
+ cacheWriteTokens: number;
34
+ messages: number;
35
+ lastUpdated: string;
36
+ }
37
+
38
+ interface UsageFile {
39
+ sessionId: string;
40
+ startedAt: string;
41
+ totals: UsageTotals;
42
+ }
43
+
44
+ function resolveProjectDir(directory?: string): string {
45
+ if (directory && existsSync(directory)) return directory;
46
+ // Plugin installed under <project>/.opencode/plugins/, so go up twice.
47
+ return dirname(dirname(PLUGIN_DIR));
48
+ }
49
+
50
+ function readRuntime(cwd: string): Record<string, any> {
51
+ try {
52
+ const file = join(cwd, ".opencode", "runtime.json");
53
+ if (existsSync(file)) {
54
+ return JSON.parse(readFileSync(file, "utf8"));
55
+ }
56
+ } catch {
57
+ /* fail-open */
58
+ }
59
+ return {};
60
+ }
61
+
62
+ function isEnabled(cwd: string): boolean {
63
+ const runtime = readRuntime(cwd);
64
+ return runtime?.usage?.enabled !== false;
65
+ }
66
+
67
+ function usagePath(cwd: string): string {
68
+ return join(cwd, ".opencode", "session-state", "usage.json");
69
+ }
70
+
71
+ function readUsage(file: string): UsageFile | null {
72
+ try {
73
+ if (!existsSync(file)) return null;
74
+ return JSON.parse(readFileSync(file, "utf8"));
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function writeUsage(file: string, payload: UsageFile): void {
81
+ try {
82
+ mkdirSync(dirname(file), { recursive: true });
83
+ writeFileSync(file, JSON.stringify(payload, null, 2));
84
+ } catch {
85
+ /* fail-open */
86
+ }
87
+ }
88
+
89
+ // Defensive extractor: OpenCode message payloads may expose usage either
90
+ // directly on the message or nested under metadata/usage. Accept both.
91
+ function extractUsage(message: any): {
92
+ input: number;
93
+ output: number;
94
+ cacheRead: number;
95
+ cacheWrite: number;
96
+ } | null {
97
+ const candidates = [
98
+ message?.usage,
99
+ message?.metadata?.usage,
100
+ message?.tokens,
101
+ message?.metadata?.tokens,
102
+ ].filter(Boolean);
103
+
104
+ for (const c of candidates) {
105
+ const input =
106
+ Number(c.input_tokens ?? c.inputTokens ?? c.prompt_tokens ?? 0) || 0;
107
+ const output =
108
+ Number(c.output_tokens ?? c.outputTokens ?? c.completion_tokens ?? 0) ||
109
+ 0;
110
+ const cacheRead =
111
+ Number(
112
+ c.cache_read_input_tokens ??
113
+ c.cacheReadInputTokens ??
114
+ c.cache_read_tokens ??
115
+ 0,
116
+ ) || 0;
117
+ const cacheWrite =
118
+ Number(
119
+ c.cache_creation_input_tokens ??
120
+ c.cacheCreationInputTokens ??
121
+ c.cache_write_tokens ??
122
+ 0,
123
+ ) || 0;
124
+ if (input || output || cacheRead || cacheWrite) {
125
+ return { input, output, cacheRead, cacheWrite };
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ export const UsagePlugin: Plugin = async ({ directory }) => {
132
+ const cwd = resolveProjectDir(directory);
133
+
134
+ return {
135
+ event: async ({ event }) => {
136
+ if (event.type !== "message.updated") return;
137
+ if (!isEnabled(cwd)) return;
138
+
139
+ const message = (event as any).properties?.info ?? (event as any).properties?.message;
140
+ if (!message) return;
141
+
142
+ const tokens = extractUsage(message);
143
+ if (!tokens) return;
144
+
145
+ const sessionId = message.sessionID ?? message.sessionId ?? "default";
146
+ const file = usagePath(cwd);
147
+ const now = new Date().toISOString();
148
+ const existing = readUsage(file);
149
+
150
+ const sameSession = existing && existing.sessionId === sessionId;
151
+ const totals: UsageTotals = sameSession
152
+ ? existing!.totals
153
+ : {
154
+ inputTokens: 0,
155
+ outputTokens: 0,
156
+ cacheReadTokens: 0,
157
+ cacheWriteTokens: 0,
158
+ messages: 0,
159
+ lastUpdated: now,
160
+ };
161
+
162
+ totals.inputTokens += tokens.input;
163
+ totals.outputTokens += tokens.output;
164
+ totals.cacheReadTokens += tokens.cacheRead;
165
+ totals.cacheWriteTokens += tokens.cacheWrite;
166
+ totals.messages += 1;
167
+ totals.lastUpdated = now;
168
+
169
+ writeUsage(file, {
170
+ sessionId,
171
+ startedAt: sameSession ? existing!.startedAt : now,
172
+ totals,
173
+ });
174
+ },
175
+ };
176
+ };
177
+
178
+ export default UsagePlugin;