@agentlogs/opencode 0.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,199 @@
1
+ // src/index.ts
2
+ import { appendFileSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ var LOG_FILE = "/tmp/agentlogs-opencode.log";
5
+ function log(message, data) {
6
+ const timestamp = new Date().toISOString();
7
+ const logLine = data ? `[${timestamp}] ${message}
8
+ ${JSON.stringify(data, null, 2)}
9
+ ` : `[${timestamp}] ${message}
10
+ `;
11
+ try {
12
+ appendFileSync(LOG_FILE, logLine);
13
+ } catch {}
14
+ }
15
+ async function uploadViaCli(sessionId, cwd) {
16
+ const cliPath = process.env.VI_CLI_PATH;
17
+ let command;
18
+ let args;
19
+ if (cliPath) {
20
+ const parts = cliPath.split(" ");
21
+ command = parts[0];
22
+ args = [...parts.slice(1), "opencode", "upload", sessionId];
23
+ } else {
24
+ command = "npx";
25
+ args = ["-y", "agentlogs@latest", "opencode", "upload", sessionId];
26
+ }
27
+ log("Spawning CLI", { command, args, sessionId });
28
+ return new Promise((resolve) => {
29
+ const proc = spawn(command, args, {
30
+ cwd,
31
+ stdio: ["ignore", "pipe", "pipe"]
32
+ });
33
+ let stdout = "";
34
+ let stderr = "";
35
+ proc.stdout.on("data", (data) => {
36
+ stdout += data.toString();
37
+ });
38
+ proc.stderr.on("data", (data) => {
39
+ stderr += data.toString();
40
+ });
41
+ proc.on("close", (code) => {
42
+ log("CLI process exited", { code, stdout, stderr });
43
+ if (code === 0) {
44
+ try {
45
+ const result = JSON.parse(stdout.trim());
46
+ resolve({
47
+ success: true,
48
+ transcriptId: result.transcriptId,
49
+ transcriptUrl: result.transcriptUrl
50
+ });
51
+ } catch {
52
+ resolve({
53
+ success: true,
54
+ error: "Failed to parse CLI output"
55
+ });
56
+ }
57
+ } else {
58
+ resolve({
59
+ success: false,
60
+ error: stderr || `CLI exited with code ${code}`
61
+ });
62
+ }
63
+ });
64
+ proc.on("error", (err) => {
65
+ log("CLI spawn error", { error: String(err) });
66
+ resolve({
67
+ success: false,
68
+ error: `Failed to spawn agentlogs CLI: ${err.message}`
69
+ });
70
+ });
71
+ });
72
+ }
73
+ function isGitCommitCommand(input) {
74
+ if (!input || typeof input !== "object")
75
+ return false;
76
+ const record = input;
77
+ const cmd = Array.isArray(record.command) ? record.command.join(" ") : typeof record.command === "string" ? record.command : "";
78
+ return /\bgit\s+commit\b/.test(cmd);
79
+ }
80
+ function appendTranscriptLinkToCommit(input, transcriptUrl) {
81
+ if (!input || typeof input !== "object")
82
+ return null;
83
+ const record = { ...input };
84
+ let cmdString;
85
+ if (Array.isArray(record.command)) {
86
+ cmdString = record.command.join(" ");
87
+ } else if (typeof record.command === "string") {
88
+ cmdString = record.command;
89
+ } else {
90
+ return null;
91
+ }
92
+ const messageMatch = cmdString.match(/-m\s+(?:"([^"]+)"|'([^']+)'|(\S+))/);
93
+ const existingMessage = messageMatch?.[1] || messageMatch?.[2] || messageMatch?.[3];
94
+ if (!existingMessage)
95
+ return null;
96
+ const newMessage = `${existingMessage}
97
+
98
+ Transcript: ${transcriptUrl}`;
99
+ if (Array.isArray(record.command)) {
100
+ const cmdArray = [...record.command];
101
+ for (let i = 0;i < cmdArray.length; i++) {
102
+ if (cmdArray[i] === "-m" && i + 1 < cmdArray.length) {
103
+ cmdArray[i + 1] = newMessage;
104
+ break;
105
+ }
106
+ }
107
+ record.command = cmdArray;
108
+ } else {
109
+ record.command = cmdString.replace(/-m\s+(?:"[^"]+"|'[^']+'|\S+)/, `-m "${newMessage.replace(/"/g, "\\\"")}"`);
110
+ }
111
+ return record;
112
+ }
113
+ var agentLogsPlugin = async (ctx) => {
114
+ const state = {
115
+ sessions: new Map,
116
+ uploading: new Set
117
+ };
118
+ log("Plugin initialized", {
119
+ directory: ctx.directory,
120
+ projectId: ctx.project?.id
121
+ });
122
+ return {
123
+ event: async (rawEvent) => {
124
+ const event = rawEvent?.event ?? rawEvent;
125
+ const eventType = event?.type;
126
+ const properties = event?.properties;
127
+ log(`Event: ${eventType}`, properties);
128
+ if (eventType === "session.created") {
129
+ const sessionId = properties?.info?.id;
130
+ const parentId = properties?.info?.parentID;
131
+ const isSubagent = !!parentId;
132
+ if (sessionId) {
133
+ state.sessions.set(sessionId, {
134
+ isSubagent,
135
+ transcriptUrl: null
136
+ });
137
+ log("Session created", { sessionId, isSubagent, parentId });
138
+ }
139
+ }
140
+ if (eventType === "session.idle") {
141
+ const sessionId = properties?.sessionID;
142
+ if (!sessionId)
143
+ return;
144
+ const session = state.sessions.get(sessionId);
145
+ if (session?.isSubagent) {
146
+ log("Skipping subagent session", { sessionId });
147
+ return;
148
+ }
149
+ if (state.uploading.has(sessionId)) {
150
+ log("Already uploading session", { sessionId });
151
+ return;
152
+ }
153
+ state.uploading.add(sessionId);
154
+ log("Session idle, uploading", { sessionId });
155
+ try {
156
+ const result = await uploadViaCli(sessionId, ctx.directory);
157
+ if (result.success && result.transcriptUrl) {
158
+ if (session) {
159
+ session.transcriptUrl = result.transcriptUrl;
160
+ }
161
+ log("Upload success", { sessionId, url: result.transcriptUrl });
162
+ } else {
163
+ log("Upload failed", { sessionId, error: result.error });
164
+ }
165
+ } catch (error) {
166
+ log("Session idle error", { sessionId, error: String(error) });
167
+ } finally {
168
+ state.uploading.delete(sessionId);
169
+ }
170
+ }
171
+ },
172
+ tool: {
173
+ execute: {
174
+ before: async (args) => {
175
+ let transcriptUrl = null;
176
+ for (const [, session] of state.sessions) {
177
+ if (!session.isSubagent && session.transcriptUrl) {
178
+ transcriptUrl = session.transcriptUrl;
179
+ }
180
+ }
181
+ if ((args.name === "shell" || args.name === "bash") && isGitCommitCommand(args.input) && transcriptUrl) {
182
+ log("Intercepting git commit", { transcriptUrl });
183
+ const modified = appendTranscriptLinkToCommit(args.input, transcriptUrl);
184
+ if (modified) {
185
+ log("Added transcript link to commit");
186
+ return { ...args, input: modified };
187
+ }
188
+ }
189
+ return args;
190
+ }
191
+ }
192
+ }
193
+ };
194
+ };
195
+ var src_default = agentLogsPlugin;
196
+ export {
197
+ src_default as default,
198
+ agentLogsPlugin
199
+ };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@agentlogs/opencode",
3
+ "version": "0.0.1",
4
+ "description": "AgentLogs plugin for OpenCode - automatically captures and uploads AI coding session transcripts",
5
+ "keywords": [
6
+ "agentlogs",
7
+ "ai-coding",
8
+ "opencode",
9
+ "plugin",
10
+ "transcript"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/agentlogs/agentlogs.git",
16
+ "directory": "packages/opencode"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "type": "module",
23
+ "main": "src/index.ts",
24
+ "types": "src/index.ts",
25
+ "publishConfig": {
26
+ "access": "public",
27
+ "main": "dist/index.js",
28
+ "types": "dist/index.d.ts"
29
+ },
30
+ "scripts": {
31
+ "build": "bun build ./src/index.ts --outdir ./dist --target node",
32
+ "prepublishOnly": "bun run build",
33
+ "check": "tsgo --project tsconfig.json"
34
+ },
35
+ "peerDependencies": {
36
+ "bun": ">=1.0.0"
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * AgentLogs OpenCode Plugin
3
+ *
4
+ * Lightweight plugin that captures OpenCode session data and uploads via the agentlogs CLI.
5
+ * No external dependencies - all heavy lifting is done by the CLI.
6
+ *
7
+ * @example
8
+ * // opencode.json
9
+ * { "plugin": ["@agentlogs/opencode"] }
10
+ */
11
+
12
+ import { appendFileSync } from "node:fs";
13
+ import { spawn } from "node:child_process";
14
+
15
+ // ============================================================================
16
+ // Debug Logging
17
+ // ============================================================================
18
+
19
+ const LOG_FILE = "/tmp/agentlogs-opencode.log";
20
+
21
+ function log(message: string, data?: unknown): void {
22
+ const timestamp = new Date().toISOString();
23
+ const logLine = data
24
+ ? `[${timestamp}] ${message}\n${JSON.stringify(data, null, 2)}\n`
25
+ : `[${timestamp}] ${message}\n`;
26
+ try {
27
+ appendFileSync(LOG_FILE, logLine);
28
+ } catch {
29
+ // Ignore write errors
30
+ }
31
+ }
32
+
33
+ // ============================================================================
34
+ // Types (minimal, no external deps)
35
+ // ============================================================================
36
+
37
+ interface PluginContext {
38
+ directory: string;
39
+ worktree?: string;
40
+ project?: { id: string; path: string };
41
+ }
42
+
43
+ interface SessionInfo {
44
+ isSubagent: boolean;
45
+ transcriptUrl: string | null;
46
+ }
47
+
48
+ interface PluginState {
49
+ // Track sessions: sessionId → info
50
+ sessions: Map<string, SessionInfo>;
51
+ // Currently uploading session IDs (to prevent concurrent uploads)
52
+ uploading: Set<string>;
53
+ }
54
+
55
+ // ============================================================================
56
+ // CLI Integration
57
+ // ============================================================================
58
+
59
+ interface UploadResult {
60
+ success: boolean;
61
+ transcriptId?: string;
62
+ transcriptUrl?: string;
63
+ error?: string;
64
+ }
65
+
66
+ /**
67
+ * Upload transcript by shelling out to the agentlogs CLI.
68
+ * Passes session ID - CLI reads directly from OpenCode storage.
69
+ * Uses $VI_CLI_PATH if set, otherwise falls back to npx.
70
+ */
71
+ async function uploadViaCli(sessionId: string, cwd: string): Promise<UploadResult> {
72
+ // Use VI_CLI_PATH if set, otherwise fall back to npx
73
+ // VI_CLI_PATH can be "bun /path/to/cli.ts" or just "/path/to/agentlogs"
74
+ const cliPath = process.env.VI_CLI_PATH;
75
+
76
+ let command: string;
77
+ let args: string[];
78
+
79
+ if (cliPath) {
80
+ const parts = cliPath.split(" ");
81
+ command = parts[0];
82
+ args = [...parts.slice(1), "opencode", "upload", sessionId];
83
+ } else {
84
+ command = "npx";
85
+ args = ["-y", "agentlogs@latest", "opencode", "upload", sessionId];
86
+ }
87
+
88
+ log("Spawning CLI", { command, args, sessionId });
89
+
90
+ return new Promise((resolve) => {
91
+ const proc = spawn(command, args, {
92
+ cwd,
93
+ stdio: ["ignore", "pipe", "pipe"],
94
+ });
95
+
96
+ let stdout = "";
97
+ let stderr = "";
98
+
99
+ proc.stdout.on("data", (data) => {
100
+ stdout += data.toString();
101
+ });
102
+ proc.stderr.on("data", (data) => {
103
+ stderr += data.toString();
104
+ });
105
+
106
+ proc.on("close", (code) => {
107
+ log("CLI process exited", { code, stdout, stderr });
108
+
109
+ if (code === 0) {
110
+ // Parse JSON output from CLI
111
+ try {
112
+ const result = JSON.parse(stdout.trim());
113
+ resolve({
114
+ success: true,
115
+ transcriptId: result.transcriptId,
116
+ transcriptUrl: result.transcriptUrl,
117
+ });
118
+ } catch {
119
+ resolve({
120
+ success: true,
121
+ error: "Failed to parse CLI output",
122
+ });
123
+ }
124
+ } else {
125
+ resolve({
126
+ success: false,
127
+ error: stderr || `CLI exited with code ${code}`,
128
+ });
129
+ }
130
+ });
131
+
132
+ proc.on("error", (err) => {
133
+ log("CLI spawn error", { error: String(err) });
134
+ resolve({
135
+ success: false,
136
+ error: `Failed to spawn agentlogs CLI: ${err.message}`,
137
+ });
138
+ });
139
+ });
140
+ }
141
+
142
+ // ============================================================================
143
+ // Git Utilities
144
+ // ============================================================================
145
+
146
+ function isGitCommitCommand(input: unknown): boolean {
147
+ if (!input || typeof input !== "object") return false;
148
+ const record = input as Record<string, unknown>;
149
+
150
+ const cmd = Array.isArray(record.command)
151
+ ? record.command.join(" ")
152
+ : typeof record.command === "string"
153
+ ? record.command
154
+ : "";
155
+
156
+ return /\bgit\s+commit\b/.test(cmd);
157
+ }
158
+
159
+ function appendTranscriptLinkToCommit(input: unknown, transcriptUrl: string): Record<string, unknown> | null {
160
+ if (!input || typeof input !== "object") return null;
161
+ const record = { ...(input as Record<string, unknown>) };
162
+
163
+ let cmdString: string;
164
+ if (Array.isArray(record.command)) {
165
+ cmdString = record.command.join(" ");
166
+ } else if (typeof record.command === "string") {
167
+ cmdString = record.command;
168
+ } else {
169
+ return null;
170
+ }
171
+
172
+ // Extract existing message
173
+ const messageMatch = cmdString.match(/-m\s+(?:"([^"]+)"|'([^']+)'|(\S+))/);
174
+ const existingMessage = messageMatch?.[1] || messageMatch?.[2] || messageMatch?.[3];
175
+ if (!existingMessage) return null;
176
+
177
+ const newMessage = `${existingMessage}\n\nTranscript: ${transcriptUrl}`;
178
+
179
+ if (Array.isArray(record.command)) {
180
+ const cmdArray = [...(record.command as string[])];
181
+ for (let i = 0; i < cmdArray.length; i++) {
182
+ if (cmdArray[i] === "-m" && i + 1 < cmdArray.length) {
183
+ cmdArray[i + 1] = newMessage;
184
+ break;
185
+ }
186
+ }
187
+ record.command = cmdArray;
188
+ } else {
189
+ record.command = cmdString.replace(/-m\s+(?:"[^"]+"|'[^']+'|\S+)/, `-m "${newMessage.replace(/"/g, '\\"')}"`);
190
+ }
191
+
192
+ return record;
193
+ }
194
+
195
+ // ============================================================================
196
+ // Main Plugin
197
+ // ============================================================================
198
+
199
+ export const agentLogsPlugin = async (ctx: PluginContext) => {
200
+ const state: PluginState = {
201
+ sessions: new Map(),
202
+ uploading: new Set(),
203
+ };
204
+
205
+ log("Plugin initialized", {
206
+ directory: ctx.directory,
207
+ projectId: ctx.project?.id,
208
+ });
209
+
210
+ return {
211
+ event: async (rawEvent: any) => {
212
+ // OpenCode wraps events: { event: { type, properties } }
213
+ const event = rawEvent?.event ?? rawEvent;
214
+ const eventType = event?.type;
215
+ const properties = event?.properties;
216
+
217
+ log(`Event: ${eventType}`, properties);
218
+
219
+ if (eventType === "session.created") {
220
+ const sessionId = properties?.info?.id;
221
+ const parentId = properties?.info?.parentID;
222
+ const isSubagent = !!parentId;
223
+
224
+ if (sessionId) {
225
+ state.sessions.set(sessionId, {
226
+ isSubagent,
227
+ transcriptUrl: null,
228
+ });
229
+ log("Session created", { sessionId, isSubagent, parentId });
230
+ }
231
+ }
232
+
233
+ if (eventType === "session.idle") {
234
+ // Use session ID from the event, not from state
235
+ const sessionId = properties?.sessionID;
236
+ if (!sessionId) return;
237
+
238
+ const session = state.sessions.get(sessionId);
239
+
240
+ // Skip subagent sessions
241
+ if (session?.isSubagent) {
242
+ log("Skipping subagent session", { sessionId });
243
+ return;
244
+ }
245
+
246
+ // Skip if already uploading this session
247
+ if (state.uploading.has(sessionId)) {
248
+ log("Already uploading session", { sessionId });
249
+ return;
250
+ }
251
+
252
+ state.uploading.add(sessionId);
253
+ log("Session idle, uploading", { sessionId });
254
+
255
+ try {
256
+ // Upload via CLI - it reads directly from OpenCode storage
257
+ const result = await uploadViaCli(sessionId, ctx.directory);
258
+
259
+ if (result.success && result.transcriptUrl) {
260
+ // Store transcript URL for this session (for git commit linking)
261
+ if (session) {
262
+ session.transcriptUrl = result.transcriptUrl;
263
+ }
264
+ log("Upload success", { sessionId, url: result.transcriptUrl });
265
+ } else {
266
+ log("Upload failed", { sessionId, error: result.error });
267
+ }
268
+ } catch (error) {
269
+ log("Session idle error", { sessionId, error: String(error) });
270
+ } finally {
271
+ state.uploading.delete(sessionId);
272
+ }
273
+ }
274
+ },
275
+
276
+ tool: {
277
+ execute: {
278
+ before: async (args: { name: string; input: unknown }) => {
279
+ // Intercept git commits to add transcript link
280
+ // Use the most recent non-subagent session's transcript URL
281
+ let transcriptUrl: string | null = null;
282
+ for (const [, session] of state.sessions) {
283
+ if (!session.isSubagent && session.transcriptUrl) {
284
+ transcriptUrl = session.transcriptUrl;
285
+ }
286
+ }
287
+
288
+ if ((args.name === "shell" || args.name === "bash") && isGitCommitCommand(args.input) && transcriptUrl) {
289
+ log("Intercepting git commit", { transcriptUrl });
290
+ const modified = appendTranscriptLinkToCommit(args.input, transcriptUrl);
291
+ if (modified) {
292
+ log("Added transcript link to commit");
293
+ return { ...args, input: modified };
294
+ }
295
+ }
296
+ return args;
297
+ },
298
+ },
299
+ },
300
+ };
301
+ };
302
+
303
+ export default agentLogsPlugin;