alchemylab 1.0.3 → 1.0.4
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/939.js +12 -11
- package/dist/http-server.js +1 -1
- package/dist/index.js +4 -3
- package/dist/mcp-direct.js +4 -3
- package/dist/plugins/anthropic-auth.js +399 -0
- package/dist/plugins/auto-journal.js +83 -0
- package/dist/plugins/hooks/condition-evaluator.js +109 -0
- package/dist/plugins/hooks/dedup-utils.js +66 -0
- package/dist/plugins/hooks/hook-utils.js +85 -0
- package/dist/plugins/hooks/lib/condition-evaluator.js +114 -0
- package/dist/plugins/hooks/lib/dedup-utils.js +130 -0
- package/dist/plugins/hooks/lib/hook-utils.js +97 -0
- package/dist/plugins/hooks/plugin.js +278 -0
- package/dist/plugins/inject-session.js +67 -0
- package/dist/plugins/notify-parent.js +95 -0
- package/dist/plugins/system-logger.js +95 -0
- package/package.json +4 -2
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* OpenCode Event Hooks Plugin — Thin Event Emitter
|
|
4
|
+
*
|
|
5
|
+
* Detects events from OpenCode (tool execution, session lifecycle) and POSTs
|
|
6
|
+
* them to the action server's /api/hook-event endpoint. All hook logic
|
|
7
|
+
* (fetching, condition evaluation, template interpolation, execution) lives
|
|
8
|
+
* in the action server.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
const path_1 = require("path");
|
|
12
|
+
const fs_1 = require("fs");
|
|
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;
|
|
18
|
+
/**
|
|
19
|
+
* Extract request ID from tool output
|
|
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
|
+
];
|
|
30
|
+
for (const pattern of patterns) {
|
|
31
|
+
const match = text.match(pattern);
|
|
32
|
+
if (match)
|
|
33
|
+
return match[1];
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Extract task ID from tool output
|
|
39
|
+
*/
|
|
40
|
+
function extractTaskId(text) {
|
|
41
|
+
const match = text.match(/Task\s+(tas\d+).*(?:completed|marked as completed)/i);
|
|
42
|
+
return match ? match[1] : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fire-and-forget POST to action server. Does not throw.
|
|
46
|
+
*/
|
|
47
|
+
function postHookEvent(eventType, sessionId, variables, timing) {
|
|
48
|
+
fetch(ACTION_SERVER_URL, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ eventType, sessionId, variables, timing }),
|
|
52
|
+
}).catch(() => { }); // Fire-and-forget
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Await POST to action server. Returns silently on failure.
|
|
56
|
+
*/
|
|
57
|
+
async function postHookEventAsync(eventType, sessionId, variables, timing) {
|
|
58
|
+
try {
|
|
59
|
+
await fetch(ACTION_SERVER_URL, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ eventType, sessionId, variables, timing }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Non-fatal
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Detect tool type and determine event type + variables from tool execution context.
|
|
71
|
+
*/
|
|
72
|
+
function extractToolEventInfo(toolName, input, resultText, sessionId) {
|
|
73
|
+
const events = [];
|
|
74
|
+
const baseVars = {
|
|
75
|
+
output: resultText || '',
|
|
76
|
+
toolName,
|
|
77
|
+
sessionId: sessionId || '',
|
|
78
|
+
};
|
|
79
|
+
const isRequestOps = toolName.includes('request_operations') || toolName.includes('complete_request');
|
|
80
|
+
const isTaskOps = toolName.includes('task_operations') || toolName.includes('complete_task');
|
|
81
|
+
const isWriteFile = toolName.includes('write_file');
|
|
82
|
+
const isReplaceInFiles = toolName.includes('replace_in_files');
|
|
83
|
+
const isQuickEdit = toolName.includes('quick_write') || toolName.includes('quick_replace') || toolName.includes('quick_manage');
|
|
84
|
+
const isReadFiles = toolName.includes('read_files');
|
|
85
|
+
const isSearch = toolName.includes('search');
|
|
86
|
+
const isManageFiles = toolName.includes('manage_files');
|
|
87
|
+
const isDocumentOps = toolName.includes('document_operations');
|
|
88
|
+
const isQueryHistory = toolName.includes('query_history');
|
|
89
|
+
if (isRequestOps && resultText) {
|
|
90
|
+
const requestId = extractRequestId(resultText);
|
|
91
|
+
if (requestId) {
|
|
92
|
+
events.push({
|
|
93
|
+
eventType: 'request_completed',
|
|
94
|
+
variables: { ...baseVars, requestId },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (isTaskOps && resultText) {
|
|
99
|
+
const taskId = extractTaskId(resultText);
|
|
100
|
+
if (taskId) {
|
|
101
|
+
events.push({
|
|
102
|
+
eventType: 'task_completed',
|
|
103
|
+
variables: { ...baseVars, taskId },
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (isWriteFile) {
|
|
108
|
+
const pathMatch = resultText?.match(/(?:Created|Updated|Wrote)\s+(?:file\s+)?([^\s\n]+)/i);
|
|
109
|
+
events.push({
|
|
110
|
+
eventType: 'write_file',
|
|
111
|
+
variables: { ...baseVars, path: pathMatch?.[1] || '' },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
else if (isReplaceInFiles) {
|
|
115
|
+
const operations = input?.input?.operations || [];
|
|
116
|
+
const paths = operations.map((op) => op.path || op.documentId).filter(Boolean);
|
|
117
|
+
events.push({
|
|
118
|
+
eventType: 'replace_in_files',
|
|
119
|
+
variables: { ...baseVars, paths: paths.join(', '), fileCount: String(paths.length) },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
else if (isQuickEdit) {
|
|
123
|
+
const vars = {
|
|
124
|
+
...baseVars,
|
|
125
|
+
path: input?.input?.operationArgs?.path || '',
|
|
126
|
+
title: input?.input?.title || '',
|
|
127
|
+
};
|
|
128
|
+
const qeRequestMatch = resultText?.match(/\*\*Request:\*\*\s*(req\d+)/i);
|
|
129
|
+
if (qeRequestMatch)
|
|
130
|
+
vars.requestId = qeRequestMatch[1];
|
|
131
|
+
events.push({ eventType: 'quick_edit', variables: vars });
|
|
132
|
+
// Quick-edit auto-completes requests — also emit request_completed
|
|
133
|
+
if (vars.requestId) {
|
|
134
|
+
events.push({
|
|
135
|
+
eventType: 'request_completed',
|
|
136
|
+
variables: { ...baseVars, requestId: vars.requestId },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (isReadFiles) {
|
|
141
|
+
const pathsInput = input?.input?.paths || [];
|
|
142
|
+
const paths = pathsInput.map((p) => p.path || p.documentId).filter(Boolean);
|
|
143
|
+
events.push({
|
|
144
|
+
eventType: 'read_files',
|
|
145
|
+
variables: { ...baseVars, paths: paths.join(', '), fileCount: String(paths.length) },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
else if (isSearch) {
|
|
149
|
+
events.push({
|
|
150
|
+
eventType: 'search',
|
|
151
|
+
variables: {
|
|
152
|
+
...baseVars,
|
|
153
|
+
query: input?.input?.needle || '',
|
|
154
|
+
directory: input?.input?.directory || '',
|
|
155
|
+
fileExtension: input?.input?.options?.fileExtension || '*',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
else if (isManageFiles) {
|
|
160
|
+
const operations = input?.input?.operations || [];
|
|
161
|
+
const opTypes = operations.map((op) => op.operationType).filter(Boolean);
|
|
162
|
+
events.push({
|
|
163
|
+
eventType: 'manage_files',
|
|
164
|
+
variables: { ...baseVars, operationTypes: [...new Set(opTypes)].join(', ') },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else if (isDocumentOps) {
|
|
168
|
+
events.push({ eventType: 'document_operations', variables: baseVars });
|
|
169
|
+
}
|
|
170
|
+
else if (isQueryHistory) {
|
|
171
|
+
events.push({ eventType: 'query_history', variables: baseVars });
|
|
172
|
+
}
|
|
173
|
+
// Always emit tool_executed for any tool
|
|
174
|
+
events.push({
|
|
175
|
+
eventType: 'tool_executed',
|
|
176
|
+
variables: {
|
|
177
|
+
tool: toolName,
|
|
178
|
+
toolName,
|
|
179
|
+
output: resultText || '',
|
|
180
|
+
sessionId: sessionId || '',
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
return events;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Fetch recent output from a session (for lastOutput variable in session_idle).
|
|
187
|
+
*/
|
|
188
|
+
async function fetchRecentSessionOutput(sessionId, directory) {
|
|
189
|
+
try {
|
|
190
|
+
const configPath = (0, path_1.join)(directory, '.alchemy_lab', 'opencode.json');
|
|
191
|
+
if (!(0, fs_1.existsSync)(configPath))
|
|
192
|
+
return { output: '' };
|
|
193
|
+
const config = JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
|
|
194
|
+
const serverUrl = config.port ? `http://127.0.0.1:${config.port}` : null;
|
|
195
|
+
if (!serverUrl)
|
|
196
|
+
return { output: '' };
|
|
197
|
+
const url = `${serverUrl}/session/${sessionId}/message?limit=5`;
|
|
198
|
+
const response = await fetch(url);
|
|
199
|
+
if (!response.ok)
|
|
200
|
+
return { output: '' };
|
|
201
|
+
const messages = await response.json();
|
|
202
|
+
const assistantMessages = messages.filter(m => m.info.role === 'assistant');
|
|
203
|
+
const lastAssistantMessage = assistantMessages[0];
|
|
204
|
+
const assistantOutput = assistantMessages
|
|
205
|
+
.flatMap(m => m.parts)
|
|
206
|
+
.filter(p => p.type === 'text' && p.text)
|
|
207
|
+
.map(p => p.text)
|
|
208
|
+
.join('\n');
|
|
209
|
+
return { output: assistantOutput, lastMessageId: lastAssistantMessage?.info?.id };
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return { output: '' };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
exports.default = async ({ directory }) => {
|
|
216
|
+
const dir = directory || process.cwd();
|
|
217
|
+
// Singleton guard
|
|
218
|
+
if (initializedDirectory === dir && cachedHandlers) {
|
|
219
|
+
return cachedHandlers;
|
|
220
|
+
}
|
|
221
|
+
initializedDirectory = dir;
|
|
222
|
+
const handlers = {
|
|
223
|
+
"tool.execute.before": async (input) => {
|
|
224
|
+
const toolName = input?.tool || input?.name;
|
|
225
|
+
if (!toolName)
|
|
226
|
+
return;
|
|
227
|
+
const sessionId = input?.sessionID || '';
|
|
228
|
+
const inputText = JSON.stringify(input?.input || {}).substring(0, 1000);
|
|
229
|
+
postHookEvent('tool_executed', sessionId, {
|
|
230
|
+
tool: toolName,
|
|
231
|
+
toolName,
|
|
232
|
+
input: inputText,
|
|
233
|
+
}, 'before');
|
|
234
|
+
},
|
|
235
|
+
"tool.execute.after": async (input, output) => {
|
|
236
|
+
const toolName = input?.tool || input?.name;
|
|
237
|
+
if (!toolName)
|
|
238
|
+
return;
|
|
239
|
+
const sessionId = input?.sessionID || '';
|
|
240
|
+
const resultText = output?.content?.[0]?.text;
|
|
241
|
+
if (!resultText)
|
|
242
|
+
return;
|
|
243
|
+
const events = extractToolEventInfo(toolName, input, resultText, sessionId);
|
|
244
|
+
for (const { eventType, variables } of events) {
|
|
245
|
+
await postHookEventAsync(eventType, sessionId, variables, 'after');
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
event: async ({ event }) => {
|
|
249
|
+
switch (event.type) {
|
|
250
|
+
case 'session.idle': {
|
|
251
|
+
const sessionId = event.properties.sessionID || '';
|
|
252
|
+
const { output: lastOutput, lastMessageId } = await fetchRecentSessionOutput(sessionId, dir);
|
|
253
|
+
// Fire-and-forget — process is dying
|
|
254
|
+
postHookEvent('session_idle', sessionId, {
|
|
255
|
+
sessionId,
|
|
256
|
+
lastOutput,
|
|
257
|
+
lastMessageId: lastMessageId || '',
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
case 'session.created': {
|
|
262
|
+
const sessionInfo = event.properties.info;
|
|
263
|
+
const sessionId = sessionInfo?.id || '';
|
|
264
|
+
if (!sessionId)
|
|
265
|
+
return;
|
|
266
|
+
// Delay to allow workflow assignment
|
|
267
|
+
setTimeout(() => {
|
|
268
|
+
postHookEvent('session_created', sessionId, { sessionId });
|
|
269
|
+
}, 500);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
cachedHandlers = handlers;
|
|
276
|
+
return handlers;
|
|
277
|
+
};
|
|
278
|
+
//# sourceMappingURL=plugin.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* OpenCode Inject Session Plugin
|
|
4
|
+
*
|
|
5
|
+
* Injects the current session ID into spawn-background-work tool calls
|
|
6
|
+
* so that spawned background sessions can be linked as children of the parent.
|
|
7
|
+
*
|
|
8
|
+
* Hooks into tool.execute.before to modify args before the MCP tool executes.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
const fs_1 = require("fs");
|
|
12
|
+
const path_1 = require("path");
|
|
13
|
+
// Log immediately when module is loaded
|
|
14
|
+
try {
|
|
15
|
+
const logFile = (0, path_1.join)(process.env.HOME || '/tmp', 'inject-session-plugin.log');
|
|
16
|
+
(0, fs_1.appendFileSync)(logFile, `[${new Date().toISOString()}] MODULE LOADED - file was imported\n`);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
// ignore
|
|
20
|
+
}
|
|
21
|
+
const debugLog = (msg, data) => {
|
|
22
|
+
const logFile = (0, path_1.join)(process.env.HOME || '/tmp', 'inject-session-plugin.log');
|
|
23
|
+
const line = `[${new Date().toISOString()}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}\n`;
|
|
24
|
+
try {
|
|
25
|
+
(0, fs_1.appendFileSync)(logFile, line);
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
// ignore
|
|
29
|
+
}
|
|
30
|
+
console.log('[inject-session-plugin]', msg, data || '');
|
|
31
|
+
};
|
|
32
|
+
exports.default = async ({ project, directory }) => {
|
|
33
|
+
debugLog('Plugin loaded', { project: project?.id, directory });
|
|
34
|
+
return {
|
|
35
|
+
"tool.execute.before": async (context, data) => {
|
|
36
|
+
debugLog('tool.execute.before called', {
|
|
37
|
+
tool: context.tool,
|
|
38
|
+
sessionID: context.sessionID,
|
|
39
|
+
hasArgs: !!data?.args
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
// Check if this is a spawn-background-work tool
|
|
43
|
+
// MCP tool names can use dashes or underscores depending on how they're registered
|
|
44
|
+
const toolName = context.tool;
|
|
45
|
+
if (!toolName || (!toolName.includes('spawn_background_work') && !toolName.includes('spawn-background-work'))) {
|
|
46
|
+
return; // Not logging every non-matching tool to avoid spam
|
|
47
|
+
}
|
|
48
|
+
debugLog('Matched spawn-background-work tool!');
|
|
49
|
+
// Inject parentSessionId into args if not already set
|
|
50
|
+
if (!data.args.parentSessionId && context.sessionID) {
|
|
51
|
+
data.args.parentSessionId = context.sessionID;
|
|
52
|
+
debugLog('Injected parentSessionId', context.sessionID);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
debugLog('parentSessionId already set or no sessionID', {
|
|
56
|
+
existing: data.args.parentSessionId,
|
|
57
|
+
sessionID: context.sessionID
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
debugLog('Error', { error: String(error) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=inject-session.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* OpenCode Notify Parent Plugin
|
|
4
|
+
*
|
|
5
|
+
* Sends a toast notification to the parent session when a request is completed.
|
|
6
|
+
* Only active when OPENCODE_PARENT_SERVER env var is set (indicating this is
|
|
7
|
+
* a background subagent spawned by another session).
|
|
8
|
+
*
|
|
9
|
+
* Hooks into tool.execute.after to detect request-operations complete calls.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
// Deduplication: track recent tool calls to prevent double-triggering
|
|
13
|
+
// OpenCode sometimes fires tool.execute.after twice for the same call
|
|
14
|
+
const recentToolCalls = new Map();
|
|
15
|
+
const DEDUP_WINDOW = 2000; // 2 second window
|
|
16
|
+
function createToolCallHash(toolName, outputText) {
|
|
17
|
+
return `${toolName}:${outputText?.substring(0, 100) || ''}`;
|
|
18
|
+
}
|
|
19
|
+
function isDuplicateToolCall(hash, now) {
|
|
20
|
+
const lastTime = recentToolCalls.get(hash);
|
|
21
|
+
if (lastTime && (now - lastTime) < DEDUP_WINDOW) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
// Clean up old entries
|
|
25
|
+
for (const [key, time] of recentToolCalls.entries()) {
|
|
26
|
+
if (now - time > DEDUP_WINDOW * 2) {
|
|
27
|
+
recentToolCalls.delete(key);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
recentToolCalls.set(hash, now);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
exports.default = async ({ project, directory }) => {
|
|
34
|
+
// Only activate if we have a parent server to notify
|
|
35
|
+
const parentServerUrl = process.env.OPENCODE_PARENT_SERVER;
|
|
36
|
+
if (!parentServerUrl) {
|
|
37
|
+
return {}; // No hooks if no parent to notify
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
"tool.execute.after": async (input, output) => {
|
|
41
|
+
try {
|
|
42
|
+
// Check if this is request-operations (with underscores)
|
|
43
|
+
const toolName = input?.tool || input?.name;
|
|
44
|
+
if (!toolName || !toolName.includes('request_operations')) {
|
|
45
|
+
return; // Not the tool we're looking for
|
|
46
|
+
}
|
|
47
|
+
// Deduplication check - OpenCode fires hooks twice
|
|
48
|
+
const outputText = output?.content?.[0]?.text || '';
|
|
49
|
+
const callHash = createToolCallHash(toolName, outputText);
|
|
50
|
+
if (isDuplicateToolCall(callHash, Date.now())) {
|
|
51
|
+
return; // Skip duplicate
|
|
52
|
+
}
|
|
53
|
+
// Check the output content for the operation result
|
|
54
|
+
if (output?.content && Array.isArray(output.content)) {
|
|
55
|
+
if (output.content[0]?.text) {
|
|
56
|
+
const resultText = output.content[0].text;
|
|
57
|
+
// Check if this was a complete operation
|
|
58
|
+
const completionMatch = resultText.match(/(?:Request|request)\s+(req\d+)\s+(?:completed|complete)/i);
|
|
59
|
+
if (completionMatch) {
|
|
60
|
+
const requestId = completionMatch[1];
|
|
61
|
+
// Send toast notification to parent
|
|
62
|
+
await sendToastToParent(parentServerUrl, {
|
|
63
|
+
title: "Background Work Complete",
|
|
64
|
+
message: `Request ${requestId} has been completed`,
|
|
65
|
+
variant: "success",
|
|
66
|
+
duration: 8000
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// Silently ignore errors to avoid breaking the main session
|
|
74
|
+
// Could log to file if debugging is needed
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Send a toast notification to the parent OpenCode session
|
|
81
|
+
*/
|
|
82
|
+
async function sendToastToParent(parentServerUrl, toast) {
|
|
83
|
+
const toastUrl = `${parentServerUrl.replace(/\/$/, '')}/tui/toast`;
|
|
84
|
+
const response = await fetch(toastUrl, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/json'
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify(toast)
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new Error(`Failed to send toast: ${response.status} ${response.statusText}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=notify-parent.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* OpenCode System Logger Plugin
|
|
4
|
+
*
|
|
5
|
+
* Hooks into experimental.chat.system.transform to log the full system prompt
|
|
6
|
+
* to .alchemy_lab/system-prompt.log. Used to diagnose whether entity context
|
|
7
|
+
* (from @ mentions) reaches the LLM via the user.system field.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
const fs_1 = require("fs");
|
|
11
|
+
const path_1 = require("path");
|
|
12
|
+
const getLogFile = (directory) => (0, path_1.join)(directory, '.alchemy_lab', 'system-prompt.log');
|
|
13
|
+
const debugLog = (logFile, msg, data) => {
|
|
14
|
+
const line = `[${new Date().toISOString()}] ${msg}${data ? ' ' + JSON.stringify(data) : ''}\n`;
|
|
15
|
+
try {
|
|
16
|
+
(0, fs_1.mkdirSync)((0, path_1.join)(logFile, '..'), { recursive: true });
|
|
17
|
+
(0, fs_1.appendFileSync)(logFile, line);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
// ignore
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Strip Task tool references from system prompt for alchemist sessions.
|
|
25
|
+
* The OpenCode base prompt heavily promotes using the Task tool, which causes
|
|
26
|
+
* the alchemist to hallucinate the tool even when it's disabled in tool config.
|
|
27
|
+
*/
|
|
28
|
+
function stripTaskReferences(system) {
|
|
29
|
+
for (let i = 0; i < system.length; i++) {
|
|
30
|
+
let s = system[i];
|
|
31
|
+
// Remove lines that promote the Task tool
|
|
32
|
+
s = s.replace(/^- When doing file search, prefer to use the Task tool.*$/gm, '');
|
|
33
|
+
s = s.replace(/^- You should proactively use the Task tool.*$/gm, '');
|
|
34
|
+
s = s.replace(/^- VERY IMPORTANT:.*use the Task tool instead of running search commands directly\.$/gm, '');
|
|
35
|
+
s = s.replace(/^- If the user specifies that they want you to run tools "in parallel".*multiple Task tool calls\.$/gm, '');
|
|
36
|
+
// Remove Task tool examples
|
|
37
|
+
s = s.replace(/<example>\n.*?\[Uses the Task tool.*?<\/example>/gs, '');
|
|
38
|
+
// Clean up excessive blank lines left behind
|
|
39
|
+
s = s.replace(/\n{3,}/g, '\n\n');
|
|
40
|
+
system[i] = s;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
exports.default = async ({ directory }) => {
|
|
44
|
+
const logFile = getLogFile(directory);
|
|
45
|
+
debugLog(logFile, 'system-logger plugin loaded', { directory });
|
|
46
|
+
return {
|
|
47
|
+
"experimental.chat.system.transform": async (hookInput, output) => {
|
|
48
|
+
// Strip Task tool references for alchemist sessions
|
|
49
|
+
const isAlchemist = output.system.some((s) => s.includes('head of engineering'));
|
|
50
|
+
debugLog(logFile, 'Alchemist detection', { isAlchemist, sessionID: hookInput.sessionID, systemParts: output.system.length, partLengths: output.system.map((s) => s.length), hasSpecialist: output.system.map((s) => s.includes('specialist_ops')) });
|
|
51
|
+
if (isAlchemist) {
|
|
52
|
+
stripTaskReferences(output.system);
|
|
53
|
+
debugLog(logFile, 'Stripped Task tool references for alchemist session', { sessionID: hookInput.sessionID });
|
|
54
|
+
}
|
|
55
|
+
const hasEntityContext = output.system.some((s) => s.includes('referenced the following') || s.includes('entity'));
|
|
56
|
+
const lines = [
|
|
57
|
+
'='.repeat(80),
|
|
58
|
+
`Timestamp: ${new Date().toISOString()}`,
|
|
59
|
+
`Session: ${hookInput.sessionID || 'N/A'}`,
|
|
60
|
+
`Model: ${hookInput.model?.providerID || '?'}/${hookInput.model?.modelID || '?'}`,
|
|
61
|
+
`System parts: ${output.system.length}`,
|
|
62
|
+
`Has entity context: ${hasEntityContext}`,
|
|
63
|
+
'-'.repeat(80),
|
|
64
|
+
...output.system.map((s, i) => {
|
|
65
|
+
const preview = s.length > 500
|
|
66
|
+
? s.slice(0, 250) + '\n...[truncated]...\n' + s.slice(-250)
|
|
67
|
+
: s;
|
|
68
|
+
return `--- Part ${i} (${s.length} chars) ---\n${preview}`;
|
|
69
|
+
}),
|
|
70
|
+
'='.repeat(80),
|
|
71
|
+
'',
|
|
72
|
+
];
|
|
73
|
+
try {
|
|
74
|
+
(0, fs_1.mkdirSync)((0, path_1.join)(logFile, '..'), { recursive: true });
|
|
75
|
+
(0, fs_1.appendFileSync)(logFile, lines.join('\n') + '\n');
|
|
76
|
+
// Also write full untruncated prompt to separate file (overwrite each time)
|
|
77
|
+
const fullLogFile = (0, path_1.join)(logFile, '..', 'system-prompt-full.log');
|
|
78
|
+
const fullLines = [
|
|
79
|
+
`Timestamp: ${new Date().toISOString()}`,
|
|
80
|
+
`Session: ${hookInput.sessionID || 'N/A'}`,
|
|
81
|
+
`IsAlchemist: ${isAlchemist}`,
|
|
82
|
+
`System parts: ${output.system.length}`,
|
|
83
|
+
...output.system.map((s, i) => `--- Part ${i} (${s.length} chars) ---\n${s}`),
|
|
84
|
+
'='.repeat(80),
|
|
85
|
+
];
|
|
86
|
+
const { writeFileSync } = require('fs');
|
|
87
|
+
writeFileSync(fullLogFile, fullLines.join('\n') + '\n');
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
// ignore
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=system-logger.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "alchemylab",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "AlchemyLab - agentic coding IDEm",
|
|
6
6
|
"author": "Testfire Software",
|
|
@@ -22,13 +22,15 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"dist/**/*.js",
|
|
25
|
+
"dist/plugins/**/*.js",
|
|
25
26
|
"package.json",
|
|
26
27
|
"README.md"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
29
30
|
"build": "npm run build:dev",
|
|
30
31
|
"build:dev": "tsc -p tsconfig.json && chmod +x dist-dev/*.js",
|
|
31
|
-
"build:prod": "webpack --mode=production && chmod +x dist/*.js",
|
|
32
|
+
"build:prod": "webpack --mode=production && chmod +x dist/*.js && npm run copy:plugins",
|
|
33
|
+
"copy:plugins": "cp -r dist-dev/plugins dist/plugins",
|
|
32
34
|
"build:analyze": "ANALYZE=true webpack --mode=production",
|
|
33
35
|
"test": "npm run build:dev && mocha --require ts-node/register test/**/*.test.ts",
|
|
34
36
|
"test:watch": "mocha --require ts-node/register test/**/*.test.ts --watch --watch-files src/**/*.ts,test/**/*.ts",
|