@shakudo/opencode-mattermost-control 0.3.45
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/.opencode/command/mattermost-connect.md +5 -0
- package/.opencode/command/mattermost-disconnect.md +5 -0
- package/.opencode/command/mattermost-monitor.md +12 -0
- package/.opencode/command/mattermost-status.md +5 -0
- package/.opencode/command/speckit.analyze.md +184 -0
- package/.opencode/command/speckit.checklist.md +294 -0
- package/.opencode/command/speckit.clarify.md +181 -0
- package/.opencode/command/speckit.constitution.md +82 -0
- package/.opencode/command/speckit.implement.md +135 -0
- package/.opencode/command/speckit.plan.md +89 -0
- package/.opencode/command/speckit.specify.md +258 -0
- package/.opencode/command/speckit.tasks.md +137 -0
- package/.opencode/command/speckit.taskstoissues.md +30 -0
- package/.opencode/plugin/mattermost-control/event-handlers/compaction.ts +61 -0
- package/.opencode/plugin/mattermost-control/event-handlers/file.ts +36 -0
- package/.opencode/plugin/mattermost-control/event-handlers/index.ts +14 -0
- package/.opencode/plugin/mattermost-control/event-handlers/message.ts +124 -0
- package/.opencode/plugin/mattermost-control/event-handlers/permission.ts +34 -0
- package/.opencode/plugin/mattermost-control/event-handlers/question.ts +92 -0
- package/.opencode/plugin/mattermost-control/event-handlers/session.ts +100 -0
- package/.opencode/plugin/mattermost-control/event-handlers/todo.ts +33 -0
- package/.opencode/plugin/mattermost-control/event-handlers/tool.ts +76 -0
- package/.opencode/plugin/mattermost-control/formatters.ts +202 -0
- package/.opencode/plugin/mattermost-control/index.ts +964 -0
- package/.opencode/plugin/mattermost-control/package.json +12 -0
- package/.opencode/plugin/mattermost-control/state.ts +180 -0
- package/.opencode/plugin/mattermost-control/timers.ts +96 -0
- package/.opencode/plugin/mattermost-control/tools/connect.ts +563 -0
- package/.opencode/plugin/mattermost-control/tools/file.ts +41 -0
- package/.opencode/plugin/mattermost-control/tools/index.ts +12 -0
- package/.opencode/plugin/mattermost-control/tools/monitor.ts +183 -0
- package/.opencode/plugin/mattermost-control/tools/schedule.ts +253 -0
- package/.opencode/plugin/mattermost-control/tools/session.ts +120 -0
- package/.opencode/plugin/mattermost-control/types.ts +107 -0
- package/LICENSE +21 -0
- package/README.md +1280 -0
- package/opencode-shared +359 -0
- package/opencode-shared-restart +495 -0
- package/opencode-shared-stop +90 -0
- package/package.json +65 -0
- package/src/clients/mattermost-client.ts +221 -0
- package/src/clients/websocket-client.ts +199 -0
- package/src/command-handler.ts +1035 -0
- package/src/config.ts +170 -0
- package/src/context-builder.ts +309 -0
- package/src/file-completion-handler.ts +521 -0
- package/src/file-handler.ts +242 -0
- package/src/guest-approval-handler.ts +223 -0
- package/src/logger.ts +73 -0
- package/src/merge-handler.ts +335 -0
- package/src/message-router.ts +151 -0
- package/src/models/index.ts +197 -0
- package/src/models/routing.ts +50 -0
- package/src/models/thread-mapping.ts +40 -0
- package/src/monitor-service.ts +222 -0
- package/src/notification-service.ts +118 -0
- package/src/opencode-session-registry.ts +370 -0
- package/src/persistence/team-store.ts +396 -0
- package/src/persistence/thread-mapping-store.ts +258 -0
- package/src/question-handler.ts +401 -0
- package/src/reaction-handler.ts +111 -0
- package/src/response-streamer.ts +364 -0
- package/src/scheduler/schedule-store.ts +261 -0
- package/src/scheduler/scheduler-service.ts +349 -0
- package/src/session-manager.ts +142 -0
- package/src/session-ownership-handler.ts +253 -0
- package/src/status-indicator.ts +279 -0
- package/src/thread-manager.ts +231 -0
- package/src/todo-manager.ts +162 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Completion Handler
|
|
3
|
+
*
|
|
4
|
+
* Provides file path completion via !! trigger in Mattermost messages.
|
|
5
|
+
* Since Mattermost doesn't stream keystrokes, this uses a post-send
|
|
6
|
+
* disambiguation flow:
|
|
7
|
+
*
|
|
8
|
+
* 1. User sends: "Look at !!src/resp"
|
|
9
|
+
* 2. Bot parses !! references and calls OpenCode /find/file API
|
|
10
|
+
* 3. If exact match: auto-attach file content
|
|
11
|
+
* 4. If fuzzy matches: prompt user to select from options
|
|
12
|
+
* 5. User replies with number(s) to resolve
|
|
13
|
+
* 6. Bot processes original message with resolved file contents
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { log } from "./logger.js";
|
|
17
|
+
|
|
18
|
+
/** Pattern to match !!<path> references */
|
|
19
|
+
const FILE_REFERENCE_PATTERN = /!!([^\s`]+)/g;
|
|
20
|
+
|
|
21
|
+
/** Pattern to match code blocks (to skip !! inside them) */
|
|
22
|
+
const CODE_BLOCK_PATTERN = /```[\s\S]*?```|`[^`]+`/g;
|
|
23
|
+
|
|
24
|
+
export interface FileMatch {
|
|
25
|
+
path: string;
|
|
26
|
+
score: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FileReference {
|
|
30
|
+
/** The original !! reference (e.g., "!!src/resp") */
|
|
31
|
+
original: string;
|
|
32
|
+
/** The query part without !! prefix (e.g., "src/resp") */
|
|
33
|
+
query: string;
|
|
34
|
+
/** Matched files from the API */
|
|
35
|
+
matches: FileMatch[];
|
|
36
|
+
/** Resolved file path (if exact match or user selected) */
|
|
37
|
+
resolvedPath?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PendingFileCompletion {
|
|
41
|
+
/** Session ID this completion is for */
|
|
42
|
+
sessionId: string;
|
|
43
|
+
/** Thread root post ID */
|
|
44
|
+
threadRootPostId: string;
|
|
45
|
+
/** Channel ID */
|
|
46
|
+
channelId: string;
|
|
47
|
+
/** Original message text */
|
|
48
|
+
originalMessage: string;
|
|
49
|
+
/** File references that need resolution */
|
|
50
|
+
references: FileReference[];
|
|
51
|
+
/** User ID who sent the message */
|
|
52
|
+
userId: string;
|
|
53
|
+
/** Timestamp when the completion was requested */
|
|
54
|
+
createdAt: Date;
|
|
55
|
+
/** Post ID of the disambiguation prompt */
|
|
56
|
+
disambiguationPostId?: string;
|
|
57
|
+
/** File IDs from original message */
|
|
58
|
+
fileIds?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FileCompletionResult {
|
|
62
|
+
/** Whether all references were resolved */
|
|
63
|
+
allResolved: boolean;
|
|
64
|
+
/** The message with file references replaced/resolved */
|
|
65
|
+
processedMessage: string;
|
|
66
|
+
/** File paths that were resolved and should be attached */
|
|
67
|
+
resolvedFilePaths: string[];
|
|
68
|
+
/** Whether disambiguation is needed */
|
|
69
|
+
needsDisambiguation: boolean;
|
|
70
|
+
/** References that need user input */
|
|
71
|
+
unresolvedReferences: FileReference[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class FileCompletionHandler {
|
|
75
|
+
/** Pending completions by session ID */
|
|
76
|
+
private pendingCompletions: Map<string, PendingFileCompletion> = new Map();
|
|
77
|
+
|
|
78
|
+
/** OpenCode server base URL */
|
|
79
|
+
private opencodeBaseUrl: string;
|
|
80
|
+
|
|
81
|
+
/** Project directory for file searches */
|
|
82
|
+
private directory: string;
|
|
83
|
+
|
|
84
|
+
constructor(opencodeBaseUrl: string, directory: string) {
|
|
85
|
+
this.opencodeBaseUrl = opencodeBaseUrl;
|
|
86
|
+
this.directory = directory;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if a message contains !! file references
|
|
91
|
+
*/
|
|
92
|
+
hasFileReferences(message: string): boolean {
|
|
93
|
+
// First, remove code blocks to avoid matching !! inside code
|
|
94
|
+
const withoutCodeBlocks = message.replace(CODE_BLOCK_PATTERN, "");
|
|
95
|
+
return FILE_REFERENCE_PATTERN.test(withoutCodeBlocks);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extract all !! file references from a message
|
|
100
|
+
*/
|
|
101
|
+
extractFileReferences(message: string): string[] {
|
|
102
|
+
// Remove code blocks first
|
|
103
|
+
const withoutCodeBlocks = message.replace(CODE_BLOCK_PATTERN, "");
|
|
104
|
+
|
|
105
|
+
const references: string[] = [];
|
|
106
|
+
let match;
|
|
107
|
+
|
|
108
|
+
// Reset regex state
|
|
109
|
+
FILE_REFERENCE_PATTERN.lastIndex = 0;
|
|
110
|
+
|
|
111
|
+
while ((match = FILE_REFERENCE_PATTERN.exec(withoutCodeBlocks)) !== null) {
|
|
112
|
+
references.push(match[1]); // The query part without !!
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Deduplicate
|
|
116
|
+
return [...new Set(references)];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Search for files matching a query using OpenCode's /find/file API
|
|
121
|
+
*/
|
|
122
|
+
async searchFiles(query: string, limit: number = 5): Promise<FileMatch[]> {
|
|
123
|
+
try {
|
|
124
|
+
const url = `${this.opencodeBaseUrl}/find/file?query=${encodeURIComponent(query)}&limit=${limit}`;
|
|
125
|
+
|
|
126
|
+
const response = await fetch(url, {
|
|
127
|
+
method: "GET",
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "application/json",
|
|
130
|
+
"x-opencode-directory": this.directory,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
log.error(`[FileCompletion] Search failed: HTTP ${response.status}`);
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const data = await response.json();
|
|
140
|
+
|
|
141
|
+
// API returns a plain array of strings, not { files: [...] }
|
|
142
|
+
if (!Array.isArray(data)) {
|
|
143
|
+
log.debug(`[FileCompletion] Unexpected response format for query: ${query}`, data);
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (data.length === 0) {
|
|
148
|
+
log.debug(`[FileCompletion] No files returned for query: ${query}`);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
log.debug(`[FileCompletion] Found ${data.length} files for query: ${query}`);
|
|
153
|
+
|
|
154
|
+
return data.map((path: string) => ({
|
|
155
|
+
path,
|
|
156
|
+
score: 0, // API doesn't return scores
|
|
157
|
+
}));
|
|
158
|
+
} catch (error) {
|
|
159
|
+
log.error(`[FileCompletion] Search error:`, error);
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Process a message with file references
|
|
166
|
+
* Returns a result indicating if disambiguation is needed
|
|
167
|
+
*/
|
|
168
|
+
async processMessage(
|
|
169
|
+
sessionId: string,
|
|
170
|
+
threadRootPostId: string,
|
|
171
|
+
channelId: string,
|
|
172
|
+
message: string,
|
|
173
|
+
userId: string,
|
|
174
|
+
fileIds?: string[]
|
|
175
|
+
): Promise<FileCompletionResult> {
|
|
176
|
+
const queries = this.extractFileReferences(message);
|
|
177
|
+
|
|
178
|
+
if (queries.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
allResolved: true,
|
|
181
|
+
processedMessage: message,
|
|
182
|
+
resolvedFilePaths: [],
|
|
183
|
+
needsDisambiguation: false,
|
|
184
|
+
unresolvedReferences: [],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
log.info(`[FileCompletion] Found ${queries.length} file reference(s): ${queries.join(", ")}`);
|
|
189
|
+
|
|
190
|
+
const references: FileReference[] = [];
|
|
191
|
+
const resolvedFilePaths: string[] = [];
|
|
192
|
+
const unresolvedReferences: FileReference[] = [];
|
|
193
|
+
|
|
194
|
+
// Search for each reference
|
|
195
|
+
for (const query of queries) {
|
|
196
|
+
const matches = await this.searchFiles(query);
|
|
197
|
+
|
|
198
|
+
const reference: FileReference = {
|
|
199
|
+
original: `!!${query}`,
|
|
200
|
+
query,
|
|
201
|
+
matches,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Check for exact match (score = 0 means perfect match in fuzzysort)
|
|
205
|
+
// Or if there's only one result with a very high score
|
|
206
|
+
const exactMatch = matches.find(m =>
|
|
207
|
+
m.path.toLowerCase() === query.toLowerCase() ||
|
|
208
|
+
m.path.toLowerCase().endsWith(`/${query.toLowerCase()}`) ||
|
|
209
|
+
m.path.toLowerCase().endsWith(`\\${query.toLowerCase()}`)
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (exactMatch) {
|
|
213
|
+
reference.resolvedPath = exactMatch.path;
|
|
214
|
+
resolvedFilePaths.push(exactMatch.path);
|
|
215
|
+
log.info(`[FileCompletion] Exact match for "${query}": ${exactMatch.path}`);
|
|
216
|
+
} else if (matches.length === 1) {
|
|
217
|
+
// Single fuzzy match - auto-resolve
|
|
218
|
+
reference.resolvedPath = matches[0].path;
|
|
219
|
+
resolvedFilePaths.push(matches[0].path);
|
|
220
|
+
log.info(`[FileCompletion] Single match for "${query}": ${matches[0].path}`);
|
|
221
|
+
} else if (matches.length === 0) {
|
|
222
|
+
log.info(`[FileCompletion] No matches for "${query}"`);
|
|
223
|
+
unresolvedReferences.push(reference);
|
|
224
|
+
} else {
|
|
225
|
+
// Multiple matches - needs disambiguation
|
|
226
|
+
log.info(`[FileCompletion] Multiple matches for "${query}": ${matches.length} options`);
|
|
227
|
+
unresolvedReferences.push(reference);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
references.push(reference);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const needsDisambiguation = unresolvedReferences.length > 0;
|
|
234
|
+
|
|
235
|
+
if (needsDisambiguation) {
|
|
236
|
+
// Store pending completion for later resolution
|
|
237
|
+
const pending: PendingFileCompletion = {
|
|
238
|
+
sessionId,
|
|
239
|
+
threadRootPostId,
|
|
240
|
+
channelId,
|
|
241
|
+
originalMessage: message,
|
|
242
|
+
references,
|
|
243
|
+
userId,
|
|
244
|
+
createdAt: new Date(),
|
|
245
|
+
fileIds,
|
|
246
|
+
};
|
|
247
|
+
this.pendingCompletions.set(sessionId, pending);
|
|
248
|
+
log.info(`[FileCompletion] Stored pending completion for session ${sessionId.substring(0, 8)}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Build processed message with resolved paths
|
|
252
|
+
let processedMessage = message;
|
|
253
|
+
for (const ref of references) {
|
|
254
|
+
if (ref.resolvedPath) {
|
|
255
|
+
// Replace !!query with the resolved path (without !!)
|
|
256
|
+
processedMessage = processedMessage.replace(ref.original, ref.resolvedPath);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
allResolved: !needsDisambiguation,
|
|
262
|
+
processedMessage,
|
|
263
|
+
resolvedFilePaths,
|
|
264
|
+
needsDisambiguation,
|
|
265
|
+
unresolvedReferences,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Format disambiguation prompt for the user
|
|
271
|
+
*/
|
|
272
|
+
formatDisambiguationPrompt(unresolvedReferences: FileReference[]): string {
|
|
273
|
+
const lines: string[] = [
|
|
274
|
+
":file_folder: **File suggestions**",
|
|
275
|
+
"",
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
let globalIndex = 1;
|
|
279
|
+
const indexMap: Map<number, { reference: FileReference; matchIndex: number }> = new Map();
|
|
280
|
+
|
|
281
|
+
for (const ref of unresolvedReferences) {
|
|
282
|
+
lines.push(`**\`${ref.original}\`**:`);
|
|
283
|
+
|
|
284
|
+
if (ref.matches.length === 0) {
|
|
285
|
+
lines.push(` _No files found matching "${ref.query}"_`);
|
|
286
|
+
} else {
|
|
287
|
+
for (let i = 0; i < ref.matches.length; i++) {
|
|
288
|
+
const match = ref.matches[i];
|
|
289
|
+
lines.push(` \`${globalIndex}\`. ${match.path}`);
|
|
290
|
+
indexMap.set(globalIndex, { reference: ref, matchIndex: i });
|
|
291
|
+
globalIndex++;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
lines.push("");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
lines.push("---");
|
|
298
|
+
lines.push("_Reply with number(s) to select (e.g., `1` or `1, 3`)_");
|
|
299
|
+
lines.push("_Or type `!cancel` to skip file completion_");
|
|
300
|
+
|
|
301
|
+
return lines.join("\n");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Check if there's a pending file completion for a session
|
|
306
|
+
*/
|
|
307
|
+
hasPendingCompletion(sessionId: string): boolean {
|
|
308
|
+
const pending = this.pendingCompletions.get(sessionId);
|
|
309
|
+
if (!pending) return false;
|
|
310
|
+
|
|
311
|
+
// Expire after 30 minutes
|
|
312
|
+
const ageMs = Date.now() - pending.createdAt.getTime();
|
|
313
|
+
if (ageMs > 30 * 60 * 1000) {
|
|
314
|
+
this.pendingCompletions.delete(sessionId);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Get pending completion for a session
|
|
323
|
+
*/
|
|
324
|
+
getPendingCompletion(sessionId: string): PendingFileCompletion | null {
|
|
325
|
+
return this.pendingCompletions.get(sessionId) || null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Set the disambiguation post ID for tracking
|
|
330
|
+
*/
|
|
331
|
+
setDisambiguationPostId(sessionId: string, postId: string): void {
|
|
332
|
+
const pending = this.pendingCompletions.get(sessionId);
|
|
333
|
+
if (pending) {
|
|
334
|
+
pending.disambiguationPostId = postId;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Handle user's reply to disambiguation prompt
|
|
340
|
+
*
|
|
341
|
+
* @param sessionId - Session ID
|
|
342
|
+
* @param reply - User's reply (numbers or !cancel)
|
|
343
|
+
* @returns Resolved result or null if cancelled
|
|
344
|
+
*/
|
|
345
|
+
handleDisambiguationReply(
|
|
346
|
+
sessionId: string,
|
|
347
|
+
reply: string
|
|
348
|
+
): { resolved: boolean; result?: FileCompletionResult; cancelled?: boolean } {
|
|
349
|
+
const pending = this.pendingCompletions.get(sessionId);
|
|
350
|
+
if (!pending) {
|
|
351
|
+
return { resolved: false };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const trimmedReply = reply.trim().toLowerCase();
|
|
355
|
+
|
|
356
|
+
// Check for cancel
|
|
357
|
+
if (trimmedReply === "!cancel" || trimmedReply === "cancel") {
|
|
358
|
+
this.pendingCompletions.delete(sessionId);
|
|
359
|
+
log.info(`[FileCompletion] User cancelled file completion for session ${sessionId.substring(0, 8)}`);
|
|
360
|
+
return { resolved: true, cancelled: true };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Parse number selections (e.g., "1" or "1, 3" or "1,3")
|
|
364
|
+
const selections = trimmedReply
|
|
365
|
+
.split(/[,\s]+/)
|
|
366
|
+
.map(s => parseInt(s.trim(), 10))
|
|
367
|
+
.filter(n => !isNaN(n));
|
|
368
|
+
|
|
369
|
+
if (selections.length === 0) {
|
|
370
|
+
// Not a valid selection, might be a regular message
|
|
371
|
+
return { resolved: false };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Build global index map
|
|
375
|
+
let globalIndex = 1;
|
|
376
|
+
const indexMap: Map<number, { reference: FileReference; match: FileMatch }> = new Map();
|
|
377
|
+
|
|
378
|
+
for (const ref of pending.references) {
|
|
379
|
+
if (!ref.resolvedPath && ref.matches.length > 0) {
|
|
380
|
+
for (const match of ref.matches) {
|
|
381
|
+
indexMap.set(globalIndex, { reference: ref, match });
|
|
382
|
+
globalIndex++;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Validate all selections
|
|
388
|
+
for (const sel of selections) {
|
|
389
|
+
if (!indexMap.has(sel)) {
|
|
390
|
+
log.warn(`[FileCompletion] Invalid selection ${sel}, max is ${globalIndex - 1}`);
|
|
391
|
+
return { resolved: false };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Apply selections
|
|
396
|
+
const resolvedFilePaths: string[] = [];
|
|
397
|
+
|
|
398
|
+
for (const sel of selections) {
|
|
399
|
+
const { reference, match } = indexMap.get(sel)!;
|
|
400
|
+
reference.resolvedPath = match.path;
|
|
401
|
+
resolvedFilePaths.push(match.path);
|
|
402
|
+
log.info(`[FileCompletion] User selected ${sel}: ${match.path} for "${reference.query}"`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Add previously resolved paths
|
|
406
|
+
for (const ref of pending.references) {
|
|
407
|
+
if (ref.resolvedPath && !resolvedFilePaths.includes(ref.resolvedPath)) {
|
|
408
|
+
resolvedFilePaths.push(ref.resolvedPath);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Check if all references are now resolved
|
|
413
|
+
const stillUnresolved = pending.references.filter(r => !r.resolvedPath && r.matches.length > 0);
|
|
414
|
+
|
|
415
|
+
if (stillUnresolved.length > 0) {
|
|
416
|
+
// Still needs more disambiguation
|
|
417
|
+
return { resolved: false };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Build final processed message
|
|
421
|
+
let processedMessage = pending.originalMessage;
|
|
422
|
+
for (const ref of pending.references) {
|
|
423
|
+
if (ref.resolvedPath) {
|
|
424
|
+
processedMessage = processedMessage.replace(ref.original, ref.resolvedPath);
|
|
425
|
+
} else if (ref.matches.length === 0) {
|
|
426
|
+
// No matches found - leave as is or remove
|
|
427
|
+
processedMessage = processedMessage.replace(ref.original, `[file not found: ${ref.query}]`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
this.pendingCompletions.delete(sessionId);
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
resolved: true,
|
|
435
|
+
result: {
|
|
436
|
+
allResolved: true,
|
|
437
|
+
processedMessage,
|
|
438
|
+
resolvedFilePaths,
|
|
439
|
+
needsDisambiguation: false,
|
|
440
|
+
unresolvedReferences: [],
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Clear pending completion for a session
|
|
447
|
+
*/
|
|
448
|
+
clearPendingCompletion(sessionId: string): void {
|
|
449
|
+
this.pendingCompletions.delete(sessionId);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Read file content from the project
|
|
454
|
+
* Uses OpenCode's file reading endpoint or direct fs
|
|
455
|
+
*/
|
|
456
|
+
async readFileContent(filePath: string): Promise<string | null> {
|
|
457
|
+
try {
|
|
458
|
+
// Use OpenCode's file content endpoint
|
|
459
|
+
const url = `${this.opencodeBaseUrl}/file/content?path=${encodeURIComponent(filePath)}`;
|
|
460
|
+
|
|
461
|
+
const response = await fetch(url, {
|
|
462
|
+
method: "GET",
|
|
463
|
+
headers: {
|
|
464
|
+
"Content-Type": "application/json",
|
|
465
|
+
"x-opencode-directory": this.directory,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
if (!response.ok) {
|
|
470
|
+
log.error(`[FileCompletion] Failed to read file ${filePath}: HTTP ${response.status}`);
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const data = await response.json() as { content?: string };
|
|
475
|
+
return data.content || null;
|
|
476
|
+
} catch (error) {
|
|
477
|
+
log.error(`[FileCompletion] Error reading file ${filePath}:`, error);
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Format file content for inclusion in prompt
|
|
484
|
+
*/
|
|
485
|
+
formatFileContentForPrompt(filePath: string, content: string): string {
|
|
486
|
+
// Determine language hint from extension
|
|
487
|
+
const ext = filePath.split(".").pop()?.toLowerCase() || "";
|
|
488
|
+
const langHints: Record<string, string> = {
|
|
489
|
+
ts: "typescript",
|
|
490
|
+
tsx: "tsx",
|
|
491
|
+
js: "javascript",
|
|
492
|
+
jsx: "jsx",
|
|
493
|
+
py: "python",
|
|
494
|
+
rb: "ruby",
|
|
495
|
+
go: "go",
|
|
496
|
+
rs: "rust",
|
|
497
|
+
java: "java",
|
|
498
|
+
cpp: "cpp",
|
|
499
|
+
c: "c",
|
|
500
|
+
h: "c",
|
|
501
|
+
hpp: "cpp",
|
|
502
|
+
cs: "csharp",
|
|
503
|
+
sh: "bash",
|
|
504
|
+
bash: "bash",
|
|
505
|
+
zsh: "zsh",
|
|
506
|
+
yaml: "yaml",
|
|
507
|
+
yml: "yaml",
|
|
508
|
+
json: "json",
|
|
509
|
+
md: "markdown",
|
|
510
|
+
sql: "sql",
|
|
511
|
+
css: "css",
|
|
512
|
+
scss: "scss",
|
|
513
|
+
html: "html",
|
|
514
|
+
xml: "xml",
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const lang = langHints[ext] || "";
|
|
518
|
+
|
|
519
|
+
return `\n\n📁 **File: \`${filePath}\`**\n\`\`\`${lang}\n${content}\n\`\`\``;
|
|
520
|
+
}
|
|
521
|
+
}
|