@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.
@@ -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