@p4r4d0xb0x/opencode-provider-logger 0.1.0 → 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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // Claude Code hook script — receives JSON on stdin, logs to JSONL
4
+ // Usage: Set as a "command" hook in ~/.claude/settings.json
5
+ // ---------------------------------------------------------------------------
6
+ import { LocalWriter } from "../../core/logger.js";
7
+ import { R2Uploader } from "../../core/uploader.js";
8
+ import { defaultConfig } from "../../core/types.js";
9
+ import { transformClaudeEvent } from "./transform.js";
10
+ const UPLOAD_ON_EVENTS = new Set(["SessionEnd", "Stop", "StopFailure"]);
11
+ async function main() {
12
+ // Read JSON from stdin
13
+ const chunks = [];
14
+ for await (const chunk of process.stdin) {
15
+ chunks.push(chunk);
16
+ }
17
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
18
+ if (!raw)
19
+ return;
20
+ let input;
21
+ try {
22
+ input = JSON.parse(raw);
23
+ }
24
+ catch {
25
+ process.stderr.write(`provider-logger: invalid JSON on stdin\n`);
26
+ return;
27
+ }
28
+ const config = defaultConfig("claude-code");
29
+ const writer = new LocalWriter(config);
30
+ const entry = transformClaudeEvent(input);
31
+ // Append to session-scoped JSONL file
32
+ await writer.append(entry.sessionID, entry);
33
+ // On session end / stop, trigger upload of pending files
34
+ if (UPLOAD_ON_EVENTS.has(input.hook_event_name)) {
35
+ const log = (level, msg, extra) => {
36
+ if (level === "error" || level === "warn") {
37
+ process.stderr.write(`provider-logger [${level}]: ${msg}\n`);
38
+ }
39
+ };
40
+ const uploader = new R2Uploader(config, writer, log);
41
+ await uploader.retryPending().catch(() => { });
42
+ await uploader.cleanup().catch(() => { });
43
+ }
44
+ }
45
+ main().catch((err) => {
46
+ process.stderr.write(`provider-logger: ${err}\n`);
47
+ process.exit(1);
48
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // Generate / merge Claude Code hook configuration into settings.json
4
+ // Usage: node dist/adapters/claude-code/install.js [--global | --project]
5
+ // ---------------------------------------------------------------------------
6
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
7
+ import { join, dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ /** Resolve the compiled hook.js path relative to this install script */
11
+ function hookCommand() {
12
+ const hookPath = join(__dirname, "hook.js");
13
+ return `node "${hookPath}"`;
14
+ }
15
+ /** All Claude Code hook events we want to capture */
16
+ const ALL_EVENTS = [
17
+ "SessionStart",
18
+ "SessionEnd",
19
+ "UserPromptSubmit",
20
+ "PreToolUse",
21
+ "PostToolUse",
22
+ "PostToolUseFailure",
23
+ "PermissionRequest",
24
+ "PermissionDenied",
25
+ "SubagentStart",
26
+ "SubagentStop",
27
+ "TaskCreated",
28
+ "TaskCompleted",
29
+ "Stop",
30
+ "StopFailure",
31
+ "InstructionsLoaded",
32
+ "ConfigChange",
33
+ "CwdChanged",
34
+ "FileChanged",
35
+ "WorktreeCreate",
36
+ "WorktreeRemove",
37
+ "PreCompact",
38
+ "PostCompact",
39
+ "Notification",
40
+ "Elicitation",
41
+ "ElicitationResult",
42
+ "TeammateIdle",
43
+ ];
44
+ function generateHooksConfig() {
45
+ const cmd = hookCommand();
46
+ const hooks = {};
47
+ for (const event of ALL_EVENTS) {
48
+ hooks[event] = [
49
+ {
50
+ matcher: "",
51
+ hooks: [
52
+ {
53
+ type: "command",
54
+ command: cmd,
55
+ timeout: 10,
56
+ async: true,
57
+ },
58
+ ],
59
+ },
60
+ ];
61
+ }
62
+ return hooks;
63
+ }
64
+ async function install(mode) {
65
+ const home = process.env.HOME ?? require("node:os").homedir();
66
+ const settingsPath = mode === "global"
67
+ ? join(home, ".claude", "settings.json")
68
+ : join(process.cwd(), ".claude", "settings.json");
69
+ // Read existing settings or start fresh
70
+ let settings = {};
71
+ try {
72
+ const raw = await readFile(settingsPath, "utf-8");
73
+ settings = JSON.parse(raw);
74
+ }
75
+ catch {
76
+ // file doesn't exist yet
77
+ }
78
+ // Merge hooks
79
+ const existingHooks = (settings.hooks ?? {});
80
+ const newHooks = generateHooksConfig();
81
+ for (const [event, matchers] of Object.entries(newHooks)) {
82
+ if (!existingHooks[event]) {
83
+ existingHooks[event] = matchers;
84
+ }
85
+ else {
86
+ // Check if provider-logger hook already exists
87
+ const cmd = hookCommand();
88
+ const alreadyInstalled = existingHooks[event].some((m) => m?.hooks?.some((h) => h?.command?.includes(cmd)));
89
+ if (!alreadyInstalled) {
90
+ existingHooks[event].push(...matchers);
91
+ }
92
+ }
93
+ }
94
+ settings.hooks = existingHooks;
95
+ await mkdir(dirname(settingsPath), { recursive: true });
96
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
97
+ console.log(`✓ Provider logger hooks installed → ${settingsPath}`);
98
+ console.log(` Events: ${ALL_EVENTS.length}`);
99
+ console.log(` Mode: async (non-blocking)`);
100
+ }
101
+ // CLI
102
+ const mode = process.argv.includes("--project") ? "project" : "global";
103
+ install(mode).catch((err) => {
104
+ console.error(`Install failed: ${err}`);
105
+ process.exit(1);
106
+ });
@@ -0,0 +1,26 @@
1
+ import type { ProviderLogEntry } from "../../core/types.js";
2
+ /** Common fields present in every Claude Code hook stdin payload */
3
+ export interface ClaudeHookInput {
4
+ session_id: string;
5
+ hook_event_name: string;
6
+ transcript_path?: string | null;
7
+ cwd?: string;
8
+ permission_mode?: string;
9
+ source?: string;
10
+ model?: string;
11
+ agent_id?: string;
12
+ agent_type?: string;
13
+ prompt?: string;
14
+ turn_id?: string;
15
+ stop_hook_active?: boolean;
16
+ last_assistant_message?: string | null;
17
+ tool_name?: string;
18
+ tool_input?: Record<string, unknown>;
19
+ tool_use_id?: string;
20
+ tool_response?: unknown;
21
+ [key: string]: unknown;
22
+ }
23
+ /**
24
+ * Transform a Claude Code hook stdin payload into a ProviderLogEntry.
25
+ */
26
+ export declare function transformClaudeEvent(input: ClaudeHookInput): ProviderLogEntry;
@@ -0,0 +1,66 @@
1
+ import { makeEntry } from "../../core/entry.js";
2
+ // ---------------------------------------------------------------------------
3
+ // Claude Code hook event → normalised category mapping
4
+ // ---------------------------------------------------------------------------
5
+ const HOOK_CATEGORY_MAP = {
6
+ // Session lifecycle
7
+ SessionStart: "session.start",
8
+ SessionEnd: "session.end",
9
+ PreCompact: "session.compact",
10
+ PostCompact: "session.compact",
11
+ // Prompt / message
12
+ UserPromptSubmit: "prompt.user",
13
+ InstructionsLoaded: "instructions.loaded",
14
+ Stop: "message.assistant",
15
+ StopFailure: "message.assistant",
16
+ // Tool execution
17
+ PreToolUse: "tool.pre",
18
+ PostToolUse: "tool.post",
19
+ PostToolUseFailure: "tool.error",
20
+ // Permission
21
+ PermissionRequest: "permission.request",
22
+ PermissionDenied: "permission.denied",
23
+ // Subagent / task
24
+ SubagentStart: "subagent.start",
25
+ SubagentStop: "subagent.stop",
26
+ TaskCreated: "task.created",
27
+ TaskCompleted: "task.completed",
28
+ // Environment
29
+ ConfigChange: "config.change",
30
+ CwdChanged: "cwd.change",
31
+ FileChanged: "file.change",
32
+ WorktreeCreate: "file.change",
33
+ WorktreeRemove: "file.change",
34
+ // Misc
35
+ Notification: "notification",
36
+ Elicitation: "elicitation",
37
+ ElicitationResult: "elicitation.result",
38
+ TeammateIdle: "session.idle",
39
+ };
40
+ /**
41
+ * Transform a Claude Code hook stdin payload into a ProviderLogEntry.
42
+ */
43
+ export function transformClaudeEvent(input) {
44
+ const hookName = input.hook_event_name;
45
+ const category = HOOK_CATEGORY_MAP[hookName] ?? "raw";
46
+ const sessionID = input.session_id ?? "unknown";
47
+ // Build output based on event type
48
+ let output = null;
49
+ if (hookName === "PostToolUse" || hookName === "PostToolUseFailure") {
50
+ output = input.tool_response ?? null;
51
+ }
52
+ else if (hookName === "Stop" || hookName === "StopFailure") {
53
+ output = input.last_assistant_message ?? null;
54
+ }
55
+ return makeEntry("claude-code", sessionID, category, hookName, input, output, {
56
+ model: input.model ?? undefined,
57
+ toolName: input.tool_name ?? undefined,
58
+ turnID: input.turn_id ?? undefined,
59
+ agentID: input.agent_id ?? undefined,
60
+ agentType: input.agent_type ?? undefined,
61
+ transcriptPath: input.transcript_path ?? undefined,
62
+ cwd: input.cwd ?? undefined,
63
+ userID: process.env.USER ?? process.env.USERNAME ?? undefined,
64
+ permissionMode: input.permission_mode ?? undefined,
65
+ });
66
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // Codex CLI hook script — receives JSON on stdin, logs to JSONL
4
+ // Usage: Set as a "command" hook in ~/.codex/hooks.json
5
+ // ---------------------------------------------------------------------------
6
+ import { LocalWriter } from "../../core/logger.js";
7
+ import { R2Uploader } from "../../core/uploader.js";
8
+ import { defaultConfig } from "../../core/types.js";
9
+ import { transformCodexEvent } from "./transform.js";
10
+ const UPLOAD_ON_EVENTS = new Set(["Stop"]);
11
+ async function main() {
12
+ // Read JSON from stdin
13
+ const chunks = [];
14
+ for await (const chunk of process.stdin) {
15
+ chunks.push(chunk);
16
+ }
17
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
18
+ if (!raw)
19
+ return;
20
+ let input;
21
+ try {
22
+ input = JSON.parse(raw);
23
+ }
24
+ catch {
25
+ process.stderr.write(`provider-logger: invalid JSON on stdin\n`);
26
+ return;
27
+ }
28
+ const config = defaultConfig("codex-cli");
29
+ const writer = new LocalWriter(config);
30
+ const entry = transformCodexEvent(input);
31
+ // Append to session-scoped JSONL file
32
+ await writer.append(entry.sessionID, entry);
33
+ // On stop, trigger upload of pending files
34
+ if (UPLOAD_ON_EVENTS.has(input.hook_event_name)) {
35
+ const log = (level, msg, extra) => {
36
+ if (level === "error" || level === "warn") {
37
+ process.stderr.write(`provider-logger [${level}]: ${msg}\n`);
38
+ }
39
+ };
40
+ const uploader = new R2Uploader(config, writer, log);
41
+ await uploader.retryPending().catch(() => { });
42
+ await uploader.cleanup().catch(() => { });
43
+ }
44
+ }
45
+ main().catch((err) => {
46
+ process.stderr.write(`provider-logger: ${err}\n`);
47
+ process.exit(1);
48
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // Generate / merge Codex CLI hook configuration into hooks.json
4
+ // Usage: node dist/adapters/codex-cli/install.js [--global | --project]
5
+ // ---------------------------------------------------------------------------
6
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
7
+ import { join, dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ /** Resolve the compiled hook.js path relative to this install script */
11
+ function hookCommand() {
12
+ const hookPath = join(__dirname, "hook.js");
13
+ return `node "${hookPath}"`;
14
+ }
15
+ function generateHooksConfig() {
16
+ const cmd = hookCommand();
17
+ return {
18
+ SessionStart: [
19
+ {
20
+ matcher: "startup|resume",
21
+ hooks: [
22
+ {
23
+ type: "command",
24
+ command: cmd,
25
+ timeout: 10,
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ PreToolUse: [
31
+ {
32
+ matcher: ".*",
33
+ hooks: [
34
+ {
35
+ type: "command",
36
+ command: cmd,
37
+ timeout: 10,
38
+ },
39
+ ],
40
+ },
41
+ ],
42
+ PostToolUse: [
43
+ {
44
+ matcher: ".*",
45
+ hooks: [
46
+ {
47
+ type: "command",
48
+ command: cmd,
49
+ timeout: 10,
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ UserPromptSubmit: [
55
+ {
56
+ hooks: [
57
+ {
58
+ type: "command",
59
+ command: cmd,
60
+ timeout: 10,
61
+ },
62
+ ],
63
+ },
64
+ ],
65
+ Stop: [
66
+ {
67
+ hooks: [
68
+ {
69
+ type: "command",
70
+ command: cmd,
71
+ timeout: 30,
72
+ },
73
+ ],
74
+ },
75
+ ],
76
+ };
77
+ }
78
+ async function install(mode) {
79
+ const home = process.env.HOME ?? require("node:os").homedir();
80
+ const hooksPath = mode === "global"
81
+ ? join(home, ".codex", "hooks.json")
82
+ : join(process.cwd(), ".codex", "hooks.json");
83
+ // Read existing hooks or start fresh
84
+ let existing = {};
85
+ try {
86
+ const raw = await readFile(hooksPath, "utf-8");
87
+ existing = JSON.parse(raw);
88
+ }
89
+ catch {
90
+ // file doesn't exist yet
91
+ }
92
+ const existingHooks = (existing.hooks ?? {});
93
+ const newHooks = generateHooksConfig();
94
+ for (const [event, matchers] of Object.entries(newHooks)) {
95
+ if (!existingHooks[event]) {
96
+ existingHooks[event] = matchers;
97
+ }
98
+ else {
99
+ const cmd = hookCommand();
100
+ const alreadyInstalled = existingHooks[event].some((m) => m?.hooks?.some((h) => h?.command?.includes(cmd)));
101
+ if (!alreadyInstalled) {
102
+ existingHooks[event].push(...matchers);
103
+ }
104
+ }
105
+ }
106
+ existing.hooks = existingHooks;
107
+ await mkdir(dirname(hooksPath), { recursive: true });
108
+ await writeFile(hooksPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
109
+ console.log(`✓ Provider logger hooks installed → ${hooksPath}`);
110
+ console.log(` Events: SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Stop`);
111
+ console.log(` Note: Codex hooks require [features] codex_hooks = true in config.toml`);
112
+ }
113
+ // CLI
114
+ const mode = process.argv.includes("--project") ? "project" : "global";
115
+ install(mode).catch((err) => {
116
+ console.error(`Install failed: ${err}`);
117
+ process.exit(1);
118
+ });
@@ -0,0 +1,26 @@
1
+ import type { ProviderLogEntry } from "../../core/types.js";
2
+ /** Common fields present in every Codex CLI hook stdin payload */
3
+ export interface CodexHookInput {
4
+ session_id: string;
5
+ hook_event_name: string;
6
+ transcript_path?: string | null;
7
+ cwd?: string;
8
+ model?: string;
9
+ source?: string;
10
+ turn_id?: string;
11
+ tool_name?: string;
12
+ tool_use_id?: string;
13
+ tool_input?: {
14
+ command?: string;
15
+ [key: string]: unknown;
16
+ };
17
+ tool_response?: unknown;
18
+ prompt?: string;
19
+ stop_hook_active?: boolean;
20
+ last_assistant_message?: string | null;
21
+ [key: string]: unknown;
22
+ }
23
+ /**
24
+ * Transform a Codex CLI hook stdin payload into a ProviderLogEntry.
25
+ */
26
+ export declare function transformCodexEvent(input: CodexHookInput): ProviderLogEntry;
@@ -0,0 +1,35 @@
1
+ import { makeEntry } from "../../core/entry.js";
2
+ // ---------------------------------------------------------------------------
3
+ // Codex CLI hook event → normalised category mapping
4
+ // ---------------------------------------------------------------------------
5
+ const HOOK_CATEGORY_MAP = {
6
+ SessionStart: "session.start",
7
+ PreToolUse: "tool.pre",
8
+ PostToolUse: "tool.post",
9
+ UserPromptSubmit: "prompt.user",
10
+ Stop: "message.assistant",
11
+ };
12
+ /**
13
+ * Transform a Codex CLI hook stdin payload into a ProviderLogEntry.
14
+ */
15
+ export function transformCodexEvent(input) {
16
+ const hookName = input.hook_event_name;
17
+ const category = HOOK_CATEGORY_MAP[hookName] ?? "raw";
18
+ const sessionID = input.session_id ?? "unknown";
19
+ // Build output based on event type
20
+ let output = null;
21
+ if (hookName === "PostToolUse") {
22
+ output = input.tool_response ?? null;
23
+ }
24
+ else if (hookName === "Stop") {
25
+ output = input.last_assistant_message ?? null;
26
+ }
27
+ return makeEntry("codex-cli", sessionID, category, hookName, input, output, {
28
+ model: input.model ?? undefined,
29
+ toolName: input.tool_name ?? undefined,
30
+ turnID: input.turn_id ?? undefined,
31
+ transcriptPath: input.transcript_path ?? undefined,
32
+ cwd: input.cwd ?? undefined,
33
+ userID: process.env.USER ?? process.env.USERNAME ?? undefined,
34
+ });
35
+ }
@@ -0,0 +1,6 @@
1
+ import type { Hooks } from "@opencode-ai/plugin";
2
+ import type { SessionBuffer, PluginConfig } from "@p4r4d0xb0x/provider-logger-core";
3
+ type FlushFn = (sessionID: string) => Promise<void>;
4
+ type LogFn = (level: "error" | "warn", message: string, extra?: Record<string, unknown>) => void;
5
+ export declare function createHooks(buffer: SessionBuffer, config: PluginConfig, flush: FlushFn, log?: LogFn): Hooks;
6
+ export {};
@@ -0,0 +1,87 @@
1
+ import { makeEntry } from "@p4r4d0xb0x/provider-logger-core";
2
+ /** OpenCode event types worth logging */
3
+ const LOG_EVENT_TYPES = new Set([
4
+ "message.updated",
5
+ "session.idle",
6
+ "session.status",
7
+ "session.created",
8
+ "session.compacted",
9
+ ]);
10
+ /** Map OpenCode hook names to normalised event categories */
11
+ const HOOK_CATEGORY_MAP = {
12
+ "chat.message": "message.updated",
13
+ "chat.params": "prompt.params",
14
+ "experimental.chat.system.transform": "prompt.system",
15
+ "experimental.chat.messages.transform": "prompt.system",
16
+ "experimental.text.complete": "message.assistant",
17
+ "tool.execute.before": "tool.pre",
18
+ "tool.execute.after": "tool.post",
19
+ event: "raw",
20
+ };
21
+ /** Map OpenCode event types to normalised categories */
22
+ const EVENT_TYPE_CATEGORY_MAP = {
23
+ "message.updated": "message.updated",
24
+ "session.idle": "session.idle",
25
+ "session.status": "session.status",
26
+ "session.created": "session.created",
27
+ "session.compacted": "session.compact",
28
+ };
29
+ export function createHooks(buffer, config, flush, log) {
30
+ const safeRecord = (sessionID, hook, input, output, categoryOverride) => {
31
+ try {
32
+ const category = categoryOverride ?? HOOK_CATEGORY_MAP[hook];
33
+ const entry = makeEntry("opencode", sessionID, category, hook, input, output);
34
+ buffer.append(entry);
35
+ if (buffer.sizeOf(sessionID) >= config.flushThresholdBytes) {
36
+ flush(sessionID).catch(() => { });
37
+ }
38
+ }
39
+ catch (err) {
40
+ log?.("error", `Hook record failed [${hook}]`, { error: String(err) });
41
+ }
42
+ };
43
+ return {
44
+ "chat.message": async (input, output) => {
45
+ safeRecord(input.sessionID, "chat.message", input, output);
46
+ },
47
+ "chat.params": async (input, output) => {
48
+ safeRecord(input.sessionID, "chat.params", input, output);
49
+ },
50
+ "experimental.chat.system.transform": async (input, output) => {
51
+ const sessionID = input.sessionID ?? "unknown";
52
+ safeRecord(sessionID, "experimental.chat.system.transform", input, output);
53
+ },
54
+ "experimental.chat.messages.transform": async (input, output) => {
55
+ const firstMsg = output.messages?.[0]?.info;
56
+ const sessionID = firstMsg?.sessionID ?? "unknown";
57
+ safeRecord(sessionID, "experimental.chat.messages.transform", input, output);
58
+ },
59
+ "experimental.text.complete": async (input, output) => {
60
+ safeRecord(input.sessionID, "experimental.text.complete", input, output);
61
+ },
62
+ "tool.execute.before": async (input, output) => {
63
+ safeRecord(input.sessionID, "tool.execute.before", input, output);
64
+ },
65
+ "tool.execute.after": async (input, output) => {
66
+ safeRecord(input.sessionID, "tool.execute.after", input, output);
67
+ },
68
+ event: async ({ event }) => {
69
+ try {
70
+ const ev = event;
71
+ if (!LOG_EVENT_TYPES.has(ev.type))
72
+ return;
73
+ const sessionID = ev.properties?.sessionID ?? ev.properties?.info?.sessionID;
74
+ if (!sessionID)
75
+ return;
76
+ const category = EVENT_TYPE_CATEGORY_MAP[ev.type] ?? "raw";
77
+ safeRecord(sessionID, "event", { type: ev.type }, ev.properties, category);
78
+ if (ev.type === "session.idle") {
79
+ await flush(sessionID);
80
+ }
81
+ }
82
+ catch (err) {
83
+ log?.("error", "Event hook failed", { error: String(err) });
84
+ }
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ declare const providerLogger: Plugin;
3
+ export default providerLogger;
@@ -0,0 +1,63 @@
1
+ import { SessionBuffer, LocalWriter, WorkerUploader, defaultConfig, } from "@p4r4d0xb0x/provider-logger-core";
2
+ import { createHooks } from "./hooks.js";
3
+ import { basename } from "node:path";
4
+ const providerLogger = async ({ client }) => {
5
+ const config = defaultConfig("opencode");
6
+ const buffer = new SessionBuffer();
7
+ const writer = new LocalWriter(config);
8
+ const log = (level, message, extra) => {
9
+ client.app.log({
10
+ body: { service: "provider-logger", level, message, extra },
11
+ });
12
+ };
13
+ const uploader = new WorkerUploader(config, writer, log);
14
+ // Retry any pending uploads from previous sessions
15
+ uploader.retryPending().catch((err) => {
16
+ log("warn", "Failed to retry pending uploads", { error: String(err) });
17
+ });
18
+ // Cleanup local files older than retention period
19
+ uploader.cleanup().catch((err) => {
20
+ log("warn", "Failed to cleanup old files", { error: String(err) });
21
+ });
22
+ const flush = async (sessionID) => {
23
+ if (!buffer.has(sessionID))
24
+ return;
25
+ const entries = buffer.flush(sessionID);
26
+ try {
27
+ const filepath = await writer.write(sessionID, entries);
28
+ if (filepath) {
29
+ const tsNanos = basename(filepath, ".jsonl");
30
+ await uploader.upload({ path: filepath, sessionID, tsNanos });
31
+ }
32
+ }
33
+ catch (err) {
34
+ log("error", `Flush failed for session ${sessionID}`, {
35
+ error: String(err),
36
+ });
37
+ }
38
+ };
39
+ // Flush remaining buffers on process exit
40
+ const flushAll = async () => {
41
+ const all = buffer.flushAll();
42
+ const writes = [];
43
+ for (const [sessionID, entries] of all) {
44
+ if (entries.length === 0)
45
+ continue;
46
+ writes.push(writer.write(sessionID, entries).then(async (filepath) => {
47
+ if (filepath) {
48
+ const tsNanos = basename(filepath, ".jsonl");
49
+ await uploader.upload({ path: filepath, sessionID, tsNanos });
50
+ }
51
+ }).catch((err) => {
52
+ log("error", `Shutdown flush failed for ${sessionID}`, { error: String(err) });
53
+ }));
54
+ }
55
+ await Promise.allSettled(writes);
56
+ };
57
+ process.on("beforeExit", () => {
58
+ flushAll().catch(() => { });
59
+ });
60
+ log("info", "Provider logger initialized");
61
+ return createHooks(buffer, config, flush, log);
62
+ };
63
+ export default providerLogger;
@@ -0,0 +1,13 @@
1
+ import type { ProviderLogEntry } from "./types.js";
2
+ /** Rough byte estimate without JSON.stringify overhead */
3
+ export declare function estimateEntrySize(entry: ProviderLogEntry): number;
4
+ export declare class SessionBuffer {
5
+ private buffers;
6
+ private sizes;
7
+ append(entry: ProviderLogEntry): void;
8
+ sizeOf(sessionID: string): number;
9
+ flush(sessionID: string): ProviderLogEntry[];
10
+ flushAll(): Map<string, ProviderLogEntry[]>;
11
+ has(sessionID: string): boolean;
12
+ sessionIDs(): string[];
13
+ }
@@ -0,0 +1,45 @@
1
+ /** Rough byte estimate without JSON.stringify overhead */
2
+ export function estimateEntrySize(entry) {
3
+ // Fixed overhead: id(36) + timestamp(24) + timestampNanos(19) + hookName(~30)
4
+ // + platform(~12) + category(~20) + JSON structure(~100)
5
+ const FIXED = 240;
6
+ const dataStr = typeof entry.data.input === "string" ? entry.data.input.length : 200;
7
+ const dataOut = typeof entry.data.output === "string" ? entry.data.output.length : 200;
8
+ return FIXED + entry.sessionID.length + dataStr + dataOut;
9
+ }
10
+ export class SessionBuffer {
11
+ buffers = new Map();
12
+ sizes = new Map();
13
+ append(entry) {
14
+ const { sessionID } = entry;
15
+ if (!this.buffers.has(sessionID)) {
16
+ this.buffers.set(sessionID, []);
17
+ this.sizes.set(sessionID, 0);
18
+ }
19
+ this.buffers.get(sessionID).push(entry);
20
+ const approx = estimateEntrySize(entry);
21
+ this.sizes.set(sessionID, (this.sizes.get(sessionID) ?? 0) + approx);
22
+ }
23
+ sizeOf(sessionID) {
24
+ return this.sizes.get(sessionID) ?? 0;
25
+ }
26
+ flush(sessionID) {
27
+ const entries = this.buffers.get(sessionID) ?? [];
28
+ this.buffers.delete(sessionID);
29
+ this.sizes.delete(sessionID);
30
+ return entries;
31
+ }
32
+ flushAll() {
33
+ const all = new Map(this.buffers);
34
+ this.buffers.clear();
35
+ this.sizes.clear();
36
+ return all;
37
+ }
38
+ has(sessionID) {
39
+ const buf = this.buffers.get(sessionID);
40
+ return buf !== undefined && buf.length > 0;
41
+ }
42
+ sessionIDs() {
43
+ return [...this.buffers.keys()];
44
+ }
45
+ }
@@ -0,0 +1,5 @@
1
+ import type { ProviderLogEntry, Platform, EventCategory } from "./types.js";
2
+ /**
3
+ * Create a ProviderLogEntry with auto-generated id, timestamp, and nanos.
4
+ */
5
+ export declare function makeEntry(platform: Platform, sessionID: string, category: EventCategory, hookName: string, input: unknown, output: unknown, meta?: ProviderLogEntry["meta"]): ProviderLogEntry;
@@ -0,0 +1,19 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * Create a ProviderLogEntry with auto-generated id, timestamp, and nanos.
4
+ */
5
+ export function makeEntry(platform, sessionID, category, hookName, input, output, meta) {
6
+ const now = Date.now();
7
+ const micro = Math.floor(Math.random() * 1_000_000);
8
+ return {
9
+ id: randomUUID(),
10
+ timestamp: new Date(now).toISOString(),
11
+ timestampNanos: `${now}${String(micro).padStart(6, "0")}`,
12
+ sessionID,
13
+ platform,
14
+ category,
15
+ hookName,
16
+ data: { input, output },
17
+ ...(meta ? { meta } : {}),
18
+ };
19
+ }
@@ -0,0 +1,8 @@
1
+ export type { ProviderLogEntry, Platform, EventCategory, OpenCodeHookName, PluginConfig, } from "./types.js";
2
+ export { defaultConfig, DEFAULT_CONFIG, LOG_EVENT_TYPES } from "./types.js";
3
+ export { SessionBuffer, estimateEntrySize } from "./buffer.js";
4
+ export { LocalWriter, makeTimestampNanos, _resetCounter } from "./logger.js";
5
+ export type { PendingFile } from "./logger.js";
6
+ export { R2Uploader } from "./uploader.js";
7
+ export type { LogFn } from "./uploader.js";
8
+ export { makeEntry } from "./entry.js";
@@ -0,0 +1,5 @@
1
+ export { defaultConfig, DEFAULT_CONFIG, LOG_EVENT_TYPES } from "./types.js";
2
+ export { SessionBuffer, estimateEntrySize } from "./buffer.js";
3
+ export { LocalWriter, makeTimestampNanos, _resetCounter } from "./logger.js";
4
+ export { R2Uploader } from "./uploader.js";
5
+ export { makeEntry } from "./entry.js";
@@ -0,0 +1,21 @@
1
+ import type { ProviderLogEntry, PluginConfig } from "./types.js";
2
+ export interface PendingFile {
3
+ path: string;
4
+ sessionID: string;
5
+ tsNanos: string;
6
+ }
7
+ export declare class LocalWriter {
8
+ private config;
9
+ constructor(config: PluginConfig);
10
+ private sessionDir;
11
+ /** Write a batch of entries to a new JSONL file (used by buffered mode) */
12
+ write(sessionID: string, entries: ProviderLogEntry[]): Promise<string>;
13
+ /** Append a single entry to a session-scoped JSONL file (used by hook scripts) */
14
+ append(sessionID: string, entry: ProviderLogEntry): Promise<string>;
15
+ getPending(): Promise<PendingFile[]>;
16
+ markUploaded(filepath: string): Promise<void>;
17
+ cleanup(): Promise<void>;
18
+ }
19
+ export declare function makeTimestampNanos(): string;
20
+ /** @internal — exposed for testing */
21
+ export declare function _resetCounter(): void;
@@ -0,0 +1,107 @@
1
+ import { mkdir, writeFile, appendFile, readdir, stat, unlink, rmdir, } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export class LocalWriter {
4
+ config;
5
+ constructor(config) {
6
+ this.config = config;
7
+ }
8
+ sessionDir(sessionID) {
9
+ return join(this.config.localDir, sessionID);
10
+ }
11
+ /** Write a batch of entries to a new JSONL file (used by buffered mode) */
12
+ async write(sessionID, entries) {
13
+ if (entries.length === 0)
14
+ return "";
15
+ const dir = this.sessionDir(sessionID);
16
+ await mkdir(dir, { recursive: true });
17
+ const tsNanos = makeTimestampNanos();
18
+ const filepath = join(dir, `${tsNanos}.jsonl`);
19
+ const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
20
+ await writeFile(filepath, content, "utf-8");
21
+ return filepath;
22
+ }
23
+ /** Append a single entry to a session-scoped JSONL file (used by hook scripts) */
24
+ async append(sessionID, entry) {
25
+ const dir = this.sessionDir(sessionID);
26
+ await mkdir(dir, { recursive: true });
27
+ // Use a stable filename per session so repeated hook calls append to one file
28
+ const filepath = join(dir, `${sessionID}.jsonl`);
29
+ await appendFile(filepath, JSON.stringify(entry) + "\n", "utf-8");
30
+ return filepath;
31
+ }
32
+ async getPending() {
33
+ const pending = [];
34
+ try {
35
+ const sessions = await readdir(this.config.localDir);
36
+ for (const sessionID of sessions) {
37
+ const dir = this.sessionDir(sessionID);
38
+ const s = await stat(dir).catch(() => null);
39
+ if (!s?.isDirectory())
40
+ continue;
41
+ const files = await readdir(dir);
42
+ const uploaded = new Set(files.filter((f) => f.endsWith(".uploaded")));
43
+ for (const file of files) {
44
+ if (file.endsWith(".jsonl") &&
45
+ !uploaded.has(`${file}.uploaded`)) {
46
+ pending.push({
47
+ path: join(dir, file),
48
+ sessionID,
49
+ tsNanos: file.replace(".jsonl", ""),
50
+ });
51
+ }
52
+ }
53
+ }
54
+ }
55
+ catch {
56
+ // directory may not exist yet
57
+ }
58
+ return pending;
59
+ }
60
+ async markUploaded(filepath) {
61
+ await writeFile(`${filepath}.uploaded`, "", "utf-8");
62
+ }
63
+ async cleanup() {
64
+ const cutoff = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
65
+ try {
66
+ const sessions = await readdir(this.config.localDir);
67
+ for (const sessionID of sessions) {
68
+ const dir = this.sessionDir(sessionID);
69
+ const s = await stat(dir).catch(() => null);
70
+ if (!s?.isDirectory())
71
+ continue;
72
+ const files = await readdir(dir);
73
+ for (const file of files) {
74
+ if (!file.endsWith(".jsonl"))
75
+ continue;
76
+ const filepath = join(dir, file);
77
+ const fstat = await stat(filepath).catch(() => null);
78
+ if (!fstat)
79
+ continue;
80
+ const hasMarker = files.includes(`${file}.uploaded`);
81
+ if (hasMarker && fstat.mtimeMs < cutoff) {
82
+ await unlink(filepath).catch(() => { });
83
+ await unlink(`${filepath}.uploaded`).catch(() => { });
84
+ }
85
+ }
86
+ // Remove empty session directories
87
+ const remaining = await readdir(dir).catch(() => ["placeholder"]);
88
+ if (remaining.length === 0) {
89
+ await rmdir(dir).catch(() => { });
90
+ }
91
+ }
92
+ }
93
+ catch {
94
+ // ignore
95
+ }
96
+ }
97
+ }
98
+ let _counter = 0;
99
+ export function makeTimestampNanos() {
100
+ const ms = Date.now();
101
+ const seq = _counter++;
102
+ return `${ms}${String(seq % 1_000_000).padStart(6, "0")}`;
103
+ }
104
+ /** @internal — exposed for testing */
105
+ export function _resetCounter() {
106
+ _counter = 0;
107
+ }
@@ -0,0 +1,42 @@
1
+ /** Supported platforms */
2
+ export type Platform = "opencode" | "claude-code" | "codex-cli";
3
+ /** Normalised event categories across all platforms */
4
+ export type EventCategory = "session.start" | "session.end" | "session.idle" | "session.compact" | "session.status" | "session.created" | "prompt.user" | "prompt.system" | "prompt.params" | "message.assistant" | "message.updated" | "tool.pre" | "tool.post" | "tool.error" | "permission.request" | "permission.denied" | "subagent.start" | "subagent.stop" | "task.created" | "task.completed" | "config.change" | "file.change" | "cwd.change" | "instructions.loaded" | "notification" | "elicitation" | "elicitation.result" | "raw";
5
+ /** A single log entry — the universal record written to JSONL */
6
+ export interface ProviderLogEntry {
7
+ id: string;
8
+ timestamp: string;
9
+ timestampNanos: string;
10
+ sessionID: string;
11
+ platform: Platform;
12
+ category: EventCategory;
13
+ /** Original platform-specific hook/event name (for debugging) */
14
+ hookName: string;
15
+ data: {
16
+ input: unknown;
17
+ output: unknown;
18
+ };
19
+ meta?: {
20
+ model?: string;
21
+ toolName?: string;
22
+ turnID?: string;
23
+ agentID?: string;
24
+ agentType?: string;
25
+ transcriptPath?: string;
26
+ cwd?: string;
27
+ userID?: string;
28
+ permissionMode?: string;
29
+ };
30
+ }
31
+ export type OpenCodeHookName = "chat.message" | "chat.params" | "experimental.chat.system.transform" | "experimental.chat.messages.transform" | "experimental.text.complete" | "tool.execute.before" | "tool.execute.after" | "event";
32
+ export interface PluginConfig {
33
+ localDir: string;
34
+ r2Remote: string;
35
+ retentionDays: number;
36
+ flushThresholdBytes: number;
37
+ }
38
+ export declare function defaultConfig(platform: Platform): PluginConfig;
39
+ /** @deprecated Use defaultConfig("opencode") instead */
40
+ export declare const DEFAULT_CONFIG: PluginConfig;
41
+ /** OpenCode event types worth logging */
42
+ export declare const LOG_EVENT_TYPES: Set<string>;
@@ -0,0 +1,27 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Platform-neutral provider log types
3
+ // ---------------------------------------------------------------------------
4
+ export function defaultConfig(platform) {
5
+ const home = process.env.HOME ?? require("node:os").homedir();
6
+ const dirMap = {
7
+ opencode: `${home}/.cache/opencode/provider-logs`,
8
+ "claude-code": `${home}/.cache/claude-code/provider-logs`,
9
+ "codex-cli": `${home}/.cache/codex-cli/provider-logs`,
10
+ };
11
+ return {
12
+ localDir: dirMap[platform],
13
+ r2Remote: "paradox:ailog",
14
+ retentionDays: 7,
15
+ flushThresholdBytes: 10 * 1024 * 1024, // 10 MB
16
+ };
17
+ }
18
+ /** @deprecated Use defaultConfig("opencode") instead */
19
+ export const DEFAULT_CONFIG = defaultConfig("opencode");
20
+ /** OpenCode event types worth logging */
21
+ export const LOG_EVENT_TYPES = new Set([
22
+ "message.updated",
23
+ "session.idle",
24
+ "session.status",
25
+ "session.created",
26
+ "session.compacted",
27
+ ]);
@@ -0,0 +1,12 @@
1
+ import type { PluginConfig } from "./types.js";
2
+ import type { LocalWriter, PendingFile } from "./logger.js";
3
+ export type LogFn = (level: "debug" | "info" | "warn" | "error", message: string, extra?: Record<string, unknown>) => void;
4
+ export declare class R2Uploader {
5
+ private config;
6
+ private writer;
7
+ private log;
8
+ constructor(config: PluginConfig, writer: LocalWriter, log: LogFn);
9
+ upload(file: PendingFile): Promise<boolean>;
10
+ retryPending(): Promise<void>;
11
+ cleanup(): Promise<void>;
12
+ }
@@ -0,0 +1,45 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ export class R2Uploader {
5
+ config;
6
+ writer;
7
+ log;
8
+ constructor(config, writer, log) {
9
+ this.config = config;
10
+ this.writer = writer;
11
+ this.log = log;
12
+ }
13
+ async upload(file) {
14
+ const remotePath = `${this.config.r2Remote}/${file.sessionID}/${file.tsNanos}.jsonl`;
15
+ try {
16
+ await execFileAsync("rclone", [
17
+ "copyto",
18
+ file.path,
19
+ remotePath,
20
+ "--no-traverse",
21
+ ]);
22
+ await this.writer.markUploaded(file.path);
23
+ this.log("info", `Uploaded → ${remotePath}`);
24
+ return true;
25
+ }
26
+ catch (err) {
27
+ this.log("warn", `Upload failed: ${file.path}`, {
28
+ error: String(err),
29
+ });
30
+ return false;
31
+ }
32
+ }
33
+ async retryPending() {
34
+ const pending = await this.writer.getPending();
35
+ if (pending.length === 0)
36
+ return;
37
+ this.log("info", `Retrying ${pending.length} pending upload(s)`);
38
+ for (const file of pending) {
39
+ await this.upload(file);
40
+ }
41
+ }
42
+ async cleanup() {
43
+ await this.writer.cleanup();
44
+ }
45
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
2
- declare const providerLogger: Plugin;
3
- export default providerLogger;
1
+ export { default } from "./adapters/opencode/index.js";
2
+ export type { ProviderLogEntry, Platform, EventCategory, PluginConfig, } from "@p4r4d0xb0x/provider-logger-core";
3
+ export { defaultConfig, SessionBuffer, LocalWriter, WorkerUploader, makeEntry, } from "@p4r4d0xb0x/provider-logger-core";
4
+ export { createHooks as createOpenCodeHooks } from "./adapters/opencode/hooks.js";
package/dist/index.js CHANGED
@@ -1,66 +1,5 @@
1
- import { SessionBuffer } from "./buffer.js";
2
- import { LocalWriter } from "./logger.js";
3
- import { R2Uploader } from "./uploader.js";
4
- import { createHooks } from "./hooks.js";
5
- import { DEFAULT_CONFIG } from "./types.js";
6
- import { basename } from "node:path";
7
- const providerLogger = async ({ client }) => {
8
- const config = { ...DEFAULT_CONFIG };
9
- const buffer = new SessionBuffer();
10
- const writer = new LocalWriter(config);
11
- const log = (level, message, extra) => {
12
- client.app.log({
13
- body: { service: "provider-logger", level, message, extra },
14
- });
15
- };
16
- const uploader = new R2Uploader(config, writer, log);
17
- // Retry any pending uploads from previous sessions
18
- uploader.retryPending().catch((err) => {
19
- log("warn", "Failed to retry pending uploads", { error: String(err) });
20
- });
21
- // Cleanup local files older than retention period
22
- uploader.cleanup().catch((err) => {
23
- log("warn", "Failed to cleanup old files", { error: String(err) });
24
- });
25
- const flush = async (sessionID) => {
26
- if (!buffer.has(sessionID))
27
- return;
28
- const entries = buffer.flush(sessionID);
29
- try {
30
- const filepath = await writer.write(sessionID, entries);
31
- if (filepath) {
32
- const tsNanos = basename(filepath, ".jsonl");
33
- await uploader.upload({ path: filepath, sessionID, tsNanos });
34
- }
35
- }
36
- catch (err) {
37
- log("error", `Flush failed for session ${sessionID}`, {
38
- error: String(err),
39
- });
40
- }
41
- };
42
- // Flush remaining buffers on process exit
43
- const flushAll = async () => {
44
- const all = buffer.flushAll();
45
- const writes = [];
46
- for (const [sessionID, entries] of all) {
47
- if (entries.length === 0)
48
- continue;
49
- writes.push(writer.write(sessionID, entries).then(async (filepath) => {
50
- if (filepath) {
51
- const tsNanos = basename(filepath, ".jsonl");
52
- await uploader.upload({ path: filepath, sessionID, tsNanos });
53
- }
54
- }).catch((err) => {
55
- log("error", `Shutdown flush failed for ${sessionID}`, { error: String(err) });
56
- }));
57
- }
58
- await Promise.allSettled(writes);
59
- };
60
- process.on("beforeExit", () => {
61
- flushAll().catch(() => { });
62
- });
63
- log("info", "Provider logger initialized");
64
- return createHooks(buffer, config, flush, log);
65
- };
66
- export default providerLogger;
1
+ // Default export: OpenCode plugin (backward compatible)
2
+ export { default } from "./adapters/opencode/index.js";
3
+ export { defaultConfig, SessionBuffer, LocalWriter, WorkerUploader, makeEntry, } from "@p4r4d0xb0x/provider-logger-core";
4
+ // Re-export OpenCode adapter
5
+ export { createHooks as createOpenCodeHooks } from "./adapters/opencode/hooks.js";
package/package.json CHANGED
@@ -1,16 +1,25 @@
1
1
  {
2
2
  "name": "@p4r4d0xb0x/opencode-provider-logger",
3
- "version": "0.1.0",
4
- "description": "OpenCode plugin that logs all provider requests/responses for model training",
3
+ "version": "1.0.0",
4
+ "description": "Provider logger plugin for OpenCode",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
8
14
  "files": ["dist"],
9
15
  "scripts": {
10
16
  "build": "tsc",
11
17
  "test": "bun test",
12
18
  "prepublishOnly": "tsc"
13
19
  },
20
+ "dependencies": {
21
+ "@p4r4d0xb0x/provider-logger-core": "file:../provider-logger-core"
22
+ },
14
23
  "peerDependencies": {
15
24
  "@opencode-ai/plugin": ">=1.3.0"
16
25
  },