@comfanion/workflow 4.35.0 → 4.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comfanion/workflow",
3
- "version": "4.35.0",
3
+ "version": "4.36.1",
4
4
  "description": "Initialize OpenCode Workflow system for AI-assisted development with semantic code search",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "3.0.0",
3
- "buildDate": "2026-01-24T13:22:48.754Z",
3
+ "buildDate": "2026-01-24T14:49:34.378Z",
4
4
  "files": [
5
5
  "config.yaml",
6
6
  "FLOW.yaml",
@@ -1,25 +1,46 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import path from "path"
3
3
  import fs from "fs/promises"
4
+ import fsSync from "fs"
4
5
 
5
6
  /**
6
7
  * File Indexer Plugin
7
8
  *
8
9
  * Automatically manages semantic search indexes:
9
- * - On session start/resume: freshen existing indexes (update stale files)
10
+ * - On plugin load (opencode startup): freshen existing indexes
10
11
  * - On file edit: queue file for reindexing (debounced)
11
12
  *
12
13
  * Configuration in .opencode/config.yaml:
13
14
  * vectorizer:
14
15
  * enabled: true # Master switch
15
16
  * auto_index: true # Enable this plugin
16
- * debounce_ms: 100 # Wait time before indexing
17
+ * debounce_ms: 1000 # Wait time before indexing
17
18
  *
18
19
  * Debug mode: set DEBUG=file-indexer or DEBUG=* to see logs
19
20
  */
20
21
 
21
22
  const DEBUG = process.env.DEBUG?.includes('file-indexer') || process.env.DEBUG === '*'
22
23
 
24
+ let logFilePath: string | null = null
25
+
26
+ // Log to file only
27
+ function logFile(msg: string): void {
28
+ if (logFilePath) {
29
+ const timestamp = new Date().toISOString().slice(11, 19)
30
+ fsSync.appendFileSync(logFilePath, `${timestamp} ${msg}\n`)
31
+ }
32
+ }
33
+
34
+ // Log to console AND file
35
+ function log(msg: string): void {
36
+ console.log(`[file-indexer] ${msg}`)
37
+ logFile(msg)
38
+ }
39
+
40
+ function debug(msg: string): void {
41
+ if (DEBUG) log(msg)
42
+ }
43
+
23
44
  // Default config (used if config.yaml is missing or invalid)
24
45
  const DEFAULT_CONFIG = {
25
46
  enabled: true,
@@ -41,12 +62,91 @@ interface VectorizerConfig {
41
62
  exclude: string[]
42
63
  }
43
64
 
44
- const pendingFiles: Map<string, { indexName: string; timestamp: number }> = new Map()
65
+ // Fun messages based on file count and language
66
+ const FUN_MESSAGES = {
67
+ en: {
68
+ indexing: (files: number) => `Indexing ${files} files...`,
69
+ fun: (files: number, mins: number) => {
70
+ if (files < 20) return `Quick coffee? ☕`
71
+ if (files < 100) return `~${mins}min. Stretch break? 🧘`
72
+ if (files < 500) return `~${mins}min. Make coffee ☕ and relax 🛋️`
73
+ return `~${mins}min. Go touch grass 🌿 or take a nap 😴`
74
+ },
75
+ done: (files: number, duration: string) => {
76
+ if (files < 20) return `Done! ${files} files in ${duration}. Fast! 🚀`
77
+ if (files < 100) return `Indexed ${files} files in ${duration}. Let's go! 🎸`
78
+ return `${files} files in ${duration}. Worth the wait! 🎉`
79
+ },
80
+ fresh: () => `Everything's fresh! Nothing to do 😎`,
81
+ error: (msg: string) => `Oops! ${msg} 😬`
82
+ },
83
+ uk: {
84
+ indexing: (files: number) => `Індексую ${files} файлів...`,
85
+ fun: (files: number, mins: number) => {
86
+ if (files < 20) return `Швидка кава? ☕`
87
+ if (files < 100) return `~${mins}хв. Розімнись! 🧘`
88
+ if (files < 500) return `~${mins}хв. Зроби каву ☕ і відпочинь 🛋️`
89
+ return `~${mins}хв. Йди погуляй 🌿 або поспи 😴`
90
+ },
91
+ done: (files: number, duration: string) => {
92
+ if (files < 20) return `Готово! ${files} файлів за ${duration}. Швидко! 🚀`
93
+ if (files < 100) return `${files} файлів за ${duration}. Поїхали! 🎸`
94
+ return `${files} файлів за ${duration}. Варто було чекати! 🎉`
95
+ },
96
+ fresh: () => `Все свіже! Нічого робити 😎`,
97
+ error: (msg: string) => `Ой! ${msg} 😬`
98
+ },
99
+ ru: {
100
+ indexing: (files: number) => `Индексирую ${files} файлов...`,
101
+ fun: (files: number, mins: number) => {
102
+ if (files < 20) return `Кофе? ☕`
103
+ if (files < 100) return `~${mins}мин. Разомнись! 🧘`
104
+ if (files < 500) return `~${mins}мин. Сделай кофе ☕ и отдохни 🛋️`
105
+ return `~${mins}мин. Иди погуляй 🌿 или поспи 😴`
106
+ },
107
+ done: (files: number, duration: string) => {
108
+ if (files < 20) return `Готово! ${files} файлов за ${duration}. Быстро! 🚀`
109
+ if (files < 100) return `${files} файлов за ${duration}. Поехали! 🎸`
110
+ return `${files} файлов за ${duration}. Стоило подождать! 🎉`
111
+ },
112
+ fresh: () => `Всё свежее! Делать нечего 😎`,
113
+ error: (msg: string) => `Ой! ${msg} 😬`
114
+ }
115
+ }
45
116
 
46
- function debug(msg: string): void {
47
- if (DEBUG) console.log(`[file-indexer] ${msg}`)
117
+ type Lang = keyof typeof FUN_MESSAGES
118
+
119
+ async function getLanguage(projectRoot: string): Promise<Lang> {
120
+ try {
121
+ const configPath = path.join(projectRoot, ".opencode", "config.yaml")
122
+ const content = await fs.readFile(configPath, 'utf8')
123
+ const match = content.match(/communication_language:\s*["']?(\w+)["']?/i)
124
+ const lang = match?.[1]?.toLowerCase()
125
+ if (lang === 'ukrainian' || lang === 'uk') return 'uk'
126
+ if (lang === 'russian' || lang === 'ru') return 'ru'
127
+ return 'en'
128
+ } catch {
129
+ return 'en'
130
+ }
48
131
  }
49
132
 
133
+ function estimateTime(fileCount: number): number {
134
+ // Model loading: ~30 sec, then ~0.5 sec per file
135
+ const modelLoadTime = 30 // seconds
136
+ const perFileTime = 0.5 // seconds
137
+ const totalSeconds = modelLoadTime + (fileCount * perFileTime)
138
+ return Math.ceil(totalSeconds / 60) // minutes
139
+ }
140
+
141
+ function formatDuration(seconds: number): string {
142
+ if (seconds < 60) return `${Math.round(seconds)}s`
143
+ const mins = Math.floor(seconds / 60)
144
+ const secs = Math.round(seconds % 60)
145
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`
146
+ }
147
+
148
+ const pendingFiles: Map<string, { indexName: string; timestamp: number }> = new Map()
149
+
50
150
  async function loadConfig(projectRoot: string): Promise<VectorizerConfig> {
51
151
  try {
52
152
  const configPath = path.join(projectRoot, ".opencode", "config.yaml")
@@ -119,46 +219,120 @@ async function hasIndex(projectRoot: string, indexName: string): Promise<boolean
119
219
  }
120
220
  }
121
221
 
222
+ interface IndexResult {
223
+ totalFiles: number
224
+ elapsedSeconds: number
225
+ action: 'created' | 'rebuilt' | 'freshened' | 'skipped'
226
+ }
227
+
122
228
  /**
123
229
  * Ensure index exists and is fresh on session start
230
+ * Creates index if missing, freshens if exists
124
231
  */
125
- async function ensureIndexOnSessionStart(projectRoot: string, config: VectorizerConfig): Promise<void> {
232
+ async function ensureIndexOnSessionStart(
233
+ projectRoot: string,
234
+ config: VectorizerConfig,
235
+ onStart?: (totalFiles: number, estimatedMins: number) => void
236
+ ): Promise<IndexResult> {
237
+ let totalFiles = 0
238
+ let elapsedSeconds = 0
239
+ let action: IndexResult['action'] = 'skipped'
240
+
126
241
  if (!await isVectorizerInstalled(projectRoot)) {
127
- debug('Session start: vectorizer not installed, skipping')
128
- return
242
+ log(`Vectorizer not installed - run: npx @comfanion/workflow vectorizer install`)
243
+ return { totalFiles: 0, elapsedSeconds: 0, action: 'skipped' }
129
244
  }
130
245
 
131
246
  try {
132
247
  const vectorizerModule = path.join(projectRoot, ".opencode", "vectorizer", "index.js")
133
248
  const { CodebaseIndexer } = await import(`file://${vectorizerModule}`)
249
+ const overallStart = Date.now()
250
+
251
+ // First pass - count files and check health
252
+ let needsWork = false
253
+ let totalExpectedFiles = 0
134
254
 
135
- // Check each enabled index
136
255
  for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
137
256
  if (!indexConfig.enabled) continue
138
-
257
+ const indexer = await new CodebaseIndexer(projectRoot, indexName).init()
139
258
  const indexExists = await hasIndex(projectRoot, indexName)
140
259
 
141
260
  if (!indexExists) {
142
- // No index - need full indexing (but don't block session, just log)
143
- debug(`Session start: index "${indexName}" not found, run: npx @comfanion/workflow index --index ${indexName}`)
144
- continue
261
+ const health = await indexer.checkHealth(config.exclude)
262
+ totalExpectedFiles += health.expectedCount
263
+ needsWork = true
264
+ } else {
265
+ const health = await indexer.checkHealth(config.exclude)
266
+ if (health.needsReindex) {
267
+ totalExpectedFiles += health.expectedCount
268
+ needsWork = true
269
+ }
145
270
  }
271
+ await indexer.unloadModel()
272
+ }
273
+
274
+ // Notify about work to do
275
+ if (needsWork && onStart) {
276
+ onStart(totalExpectedFiles, estimateTime(totalExpectedFiles))
277
+ }
278
+
279
+ // Second pass - do the actual work
280
+ for (const [indexName, indexConfig] of Object.entries(config.indexes)) {
281
+ if (!indexConfig.enabled) continue
282
+
283
+ const indexExists = await hasIndex(projectRoot, indexName)
284
+ const startTime = Date.now()
146
285
 
147
- // Index exists - freshen it (update stale files)
148
- debug(`Session start: freshening index "${indexName}"...`)
149
286
  const indexer = await new CodebaseIndexer(projectRoot, indexName).init()
150
- const stats = await indexer.freshen()
151
287
 
152
- if (stats.updated > 0 || stats.deleted > 0) {
153
- debug(`Session start: ${indexName} - updated ${stats.updated}, deleted ${stats.deleted}`)
288
+ if (!indexExists) {
289
+ log(`Creating "${indexName}" index...`)
290
+ const stats = await indexer.indexAll((indexed: number, total: number, file: string) => {
291
+ if (indexed % 10 === 0 || indexed === total) {
292
+ logFile(`"${indexName}": ${indexed}/${total} - ${file}`)
293
+ }
294
+ }, config.exclude)
295
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
296
+ log(`"${indexName}": done ${stats.indexed} files (${elapsed}s)`)
297
+ totalFiles += stats.indexed
298
+ action = 'created'
154
299
  } else {
155
- debug(`Session start: ${indexName} - index is fresh`)
300
+ const health = await indexer.checkHealth(config.exclude)
301
+
302
+ if (health.needsReindex) {
303
+ log(`Rebuilding "${indexName}" (${health.reason}: ${health.currentCount} vs ${health.expectedCount} files)...`)
304
+ const stats = await indexer.indexAll((indexed: number, total: number, file: string) => {
305
+ if (indexed % 10 === 0 || indexed === total) {
306
+ logFile(`"${indexName}": ${indexed}/${total} - ${file}`)
307
+ }
308
+ }, config.exclude)
309
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
310
+ log(`"${indexName}": rebuilt ${stats.indexed} files (${elapsed}s)`)
311
+ totalFiles += stats.indexed
312
+ action = 'rebuilt'
313
+ } else {
314
+ log(`Freshening "${indexName}"...`)
315
+ const stats = await indexer.freshen()
316
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
317
+
318
+ if (stats.updated > 0 || stats.deleted > 0) {
319
+ log(`"${indexName}": +${stats.updated} -${stats.deleted} (${elapsed}s)`)
320
+ action = 'freshened'
321
+ } else {
322
+ log(`"${indexName}": fresh (${elapsed}s)`)
323
+ }
324
+ }
156
325
  }
157
326
 
158
327
  await indexer.unloadModel()
159
328
  }
329
+
330
+ elapsedSeconds = (Date.now() - overallStart) / 1000
331
+ log(`Indexes ready!`)
332
+ return { totalFiles, elapsedSeconds, action }
160
333
  } catch (e) {
161
- debug(`Session start error: ${(e as Error).message}`)
334
+ log(`Index error: ${(e as Error).message}`)
335
+ throw e
162
336
  }
163
337
  }
164
338
 
@@ -209,20 +383,63 @@ async function processPendingFiles(projectRoot: string, config: VectorizerConfig
209
383
  }
210
384
  }
211
385
 
212
- export const FileIndexerPlugin: Plugin = async ({ directory }) => {
386
+ export const FileIndexerPlugin: Plugin = async ({ directory, client }) => {
213
387
  let processingTimeout: NodeJS.Timeout | null = null
214
388
  let config = await loadConfig(directory)
215
389
 
390
+ // Toast helper
391
+ const toast = async (message: string, variant: 'info' | 'success' | 'error' = 'info') => {
392
+ try {
393
+ await client?.tui?.showToast?.({ body: { message, variant } })
394
+ } catch {}
395
+ }
396
+
397
+ // Always log plugin load
398
+ log(`Plugin loaded for: ${path.basename(directory)}`)
399
+
216
400
  // Check if plugin should be active
217
401
  if (!config.enabled || !config.auto_index) {
218
- debug(`Plugin disabled (enabled: ${config.enabled}, auto_index: ${config.auto_index})`)
402
+ log(`Plugin DISABLED (enabled: ${config.enabled}, auto_index: ${config.auto_index})`)
219
403
  return {
220
404
  event: async () => {}, // No-op
221
405
  }
222
406
  }
223
407
 
224
- debug(`Plugin loaded for: ${directory}`)
225
- debug(`Config: debounce=${config.debounce_ms}ms, exclude=${config.exclude.length} patterns`)
408
+ // Setup log file
409
+ logFilePath = path.join(directory, '.opencode', 'indexer.log')
410
+ fsSync.writeFileSync(logFilePath, '') // Clear old log
411
+
412
+ log(`Plugin ACTIVE`)
413
+
414
+ // Get language for fun messages
415
+ const lang = await getLanguage(directory)
416
+ const messages = FUN_MESSAGES[lang]
417
+
418
+ // Run indexing async (non-blocking) with toast notifications
419
+ // Small delay to let TUI initialize
420
+ setTimeout(async () => {
421
+ try {
422
+ const result = await ensureIndexOnSessionStart(
423
+ directory,
424
+ config,
425
+ // onStart callback - show 2 toasts
426
+ async (totalFiles, estimatedMins) => {
427
+ await toast(messages.indexing(totalFiles), 'info')
428
+ setTimeout(() => toast(messages.fun(totalFiles, estimatedMins), 'info'), 1500)
429
+ }
430
+ )
431
+
432
+ // Show result
433
+ if (result.action === 'skipped') {
434
+ toast(messages.fresh(), 'success')
435
+ } else {
436
+ const duration = formatDuration(result.elapsedSeconds)
437
+ toast(messages.done(result.totalFiles, duration), 'success')
438
+ }
439
+ } catch (e: any) {
440
+ toast(messages.error(e.message), 'error')
441
+ }
442
+ }, 1000)
226
443
 
227
444
  function queueFileForIndexing(filePath: string): void {
228
445
  const relativePath = path.relative(directory, filePath)
@@ -250,35 +467,15 @@ export const FileIndexerPlugin: Plugin = async ({ directory }) => {
250
467
  }, config.debounce_ms + 100)
251
468
  }
252
469
 
253
- // Track if we've already freshened this session
254
- let sessionFreshened = false
255
-
470
+ // Event handler for file changes (if events start working in future)
256
471
  return {
257
- event: async (ctx) => {
258
- const event = ctx.event
259
-
260
- // Freshen index on session start/resume
261
- if ((event.type === "session.started" || event.type === "session.resumed") && !sessionFreshened) {
262
- sessionFreshened = true
263
- debug(`${event.type}: checking indexes...`)
264
- // Run async, don't block
265
- ensureIndexOnSessionStart(directory, config).catch(e => debug(`Error: ${e.message}`))
266
- }
267
-
268
- if (event.type === "file.edited") {
269
- const props = (event as any).properties || {}
270
- const filePath = props.file || props.path || props.filePath
271
- if (filePath) {
272
- debug(`file.edited: ${filePath}`)
273
- queueFileForIndexing(filePath)
274
- }
275
- }
276
-
277
- if (event.type === "file.watcher.updated") {
472
+ event: async ({ event }) => {
473
+ // File edit events - queue for reindexing
474
+ if (event.type === "file.edited" || event.type === "file.watcher.updated") {
278
475
  const props = (event as any).properties || {}
279
476
  const filePath = props.file || props.path || props.filePath
280
477
  if (filePath) {
281
- debug(`file.watcher.updated: ${filePath}`)
478
+ debug(`${event.type}: ${filePath}`)
282
479
  queueFileForIndexing(filePath)
283
480
  }
284
481
  }