alchemylab 1.3.4 → 1.4.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/dist/60.js +13 -13
- package/dist/939.js +16 -13
- package/dist/cli.js +1279 -888
- package/dist/http-server.js +2164 -2305
- package/dist/index.js +2232 -2370
- package/dist/mcp-direct.js +2162 -2300
- package/dist/plugins/hooks/lib/dedup-utils.js +6 -14
- package/dist/plugins/hooks/lib/hook-utils.js +5 -12
- package/dist/plugins/hooks/plugin.js +47 -244
- package/dist/plugins/inject-session.js +8 -10
- package/dist/plugins/system-logger.js +9 -11
- package/package.json +2 -2
|
@@ -1,17 +1,9 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
2
|
* Deduplication Utilities
|
|
4
3
|
*
|
|
5
4
|
* Functions to prevent duplicate hook triggers when OpenCode fires
|
|
6
5
|
* the same event multiple times in quick succession.
|
|
7
6
|
*/
|
|
8
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.createToolCallHash = createToolCallHash;
|
|
10
|
-
exports.isDuplicateToolCall = isDuplicateToolCall;
|
|
11
|
-
exports.clearRecentCalls = clearRecentCalls;
|
|
12
|
-
exports.getRecentCallsCount = getRecentCallsCount;
|
|
13
|
-
exports.tryMarkMessageProcessed = tryMarkMessageProcessed;
|
|
14
|
-
exports.clearProcessedMessages = clearProcessedMessages;
|
|
15
7
|
// Track recent tool calls with timestamps
|
|
16
8
|
const recentCalls = new Map();
|
|
17
9
|
// Default window for considering calls as duplicates (2 seconds)
|
|
@@ -24,7 +16,7 @@ const DEFAULT_DEDUP_WINDOW = 2000;
|
|
|
24
16
|
* @param text - The output or input text to include in hash (truncated to 100 chars)
|
|
25
17
|
* @returns A string hash identifying this specific call
|
|
26
18
|
*/
|
|
27
|
-
function createToolCallHash(toolName, text) {
|
|
19
|
+
export function createToolCallHash(toolName, text) {
|
|
28
20
|
// Simple hash using tool name + first 100 chars of text
|
|
29
21
|
return `${toolName}:${text?.substring(0, 100) || ''}`;
|
|
30
22
|
}
|
|
@@ -37,7 +29,7 @@ function createToolCallHash(toolName, text) {
|
|
|
37
29
|
* @param dedupWindow - Time window in ms to consider calls as duplicates (default: 2000ms)
|
|
38
30
|
* @returns true if this appears to be a duplicate call, false otherwise
|
|
39
31
|
*/
|
|
40
|
-
function isDuplicateToolCall(hash, now = Date.now(), dedupWindow = DEFAULT_DEDUP_WINDOW) {
|
|
32
|
+
export function isDuplicateToolCall(hash, now = Date.now(), dedupWindow = DEFAULT_DEDUP_WINDOW) {
|
|
41
33
|
const lastTime = recentCalls.get(hash);
|
|
42
34
|
if (lastTime && (now - lastTime) < dedupWindow) {
|
|
43
35
|
return true;
|
|
@@ -56,13 +48,13 @@ function isDuplicateToolCall(hash, now = Date.now(), dedupWindow = DEFAULT_DEDUP
|
|
|
56
48
|
/**
|
|
57
49
|
* Clear all tracked calls (useful for testing)
|
|
58
50
|
*/
|
|
59
|
-
function clearRecentCalls() {
|
|
51
|
+
export function clearRecentCalls() {
|
|
60
52
|
recentCalls.clear();
|
|
61
53
|
}
|
|
62
54
|
/**
|
|
63
55
|
* Get the number of tracked calls (useful for testing)
|
|
64
56
|
*/
|
|
65
|
-
function getRecentCallsCount() {
|
|
57
|
+
export function getRecentCallsCount() {
|
|
66
58
|
return recentCalls.size;
|
|
67
59
|
}
|
|
68
60
|
// Track last processed message ID per session to prevent duplicate triggers
|
|
@@ -75,7 +67,7 @@ const lastProcessedMessageId = new Map();
|
|
|
75
67
|
* This prevents race conditions where two concurrent calls both check
|
|
76
68
|
* hasProcessedMessage() before either calls markMessageProcessed().
|
|
77
69
|
*/
|
|
78
|
-
function tryMarkMessageProcessed(sessionId, messageId) {
|
|
70
|
+
export function tryMarkMessageProcessed(sessionId, messageId) {
|
|
79
71
|
if (!messageId)
|
|
80
72
|
return true; // No message ID = proceed (can't dedupe)
|
|
81
73
|
const lastId = lastProcessedMessageId.get(sessionId);
|
|
@@ -96,7 +88,7 @@ function tryMarkMessageProcessed(sessionId, messageId) {
|
|
|
96
88
|
/**
|
|
97
89
|
* Clear processed message tracking (for testing)
|
|
98
90
|
*/
|
|
99
|
-
function clearProcessedMessages() {
|
|
91
|
+
export function clearProcessedMessages() {
|
|
100
92
|
lastProcessedMessageId.clear();
|
|
101
93
|
}
|
|
102
94
|
//# sourceMappingURL=dedup-utils.js.map
|
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
2
|
* Hook Utility Functions
|
|
4
3
|
*
|
|
5
4
|
* Shared utilities for hook processing including template interpolation
|
|
6
5
|
* and ID extraction from tool outputs.
|
|
7
6
|
*/
|
|
8
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
-
exports.interpolatePrompt = interpolatePrompt;
|
|
10
|
-
exports.extractRequestId = extractRequestId;
|
|
11
|
-
exports.extractTaskId = extractTaskId;
|
|
12
|
-
exports.extractFilePath = extractFilePath;
|
|
13
|
-
exports.sanitizeTemplateValue = sanitizeTemplateValue;
|
|
14
7
|
/**
|
|
15
8
|
* Interpolate template variables in prompt
|
|
16
9
|
* Replaces {{variableName}} patterns with corresponding values from variables object
|
|
@@ -19,7 +12,7 @@ exports.sanitizeTemplateValue = sanitizeTemplateValue;
|
|
|
19
12
|
* @param variables - Key-value pairs to substitute into the template
|
|
20
13
|
* @returns The interpolated string with all variables replaced
|
|
21
14
|
*/
|
|
22
|
-
function interpolatePrompt(template, variables) {
|
|
15
|
+
export function interpolatePrompt(template, variables) {
|
|
23
16
|
let result = template;
|
|
24
17
|
for (const [key, value] of Object.entries(variables)) {
|
|
25
18
|
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
|
@@ -33,7 +26,7 @@ function interpolatePrompt(template, variables) {
|
|
|
33
26
|
* @param text - The tool output text to search
|
|
34
27
|
* @returns The request ID (e.g., "req001234") or null if not found
|
|
35
28
|
*/
|
|
36
|
-
function extractRequestId(text) {
|
|
29
|
+
export function extractRequestId(text) {
|
|
37
30
|
const patterns = [
|
|
38
31
|
/Request completed:\s*(req\d+)/i,
|
|
39
32
|
/Request (req\d+) completed/i,
|
|
@@ -56,7 +49,7 @@ function extractRequestId(text) {
|
|
|
56
49
|
* @param text - The tool output text to search
|
|
57
50
|
* @returns The task ID (e.g., "tas002152") or null if not found
|
|
58
51
|
*/
|
|
59
|
-
function extractTaskId(text) {
|
|
52
|
+
export function extractTaskId(text) {
|
|
60
53
|
const match = text.match(/Task\s+(tas\d+).*(?:completed|marked as completed)/i);
|
|
61
54
|
return match ? match[1] : null;
|
|
62
55
|
}
|
|
@@ -67,7 +60,7 @@ function extractTaskId(text) {
|
|
|
67
60
|
* @param text - The tool output text to search
|
|
68
61
|
* @returns The file path or null if not found
|
|
69
62
|
*/
|
|
70
|
-
function extractFilePath(text) {
|
|
63
|
+
export function extractFilePath(text) {
|
|
71
64
|
// Match "Created file X" or "**Created file X**"
|
|
72
65
|
const createdMatch = text.match(/\*?\*?Created file\s+([^\s*]+)/i);
|
|
73
66
|
if (createdMatch)
|
|
@@ -86,7 +79,7 @@ function extractFilePath(text) {
|
|
|
86
79
|
* @param maxLength - Maximum length before truncation (default: 1000)
|
|
87
80
|
* @returns The sanitized string
|
|
88
81
|
*/
|
|
89
|
-
function sanitizeTemplateValue(value, maxLength = 1000) {
|
|
82
|
+
export function sanitizeTemplateValue(value, maxLength = 1000) {
|
|
90
83
|
if (!value)
|
|
91
84
|
return '';
|
|
92
85
|
if (value.length > maxLength) {
|
|
@@ -1,269 +1,69 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
|
-
* OpenCode Event Hooks Plugin —
|
|
2
|
+
* OpenCode Event Hooks Plugin — Raw Event Emitter
|
|
4
3
|
*
|
|
5
4
|
* Detects events from OpenCode (tool execution, session lifecycle) and POSTs
|
|
6
|
-
* them to the action server
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* them to the action server. The plugin itself does no parsing or event-type
|
|
6
|
+
* derivation: it ships raw envelopes and the action server's hook layer
|
|
7
|
+
* handles extraction, derivation, condition evaluation, and execution.
|
|
9
8
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const fs_1 = require("fs");
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { readFileSync, existsSync } from "fs";
|
|
13
11
|
const ACTION_SERVER_PORT = 3456;
|
|
14
|
-
const
|
|
12
|
+
const ACTION_SERVER_BASE = `http://localhost:${ACTION_SERVER_PORT}`;
|
|
13
|
+
const HOOK_EVENT_URL = `${ACTION_SERVER_BASE}/api/hook-event`;
|
|
14
|
+
const TOOL_EVENT_URL = `${ACTION_SERVER_BASE}/api/tool-event`;
|
|
15
15
|
// Singleton guard — OpenCode calls plugin init twice
|
|
16
16
|
let initializedDirectory = null;
|
|
17
17
|
let cachedHandlers = null;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*/
|
|
21
|
-
function extractRequestId(text) {
|
|
22
|
-
const patterns = [
|
|
23
|
-
/Request completed:\s*(req\d+)/i,
|
|
24
|
-
/Request (req\d+) completed/i,
|
|
25
|
-
/completed.*?(req\d{6})/i,
|
|
26
|
-
/Request finalized:\s*(req\d+)/i,
|
|
27
|
-
/Request (req\d+) finalized/i,
|
|
28
|
-
/finalized.*?(req\d{6})/i,
|
|
29
|
-
/Request (req\d+) merged/i,
|
|
30
|
-
/merged.*?(req\d{6})/i,
|
|
31
|
-
];
|
|
32
|
-
for (const pattern of patterns) {
|
|
33
|
-
const match = text.match(pattern);
|
|
34
|
-
if (match)
|
|
35
|
-
return match[1];
|
|
36
|
-
}
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Extract task ID from tool output
|
|
41
|
-
*/
|
|
42
|
-
function extractTaskId(text) {
|
|
43
|
-
const match = text.match(/Task\s+(tas\d+).*(?:completed|marked as completed)/i);
|
|
44
|
-
return match ? match[1] : null;
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Fire-and-forget POST to action server. Does not throw.
|
|
48
|
-
*/
|
|
49
|
-
function postHookEvent(eventType, sessionId, variables, timing) {
|
|
50
|
-
fetch(ACTION_SERVER_URL, {
|
|
18
|
+
function postJson(url, body) {
|
|
19
|
+
fetch(url, {
|
|
51
20
|
method: 'POST',
|
|
52
21
|
headers: { 'Content-Type': 'application/json' },
|
|
53
|
-
body: JSON.stringify(
|
|
22
|
+
body: JSON.stringify(body),
|
|
54
23
|
}).catch(() => { }); // Fire-and-forget
|
|
55
24
|
}
|
|
56
|
-
|
|
57
|
-
* Await POST to action server. Returns silently on failure.
|
|
58
|
-
*/
|
|
59
|
-
async function postHookEventAsync(eventType, sessionId, variables, timing) {
|
|
25
|
+
async function postJsonAwaited(url, body) {
|
|
60
26
|
try {
|
|
61
|
-
await fetch(
|
|
27
|
+
await fetch(url, {
|
|
62
28
|
method: 'POST',
|
|
63
29
|
headers: { 'Content-Type': 'application/json' },
|
|
64
|
-
body: JSON.stringify(
|
|
30
|
+
body: JSON.stringify(body),
|
|
65
31
|
});
|
|
66
32
|
}
|
|
67
|
-
catch {
|
|
68
|
-
// Non-fatal
|
|
69
|
-
}
|
|
33
|
+
catch { /* non-fatal */ }
|
|
70
34
|
}
|
|
71
35
|
/**
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const events = [];
|
|
76
|
-
const baseVars = {
|
|
77
|
-
output: resultText || '',
|
|
78
|
-
toolName,
|
|
79
|
-
sessionId: sessionId || '',
|
|
80
|
-
};
|
|
81
|
-
const isRequestOps = toolName.includes('request_operations') || toolName.includes('complete_request');
|
|
82
|
-
const isTaskOps = toolName.includes('task_operations') || toolName.includes('complete_task');
|
|
83
|
-
const isWriteFile = toolName.includes('write_file');
|
|
84
|
-
const isReplaceInFiles = toolName.includes('replace_in_files');
|
|
85
|
-
const isQuickEdit = toolName.includes('quick_write') || toolName.includes('quick_replace') || toolName.includes('quick_manage');
|
|
86
|
-
const isReadFiles = toolName.includes('read_files');
|
|
87
|
-
const isSearch = toolName.includes('search');
|
|
88
|
-
const isManageFiles = toolName.includes('manage_files');
|
|
89
|
-
const isDocumentOps = toolName.includes('document_operations');
|
|
90
|
-
const isQueryHistory = toolName.includes('query_history');
|
|
91
|
-
if (isRequestOps && resultText) {
|
|
92
|
-
const toolArgs = input?.args || input?.input || {};
|
|
93
|
-
const operation = toolArgs.operation;
|
|
94
|
-
const requestId = extractRequestId(resultText);
|
|
95
|
-
// Debug: log what we see for request ops to diagnose hook firing
|
|
96
|
-
try {
|
|
97
|
-
const fs = require('fs'), path = require('path');
|
|
98
|
-
const debugPath = path.join(process.env.ALCHEMY_PROJECT_PATH || process.cwd(), '.alchemy_lab', 'hook-debug.log');
|
|
99
|
-
const inputKeys = Object.keys(input || {}).join(',');
|
|
100
|
-
const inputInputKeys = Object.keys(input?.input || {}).join(',');
|
|
101
|
-
fs.appendFileSync(debugPath, `[${new Date().toISOString()}] request_ops: tool=${toolName} op=${operation} reqId=${requestId} inputKeys=[${inputKeys}] input.inputKeys=[${inputInputKeys}] input.input.op=${input?.input?.operation} input.operation=${input?.operation} result=${resultText?.substring(0, 80)}\n`);
|
|
102
|
-
}
|
|
103
|
-
catch { }
|
|
104
|
-
if (requestId) {
|
|
105
|
-
if (operation === 'merge' || operation === 'finalize') {
|
|
106
|
-
// Actual merge to main
|
|
107
|
-
events.push({
|
|
108
|
-
eventType: 'request_completed',
|
|
109
|
-
variables: { ...baseVars, requestId },
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
else if (operation === 'complete') {
|
|
113
|
-
// Phase 1 — work done, ready for merge
|
|
114
|
-
events.push({
|
|
115
|
-
eventType: 'request_ready_for_merge',
|
|
116
|
-
variables: { ...baseVars, requestId },
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
else if (isTaskOps && resultText) {
|
|
122
|
-
const taskId = extractTaskId(resultText);
|
|
123
|
-
if (taskId) {
|
|
124
|
-
events.push({
|
|
125
|
-
eventType: 'task_completed',
|
|
126
|
-
variables: { ...baseVars, taskId },
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
else if (isWriteFile) {
|
|
131
|
-
const pathMatch = resultText?.match(/(?:Created|Updated|Wrote)\s+(?:file\s+)?([^\s\n]+)/i);
|
|
132
|
-
events.push({
|
|
133
|
-
eventType: 'write_file',
|
|
134
|
-
variables: { ...baseVars, path: pathMatch?.[1] || '' },
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
else if (isReplaceInFiles) {
|
|
138
|
-
const replaceArgs = input?.args || input?.input || {};
|
|
139
|
-
let operations = replaceArgs.operations || [];
|
|
140
|
-
if (typeof operations === 'string') {
|
|
141
|
-
try {
|
|
142
|
-
operations = JSON.parse(operations);
|
|
143
|
-
}
|
|
144
|
-
catch {
|
|
145
|
-
operations = [];
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
const paths = operations.map((op) => op.path || op.documentId).filter(Boolean);
|
|
149
|
-
events.push({
|
|
150
|
-
eventType: 'replace_in_files',
|
|
151
|
-
variables: { ...baseVars, paths: paths.join(', '), fileCount: String(paths.length) },
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
else if (isQuickEdit) {
|
|
155
|
-
const vars = {
|
|
156
|
-
...baseVars,
|
|
157
|
-
path: (input?.args || input?.input)?.operationArgs?.path || '',
|
|
158
|
-
title: (input?.args || input?.input)?.title || '',
|
|
159
|
-
};
|
|
160
|
-
const qeRequestMatch = resultText?.match(/\*\*Request:\*\*\s*(req\d+)/i);
|
|
161
|
-
if (qeRequestMatch)
|
|
162
|
-
vars.requestId = qeRequestMatch[1];
|
|
163
|
-
events.push({ eventType: 'quick_edit', variables: vars });
|
|
164
|
-
// Quick-edit auto-completes requests — also emit request_completed
|
|
165
|
-
if (vars.requestId) {
|
|
166
|
-
events.push({
|
|
167
|
-
eventType: 'request_completed',
|
|
168
|
-
variables: { ...baseVars, requestId: vars.requestId },
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
else if (isReadFiles) {
|
|
173
|
-
let pathsInput = (input?.args || input?.input)?.paths || [];
|
|
174
|
-
if (typeof pathsInput === 'string') {
|
|
175
|
-
try {
|
|
176
|
-
pathsInput = JSON.parse(pathsInput);
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
pathsInput = [];
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
const paths = pathsInput.map((p) => p.path || p.documentId).filter(Boolean);
|
|
183
|
-
events.push({
|
|
184
|
-
eventType: 'read_files',
|
|
185
|
-
variables: { ...baseVars, paths: paths.join(', '), fileCount: String(paths.length) },
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
else if (isSearch) {
|
|
189
|
-
events.push({
|
|
190
|
-
eventType: 'search',
|
|
191
|
-
variables: {
|
|
192
|
-
...baseVars,
|
|
193
|
-
query: (input?.args || input?.input)?.needle || '',
|
|
194
|
-
directory: (input?.args || input?.input)?.directory || '',
|
|
195
|
-
fileExtension: (input?.args || input?.input)?.options?.fileExtension || '*',
|
|
196
|
-
},
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
else if (isManageFiles) {
|
|
200
|
-
const manageArgs = input?.args || input?.input || {};
|
|
201
|
-
let manageOps = manageArgs.operations || [];
|
|
202
|
-
if (typeof manageOps === 'string') {
|
|
203
|
-
try {
|
|
204
|
-
manageOps = JSON.parse(manageOps);
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
manageOps = [];
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
const opTypes = manageOps.map((op) => op.operationType).filter(Boolean);
|
|
211
|
-
events.push({
|
|
212
|
-
eventType: 'manage_files',
|
|
213
|
-
variables: { ...baseVars, operationTypes: [...new Set(opTypes)].join(', ') },
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
else if (isDocumentOps) {
|
|
217
|
-
events.push({ eventType: 'document_operations', variables: baseVars });
|
|
218
|
-
}
|
|
219
|
-
else if (isQueryHistory) {
|
|
220
|
-
events.push({ eventType: 'query_history', variables: baseVars });
|
|
221
|
-
}
|
|
222
|
-
// Always emit tool_executed for any tool
|
|
223
|
-
events.push({
|
|
224
|
-
eventType: 'tool_executed',
|
|
225
|
-
variables: {
|
|
226
|
-
tool: toolName,
|
|
227
|
-
toolName,
|
|
228
|
-
output: resultText || '',
|
|
229
|
-
sessionId: sessionId || '',
|
|
230
|
-
},
|
|
231
|
-
});
|
|
232
|
-
return events;
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* Fetch recent output from a session (for lastOutput variable in session_idle).
|
|
36
|
+
* Fetch recent assistant output from a session. Used by the session.idle
|
|
37
|
+
* handler to populate the lastOutput hook variable; OpenCode's local server
|
|
38
|
+
* is reachable only from the plugin process.
|
|
236
39
|
*/
|
|
237
40
|
async function fetchRecentSessionOutput(sessionId, directory) {
|
|
238
41
|
try {
|
|
239
|
-
const configPath =
|
|
240
|
-
if (!
|
|
42
|
+
const configPath = join(directory, '.alchemy_lab', 'opencode.json');
|
|
43
|
+
if (!existsSync(configPath))
|
|
241
44
|
return { output: '' };
|
|
242
|
-
const config = JSON.parse(
|
|
45
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
243
46
|
const serverUrl = config.port ? `http://127.0.0.1:${config.port}` : null;
|
|
244
47
|
if (!serverUrl)
|
|
245
48
|
return { output: '' };
|
|
246
|
-
const
|
|
247
|
-
const response = await fetch(url);
|
|
49
|
+
const response = await fetch(`${serverUrl}/session/${sessionId}/message?limit=5`);
|
|
248
50
|
if (!response.ok)
|
|
249
51
|
return { output: '' };
|
|
250
52
|
const messages = await response.json();
|
|
251
53
|
const assistantMessages = messages.filter(m => m.info.role === 'assistant');
|
|
252
|
-
const lastAssistantMessage = assistantMessages[0];
|
|
253
54
|
const assistantOutput = assistantMessages
|
|
254
55
|
.flatMap(m => m.parts)
|
|
255
56
|
.filter(p => p.type === 'text' && p.text)
|
|
256
57
|
.map(p => p.text)
|
|
257
58
|
.join('\n');
|
|
258
|
-
return { output: assistantOutput, lastMessageId:
|
|
59
|
+
return { output: assistantOutput, lastMessageId: assistantMessages[0]?.info?.id };
|
|
259
60
|
}
|
|
260
61
|
catch {
|
|
261
62
|
return { output: '' };
|
|
262
63
|
}
|
|
263
64
|
}
|
|
264
|
-
|
|
65
|
+
export default async ({ directory }) => {
|
|
265
66
|
const dir = directory || process.cwd();
|
|
266
|
-
// Singleton guard
|
|
267
67
|
if (initializedDirectory === dir && cachedHandlers) {
|
|
268
68
|
return cachedHandlers;
|
|
269
69
|
}
|
|
@@ -273,48 +73,51 @@ exports.default = async ({ directory }) => {
|
|
|
273
73
|
const toolName = input?.tool || input?.name;
|
|
274
74
|
if (!toolName)
|
|
275
75
|
return;
|
|
276
|
-
|
|
277
|
-
const inputText = JSON.stringify(input?.args || input?.input || {}).substring(0, 1000);
|
|
278
|
-
postHookEvent('tool_executed', sessionId, {
|
|
279
|
-
tool: toolName,
|
|
76
|
+
postJson(TOOL_EVENT_URL, {
|
|
280
77
|
toolName,
|
|
281
|
-
|
|
282
|
-
|
|
78
|
+
sessionId: input?.sessionID || '',
|
|
79
|
+
input: input?.args || input?.input || {},
|
|
80
|
+
timing: 'before',
|
|
81
|
+
});
|
|
283
82
|
},
|
|
284
83
|
"tool.execute.after": async (input, output) => {
|
|
285
84
|
const toolName = input?.tool || input?.name;
|
|
286
85
|
if (!toolName)
|
|
287
86
|
return;
|
|
288
|
-
const sessionId = input?.sessionID || '';
|
|
289
87
|
const resultText = output?.content?.[0]?.text;
|
|
290
88
|
if (!resultText)
|
|
291
89
|
return;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
90
|
+
await postJsonAwaited(TOOL_EVENT_URL, {
|
|
91
|
+
toolName,
|
|
92
|
+
sessionId: input?.sessionID || '',
|
|
93
|
+
input: input?.args || input?.input || {},
|
|
94
|
+
output: resultText,
|
|
95
|
+
timing: 'after',
|
|
96
|
+
});
|
|
296
97
|
},
|
|
297
98
|
event: async ({ event }) => {
|
|
298
99
|
switch (event.type) {
|
|
299
100
|
case 'session.idle': {
|
|
300
101
|
const sessionId = event.properties.sessionID || '';
|
|
301
102
|
const { output: lastOutput, lastMessageId } = await fetchRecentSessionOutput(sessionId, dir);
|
|
302
|
-
|
|
303
|
-
|
|
103
|
+
postJson(HOOK_EVENT_URL, {
|
|
104
|
+
eventType: 'session_idle',
|
|
304
105
|
sessionId,
|
|
305
|
-
lastOutput,
|
|
306
|
-
lastMessageId: lastMessageId || '',
|
|
106
|
+
variables: { sessionId, lastOutput, lastMessageId: lastMessageId || '' },
|
|
307
107
|
});
|
|
308
108
|
return;
|
|
309
109
|
}
|
|
310
110
|
case 'session.created': {
|
|
311
|
-
const
|
|
312
|
-
const sessionId = sessionInfo?.id || '';
|
|
111
|
+
const sessionId = event.properties.info?.id || '';
|
|
313
112
|
if (!sessionId)
|
|
314
113
|
return;
|
|
315
|
-
// Delay
|
|
114
|
+
// Delay so workflow assignment lands before the hook fires.
|
|
316
115
|
setTimeout(() => {
|
|
317
|
-
|
|
116
|
+
postJson(HOOK_EVENT_URL, {
|
|
117
|
+
eventType: 'session_created',
|
|
118
|
+
sessionId,
|
|
119
|
+
variables: { sessionId },
|
|
120
|
+
});
|
|
318
121
|
}, 500);
|
|
319
122
|
return;
|
|
320
123
|
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
2
|
* OpenCode Inject Session Plugin
|
|
4
3
|
*
|
|
@@ -7,30 +6,29 @@
|
|
|
7
6
|
*
|
|
8
7
|
* Hooks into tool.execute.before to modify args before the MCP tool executes.
|
|
9
8
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const path_1 = require("path");
|
|
9
|
+
import { appendFileSync } from 'fs';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import { join } from 'path';
|
|
14
12
|
// Log immediately when module is loaded
|
|
15
13
|
try {
|
|
16
|
-
const logFile =
|
|
17
|
-
|
|
14
|
+
const logFile = join(homedir(), 'inject-session-plugin.log');
|
|
15
|
+
appendFileSync(logFile, `[${new Date().toISOString()}] MODULE LOADED - file was imported\n`);
|
|
18
16
|
}
|
|
19
17
|
catch (e) {
|
|
20
18
|
// ignore
|
|
21
19
|
}
|
|
22
20
|
const debugLog = (msg, data) => {
|
|
23
|
-
const logFile =
|
|
21
|
+
const logFile = join(homedir(), 'inject-session-plugin.log');
|
|
24
22
|
const line = `[${new Date().toISOString()}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}\n`;
|
|
25
23
|
try {
|
|
26
|
-
|
|
24
|
+
appendFileSync(logFile, line);
|
|
27
25
|
}
|
|
28
26
|
catch (e) {
|
|
29
27
|
// ignore
|
|
30
28
|
}
|
|
31
29
|
console.log('[inject-session-plugin]', msg, data || '');
|
|
32
30
|
};
|
|
33
|
-
|
|
31
|
+
export default async ({ project, directory }) => {
|
|
34
32
|
debugLog('Plugin loaded', { project: project?.id, directory });
|
|
35
33
|
return {
|
|
36
34
|
"tool.execute.before": async (context, data) => {
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
/**
|
|
3
2
|
* OpenCode System Logger Plugin
|
|
4
3
|
*
|
|
@@ -6,15 +5,14 @@
|
|
|
6
5
|
* to .alchemy_lab/system-prompt.log. Used to diagnose whether entity context
|
|
7
6
|
* (from @ mentions) reaches the LLM via the user.system field.
|
|
8
7
|
*/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const getLogFile = (directory) => (0, path_1.join)(directory, '.alchemy_lab', 'system-prompt.log');
|
|
8
|
+
import { appendFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
const getLogFile = (directory) => join(directory, '.alchemy_lab', 'system-prompt.log');
|
|
13
11
|
const debugLog = (logFile, msg, data) => {
|
|
14
12
|
const line = `[${new Date().toISOString()}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}\n`;
|
|
15
13
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
mkdirSync(join(logFile, '..'), { recursive: true });
|
|
15
|
+
appendFileSync(logFile, line);
|
|
18
16
|
}
|
|
19
17
|
catch (e) {
|
|
20
18
|
// ignore
|
|
@@ -40,7 +38,7 @@ function stripTaskReferences(system) {
|
|
|
40
38
|
system[i] = s;
|
|
41
39
|
}
|
|
42
40
|
}
|
|
43
|
-
|
|
41
|
+
export default async ({ directory }) => {
|
|
44
42
|
const logFile = getLogFile(directory);
|
|
45
43
|
debugLog(logFile, 'system-logger plugin loaded', { directory });
|
|
46
44
|
return {
|
|
@@ -71,10 +69,10 @@ exports.default = async ({ directory }) => {
|
|
|
71
69
|
'',
|
|
72
70
|
];
|
|
73
71
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
mkdirSync(join(logFile, '..'), { recursive: true });
|
|
73
|
+
appendFileSync(logFile, lines.join('\n') + '\n');
|
|
76
74
|
// Also write full untruncated prompt to separate file (overwrite each time)
|
|
77
|
-
const fullLogFile =
|
|
75
|
+
const fullLogFile = join(logFile, '..', 'system-prompt-full.log');
|
|
78
76
|
const fullLines = [
|
|
79
77
|
`Timestamp: ${new Date().toISOString()}`,
|
|
80
78
|
`Session: ${hookInput.sessionID || 'N/A'}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alchemylab",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "AlchemyLab - agentic coding IDEm",
|
|
6
6
|
"author": "Testfire Software",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"ws": "^8.14.2",
|
|
50
50
|
"zod": "^3.22.4",
|
|
51
51
|
"zod-to-json-schema": "^3.22.3",
|
|
52
|
-
"alchemylab-opencode": "^1.0.
|
|
52
|
+
"alchemylab-opencode": "^1.0.15"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@types/chai": "^4.3.5",
|