alchemylab 1.3.5 → 1.4.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/414.js +9 -0
- package/dist/60.js +13 -13
- package/dist/939.js +16 -13
- package/dist/cli.js +1279 -888
- package/dist/http-server.js +2170 -2316
- package/dist/index.js +2238 -2381
- package/dist/mcp-cli-bridge.js +18 -10
- package/dist/mcp-direct.js +2168 -2311
- 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 +74 -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,320 +1,150 @@
|
|
|
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
|
|
13
|
-
const ACTION_SERVER_PORT = 3456;
|
|
14
|
-
const ACTION_SERVER_URL = `http://localhost:${ACTION_SERVER_PORT}/api/hook-event`;
|
|
15
|
-
// Singleton guard — OpenCode calls plugin init twice
|
|
16
|
-
let initializedDirectory = null;
|
|
17
|
-
let cachedHandlers = null;
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { readFileSync, existsSync } from "fs";
|
|
11
|
+
const DEFAULT_ACTION_SERVER_PORT = 3456;
|
|
18
12
|
/**
|
|
19
|
-
*
|
|
13
|
+
* Read the action server's actual port from `${directory}/.alchemy_lab/action-server.json`.
|
|
14
|
+
* The action server auto-increments from 3456 when the preferred port is busy
|
|
15
|
+
* (multiple alchemylab installs on one machine), and writes the resolved port
|
|
16
|
+
* to that file. Without this discovery we'd POST events to whatever first
|
|
17
|
+
* action server happens to own 3456 — wrong project, hooks never fire.
|
|
18
|
+
*
|
|
19
|
+
* Falls back to 3456 if the file is missing or malformed.
|
|
20
20
|
*/
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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];
|
|
21
|
+
function readActionServerPort(directory) {
|
|
22
|
+
try {
|
|
23
|
+
const filePath = join(directory, '.alchemy_lab', 'action-server.json');
|
|
24
|
+
if (!existsSync(filePath))
|
|
25
|
+
return DEFAULT_ACTION_SERVER_PORT;
|
|
26
|
+
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
27
|
+
if (typeof data.port === 'number' && data.port > 0 && data.port < 65536) {
|
|
28
|
+
return data.port;
|
|
29
|
+
}
|
|
36
30
|
}
|
|
37
|
-
|
|
31
|
+
catch { /* fall through */ }
|
|
32
|
+
return DEFAULT_ACTION_SERVER_PORT;
|
|
38
33
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
function
|
|
43
|
-
|
|
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, {
|
|
34
|
+
// Singleton guard — OpenCode calls plugin init twice
|
|
35
|
+
let initializedDirectory = null;
|
|
36
|
+
let cachedHandlers = null;
|
|
37
|
+
function postJson(url, body) {
|
|
38
|
+
fetch(url, {
|
|
51
39
|
method: 'POST',
|
|
52
40
|
headers: { 'Content-Type': 'application/json' },
|
|
53
|
-
body: JSON.stringify(
|
|
41
|
+
body: JSON.stringify(body),
|
|
54
42
|
}).catch(() => { }); // Fire-and-forget
|
|
55
43
|
}
|
|
56
|
-
|
|
57
|
-
* Await POST to action server. Returns silently on failure.
|
|
58
|
-
*/
|
|
59
|
-
async function postHookEventAsync(eventType, sessionId, variables, timing) {
|
|
44
|
+
async function postJsonAwaited(url, body) {
|
|
60
45
|
try {
|
|
61
|
-
await fetch(
|
|
46
|
+
await fetch(url, {
|
|
62
47
|
method: 'POST',
|
|
63
48
|
headers: { 'Content-Type': 'application/json' },
|
|
64
|
-
body: JSON.stringify(
|
|
49
|
+
body: JSON.stringify(body),
|
|
65
50
|
});
|
|
66
51
|
}
|
|
67
|
-
catch {
|
|
68
|
-
// Non-fatal
|
|
69
|
-
}
|
|
52
|
+
catch { /* non-fatal */ }
|
|
70
53
|
}
|
|
71
54
|
/**
|
|
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).
|
|
55
|
+
* Fetch recent assistant output from a session. Used by the session.idle
|
|
56
|
+
* handler to populate the lastOutput hook variable; OpenCode's local server
|
|
57
|
+
* is reachable only from the plugin process.
|
|
236
58
|
*/
|
|
237
59
|
async function fetchRecentSessionOutput(sessionId, directory) {
|
|
238
60
|
try {
|
|
239
|
-
const configPath =
|
|
240
|
-
if (!
|
|
61
|
+
const configPath = join(directory, '.alchemy_lab', 'opencode.json');
|
|
62
|
+
if (!existsSync(configPath))
|
|
241
63
|
return { output: '' };
|
|
242
|
-
const config = JSON.parse(
|
|
64
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
243
65
|
const serverUrl = config.port ? `http://127.0.0.1:${config.port}` : null;
|
|
244
66
|
if (!serverUrl)
|
|
245
67
|
return { output: '' };
|
|
246
|
-
const
|
|
247
|
-
const response = await fetch(url);
|
|
68
|
+
const response = await fetch(`${serverUrl}/session/${sessionId}/message?limit=5`);
|
|
248
69
|
if (!response.ok)
|
|
249
70
|
return { output: '' };
|
|
250
71
|
const messages = await response.json();
|
|
251
72
|
const assistantMessages = messages.filter(m => m.info.role === 'assistant');
|
|
252
|
-
const lastAssistantMessage = assistantMessages[0];
|
|
253
73
|
const assistantOutput = assistantMessages
|
|
254
74
|
.flatMap(m => m.parts)
|
|
255
75
|
.filter(p => p.type === 'text' && p.text)
|
|
256
76
|
.map(p => p.text)
|
|
257
77
|
.join('\n');
|
|
258
|
-
return { output: assistantOutput, lastMessageId:
|
|
78
|
+
return { output: assistantOutput, lastMessageId: assistantMessages[0]?.info?.id };
|
|
259
79
|
}
|
|
260
80
|
catch {
|
|
261
81
|
return { output: '' };
|
|
262
82
|
}
|
|
263
83
|
}
|
|
264
|
-
|
|
84
|
+
export default async ({ directory }) => {
|
|
265
85
|
const dir = directory || process.cwd();
|
|
266
|
-
// Singleton guard
|
|
267
86
|
if (initializedDirectory === dir && cachedHandlers) {
|
|
268
87
|
return cachedHandlers;
|
|
269
88
|
}
|
|
270
89
|
initializedDirectory = dir;
|
|
90
|
+
// Discover the action server's actual port from the project's
|
|
91
|
+
// .alchemy_lab/action-server.json. Multiple alchemylab installs on one
|
|
92
|
+
// machine race for 3456; without this discovery hooks for any non-first
|
|
93
|
+
// project would silently land at the wrong action server (or nowhere).
|
|
94
|
+
const actionServerPort = readActionServerPort(dir);
|
|
95
|
+
const actionServerBase = `http://localhost:${actionServerPort}`;
|
|
96
|
+
const HOOK_EVENT_URL = `${actionServerBase}/api/hook-event`;
|
|
97
|
+
const TOOL_EVENT_URL = `${actionServerBase}/api/tool-event`;
|
|
271
98
|
const handlers = {
|
|
272
99
|
"tool.execute.before": async (input) => {
|
|
273
100
|
const toolName = input?.tool || input?.name;
|
|
274
101
|
if (!toolName)
|
|
275
102
|
return;
|
|
276
|
-
|
|
277
|
-
const inputText = JSON.stringify(input?.args || input?.input || {}).substring(0, 1000);
|
|
278
|
-
postHookEvent('tool_executed', sessionId, {
|
|
279
|
-
tool: toolName,
|
|
103
|
+
postJson(TOOL_EVENT_URL, {
|
|
280
104
|
toolName,
|
|
281
|
-
|
|
282
|
-
|
|
105
|
+
sessionId: input?.sessionID || '',
|
|
106
|
+
input: input?.args || input?.input || {},
|
|
107
|
+
timing: 'before',
|
|
108
|
+
});
|
|
283
109
|
},
|
|
284
110
|
"tool.execute.after": async (input, output) => {
|
|
285
111
|
const toolName = input?.tool || input?.name;
|
|
286
112
|
if (!toolName)
|
|
287
113
|
return;
|
|
288
|
-
const sessionId = input?.sessionID || '';
|
|
289
114
|
const resultText = output?.content?.[0]?.text;
|
|
290
115
|
if (!resultText)
|
|
291
116
|
return;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
117
|
+
await postJsonAwaited(TOOL_EVENT_URL, {
|
|
118
|
+
toolName,
|
|
119
|
+
sessionId: input?.sessionID || '',
|
|
120
|
+
input: input?.args || input?.input || {},
|
|
121
|
+
output: resultText,
|
|
122
|
+
timing: 'after',
|
|
123
|
+
});
|
|
296
124
|
},
|
|
297
125
|
event: async ({ event }) => {
|
|
298
126
|
switch (event.type) {
|
|
299
127
|
case 'session.idle': {
|
|
300
128
|
const sessionId = event.properties.sessionID || '';
|
|
301
129
|
const { output: lastOutput, lastMessageId } = await fetchRecentSessionOutput(sessionId, dir);
|
|
302
|
-
|
|
303
|
-
|
|
130
|
+
postJson(HOOK_EVENT_URL, {
|
|
131
|
+
eventType: 'session_idle',
|
|
304
132
|
sessionId,
|
|
305
|
-
lastOutput,
|
|
306
|
-
lastMessageId: lastMessageId || '',
|
|
133
|
+
variables: { sessionId, lastOutput, lastMessageId: lastMessageId || '' },
|
|
307
134
|
});
|
|
308
135
|
return;
|
|
309
136
|
}
|
|
310
137
|
case 'session.created': {
|
|
311
|
-
const
|
|
312
|
-
const sessionId = sessionInfo?.id || '';
|
|
138
|
+
const sessionId = event.properties.info?.id || '';
|
|
313
139
|
if (!sessionId)
|
|
314
140
|
return;
|
|
315
|
-
// Delay
|
|
141
|
+
// Delay so workflow assignment lands before the hook fires.
|
|
316
142
|
setTimeout(() => {
|
|
317
|
-
|
|
143
|
+
postJson(HOOK_EVENT_URL, {
|
|
144
|
+
eventType: 'session_created',
|
|
145
|
+
sessionId,
|
|
146
|
+
variables: { sessionId },
|
|
147
|
+
});
|
|
318
148
|
}, 500);
|
|
319
149
|
return;
|
|
320
150
|
}
|
|
@@ -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.1",
|
|
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",
|