@comfanion/workflow 4.38.3-dev.2 → 4.38.4-dev.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/package.json +1 -1
- package/src/build-info.json +4 -5
- package/src/opencode/config.yaml +0 -69
- package/src/opencode/gitignore +2 -0
- package/src/opencode/opencode.json +3 -5
- package/src/opencode/vectorizer.yaml +45 -0
- package/src/opencode/plugins/README.md +0 -182
- package/src/opencode/plugins/__tests__/custom-compaction.test.ts +0 -829
- package/src/opencode/plugins/__tests__/file-indexer.test.ts +0 -425
- package/src/opencode/plugins/__tests__/helpers/mock-ctx.ts +0 -171
- package/src/opencode/plugins/__tests__/leak-stress.test.ts +0 -315
- package/src/opencode/plugins/__tests__/usethis-todo.test.ts +0 -205
- package/src/opencode/plugins/__tests__/version-check.test.ts +0 -223
- package/src/opencode/plugins/custom-compaction.ts +0 -1080
- package/src/opencode/plugins/file-indexer.ts +0 -516
- package/src/opencode/plugins/usethis-todo-publish.ts +0 -44
- package/src/opencode/plugins/usethis-todo-ui.ts +0 -37
- package/src/opencode/plugins/version-check.ts +0 -230
- package/src/opencode/tools/codeindex.ts +0 -264
- package/src/opencode/tools/search.ts +0 -149
- package/src/opencode/tools/usethis_todo.ts +0 -538
- package/src/vectorizer/index.js +0 -573
- package/src/vectorizer/package.json +0 -16
|
@@ -1,516 +0,0 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
-
import path from "path"
|
|
3
|
-
import fs from "fs/promises"
|
|
4
|
-
import fsSync from "fs"
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* File Indexer Plugin
|
|
8
|
-
*
|
|
9
|
-
* Automatically manages semantic search indexes:
|
|
10
|
-
* - On plugin load (opencode startup): freshen existing indexes
|
|
11
|
-
* - On file edit: queue file for reindexing (debounced)
|
|
12
|
-
*
|
|
13
|
-
* Configuration in .opencode/config.yaml:
|
|
14
|
-
* vectorizer:
|
|
15
|
-
* enabled: true # Master switch
|
|
16
|
-
* auto_index: true # Enable this plugin
|
|
17
|
-
* debounce_ms: 1000 # Wait time before indexing
|
|
18
|
-
*
|
|
19
|
-
* Debug mode: set DEBUG=file-indexer or DEBUG=* to see logs
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
const DEBUG = process.env.DEBUG?.includes('file-indexer') || process.env.DEBUG === '*'
|
|
23
|
-
|
|
24
|
-
let logFilePath: string | null = null
|
|
25
|
-
|
|
26
|
-
// Log to file only
|
|
27
|
-
function logFile(msg: string): void {
|
|
28
|
-
if (logFilePath) {
|
|
29
|
-
try {
|
|
30
|
-
const timestamp = new Date().toISOString().slice(11, 19)
|
|
31
|
-
fsSync.appendFileSync(logFilePath, `${timestamp} ${msg}\n`)
|
|
32
|
-
} catch {
|
|
33
|
-
// Ignore write errors (e.g., ENOENT if log directory was removed)
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Log to file, console only in debug mode
|
|
39
|
-
function log(msg: string): void {
|
|
40
|
-
if (DEBUG) console.log(`[file-indexer] ${msg}`)
|
|
41
|
-
logFile(msg)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function debug(msg: string): void {
|
|
45
|
-
if (DEBUG) log(msg)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Default config (used if config.yaml is missing or invalid)
|
|
49
|
-
const DEFAULT_CONFIG = {
|
|
50
|
-
enabled: true,
|
|
51
|
-
auto_index: true,
|
|
52
|
-
debounce_ms: 1000,
|
|
53
|
-
indexes: {
|
|
54
|
-
code: { enabled: true, extensions: ['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.c', '.cpp', '.h', '.hpp', '.cs', '.rb', '.php', '.scala', '.clj'] },
|
|
55
|
-
docs: { enabled: true, extensions: ['.md', '.mdx', '.txt', '.rst', '.adoc'] },
|
|
56
|
-
config: { enabled: false, extensions: ['.yaml', '.yml', '.json', '.toml', '.ini', '.xml'] },
|
|
57
|
-
},
|
|
58
|
-
exclude: [
|
|
59
|
-
// Build & deps (dot-folders like .git, .claude, .idea are already ignored by glob default)
|
|
60
|
-
'node_modules', 'vendor', 'dist', 'build', 'out', '__pycache__',
|
|
61
|
-
],
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface VectorizerConfig {
|
|
65
|
-
enabled: boolean
|
|
66
|
-
auto_index: boolean
|
|
67
|
-
debounce_ms: number
|
|
68
|
-
indexes: Record<string, { enabled: boolean; extensions: string[] }>
|
|
69
|
-
exclude: string[]
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Fun messages based on file count and language
|
|
73
|
-
const FUN_MESSAGES = {
|
|
74
|
-
en: {
|
|
75
|
-
indexing: (files: number) => `Indexing ${files} files...`,
|
|
76
|
-
fun: (files: number, mins: number) => {
|
|
77
|
-
if (files < 20) return `Quick coffee? ☕`
|
|
78
|
-
if (files < 100) return `~${mins}min. Stretch break? 🧘`
|
|
79
|
-
if (files < 500) return `~${mins}min. Make coffee ☕ and relax 🛋️`
|
|
80
|
-
return `~${mins}min. Go touch grass 🌿 or take a nap 😴`
|
|
81
|
-
},
|
|
82
|
-
done: (files: number, duration: string) => {
|
|
83
|
-
if (files < 20) return `Done! ${files} files in ${duration}. Fast! 🚀`
|
|
84
|
-
if (files < 100) return `Indexed ${files} files in ${duration}. Let's go! 🎸`
|
|
85
|
-
return `${files} files in ${duration}. Worth the wait! 🎉`
|
|
86
|
-
},
|
|
87
|
-
fresh: () => `Everything's fresh! Nothing to do 😎`,
|
|
88
|
-
error: (msg: string) => `Oops! ${msg} 😬`
|
|
89
|
-
},
|
|
90
|
-
uk: {
|
|
91
|
-
indexing: (files: number) => `Індексую ${files} файлів...`,
|
|
92
|
-
fun: (files: number, mins: number) => {
|
|
93
|
-
if (files < 20) return `Швидка кава? ☕`
|
|
94
|
-
if (files < 100) return `~${mins}хв. Розімнись! 🧘`
|
|
95
|
-
if (files < 500) return `~${mins}хв. Зроби каву ☕ і відпочинь 🛋️`
|
|
96
|
-
return `~${mins}хв. Йди погуляй 🌿 або поспи 😴`
|
|
97
|
-
},
|
|
98
|
-
done: (files: number, duration: string) => {
|
|
99
|
-
if (files < 20) return `Готово! ${files} файлів за ${duration}. Швидко! 🚀`
|
|
100
|
-
if (files < 100) return `${files} файлів за ${duration}. Поїхали! 🎸`
|
|
101
|
-
return `${files} файлів за ${duration}. Варто було чекати! 🎉`
|
|
102
|
-
},
|
|
103
|
-
fresh: () => `Все свіже! Нічого робити 😎`,
|
|
104
|
-
error: (msg: string) => `Ой! ${msg} 😬`
|
|
105
|
-
},
|
|
106
|
-
ru: {
|
|
107
|
-
indexing: (files: number) => `Индексирую ${files} файлов...`,
|
|
108
|
-
fun: (files: number, mins: number) => {
|
|
109
|
-
if (files < 20) return `Кофе? ☕`
|
|
110
|
-
if (files < 100) return `~${mins}мин. Разомнись! 🧘`
|
|
111
|
-
if (files < 500) return `~${mins}мин. Сделай кофе ☕ и отдохни 🛋️`
|
|
112
|
-
return `~${mins}мин. Иди погуляй 🌿 или поспи 😴`
|
|
113
|
-
},
|
|
114
|
-
done: (files: number, duration: string) => {
|
|
115
|
-
if (files < 20) return `Готово! ${files} файлов за ${duration}. Быстро! 🚀`
|
|
116
|
-
if (files < 100) return `${files} файлов за ${duration}. Поехали! 🎸`
|
|
117
|
-
return `${files} файлов за ${duration}. Стоило подождать! 🎉`
|
|
118
|
-
},
|
|
119
|
-
fresh: () => `Всё свежее! Делать нечего 😎`,
|
|
120
|
-
error: (msg: string) => `Ой! ${msg} 😬`
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
type Lang = keyof typeof FUN_MESSAGES
|
|
125
|
-
|
|
126
|
-
async function getLanguage(projectRoot: string): Promise<Lang> {
|
|
127
|
-
try {
|
|
128
|
-
const configPath = path.join(projectRoot, ".opencode", "config.yaml")
|
|
129
|
-
const content = await fs.readFile(configPath, 'utf8')
|
|
130
|
-
const match = content.match(/communication_language:\s*["']?(\w+)["']?/i)
|
|
131
|
-
const lang = match?.[1]?.toLowerCase()
|
|
132
|
-
if (lang === 'ukrainian' || lang === 'uk') return 'uk'
|
|
133
|
-
if (lang === 'russian' || lang === 'ru') return 'ru'
|
|
134
|
-
return 'en'
|
|
135
|
-
} catch {
|
|
136
|
-
return 'en'
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function estimateTime(fileCount: number): number {
|
|
141
|
-
// Model loading: ~30 sec, then ~0.5 sec per file
|
|
142
|
-
const modelLoadTime = 30 // seconds
|
|
143
|
-
const perFileTime = 0.5 // seconds
|
|
144
|
-
const totalSeconds = modelLoadTime + (fileCount * perFileTime)
|
|
145
|
-
return Math.ceil(totalSeconds / 60) // minutes
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function formatDuration(seconds: number): string {
|
|
149
|
-
if (seconds < 60) return `${Math.round(seconds)}s`
|
|
150
|
-
const mins = Math.floor(seconds / 60)
|
|
151
|
-
const secs = Math.round(seconds % 60)
|
|
152
|
-
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const pendingFiles: Map<string, { indexName: string; timestamp: number }> = new Map()
|
|
156
|
-
|
|
157
|
-
async function loadConfig(projectRoot: string): Promise<VectorizerConfig> {
|
|
158
|
-
try {
|
|
159
|
-
const configPath = path.join(projectRoot, ".opencode", "config.yaml")
|
|
160
|
-
const content = await fs.readFile(configPath, 'utf8')
|
|
161
|
-
|
|
162
|
-
// Simple YAML parsing for vectorizer section
|
|
163
|
-
const vectorizerMatch = content.match(/vectorizer:\s*\n([\s\S]*?)(?=\n[a-z_]+:|$)/i)
|
|
164
|
-
if (!vectorizerMatch) {
|
|
165
|
-
debug('No vectorizer section in config.yaml, using defaults')
|
|
166
|
-
return DEFAULT_CONFIG
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const section = vectorizerMatch[1]
|
|
170
|
-
|
|
171
|
-
// Parse enabled
|
|
172
|
-
const enabledMatch = section.match(/^\s+enabled:\s*(true|false)/m)
|
|
173
|
-
const enabled = enabledMatch ? enabledMatch[1] === 'true' : DEFAULT_CONFIG.enabled
|
|
174
|
-
|
|
175
|
-
// Parse auto_index
|
|
176
|
-
const autoIndexMatch = section.match(/^\s+auto_index:\s*(true|false)/m)
|
|
177
|
-
const auto_index = autoIndexMatch ? autoIndexMatch[1] === 'true' : DEFAULT_CONFIG.auto_index
|
|
178
|
-
|
|
179
|
-
// Parse debounce_ms
|
|
180
|
-
const debounceMatch = section.match(/^\s+debounce_ms:\s*(\d+)/m)
|
|
181
|
-
const debounce_ms = debounceMatch ? parseInt(debounceMatch[1]) : DEFAULT_CONFIG.debounce_ms
|
|
182
|
-
|
|
183
|
-
// Parse exclude array
|
|
184
|
-
const excludeMatch = section.match(/exclude:\s*\n((?:\s+-\s+.+\n?)+)/m)
|
|
185
|
-
let exclude = DEFAULT_CONFIG.exclude
|
|
186
|
-
if (excludeMatch) {
|
|
187
|
-
exclude = excludeMatch[1].match(/-\s+(.+)/g)?.map(m => m.replace(/^-\s+/, '').trim()) || DEFAULT_CONFIG.exclude
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// TODO(BACKLOG): parse vectorizer.indexes from config.yaml to support custom extensions
|
|
191
|
-
return { enabled, auto_index, debounce_ms, indexes: DEFAULT_CONFIG.indexes, exclude }
|
|
192
|
-
} catch (e) {
|
|
193
|
-
debug(`Failed to load config: ${(e as Error).message}`)
|
|
194
|
-
return DEFAULT_CONFIG
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function getIndexForFile(filePath: string, config: VectorizerConfig): string | null {
|
|
199
|
-
const ext = path.extname(filePath).toLowerCase()
|
|
200
|
-
for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
|
|
201
|
-
if (indexConfig.enabled && indexConfig.extensions.includes(ext)) {
|
|
202
|
-
return indexName
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return null
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function isExcluded(relativePath: string, config: VectorizerConfig): boolean {
|
|
209
|
-
const norm = relativePath.replace(/\\/g, '/')
|
|
210
|
-
return config.exclude.some(pattern => {
|
|
211
|
-
const p = pattern.replace(/\\/g, '/').replace(/\/+$/, '')
|
|
212
|
-
return norm === p || norm.startsWith(`${p}/`) || norm.includes(`/${p}/`)
|
|
213
|
-
})
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function isVectorizerInstalled(projectRoot: string): Promise<boolean> {
|
|
217
|
-
try {
|
|
218
|
-
await fs.access(path.join(projectRoot, ".opencode", "vectorizer", "node_modules"))
|
|
219
|
-
return true
|
|
220
|
-
} catch {
|
|
221
|
-
return false
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function hasIndex(projectRoot: string, indexName: string): Promise<boolean> {
|
|
226
|
-
try {
|
|
227
|
-
await fs.access(path.join(projectRoot, ".opencode", "vectors", indexName, "hashes.json"))
|
|
228
|
-
return true
|
|
229
|
-
} catch {
|
|
230
|
-
return false
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
interface IndexResult {
|
|
235
|
-
totalFiles: number
|
|
236
|
-
elapsedSeconds: number
|
|
237
|
-
action: 'created' | 'rebuilt' | 'freshened' | 'skipped'
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Ensure index exists and is fresh on session start
|
|
242
|
-
* Creates index if missing, freshens if exists
|
|
243
|
-
*/
|
|
244
|
-
async function ensureIndexOnSessionStart(
|
|
245
|
-
projectRoot: string,
|
|
246
|
-
config: VectorizerConfig,
|
|
247
|
-
onStart?: (totalFiles: number, estimatedMins: number) => void
|
|
248
|
-
): Promise<IndexResult> {
|
|
249
|
-
let totalFiles = 0
|
|
250
|
-
let elapsedSeconds = 0
|
|
251
|
-
let action: IndexResult['action'] = 'skipped'
|
|
252
|
-
|
|
253
|
-
if (!await isVectorizerInstalled(projectRoot)) {
|
|
254
|
-
log(`Vectorizer not installed - run: npx @comfanion/workflow vectorizer install`)
|
|
255
|
-
return { totalFiles: 0, elapsedSeconds: 0, action: 'skipped' }
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
try {
|
|
259
|
-
const vectorizerModule = path.join(projectRoot, ".opencode", "vectorizer", "index.js")
|
|
260
|
-
const { CodebaseIndexer } = await import(`file://${vectorizerModule}`)
|
|
261
|
-
const overallStart = Date.now()
|
|
262
|
-
|
|
263
|
-
// First pass - count files and check health
|
|
264
|
-
let needsWork = false
|
|
265
|
-
let totalExpectedFiles = 0
|
|
266
|
-
|
|
267
|
-
for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
|
|
268
|
-
if (!indexConfig.enabled) continue
|
|
269
|
-
const indexer = await new CodebaseIndexer(projectRoot, indexName).init()
|
|
270
|
-
try {
|
|
271
|
-
const indexExists = await hasIndex(projectRoot, indexName)
|
|
272
|
-
|
|
273
|
-
if (!indexExists) {
|
|
274
|
-
const health = await indexer.checkHealth(config.exclude)
|
|
275
|
-
totalExpectedFiles += health.expectedCount
|
|
276
|
-
needsWork = true
|
|
277
|
-
} else {
|
|
278
|
-
const health = await indexer.checkHealth(config.exclude)
|
|
279
|
-
if (health.needsReindex) {
|
|
280
|
-
totalExpectedFiles += health.expectedCount
|
|
281
|
-
needsWork = true
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
} finally {
|
|
285
|
-
await indexer.unloadModel()
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Notify about work to do
|
|
290
|
-
if (needsWork && onStart) {
|
|
291
|
-
onStart(totalExpectedFiles, estimateTime(totalExpectedFiles))
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Second pass - do the actual work
|
|
295
|
-
for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
|
|
296
|
-
if (!indexConfig.enabled) continue
|
|
297
|
-
|
|
298
|
-
const indexExists = await hasIndex(projectRoot, indexName)
|
|
299
|
-
const startTime = Date.now()
|
|
300
|
-
|
|
301
|
-
const indexer = await new CodebaseIndexer(projectRoot, indexName).init()
|
|
302
|
-
try {
|
|
303
|
-
if (!indexExists) {
|
|
304
|
-
log(`Creating "${indexName}" index...`)
|
|
305
|
-
const stats = await indexer.indexAll((indexed: number, total: number, file: string) => {
|
|
306
|
-
if (indexed % 10 === 0 || indexed === total) {
|
|
307
|
-
logFile(`"${indexName}": ${indexed}/${total} - ${file}`)
|
|
308
|
-
}
|
|
309
|
-
}, config.exclude)
|
|
310
|
-
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
|
|
311
|
-
log(`"${indexName}": done ${stats.indexed} files (${elapsed}s)`)
|
|
312
|
-
totalFiles += stats.indexed
|
|
313
|
-
action = 'created'
|
|
314
|
-
} else {
|
|
315
|
-
const health = await indexer.checkHealth(config.exclude)
|
|
316
|
-
|
|
317
|
-
if (health.needsReindex) {
|
|
318
|
-
log(`Rebuilding "${indexName}" (${health.reason}: ${health.currentCount} vs ${health.expectedCount} files)...`)
|
|
319
|
-
const stats = await indexer.indexAll((indexed: number, total: number, file: string) => {
|
|
320
|
-
if (indexed % 10 === 0 || indexed === total) {
|
|
321
|
-
logFile(`"${indexName}": ${indexed}/${total} - ${file}`)
|
|
322
|
-
}
|
|
323
|
-
}, config.exclude)
|
|
324
|
-
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
|
|
325
|
-
log(`"${indexName}": rebuilt ${stats.indexed} files (${elapsed}s)`)
|
|
326
|
-
totalFiles += stats.indexed
|
|
327
|
-
action = 'rebuilt'
|
|
328
|
-
} else {
|
|
329
|
-
log(`Freshening "${indexName}"...`)
|
|
330
|
-
const stats = await indexer.freshen()
|
|
331
|
-
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
|
|
332
|
-
|
|
333
|
-
if (stats.updated > 0 || stats.deleted > 0) {
|
|
334
|
-
log(`"${indexName}": +${stats.updated} -${stats.deleted} (${elapsed}s)`)
|
|
335
|
-
action = 'freshened'
|
|
336
|
-
} else {
|
|
337
|
-
log(`"${indexName}": fresh (${elapsed}s)`)
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
} finally {
|
|
342
|
-
await indexer.unloadModel()
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
elapsedSeconds = (Date.now() - overallStart) / 1000
|
|
347
|
-
log(`Indexes ready!`)
|
|
348
|
-
return { totalFiles, elapsedSeconds, action }
|
|
349
|
-
} catch (e) {
|
|
350
|
-
log(`Index error: ${(e as Error).message}`)
|
|
351
|
-
throw e
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
async function processPendingFiles(projectRoot: string, config: VectorizerConfig): Promise<void> {
|
|
356
|
-
if (pendingFiles.size === 0) return
|
|
357
|
-
|
|
358
|
-
const now = Date.now()
|
|
359
|
-
const filesToProcess: Map<string, string[]> = new Map()
|
|
360
|
-
|
|
361
|
-
for (const [filePath, info] of pendingFiles.entries()) {
|
|
362
|
-
if (now - info.timestamp >= config.debounce_ms) {
|
|
363
|
-
const files = filesToProcess.get(info.indexName) || []
|
|
364
|
-
files.push(filePath)
|
|
365
|
-
filesToProcess.set(info.indexName, files)
|
|
366
|
-
pendingFiles.delete(filePath)
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (filesToProcess.size === 0) return
|
|
371
|
-
|
|
372
|
-
debug(`Processing ${filesToProcess.size} index(es)...`)
|
|
373
|
-
|
|
374
|
-
try {
|
|
375
|
-
const vectorizerModule = path.join(projectRoot, ".opencode", "vectorizer", "index.js")
|
|
376
|
-
const { CodebaseIndexer } = await import(`file://${vectorizerModule}`)
|
|
377
|
-
|
|
378
|
-
for (const [indexName, files] of filesToProcess.entries()) {
|
|
379
|
-
const indexer = await new CodebaseIndexer(projectRoot, indexName).init()
|
|
380
|
-
try {
|
|
381
|
-
for (const filePath of files) {
|
|
382
|
-
try {
|
|
383
|
-
const wasIndexed = await indexer.indexSingleFile(filePath)
|
|
384
|
-
if (wasIndexed) {
|
|
385
|
-
log(`Reindexed: ${path.relative(projectRoot, filePath)} → ${indexName}`)
|
|
386
|
-
} else {
|
|
387
|
-
logFile(`Skipped (unchanged): ${path.relative(projectRoot, filePath)}`)
|
|
388
|
-
}
|
|
389
|
-
} catch (e) {
|
|
390
|
-
log(`Error reindexing ${path.relative(projectRoot, filePath)}: ${(e as Error).message}`)
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
} finally {
|
|
394
|
-
await indexer.unloadModel()
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
} catch (e) {
|
|
398
|
-
debug(`Fatal: ${(e as Error).message}`)
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
export const FileIndexerPlugin: Plugin = async ({ directory, client }) => {
|
|
403
|
-
let processingTimeout: NodeJS.Timeout | null = null
|
|
404
|
-
let config = await loadConfig(directory)
|
|
405
|
-
|
|
406
|
-
// Toast helper
|
|
407
|
-
const toast = async (message: string, variant: 'info' | 'success' | 'error' = 'info') => {
|
|
408
|
-
try {
|
|
409
|
-
await client?.tui?.showToast?.({ body: { message, variant } })
|
|
410
|
-
} catch {}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Always log plugin load
|
|
414
|
-
log(`Plugin loaded for: ${path.basename(directory)}`)
|
|
415
|
-
|
|
416
|
-
// Check if plugin should be active
|
|
417
|
-
if (!config.enabled || !config.auto_index) {
|
|
418
|
-
log(`Plugin DISABLED (enabled: ${config.enabled}, auto_index: ${config.auto_index})`)
|
|
419
|
-
return {
|
|
420
|
-
event: async () => {}, // No-op
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// Setup log file
|
|
425
|
-
logFilePath = path.join(directory, '.opencode', 'indexer.log')
|
|
426
|
-
try {
|
|
427
|
-
fsSync.writeFileSync(logFilePath, '') // Clear old log
|
|
428
|
-
} catch {
|
|
429
|
-
if (DEBUG) console.log(`[file-indexer] log init failed`)
|
|
430
|
-
logFilePath = null // Disable file logging if can't write
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
log(`Plugin ACTIVE`)
|
|
434
|
-
|
|
435
|
-
// Get language for fun messages
|
|
436
|
-
const lang = await getLanguage(directory)
|
|
437
|
-
const messages = FUN_MESSAGES[lang]
|
|
438
|
-
|
|
439
|
-
// Run indexing async (non-blocking) with toast notifications
|
|
440
|
-
// Small delay to let TUI initialize
|
|
441
|
-
setTimeout(async () => {
|
|
442
|
-
try {
|
|
443
|
-
const result = await ensureIndexOnSessionStart(
|
|
444
|
-
directory,
|
|
445
|
-
config,
|
|
446
|
-
// onStart callback - show toasts
|
|
447
|
-
async (totalFiles, estimatedMins) => {
|
|
448
|
-
await toast(messages.indexing(totalFiles), 'info')
|
|
449
|
-
// Only show fun message if there's actual work to do
|
|
450
|
-
if (totalFiles > 0) {
|
|
451
|
-
setTimeout(() => toast(messages.fun(totalFiles, estimatedMins), 'info'), 1500)
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
)
|
|
455
|
-
|
|
456
|
-
// Show result
|
|
457
|
-
if (result.action === 'skipped') {
|
|
458
|
-
toast(messages.fresh(), 'success')
|
|
459
|
-
} else {
|
|
460
|
-
const duration = formatDuration(result.elapsedSeconds)
|
|
461
|
-
toast(messages.done(result.totalFiles, duration), 'success')
|
|
462
|
-
}
|
|
463
|
-
} catch (e: any) {
|
|
464
|
-
toast(messages.error(e.message), 'error')
|
|
465
|
-
}
|
|
466
|
-
}, 1000)
|
|
467
|
-
|
|
468
|
-
function queueFileForIndexing(filePath: string): void {
|
|
469
|
-
const relativePath = path.relative(directory, filePath)
|
|
470
|
-
|
|
471
|
-
// Reject paths outside project directory
|
|
472
|
-
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
473
|
-
return
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Check exclusions from config
|
|
477
|
-
if (isExcluded(relativePath, config)) {
|
|
478
|
-
return
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const indexName = getIndexForFile(filePath, config)
|
|
482
|
-
if (!indexName) return
|
|
483
|
-
|
|
484
|
-
debug(`Queued: ${relativePath} -> ${indexName}`)
|
|
485
|
-
pendingFiles.set(filePath, { indexName, timestamp: Date.now() })
|
|
486
|
-
|
|
487
|
-
if (processingTimeout) {
|
|
488
|
-
clearTimeout(processingTimeout)
|
|
489
|
-
}
|
|
490
|
-
processingTimeout = setTimeout(async () => {
|
|
491
|
-
if (await isVectorizerInstalled(directory)) {
|
|
492
|
-
await processPendingFiles(directory, config)
|
|
493
|
-
} else {
|
|
494
|
-
debug(`Vectorizer not installed`)
|
|
495
|
-
pendingFiles.clear() // Prevent unbounded growth when vectorizer is not installed
|
|
496
|
-
}
|
|
497
|
-
}, config.debounce_ms + 100)
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Event handler for file changes (if events start working in future)
|
|
501
|
-
return {
|
|
502
|
-
event: async ({ event }) => {
|
|
503
|
-
// File edit events - queue for reindexing
|
|
504
|
-
if (event.type === "file.edited" || event.type === "file.watcher.updated") {
|
|
505
|
-
const props = (event as any).properties || {}
|
|
506
|
-
const filePath = props.file || props.path || props.filePath
|
|
507
|
-
if (filePath) {
|
|
508
|
-
log(`Event: ${event.type} → ${filePath}`)
|
|
509
|
-
queueFileForIndexing(filePath)
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
},
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
export default FileIndexerPlugin
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Publishes a TODO snapshot into the session chat.
|
|
5
|
-
*
|
|
6
|
-
* Why: the TODO sidebar may not live-refresh when we write native todo files
|
|
7
|
-
* (no todo.updated Bus event). This makes changes visible in the main dialog.
|
|
8
|
-
*/
|
|
9
|
-
export const UsethisTodoPublish: Plugin = async ({ client }) => {
|
|
10
|
-
return {
|
|
11
|
-
"tool.execute.after": async (input, output) => {
|
|
12
|
-
const publishTools = new Set([
|
|
13
|
-
"usethis_todo_write",
|
|
14
|
-
"usethis_todo_update",
|
|
15
|
-
"usethis_todo_read",
|
|
16
|
-
"usethis_todo_read_five",
|
|
17
|
-
"usethis_todo_read_by_id",
|
|
18
|
-
])
|
|
19
|
-
|
|
20
|
-
if (!publishTools.has(input.tool)) return
|
|
21
|
-
|
|
22
|
-
const text = [
|
|
23
|
-
`## TODO`,
|
|
24
|
-
// `session: ${input.sessionID}`,
|
|
25
|
-
"",
|
|
26
|
-
output.output
|
|
27
|
-
].join("\n")
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
await client.session.prompt({
|
|
31
|
-
path: { id: input.sessionID },
|
|
32
|
-
body: {
|
|
33
|
-
noReply: true,
|
|
34
|
-
parts: [{ type: "text", text }],
|
|
35
|
-
},
|
|
36
|
-
})
|
|
37
|
-
} catch {}
|
|
38
|
-
|
|
39
|
-
// Debug toasts removed
|
|
40
|
-
},
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export default UsethisTodoPublish
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* UseThis TODO UI Plugin — zero-state
|
|
5
|
-
*
|
|
6
|
-
* ONLY sets output.title for usethis_todo_* tools in TUI.
|
|
7
|
-
* No caching, no env vars, no before hook, no HTTP calls.
|
|
8
|
-
* Parses tool output string directly in after hook.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export const UsethisTodoUIPlugin: Plugin = async () => {
|
|
12
|
-
return {
|
|
13
|
-
"tool.execute.after": async (input, output) => {
|
|
14
|
-
if (!input.tool.startsWith("usethis_todo_")) return
|
|
15
|
-
|
|
16
|
-
const out = output.output || ""
|
|
17
|
-
|
|
18
|
-
if (input.tool === "usethis_todo_write") {
|
|
19
|
-
const match = out.match(/\[(\d+)\/(\d+) done/)
|
|
20
|
-
output.title = match ? `📋 TODO: ${match[2]} tasks` : "📋 TODO updated"
|
|
21
|
-
|
|
22
|
-
} else if (input.tool === "usethis_todo_update") {
|
|
23
|
-
const match = out.match(/^✅ (.+)$/m)
|
|
24
|
-
output.title = match ? `📝 ${match[1]}` : "📝 Task updated"
|
|
25
|
-
|
|
26
|
-
} else if (input.tool === "usethis_todo_read") {
|
|
27
|
-
const match = out.match(/\[(\d+)\/(\d+) done, (\d+) in progress\]/)
|
|
28
|
-
output.title = match ? `📋 TODO [${match[1]}/${match[2]} done]` : "📋 TODO list"
|
|
29
|
-
|
|
30
|
-
} else if (input.tool === "usethis_todo_read_next_five") {
|
|
31
|
-
output.title = "📋 Next 5 tasks"
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export default UsethisTodoUIPlugin
|