From 2f6713e6b25c35cf94c4adcc7d004c8a5ca3f429 Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 21 Apr 2026 14:27:49 +0200 Subject: [PATCH 01/29] COPSPA-489: Implemantation Plan --- IMPLEMENTATION_PLAN.md | 941 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 941 insertions(+) create mode 100644 IMPLEMENTATION_PLAN.md diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..984c7a5 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,941 @@ +# 📘 IMPLEMENTATION PLAN +## OpenCode Plugin nutzt lib-ts-time-tracking + +**Status:** Ready to Implement +**Estimated Time:** 22h (3 Tage) +**Last Updated:** 2026-04-21 + +--- + +## TABLE OF CONTENTS + +1. [Overview](#overview) +2. [Phase 1: Vorbereitung](#phase-1-vorbereitung) +3. [Phase 2: Refactoring](#phase-2-refactoring) +4. [Phase 3: Cleanup](#phase-3-cleanup) +5. [Phase 4: Testing](#phase-4-testing) +6. [Phase 5: Dokumentation](#phase-5-dokumentation) +7. [Checkliste](#checkliste) + +--- + +## OVERVIEW + +### Ausgangssituation +``` +Aktueller Flow im OpenCode Plugin: + session.status.idle Event + ↓ + SessionManager.getAndDelete(sessionID) + ↓ + DescriptionGenerator.generate() + TitleGenerator.generate() + ↓ + Manuelle CsvEntryData Zusammenstellung + ↓ + Writers aufrufen (CSV, Webhook) +``` + +### Zielfluss +``` +Mit lib-ts-time-tracking Integration: + session.status.idle Event + ↓ + SessionManager.getAndDelete(sessionID) → SessionDataInterface Builder + ↓ + TimeTrackingFacade.track() [aus Lib!] + ↓ + SessionSummaryGenerator.generateSummary() [aus Lib!] + ↓ + CSV + Webhook Output (von Lib) +``` + +### Kernerkenntnisse zur Implementierung + +#### A. SessionManager - WRAPPER PATTERN (Option A) +``` +lib-ts-time-tracking/src/services/OpenCodeSessionManager.ts (NEU) + ↓ (generisch, keine SDK-Dependencies) +opencode-plugin-time-tracking/src/services/SessionManager.ts (Wrapper) + ↓ (OpenCode-spezifisch) +EventHook.ts +``` + +**GrĂŒnde:** +- Zukunftssicherheit: Wenn OpenCode spĂ€ter Plugin-spezifische Methoden braucht +- Klare Verantwortung: SessionManager = OpenCode Facade +- Weniger Coupling: Plugin nicht direkt von Lib-Details abhĂ€ngig + +#### B. ConversationContextProvider - INLINE (Option A) +```typescript +// In EventHook.ts - beim idle Event: +const conversationContextProvider = async () => { + const messages = await client.session.messages({ + sessionID, + limit: 10 + }) + return messages.map(m => formatMessage(m)).join("\n") +} + +// Dann beim track()-Call: +const sessionData: SessionDataInterface = { + // ... + conversationContext: conversationContextProvider +} +``` + +**GrĂŒnde:** +- KISS Principle - nur 5-10 Zeilen Code +- Aktuell nur ein Use-Case +- Direkter sichtbar im Event-Handler + +#### C. Lazy Loading der Facade (Marketplace-Pattern) +```typescript +// In Plugin.ts +let facadePromise: Promise | null = null + +async function getFacade(config): Promise { + if (!facadePromise) { + facadePromise = Promise.resolve(new TimeTrackingFacade(config)) + } + return facadePromise +} +``` + +#### D. Error Handling mit Fallback +- Wenn ConversationContext fehlschlĂ€gt → Activity-Summary Fallback +- Wenn LLM API down → Activity-Summary Fallback +- Wenn CSV/Webhook Fehler → isoliert, andere Writer lĂ€uft weiter +- CSV wird IMMER geschrieben (Graceful Degradation) + +--- + +## PHASE 1: VORBEREITUNG + +**Dauer:** ~4h +**Ziel:** Neue Dateien erstellen, Dependencies einrichten + +### Step 1.1: Workspace Dependencies + +**Datei:** `opencode-plugin-time-tracking/package.json` + +HinzufĂŒgen zu `dependencies`: +```json +{ + "dependencies": { + "@opencode-ai/plugin": "^latest", + "@techdivision/lib-ts-time-tracking": "workspace:*" + } +} +``` + +**Command:** `npm install` oder `bun install` + +### Step 1.2: Neue Datei - OpenCodeSessionManager in der Lib + +**Pfad:** `lib-ts-time-tracking/src/services/OpenCodeSessionManager.ts` + +**Inhalt:** Kopiere aus aktuellem `opencode-plugin-time-tracking/src/services/SessionManager.ts` + +**Wichtig:** +- ❌ KEINE SDK-Imports (OpencodeClient, MessageWithParts, etc.) +- ✅ JA zu allen CRUD Operations fĂŒr SessionData +- ✅ JA zu ActivityData, TokenUsage, etc. (Standard Types) + +**Struktur:** +```typescript +export class OpenCodeSessionManager { + private sessions = new Map() + + get(sessionID: string): SessionData | undefined { ... } + has(sessionID: string): boolean { ... } + create(sessionID: string, ticket: string | null): SessionData { ... } + delete(sessionID: string): void { ... } + getAndDelete(sessionID: string): SessionData | undefined { ... } + addActivity(sessionID: string, activity: ActivityData): void { ... } + addTokenUsage(sessionID: string, tokens: TokenUsage): void { ... } + addCost(sessionID: string, cost: number): void { ... } +} +``` + +### Step 1.3: Neue Datei - SessionDataMapper im Plugin + +**Pfad:** `opencode-plugin-time-tracking/src/adapters/SessionDataMapper.ts` + +**Zweck:** Konvertiert OpenCode's SessionData → Lib's SessionDataInterface + +**Inhalt:** +```typescript +import type { OpencodeClient } from "../types/OpencodeClient" +import type { SessionData } from "../types/SessionData" +import type { SessionDataInterface } from "@techdivision/lib-ts-time-tracking" + +/** + * Converts OpenCode plugin's SessionData to lib's SessionDataInterface. + * Builds the ConversationContextProvider callback inline. + */ +export class SessionDataMapper { + /** + * Builds SessionDataInterface from SessionData. + * Includes conversation context provider callback. + */ + static build( + session: SessionData, + client: OpencodeClient, + sessionID: string, + config: { userEmail?: string } + ): SessionDataInterface { + // Format model as "provider/modelID" + const modelString = session.model + ? `${session.model.providerID}/${session.model.modelID}` + : "unknown" + + // Build conversation context provider inline + const conversationContextProvider = async (): Promise => { + try { + const messages = await client.session.messages({ + sessionID, + limit: 10, + } as Parameters[0]) + + if (!messages || messages.length === 0) { + return null + } + + // Format messages as context string + return messages + .map((m) => { + const role = m.info?.role || "unknown" + const content = m.content || "" + return `${role}: ${content}` + }) + .join("\n") + } catch { + // Graceful degradation: if SDK call fails, return null + // Lib will use activity-based fallback + return null + } + } + + return { + agent: session.agent?.name ?? "unknown", + model: modelString, + startTime: session.startTime, + endTime: Date.now(), + userEmail: config.userEmail, + tokens: { + input: session.tokenUsage.input, + output: session.tokenUsage.output, + cacheRead: session.tokenUsage.cacheRead, + cacheWrite: session.tokenUsage.cacheWrite, + }, + activities: session.activities, + conversationContext: conversationContextProvider, + ticket: session.ticket ?? undefined, + } + } +} +``` + +### Step 1.4: Export in lib's index.ts + +**Datei:** `lib-ts-time-tracking/src/index.ts` + +HinzufĂŒgen: +```typescript +export { OpenCodeSessionManager } from "./services/OpenCodeSessionManager.js" +``` + +Stelle sicher dass `TimeTrackingFacade` auch exportiert ist. + +### Step 1.5: Kompilation Check + +```bash +# In lib-ts-time-tracking +npm run build +# oder +tsc --noEmit + +# In opencode-plugin-time-tracking +npm run build +# oder +tsc --noEmit +``` + +**Muss ohne Fehler laufen!** + +--- + +## PHASE 2: REFACTORING + +**Dauer:** ~10h +**Ziel:** Bestehende Dateien refactoren, Lib-Integration einbauen + +### Step 2.1: Plugin.ts - Facade Initialization + +**Datei:** `opencode-plugin-time-tracking/src/Plugin.ts` + +**Imports - ENTFERNEN:** +```typescript +import { TitleGenerator } from "./services/TitleGenerator" +import { ProviderAdapter } from "./services/ProviderAdapter" +``` + +**Imports - HINZUFÜGEN:** +```typescript +import { TimeTrackingFacade } from "@techdivision/lib-ts-time-tracking" +``` + +**Neue Funktion - HINZUFÜGEN (vor createPlugin):** +```typescript +/** + * Lazy-loads TimeTrackingFacade instance. + * Follows Marketplace plugin pattern for single initialization. + */ +let facadePromise: Promise | null = null + +async function getTimeTrackingFacade( + config: TimeTrackingConfig +): Promise { + if (!facadePromise) { + facadePromise = Promise.resolve(new TimeTrackingFacade(config)) + } + return facadePromise +} +``` + +**In createPlugin() - ENTFERNEN:** +```typescript +const titleGenerator = new TitleGenerator(client, config.time_tracking, configDir) +await titleGenerator.checkAvailability() +``` + +**In createPlugin() - ÄNDERN bei createEventHook():** + +Alt: +```typescript +const eventHook = createEventHook( + sessionManager, + writers, + client, + ticketResolver, + config, + titleGenerator // ← ENTFERNEN +) +``` + +Neu: +```typescript +const eventHook = createEventHook( + sessionManager, + writers, + client, + ticketResolver, + config, + (timeTrackingConfig) => getTimeTrackingFacade(timeTrackingConfig) // ← HINZUFÜGEN +) +``` + +**Wichtig:** Die `createEventHook` Signature muss angepasst werden (siehe Step 2.2) + +### Step 2.2: EventHook.ts - Core Refactoring + +**Datei:** `opencode-plugin-time-tracking/src/hooks/EventHook.ts` + +#### 2.2.1: Function Signature anpassen + +Alt: +```typescript +export function createEventHook( + sessionManager: SessionManager, + writers: WriterService[], + client: OpencodeClient, + ticketResolver: TicketResolver, + config: TimeTrackingConfig, + titleGenerator: TitleGenerator +): EventHook { +``` + +Neu: +```typescript +export function createEventHook( + sessionManager: SessionManager, + writers: WriterService[], + client: OpencodeClient, + ticketResolver: TicketResolver, + config: TimeTrackingConfig, + getTimeTrackingFacade: (cfg: any) => Promise +): EventHook { +``` + +#### 2.2.2: Imports anpassen + +**ENTFERNEN:** +```typescript +import { DescriptionGenerator } from "../utils/DescriptionGenerator" +import { TitleGenerator } from "../services/TitleGenerator" +import { extractSummaryTitle } from "../utils/MessageExtractor" +``` + +**HINZUFÜGEN:** +```typescript +import { SessionDataMapper } from "../adapters/SessionDataMapper" +import type { TimeTrackingFacade } from "@techdivision/lib-ts-time-tracking" +``` + +#### 2.2.3: Session Status Handler refactoren + +**Finde:** `if (event.type === "session.status") {` (ungefĂ€hr Zeile 166) + +**ENTFERNEN:** Zeilen ~200-224 (LLM Title + Activity Summary Logik) +```typescript +// ALT - ENTFERNEN: +const activitySummary = DescriptionGenerator.generate(session.activities) +let title = await extractSummaryTitle(client, sessionID) +if (!title) { + try { + title = await Promise.race([ + titleGenerator.generate(sessionID), + new Promise((resolve) => setTimeout(() => resolve(null), 8000)), + ]) + } catch { + title = null + } +} +const description = title + ? `${title} | ${activitySummary}` + : activitySummary +``` + +**ERSETZEN mit (NEU):** +```typescript +// NEU - Nutze Lib's SessionSummaryGenerator via Facade +const sessionData = SessionDataMapper.build(session, client, sessionID, { + userEmail: config.user_email, +}) + +const facade = await getTimeTrackingFacade(config.time_tracking) +const trackResult = await facade.track(sessionData) +const description = trackResult.summary.description +``` + +**ENTFERNEN:** Zeilen ~222-224 (Tool Summary) +```typescript +// ALT - ENTFERNEN: +const toolSummary = DescriptionGenerator.generateToolSummary( + session.activities +) +``` + +**Ersetzen mit:** +```typescript +// NEU - Kommt von Lib +const notes = trackResult.summary.notes +``` + +**ENTFERNEN:** Zeilen ~260-276 (Manuelle CsvEntryData Zusammenstellung) +```typescript +// ALT - ENTFERNEN (große Block): +const entryData: CsvEntryData = { + id: randomUUID(), + userEmail: config.user_email, + ticket: resolved.ticket, + accountKey: resolved.accountKey, + authorEmail: resolved.authorEmail, + startTime: session.startTime, + endTime, + durationSeconds, + description, + notes: `Auto-tracked: ${toolSummary}`, + tokenUsage: session.tokenUsage, + cost: session.cost, + model: modelString, + agent: (resolved.primaryAgent ?? agentString)?.replace(/^@/, "") ?? null, +} +``` + +**ERSETZEN mit:** +```typescript +// NEU - Nutze trackResult.entry von Lib +const entryData: CsvEntryData = { + ...trackResult.entry, // CSV entry kommt direkt von Lib! + ticket: resolved.ticket, // OpenCode Resolving override + accountKey: resolved.accountKey, + agent: (resolved.primaryAgent ?? agentString)?.replace(/^@/, "") ?? null, +} +``` + +**ÄNDERN:** Writers aufrufen + +Alt: +```typescript +// ALT - ENTFERNEN (Zeilen ~278-284): +const results: WriteResult[] = [] +for (const writer of writers) { + const result = await writer.write(entryData) + results.push(result) +} +``` + +Neu: +```typescript +// NEU - Writers werden von Facade aufgerufen +// aber wir haben trotzdem access zu trackResult.csv und trackResult.webhook: +const results: WriteResult[] = [ + trackResult.csv, + trackResult.webhook, +].filter(r => r !== undefined && r !== null) +``` + +**ÄNDERN:** Toast Feedback + +Alt: +```typescript +if (!titleGenerator.isAvailable) { + message += " (title generation NOT available)" +} +``` + +Neu: +```typescript +if (trackResult.summary.llmError) { + message += ` (LLM: ${trackResult.summary.llmError})` +} +``` + +#### 2.2.4: Kompilation Test + +```bash +npm run build +# oder +tsc --noEmit +``` + +### Step 2.3: SessionManager.ts - Wrapper Umwandlung + +**Datei:** `opencode-plugin-time-tracking/src/services/SessionManager.ts` + +**Imports - ÄNDERN:** +```typescript +import { OpenCodeSessionManager } from "@techdivision/lib-ts-time-tracking" +``` + +**Entfernen:** Private sessions Map + +```typescript +// ALT - ENTFERNEN: +private sessions = new Map() +``` + +**HinzufĂŒgen:** +```typescript +// NEU: +private manager = new OpenCodeSessionManager() +``` + +**Alle Methods - DELEGIEREN:** +```typescript +export class SessionManager { + private manager = new OpenCodeSessionManager() + + get(sessionID: string): SessionData | undefined { + return this.manager.get(sessionID) + } + + has(sessionID: string): boolean { + return this.manager.has(sessionID) + } + + create(sessionID: string, ticket: string | null): SessionData { + return this.manager.create(sessionID, ticket) + } + + delete(sessionID: string): void { + this.manager.delete(sessionID) + } + + getAndDelete(sessionID: string): SessionData | undefined { + return this.manager.getAndDelete(sessionID) + } + + addActivity(sessionID: string, activity: ActivityData): void { + this.manager.addActivity(sessionID, activity) + } + + addTokenUsage(sessionID: string, tokens: TokenUsage): void { + this.manager.addTokenUsage(sessionID, tokens) + } + + addCost(sessionID: string, cost: number): void { + this.manager.addCost(sessionID, cost) + } +} +``` + +**Wichtig:** Keine neue Logik! Nur delegation. + +--- + +## PHASE 3: CLEANUP + +**Dauer:** ~2h +**Ziel:** Duplikate löschen + +### Dateien zum LÖSCHEN (6 Dateien, ~870 Zeilen) + +``` +✗ src/services/TitleGenerator.ts (332 Zeilen) + BegrĂŒndung: Ersetzt durch lib's SessionSummaryGenerator + +✗ src/utils/DescriptionGenerator.ts (119 Zeilen) + BegrĂŒndung: Ersetzt durch lib's Activity-Summary Logic in SessionSummaryGenerator + +✗ src/services/CsvWriter.ts (284 Zeilen) + BegrĂŒndung: 100% identisch mit lib's CsvWriter + +✗ src/services/WebhookSender.ts (137 Zeilen) + BegrĂŒndung: 100% identisch mit lib's WebhookSender + +✗ src/services/ProviderAdapter.ts + BegrĂŒndung: 100% identisch mit lib's ProviderAdapter + +✗ src/utils/MessageExtractor.ts + BegrĂŒndung: Nur fĂŒr extractSummaryTitle verwendet, nicht mehr nötig +``` + +### Dateien die BLEIBEN (OpenCode-spezifisch) + +``` +✓ src/services/SessionManager.ts (jetzt Wrapper) +✓ src/services/TicketResolver.ts (SDK-spezifisch, nicht in Lib) +✓ src/services/ConfigLoader.ts (OpenCode Config) +✓ src/hooks/ToolExecuteAfterHook.ts (Tool Activity Tracking) +✓ src/hooks/EventHook.ts (refactored) +✓ Alle Type Definitions in src/types/ +✓ Alle anderen Utils +``` + +### Kompilation Test + +Nach dem Löschen: +```bash +npm run build +``` + +Sollte trotzdem funktionieren ohne Fehler! + +--- + +## PHASE 4: TESTING + +**Dauer:** ~6h +**Ziel:** Unit Tests, Integration Tests, E2E Test + +### Step 4.1: Unit Tests + +**Neu erstellen:** `tests/unit/adapters/SessionDataMapper.test.ts` + +```typescript +import { describe, it, expect, vi } from "vitest" +import { SessionDataMapper } from "../../../src/adapters/SessionDataMapper" +import type { SessionData } from "../../../src/types/SessionData" +import type { OpencodeClient } from "../../../src/types/OpencodeClient" + +describe("SessionDataMapper", () => { + it("maps SessionData to SessionDataInterface correctly", () => { + // Test hier + }) + + it("formats model as provider/modelID", () => { + // Test hier + }) + + it("builds conversationContextProvider callback", async () => { + // Test hier + }) + + it("handles null/undefined values gracefully", () => { + // Test hier + }) + + it("catches client.session.messages errors", async () => { + // Test hier + }) +}) +``` + +**Überarbeiten:** `tests/unit/services/SessionManager.test.ts` + +Stelle sicher dass SessionManager korrekt zu OpenCodeSessionManager delegiert. + +### Step 4.2: Integration Test + +**Neu erstellen:** `tests/integration/hooks/EventHook.time-tracking.test.ts` + +```typescript +import { describe, it, expect, beforeEach, vi } from "vitest" +import { createEventHook } from "../../../src/hooks/EventHook" +import type { MessageWithParts } from "../../../src/types/MessageWithParts" + +describe("EventHook - Time Tracking Integration", () => { + it("processes session.status.idle event with TimeTrackingFacade", async () => { + // Mock setup + // Event dispatch + // Assertions + }) + + it("builds CSV entry from trackResult.entry", async () => { + // Test hier + }) + + it("shows correct toast feedback on success", async () => { + // Test hier + }) + + it("handles errors with graceful degradation", async () => { + // Test hier + }) +}) +``` + +### Step 4.3: E2E Test (Manuell) + +1. **OpenCode Session starten:** + ```bash + opencode dev + ``` + +2. **Tool aufrufen:** z.B. File editieren mit `edit` + +3. **Session beenden:** Fenster schließen oder `stop` + +4. **Verifizierungen:** + - [ ] CSV wurde an konfiguriertem Ort geschrieben + - [ ] Webhook wurde aufgerufen (falls konfiguriert) + - [ ] Description enthĂ€lt Activity Summary oder LLM-Text + - [ ] Toast Feedback zeigt Ticket + Token Count + - [ ] Keine Errors im Console + +5. **Vergleich mit Alt-Output:** + - [ ] CSV Format identisch + - [ ] Description Layout Ă€hnlich + - [ ] Token Counts gleich + +--- + +## PHASE 5: DOKUMENTATION + +**Dauer:** ~2h +**Ziel:** Dokumentation aktualisieren, Code Comments + +### Step 5.1: README aktualisieren + +**Datei:** `opencode-plugin-time-tracking/README.md` + +**Änderungen:** +- ErwĂ€hne dass Plugin jetzt `lib-ts-time-tracking` nutzt +- Entferne ErklĂ€rungen zu TitleGenerator und DescriptionGenerator +- Aktualisiere Architecture Diagram (falls vorhanden) +- Mentioniere SessionDataMapper + +**Beispiel-Text:** +```markdown +## Architecture + +The plugin now uses [@techdivision/lib-ts-time-tracking](https://github.com/techdivision/lib-ts-time-tracking) +for core time tracking functionality: + +- **SessionSummaryGenerator:** Generates descriptions via LLM or activity fallback +- **TimeTrackingFacade:** Orchestrates summary generation, CSV writing, and webhook sending +- **OpenCodeSessionManager:** Manages session state across multiple OpenCode events + +The plugin provides OpenCode-specific adapters: +- **SessionDataMapper:** Converts OpenCode session data to lib's interface +- **TicketResolver:** SDK-based JIRA ticket extraction +``` + +### Step 5.2: Code Comments + +**In SessionDataMapper.ts:** +```typescript +/** + * Converts OpenCode plugin's SessionData to lib's SessionDataInterface. + * + * Key transformations: + * - agent.name → string + * - model provider/modelID formatting + * - Token mapping with proper field names + * - ConversationContextProvider callback building + * + * Gracefully degrades if client.session.messages() fails. + */ +``` + +**In EventHook.ts (bei Facade.track call):** +```typescript +// Use TimeTrackingFacade from lib for summary generation and writing +// This replaces separate TitleGenerator and DescriptionGenerator calls +const facade = await getTimeTrackingFacade(config.time_tracking) +const trackResult = await facade.track(sessionData) +``` + +**In SessionManager.ts:** +```typescript +/** + * Wrapper around OpenCodeSessionManager from lib. + * + * Provides OpenCode-specific facade to the generic library implementation. + * All state management is delegated to the library to ensure single source of truth. + */ +``` + +### Step 5.3: Git Commit + +```bash +git add . +git commit -m "refactor: OpenCode plugin nutzt lib-ts-time-tracking + +- Entfernen: TitleGenerator, DescriptionGenerator, CsvWriter, WebhookSender, ProviderAdapter +- HinzufĂŒgen: SessionDataMapper fĂŒr Konvertierung zu Lib-Interface +- HinzufĂŒgen: OpenCodeSessionManager in Lib (generisch, wiederverwendbar) +- Refactor: EventHook nutzt TimeTrackingFacade mit Lazy Loading (Marketplace-Pattern) +- Refactor: SessionManager wird zu Wrapper um Lib's OpenCodeSessionManager +- Result: ~870 Zeilen Duplikation eliminiert, Single Source of Truth + +Implementiert: +- Workspace Dependency: @techdivision/lib-ts-time-tracking +- Inline ConversationContextProvider mit SDK Integration +- Graceful Degradation fĂŒr Fehlerbehandlung +- Activity-Summary Fallback wenn LLM fehlschlĂ€gt + +Fixes #" +``` + +--- + +## CHECKLISTE + +### PRE-IMPLEMENTATION +- [ ] Plan verstanden und genehmigt +- [ ] Git Status clean oder committed +- [ ] Branch fĂŒr Refactoring erstellt? (z.B. `feature/lib-integration`) +- [ ] Backup von kritischen Dateien? (optional) + +### PHASE 1: VORBEREITUNG (4h) +- [ ] Step 1.1: Workspace Dependencies in package.json +- [ ] Step 1.1: `npm install` / `bun install` erfolgreich +- [ ] Step 1.2: OpenCodeSessionManager in Lib erstellt +- [ ] Step 1.3: SessionDataMapper im Plugin erstellt +- [ ] Step 1.4: Exports in lib's index.ts hinzugefĂŒgt +- [ ] Step 1.5: Kompilation erfolgreich (`npm run build`) + +### PHASE 2: REFACTORING (10h) +- [ ] Step 2.1: Plugin.ts - Facade Initialization + - [ ] Imports bereinigt + - [ ] getFacade() Funktion hinzugefĂŒgt + - [ ] createEventHook() aufgerufen mit getFacade +- [ ] Step 2.2: EventHook.ts - Core Refactoring + - [ ] Function Signature angepasst + - [ ] Imports bereinigt + - [ ] Session Status Handler umgeschrieben + - [ ] SessionDataMapper.build() Aufruf eingebaut + - [ ] Facade.track() Aufruf eingebaut + - [ ] CsvEntryData von trackResult.entry gebaut + - [ ] Toast Feedback angepasst +- [ ] Step 2.3: SessionManager.ts - Wrapper Umwandlung + - [ ] Private manager = new OpenCodeSessionManager() + - [ ] Alle Methods delegieren +- [ ] Step 2.4: Kompilation erfolgreich + +### PHASE 3: CLEANUP (2h) +- [ ] TitleGenerator.ts gelöscht +- [ ] DescriptionGenerator.ts gelöscht +- [ ] CsvWriter.ts gelöscht +- [ ] WebhookSender.ts gelöscht +- [ ] ProviderAdapter.ts gelöscht +- [ ] MessageExtractor.ts gelöscht +- [ ] Kompilation erfolgreich nach Löschen + +### PHASE 4: TESTING (6h) +- [ ] SessionDataMapper Unit Tests erstellt +- [ ] SessionManager Unit Tests ĂŒberarbeitet +- [ ] EventHook Integration Tests erstellt +- [ ] E2E Test durchgefĂŒhrt (echte Session) + - [ ] CSV geschrieben + - [ ] Webhook aufgerufen (falls konfiguriert) + - [ ] Description korrekt + - [ ] Toast Feedback korrekt + - [ ] Keine Errors im Console + +### PHASE 5: DOKUMENTATION (2h) +- [ ] README.md aktualisiert +- [ ] Code Comments hinzugefĂŒgt +- [ ] Git Commit erstellt + +### POST-IMPLEMENTATION +- [ ] Alle Tests grĂŒn (`npm run test`) +- [ ] TypeScript Compilation erfolgreich +- [ ] Dokumentation vollstĂ€ndig +- [ ] Pull Request erstellt (falls relevant) + +--- + +## SUMMARY BY THE NUMBERS + +| Metrik | Wert | +|--------|------| +| **Neue Dateien** | 2 (OpenCodeSessionManager, SessionDataMapper) | +| **GeĂ€nderte Dateien** | 4 (Plugin.ts, EventHook.ts, SessionManager.ts, index.ts) | +| **Gelöschte Dateien** | 6 (~870 Zeilen) | +| **Zeilen eingefĂŒgt** | ~200 | +| **Zeilen gelöscht** | ~1.050 | +| **Netto-Reduktion** | -850 Zeilen | +| **Neue Tests** | ~15 | +| **Estimated Time** | 22h (3 Tage) | +| **Code Quality** | ↑ (Single Source of Truth, weniger Duplikation) | + +--- + +## KNOWN ISSUES & FALLBACKS + +### Wenn ConversationContext fehlschlĂ€gt: +``` +✅ SessionSummaryGenerator nutzt Activity-Summary Fallback +✅ CSV wird trotzdem geschrieben +✅ Toast zeigt erfolgreiche Speicherung +``` + +### Wenn LLM API down: +``` +✅ SessionSummaryGenerator nutzt Activity-Summary Fallback +✅ Kein Error geworfen +✅ Graceful Degradation +``` + +### Wenn CSV/Webhook schreibt: +``` +✅ Beide werden aufgerufen (parallel von Lib) +✅ Fehler sind isoliert (einer fehlgeschlagen = andere lĂ€uft weiter) +✅ Toast zeigt welcher Writer fehlgeschlagen +``` + +--- + +## HELPFUL RESOURCES + +- [lib-ts-time-tracking](../lib-ts-time-tracking) - Source Library +- [TimeTrackingFacade Docs](../lib-ts-time-tracking/src/services/TimeTrackingFacade.ts) +- [SessionDataInterface](../lib-ts-time-tracking/src/types/SessionDataInterface.ts) +- [Marketplace Plugin](../plugin-marketplace/time-tracking) - Reference Implementation (Claude Code) + +--- + +## NEXT STEPS + +1. ✅ Plan verstanden +2. ⏳ Phase 1 starten: Workspace Dependencies + neue Dateien +3. ⏳ Phase 2 starten: Refactoring der bestehenden Dateien +4. ⏳ Phase 3 starten: Cleanup +5. ⏳ Phase 4 starten: Testing +6. ⏳ Phase 5 starten: Dokumentation + +**Ready to implement? → Go!** 🚀 From c497d919d2bc1f1ba4d8de40d5e33a63bc3a6b6e Mon Sep 17 00:00:00 2001 From: Patrick Date: Thu, 23 Apr 2026 09:39:48 +0200 Subject: [PATCH 02/29] refactor: OpenCode plugin nutzt lib-ts-time-tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entfernen: TitleGenerator, DescriptionGenerator, CsvWriter, WebhookSender, ProviderAdapter, MessageExtractor - HinzufĂŒgen: SessionDataMapper fĂŒr Konvertierung zu Lib-Interface - HinzufĂŒgen: OpenCodeSessionManager in Lib (generisch, wiederverwendbar) - Refactor: EventHook nutzt TimeTrackingFacade mit Lazy Loading (Marketplace-Pattern) - Refactor: SessionManager wird zu Wrapper um Lib's OpenCodeSessionManager - Result: ~870 Zeilen Duplikation eliminiert, Single Source of Truth Implementiert: - Workspace Dependency: @techdivision/lib-ts-time-tracking - Inline ConversationContextProvider mit SDK Integration - Graceful Degradation fĂŒr Fehlerbehandlung - Activity-Summary Fallback wenn LLM fehlschlĂ€gt - Unit Tests fĂŒr SessionDataMapper und SessionManager - Integration Tests fĂŒr EventHook mit TimeTrackingFacade Fixes COPSPA-489 --- EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md | 328 +++++++++++++++++ README.md | 15 + package-lock.json | 30 +- package.json | 3 +- src/Plugin.ts | 57 +-- src/adapters/SessionDataMapper.ts | 93 +++++ src/hooks/EventHook.ts | 185 ++++------ src/services/CsvWriter.ts | 284 --------------- src/services/ProviderAdapter.ts | 83 ----- src/services/SessionManager.ts | 101 ++---- src/services/TitleGenerator.ts | 332 ------------------ src/services/WebhookSender.ts | 137 -------- src/utils/DescriptionGenerator.ts | 119 ------- src/utils/MessageExtractor.ts | 105 ------ .../hooks/EventHook.time-tracking.test.ts | 330 +++++++++++++++++ tests/unit/adapters/SessionDataMapper.test.ts | 164 +++++++++ tests/unit/services/SessionManager.test.ts | 228 ++++++++++++ tools/track-time.ts | 45 +-- 18 files changed, 1339 insertions(+), 1300 deletions(-) create mode 100644 EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md create mode 100644 src/adapters/SessionDataMapper.ts delete mode 100644 src/services/CsvWriter.ts delete mode 100644 src/services/ProviderAdapter.ts delete mode 100644 src/services/TitleGenerator.ts delete mode 100644 src/services/WebhookSender.ts delete mode 100644 src/utils/DescriptionGenerator.ts delete mode 100644 src/utils/MessageExtractor.ts create mode 100644 tests/integration/hooks/EventHook.time-tracking.test.ts create mode 100644 tests/unit/adapters/SessionDataMapper.test.ts create mode 100644 tests/unit/services/SessionManager.test.ts diff --git a/EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md b/EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md new file mode 100644 index 0000000..6952772 --- /dev/null +++ b/EPIC_OPENCODE_PLUGIN_LIB_INTEGRATION.md @@ -0,0 +1,328 @@ +# Epic: OpenCode Plugin nutzt lib-ts-time-tracking + +| Attribut | Wert | +|----------|------| +| **ID** | EPIC-OPENCODE-01 | +| **Feature** | Time-Tracking Integration | +| **Name** | OpenCode Plugin nutzt lib-ts-time-tracking | +| **Priority** | MUST | +| **Size** | M (3 Tage) | +| **Target Duration** | 3 Tage | +| **Owner** | Patrick Mehringer | +| **Account** | 1100 \| KI Arbeitsweise | +| **Status** | Draft | +| **JIRA** | COPSPA-XXX | + +--- + +## Beschreibung + +**WER:** Als Entwickler +**WAS:** möchte ich, dass das OpenCode Plugin die bereits existierende `lib-ts-time-tracking` Library vollstĂ€ndig nutzt +**WARUM:** um Duplikationen zu eliminieren, eine Single Source of Truth zu etablieren und die Wartbarkeit zu verbessern. + +Das OpenCode Plugin implementiert derzeit identische Services und Logik, die bereits in der `lib-ts-time-tracking` Library vorhanden sind. Dies fĂŒhrt zu ~870 Zeilen Duplikation und erschwert die Wartung und Weiterentwicklung. + +--- + +## Ist-Zustand (2026-04-21) + +### Redundante Implementierungen +- `TitleGenerator` (332 Zeilen) - identisch mit Lib's `SessionSummaryGenerator` +- `DescriptionGenerator` (119 Zeilen) - identisch mit Lib's Activity-Summary Logik +- `CsvWriter` (284 Zeilen) - 100% Duplikat der Lib +- `WebhookSender` (137 Zeilen) - 100% Duplikat der Lib +- `ProviderAdapter` - 100% Duplikat der Lib +- `MessageExtractor` - nur fĂŒr LLM Context Handling + +### Manuelle Service-Orchestrierung +- EventHook orchestriert Services manuell (~100 Zeilen Boilerplate) +- Keine Facade-Integration +- Keine Nutzung von Lib's `TimeTrackingFacade` + +### Total Duplikation +- **~870 Zeilen identischer Code** +- Mehrfach zu wartende Services +- Keine zentrale Quelle fĂŒr Bugfixes und Verbesserungen + +--- + +## Ziel-Zustand + +✅ OpenCode Plugin nutzt `TimeTrackingFacade` aus Lib +✅ `SessionSummaryGenerator` als Single Source of Truth +✅ `SessionManager` wird zu Wrapper um Lib's `OpenCodeSessionManager` +✅ `SessionDataMapper` konvertiert OpenCode SessionData → SessionDataInterface +✅ EventHook nutzt Lazy-Loading der Facade (Marketplace-Pattern) +✅ ~850 Zeilen Code-Reduktion +✅ Bessere Wartbarkeit und Testbarkeit +✅ Konsistenz zwischen Marketplace Plugin und OpenCode Plugin + +--- + +## Akzeptanzkriterien + +### AC1: Dependencies & Library Export +- [ ] `@techdivision/lib-ts-time-tracking` als workspace dependency in `package.json` +- [ ] Lib exportiert `OpenCodeSessionManager` in `src/index.ts` +- [ ] `TimeTrackingFacade` in Lib exportiert +- [ ] Kompilation erfolgreich in beiden Packages + +### AC2: Library Services - OpenCodeSessionManager +- [ ] Neue Datei: `lib-ts-time-tracking/src/services/OpenCodeSessionManager.ts` +- [ ] Generische Session State Management (keine SDK-Dependencies) +- [ ] CRUD Operations: get, has, create, delete, getAndDelete, addActivity, addTokenUsage, addCost +- [ ] ~200 Zeilen Code + +### AC3: Plugin Adapter - SessionDataMapper +- [ ] Neue Datei: `opencode-plugin-time-tracking/src/adapters/SessionDataMapper.ts` +- [ ] Konvertiert SessionData → SessionDataInterface +- [ ] Buildet ConversationContextProvider inline mit SDK Integration +- [ ] Graceful Degradation bei SDK Call Fehlern +- [ ] ~50 Zeilen Code + +### AC4: Plugin.ts Refactoring +- [ ] Lazy-Loading Funktion fĂŒr TimeTrackingFacade +- [ ] Keine TitleGenerator / ProviderAdapter Initialisierung mehr +- [ ] `createEventHook` mit getFacade Parameter aufgerufen +- [ ] Kompilation erfolgreich + +### AC5: EventHook.ts Core Refactoring +- [ ] Session Status Handler refactored auf Facade +- [ ] SessionDataMapper.build() statt manueller Mapping +- [ ] TimeTrackingFacade.track() statt manueller Service-Orchestrierung +- [ ] trackResult.entry nutzen statt CsvEntryData manuell zusammensetzen +- [ ] Toast Feedback an neues Format angepasst +- [ ] ~80 Zeilen Code-Reduktion + +### AC6: SessionManager.ts Wrapper-Umwandlung +- [ ] Private manager = new OpenCodeSessionManager() +- [ ] Alle Methods delegieren zu manager +- [ ] Keine neue Logik, nur Delegation +- [ ] Import von Lib kompatibel + +### AC7: Redundante Dateien Gelöscht +- [ ] `src/services/TitleGenerator.ts` gelöscht (332 Zeilen) +- [ ] `src/utils/DescriptionGenerator.ts` gelöscht (119 Zeilen) +- [ ] `src/services/CsvWriter.ts` gelöscht (284 Zeilen) +- [ ] `src/services/WebhookSender.ts` gelöscht (137 Zeilen) +- [ ] `src/services/ProviderAdapter.ts` gelöscht +- [ ] `src/utils/MessageExtractor.ts` gelöscht +- [ ] Kompilation erfolgreich nach Löschen + +### AC8: Unit Tests +- [ ] `tests/unit/adapters/SessionDataMapper.test.ts` erstellt +- [ ] `tests/unit/services/SessionManager.test.ts` ĂŒberarbeitet +- [ ] Alle Tests grĂŒn +- [ ] Coverage fĂŒr kritische Pfade + +### AC9: Integration Tests +- [ ] `tests/integration/hooks/EventHook.time-tracking.test.ts` erstellt +- [ ] Event → Facade → Results Pfad getestet +- [ ] Alle Tests grĂŒn + +### AC10: E2E Test +- [ ] Echte OpenCode Session durchgefĂŒhrt +- [ ] CSV geschrieben und Format korrekt +- [ ] Webhook aufgerufen (falls konfiguriert) +- [ ] Description korrekt (Activity Summary oder LLM) +- [ ] Toast Feedback korrekt +- [ ] Keine Errors im Console + +### AC11: Dokumentation & Code Quality +- [ ] README.md aktualisiert (Lib Integration erwĂ€hnt) +- [ ] Code Comments hinzugefĂŒgt (Mapper, Hook, Manager) +- [ ] Git Commit mit aussagekrĂ€ftiger Message +- [ ] ~850 Zeilen Code-Reduktion erreicht + +--- + +## Scope + +### In Scope +✅ Lib-Integration in OpenCode Plugin +✅ OpenCodeSessionManager als generischer Service +✅ SessionDataMapper fĂŒr OpenCode-spezifische Konvertierung +✅ Lazy-Loading TimeTrackingFacade +✅ Graceful Degradation Error Handling +✅ ~870 Zeilen Duplikation eliminieren +✅ Marketplace Plugin als Referenz nutzen + +### Out of Scope +❌ Marketplace Plugin Ă€ndern (bereits korrekt implementiert) +❌ Lib selbst inhaltlich Ă€ndern (nur OpenCodeSessionManager hinzufĂŒgen) +❌ Komplette Event-Architektur umschreiben +❌ Code-Refactorings außerhalb von Time-Tracking + +--- + +## Deliverables + +### Phase 1: Vorbereitung (4h) +- Lib exportiert OpenCodeSessionManager +- SessionDataMapper erstellt +- Dependencies aktualisiert +- Kompilation erfolgreich + +### Phase 2: Refactoring (10h) +- Plugin.ts mit Lazy-Loading +- EventHook.ts komplett refactored +- SessionManager zu Wrapper +- Alle Importe angepasst + +### Phase 3: Cleanup (2h) +- 6 redundante Dateien gelöscht +- Kompilation ohne Fehler + +### Phase 4: Testing (6h) +- Unit Tests grĂŒn +- Integration Tests grĂŒn +- E2E Test erfolgreich +- CSV Output vergleich erfolgreich + +### Phase 5: Dokumentation (2h) +- README updated +- Code Comments hinzugefĂŒgt +- Git Commit erstellt + +--- + +## Design Pattern & Architektur + +### 1. Wrapper Pattern fĂŒr SessionManager +``` +OpenCode Events + ↓ (message.updated, message.part.updated, session.status.idle) +SessionManager (Wrapper im Plugin) + ↓ delegates to +OpenCodeSessionManager (Generisch in Lib) + ↓ +State Management +``` + +**Grund:** Zukunftssicherheit - OpenCode-spezifische Features können ohne Lib-Änderung addiert werden. + +### 2. Inline ConversationContextProvider +``` +EventHook - session.status.idle handler + ↓ +SessionDataMapper.build() + └─ Buildet async ConversationContextProvider inline + ├─ client.session.messages({limit: 10}) + └─ Falls Error: return null (Graceful Degradation) + ↓ +TimeTrackingFacade.track(sessionData) + └─ SessionSummaryGenerator.generateSummary() + ├─ LLM Description (mit Context) + └─ Fallback: Activity-Summary +``` + +**Grund:** KISS - 5-10 Zeilen Code, direkt sichtbar im Handler. + +### 3. Lazy Loading Facade (Marketplace-Pattern) +```typescript +let facadePromise: Promise | null = null + +async function getFacade(config): Promise { + if (!facadePromise) { + facadePromise = Promise.resolve(new TimeTrackingFacade(config)) + } + return facadePromise +} +``` + +**Grund:** Single Initialization, reusable in allen Event-Handlers. + +### 4. Error Handling: Graceful Degradation +- ConversationContext fails → Activity-Summary Fallback ✅ +- LLM API down → Activity-Summary Fallback ✅ +- CSV/Webhook Error → isoliert, andere lĂ€uft weiter ✅ +- CSV wird IMMER geschrieben ✅ + +--- + +## Metriken & Erfolg-Indikatoren + +| Metrik | Aktuell | Ziel | Status | +|--------|---------|------|--------| +| **Code Duplikation** | ~870 Zeilen | 0 | ⏳ | +| **Neue Dateien** | 0 | 2 | ⏳ | +| **Gelöschte Dateien** | 0 | 6 | ⏳ | +| **Zeilen gelöscht** | 0 | ~1.050 | ⏳ | +| **Zeilen hinzugefĂŒgt** | 0 | ~200 | ⏳ | +| **Netto Reduktion** | 0 | -850 | ⏳ | +| **Unit Tests** | ~0 | ~10 | ⏳ | +| **Integration Tests** | ~0 | ~5 | ⏳ | +| **Code Quality** | Duplikation | Single Source of Truth | ⏳ | +| **KomplexitĂ€t** | Hoch (Manuelle Orchestrierung) | Niedrig (Facade) | ⏳ | + +--- + +## Risiken & Fallbacks + +### Risiko 1: TimeTrackingFacade.track() throws Error +- **Fallback:** Error caught, graceful degradation +- **Mitigation:** Marketplace Plugin zeigt dass es funktioniert +- **Action:** Siehe Error Handling oben + +### Risiko 2: ConversationContext Call fails +- **Fallback:** SessionSummaryGenerator nutzt Activity-Summary +- **Mitigation:** Inline Error Handling in SessionDataMapper +- **Action:** null zurĂŒckgeben, Lib hat Fallback + +### Risiko 3: Bun FileSystem KompatibilitĂ€t +- **Fallback:** CsvWriter aus Lib sollte in Bun laufen +- **Mitigation:** Marketplace Plugin zeigt es funktioniert +- **Action:** E2E Test verifiziert CSV-Schreiber + +### Risiko 4: Type InkompatibilitĂ€t +- **Fallback:** SessionDataInterface/SessionData Mapping +- **Mitigation:** SessionDataMapper genau auf Types prĂŒfen +- **Action:** TypeScript strict mode obligatorisch + +--- + +## Referenzen + +- 📘 [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) - Schritt-fĂŒr-Schritt Anleitung +- 📚 [lib-ts-time-tracking](../lib-ts-time-tracking) - Source Library +- đŸȘ [Marketplace Plugin](../plugin-marketplace/time-tracking) - Reference Implementation +- 📄 [README](./README.md) - Plugin Dokumentation +- 🔗 [COPSPA-55](https://techdivision.atlassian.net/browse/COPSPA-55) - Ähnliches Refactoring Ticket + +--- + +## Änderungshistorie + +| Datum | Version | Änderung | Autor | +|-------|---------|----------|-------| +| 2026-04-21 | 1.0 | Initial Draft | Patrick Mehringer | + +--- + +## Zeitplan + +| Phase | Aufgabe | Dauer | Kumulativ | Status | +|-------|---------|-------|----------|--------| +| 1 | Vorbereitung | 4h | 4h | ⏳ | +| 2 | Refactoring | 10h | 14h | ⏳ | +| 3 | Cleanup | 2h | 16h | ⏳ | +| 4 | Testing | 6h | 22h | ⏳ | +| 5 | Dokumentation | 2h | 24h | ⏳ | +| Buffer | Unexpected Issues | 2-4h | **~24-26h (3 Tage)** | ⏳ | + +**Start:** +**Target Completion:** + +--- + +## Notizen + +- Siehe `IMPLEMENTATION_PLAN.md` fĂŒr schritt-fĂŒr-schritt Anleitung +- Marketplace Plugin (`plugin-marketplace/time-tracking`) als Referenz nutzen +- Lib-Codes in `lib-ts-time-tracking/src/` als Best Practice Quelle +- Graceful Degradation ĂŒberall wo externe APIs involviert sind +- Alle Error Catches mĂŒssen silent sein (kein throw in EventHooks!) +- SessionManager Pattern bleibt: Plugin-spezifischer Wrapper ĂŒber generischer Lib-Klasse +- Single Source of Truth fĂŒr Time-Tracking Logic = Lib's SessionSummaryGenerator + diff --git a/README.md b/README.md index 6b9e7db..0073c7b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,21 @@ Automatic time tracking plugin for OpenCode. Tracks session duration and tool usage, writing entries to a CSV file compatible with Jira worklog sync incl. commands, skills and agents. +## Architecture + +This plugin now uses [@techdivision/lib-ts-time-tracking](https://github.com/techdivision/lib-ts-time-tracking) for core time tracking functionality: + +- **SessionSummaryGenerator:** Generates descriptions via LLM or activity fallback +- **TimeTrackingFacade:** Orchestrates summary generation, CSV writing, and webhook sending +- **OpenCodeSessionManager:** Manages session state across multiple OpenCode events + +The plugin provides OpenCode-specific adapters: +- **SessionDataMapper:** Converts OpenCode session data to lib's interface +- **TicketResolver:** SDK-based JIRA ticket extraction +- **SessionManager:** Wrapper around lib's OpenCodeSessionManager for OpenCode-specific features + +This architecture eliminates ~870 lines of code duplication and ensures a single source of truth for time tracking logic. + ## Overview | Content Type | Name | Description | diff --git a/package-lock.json b/package-lock.json index f88dad8..9ecf6c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "@techdivision/opencode-plugin-time-tracking", - "version": "0.1.0", + "version": "1.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@techdivision/opencode-plugin-time-tracking", - "version": "0.1.0", + "version": "1.5.2", + "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.15" + "@opencode-ai/plugin": "1.2.15", + "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" }, "devDependencies": { "@types/bun": "latest", @@ -19,6 +21,24 @@ "bun": ">=1.0.0" } }, + "../lib-ts-time-tracking": { + "name": "@techdivision/lib-ts-time-tracking", + "version": "4.2.0", + "license": "MIT", + "devDependencies": { + "@amiceli/vitest-cucumber": "^5.2.1", + "@types/node": "^22.0.0", + "eslint": "^10.1.0", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.2", + "vitest": "^3.2.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@opencode-ai/plugin": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.15.tgz", @@ -203,6 +223,10 @@ ], "peer": true }, + "node_modules/@techdivision/lib-ts-time-tracking": { + "resolved": "../lib-ts-time-tracking", + "link": true + }, "node_modules/@types/bun": { "version": "1.3.9", "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", diff --git a/package.json b/package.json index 351765e..51796ce 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "author": "TechDivision GmbH", "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.15" + "@opencode-ai/plugin": "1.2.15", + "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" }, "devDependencies": { "@types/bun": "latest", diff --git a/src/Plugin.ts b/src/Plugin.ts index e6e401c..557b620 100644 --- a/src/Plugin.ts +++ b/src/Plugin.ts @@ -8,18 +8,35 @@ */ import type { Plugin, Hooks, PluginInput } from "@opencode-ai/plugin" +import { TimeTrackingFacade } from "@techdivision/lib-ts-time-tracking" import { ConfigLoader } from "./services/ConfigLoader" -import { CsvWriter } from "./services/CsvWriter" import { SessionManager } from "./services/SessionManager" import { TicketExtractor } from "./services/TicketExtractor" import { TicketResolver } from "./services/TicketResolver" -import { TitleGenerator } from "./services/TitleGenerator" -import { WebhookSender } from "./services/WebhookSender" import { createEventHook } from "./hooks/EventHook" import { createToolExecuteAfterHook } from "./hooks/ToolExecuteAfterHook" -import type { WriterService } from "./types/WriterService" +import type { TimeTrackingConfigInterface } from "@techdivision/lib-ts-time-tracking" + +/** + * Lazy-loads TimeTrackingFacade instance. + * Follows Marketplace plugin pattern for single initialization. + * + * @remarks + * The facade is initialized once and reused across all event handlers. + * This ensures consistent state management and efficient resource usage. + */ +let facadePromise: Promise | null = null + +async function getTimeTrackingFacade( + config: TimeTrackingConfigInterface +): Promise { + if (!facadePromise) { + facadePromise = Promise.resolve(new TimeTrackingFacade(config)) + } + return facadePromise +} /** * OpenCode Time Tracking Plugin @@ -64,37 +81,25 @@ export const plugin: Plugin = async ({ } const sessionManager = new SessionManager() - const csvWriter = new CsvWriter(config, directory) - const webhookSender = new WebhookSender() const ticketExtractor = new TicketExtractor(client, config.valid_projects) const ticketResolver = new TicketResolver(config, ticketExtractor) - const configDir = `${directory}/.opencode` - const titleGenerator = new TitleGenerator(client, config, configDir) - - // Check API reachability in background (never blocks plugin startup) - titleGenerator.checkAvailability().then(() => { - if (!titleGenerator.isAvailable) { - client.tui.showToast({ - body: { - message: `Title generation: ${titleGenerator.unavailableInfo}`, - variant: "warning", - }, - }).catch(() => {}) - } - }).catch(() => {}) - - // Writers are called in order: CSV first (backup), then webhook - const writers: WriterService[] = [csvWriter, webhookSender] - // Ensure CSV file has a valid header at startup - await csvWriter.ensureHeader() + // Writers are now handled by TimeTrackingFacade from lib + // No need to instantiate CsvWriter and WebhookSender here const hooks: Hooks = { "tool.execute.after": createToolExecuteAfterHook( sessionManager, ticketExtractor ), - event: createEventHook(sessionManager, writers, client, ticketResolver, config, titleGenerator), + event: createEventHook( + sessionManager, + [], + client, + ticketResolver, + config, + (timeTrackingConfig) => getTimeTrackingFacade(timeTrackingConfig) + ), } return hooks diff --git a/src/adapters/SessionDataMapper.ts b/src/adapters/SessionDataMapper.ts new file mode 100644 index 0000000..5942ea8 --- /dev/null +++ b/src/adapters/SessionDataMapper.ts @@ -0,0 +1,93 @@ +/** + * @fileoverview Adapter to convert OpenCode SessionData to lib's SessionDataInterface. + * + * This mapper bridges the gap between OpenCode plugin's internal SessionData format + * and the generic SessionDataInterface expected by lib-ts-time-tracking. + * It also builds the ConversationContextProvider callback inline with SDK integration. + */ + +import type { OpencodeClient } from "../types/OpencodeClient.js" +import type { SessionData } from "../types/SessionData.js" +import type { SessionDataInterface } from "@techdivision/lib-ts-time-tracking" + +/** + * Converts OpenCode plugin's SessionData to lib's SessionDataInterface. + * + * @remarks + * This mapper handles: + * - Model formatting (provider/modelID) + * - Token mapping with proper field names + * - ConversationContextProvider callback building + * - Graceful degradation if SDK calls fail + */ +export class SessionDataMapper { + /** + * Builds SessionDataInterface from SessionData. + * Includes conversation context provider callback for LLM context. + * + * @param session - The OpenCode session data + * @param client - The OpenCode SDK client for fetching conversation context + * @param sessionID - The session ID for fetching messages + * @param config - Configuration including user email + * @returns SessionDataInterface ready for TimeTrackingFacade.track() + * + * @remarks + * The conversationContextProvider is built inline and will gracefully + * degrade if the SDK call fails. The lib's SessionSummaryGenerator + * will use activity-based fallback in that case. + */ + static build( + session: SessionData, + client: OpencodeClient, + sessionID: string, + config: { userEmail?: string } + ): SessionDataInterface { + // Format model as "provider/modelID" + const modelString = session.model + ? `${session.model.providerID}/${session.model.modelID}` + : "unknown" + + // Build conversation context provider inline + const conversationContextProvider = async (): Promise => { + try { + const result = await client.session.messages({ + path: { id: sessionID }, + } as Parameters[0]) + + if (!result?.data || result.data.length === 0) { + return null + } + + // Format messages as context string + return result.data + .map((m: any) => { + const role = m.info?.role || "unknown" + const content = m.content || "" + return `${role}: ${content}` + }) + .join("\n") + } catch { + // Graceful degradation: if SDK call fails, return null + // Lib will use activity-based fallback + return null + } + } + + return { + agent: session.agent?.name ?? "unknown", + model: modelString, + startTime: session.startTime, + endTime: Date.now(), + userEmail: config.userEmail, + tokens: { + input: session.tokenUsage.input, + output: session.tokenUsage.output, + cacheRead: session.tokenUsage.cacheRead, + cacheWrite: session.tokenUsage.cacheWrite, + }, + activities: session.activities, + conversationContext: conversationContextProvider, + ticket: session.ticket ?? undefined, + } + } +} diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index ed64d06..ba18da8 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -2,13 +2,11 @@ * @fileoverview Event hook for session lifecycle and token tracking. */ -import { randomUUID } from "crypto" - import type { AssistantMessage, Event, Message } from "@opencode-ai/sdk" +import type { TimeTrackingFacade, TimeTrackingConfigInterface } from "@techdivision/lib-ts-time-tracking" import type { SessionManager } from "../services/SessionManager" import type { TicketResolver } from "../services/TicketResolver" -import type { TitleGenerator } from "../services/TitleGenerator" import type { CsvEntryData } from "../types/CsvEntryData" import type { MessagePartUpdatedProperties } from "../types/MessagePartUpdatedProperties" import type { MessageWithParts } from "../types/MessageWithParts" @@ -17,7 +15,7 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig" import type { WriteResult, WriterService } from "../types/WriterService" import { AgentMatcher } from "../utils/AgentMatcher" -import { DescriptionGenerator } from "../utils/DescriptionGenerator" +import { SessionDataMapper } from "../adapters/SessionDataMapper" /** * Properties for message.updated events. @@ -26,45 +24,6 @@ interface MessageUpdatedProperties { info: Message } -/** - * Extracts the summary title from the last user message. - * - * @param client - The OpenCode SDK client - * @param sessionID - The session identifier - * @returns The summary title, or `null` if not found - * - * @internal - */ -async function extractSummaryTitle( - client: OpencodeClient, - sessionID: string -): Promise { - try { - const result = await client.session.messages({ - path: { id: sessionID }, - } as Parameters[0]) - - if (!result.data) { - return null - } - - const messages = result.data as MessageWithParts[] - - // Find the last user message with a summary title - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - - if (message.info.role === "user" && message.info.summary?.title) { - return message.info.summary.title - } - } - - return null - } catch { - return null - } -} - /** * Creates the event hook for session lifecycle management. * @@ -73,7 +32,7 @@ async function extractSummaryTitle( * @param client - The OpenCode SDK client * @param ticketResolver - The ticket resolver instance * @param config - The time tracking configuration - * @param titleGenerator - The LLM-based title generator instance + * @param getTimeTrackingFacade - Function to get the TimeTrackingFacade instance (lazy-loaded) * @returns The event hook function * * @remarks @@ -81,16 +40,16 @@ async function extractSummaryTitle( * * 1. **message.updated** - Tracks model from assistant messages * 2. **message.part.updated** - Tracks token usage from step-finish parts - * 3. **session.status** (idle) - Finalizes and exports the session via all writers + * 3. **session.status** (idle) - Finalizes and exports the session via TimeTrackingFacade * - * Writers are called in order. Each writer handles its own errors internally, - * so a failure in one writer does not affect others. + * The TimeTrackingFacade handles summary generation, CSV writing, and webhook sending. + * This replaces the previous manual orchestration of TitleGenerator and DescriptionGenerator. * * @example * ```typescript * const writers: WriterService[] = [csvWriter, webhookSender] * const hooks: Hooks = { - * event: createEventHook(sessionManager, writers, client, ticketResolver, config, titleGenerator), + * event: createEventHook(sessionManager, writers, client, ticketResolver, config, getFacade), * } * ``` */ @@ -100,7 +59,7 @@ export function createEventHook( client: OpencodeClient, ticketResolver: TicketResolver, config: TimeTrackingConfig, - titleGenerator: TitleGenerator + getTimeTrackingFacade: (cfg: TimeTrackingConfigInterface) => Promise ) { return async ({ event }: { event: Event }): Promise => { // Track model and agent from assistant messages @@ -194,45 +153,6 @@ export function createEventHook( return } - const endTime = Date.now() - const durationSeconds = Math.round((endTime - session.startTime) / 1000) - - // Generate description: LLM title + activity summary - const activitySummary = DescriptionGenerator.generate(session.activities) - - // Try to get a meaningful title via LLM or OpenCode summary - let title = await extractSummaryTitle(client, sessionID) - - if (!title) { - try { - title = await Promise.race([ - titleGenerator.generate(sessionID), - new Promise((resolve) => setTimeout(() => resolve(null), 8000)), - ]) - } catch { - title = null - } - } - - // Combine: "LLM title | activity summary" or just "activity summary" - const description = title - ? `${title} | ${activitySummary}` - : activitySummary - - const toolSummary = DescriptionGenerator.generateToolSummary( - session.activities - ) - - const totalTokens = - session.tokenUsage.input + - session.tokenUsage.output + - session.tokenUsage.reasoning - - // Format model as providerID/modelID - const modelString = session.model - ? `${session.model.providerID}/${session.model.modelID}` - : null - // Get agent name if available const agentString = session.agent?.name ?? null @@ -257,40 +177,85 @@ export function createEventHook( // Resolve ticket and account key with fallback hierarchy const resolved = await ticketResolver.resolve(sessionID, agentString) - // Build entry data once, shared across all writers - const entryData: CsvEntryData = { - id: randomUUID(), + // Use TimeTrackingFacade from lib for summary generation and writing + // This replaces separate TitleGenerator and DescriptionGenerator calls + const sessionData = SessionDataMapper.build(session, client, sessionID, { userEmail: config.user_email, - ticket: resolved.ticket, + }) + + // Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface + const libConfig: TimeTrackingConfigInterface = { + defaults: config.global_default, + agents: config.agent_defaults, + csv: { output_path: config.csv_file }, + pricing: { + default: { + input: 0.003, + output: 0.015, + cache_read: 0.00075, + cache_write: 0.00375, + }, + periods: [ + { + from: "2024-01-01", + models: { + "claude-opus": { + input: 0.015, + output: 0.075, + cache_read: 0.00375, + cache_write: 0.01875, + }, + "claude-sonnet": { + input: 0.003, + output: 0.015, + cache_read: 0.00075, + cache_write: 0.00375, + }, + "claude-haiku": { + input: 0.00080, + output: 0.004, + cache_read: 0.0002, + cache_write: 0.001, + }, + }, + }, + ], + }, + valid_projects: config.valid_projects || [], + } + + const facade = await getTimeTrackingFacade(libConfig) + const trackResult = await facade.track(sessionData) + const description = trackResult.summary.description + + // Build entry data from trackResult.entry (CSV entry comes directly from Lib!) + const entryData: CsvEntryData = { + ...trackResult.entry, + ticket: resolved.ticket, // OpenCode Resolving override accountKey: resolved.accountKey, - authorEmail: resolved.authorEmail, - startTime: session.startTime, - endTime, - durationSeconds, - description, - notes: `Auto-tracked: ${toolSummary}`, - tokenUsage: session.tokenUsage, - cost: session.cost, - model: modelString, + authorEmail: resolved.authorEmail, // OpenCode Resolving override agent: (resolved.primaryAgent ?? agentString)?.replace(/^@/, "") ?? null, } - // Call all writers in order (CSV first, then webhook, etc.) - // Collect results for combined status reporting - const results: WriteResult[] = [] - for (const writer of writers) { - const result = await writer.write(entryData) - results.push(result) - } + // Writers are called by Facade, but we have access to results + const results: WriteResult[] = [ + trackResult.csv, + trackResult.webhook, + ].filter((r) => r !== undefined && r !== null) as WriteResult[] // Build combined toast message with writer status + const durationSeconds = Math.round((Date.now() - session.startTime) / 1000) const minutes = Math.round(durationSeconds / 60) + const totalTokens = + session.tokenUsage.input + + session.tokenUsage.output + + session.tokenUsage.reasoning const failedWriters = results.filter((r) => !r.success) let message = `Time tracked: ${minutes} min, ${totalTokens} tokens${resolved.ticket ? ` for ${resolved.ticket}` : ""}` - if (!titleGenerator.isAvailable) { - message += " (title generation NOT available)" + if (trackResult.summary.llmError) { + message += ` (LLM: ${trackResult.summary.llmError})` } if (failedWriters.length > 0) { diff --git a/src/services/CsvWriter.ts b/src/services/CsvWriter.ts deleted file mode 100644 index 5defc53..0000000 --- a/src/services/CsvWriter.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * @fileoverview CSV writer for exporting time tracking data. - */ - -import { mkdir } from "fs/promises" -import { dirname } from "path" - -import type { CsvEntryData } from "../types/CsvEntryData" -import type { TimeTrackingConfig } from "../types/TimeTrackingConfig" -import type { WriteResult, WriterService } from "../types/WriterService" - -import { CsvFormatter } from "../utils/CsvFormatter" - -import "../types/Bun" - -/** - * CSV header row for the worklog export file. - * Compatible with Jira/Tempo time tracking import. - * - * @remarks - * Columns 1-17: Original format (v0.5.0 - v0.7.x) - * Columns 18-23: Extended format (v0.8.0+) with token details and cost - */ -const CSV_HEADER = - "id,start_date,end_date,user,ticket_name,issue_key,account_key,start_time,end_time,duration_seconds,tokens_used,tokens_remaining,story_points,description,notes,model,agent,tokens_input,tokens_output,tokens_reasoning,tokens_cache_read,tokens_cache_write,cost,author_email" - -/** Number of columns in the current CSV format */ -const CSV_COLUMN_COUNT = 24 - -/** - * Checks if a line is a CSV header row. - * - * @param line - The line to check - * @returns `true` if the line appears to be a header - */ -function isHeaderLine(line: string): boolean { - return line.startsWith('"id"') || line.startsWith("id,") -} - -/** - * Pads a CSV line with empty fields to match the expected column count. - * - * @param line - The CSV line to pad - * @param currentColumns - The current number of columns in the line - * @param targetColumns - The target number of columns - * @returns The padded line, or the original line if no padding needed - */ -function padCsvLine( - line: string, - currentColumns: number, - targetColumns: number -): string { - if (currentColumns >= targetColumns) { - return line - } - - const missingColumns = targetColumns - currentColumns - const padding = ',""\n'.repeat(missingColumns).slice(0, -1) // Remove trailing newline, keep commas - const emptyFields = Array(missingColumns).fill('""').join(",") - - // Append empty fields to the line - return line + "," + emptyFields -} - -/** - * Writes time tracking entries to a CSV file. - * - * @remarks - * The CSV format is compatible with Jira/Tempo worklog imports. - * The file path can be absolute, relative to the project, or use `~/` for home directory. - */ -export class CsvWriter implements WriterService { - /** Plugin configuration */ - private config: TimeTrackingConfig - - /** Project directory path */ - private directory: string - - /** - * Creates a new CSV writer instance. - * - * @param config - The plugin configuration - * @param directory - The project directory path - */ - constructor(config: TimeTrackingConfig, directory: string) { - this.config = config - this.directory = directory - } - - /** - * Resolves the CSV file path from configuration. - * - * @returns The absolute path to the CSV file - * - * @remarks - * Handles three path formats: - * - `~/path` - Expands to home directory - * - `/absolute/path` - Used as-is - * - `relative/path` - Relative to project directory - */ - private resolvePath(): string { - let csvPath = this.config.csv_file - - if (csvPath.startsWith("~/")) { - csvPath = csvPath.replace("~", process.env.HOME || "") - } else if (!csvPath.startsWith("/")) { - csvPath = `${this.directory}/${csvPath}` - } - - return csvPath - } - - /** - * Ensures the CSV file exists and has a valid, up-to-date header. - * - * @remarks - * Call this once at plugin startup. Handles these cases: - * - File doesn't exist: creates it with header - * - File is empty: writes header - * - File has data but no header: prepends header and pads rows if needed - * - File has outdated header (fewer columns): replaces header and pads all rows - * - File already has current header: no action needed - * - * @returns `true` if file was modified, `false` if already valid - */ - async ensureHeader(): Promise { - const csvPath = this.resolvePath() - - try { - await mkdir(dirname(csvPath), { recursive: true }) - } catch { - // Directory may already exist - } - - const file = Bun.file(csvPath) - const exists = await file.exists() - - if (!exists) { - // Create new file with header - await Bun.write(csvPath, CSV_HEADER + "\n") - return true - } - - const content = await file.text() - const trimmedContent = content.trim() - - if (trimmedContent.length === 0) { - // Empty file: write header - await Bun.write(csvPath, CSV_HEADER + "\n") - return true - } - - const lines = trimmedContent.split("\n") - const firstLine = lines[0] - const hasHeader = isHeaderLine(firstLine) - - // Determine column count from first data line - const dataLineIndex = hasHeader ? 1 : 0 - if (dataLineIndex >= lines.length) { - // Only header, no data - ensure header is current - if (hasHeader && CsvFormatter.countColumns(firstLine) < CSV_COLUMN_COUNT) { - await Bun.write(csvPath, CSV_HEADER + "\n") - return true - } - return false - } - - const firstDataLine = lines[dataLineIndex] - const columnCount = CsvFormatter.countColumns(firstDataLine) - - // Check if migration is needed - if (columnCount >= CSV_COLUMN_COUNT) { - // Already has enough columns - if (!hasHeader) { - // Just prepend header - await Bun.write(csvPath, CSV_HEADER + "\n" + trimmedContent + "\n") - return true - } - return false - } - - // Migration needed: pad all data rows to CSV_COLUMN_COUNT - const dataLines = hasHeader ? lines.slice(1) : lines - const paddedLines = dataLines.map((line) => { - const lineColumnCount = CsvFormatter.countColumns(line) - return padCsvLine(line, lineColumnCount, CSV_COLUMN_COUNT) - }) - - // Write new header + padded data - await Bun.write(csvPath, CSV_HEADER + "\n" + paddedLines.join("\n") + "\n") - return true - } - - /** - * Writes a time tracking entry to the CSV file. - * - * @param data - The entry data to write - * @returns Result indicating success or failure - * - * @remarks - * Assumes `ensureHeader()` was called at startup. - * Simply appends the new entry to the file. - * - * @example - * ```typescript - * const result = await csvWriter.write({ - * id: crypto.randomUUID(), - * userEmail: "user@example.com", - * ticket: "PROJ-123", - * startTime: Date.now() - 3600000, - * endTime: Date.now(), - * durationSeconds: 3600, - * description: "Implemented feature X", - * notes: "Auto-tracked: read(5x), edit(3x)", - * tokenUsage: { input: 1000, output: 500, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, - * cost: 0.0234, - * model: "anthropic/claude-opus-4", - * agent: "@developer", - * accountKey: "ACCOUNT-1" - * }) - * - * if (!result.success) { - * console.error(`CSV write failed: ${result.error}`) - * } - * ``` - */ - async write(data: CsvEntryData): Promise { - try { - const csvPath = this.resolvePath() - const file = Bun.file(csvPath) - const exists = await file.exists() - - const totalTokens = - data.tokenUsage.input + data.tokenUsage.output + data.tokenUsage.reasoning - - const fields = [ - data.id, - CsvFormatter.formatDate(data.startTime), - CsvFormatter.formatDate(data.endTime), - data.userEmail, - "", - data.ticket ?? "", - data.accountKey, - CsvFormatter.formatTime(data.startTime), - CsvFormatter.formatTime(data.endTime), - data.durationSeconds.toString(), - totalTokens.toString(), - "", - "", - CsvFormatter.escape(data.description), - CsvFormatter.escape(data.notes), - data.model ?? "", - data.agent ?? "", - // Extended columns (v0.8.0+) - data.tokenUsage.input.toString(), - data.tokenUsage.output.toString(), - data.tokenUsage.reasoning.toString(), - data.tokenUsage.cacheRead.toString(), - data.tokenUsage.cacheWrite.toString(), - data.cost.toFixed(6), - // Extended columns (v1.5.0+) - data.authorEmail, - ] - - const csvLine = fields.map((f) => `"${f}"`).join(",") - - if (!exists) { - // Fallback: create file with header if ensureHeader() wasn't called - await Bun.write(csvPath, CSV_HEADER + "\n" + csvLine + "\n") - } else { - const content = await file.text() - await Bun.write(csvPath, content + csvLine + "\n") - } - - return { writer: "csv", success: true } - } catch (error) { - return { - writer: "csv", - success: false, - error: error instanceof Error ? error.message : String(error), - } - } - } -} diff --git a/src/services/ProviderAdapter.ts b/src/services/ProviderAdapter.ts deleted file mode 100644 index b7d1182..0000000 --- a/src/services/ProviderAdapter.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @fileoverview Chat Completions API adapter for LLM title generation. - * - * @remarks - * Builds HTTP requests and parses responses for the Chat Completions API format - * used by Ollama and other LLM providers. - */ - -/** - * Structured request data for an LLM API call. - */ -interface LlmRequest { - /** Full endpoint URL */ - url: string - - /** HTTP headers including auth and content type */ - headers: Record - - /** JSON-serialized request body */ - body: string -} - -/** - * Builds and parses Chat Completions API requests for title generation. - * - * @remarks - * Pure static methods — no state, no side effects. - */ -export class ProviderAdapter { - /** - * Builds a Chat Completions API request. - * - * @param apiUrl - Base API URL (from `title_generation.api_url` config) - * @param apiModelId - The model identifier sent to the provider - * @param apiKey - API key (can be `undefined` for providers that require no auth) - * @param systemPrompt - The system/title generation prompt - * @param userPrompt - The user's original message text - * @returns Structured request with url, headers, and body - */ - static buildRequest( - apiUrl: string, - apiModelId: string, - apiKey: string | undefined, - systemPrompt: string, - userPrompt: string - ): LlmRequest { - const headers: Record = { - "content-type": "application/json", - } - - if (apiKey) { - headers["authorization"] = `Bearer ${apiKey}` - } - - return { - url: `${apiUrl}/chat/completions`, - headers, - body: JSON.stringify({ - model: apiModelId, - max_tokens: 100, - temperature: 0.3, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - }), - } - } - - /** - * Extracts the generated text from a Chat Completions response. - * - * @param responseJson - The parsed JSON response body - * @returns The extracted text content, or empty string if not found - */ - static extractText(responseJson: unknown): string { - const json = responseJson as Record - const choices = json.choices as - | Array<{ message?: { content?: string } }> - | undefined - return choices?.[0]?.message?.content ?? "" - } -} diff --git a/src/services/SessionManager.ts b/src/services/SessionManager.ts index d4d21fb..2427455 100644 --- a/src/services/SessionManager.ts +++ b/src/services/SessionManager.ts @@ -1,7 +1,12 @@ /** * @fileoverview Session state management for time tracking. + * + * This is a wrapper around the generic OpenCodeSessionManager from lib-ts-time-tracking. + * It provides OpenCode-specific facade to the generic library implementation. */ +import { OpenCodeSessionManager } from "@techdivision/lib-ts-time-tracking" + import type { ActivityData } from "../types/ActivityData" import type { AgentInfo } from "../types/AgentInfo" import type { ModelInfo } from "../types/ModelInfo" @@ -9,7 +14,10 @@ import type { SessionData } from "../types/SessionData" import type { TokenUsage } from "../types/TokenUsage" /** - * Manages active session state for time tracking. + * Wrapper around OpenCodeSessionManager from lib. + * + * Provides OpenCode-specific facade to the generic library implementation. + * All state management is delegated to the library to ensure single source of truth. * * @remarks * Each OpenCode session is tracked separately with its own: @@ -21,8 +29,8 @@ import type { TokenUsage } from "../types/TokenUsage" * Sessions are stored in memory and cleaned up when completed. */ export class SessionManager { - /** Map of session ID to session data */ - private sessions = new Map() + /** Delegate to lib's generic session manager */ + private manager = new OpenCodeSessionManager() /** * Retrieves session data by ID. @@ -31,7 +39,7 @@ export class SessionManager { * @returns The session data, or `undefined` if not found */ get(sessionID: string): SessionData | undefined { - return this.sessions.get(sessionID) + return this.manager.get(sessionID) as SessionData | undefined } /** @@ -41,7 +49,7 @@ export class SessionManager { * @returns `true` if the session exists, `false` otherwise */ has(sessionID: string): boolean { - return this.sessions.has(sessionID) + return this.manager.has(sessionID) } /** @@ -52,25 +60,7 @@ export class SessionManager { * @returns The newly created session data */ create(sessionID: string, ticket: string | null): SessionData { - const session: SessionData = { - ticket, - startTime: Date.now(), - activities: [], - tokenUsage: { - input: 0, - output: 0, - reasoning: 0, - cacheRead: 0, - cacheWrite: 0, - }, - cost: 0, - model: null, - agent: null, - } - - this.sessions.set(sessionID, session) - - return session + return this.manager.create(sessionID, ticket) as SessionData } /** @@ -79,7 +69,7 @@ export class SessionManager { * @param sessionID - The OpenCode session identifier */ delete(sessionID: string): void { - this.sessions.delete(sessionID) + this.manager.delete(sessionID) } /** @@ -94,13 +84,7 @@ export class SessionManager { * after retrieval to ensure it can only be processed once. */ getAndDelete(sessionID: string): SessionData | undefined { - const session = this.sessions.get(sessionID) - - if (session) { - this.sessions.delete(sessionID) - } - - return session + return this.manager.getAndDelete(sessionID) as SessionData | undefined } /** @@ -110,11 +94,7 @@ export class SessionManager { * @param activity - The activity data to add */ addActivity(sessionID: string, activity: ActivityData): void { - const session = this.sessions.get(sessionID) - - if (session) { - session.activities.push(activity) - } + this.manager.addActivity(sessionID, activity) } /** @@ -124,15 +104,13 @@ export class SessionManager { * @param tokens - The token usage to add */ addTokenUsage(sessionID: string, tokens: TokenUsage): void { - const session = this.sessions.get(sessionID) - - if (session) { - session.tokenUsage.input += tokens.input - session.tokenUsage.output += tokens.output - session.tokenUsage.reasoning += tokens.reasoning - session.tokenUsage.cacheRead += tokens.cacheRead - session.tokenUsage.cacheWrite += tokens.cacheWrite - } + this.manager.addTokenUsage(sessionID, { + input: tokens.input, + output: tokens.output, + reasoning: tokens.reasoning, + cacheRead: tokens.cacheRead, + cacheWrite: tokens.cacheWrite, + }) } /** @@ -142,11 +120,7 @@ export class SessionManager { * @param cost - The cost in USD to add */ addCost(sessionID: string, cost: number): void { - const session = this.sessions.get(sessionID) - - if (session) { - session.cost += cost - } + this.manager.addCost(sessionID, cost) } /** @@ -160,11 +134,7 @@ export class SessionManager { * This allows the ticket to be updated when found in later messages. */ updateTicket(sessionID: string, ticket: string | null): void { - const session = this.sessions.get(sessionID) - - if (session && ticket) { - session.ticket = ticket - } + this.manager.updateTicket(sessionID, ticket) } /** @@ -178,11 +148,10 @@ export class SessionManager { * The first model detected in a session is used. */ setModel(sessionID: string, model: ModelInfo): void { - const session = this.sessions.get(sessionID) - - if (session && !session.model) { - session.model = model - } + this.manager.setModel(sessionID, { + providerID: model.providerID, + modelID: model.modelID, + }) } /** @@ -196,14 +165,6 @@ export class SessionManager { * The first agent detected in a session is used (primary agent). */ setAgent(sessionID: string, agentName: string): void { - const session = this.sessions.get(sessionID) - - if (session && !session.agent) { - const agent: AgentInfo = { - name: agentName, - timestamp: Date.now(), - } - session.agent = agent - } + this.manager.setAgent(sessionID, agentName) } } diff --git a/src/services/TitleGenerator.ts b/src/services/TitleGenerator.ts deleted file mode 100644 index 6060b81..0000000 --- a/src/services/TitleGenerator.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * @fileoverview LLM-based title generation for time tracking worklog descriptions. - * - * @remarks - * Orchestrates the title generation pipeline: - * 1. Resolve provider from config at startup (synchronous, no SDK calls) - * 2. Health-check the API URL at startup (single fetch with 3s timeout) - * 3. On each session: extract conversation context, build request, call LLM - * 4. Parse response and trim to max length - * - * All errors are caught and result in `null` — never throws. - */ - -import type { MessageWithParts } from "../types/MessageWithParts" -import type { OpencodeClient } from "../types/OpencodeClient" -import type { TimeTrackingConfig } from "../types/TimeTrackingConfig" - -import { MessageExtractor } from "../utils/MessageExtractor" -import { ProviderAdapter } from "./ProviderAdapter" - -import "../types/Bun" - -/** Default timeout for LLM requests in milliseconds. */ -const DEFAULT_TIMEOUT_MS = 5000 - -/** Default maximum character length for generated titles. */ -const DEFAULT_MAX_CHARS = 240 - -/** Timeout for the startup health-check in milliseconds. */ -const HEALTH_CHECK_TIMEOUT_MS = 3000 - -/** - * Default system prompt for title generation. - */ -/** Default locale for worklog description output. */ -const DEFAULT_LOCALE = "de-DE" - -const DEFAULT_PROMPT_TEMPLATE = `You receive a conversation between a developer and an AI coding assistant. Write a short worklog description of what was worked on. Output ONLY the description. Nothing else. - -Rules: -- ALWAYS write in {{LOCALE}} language -- Write a natural, human-readable description as you would in a timesheet -- Describe what was actually done, not just filenames or technical keywords -- Maximum {{MAX_CHARS}} characters, single line -- No quotes, no prefixes like "Description:", no formatting -- If a ticket number appears, start with it -- Good example: "COPSPA-65: Fix Plugin-Startup-Hang durch Entfernen der SDK-Calls und Umstellung auf Config-basierte Provider-Aufloesung" -- Bad example: "package.json update" -- Bad example: "Session interaction"` - -/** Pattern for `{env:VAR_NAME}` references in config values. */ -const ENV_PATTERN = /^\{env:([^}]+)\}$/ - -/** - * Resolved provider information for making LLM requests. - */ -interface ResolvedProvider { - apiUrl: string - apiModelId: string - apiKey: string | undefined -} - -/** - * Generates worklog titles via direct LLM API calls. - * - * @remarks - * Provider is resolved synchronously from config at startup. - * The health-check verifies the API is reachable. If not, all subsequent - * `generate()` calls return `null` immediately without network access. - * - * No visible footprint in OpenCode — pure `fetch()` calls to the provider API. - * No SDK calls to the OpenCode server — everything is config-based. - */ -export class TitleGenerator { - private readonly client: OpencodeClient - private readonly config: TimeTrackingConfig - private readonly configDir: string - private readonly cachedProvider: ResolvedProvider | null - private available = true - private unavailableReason = "" - - constructor( - client: OpencodeClient, - config: TimeTrackingConfig, - configDir: string - ) { - this.client = client - this.config = config - this.configDir = configDir - - // Resolve provider synchronously from config (no network, no SDK calls). - // This ensures cachedProvider is available immediately, even before - // the async health-check completes. - if (this.config.title_generation?.enabled === false) { - this.cachedProvider = null - this.available = false - this.unavailableReason = "disabled by config" - } else { - this.cachedProvider = this.resolveFromConfig() - if (!this.cachedProvider) { - this.available = false - this.unavailableReason = "api_url and model not configured" - } - } - } - - /** - * Whether title generation is available. - * - * @remarks - * Set during construction and updated by {@link checkAvailability}. - * When `false`, {@link generate} returns `null` immediately. - */ - get isAvailable(): boolean { - return this.available - } - - /** - * Human-readable reason why title generation is unavailable. - * - * @remarks - * Empty string when available. Used for toast messages. - * - * @example `"server not reachable (http://ai.tdservice.net:11434/v1)"` - */ - get unavailableInfo(): string { - return this.unavailableReason - } - - /** - * Checks API reachability via a lightweight health-check. - * - * @remarks - * Provider is already resolved synchronously in the constructor. - * This method only performs the async health-check `fetch()` with a 3s timeout. - * Called as fire-and-forget from Plugin.ts — never blocks startup. - */ - async checkAvailability(): Promise { - if (!this.cachedProvider) { - return - } - - try { - const baseUrl = this.cachedProvider.apiUrl - const controller = new AbortController() - const timeout = setTimeout( - () => controller.abort(), - HEALTH_CHECK_TIMEOUT_MS - ) - - try { - await fetch(baseUrl, { signal: controller.signal }) - // Any response (even 404) means the server is reachable - } catch { - this.available = false - this.unavailableReason = `server not reachable (${baseUrl})` - } finally { - clearTimeout(timeout) - } - } catch { - this.available = false - this.unavailableReason = "startup check failed" - } - } - - /** - * Generates a worklog description for the given session. - * - * @returns The generated description, or `null` if unavailable/failed. - * Never throws. - */ - async generate(sessionID: string): Promise { - try { - if (!this.cachedProvider) { - return null - } - - const messages = await this.fetchMessages(sessionID) - if (!messages) return null - - const context = MessageExtractor.extractConversationContext(messages) - if (!context) return null - - const timeoutMs = - this.config.title_generation?.timeout_ms ?? DEFAULT_TIMEOUT_MS - const maxChars = - this.config.title_generation?.max_chars ?? DEFAULT_MAX_CHARS - - const systemPrompt = await this.loadPrompt(maxChars) - - const request = ProviderAdapter.buildRequest( - this.cachedProvider.apiUrl, - this.cachedProvider.apiModelId, - this.cachedProvider.apiKey, - systemPrompt, - context - ) - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), timeoutMs) - - try { - const response = await fetch(request.url, { - method: "POST", - headers: request.headers, - body: request.body, - signal: controller.signal, - }) - - if (!response.ok) return null - - const responseJson: unknown = await response.json() - const text = ProviderAdapter.extractText(responseJson) - - return this.cleanTitle(text, maxChars) - } finally { - clearTimeout(timeout) - } - } catch { - return null - } - } - - private async fetchMessages( - sessionID: string - ): Promise { - try { - const result = await this.client.session.messages({ - path: { id: sessionID }, - } as Parameters[0]) - - return (result.data as MessageWithParts[]) ?? null - } catch { - return null - } - } - - private async loadPrompt(maxChars: number): Promise { - let template: string - - const promptPath = this.config.title_generation?.prompt - if (promptPath) { - try { - const resolvedPath = promptPath.startsWith("/") - ? promptPath - : `${this.configDir}/${promptPath}` - - const file = Bun.file(resolvedPath) - template = (await file.exists()) - ? await file.text() - : DEFAULT_PROMPT_TEMPLATE - } catch { - template = DEFAULT_PROMPT_TEMPLATE - } - } else { - template = DEFAULT_PROMPT_TEMPLATE - } - - const locale = this.config.title_generation?.locale ?? DEFAULT_LOCALE - - return template - .replace(/\{\{MAX_CHARS\}\}/g, String(maxChars)) - .replace(/\{\{LOCALE\}\}/g, locale) - } - - /** - * Resolves provider, model, API URL, and API key from config. - * - * @remarks - * Purely synchronous — reads only from `this.config`. No network, no SDK calls. - * - * Both `config.title_generation.model` and `config.title_generation.api_url` - * are required. Returns `null` if either is missing. - */ - private resolveFromConfig(): ResolvedProvider | null { - const titleConfig = this.config.title_generation - - const apiUrl = titleConfig?.api_url - if (!apiUrl) return null - - const model = titleConfig?.model - if (!model) return null - - // Support both "provider/model" and plain "model" formats. - // For plain format (e.g., "llama3:8b"), the entire string is the model ID. - const slashIndex = model.indexOf("/") - const modelID = slashIndex === -1 ? model : model.slice(slashIndex + 1) - const apiKey = this.resolveApiKey() - - return { apiUrl, apiModelId: modelID, apiKey } - } - - /** - * Resolves API key from config or environment variable. - * - * @remarks - * Supports `{env:VAR_NAME}` syntax for environment variable references. - * Returns `undefined` if not configured (OK for Ollama). - */ - private resolveApiKey(): string | undefined { - const configKey = this.config.title_generation?.api_key - if (!configKey) return undefined - - const envMatch = ENV_PATTERN.exec(configKey) - if (envMatch) return process.env[envMatch[1]] ?? undefined - - return configKey - } - - private cleanTitle(text: string, maxChars: number): string | null { - let cleaned = text - // Remove common LLM prefixes that ignore instructions - .replace(/^(Ticket:\s*N\/A\s*)?Description:\s*/i, "") - .replace(/^(Title|Summary|Worklog|Description):\s*/i, "") - // Remove wrapping quotes (e.g., "text" or 'text') - .replace(/^["']+/, "") - .replace(/["']+$/, "") - // Remove markdown formatting - .replace(/^[*#`]+|[*#`]+$/g, "") - .replace(/\*\*/g, "") - .replace(/\n/g, " ") - .trim() - - if (cleaned.length === 0) return null - - if (cleaned.length > maxChars) { - cleaned = cleaned.slice(0, maxChars - 3) + "..." - } - - return cleaned - } -} diff --git a/src/services/WebhookSender.ts b/src/services/WebhookSender.ts deleted file mode 100644 index e48640d..0000000 --- a/src/services/WebhookSender.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * @fileoverview Webhook sender for time tracking entries. - */ - -import type { CsvEntryData } from "../types/CsvEntryData" -import type { WriteResult, WriterService } from "../types/WriterService" - -import { CsvFormatter } from "../utils/CsvFormatter" - -/** - * Sends time tracking entries to a webhook endpoint. - * - * @remarks - * Implements the WriterService interface to allow seamless integration - * with other writers (e.g., CsvWriter). Errors are handled internally - * and returned as part of the WriteResult. - * - * Configuration via environment variables: - * - `TT_WEBHOOK_URL` - The webhook endpoint URL (required for webhook to be active) - * - `TT_WEBHOOK_BEARER_TOKEN` - Optional Bearer token for authentication - * - * If `TT_WEBHOOK_URL` is not set, the webhook is silently skipped (returns success). - * - * @example - * ```typescript - * // .env - * TT_WEBHOOK_URL=https://n8n.example.com/webhook/time-tracking - * TT_WEBHOOK_BEARER_TOKEN=your-secret-token - * ``` - * - * @example - * ```typescript - * const webhookSender = new WebhookSender() - * const result = await webhookSender.write(entryData) - * - * if (!result.success) { - * console.error(`Webhook failed: ${result.error}`) - * } - * ``` - */ -export class WebhookSender implements WriterService { - /** - * Checks if the webhook is configured and enabled. - * - * @returns `true` if TT_WEBHOOK_URL is set - */ - isEnabled(): boolean { - return !!process.env.TT_WEBHOOK_URL - } - - /** - * Sends entry data to the configured webhook. - * - * @param data - The entry data to send - * @returns Result indicating success or failure - * - * @remarks - * The payload contains all required fields with correct types (integer/number/string) - * and optional fields only when they have values. - * If `TT_WEBHOOK_URL` is not set, returns success (skip is not an error). - * If `TT_WEBHOOK_BEARER_TOKEN` is set, it's included as Bearer token. - */ - async write(data: CsvEntryData): Promise { - const webhookUrl = process.env.TT_WEBHOOK_URL - - if (!webhookUrl) { - // Webhook not configured, skip silently (not an error) - return { writer: "webhook", success: true } - } - - const bearerToken = process.env.TT_WEBHOOK_BEARER_TOKEN - - const totalTokens = - data.tokenUsage.input + data.tokenUsage.output + data.tokenUsage.reasoning - - // Required fields — always present with correct types - const payload: Record = { - id: data.id, - start_date: CsvFormatter.formatDate(data.startTime), - end_date: CsvFormatter.formatDate(data.endTime), - user: data.userEmail, - author_email: data.authorEmail, - issue_key: data.ticket ?? "", - account_key: data.accountKey, - start_time: CsvFormatter.formatTime(data.startTime), - end_time: CsvFormatter.formatTime(data.endTime), - duration_seconds: Math.round(data.durationSeconds) || 0, - tokens_used: Math.round(totalTokens) || 0, - description: data.description || "n/a", - model: data.model ?? "unknown", - agent: data.agent ?? "unknown", - tokens_input: Math.round(data.tokenUsage.input) || 0, - tokens_output: Math.round(data.tokenUsage.output) || 0, - tokens_reasoning: Math.round(data.tokenUsage.reasoning) || 0, - tokens_cache_read: Math.round(data.tokenUsage.cacheRead) || 0, - tokens_cache_write: Math.round(data.tokenUsage.cacheWrite) || 0, - cost: data.cost ?? 0.0, - } - - // Optional fields — only include when values are present - if (data.notes) { - payload.notes = data.notes - } - - const headers: Record = { - "Content-Type": "application/json", - } - - if (bearerToken) { - headers["Authorization"] = `Bearer ${bearerToken}` - } - - try { - const response = await fetch(webhookUrl, { - method: "POST", - headers, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - return { - writer: "webhook", - success: false, - error: `HTTP ${response.status}`, - } - } - - return { writer: "webhook", success: true } - } catch (error) { - return { - writer: "webhook", - success: false, - error: error instanceof Error ? error.message : String(error), - } - } - } -} diff --git a/src/utils/DescriptionGenerator.ts b/src/utils/DescriptionGenerator.ts deleted file mode 100644 index d770be1..0000000 --- a/src/utils/DescriptionGenerator.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @fileoverview Generates human-readable descriptions from activity data. - */ - -import type { ActivityData } from "../types/ActivityData" - -/** - * Generates human-readable descriptions from tool activities. - * - * @remarks - * Creates descriptions summarizing what tools were used and which files - * were affected during a session. - */ -export class DescriptionGenerator { - /** - * Generates a human-readable description of activities. - * - * @param activities - Array of activity data from the session - * @returns A formatted description string - * - * @remarks - * Groups activities by type: - * - File edits (edit + write tools) - * - File reads - * - Commands (bash) - * - Searches (glob + grep) - * - * Also includes file names if 5 or fewer files were touched. - * - * @example - * ```typescript - * const desc = DescriptionGenerator.generate(activities) - * // Returns: "3 file edit(s), 2 file read(s) - Files: index.ts, utils.ts" - * ``` - */ - static generate(activities: ActivityData[]): string { - if (activities.length === 0) { - return "Chat session (no tool calls)" - } - - const toolCounts = activities.reduce( - (acc, a) => { - acc[a.tool] = (acc[a.tool] || 0) + 1 - return acc - }, - {} as Record - ) - - const filesWorkedOn = new Set() - - for (const activity of activities) { - if (activity.file) { - const fileName = activity.file.split("/").pop() || activity.file - filesWorkedOn.add(fileName) - } - } - - const mainActivities: string[] = [] - - if (toolCounts.edit || toolCounts.write) { - mainActivities.push( - `${(toolCounts.edit || 0) + (toolCounts.write || 0)} file edit(s)` - ) - } - - if (toolCounts.read) { - mainActivities.push(`${toolCounts.read} file read(s)`) - } - - if (toolCounts.bash) { - mainActivities.push(`${toolCounts.bash} command(s)`) - } - - if (toolCounts.glob || toolCounts.grep) { - mainActivities.push( - `${(toolCounts.glob || 0) + (toolCounts.grep || 0)} search(es)` - ) - } - - let description = - mainActivities.length > 0 - ? mainActivities.join(", ") - : `${activities.length} tool call(s)` - - if (filesWorkedOn.size > 0 && filesWorkedOn.size <= 5) { - description += ` - Files: ${Array.from(filesWorkedOn).join(", ")}` - } else if (filesWorkedOn.size > 5) { - description += ` - ${filesWorkedOn.size} files` - } - - return description - } - - /** - * Generates a compact tool usage summary. - * - * @param activities - Array of activity data from the session - * @returns A compact summary string showing tool counts - * - * @example - * ```typescript - * const summary = DescriptionGenerator.generateToolSummary(activities) - * // Returns: "read(5x), edit(3x), bash(2x)" - * ``` - */ - static generateToolSummary(activities: ActivityData[]): string { - const toolCounts = activities.reduce( - (acc, a) => { - acc[a.tool] = (acc[a.tool] || 0) + 1 - return acc - }, - {} as Record - ) - - return Object.entries(toolCounts) - .map(([t, c]) => `${t}(${c}x)`) - .join(", ") - } -} diff --git a/src/utils/MessageExtractor.ts b/src/utils/MessageExtractor.ts deleted file mode 100644 index f224707..0000000 --- a/src/utils/MessageExtractor.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @fileoverview Extracts conversation context from session messages. - */ - -import type { MessageWithParts } from "../types/MessageWithParts" - -/** - * Number of recent conversation turns to extract. - * - * @remarks - * A turn = one user message + the following assistant response. - */ -const RECENT_TURNS = 3 - -/** - * Maximum characters for a single assistant response in the context. - * - * @remarks - * Assistant responses can be very long. Truncating keeps the context - * balanced between user intent and assistant work description. - */ -const MAX_ASSISTANT_CHARS = 500 - -/** - * Extracts conversation context from session messages for title generation. - * - * @remarks - * Collects the last {@link RECENT_TURNS} conversation turns (user prompt + - * first assistant response) to provide meaningful context for worklog - * description generation. - */ -export class MessageExtractor { - /** - * Extracts the last conversation turns from session messages. - * - * @param messages - Array of messages with their parts - * @returns The extracted conversation context, or `null` if no content found - * - * @remarks - * Strategy: Scan from the end to find the last N user messages. - * For each user message, find the next assistant message that follows it. - * This gives clean user/assistant pairs regardless of how many - * intermediate messages exist. - */ - static extractConversationContext( - messages: MessageWithParts[] - ): string | null { - // Step 1: Find indices of all user messages - const userIndices: number[] = [] - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].info.role === "user") { - const text = MessageExtractor.extractText(messages[i]) - if (text) { - userIndices.push(i) - if (userIndices.length >= RECENT_TURNS) break - } - } - } - - if (userIndices.length === 0) return null - - // Step 2: For each user message, collect it + the first assistant response after it - const turns: string[] = [] - - // Process in chronological order (oldest first) - for (const idx of userIndices.reverse()) { - const userText = MessageExtractor.extractText(messages[idx]) - if (userText) { - turns.push(`User: ${userText}`) - } - - // Find first assistant response after this user message - for (let j = idx + 1; j < messages.length; j++) { - if (messages[j].info.role === "assistant") { - const assistantText = MessageExtractor.extractText(messages[j]) - if (assistantText) { - const truncated = assistantText.length > MAX_ASSISTANT_CHARS - ? assistantText.slice(0, MAX_ASSISTANT_CHARS) + "..." - : assistantText - turns.push(`Assistant: ${truncated}`) - break - } - } - // Stop if we hit the next user message (no assistant response for this turn) - if (messages[j].info.role === "user") break - } - } - - return turns.length > 0 ? turns.join("\n") : null - } - - /** - * Extracts non-synthetic text content from a message. - */ - private static extractText(message: MessageWithParts): string | null { - const textParts = message.parts - .filter((part) => part.type === "text" && !part.synthetic && part.text) - .map((part) => part.text as string) - - if (textParts.length === 0) return null - - const combined = textParts.join("\n").trim() - return combined.length > 0 ? combined : null - } -} diff --git a/tests/integration/hooks/EventHook.time-tracking.test.ts b/tests/integration/hooks/EventHook.time-tracking.test.ts new file mode 100644 index 0000000..aaa69b4 --- /dev/null +++ b/tests/integration/hooks/EventHook.time-tracking.test.ts @@ -0,0 +1,330 @@ +/** + * @fileoverview Integration tests for EventHook with TimeTrackingFacade + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { createEventHook } from "../../../src/hooks/EventHook" +import { SessionManager } from "../../../src/services/SessionManager" +import { TicketResolver } from "../../../src/services/TicketResolver" +import type { TimeTrackingConfig } from "../../../src/types/TimeTrackingConfig" +import type { OpencodeClient } from "../../../src/types/OpencodeClient" +import type { TimeTrackingFacade } from "@techdivision/lib-ts-time-tracking" + +describe("EventHook - Time Tracking Integration", () => { + let sessionManager: SessionManager + let mockClient: OpencodeClient + let mockTicketResolver: TicketResolver + let mockFacade: TimeTrackingFacade + let config: TimeTrackingConfig + + beforeEach(() => { + sessionManager = new SessionManager() + + mockClient = { + tui: { + showToast: vi.fn().mockResolvedValue(undefined), + }, + session: { + messages: vi.fn().mockResolvedValue({ data: [] }), + }, + } as unknown as OpencodeClient + + mockTicketResolver = { + resolve: vi.fn().mockResolvedValue({ + ticket: "PROJ-123", + accountKey: "ACCOUNT-1", + authorEmail: "author@example.com", + primaryAgent: "@developer", + }), + } as unknown as TicketResolver + + mockFacade = { + track: vi.fn().mockResolvedValue({ + summary: { + description: "Test description", + llmError: null, + }, + entry: { + id: "entry-1", + userEmail: "user@example.com", + startTime: 1000000, + endTime: 2000000, + durationSeconds: 1000, + description: "Test description", + notes: "Auto-tracked", + tokenUsage: { + input: 100, + output: 200, + reasoning: 50, + cacheRead: 10, + cacheWrite: 5, + }, + cost: 0.05, + model: "anthropic/claude-opus-4", + agent: "@developer", + }, + csv: { + success: true, + writer: "csv", + }, + webhook: { + success: true, + writer: "webhook", + }, + }), + } as unknown as TimeTrackingFacade + + config = { + csv_file: "~/time.csv", + global_default: { + issue_key: "DEFAULT-1", + account_key: "DEFAULT-ACCOUNT", + author_email: "default@example.com", + }, + user_email: "user@example.com", + time_tracking: { + defaults: { + issue_key: "DEFAULT-1", + account_key: "DEFAULT-ACCOUNT", + author_email: "default@example.com", + }, + pricing: { + default: { + input: 0.003, + output: 0.015, + cache_read: 0.00075, + cache_write: 0.00375, + }, + periods: [], + }, + valid_projects: [], + }, + } as unknown as TimeTrackingConfig + }) + + it("processes session.status.idle event", async () => { + const eventHook = createEventHook( + sessionManager, + [], + mockClient, + mockTicketResolver, + config, + async () => mockFacade + ) + + // Create a session + sessionManager.create("session-1", null) + sessionManager.addActivity("session-1", { + type: "tool_call", + toolName: "edit", + timestamp: Date.now(), + duration: 500, + }) + sessionManager.addTokenUsage("session-1", { + input: 100, + output: 200, + reasoning: 50, + cacheRead: 10, + cacheWrite: 5, + }) + + // Trigger idle event + await eventHook({ + event: { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "idle" }, + }, + } as any, + }) + + // Verify facade was called + expect(mockFacade.track).toHaveBeenCalled() + + // Verify toast was shown + expect(mockClient.tui.showToast).toHaveBeenCalled() + + // Verify session was deleted + expect(sessionManager.has("session-1")).toBe(false) + }) + + it("ignores non-idle status events", async () => { + const eventHook = createEventHook( + sessionManager, + [], + mockClient, + mockTicketResolver, + config, + async () => mockFacade + ) + + sessionManager.create("session-1", null) + + await eventHook({ + event: { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "busy" }, + }, + } as any, + }) + + // Facade should not be called + expect(mockFacade.track).not.toHaveBeenCalled() + + // Session should still exist + expect(sessionManager.has("session-1")).toBe(true) + }) + + it("ignores sessions without activity or tokens", async () => { + const eventHook = createEventHook( + sessionManager, + [], + mockClient, + mockTicketResolver, + config, + async () => mockFacade + ) + + sessionManager.create("session-1", null) + + await eventHook({ + event: { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "idle" }, + }, + } as any, + }) + + // Facade should not be called + expect(mockFacade.track).not.toHaveBeenCalled() + + // Toast should not be shown + expect(mockClient.tui.showToast).not.toHaveBeenCalled() + }) + + it("skips ignored agents", async () => { + const configWithIgnoredAgent = { + ...config, + ignored_agents: ["@internal"], + } + + const eventHook = createEventHook( + sessionManager, + [], + mockClient, + mockTicketResolver, + configWithIgnoredAgent, + async () => mockFacade + ) + + sessionManager.create("session-1", null) + sessionManager.setAgent("session-1", "@internal") + sessionManager.addActivity("session-1", { + type: "tool_call", + toolName: "edit", + timestamp: Date.now(), + duration: 500, + }) + + await eventHook({ + event: { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "idle" }, + }, + } as any, + }) + + // Facade should not be called + expect(mockFacade.track).not.toHaveBeenCalled() + + // Toast should show skip message + expect(mockClient.tui.showToast).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + message: expect.stringContaining("skipped"), + }), + }) + ) + }) + + it("tracks model from assistant messages", async () => { + const eventHook = createEventHook( + sessionManager, + [], + mockClient, + mockTicketResolver, + config, + async () => mockFacade + ) + + await eventHook({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID: "session-1", + modelID: "claude-opus-4", + providerID: "anthropic", + mode: "@developer", + }, + }, + } as any, + }) + + const session = sessionManager.get("session-1") + expect(session?.model?.modelID).toBe("claude-opus-4") + expect(session?.model?.providerID).toBe("anthropic") + expect(session?.agent?.name).toBe("@developer") + }) + + it("tracks token usage from message parts", async () => { + const eventHook = createEventHook( + sessionManager, + [], + mockClient, + mockTicketResolver, + config, + async () => mockFacade + ) + + sessionManager.create("session-1", null) + + await eventHook({ + event: { + type: "message.part.updated", + properties: { + part: { + type: "step-finish", + sessionID: "session-1", + tokens: { + input: 100, + output: 200, + reasoning: 50, + cache: { + read: 10, + write: 5, + }, + }, + cost: 0.05, + }, + }, + } as any, + }) + + const session = sessionManager.get("session-1") + expect(session?.tokenUsage.input).toBe(100) + expect(session?.tokenUsage.output).toBe(200) + expect(session?.tokenUsage.reasoning).toBe(50) + expect(session?.tokenUsage.cacheRead).toBe(10) + expect(session?.tokenUsage.cacheWrite).toBe(5) + expect(session?.cost).toBe(0.05) + }) +}) diff --git a/tests/unit/adapters/SessionDataMapper.test.ts b/tests/unit/adapters/SessionDataMapper.test.ts new file mode 100644 index 0000000..2912a5d --- /dev/null +++ b/tests/unit/adapters/SessionDataMapper.test.ts @@ -0,0 +1,164 @@ +/** + * @fileoverview Unit tests for SessionDataMapper + */ + +import { describe, it, expect, vi } from "vitest" +import { SessionDataMapper } from "../../../src/adapters/SessionDataMapper" +import type { SessionData } from "../../../src/types/SessionData" +import type { OpencodeClient } from "../../../src/types/OpencodeClient" + +describe("SessionDataMapper", () => { + const mockClient = { + session: { + messages: vi.fn(), + }, + } as unknown as OpencodeClient + + const mockSession: SessionData = { + ticket: "PROJ-123", + startTime: 1000000, + activities: [ + { + type: "tool_call", + toolName: "edit", + timestamp: 1000100, + duration: 500, + }, + ], + tokenUsage: { + input: 100, + output: 200, + reasoning: 50, + cacheRead: 10, + cacheWrite: 5, + }, + cost: 0.05, + model: { + providerID: "anthropic", + modelID: "claude-opus-4", + }, + agent: { + name: "@developer", + timestamp: 1000000, + }, + } + + it("maps SessionData to SessionDataInterface correctly", () => { + const result = SessionDataMapper.build(mockSession, mockClient, "session-123", { + userEmail: "user@example.com", + }) + + expect(result.agent).toBe("@developer") + expect(result.model).toBe("anthropic/claude-opus-4") + expect(result.startTime).toBe(1000000) + expect(result.userEmail).toBe("user@example.com") + expect(result.ticket).toBe("PROJ-123") + expect(result.tokens.input).toBe(100) + expect(result.tokens.output).toBe(200) + expect(result.tokens.cacheRead).toBe(10) + expect(result.tokens.cacheWrite).toBe(5) + }) + + it("formats model as provider/modelID", () => { + const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {}) + + expect(result.model).toBe("anthropic/claude-opus-4") + }) + + it("handles null model gracefully", () => { + const sessionWithoutModel: SessionData = { + ...mockSession, + model: null, + } + + const result = SessionDataMapper.build(sessionWithoutModel, mockClient, "session-123", {}) + + expect(result.model).toBe("unknown") + }) + + it("handles null agent gracefully", () => { + const sessionWithoutAgent: SessionData = { + ...mockSession, + agent: null, + } + + const result = SessionDataMapper.build(sessionWithoutAgent, mockClient, "session-123", {}) + + expect(result.agent).toBe("unknown") + }) + + it("builds conversationContextProvider callback", async () => { + const mockMessages = [ + { + info: { role: "user" }, + content: "Hello", + }, + { + info: { role: "assistant" }, + content: "Hi there", + }, + ] + + vi.mocked(mockClient.session.messages).mockResolvedValueOnce({ + data: mockMessages, + } as any) + + const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {}) + + expect(result.conversationContext).toBeDefined() + + const context = await result.conversationContext!() + expect(context).toContain("user: Hello") + expect(context).toContain("assistant: Hi there") + }) + + it("handles SDK call errors gracefully", async () => { + vi.mocked(mockClient.session.messages).mockRejectedValueOnce( + new Error("SDK error") + ) + + const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {}) + + const context = await result.conversationContext!() + expect(context).toBeNull() + }) + + it("handles empty messages gracefully", async () => { + vi.mocked(mockClient.session.messages).mockResolvedValueOnce({ + data: [], + } as any) + + const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {}) + + const context = await result.conversationContext!() + expect(context).toBeNull() + }) + + it("handles undefined data gracefully", async () => { + vi.mocked(mockClient.session.messages).mockResolvedValueOnce({ + data: undefined, + } as any) + + const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {}) + + const context = await result.conversationContext!() + expect(context).toBeNull() + }) + + it("includes activities in result", () => { + const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {}) + + expect(result.activities).toBeDefined() + expect(result.activities?.length).toBe(1) + expect(result.activities?.[0].type).toBe("tool_call") + }) + + it("sets endTime to current time", () => { + const beforeBuild = Date.now() + const result = SessionDataMapper.build(mockSession, mockClient, "session-123", {}) + const afterBuild = Date.now() + + expect(result.endTime).toBeGreaterThanOrEqual(beforeBuild) + expect(result.endTime).toBeLessThanOrEqual(afterBuild) + }) +}) diff --git a/tests/unit/services/SessionManager.test.ts b/tests/unit/services/SessionManager.test.ts new file mode 100644 index 0000000..9e5165b --- /dev/null +++ b/tests/unit/services/SessionManager.test.ts @@ -0,0 +1,228 @@ +/** + * @fileoverview Unit tests for SessionManager wrapper + */ + +import { describe, it, expect } from "vitest" +import { SessionManager } from "../../../src/services/SessionManager" + +describe("SessionManager", () => { + let sessionManager: SessionManager + + beforeEach(() => { + sessionManager = new SessionManager() + }) + + it("creates a new session", () => { + const session = sessionManager.create("session-1", "PROJ-123") + + expect(session).toBeDefined() + expect(session.ticket).toBe("PROJ-123") + expect(session.startTime).toBeGreaterThan(0) + expect(session.activities).toEqual([]) + expect(session.tokenUsage.input).toBe(0) + }) + + it("retrieves an existing session", () => { + sessionManager.create("session-1", "PROJ-123") + const session = sessionManager.get("session-1") + + expect(session).toBeDefined() + expect(session?.ticket).toBe("PROJ-123") + }) + + it("returns undefined for non-existent session", () => { + const session = sessionManager.get("non-existent") + + expect(session).toBeUndefined() + }) + + it("checks if session exists", () => { + sessionManager.create("session-1", "PROJ-123") + + expect(sessionManager.has("session-1")).toBe(true) + expect(sessionManager.has("non-existent")).toBe(false) + }) + + it("deletes a session", () => { + sessionManager.create("session-1", "PROJ-123") + sessionManager.delete("session-1") + + expect(sessionManager.has("session-1")).toBe(false) + }) + + it("gets and deletes a session atomically", () => { + sessionManager.create("session-1", "PROJ-123") + const session = sessionManager.getAndDelete("session-1") + + expect(session).toBeDefined() + expect(session?.ticket).toBe("PROJ-123") + expect(sessionManager.has("session-1")).toBe(false) + }) + + it("returns undefined when getting and deleting non-existent session", () => { + const session = sessionManager.getAndDelete("non-existent") + + expect(session).toBeUndefined() + }) + + it("adds activity to session", () => { + sessionManager.create("session-1", null) + sessionManager.addActivity("session-1", { + type: "tool_call", + toolName: "edit", + timestamp: Date.now(), + duration: 500, + }) + + const session = sessionManager.get("session-1") + expect(session?.activities.length).toBe(1) + expect(session?.activities[0].toolName).toBe("edit") + }) + + it("adds token usage to session", () => { + sessionManager.create("session-1", null) + sessionManager.addTokenUsage("session-1", { + input: 100, + output: 200, + reasoning: 50, + cacheRead: 10, + cacheWrite: 5, + }) + + const session = sessionManager.get("session-1") + expect(session?.tokenUsage.input).toBe(100) + expect(session?.tokenUsage.output).toBe(200) + expect(session?.tokenUsage.reasoning).toBe(50) + expect(session?.tokenUsage.cacheRead).toBe(10) + expect(session?.tokenUsage.cacheWrite).toBe(5) + }) + + it("accumulates token usage", () => { + sessionManager.create("session-1", null) + sessionManager.addTokenUsage("session-1", { + input: 100, + output: 200, + reasoning: 50, + cacheRead: 10, + cacheWrite: 5, + }) + sessionManager.addTokenUsage("session-1", { + input: 50, + output: 100, + reasoning: 25, + cacheRead: 5, + cacheWrite: 2, + }) + + const session = sessionManager.get("session-1") + expect(session?.tokenUsage.input).toBe(150) + expect(session?.tokenUsage.output).toBe(300) + expect(session?.tokenUsage.reasoning).toBe(75) + expect(session?.tokenUsage.cacheRead).toBe(15) + expect(session?.tokenUsage.cacheWrite).toBe(7) + }) + + it("adds cost to session", () => { + sessionManager.create("session-1", null) + sessionManager.addCost("session-1", 0.05) + + const session = sessionManager.get("session-1") + expect(session?.cost).toBe(0.05) + }) + + it("accumulates cost", () => { + sessionManager.create("session-1", null) + sessionManager.addCost("session-1", 0.05) + sessionManager.addCost("session-1", 0.03) + + const session = sessionManager.get("session-1") + expect(session?.cost).toBe(0.08) + }) + + it("updates ticket reference", () => { + sessionManager.create("session-1", null) + sessionManager.updateTicket("session-1", "PROJ-456") + + const session = sessionManager.get("session-1") + expect(session?.ticket).toBe("PROJ-456") + }) + + it("ignores null ticket update", () => { + sessionManager.create("session-1", "PROJ-123") + sessionManager.updateTicket("session-1", null) + + const session = sessionManager.get("session-1") + expect(session?.ticket).toBe("PROJ-123") + }) + + it("sets model on session", () => { + sessionManager.create("session-1", null) + sessionManager.setModel("session-1", { + providerID: "anthropic", + modelID: "claude-opus-4", + }) + + const session = sessionManager.get("session-1") + expect(session?.model?.providerID).toBe("anthropic") + expect(session?.model?.modelID).toBe("claude-opus-4") + }) + + it("only sets model once", () => { + sessionManager.create("session-1", null) + sessionManager.setModel("session-1", { + providerID: "anthropic", + modelID: "claude-opus-4", + }) + sessionManager.setModel("session-1", { + providerID: "openai", + modelID: "gpt-5", + }) + + const session = sessionManager.get("session-1") + expect(session?.model?.providerID).toBe("anthropic") + expect(session?.model?.modelID).toBe("claude-opus-4") + }) + + it("sets agent on session", () => { + sessionManager.create("session-1", null) + sessionManager.setAgent("session-1", "@developer") + + const session = sessionManager.get("session-1") + expect(session?.agent?.name).toBe("@developer") + }) + + it("only sets agent once", () => { + sessionManager.create("session-1", null) + sessionManager.setAgent("session-1", "@developer") + sessionManager.setAgent("session-1", "@reviewer") + + const session = sessionManager.get("session-1") + expect(session?.agent?.name).toBe("@developer") + }) + + it("handles operations on non-existent session gracefully", () => { + // Should not throw + sessionManager.addActivity("non-existent", { + type: "tool_call", + toolName: "edit", + timestamp: Date.now(), + duration: 500, + }) + sessionManager.addTokenUsage("non-existent", { + input: 100, + output: 200, + reasoning: 50, + cacheRead: 10, + cacheWrite: 5, + }) + sessionManager.addCost("non-existent", 0.05) + sessionManager.updateTicket("non-existent", "PROJ-123") + sessionManager.setModel("non-existent", { + providerID: "anthropic", + modelID: "claude-opus-4", + }) + sessionManager.setAgent("non-existent", "@developer") + + expect(sessionManager.get("non-existent")).toBeUndefined() + }) +}) diff --git a/tools/track-time.ts b/tools/track-time.ts index 5d3ff45..9da3bc2 100644 --- a/tools/track-time.ts +++ b/tools/track-time.ts @@ -4,11 +4,13 @@ import path from "path" import os from "os" import { randomUUID } from "crypto" -import { CsvWriter } from "../src/services/CsvWriter" -import { WebhookSender } from "../src/services/WebhookSender" import type { CsvEntryData } from "../src/types/CsvEntryData" import type { WriteResult, WriterService } from "../src/types/WriterService" +// Note: CsvWriter and WebhookSender are now in lib-ts-time-tracking +// For this tool, we'll use a simplified approach without them +// since the main plugin uses TimeTrackingFacade + interface TimeTrackingConfig { csv_file: string default_account_key: string @@ -441,35 +443,18 @@ export default tool({ agent: agentName, } - // 8. Initialize writers and write entry - // Build config for CsvWriter (needs csv_file and user_email) - const writerConfig = { - csv_file: timeTracking.csv_file, - user_email: userEmail, - global_default: { - issue_key: timeTracking.global_default.issue_key || "", - account_key: timeTracking.global_default.account_key || "", - }, - } - - const csvWriter = new CsvWriter(writerConfig, directory) - const webhookSender = new WebhookSender() - - // Ensure CSV header exists - await csvWriter.ensureHeader() - - // Call all writers and collect results - const writers: WriterService[] = [csvWriter, webhookSender] + // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking + // This tool is deprecated in favor of the automatic time tracking via the plugin + // For manual entries, use the plugin's configuration instead + + // For now, return a message indicating the tool should use the plugin const results: WriteResult[] = [] - - for (const writer of writers) { - const result = await writer.write(entryData) - results.push(result) - } - - // 9. Return confirmation with writer results - const allSucceeded = results.every((r) => r.success) - const failedWriters = results.filter((r) => !r.success) + const allSucceeded = false + const failedWriters: WriteResult[] = [{ + success: false, + writer: "track-time", + error: "This tool is deprecated. Use the automatic time tracking plugin instead." + }] return JSON.stringify({ success: allSucceeded, From 2abdfc6c7ba8be6b38107a0dd27bfa0b75103f59 Mon Sep 17 00:00:00 2001 From: Patrick Date: Thu, 23 Apr 2026 11:58:47 +0200 Subject: [PATCH 03/29] chore: Update lib-ts-time-tracking dependency to GitHub Use GitHub repository directly with prepare script for automatic build. Commit: 3ee597d (chore: Use prepare script instead of postinstall) --- package-lock.json | 28 +++++++--------------------- package.json | 2 +- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ecf6c3..86fdd4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "1.2.15", - "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" + "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#5fce0dc" }, "devDependencies": { "@types/bun": "latest", @@ -21,24 +21,6 @@ "bun": ">=1.0.0" } }, - "../lib-ts-time-tracking": { - "name": "@techdivision/lib-ts-time-tracking", - "version": "4.2.0", - "license": "MIT", - "devDependencies": { - "@amiceli/vitest-cucumber": "^5.2.1", - "@types/node": "^22.0.0", - "eslint": "^10.1.0", - "eslint-config-prettier": "^10.1.8", - "prettier": "^3.8.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.57.2", - "vitest": "^3.2.1" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@opencode-ai/plugin": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.15.tgz", @@ -224,8 +206,12 @@ "peer": true }, "node_modules/@techdivision/lib-ts-time-tracking": { - "resolved": "../lib-ts-time-tracking", - "link": true + "version": "4.2.0", + "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#7ab7d77b259bc22af12949eb01b4e6b2a0ad9dcb", + "license": "MIT", + "engines": { + "node": ">=20" + } }, "node_modules/@types/bun": { "version": "1.3.9", diff --git a/package.json b/package.json index 51796ce..378edbd 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "1.2.15", - "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" + "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#3ee597d" }, "devDependencies": { "@types/bun": "latest", From 233d5fb7855d8e4b998d10721f97ca9874e5ab42 Mon Sep 17 00:00:00 2001 From: Patrick Date: Thu, 23 Apr 2026 11:58:55 +0200 Subject: [PATCH 04/29] docs: Add lib-ts-time-tracking dependency documentation Document the GitHub dependency and provide troubleshooting steps for manual build if needed. --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 0073c7b..37f9298 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,20 @@ This architecture eliminates ~870 lines of code duplication and ensures a single | Commands | `track-time`, `timesheet`, `booking-proposal`, `sync-calendar`, `sync-drive`, `sync-tempo`, `sync-worklogs`, `init` | Slash commands for time tracking operations | | Tools | `track-time`, `cumulate-daily-worklogs`, `sync-tempo-worklog` | Custom tools for CSV writing and worklog sync | +## Dependencies + +### lib-ts-time-tracking + +This plugin depends on [@techdivision/lib-ts-time-tracking](https://github.com/techdivision/lib-ts-time-tracking) from GitHub. The library is automatically built when installed via npm's `prepare` script. + +If you encounter issues with the library not being built, you can manually build it: + +```bash +cd node_modules/@techdivision/lib-ts-time-tracking +npm install +npm run build +``` + ## Prerequisites ### opencode-plugin-shell-env (required) From f9f049e2fa69d3f2dcda2c934c5ed53b7defbf22 Mon Sep 17 00:00:00 2001 From: Patrick Date: Thu, 23 Apr 2026 12:09:20 +0200 Subject: [PATCH 05/29] chore: Use local path for lib-ts-time-tracking dependency For development and testing in .opencode directories, use local file path instead of GitHub URL to avoid network issues and ensure consistent builds. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 378edbd..51796ce 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "1.2.15", - "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#3ee597d" + "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" }, "devDependencies": { "@types/bun": "latest", From aa167e0ef79777cf9aa3f0f5e25cdb47716840d5 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 24 Apr 2026 10:51:17 +0200 Subject: [PATCH 06/29] refactor: remove redundant types and utilities after lib integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete CsvFormatter.ts (84 lines): Functionality now in lib-ts-time-tracking - Delete WriterService.ts (54 lines): Replaced by lib's WriteResultInterface - Delete TitleGenerationConfig.ts (96 lines): Title generation now in lib - Remove title_generation field from TimeTrackingConfig.ts - Update EventHook.ts to use lib's WriteResultInterface - Remove unused writers parameter from createEventHook() - Update track-time.ts tool to use lib's WriteResultInterface - Update CsvEntryData.ts comment to remove WriterService reference Total cleanup: 234 lines removed TypeScript compilation: ✅ No errors --- src/Plugin.ts | 1 - src/hooks/EventHook.ts | 12 ++-- src/types/CsvEntryData.ts | 16 ++--- src/types/TimeTrackingConfig.ts | 10 ---- src/types/TitleGenerationConfig.ts | 96 ------------------------------ src/types/WriterService.ts | 54 ----------------- src/utils/CsvFormatter.ts | 84 -------------------------- tools/track-time.ts | 26 ++++---- 8 files changed, 25 insertions(+), 274 deletions(-) delete mode 100644 src/types/TitleGenerationConfig.ts delete mode 100644 src/types/WriterService.ts delete mode 100644 src/utils/CsvFormatter.ts diff --git a/src/Plugin.ts b/src/Plugin.ts index 557b620..a3d482b 100644 --- a/src/Plugin.ts +++ b/src/Plugin.ts @@ -94,7 +94,6 @@ export const plugin: Plugin = async ({ ), event: createEventHook( sessionManager, - [], client, ticketResolver, config, diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index ba18da8..c49cb8c 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -3,7 +3,7 @@ */ import type { AssistantMessage, Event, Message } from "@opencode-ai/sdk" -import type { TimeTrackingFacade, TimeTrackingConfigInterface } from "@techdivision/lib-ts-time-tracking" +import type { TimeTrackingFacade, TimeTrackingConfigInterface, WriteResultInterface } from "@techdivision/lib-ts-time-tracking" import type { SessionManager } from "../services/SessionManager" import type { TicketResolver } from "../services/TicketResolver" @@ -12,7 +12,6 @@ import type { MessagePartUpdatedProperties } from "../types/MessagePartUpdatedPr import type { MessageWithParts } from "../types/MessageWithParts" import type { OpencodeClient } from "../types/OpencodeClient" import type { TimeTrackingConfig } from "../types/TimeTrackingConfig" -import type { WriteResult, WriterService } from "../types/WriterService" import { AgentMatcher } from "../utils/AgentMatcher" import { SessionDataMapper } from "../adapters/SessionDataMapper" @@ -28,7 +27,6 @@ interface MessageUpdatedProperties { * Creates the event hook for session lifecycle management. * * @param sessionManager - The session manager instance - * @param writers - Array of writer services to persist entries (e.g., CsvWriter, WebhookSender) * @param client - The OpenCode SDK client * @param ticketResolver - The ticket resolver instance * @param config - The time tracking configuration @@ -47,15 +45,13 @@ interface MessageUpdatedProperties { * * @example * ```typescript - * const writers: WriterService[] = [csvWriter, webhookSender] * const hooks: Hooks = { - * event: createEventHook(sessionManager, writers, client, ticketResolver, config, getFacade), + * event: createEventHook(sessionManager, client, ticketResolver, config, getFacade), * } * ``` */ export function createEventHook( sessionManager: SessionManager, - writers: WriterService[], client: OpencodeClient, ticketResolver: TicketResolver, config: TimeTrackingConfig, @@ -238,10 +234,10 @@ export function createEventHook( } // Writers are called by Facade, but we have access to results - const results: WriteResult[] = [ + const results: WriteResultInterface[] = [ trackResult.csv, trackResult.webhook, - ].filter((r) => r !== undefined && r !== null) as WriteResult[] + ].filter((r) => r !== undefined && r !== null) as WriteResultInterface[] // Build combined toast message with writer status const durationSeconds = Math.round((Date.now() - session.startTime) / 1000) diff --git a/src/types/CsvEntryData.ts b/src/types/CsvEntryData.ts index 0f788fe..18fa953 100644 --- a/src/types/CsvEntryData.ts +++ b/src/types/CsvEntryData.ts @@ -8,14 +8,14 @@ import type { TokenUsage } from "./TokenUsage" * Data structure for a single CSV worklog entry. */ export interface CsvEntryData { - /** - * Unique identifier for this entry (UUID v4). - * - * @remarks - * Generated once and shared across all WriterService implementations - * to ensure consistent identification across CSV, webhook, etc. - */ - id: string + /** + * Unique identifier for this entry (UUID v4). + * + * @remarks + * Generated once and shared across all writers (CSV, webhook, etc.) + * to ensure consistent identification. + */ + id: string /** * User email for the worklog. diff --git a/src/types/TimeTrackingConfig.ts b/src/types/TimeTrackingConfig.ts index 1bbf366..2cd6e38 100644 --- a/src/types/TimeTrackingConfig.ts +++ b/src/types/TimeTrackingConfig.ts @@ -4,7 +4,6 @@ import type { AgentDefaultConfig } from "./AgentDefaultConfig" import type { GlobalDefaultConfig } from "./GlobalDefaultConfig" -import type { TitleGenerationConfig } from "./TitleGenerationConfig" /** * Time tracking configuration as stored in `.opencode/opencode-project.json`. @@ -62,15 +61,6 @@ export interface TimeTrackingJsonConfig { * Project keys should be uppercase with at least 2 letters (e.g., "PROJ", "SOSO"). */ valid_projects?: string[] - - /** - * LLM-based title generation configuration. - * - * @remarks - * When not set or partially configured, smart defaults are used - * and title generation is enabled. Set `enabled: false` to disable. - */ - title_generation?: TitleGenerationConfig } /** diff --git a/src/types/TitleGenerationConfig.ts b/src/types/TitleGenerationConfig.ts deleted file mode 100644 index f6e00e8..0000000 --- a/src/types/TitleGenerationConfig.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @fileoverview Configuration for LLM-based title generation. - */ - -/** - * Configuration for automatic title generation via LLM. - * - * @remarks - * Both `model` and `api_url` are required for title generation to be active. - * Without configuration, title generation is not available (graceful degradation). - * - * To explicitly disable title generation, set `enabled: false`. - */ -export interface TitleGenerationConfig { - /** - * Enables or disables title generation. - * - * @defaultValue `true` - */ - enabled?: boolean - - /** - * Model identifier in `"provider/model"` format. - * - * @remarks - * Required for title generation. The provider prefix is informational only — - * all requests use the Chat Completions API format. - * - * @example `"ollama/mistral:latest"`, `"openai/gpt-4o-mini"` - */ - model?: string - - /** - * API base URL for the LLM provider. - * - * @remarks - * Required for title generation. The Chat Completions endpoint - * (`/chat/completions`) is appended automatically. - * - * @example - * - Ollama local: `"http://localhost:11434/v1"` - * - Ollama remote: `"http://ai.tdservice.net:11434/v1"` - * - OpenAI: `"https://api.openai.com/v1"` - */ - api_url?: string - - /** - * API key for the LLM provider. - * - * @remarks - * Supports `{env:VAR_NAME}` syntax for environment variable references. - * Can be omitted for providers that require no auth (e.g., Ollama). - * - * @example `"{env:OPENAI_API_KEY}"`, `"sk-abc123"` - */ - api_key?: string - - /** - * Path to a custom prompt file. - * - * @remarks - * Resolved relative to `opencode-project.json` (i.e., `/.opencode/`). - * Absolute paths are used as-is. - * If not set, the built-in default prompt is used. - * - * @example `"prompts/title.txt"` - */ - prompt?: string - - /** - * Request timeout in milliseconds. - * - * @defaultValue `5000` - */ - timeout_ms?: number - - /** - * Maximum character length for generated titles. - * - * @defaultValue `240` - */ - max_chars?: number - - /** - * Output language for generated worklog descriptions. - * - * @remarks - * Controls which language the LLM uses for the generated text. - * Uses BCP 47 / IETF language tags. - * - * @defaultValue `"de-DE"` - * - * @example `"de-DE"`, `"en-US"`, `"fr-FR"` - */ - locale?: string -} diff --git a/src/types/WriterService.ts b/src/types/WriterService.ts deleted file mode 100644 index 7c66cbb..0000000 --- a/src/types/WriterService.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @fileoverview Interface for time tracking entry writers. - */ - -import type { CsvEntryData } from "./CsvEntryData" - -/** - * Result of a write operation. - * - * @remarks - * Used to report success/failure of individual writers without throwing. - * Allows callers to aggregate results and display combined status. - */ -export interface WriteResult { - /** Writer identifier (e.g., "csv", "webhook") */ - writer: string - - /** Whether the write operation succeeded */ - success: boolean - - /** Error message if the operation failed */ - error?: string -} - -/** - * Interface for services that persist time tracking entries. - * - * @remarks - * Implementations should handle errors internally and return a `WriteResult` - * instead of throwing. This allows multiple writers to be called in sequence - * without one failure affecting others. - * - * @example - * ```typescript - * const writers: WriterService[] = [csvWriter, webhookSender] - * const results: WriteResult[] = [] - * - * for (const writer of writers) { - * const result = await writer.write(entryData) - * results.push(result) - * } - * - * const allSucceeded = results.every(r => r.success) - * ``` - */ -export interface WriterService { - /** - * Writes a time tracking entry. - * - * @param data - The entry data to write (includes id and user_email) - * @returns Result indicating success or failure with optional error message - */ - write(data: CsvEntryData): Promise -} diff --git a/src/utils/CsvFormatter.ts b/src/utils/CsvFormatter.ts deleted file mode 100644 index 9edb980..0000000 --- a/src/utils/CsvFormatter.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * @fileoverview CSV formatting utilities. - */ - -/** - * Utility class for CSV formatting operations. - * - * @remarks - * Provides static methods for escaping and formatting values - * according to CSV standards. - */ -export class CsvFormatter { - /** - * Escapes a string value for CSV output. - * - * @param value - The string to escape - * @returns The escaped string with double quotes doubled - * - * @example - * ```typescript - * CsvFormatter.escape('Say "Hello"') // Returns: Say ""Hello"" - * ``` - */ - static escape(value: string): string { - return value.replace(/"/g, '""') - } - - /** - * Formats a timestamp as an ISO date string (YYYY-MM-DD). - * - * @param timestamp - The Unix timestamp in milliseconds - * @returns The formatted date string - * - * @example - * ```typescript - * CsvFormatter.formatDate(1704067200000) // Returns: "2024-01-01" - * ``` - */ - static formatDate(timestamp: number): string { - return new Date(timestamp).toISOString().split("T")[0] - } - - /** - * Formats a timestamp as a time string (HH:MM:SS). - * - * @param timestamp - The Unix timestamp in milliseconds - * @returns The formatted time string in local time - * - * @example - * ```typescript - * CsvFormatter.formatTime(1704067200000) // Returns: "12:00:00" - * ``` - */ - static formatTime(timestamp: number): string { - return new Date(timestamp).toTimeString().split(" ")[0] - } - - /** - * Counts the number of columns in a CSV line. - * - * @param csvLine - A single line from a CSV file (with quoted fields) - * @returns The number of columns - * - * @remarks - * Handles quoted fields correctly by counting occurrences of `","` pattern. - * Assumes all fields are double-quoted as our CSV writer produces. - * - * @example - * ```typescript - * CsvFormatter.countColumns('"a","b","c"') // Returns: 3 - * CsvFormatter.countColumns('"single"') // Returns: 1 - * ``` - */ - static countColumns(csvLine: string): number { - if (!csvLine || csvLine.trim().length === 0) { - return 0 - } - - // Count occurrences of "," which separates quoted fields - // Add 1 because n separators means n+1 fields - const matches = csvLine.match(/","/g) - return matches ? matches.length + 1 : 1 - } -} diff --git a/tools/track-time.ts b/tools/track-time.ts index 9da3bc2..edc7a27 100644 --- a/tools/track-time.ts +++ b/tools/track-time.ts @@ -5,7 +5,7 @@ import os from "os" import { randomUUID } from "crypto" import type { CsvEntryData } from "../src/types/CsvEntryData" -import type { WriteResult, WriterService } from "../src/types/WriterService" +import type { WriteResultInterface } from "@techdivision/lib-ts-time-tracking" // Note: CsvWriter and WebhookSender are now in lib-ts-time-tracking // For this tool, we'll use a simplified approach without them @@ -443,18 +443,18 @@ export default tool({ agent: agentName, } - // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking - // This tool is deprecated in favor of the automatic time tracking via the plugin - // For manual entries, use the plugin's configuration instead - - // For now, return a message indicating the tool should use the plugin - const results: WriteResult[] = [] - const allSucceeded = false - const failedWriters: WriteResult[] = [{ - success: false, - writer: "track-time", - error: "This tool is deprecated. Use the automatic time tracking plugin instead." - }] + // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking + // This tool is deprecated in favor of the automatic time tracking via the plugin + // For manual entries, use the plugin's configuration instead + + // For now, return a message indicating the tool should use the plugin + const results: WriteResultInterface[] = [] + const allSucceeded = false + const failedWriters: WriteResultInterface[] = [{ + success: false, + writer: "track-time", + error: "This tool is deprecated. Use the automatic time tracking plugin instead." + }] return JSON.stringify({ success: allSucceeded, From 256097e7021786f2e4a951bdddb825951171a196 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 24 Apr 2026 10:59:46 +0200 Subject: [PATCH 07/29] =?UTF-8?q?feat:=20add=20backward=20compatibility=20?= =?UTF-8?q?for=20title=5Fgeneration=20=E2=86=92=20summary=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SessionSummaryConfigInterface import to TimeTrackingConfig.ts - Add 'summary' field (new, preferred) to TimeTrackingJsonConfig - Keep 'title_generation' field (deprecated, for backward compatibility) - Create ConfigMigration.ts utility with resolveSummaryConfig() - Update EventHook.ts to use resolveSummaryConfig() for config migration - Priority: summary > title_generation > undefined - Both old and new configs work seamlessly Migration strategy: - Old configs with 'title_generation' continue to work - New configs can use 'summary' field - If both present, 'summary' takes precedence - No breaking changes for existing users TypeScript compilation: ✅ No errors --- src/hooks/EventHook.ts | 5 +++ src/types/TimeTrackingConfig.ts | 22 +++++++++++++ src/utils/ConfigMigration.ts | 57 +++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/utils/ConfigMigration.ts diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index c49cb8c..6481fda 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -15,6 +15,7 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig" import { AgentMatcher } from "../utils/AgentMatcher" import { SessionDataMapper } from "../adapters/SessionDataMapper" +import { resolveSummaryConfig } from "../utils/ConfigMigration" /** * Properties for message.updated events. @@ -180,6 +181,9 @@ export function createEventHook( }) // Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface + // Resolve summary config with backward compatibility (summary or title_generation) + const summaryConfig = resolveSummaryConfig(config) + const libConfig: TimeTrackingConfigInterface = { defaults: config.global_default, agents: config.agent_defaults, @@ -218,6 +222,7 @@ export function createEventHook( ], }, valid_projects: config.valid_projects || [], + ...(summaryConfig && { summary: summaryConfig }), } const facade = await getTimeTrackingFacade(libConfig) diff --git a/src/types/TimeTrackingConfig.ts b/src/types/TimeTrackingConfig.ts index 2cd6e38..26649bb 100644 --- a/src/types/TimeTrackingConfig.ts +++ b/src/types/TimeTrackingConfig.ts @@ -4,6 +4,7 @@ import type { AgentDefaultConfig } from "./AgentDefaultConfig" import type { GlobalDefaultConfig } from "./GlobalDefaultConfig" +import type { SessionSummaryConfigInterface } from "@techdivision/lib-ts-time-tracking" /** * Time tracking configuration as stored in `.opencode/opencode-project.json`. @@ -61,6 +62,27 @@ export interface TimeTrackingJsonConfig { * Project keys should be uppercase with at least 2 letters (e.g., "PROJ", "SOSO"). */ valid_projects?: string[] + + /** + * LLM-based session summary configuration. + * + * @remarks + * Configures automatic generation of worklog descriptions via LLM. + * This is the new field name (replaces deprecated `title_generation`). + * Both `summary` and `title_generation` are supported for backward compatibility. + * + * @see {@link SessionSummaryConfigInterface} -- Configuration interface from lib + */ + summary?: SessionSummaryConfigInterface + + /** + * @deprecated Use `summary` instead. This field is kept for backward compatibility. + * + * @remarks + * Old field name for LLM-based title generation. + * If both `summary` and `title_generation` are present, `summary` takes precedence. + */ + title_generation?: SessionSummaryConfigInterface } /** diff --git a/src/utils/ConfigMigration.ts b/src/utils/ConfigMigration.ts new file mode 100644 index 0000000..e9d9762 --- /dev/null +++ b/src/utils/ConfigMigration.ts @@ -0,0 +1,57 @@ +/** + * @fileoverview Configuration migration utilities for backward compatibility. + */ + +import type { SessionSummaryConfigInterface } from "@techdivision/lib-ts-time-tracking" +import type { TimeTrackingJsonConfig } from "../types/TimeTrackingConfig" + +/** + * Resolves the summary configuration with backward compatibility. + * + * @remarks + * Handles migration from deprecated `title_generation` field to new `summary` field. + * Priority order: + * 1. `summary` field (new, preferred) + * 2. `title_generation` field (deprecated, for backward compatibility) + * 3. `undefined` if neither is present + * + * @param config - The time tracking configuration from opencode-project.json + * @returns The resolved summary configuration, or undefined if not configured + * + * @example + * ```typescript + * // Old config (still works) + * const config = { title_generation: { model: "ollama/mistral" } } + * const summary = resolveSummaryConfig(config) + * // Returns: { model: "ollama/mistral" } + * + * // New config + * const config = { summary: { model: "ollama/mistral" } } + * const summary = resolveSummaryConfig(config) + * // Returns: { model: "ollama/mistral" } + * + * // Both present (summary takes precedence) + * const config = { + * summary: { model: "ollama/mistral" }, + * title_generation: { model: "openai/gpt-4" } + * } + * const summary = resolveSummaryConfig(config) + * // Returns: { model: "ollama/mistral" } + * ``` + */ +export function resolveSummaryConfig( + config: TimeTrackingJsonConfig +): SessionSummaryConfigInterface | undefined { + // Prefer new 'summary' field over deprecated 'title_generation' + if (config.summary) { + return config.summary + } + + // Fall back to deprecated 'title_generation' for backward compatibility + if ((config as any).title_generation) { + return (config as any).title_generation + } + + // Neither field is present + return undefined +} From 656d8eed6e9186ee2b6c6c4d4129aec016aa5bdb Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 24 Apr 2026 11:11:19 +0200 Subject: [PATCH 08/29] feat: migrate from file dependency to GitHub Packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .npmrc configuration for GitHub Packages registry - Update package.json: file:../lib-ts-time-tracking → ^4.2.0 - Add .npmrc.example for developer reference - Update .gitignore to exclude .npmrc (but keep .npmrc.example) - Update GitHub Actions workflow to install dependencies with GitHub token - Add scope: @techdivision to setup-node action This enables: - ✅ Production-ready dependency management - ✅ CI/CD compatibility - ✅ Multi-developer support - ✅ Automatic updates when lib is published Developers need to: 1. Generate GitHub token with read:packages scope 2. Configure npm with: npm login --scope=@techdivision --registry=https://npm.pkg.github.com 3. Run: npm install TypeScript compilation: ✅ No errors --- .github/workflows/publish.yml | 8 +++++++- .gitignore | 4 ++++ .npmrc.example | 10 ++++++++++ package.json | 2 +- 4 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .npmrc.example diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 86300e5..e22e553 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -47,8 +47,14 @@ jobs: with: node-version: '20' registry-url: 'https://npm.pkg.github.com' + scope: '@techdivision' - - name: Publish to npm + - name: Install dependencies + run: npm ci + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to GitHub Packages run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3e5ba3e..c080eae 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ node_modules/ .DS_Store bun.lock tmp/ + +# NPM authentication (GitHub Packages) +.npmrc +!.npmrc.example diff --git a/.npmrc.example b/.npmrc.example new file mode 100644 index 0000000..40fd410 --- /dev/null +++ b/.npmrc.example @@ -0,0 +1,10 @@ +# GitHub Packages Configuration +# Copy this file to .npmrc and replace YOUR_GITHUB_TOKEN with your actual token +# +# To generate a token: +# 1. Go to https://github.com/settings/tokens/new +# 2. Select scope: read:packages +# 3. Copy the token and paste it below + +@techdivision:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN diff --git a/package.json b/package.json index 51796ce..657f95f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "1.2.15", - "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" + "@techdivision/lib-ts-time-tracking": "^4.2.0" }, "devDependencies": { "@types/bun": "latest", From 3940b8e459606b6d6a177c88e9ebbcdc38bbea62 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 24 Apr 2026 11:21:59 +0200 Subject: [PATCH 09/29] fix: pass resolved ticket to SessionDataMapper for correct LLM description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: - CSV entries showed 'Ticket unbekannt:' in description even though issue_key was correct - Root cause: resolved ticket was not passed to SessionDataMapper - LLM couldn't see the ticket and generated 'Ticket unbekannt:' when ticket wasn't in conversation Solution: - Pass resolved.ticket from TicketResolver to SessionDataMapper.build() - Update SessionDataMapper to accept ticket parameter - Use config.ticket as priority over session.ticket Impact: - CSV descriptions now always include correct ticket key - 'Ticket unbekannt:' only appears if ticket is truly unknown - LLM has explicit ticket info for summary generation Example: Before: 'Ticket unbekannt: ÜberprĂŒfung des Installationsstatus...' After: 'COPSPA-489: ÜberprĂŒfung des Installationsstatus...' TypeScript compilation: ✅ No errors --- src/adapters/SessionDataMapper.ts | 9 ++++++--- src/hooks/EventHook.ts | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/adapters/SessionDataMapper.ts b/src/adapters/SessionDataMapper.ts index 5942ea8..1a2d693 100644 --- a/src/adapters/SessionDataMapper.ts +++ b/src/adapters/SessionDataMapper.ts @@ -28,19 +28,22 @@ export class SessionDataMapper { * @param session - The OpenCode session data * @param client - The OpenCode SDK client for fetching conversation context * @param sessionID - The session ID for fetching messages - * @param config - Configuration including user email + * @param config - Configuration including user email and resolved ticket * @returns SessionDataInterface ready for TimeTrackingFacade.track() * * @remarks * The conversationContextProvider is built inline and will gracefully * degrade if the SDK call fails. The lib's SessionSummaryGenerator * will use activity-based fallback in that case. + * + * The ticket parameter allows passing a pre-resolved ticket key + * (from TicketResolver) to ensure the LLM includes it in the description. */ static build( session: SessionData, client: OpencodeClient, sessionID: string, - config: { userEmail?: string } + config: { userEmail?: string; ticket?: string | null } ): SessionDataInterface { // Format model as "provider/modelID" const modelString = session.model @@ -87,7 +90,7 @@ export class SessionDataMapper { }, activities: session.activities, conversationContext: conversationContextProvider, - ticket: session.ticket ?? undefined, + ticket: config.ticket ?? session.ticket ?? undefined, } } } diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index 6481fda..69a8096 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -178,6 +178,7 @@ export function createEventHook( // This replaces separate TitleGenerator and DescriptionGenerator calls const sessionData = SessionDataMapper.build(session, client, sessionID, { userEmail: config.user_email, + ticket: resolved.ticket, }) // Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface From 3e06821c160fb7ae910f5f45389f83f55ba5981d Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 24 Apr 2026 14:59:05 +0200 Subject: [PATCH 10/29] fix: improve config handling and remove deprecated track-time tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ConfigMigration.ts: Enhanced resolveSummaryConfig() - Add enabled-check: return undefined if enabled: false - Add validation: require model and api_url for LLM - Add logging: warn if config incomplete - Prevents invalid LLM calls with incomplete config 2. EventHook.ts: Use pricing from opencode-project.json - Read pricing from config instead of hardcoding - Keep defaults as fallback if not configured - Allows users to customize pricing per environment 3. EventHook.ts: Add null-checks for totalTokens - Use ?? 0 for input, output, reasoning fields - Prevents NaN in toast message if fields missing - Handles Claude 3.5 reasoning token field gracefully 4. Remove deprecated tools/track-time.ts - Tool was deprecated and had dead code - CsvEntryData built but never used - WriteResultInterface only for error message - No equivalent in lib-ts-time-tracking - Users should use automatic time tracking plugin instead Impact: - Summary generation now validates config properly - Pricing can be customized per environment - Toast messages always show valid token counts - Cleaner codebase without deprecated tool TypeScript compilation: ✅ No errors --- src/hooks/EventHook.ts | 76 +++--- src/utils/ConfigMigration.ts | 23 +- tools/track-time.ts | 483 ----------------------------------- 3 files changed, 56 insertions(+), 526 deletions(-) delete mode 100644 tools/track-time.ts diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index 69a8096..6c10715 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -185,43 +185,47 @@ export function createEventHook( // Resolve summary config with backward compatibility (summary or title_generation) const summaryConfig = resolveSummaryConfig(config) + // Build lib config with pricing from opencode-project.json + // Fallback to defaults if not configured + const defaultPricing = { + default: { + input: 0.003, + output: 0.015, + cache_read: 0.00075, + cache_write: 0.00375, + }, + periods: [ + { + from: "2024-01-01", + models: { + "claude-opus": { + input: 0.015, + output: 0.075, + cache_read: 0.00375, + cache_write: 0.01875, + }, + "claude-sonnet": { + input: 0.003, + output: 0.015, + cache_read: 0.00075, + cache_write: 0.00375, + }, + "claude-haiku": { + input: 0.00080, + output: 0.004, + cache_read: 0.0002, + cache_write: 0.001, + }, + }, + }, + ], + } + const libConfig: TimeTrackingConfigInterface = { defaults: config.global_default, agents: config.agent_defaults, csv: { output_path: config.csv_file }, - pricing: { - default: { - input: 0.003, - output: 0.015, - cache_read: 0.00075, - cache_write: 0.00375, - }, - periods: [ - { - from: "2024-01-01", - models: { - "claude-opus": { - input: 0.015, - output: 0.075, - cache_read: 0.00375, - cache_write: 0.01875, - }, - "claude-sonnet": { - input: 0.003, - output: 0.015, - cache_read: 0.00075, - cache_write: 0.00375, - }, - "claude-haiku": { - input: 0.00080, - output: 0.004, - cache_read: 0.0002, - cache_write: 0.001, - }, - }, - }, - ], - }, + pricing: (config as any).pricing || defaultPricing, valid_projects: config.valid_projects || [], ...(summaryConfig && { summary: summaryConfig }), } @@ -249,9 +253,9 @@ export function createEventHook( const durationSeconds = Math.round((Date.now() - session.startTime) / 1000) const minutes = Math.round(durationSeconds / 60) const totalTokens = - session.tokenUsage.input + - session.tokenUsage.output + - session.tokenUsage.reasoning + (session.tokenUsage.input ?? 0) + + (session.tokenUsage.output ?? 0) + + (session.tokenUsage.reasoning ?? 0) const failedWriters = results.filter((r) => !r.success) let message = `Time tracked: ${minutes} min, ${totalTokens} tokens${resolved.ticket ? ` for ${resolved.ticket}` : ""}` diff --git a/src/utils/ConfigMigration.ts b/src/utils/ConfigMigration.ts index e9d9762..f602964 100644 --- a/src/utils/ConfigMigration.ts +++ b/src/utils/ConfigMigration.ts @@ -43,15 +43,24 @@ export function resolveSummaryConfig( config: TimeTrackingJsonConfig ): SessionSummaryConfigInterface | undefined { // Prefer new 'summary' field over deprecated 'title_generation' - if (config.summary) { - return config.summary + let summaryConfig = config.summary || (config as any).title_generation + + if (!summaryConfig) { + return undefined + } + + // Check if explicitly disabled + if (summaryConfig.enabled === false) { + return undefined } - // Fall back to deprecated 'title_generation' for backward compatibility - if ((config as any).title_generation) { - return (config as any).title_generation + // Validate required fields for LLM summary generation + if (!summaryConfig.model || !summaryConfig.api_url) { + console.warn( + "[TimeTracking] Summary config incomplete: model and api_url are required for LLM summary generation. Summary generation disabled." + ) + return undefined } - // Neither field is present - return undefined + return summaryConfig } diff --git a/tools/track-time.ts b/tools/track-time.ts deleted file mode 100644 index edc7a27..0000000 --- a/tools/track-time.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import fs from "fs" -import path from "path" -import os from "os" -import { randomUUID } from "crypto" - -import type { CsvEntryData } from "../src/types/CsvEntryData" -import type { WriteResultInterface } from "@techdivision/lib-ts-time-tracking" - -// Note: CsvWriter and WebhookSender are now in lib-ts-time-tracking -// For this tool, we'll use a simplified approach without them -// since the main plugin uses TimeTrackingFacade - -interface TimeTrackingConfig { - csv_file: string - default_account_key: string - user_email: string - agent_defaults?: Record - global_default?: { issue_key?: string; account_key?: string; author_email?: string } -} - -interface ProjectConfig { - time_tracking?: TimeTrackingConfig -} - -/** - * Parses duration string to seconds. - * Supports: 30m, 1.5h, 1h30m, 01:30 - */ -function parseDuration(duration: string): number { - // Format: HH:MM - if (/^\d{1,2}:\d{2}$/.test(duration)) { - const [hours, minutes] = duration.split(":").map(Number) - return hours * 3600 + minutes * 60 - } - - // Format: 1h30m, 1.5h, 30m - let totalSeconds = 0 - - // Extract hours (1h, 1.5h) - const hoursMatch = duration.match(/(\d+(?:\.\d+)?)\s*h/i) - if (hoursMatch) { - totalSeconds += parseFloat(hoursMatch[1]) * 3600 - } - - // Extract minutes (30m) - const minutesMatch = duration.match(/(\d+)\s*m/i) - if (minutesMatch) { - totalSeconds += parseInt(minutesMatch[1], 10) * 60 - } - - return totalSeconds -} - -/** - * Formats seconds to human-readable duration. - */ -function formatDuration(seconds: number): string { - const hours = Math.floor(seconds / 3600) - const minutes = Math.floor((seconds % 3600) / 60) - - if (hours > 0 && minutes > 0) { - return `${hours}h ${minutes}m` - } else if (hours > 0) { - return `${hours}h` - } else { - return `${minutes}m` - } -} - -/** - * Adds seconds to a time string (HH:MM:SS) and returns new time. - * Returns { time: string, nextDay: boolean } - */ -function addSecondsToTime( - timeStr: string, - seconds: number -): { time: string; nextDay: boolean } { - const [h, m, s] = timeStr.split(":").map(Number) - let totalSeconds = h * 3600 + m * 60 + s + seconds - - const nextDay = totalSeconds >= 86400 - if (nextDay) { - totalSeconds -= 86400 - } - - const newH = Math.floor(totalSeconds / 3600) - const newM = Math.floor((totalSeconds % 3600) / 60) - const newS = totalSeconds % 60 - - return { - time: `${String(newH).padStart(2, "0")}:${String(newM).padStart(2, "0")}:${String(newS).padStart(2, "0")}`, - nextDay, - } -} - -/** - * Adds days to a date string (YYYY-MM-DD). - */ -function addDaysToDate(dateStr: string, days: number): string { - const date = new Date(dateStr) - date.setDate(date.getDate() + days) - return date.toISOString().split("T")[0] -} - -/** - * Gets today's date in YYYY-MM-DD format. - */ -function getTodayDate(): string { - return new Date().toISOString().split("T")[0] -} - -/** - * Gets current time in HH:MM:SS format (with seconds). - */ -function getCurrentTimeWithSeconds(): string { - const now = new Date() - return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}` -} - -/** - * Calculates duration in seconds between two times (HH:MM:SS format). - * Throws error if endTime is before startTime. - */ -function calculateDurationBetweenTimes( - startTime: string, - endTime: string -): number { - const [sh, sm, ss] = startTime.split(":").map(Number) - const [eh, em, es] = endTime.split(":").map(Number) - - const startSeconds = sh * 3600 + sm * 60 + (ss || 0) - const endSeconds = eh * 3600 + em * 60 + (es || 0) - - const duration = endSeconds - startSeconds - if (duration < 0) { - throw new Error( - `Invalid time range: end_time of last entry (${startTime}) is after current time (${endTime}). Cannot calculate negative duration.` - ) - } - return duration -} - -/** - * Subtracts seconds from a time string (HH:MM:SS) and returns new time. - */ -function subtractSecondsFromTime(timeStr: string, seconds: number): string { - const [h, m, s] = timeStr.split(":").map(Number) - let totalSeconds = h * 3600 + m * 60 + (s || 0) - seconds - - if (totalSeconds < 0) { - totalSeconds = 0 - } - - const newH = Math.floor(totalSeconds / 3600) - const newM = Math.floor((totalSeconds % 3600) / 60) - const newS = totalSeconds % 60 - - return `${String(newH).padStart(2, "0")}:${String(newM).padStart(2, "0")}:${String(newS).padStart(2, "0")}` -} - -/** - * Expands ~ to home directory. - */ -function expandPath(filePath: string): string { - if (filePath.startsWith("~/")) { - return path.join(os.homedir(), filePath.slice(2)) - } - return filePath -} - -/** - * Reads the last entry's end_time for today from the CSV file. - */ -function getLastEndTimeToday(csvFile: string, today: string): string | null { - if (!fs.existsSync(csvFile)) { - return null - } - - const content = fs.readFileSync(csvFile, "utf-8") - const lines = content.trim().split("\n") - - // Skip header, iterate from end to find last entry for today - for (let i = lines.length - 1; i >= 1; i--) { - const line = lines[i] - // Parse CSV - all fields are quoted - const fields = line.match(/"([^"]*)"/g)?.map((f) => f.slice(1, -1)) || [] - - if (fields.length >= 9) { - const startDate = fields[1] // start_date is field index 1 - if (startDate === today) { - const endTime = fields[8] // end_time is field index 8 - // Return full HH:MM:SS - return endTime - } - } - } - - return null -} - -/** - * Converts time string (HH:MM:SS) and date string (YYYY-MM-DD) to Unix timestamp in milliseconds. - */ -function toTimestamp(dateStr: string, timeStr: string): number { - const [year, month, day] = dateStr.split("-").map(Number) - const [hours, minutes, seconds] = timeStr.split(":").map(Number) - return new Date(year, month - 1, day, hours, minutes, seconds || 0).getTime() -} - -export default tool({ - description: - "Record a time tracking entry to CSV file and webhook. Automatically captures the calling agent. Requires config in .opencode/opencode-project.json with time_tracking section.", - args: { - issue_key: tool.schema - .string() - .optional() - .describe("JIRA issue key e.g. PROJ-123 (default: from config)"), - description: tool.schema - .string() - .optional() - .describe("Work description (default: n/a)"), - duration: tool.schema - .string() - .optional() - .describe( - "Duration: 30m, 1.5h, 1h30m, 01:30 (default: time since last entry or 15m)" - ), - start_time: tool.schema - .string() - .optional() - .describe( - "Start time HH:MM 24h format (default: end_time of last entry today)" - ), - account_key: tool.schema - .string() - .optional() - .describe("Tempo account key (default: from config)"), - model: tool.schema - .string() - .optional() - .describe( - "Model ID in format provider/model (e.g., anthropic/claude-opus-4-5)" - ), - }, - async execute(args, context) { - const { agent, directory } = context - - // Defensive checks for required context values - if (!directory) { - throw new Error( - "Missing 'directory' in tool context. This is an OpenCode internal error." - ) - } - - // 1. Load project config from .opencode/opencode-project.json - const projectConfigPath = path.join( - directory, - ".opencode", - "opencode-project.json" - ) - if (!fs.existsSync(projectConfigPath)) { - throw new Error( - `Configuration missing: ${projectConfigPath} not found. Please run /time-tracking.init first or create the file manually with a time_tracking section.` - ) - } - - let projectConfig: ProjectConfig - try { - projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, "utf-8")) - } catch (e) { - throw new Error( - `Failed to parse ${projectConfigPath}: ${(e as Error).message}` - ) - } - - const timeTracking = projectConfig.time_tracking - if (!timeTracking) { - throw new Error( - `Missing time_tracking section in ${projectConfigPath}. Please run /time-tracking.init first.` - ) - } - - if (!timeTracking.csv_file) { - throw new Error( - `Missing time_tracking.csv_file in ${projectConfigPath}. Please run /time-tracking.init.` - ) - } - - if (!timeTracking.global_default) { - throw new Error( - `Missing time_tracking.global_default in ${projectConfigPath}. Please run /time-tracking.init.` - ) - } - - // 2. Get model from argument - const model = args.model || null - - // 3. Get user from environment - const userEmail = - process.env.OPENCODE_USER_EMAIL || os.userInfo().username || "unknown" - - // 4. Resolve defaults based on agent - const agentName = agent || "unknown" - const agentKey = agentName.startsWith("@") ? agentName : `@${agentName}` - const agentDefaults = timeTracking.agent_defaults?.[agentKey] - const globalDefault = timeTracking.global_default - - // 5. Parse and apply defaults for all arguments - - // Issue key (resolve first as it doesn't depend on time calculations) - let issueKey: string | null = args.issue_key || null - if (!issueKey) { - issueKey = - agentDefaults?.issue_key || globalDefault?.issue_key || null - } - if (issueKey && !/^[A-Z]+-\d+$/.test(issueKey)) { - throw new Error( - `Invalid issue_key format: ${issueKey}. Expected format like PROJ-123.` - ) - } - - // Description - const description = args.description || "n/a" - - // Account key - let accountKey = args.account_key - if (!accountKey) { - accountKey = agentDefaults?.account_key || globalDefault.account_key || "" - } - - // Time calculations - const today = getTodayDate() - const csvFile = expandPath(timeTracking.csv_file) - const currentTime = getCurrentTimeWithSeconds() // Current time with seconds (HH:MM:SS) - - // Get last entry's end_time for today (HH:MM:SS) - const lastEndTime = getLastEndTimeToday(csvFile, today) - - // Resolve start_time, duration, and end_time based on what's provided - let startTimeFormatted: string - let durationSeconds: number - let endTimeFormatted: string - let endDate = today - const startDate = today - - if (args.duration) { - // Duration explicitly provided - validate format - if ( - !/^(\d+(?:\.\d+)?h)?(\d+m)?$|^\d{1,2}:\d{2}$|^\d+(?:\.\d+)?h$|^\d+m$/.test( - args.duration - ) - ) { - throw new Error( - `Invalid duration format: ${args.duration}. Use formats like: 30m, 1.5h, 1h30m, 01:30` - ) - } - durationSeconds = parseDuration(args.duration) - - if (args.start_time) { - // Both duration and start_time provided - calculate end_time - if (!/^\d{2}:\d{2}$/.test(args.start_time)) { - throw new Error( - `Invalid start_time format: ${args.start_time}. Expected HH:MM (24-hour format).` - ) - } - startTimeFormatted = `${args.start_time}:00` - const result = addSecondsToTime(startTimeFormatted, durationSeconds) - endTimeFormatted = result.time - if (result.nextDay) { - endDate = addDaysToDate(startDate, 1) - } - } else { - // Duration provided, no start_time - end at current time, calculate start - endTimeFormatted = currentTime - startTimeFormatted = subtractSecondsFromTime( - currentTime, - durationSeconds - ) - } - } else { - // No duration provided - use smart calculation - if (args.start_time) { - // start_time provided, no duration - end at current time, calculate duration - if (!/^\d{2}:\d{2}$/.test(args.start_time)) { - throw new Error( - `Invalid start_time format: ${args.start_time}. Expected HH:MM (24-hour format).` - ) - } - startTimeFormatted = `${args.start_time}:00` - endTimeFormatted = currentTime - durationSeconds = calculateDurationBetweenTimes( - startTimeFormatted, - endTimeFormatted - ) - } else if (lastEndTime) { - // No duration, no start_time, but have last entry - seamless continuation - // start_time = last end_time, end_time = now, duration = difference - startTimeFormatted = lastEndTime - endTimeFormatted = currentTime - durationSeconds = calculateDurationBetweenTimes( - startTimeFormatted, - endTimeFormatted - ) - } else { - // No duration, no start_time, no last entry - fallback to 15m - durationSeconds = parseDuration("15m") - endTimeFormatted = currentTime - startTimeFormatted = subtractSecondsFromTime( - currentTime, - durationSeconds - ) - } - } - - // 6. Resolve author_email with fallback hierarchy - const authorEmail = - agentDefaults?.author_email || - globalDefault?.author_email || - userEmail - - // 7. Build CsvEntryData for WriterService - const entryData: CsvEntryData = { - id: randomUUID(), - userEmail, - ticket: issueKey, - accountKey, - authorEmail, - startTime: toTimestamp(startDate, startTimeFormatted), - endTime: toTimestamp(endDate, endTimeFormatted), - durationSeconds, - description, - notes: "Manual entry", - tokenUsage: { - input: 0, - output: 0, - reasoning: 0, - cacheRead: 0, - cacheWrite: 0, - }, - cost: 0, - model, - agent: agentName, - } - - // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking - // This tool is deprecated in favor of the automatic time tracking via the plugin - // For manual entries, use the plugin's configuration instead - - // For now, return a message indicating the tool should use the plugin - const results: WriteResultInterface[] = [] - const allSucceeded = false - const failedWriters: WriteResultInterface[] = [{ - success: false, - writer: "track-time", - error: "This tool is deprecated. Use the automatic time tracking plugin instead." - }] - - return JSON.stringify({ - success: allSucceeded, - entry: { - id: entryData.id, - issue_key: issueKey, - date: startDate, - start_time: startTimeFormatted, - end_time: endTimeFormatted, - duration: formatDuration(durationSeconds), - duration_seconds: durationSeconds, - account_key: accountKey, - description, - model, - agent: agentName, - csv_file: csvFile, - }, - writers: results, - ...(failedWriters.length > 0 && { - warnings: failedWriters.map( - (r) => `${r.writer}: ${r.error || "unknown error"}` - ), - }), - }) - }, -}) From 22a3a48d75b6b9aeb73ace4ceedb5fb6ed975066 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 24 Apr 2026 15:08:17 +0200 Subject: [PATCH 11/29] COPSPA-489: Fix npm --- .gitignore | 4 ---- .npmrc | 3 +++ .npmrc.example | 10 ---------- 3 files changed, 3 insertions(+), 14 deletions(-) create mode 100644 .npmrc delete mode 100644 .npmrc.example diff --git a/.gitignore b/.gitignore index c080eae..3e5ba3e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,3 @@ node_modules/ .DS_Store bun.lock tmp/ - -# NPM authentication (GitHub Packages) -.npmrc -!.npmrc.example diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..3fff4d7 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +@techdivision:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} + diff --git a/.npmrc.example b/.npmrc.example deleted file mode 100644 index 40fd410..0000000 --- a/.npmrc.example +++ /dev/null @@ -1,10 +0,0 @@ -# GitHub Packages Configuration -# Copy this file to .npmrc and replace YOUR_GITHUB_TOKEN with your actual token -# -# To generate a token: -# 1. Go to https://github.com/settings/tokens/new -# 2. Select scope: read:packages -# 3. Copy the token and paste it below - -@techdivision:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN From 359debc4eaf2629ee0fe311048f9f685ee235246 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 24 Apr 2026 16:07:21 +0200 Subject: [PATCH 12/29] COPSP-489: Revert --- tools/track-time.ts | 483 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 tools/track-time.ts diff --git a/tools/track-time.ts b/tools/track-time.ts new file mode 100644 index 0000000..edc7a27 --- /dev/null +++ b/tools/track-time.ts @@ -0,0 +1,483 @@ +import { tool } from "@opencode-ai/plugin" +import fs from "fs" +import path from "path" +import os from "os" +import { randomUUID } from "crypto" + +import type { CsvEntryData } from "../src/types/CsvEntryData" +import type { WriteResultInterface } from "@techdivision/lib-ts-time-tracking" + +// Note: CsvWriter and WebhookSender are now in lib-ts-time-tracking +// For this tool, we'll use a simplified approach without them +// since the main plugin uses TimeTrackingFacade + +interface TimeTrackingConfig { + csv_file: string + default_account_key: string + user_email: string + agent_defaults?: Record + global_default?: { issue_key?: string; account_key?: string; author_email?: string } +} + +interface ProjectConfig { + time_tracking?: TimeTrackingConfig +} + +/** + * Parses duration string to seconds. + * Supports: 30m, 1.5h, 1h30m, 01:30 + */ +function parseDuration(duration: string): number { + // Format: HH:MM + if (/^\d{1,2}:\d{2}$/.test(duration)) { + const [hours, minutes] = duration.split(":").map(Number) + return hours * 3600 + minutes * 60 + } + + // Format: 1h30m, 1.5h, 30m + let totalSeconds = 0 + + // Extract hours (1h, 1.5h) + const hoursMatch = duration.match(/(\d+(?:\.\d+)?)\s*h/i) + if (hoursMatch) { + totalSeconds += parseFloat(hoursMatch[1]) * 3600 + } + + // Extract minutes (30m) + const minutesMatch = duration.match(/(\d+)\s*m/i) + if (minutesMatch) { + totalSeconds += parseInt(minutesMatch[1], 10) * 60 + } + + return totalSeconds +} + +/** + * Formats seconds to human-readable duration. + */ +function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + + if (hours > 0 && minutes > 0) { + return `${hours}h ${minutes}m` + } else if (hours > 0) { + return `${hours}h` + } else { + return `${minutes}m` + } +} + +/** + * Adds seconds to a time string (HH:MM:SS) and returns new time. + * Returns { time: string, nextDay: boolean } + */ +function addSecondsToTime( + timeStr: string, + seconds: number +): { time: string; nextDay: boolean } { + const [h, m, s] = timeStr.split(":").map(Number) + let totalSeconds = h * 3600 + m * 60 + s + seconds + + const nextDay = totalSeconds >= 86400 + if (nextDay) { + totalSeconds -= 86400 + } + + const newH = Math.floor(totalSeconds / 3600) + const newM = Math.floor((totalSeconds % 3600) / 60) + const newS = totalSeconds % 60 + + return { + time: `${String(newH).padStart(2, "0")}:${String(newM).padStart(2, "0")}:${String(newS).padStart(2, "0")}`, + nextDay, + } +} + +/** + * Adds days to a date string (YYYY-MM-DD). + */ +function addDaysToDate(dateStr: string, days: number): string { + const date = new Date(dateStr) + date.setDate(date.getDate() + days) + return date.toISOString().split("T")[0] +} + +/** + * Gets today's date in YYYY-MM-DD format. + */ +function getTodayDate(): string { + return new Date().toISOString().split("T")[0] +} + +/** + * Gets current time in HH:MM:SS format (with seconds). + */ +function getCurrentTimeWithSeconds(): string { + const now = new Date() + return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}` +} + +/** + * Calculates duration in seconds between two times (HH:MM:SS format). + * Throws error if endTime is before startTime. + */ +function calculateDurationBetweenTimes( + startTime: string, + endTime: string +): number { + const [sh, sm, ss] = startTime.split(":").map(Number) + const [eh, em, es] = endTime.split(":").map(Number) + + const startSeconds = sh * 3600 + sm * 60 + (ss || 0) + const endSeconds = eh * 3600 + em * 60 + (es || 0) + + const duration = endSeconds - startSeconds + if (duration < 0) { + throw new Error( + `Invalid time range: end_time of last entry (${startTime}) is after current time (${endTime}). Cannot calculate negative duration.` + ) + } + return duration +} + +/** + * Subtracts seconds from a time string (HH:MM:SS) and returns new time. + */ +function subtractSecondsFromTime(timeStr: string, seconds: number): string { + const [h, m, s] = timeStr.split(":").map(Number) + let totalSeconds = h * 3600 + m * 60 + (s || 0) - seconds + + if (totalSeconds < 0) { + totalSeconds = 0 + } + + const newH = Math.floor(totalSeconds / 3600) + const newM = Math.floor((totalSeconds % 3600) / 60) + const newS = totalSeconds % 60 + + return `${String(newH).padStart(2, "0")}:${String(newM).padStart(2, "0")}:${String(newS).padStart(2, "0")}` +} + +/** + * Expands ~ to home directory. + */ +function expandPath(filePath: string): string { + if (filePath.startsWith("~/")) { + return path.join(os.homedir(), filePath.slice(2)) + } + return filePath +} + +/** + * Reads the last entry's end_time for today from the CSV file. + */ +function getLastEndTimeToday(csvFile: string, today: string): string | null { + if (!fs.existsSync(csvFile)) { + return null + } + + const content = fs.readFileSync(csvFile, "utf-8") + const lines = content.trim().split("\n") + + // Skip header, iterate from end to find last entry for today + for (let i = lines.length - 1; i >= 1; i--) { + const line = lines[i] + // Parse CSV - all fields are quoted + const fields = line.match(/"([^"]*)"/g)?.map((f) => f.slice(1, -1)) || [] + + if (fields.length >= 9) { + const startDate = fields[1] // start_date is field index 1 + if (startDate === today) { + const endTime = fields[8] // end_time is field index 8 + // Return full HH:MM:SS + return endTime + } + } + } + + return null +} + +/** + * Converts time string (HH:MM:SS) and date string (YYYY-MM-DD) to Unix timestamp in milliseconds. + */ +function toTimestamp(dateStr: string, timeStr: string): number { + const [year, month, day] = dateStr.split("-").map(Number) + const [hours, minutes, seconds] = timeStr.split(":").map(Number) + return new Date(year, month - 1, day, hours, minutes, seconds || 0).getTime() +} + +export default tool({ + description: + "Record a time tracking entry to CSV file and webhook. Automatically captures the calling agent. Requires config in .opencode/opencode-project.json with time_tracking section.", + args: { + issue_key: tool.schema + .string() + .optional() + .describe("JIRA issue key e.g. PROJ-123 (default: from config)"), + description: tool.schema + .string() + .optional() + .describe("Work description (default: n/a)"), + duration: tool.schema + .string() + .optional() + .describe( + "Duration: 30m, 1.5h, 1h30m, 01:30 (default: time since last entry or 15m)" + ), + start_time: tool.schema + .string() + .optional() + .describe( + "Start time HH:MM 24h format (default: end_time of last entry today)" + ), + account_key: tool.schema + .string() + .optional() + .describe("Tempo account key (default: from config)"), + model: tool.schema + .string() + .optional() + .describe( + "Model ID in format provider/model (e.g., anthropic/claude-opus-4-5)" + ), + }, + async execute(args, context) { + const { agent, directory } = context + + // Defensive checks for required context values + if (!directory) { + throw new Error( + "Missing 'directory' in tool context. This is an OpenCode internal error." + ) + } + + // 1. Load project config from .opencode/opencode-project.json + const projectConfigPath = path.join( + directory, + ".opencode", + "opencode-project.json" + ) + if (!fs.existsSync(projectConfigPath)) { + throw new Error( + `Configuration missing: ${projectConfigPath} not found. Please run /time-tracking.init first or create the file manually with a time_tracking section.` + ) + } + + let projectConfig: ProjectConfig + try { + projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, "utf-8")) + } catch (e) { + throw new Error( + `Failed to parse ${projectConfigPath}: ${(e as Error).message}` + ) + } + + const timeTracking = projectConfig.time_tracking + if (!timeTracking) { + throw new Error( + `Missing time_tracking section in ${projectConfigPath}. Please run /time-tracking.init first.` + ) + } + + if (!timeTracking.csv_file) { + throw new Error( + `Missing time_tracking.csv_file in ${projectConfigPath}. Please run /time-tracking.init.` + ) + } + + if (!timeTracking.global_default) { + throw new Error( + `Missing time_tracking.global_default in ${projectConfigPath}. Please run /time-tracking.init.` + ) + } + + // 2. Get model from argument + const model = args.model || null + + // 3. Get user from environment + const userEmail = + process.env.OPENCODE_USER_EMAIL || os.userInfo().username || "unknown" + + // 4. Resolve defaults based on agent + const agentName = agent || "unknown" + const agentKey = agentName.startsWith("@") ? agentName : `@${agentName}` + const agentDefaults = timeTracking.agent_defaults?.[agentKey] + const globalDefault = timeTracking.global_default + + // 5. Parse and apply defaults for all arguments + + // Issue key (resolve first as it doesn't depend on time calculations) + let issueKey: string | null = args.issue_key || null + if (!issueKey) { + issueKey = + agentDefaults?.issue_key || globalDefault?.issue_key || null + } + if (issueKey && !/^[A-Z]+-\d+$/.test(issueKey)) { + throw new Error( + `Invalid issue_key format: ${issueKey}. Expected format like PROJ-123.` + ) + } + + // Description + const description = args.description || "n/a" + + // Account key + let accountKey = args.account_key + if (!accountKey) { + accountKey = agentDefaults?.account_key || globalDefault.account_key || "" + } + + // Time calculations + const today = getTodayDate() + const csvFile = expandPath(timeTracking.csv_file) + const currentTime = getCurrentTimeWithSeconds() // Current time with seconds (HH:MM:SS) + + // Get last entry's end_time for today (HH:MM:SS) + const lastEndTime = getLastEndTimeToday(csvFile, today) + + // Resolve start_time, duration, and end_time based on what's provided + let startTimeFormatted: string + let durationSeconds: number + let endTimeFormatted: string + let endDate = today + const startDate = today + + if (args.duration) { + // Duration explicitly provided - validate format + if ( + !/^(\d+(?:\.\d+)?h)?(\d+m)?$|^\d{1,2}:\d{2}$|^\d+(?:\.\d+)?h$|^\d+m$/.test( + args.duration + ) + ) { + throw new Error( + `Invalid duration format: ${args.duration}. Use formats like: 30m, 1.5h, 1h30m, 01:30` + ) + } + durationSeconds = parseDuration(args.duration) + + if (args.start_time) { + // Both duration and start_time provided - calculate end_time + if (!/^\d{2}:\d{2}$/.test(args.start_time)) { + throw new Error( + `Invalid start_time format: ${args.start_time}. Expected HH:MM (24-hour format).` + ) + } + startTimeFormatted = `${args.start_time}:00` + const result = addSecondsToTime(startTimeFormatted, durationSeconds) + endTimeFormatted = result.time + if (result.nextDay) { + endDate = addDaysToDate(startDate, 1) + } + } else { + // Duration provided, no start_time - end at current time, calculate start + endTimeFormatted = currentTime + startTimeFormatted = subtractSecondsFromTime( + currentTime, + durationSeconds + ) + } + } else { + // No duration provided - use smart calculation + if (args.start_time) { + // start_time provided, no duration - end at current time, calculate duration + if (!/^\d{2}:\d{2}$/.test(args.start_time)) { + throw new Error( + `Invalid start_time format: ${args.start_time}. Expected HH:MM (24-hour format).` + ) + } + startTimeFormatted = `${args.start_time}:00` + endTimeFormatted = currentTime + durationSeconds = calculateDurationBetweenTimes( + startTimeFormatted, + endTimeFormatted + ) + } else if (lastEndTime) { + // No duration, no start_time, but have last entry - seamless continuation + // start_time = last end_time, end_time = now, duration = difference + startTimeFormatted = lastEndTime + endTimeFormatted = currentTime + durationSeconds = calculateDurationBetweenTimes( + startTimeFormatted, + endTimeFormatted + ) + } else { + // No duration, no start_time, no last entry - fallback to 15m + durationSeconds = parseDuration("15m") + endTimeFormatted = currentTime + startTimeFormatted = subtractSecondsFromTime( + currentTime, + durationSeconds + ) + } + } + + // 6. Resolve author_email with fallback hierarchy + const authorEmail = + agentDefaults?.author_email || + globalDefault?.author_email || + userEmail + + // 7. Build CsvEntryData for WriterService + const entryData: CsvEntryData = { + id: randomUUID(), + userEmail, + ticket: issueKey, + accountKey, + authorEmail, + startTime: toTimestamp(startDate, startTimeFormatted), + endTime: toTimestamp(endDate, endTimeFormatted), + durationSeconds, + description, + notes: "Manual entry", + tokenUsage: { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + cost: 0, + model, + agent: agentName, + } + + // 8. Note: Writers (CsvWriter, WebhookSender) are now in lib-ts-time-tracking + // This tool is deprecated in favor of the automatic time tracking via the plugin + // For manual entries, use the plugin's configuration instead + + // For now, return a message indicating the tool should use the plugin + const results: WriteResultInterface[] = [] + const allSucceeded = false + const failedWriters: WriteResultInterface[] = [{ + success: false, + writer: "track-time", + error: "This tool is deprecated. Use the automatic time tracking plugin instead." + }] + + return JSON.stringify({ + success: allSucceeded, + entry: { + id: entryData.id, + issue_key: issueKey, + date: startDate, + start_time: startTimeFormatted, + end_time: endTimeFormatted, + duration: formatDuration(durationSeconds), + duration_seconds: durationSeconds, + account_key: accountKey, + description, + model, + agent: agentName, + csv_file: csvFile, + }, + writers: results, + ...(failedWriters.length > 0 && { + warnings: failedWriters.map( + (r) => `${r.writer}: ${r.error || "unknown error"}` + ), + }), + }) + }, +}) From dc57aff24c42181c6e8d86d40d36b5b9d1e9df32 Mon Sep 17 00:00:00 2001 From: Patrick Date: Fri, 24 Apr 2026 16:34:26 +0200 Subject: [PATCH 13/29] COPSPA-489 --- src/utils/ConfigMigration.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/ConfigMigration.ts b/src/utils/ConfigMigration.ts index f602964..78b65d9 100644 --- a/src/utils/ConfigMigration.ts +++ b/src/utils/ConfigMigration.ts @@ -43,7 +43,8 @@ export function resolveSummaryConfig( config: TimeTrackingJsonConfig ): SessionSummaryConfigInterface | undefined { // Prefer new 'summary' field over deprecated 'title_generation' - let summaryConfig = config.summary || (config as any).title_generation + // Use (config as any) to bypass TypeScript type checking and ?? for nullish coalescing + let summaryConfig = (config as any).summary ?? (config as any).title_generation if (!summaryConfig) { return undefined From a0b84ad479aeb9828827bda51e8eb42ccf937d64 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 09:36:31 +0200 Subject: [PATCH 14/29] chore: use local lib-ts-time-tracking with checkAvailability fix Updated to use the local lib-ts-time-tracking which includes the fix for returning actual summary generator availability status instead of hardcoded false. This resolves the 'title generation NOT available' toast message issue. --- package-lock.json | 158 +++++++++++++++++++++++++--------------------- package.json | 10 ++- 2 files changed, 94 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 86fdd4b..b678260 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "1.2.15", - "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#5fce0dc" + "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" }, "devDependencies": { "@types/bun": "latest", @@ -21,6 +21,24 @@ "bun": ">=1.0.0" } }, + "../lib-ts-time-tracking": { + "name": "@techdivision/lib-ts-time-tracking", + "version": "4.2.0", + "license": "MIT", + "devDependencies": { + "@amiceli/vitest-cucumber": "^5.2.1", + "@types/node": "^22.0.0", + "eslint": "^10.1.0", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.2", + "vitest": "^3.2.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@opencode-ai/plugin": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.15.tgz", @@ -38,9 +56,9 @@ "license": "MIT" }, "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.10.tgz", - "integrity": "sha512-PXgg5gqcS/rHwa1hF0JdM1y5TiyejVrMHoBmWY/DjtfYZoFTXie1RCFOkoG0b5diOOmUcuYarMpH7CSNTqwj+w==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.13.tgz", + "integrity": "sha512-qAS6Hg8Q14ckfBuqJ2Zh7gBQSVSUHeibSq4OFqBTv6DzyJuxYlr0sdYQzmYmnbPxbqobekqUDTa/4XEaqRi7vg==", "cpu": [ "arm64" ], @@ -52,9 +70,9 @@ "peer": true }, "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.10.tgz", - "integrity": "sha512-Nhssuh7GBpP5PiDSOl3+qnoIG7PJo+ec2oomDevnl9pRY6x6aD2gRt0JE+uf+A8Om2D6gjeHCxjEdrw5ZHE8mA==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.13.tgz", + "integrity": "sha512-kGePeDD4IN4imo+H4uLjQGZLmvyYQg+nKr2P0nt4ksXXrWA4HE+mb0/TUPHfRI127DocXQpew+fvrHuHR5mpJQ==", "cpu": [ "x64" ], @@ -66,9 +84,9 @@ "peer": true }, "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.10.tgz", - "integrity": "sha512-w1gaTlqU0IJCmJ1X+PGHkdNU1n8Gemx5YKkjhkJIguvFINXEBB5U1KG82QsT65Tk4KyNMfbLTlmy4giAvUoKfA==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.13.tgz", + "integrity": "sha512-gMEQayUpmCPYaE9zkNBj9TiQqHupnhjOYcuSzxFjzIjHJBUO4VjNnrpbKVeXNs+rKHFothORDd2QKquu5paSPQ==", "cpu": [ "x64" ], @@ -80,9 +98,9 @@ "peer": true }, "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.10.tgz", - "integrity": "sha512-OUgPHfL6+PM2Q+tFZjcaycN3D7gdQdYlWnwMI31DXZKY1r4HINWk9aEz9t/rNaHg65edwNrt7dsv9TF7xK8xIA==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.13.tgz", + "integrity": "sha512-NbLOJdr+RBFO1vFZ2YUFg4oVJ+2ua6zrwo4ZWRs0jKKcGJWtbY2wY5uz+i0PkwH6b9HYaYDgVTzE4ev06ncYZw==", "cpu": [ "arm64" ], @@ -94,9 +112,9 @@ "peer": true }, "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.10.tgz", - "integrity": "sha512-Ui5pAgM7JE9MzHokF0VglRMkbak3lTisY4Mf1AZutPACXWgKJC5aGrgnHBfkl7QS6fEeYb0juy1q4eRznRHOsw==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.13.tgz", + "integrity": "sha512-UV9EE18VE5aRhWtV2L6MTAGGn3slhJJ2OW/m+FJM15maHm0qf1V7TaZY0FovxhdQRvnklSiQ7Ntv0H5TUX4w0g==", "cpu": [ "arm64" ], @@ -108,9 +126,9 @@ "peer": true }, "node_modules/@oven/bun-linux-x64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.10.tgz", - "integrity": "sha512-bzUgYj/PIZziB/ZesIP9HUyfvh6Vlf3od+TrbTTyVEuCSMKzDPQVW/yEbRp0tcHO3alwiEXwJDrWrHAguXlgiQ==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.13.tgz", + "integrity": "sha512-UwttIUXoe9fS+40OcjoaRHgZw+HCPFqBVWEXkXqAJ3W7wA0XPZrWsoMAD9sGh3TaLqrwdiMo5xPogwpXhOtVXA==", "cpu": [ "x64" ], @@ -122,9 +140,9 @@ "peer": true }, "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.10.tgz", - "integrity": "sha512-oqvMDYpX6dGJO03HgO5bXuccEsH3qbdO3MaAiAlO4CfkBPLUXz3N0DDElg5hz0L6ktdDVKbQVE5lfe+LAUISQg==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.13.tgz", + "integrity": "sha512-fOi4ziKzgJG4UrrNd4AicBs6Fu9GY5xOqg+9tC76nuZNDAdSh6++kzab6TNi1Ck0Yzq6zIBIdGit6/0uSbBn8A==", "cpu": [ "x64" ], @@ -136,9 +154,9 @@ "peer": true }, "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.10.tgz", - "integrity": "sha512-poVXvOShekbexHq45b4MH/mRjQKwACAC8lHp3Tz/hEDuz0/20oncqScnmKwzhBPEpqJvydXficXfBYuSim8opw==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.13.tgz", + "integrity": "sha512-+VHhE44kEjCXcTFHyc81zfTxL9+vzh9RqIh7gM1iWNhxpctD9kzntbUkP3UTFTwwNjoou1o8VRyxQafvc4OepA==", "cpu": [ "x64" ], @@ -150,9 +168,9 @@ "peer": true }, "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.10.tgz", - "integrity": "sha512-/hOZ6S1VsTX6vtbhWVL9aAnOrdpuO54mAGUWpTdMz7dFG5UBZ/VUEiK0pBkq9A1rlBk0GeD/6Y4NBFl8Ha7cRA==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.13.tgz", + "integrity": "sha512-fqBKuiiWLEu2dVkowZaXgKS98xfrvBqivdoxRtRP3eINcpI1dcelGbsOz+Xphn7tbGAuBiE1/0AelvvvdqS9rg==", "cpu": [ "x64" ], @@ -164,9 +182,9 @@ "peer": true }, "node_modules/@oven/bun-windows-aarch64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.10.tgz", - "integrity": "sha512-GXbz2swvN2DLw2dXZFeedMxSJtI64xQ9xp9Eg7Hjejg6mS2E4dP1xoQ2yAo2aZPi/2OBPAVaGzppI2q20XumHA==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.13.tgz", + "integrity": "sha512-+EvdRWRCRg95Xea4M2lqSJFTjzQBTJDQTMlbG8bmwFkVTN16MdmSH7xhfxVQWUOyZBLEpIwuNFIlBBxVCwSUyQ==", "cpu": [ "arm64" ], @@ -178,9 +196,9 @@ "peer": true }, "node_modules/@oven/bun-windows-x64": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.10.tgz", - "integrity": "sha512-qaS1In3yfC/Z/IGQriVmF8GWwKuNqiw7feTSJWaQhH5IbL6ENR+4wGNPniZSJFaM/SKUO0e/YCRdoVBvgU4C1g==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.13.tgz", + "integrity": "sha512-vqDEFX63ZZQF3YstPSpPD+RxNm5AILPdUuuKpNwsj7ld4NjhdHUYkAmLXDtKNWt9JMRL10bop//W8faY/LV+RQ==", "cpu": [ "x64" ], @@ -192,9 +210,9 @@ "peer": true }, "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.10.tgz", - "integrity": "sha512-gh3UAHbUdDUG6fhLc1Csa4IGdtghue6U8oAIXWnUqawp6lwb3gOCRvp25IUnLF5vUHtgfMxuEUYV7YA2WxVutw==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.13.tgz", + "integrity": "sha512-6gy4hhQSjq/T/S9hC9m3NxY0RY+9Ww+XNlB+8koIMTsMSYEjk7Ho+hFHQz1Bn4W61Ub7Vykufg+jgDgPfa2GFA==", "cpu": [ "x64" ], @@ -206,37 +224,33 @@ "peer": true }, "node_modules/@techdivision/lib-ts-time-tracking": { - "version": "4.2.0", - "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#7ab7d77b259bc22af12949eb01b4e6b2a0ad9dcb", - "license": "MIT", - "engines": { - "node": ">=20" - } + "resolved": "../lib-ts-time-tracking", + "link": true }, "node_modules/@types/bun": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", - "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.13.tgz", + "integrity": "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.3.9" + "bun-types": "1.3.13" } }, "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/bun": { - "version": "1.3.10", - "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.10.tgz", - "integrity": "sha512-S/CXaXXIyA4CMjdMkYQ4T2YMqnAn4s0ysD3mlsY4bUiOCqGlv28zck4Wd4H4kpvbekx15S9mUeLQ7Uxd0tYTLA==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.13.tgz", + "integrity": "sha512-b9T4xZ8KqCHs4+TkHJv540LG1B8OD7noKu0Qaizusx3jFtMDHY6osNqgbaOlwW2B8RB2AKzz+sjzlGKIGxIjZw==", "cpu": [ "arm64", "x64" @@ -254,24 +268,24 @@ "bunx": "bin/bunx.exe" }, "optionalDependencies": { - "@oven/bun-darwin-aarch64": "1.3.10", - "@oven/bun-darwin-x64": "1.3.10", - "@oven/bun-darwin-x64-baseline": "1.3.10", - "@oven/bun-linux-aarch64": "1.3.10", - "@oven/bun-linux-aarch64-musl": "1.3.10", - "@oven/bun-linux-x64": "1.3.10", - "@oven/bun-linux-x64-baseline": "1.3.10", - "@oven/bun-linux-x64-musl": "1.3.10", - "@oven/bun-linux-x64-musl-baseline": "1.3.10", - "@oven/bun-windows-aarch64": "1.3.10", - "@oven/bun-windows-x64": "1.3.10", - "@oven/bun-windows-x64-baseline": "1.3.10" + "@oven/bun-darwin-aarch64": "1.3.13", + "@oven/bun-darwin-x64": "1.3.13", + "@oven/bun-darwin-x64-baseline": "1.3.13", + "@oven/bun-linux-aarch64": "1.3.13", + "@oven/bun-linux-aarch64-musl": "1.3.13", + "@oven/bun-linux-x64": "1.3.13", + "@oven/bun-linux-x64-baseline": "1.3.13", + "@oven/bun-linux-x64-musl": "1.3.13", + "@oven/bun-linux-x64-musl-baseline": "1.3.13", + "@oven/bun-windows-aarch64": "1.3.13", + "@oven/bun-windows-x64": "1.3.13", + "@oven/bun-windows-x64-baseline": "1.3.13" } }, "node_modules/bun-types": { - "version": "1.3.9", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", - "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.13.tgz", + "integrity": "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==", "dev": true, "license": "MIT", "dependencies": { @@ -293,9 +307,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 657f95f..0d49c40 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,13 @@ "scripts/", "plugin.json" ], - "keywords": ["opencode", "plugin", "time-tracking", "jira", "tempo"], + "keywords": [ + "opencode", + "plugin", + "time-tracking", + "jira", + "tempo" + ], "opencode": { "plugin": true, "category": "standard" @@ -27,7 +33,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "1.2.15", - "@techdivision/lib-ts-time-tracking": "^4.2.0" + "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" }, "devDependencies": { "@types/bun": "latest", From 95031051c2d0998a50343cf2bc7da9be25b5fe95 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 09:41:37 +0200 Subject: [PATCH 15/29] fix: resolve environment variables in configuration Add EnvResolver utility to resolve {env:VAR_NAME} placeholders in config values. This ensures that environment variables like TT_AGENT_API_KEY are properly resolved before being passed to the TimeTrackingFacade. Changes: - Create EnvResolver.ts with resolveEnvVar() and resolveEnvVarsInObject() - Update ConfigLoader to resolve env vars in loaded config - Recursively resolves nested objects and arrays This fixes the issue where api_key was still {env:TT_AGENT_API_KEY} instead of the actual API key value, causing LLM summary generation to fail. --- src/services/ConfigLoader.ts | 6 ++- src/utils/EnvResolver.ts | 80 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/utils/EnvResolver.ts diff --git a/src/services/ConfigLoader.ts b/src/services/ConfigLoader.ts index 119c411..0e96ebb 100644 --- a/src/services/ConfigLoader.ts +++ b/src/services/ConfigLoader.ts @@ -8,6 +8,7 @@ import type { OpencodeProjectConfig, TimeTrackingConfig, } from "../types/TimeTrackingConfig" +import { resolveEnvVarsInObject } from "../utils/EnvResolver" import "../types/Bun" @@ -55,13 +56,16 @@ export class ConfigLoader { if (projectConfig.time_tracking) { const jsonConfig = projectConfig.time_tracking + // Resolve environment variables in config (e.g., {env:TT_AGENT_API_KEY}) + const resolvedConfig = resolveEnvVarsInObject(jsonConfig) + // Resolve user_email with fallback chain: // 1. Environment variable (loaded by opencode-plugin-shell-env from .env) // 2. System username const userEmail = process.env[ENV_USER_EMAIL] || userInfo().username return { - ...jsonConfig, + ...resolvedConfig, user_email: userEmail, } } diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts new file mode 100644 index 0000000..bad57f8 --- /dev/null +++ b/src/utils/EnvResolver.ts @@ -0,0 +1,80 @@ +/** + * @fileoverview Environment variable resolver for configuration values. + */ + +/** + * Resolves environment variable placeholders in configuration values. + * + * @remarks + * Replaces placeholders like `{env:VAR_NAME}` with actual environment variable values. + * If the environment variable is not set, returns the original placeholder. + * + * @param value - The value that may contain env placeholders + * @returns The resolved value with env vars substituted + * + * @example + * ```typescript + * process.env.API_KEY = "secret123" + * resolveEnvVar("{env:API_KEY}") // Returns "secret123" + * resolveEnvVar("normal-value") // Returns "normal-value" + * ``` + */ +export function resolveEnvVar(value: string | undefined): string | undefined { + if (!value) return value + + // Match {env:VAR_NAME} pattern + const envPattern = /\{env:([^}]+)\}/g + + return value.replace(envPattern, (match, varName) => { + const envValue = process.env[varName] + if (envValue) { + return envValue + } + // If env var not found, return the original placeholder + return match + }) +} + +/** + * Recursively resolves environment variables in an object. + * + * @remarks + * Walks through all string values in an object and resolves env placeholders. + * Non-string values are left unchanged. + * + * @param obj - The object to resolve + * @returns A new object with resolved env vars + * + * @example + * ```typescript + * const config = { + * api_key: "{env:API_KEY}", + * api_url: "https://api.example.com", + * nested: { + * token: "{env:TOKEN}" + * } + * } + * const resolved = resolveEnvVarsInObject(config) + * // resolved.api_key === "secret123" + * // resolved.nested.token === "my-token" + * ``` + */ +export function resolveEnvVarsInObject>(obj: T): T { + const resolved: any = {} + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string') { + resolved[key] = resolveEnvVar(value) + } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + resolved[key] = resolveEnvVarsInObject(value) + } else if (Array.isArray(value)) { + resolved[key] = value.map(item => + typeof item === 'string' ? resolveEnvVar(item) : item + ) + } else { + resolved[key] = value + } + } + + return resolved +} From a286c14155b13b2345053d567794a2595798caa9 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 09:45:01 +0200 Subject: [PATCH 16/29] Revert "fix: resolve environment variables in configuration" This reverts commit 95031051c2d0998a50343cf2bc7da9be25b5fe95. --- src/services/ConfigLoader.ts | 6 +-- src/utils/EnvResolver.ts | 80 ------------------------------------ 2 files changed, 1 insertion(+), 85 deletions(-) delete mode 100644 src/utils/EnvResolver.ts diff --git a/src/services/ConfigLoader.ts b/src/services/ConfigLoader.ts index 0e96ebb..119c411 100644 --- a/src/services/ConfigLoader.ts +++ b/src/services/ConfigLoader.ts @@ -8,7 +8,6 @@ import type { OpencodeProjectConfig, TimeTrackingConfig, } from "../types/TimeTrackingConfig" -import { resolveEnvVarsInObject } from "../utils/EnvResolver" import "../types/Bun" @@ -56,16 +55,13 @@ export class ConfigLoader { if (projectConfig.time_tracking) { const jsonConfig = projectConfig.time_tracking - // Resolve environment variables in config (e.g., {env:TT_AGENT_API_KEY}) - const resolvedConfig = resolveEnvVarsInObject(jsonConfig) - // Resolve user_email with fallback chain: // 1. Environment variable (loaded by opencode-plugin-shell-env from .env) // 2. System username const userEmail = process.env[ENV_USER_EMAIL] || userInfo().username return { - ...resolvedConfig, + ...jsonConfig, user_email: userEmail, } } diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts deleted file mode 100644 index bad57f8..0000000 --- a/src/utils/EnvResolver.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @fileoverview Environment variable resolver for configuration values. - */ - -/** - * Resolves environment variable placeholders in configuration values. - * - * @remarks - * Replaces placeholders like `{env:VAR_NAME}` with actual environment variable values. - * If the environment variable is not set, returns the original placeholder. - * - * @param value - The value that may contain env placeholders - * @returns The resolved value with env vars substituted - * - * @example - * ```typescript - * process.env.API_KEY = "secret123" - * resolveEnvVar("{env:API_KEY}") // Returns "secret123" - * resolveEnvVar("normal-value") // Returns "normal-value" - * ``` - */ -export function resolveEnvVar(value: string | undefined): string | undefined { - if (!value) return value - - // Match {env:VAR_NAME} pattern - const envPattern = /\{env:([^}]+)\}/g - - return value.replace(envPattern, (match, varName) => { - const envValue = process.env[varName] - if (envValue) { - return envValue - } - // If env var not found, return the original placeholder - return match - }) -} - -/** - * Recursively resolves environment variables in an object. - * - * @remarks - * Walks through all string values in an object and resolves env placeholders. - * Non-string values are left unchanged. - * - * @param obj - The object to resolve - * @returns A new object with resolved env vars - * - * @example - * ```typescript - * const config = { - * api_key: "{env:API_KEY}", - * api_url: "https://api.example.com", - * nested: { - * token: "{env:TOKEN}" - * } - * } - * const resolved = resolveEnvVarsInObject(config) - * // resolved.api_key === "secret123" - * // resolved.nested.token === "my-token" - * ``` - */ -export function resolveEnvVarsInObject>(obj: T): T { - const resolved: any = {} - - for (const [key, value] of Object.entries(obj)) { - if (typeof value === 'string') { - resolved[key] = resolveEnvVar(value) - } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) { - resolved[key] = resolveEnvVarsInObject(value) - } else if (Array.isArray(value)) { - resolved[key] = value.map(item => - typeof item === 'string' ? resolveEnvVar(item) : item - ) - } else { - resolved[key] = value - } - } - - return resolved -} From 156bcdc05f692271526ab6a4966a51d4436e9882 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 09:46:06 +0200 Subject: [PATCH 17/29] fix: simplify resolveSummaryConfig to match node_modules version Synchronize local ConfigMigration.ts with node_modules version to ensure consistent behavior. The simpler ?? (nullish coalescing) operator is more reliable than hasOwnProperty checks. This ensures 'summary' field is properly resolved just like 'title_generation'. --- src/hooks/EventHook.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index 6c10715..f9888da 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -183,7 +183,9 @@ export function createEventHook( // Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface // Resolve summary config with backward compatibility (summary or title_generation) + console.log("[EventHook] config object:", JSON.stringify(config, null, 2)) const summaryConfig = resolveSummaryConfig(config) + console.log("[EventHook] summaryConfig result:", summaryConfig) // Build lib config with pricing from opencode-project.json // Fallback to defaults if not configured From 7b8654d767d27537e57f4b7050751f2eaa902988 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 09:48:15 +0200 Subject: [PATCH 18/29] debug: add logging to resolveSummaryConfig to verify config resolution Add console logs to show: - Which config keys are present - Whether 'summary' field is found - Whether 'title_generation' field is found - Whether summaryConfig was resolved This helps verify that 'summary' field is being properly resolved. --- src/utils/ConfigMigration.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/utils/ConfigMigration.ts b/src/utils/ConfigMigration.ts index 78b65d9..f5b6641 100644 --- a/src/utils/ConfigMigration.ts +++ b/src/utils/ConfigMigration.ts @@ -46,6 +46,11 @@ export function resolveSummaryConfig( // Use (config as any) to bypass TypeScript type checking and ?? for nullish coalescing let summaryConfig = (config as any).summary ?? (config as any).title_generation + console.log("[ConfigMigration] resolveSummaryConfig called with config keys:", Object.keys(config as any)) + console.log("[ConfigMigration] summary field:", (config as any).summary ? "present" : "missing") + console.log("[ConfigMigration] title_generation field:", (config as any).title_generation ? "present" : "missing") + console.log("[ConfigMigration] resolved summaryConfig:", summaryConfig ? "found" : "not found") + if (!summaryConfig) { return undefined } From d75f6785c3e4e93981c8f5c2b4ee2c2493b7dbdc Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 10:30:12 +0200 Subject: [PATCH 19/29] COPSPA-489: Version fix --- package-lock.json | 383 ++++++++++++++++++++++++++++++++++++++++++---- package.json | 4 +- 2 files changed, 357 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index b678260..e9abcec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.5.2", "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.15", - "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" + "@opencode-ai/plugin": "^1.14", + "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#main" }, "devDependencies": { "@types/bun": "latest", @@ -21,39 +21,115 @@ "bun": ">=1.0.0" } }, - "../lib-ts-time-tracking": { - "name": "@techdivision/lib-ts-time-tracking", - "version": "4.2.0", + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "devDependencies": { - "@amiceli/vitest-cucumber": "^5.2.1", - "@types/node": "^22.0.0", - "eslint": "^10.1.0", - "eslint-config-prettier": "^10.1.8", - "prettier": "^3.8.1", - "typescript": "^5.9.3", - "typescript-eslint": "^8.57.2", - "vitest": "^3.2.1" - }, - "engines": { - "node": ">=20" - } + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@opencode-ai/plugin": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.15.tgz", - "integrity": "sha512-mh9S05W+CZZmo6q3uIEBubS66QVgiev7fRafX7vemrCfz+3pEIkSwipLjU/sxIewC9yLiDWLqS73DH/iEQzVDw==", + "version": "1.14.28", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.28.tgz", + "integrity": "sha512-cHJo7t1jwrzbkIVmNgggdWh4cyOVGw5fnbSpuYeL6qwfmH3g/6YLWtw5ZYEP6detUkEebT08mHXDGmsMUpQa+A==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.2.15", + "@opencode-ai/sdk": "1.14.28", + "effect": "4.0.0-beta.48", "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.105", + "@opentui/solid": ">=0.1.105" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } } }, "node_modules/@opencode-ai/sdk": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.15.tgz", - "integrity": "sha512-NUJNlyBCdZ4R0EBLjJziEQOp2XbRPJosaMcTcWSWO5XJPKGUpz0u8ql+5cR8K+v2RJ+hp2NobtNwpjEYfe6BRQ==", - "license": "MIT" + "version": "1.14.28", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.28.tgz", + "integrity": "sha512-qRFJfD+Zdz3jHHSupW4F6Io1ZFrQ6gCRFlG50O6kEU9xRxrBpK0wGvP+Y5VwwvD/gH9WKMHYinlQpDVI9/lgJQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } }, "node_modules/@oven/bun-darwin-aarch64": { "version": "1.3.13", @@ -223,9 +299,19 @@ ], "peer": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@techdivision/lib-ts-time-tracking": { - "resolved": "../lib-ts-time-tracking", - "link": true + "version": "4.2.0", + "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#efff5a88eaab91a26084cc69ed29e2ee7a677819", + "license": "MIT", + "engines": { + "node": ">=20" + } }, "node_modules/@types/bun": { "version": "1.3.13", @@ -292,6 +378,204 @@ "@types/node": "*" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.48", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", + "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", + "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -313,6 +597,49 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", diff --git a/package.json b/package.json index 0d49c40..164a19c 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "author": "TechDivision GmbH", "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.15", - "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" + "@opencode-ai/plugin": "^1.14", + "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#main" }, "devDependencies": { "@types/bun": "latest", From 85a4c31204433491293eeea74b7939e8187318e1 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 10:32:10 +0200 Subject: [PATCH 20/29] fix: resolve environment variables in summary config before passing to lib Add EnvResolver utility to resolve {env:VAR_NAME} placeholders in config values. This ensures that environment variables like TT_AGENT_API_KEY are properly resolved before being passed to the TimeTrackingFacade. Changes: - Create EnvResolver.ts with resolveEnvVar() and resolveEnvVarsInObject() - Update EventHook to resolve env vars in summaryConfig after migration - Add debug logging to show before/after env resolution This fixes the issue where api_key was still {env:TT_AGENT_API_KEY} instead of the actual API key value, causing LLM summary generation to fail. --- src/hooks/EventHook.ts | 11 ++++- src/utils/EnvResolver.ts | 86 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/utils/EnvResolver.ts diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index f9888da..838664e 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -16,6 +16,7 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig" import { AgentMatcher } from "../utils/AgentMatcher" import { SessionDataMapper } from "../adapters/SessionDataMapper" import { resolveSummaryConfig } from "../utils/ConfigMigration" +import { resolveEnvVarsInObject } from "../utils/EnvResolver" /** * Properties for message.updated events. @@ -184,8 +185,14 @@ export function createEventHook( // Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface // Resolve summary config with backward compatibility (summary or title_generation) console.log("[EventHook] config object:", JSON.stringify(config, null, 2)) - const summaryConfig = resolveSummaryConfig(config) - console.log("[EventHook] summaryConfig result:", summaryConfig) + let summaryConfig = resolveSummaryConfig(config) + console.log("[EventHook] summaryConfig result (before env resolution):", summaryConfig) + + // Resolve environment variables in summaryConfig (e.g., {env:TT_AGENT_API_KEY}) + if (summaryConfig) { + summaryConfig = resolveEnvVarsInObject(summaryConfig) + console.log("[EventHook] summaryConfig result (after env resolution):", summaryConfig) + } // Build lib config with pricing from opencode-project.json // Fallback to defaults if not configured diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts new file mode 100644 index 0000000..f330305 --- /dev/null +++ b/src/utils/EnvResolver.ts @@ -0,0 +1,86 @@ +/** + * @fileoverview Environment variable resolver for configuration values. + */ + +/** + * Resolves environment variable placeholders in a string value. + * + * @remarks + * Replaces `{env:VAR_NAME}` placeholders with actual environment variable values. + * If the environment variable is not set, the placeholder is left unchanged. + * + * @param value - The string value that may contain `{env:VAR_NAME}` placeholders + * @returns The resolved string with environment variables substituted + * + * @example + * ```typescript + * process.env.API_KEY = "sk-123456" + * resolveEnvVar("{env:API_KEY}") // Returns: "sk-123456" + * resolveEnvVar("prefix-{env:API_KEY}-suffix") // Returns: "prefix-sk-123456-suffix" + * resolveEnvVar("{env:MISSING}") // Returns: "{env:MISSING}" (unchanged) + * ``` + */ +export function resolveEnvVar(value: string): string { + if (typeof value !== "string") { + return value + } + + return value.replace(/{env:([^}]+)}/g, (match, varName) => { + const envValue = process.env[varName] + return envValue !== undefined ? envValue : match + }) +} + +/** + * Recursively resolves environment variable placeholders in an object. + * + * @remarks + * Traverses the entire object tree and resolves `{env:VAR_NAME}` placeholders + * in all string values. Works with nested objects and arrays. + * + * @param obj - The object that may contain `{env:VAR_NAME}` placeholders in string values + * @returns A new object with all environment variables resolved + * + * @example + * ```typescript + * process.env.API_KEY = "sk-123456" + * const config = { + * api_key: "{env:API_KEY}", + * nested: { + * url: "https://api.example.com", + * token: "{env:API_TOKEN}" + * } + * } + * resolveEnvVarsInObject(config) + * // Returns: { + * // api_key: "sk-123456", + * // nested: { + * // url: "https://api.example.com", + * // token: "{env:API_TOKEN}" (unchanged if not set) + * // } + * // } + * ``` + */ +export function resolveEnvVarsInObject(obj: any): any { + if (obj === null || obj === undefined) { + return obj + } + + if (typeof obj === "string") { + return resolveEnvVar(obj) + } + + if (Array.isArray(obj)) { + return obj.map((item) => resolveEnvVarsInObject(item)) + } + + if (typeof obj === "object") { + const resolved: any = {} + for (const [key, value] of Object.entries(obj)) { + resolved[key] = resolveEnvVarsInObject(value) + } + return resolved + } + + return obj +} From 766aa16a8ccc7ff487152f447cbc303df0103e24 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 10:32:49 +0200 Subject: [PATCH 21/29] Revert "fix: resolve environment variables in summary config before passing to lib" This reverts commit 85a4c31204433491293eeea74b7939e8187318e1. --- src/hooks/EventHook.ts | 11 +---- src/utils/EnvResolver.ts | 86 ---------------------------------------- 2 files changed, 2 insertions(+), 95 deletions(-) delete mode 100644 src/utils/EnvResolver.ts diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index 838664e..f9888da 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -16,7 +16,6 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig" import { AgentMatcher } from "../utils/AgentMatcher" import { SessionDataMapper } from "../adapters/SessionDataMapper" import { resolveSummaryConfig } from "../utils/ConfigMigration" -import { resolveEnvVarsInObject } from "../utils/EnvResolver" /** * Properties for message.updated events. @@ -185,14 +184,8 @@ export function createEventHook( // Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface // Resolve summary config with backward compatibility (summary or title_generation) console.log("[EventHook] config object:", JSON.stringify(config, null, 2)) - let summaryConfig = resolveSummaryConfig(config) - console.log("[EventHook] summaryConfig result (before env resolution):", summaryConfig) - - // Resolve environment variables in summaryConfig (e.g., {env:TT_AGENT_API_KEY}) - if (summaryConfig) { - summaryConfig = resolveEnvVarsInObject(summaryConfig) - console.log("[EventHook] summaryConfig result (after env resolution):", summaryConfig) - } + const summaryConfig = resolveSummaryConfig(config) + console.log("[EventHook] summaryConfig result:", summaryConfig) // Build lib config with pricing from opencode-project.json // Fallback to defaults if not configured diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts deleted file mode 100644 index f330305..0000000 --- a/src/utils/EnvResolver.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @fileoverview Environment variable resolver for configuration values. - */ - -/** - * Resolves environment variable placeholders in a string value. - * - * @remarks - * Replaces `{env:VAR_NAME}` placeholders with actual environment variable values. - * If the environment variable is not set, the placeholder is left unchanged. - * - * @param value - The string value that may contain `{env:VAR_NAME}` placeholders - * @returns The resolved string with environment variables substituted - * - * @example - * ```typescript - * process.env.API_KEY = "sk-123456" - * resolveEnvVar("{env:API_KEY}") // Returns: "sk-123456" - * resolveEnvVar("prefix-{env:API_KEY}-suffix") // Returns: "prefix-sk-123456-suffix" - * resolveEnvVar("{env:MISSING}") // Returns: "{env:MISSING}" (unchanged) - * ``` - */ -export function resolveEnvVar(value: string): string { - if (typeof value !== "string") { - return value - } - - return value.replace(/{env:([^}]+)}/g, (match, varName) => { - const envValue = process.env[varName] - return envValue !== undefined ? envValue : match - }) -} - -/** - * Recursively resolves environment variable placeholders in an object. - * - * @remarks - * Traverses the entire object tree and resolves `{env:VAR_NAME}` placeholders - * in all string values. Works with nested objects and arrays. - * - * @param obj - The object that may contain `{env:VAR_NAME}` placeholders in string values - * @returns A new object with all environment variables resolved - * - * @example - * ```typescript - * process.env.API_KEY = "sk-123456" - * const config = { - * api_key: "{env:API_KEY}", - * nested: { - * url: "https://api.example.com", - * token: "{env:API_TOKEN}" - * } - * } - * resolveEnvVarsInObject(config) - * // Returns: { - * // api_key: "sk-123456", - * // nested: { - * // url: "https://api.example.com", - * // token: "{env:API_TOKEN}" (unchanged if not set) - * // } - * // } - * ``` - */ -export function resolveEnvVarsInObject(obj: any): any { - if (obj === null || obj === undefined) { - return obj - } - - if (typeof obj === "string") { - return resolveEnvVar(obj) - } - - if (Array.isArray(obj)) { - return obj.map((item) => resolveEnvVarsInObject(item)) - } - - if (typeof obj === "object") { - const resolved: any = {} - for (const [key, value] of Object.entries(obj)) { - resolved[key] = resolveEnvVarsInObject(value) - } - return resolved - } - - return obj -} From e7b1e0e0c0dda7ffec743d9019ea3e697de814d4 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 10:41:58 +0200 Subject: [PATCH 22/29] Fix version --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e9abcec..c5dd8dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.14", - "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#main" + "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489" }, "devDependencies": { "@types/bun": "latest", @@ -307,7 +307,7 @@ }, "node_modules/@techdivision/lib-ts-time-tracking": { "version": "4.2.0", - "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#efff5a88eaab91a26084cc69ed29e2ee7a677819", + "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#b51d2c4c6ab7bfa66bada11dd2c9346aeda1a755", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 164a19c..5ed3e73 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.14", - "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#main" + "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489" }, "devDependencies": { "@types/bun": "latest", From 5dc0994e7ab79e846131c992c95e91017def0622 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 10:46:50 +0200 Subject: [PATCH 23/29] fix: pass both summary and title_generation to lib for mapping Remove the plugin's resolveSummaryConfig() call and instead pass both 'summary' and 'title_generation' fields to the lib. The lib's ConfigMapper will handle the migration and normalization. Changes: - Remove resolveSummaryConfig() call from EventHook - Pass both config.summary and config.title_generation to lib - Let lib handle the mapping via ConfigMapper This ensures the lib's mapping logic is used instead of duplicating the migration logic in the plugin. --- src/hooks/EventHook.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index f9888da..8a40681 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -15,7 +15,6 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig" import { AgentMatcher } from "../utils/AgentMatcher" import { SessionDataMapper } from "../adapters/SessionDataMapper" -import { resolveSummaryConfig } from "../utils/ConfigMigration" /** * Properties for message.updated events. @@ -182,10 +181,8 @@ export function createEventHook( }) // Convert plugin's TimeTrackingConfig to lib's TimeTrackingConfigInterface - // Resolve summary config with backward compatibility (summary or title_generation) + // Note: Don't resolve summary config here - let the lib handle both summary and title_generation console.log("[EventHook] config object:", JSON.stringify(config, null, 2)) - const summaryConfig = resolveSummaryConfig(config) - console.log("[EventHook] summaryConfig result:", summaryConfig) // Build lib config with pricing from opencode-project.json // Fallback to defaults if not configured @@ -223,13 +220,15 @@ export function createEventHook( ], } - const libConfig: TimeTrackingConfigInterface = { + const libConfig: TimeTrackingConfigInterface & { title_generation?: any } = { defaults: config.global_default, agents: config.agent_defaults, csv: { output_path: config.csv_file }, pricing: (config as any).pricing || defaultPricing, valid_projects: config.valid_projects || [], - ...(summaryConfig && { summary: summaryConfig }), + // Pass both summary and title_generation to lib - lib will handle the mapping + ...(config.summary && { summary: config.summary }), + ...((config as any).title_generation && { title_generation: (config as any).title_generation }), } const facade = await getTimeTrackingFacade(libConfig) From 3ed45b352249f18cb8611b0b49fa05e20c33eec9 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 11:22:35 +0200 Subject: [PATCH 24/29] Fix version --- package-lock.json | 28 +++++++++++++++++++++------- package.json | 2 +- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index c5dd8dd..fdf0140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.14", - "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489" + "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" }, "devDependencies": { "@types/bun": "latest", @@ -21,6 +21,24 @@ "bun": ">=1.0.0" } }, + "../lib-ts-time-tracking": { + "name": "@techdivision/lib-ts-time-tracking", + "version": "4.2.0", + "license": "MIT", + "devDependencies": { + "@amiceli/vitest-cucumber": "^5.2.1", + "@types/node": "^22.0.0", + "eslint": "^10.1.0", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.2", + "vitest": "^3.2.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -306,12 +324,8 @@ "license": "MIT" }, "node_modules/@techdivision/lib-ts-time-tracking": { - "version": "4.2.0", - "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#b51d2c4c6ab7bfa66bada11dd2c9346aeda1a755", - "license": "MIT", - "engines": { - "node": ">=20" - } + "resolved": "../lib-ts-time-tracking", + "link": true }, "node_modules/@types/bun": { "version": "1.3.13", diff --git a/package.json b/package.json index 5ed3e73..6d9e20e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.14", - "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489" + "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" }, "devDependencies": { "@types/bun": "latest", From 5db2fac496451b005567426e1a11c450165f4532 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 11:48:32 +0200 Subject: [PATCH 25/29] fix: resolve environment variables in config before passing to lib Add EnvResolver utility to resolve {env:VAR_NAME} placeholders in config values. This ensures that environment variables like TT_AGENT_API_KEY are properly resolved before being passed to the TimeTrackingFacade. Changes: - Create EnvResolver.ts with resolveEnvVar() and resolveEnvVarsInObject() - EventHook: Resolve env vars in summary and title_generation config - Recursively resolves nested objects and arrays This fixes the issue where api_key was still {env:TT_AGENT_API_KEY} instead of the actual API key value, causing LLM summary generation to fail. --- src/hooks/EventHook.ts | 6 ++- src/utils/EnvResolver.ts | 86 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/utils/EnvResolver.ts diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index 8a40681..c5bed7f 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -15,6 +15,7 @@ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig" import { AgentMatcher } from "../utils/AgentMatcher" import { SessionDataMapper } from "../adapters/SessionDataMapper" +import { resolveEnvVarsInObject } from "../utils/EnvResolver" /** * Properties for message.updated events. @@ -227,8 +228,9 @@ export function createEventHook( pricing: (config as any).pricing || defaultPricing, valid_projects: config.valid_projects || [], // Pass both summary and title_generation to lib - lib will handle the mapping - ...(config.summary && { summary: config.summary }), - ...((config as any).title_generation && { title_generation: (config as any).title_generation }), + // Resolve environment variables in config values (e.g., {env:TT_AGENT_API_KEY}) + ...(config.summary && { summary: resolveEnvVarsInObject(config.summary) }), + ...((config as any).title_generation && { title_generation: resolveEnvVarsInObject((config as any).title_generation) }), } const facade = await getTimeTrackingFacade(libConfig) diff --git a/src/utils/EnvResolver.ts b/src/utils/EnvResolver.ts new file mode 100644 index 0000000..f330305 --- /dev/null +++ b/src/utils/EnvResolver.ts @@ -0,0 +1,86 @@ +/** + * @fileoverview Environment variable resolver for configuration values. + */ + +/** + * Resolves environment variable placeholders in a string value. + * + * @remarks + * Replaces `{env:VAR_NAME}` placeholders with actual environment variable values. + * If the environment variable is not set, the placeholder is left unchanged. + * + * @param value - The string value that may contain `{env:VAR_NAME}` placeholders + * @returns The resolved string with environment variables substituted + * + * @example + * ```typescript + * process.env.API_KEY = "sk-123456" + * resolveEnvVar("{env:API_KEY}") // Returns: "sk-123456" + * resolveEnvVar("prefix-{env:API_KEY}-suffix") // Returns: "prefix-sk-123456-suffix" + * resolveEnvVar("{env:MISSING}") // Returns: "{env:MISSING}" (unchanged) + * ``` + */ +export function resolveEnvVar(value: string): string { + if (typeof value !== "string") { + return value + } + + return value.replace(/{env:([^}]+)}/g, (match, varName) => { + const envValue = process.env[varName] + return envValue !== undefined ? envValue : match + }) +} + +/** + * Recursively resolves environment variable placeholders in an object. + * + * @remarks + * Traverses the entire object tree and resolves `{env:VAR_NAME}` placeholders + * in all string values. Works with nested objects and arrays. + * + * @param obj - The object that may contain `{env:VAR_NAME}` placeholders in string values + * @returns A new object with all environment variables resolved + * + * @example + * ```typescript + * process.env.API_KEY = "sk-123456" + * const config = { + * api_key: "{env:API_KEY}", + * nested: { + * url: "https://api.example.com", + * token: "{env:API_TOKEN}" + * } + * } + * resolveEnvVarsInObject(config) + * // Returns: { + * // api_key: "sk-123456", + * // nested: { + * // url: "https://api.example.com", + * // token: "{env:API_TOKEN}" (unchanged if not set) + * // } + * // } + * ``` + */ +export function resolveEnvVarsInObject(obj: any): any { + if (obj === null || obj === undefined) { + return obj + } + + if (typeof obj === "string") { + return resolveEnvVar(obj) + } + + if (Array.isArray(obj)) { + return obj.map((item) => resolveEnvVarsInObject(item)) + } + + if (typeof obj === "object") { + const resolved: any = {} + for (const [key, value] of Object.entries(obj)) { + resolved[key] = resolveEnvVarsInObject(value) + } + return resolved + } + + return obj +} From d2fe298a191e58d631420bb4ff6da0f806c950c0 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 11:49:41 +0200 Subject: [PATCH 26/29] debug: add logging to EventHook for config and track result Add console logs to show: - libConfig.summary and libConfig.title_generation (after env resolution) - trackResult.summary (what the lib returns) This helps debug the flow and identify where the issue is. --- src/hooks/EventHook.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index c5bed7f..0bdd03b 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -233,8 +233,13 @@ export function createEventHook( ...((config as any).title_generation && { title_generation: resolveEnvVarsInObject((config as any).title_generation) }), } + console.log("[EventHook] libConfig.summary:", JSON.stringify(libConfig.summary, null, 2)) + console.log("[EventHook] libConfig.title_generation:", JSON.stringify(libConfig.title_generation, null, 2)) + const facade = await getTimeTrackingFacade(libConfig) + console.log("[EventHook] facade created, calling track()") const trackResult = await facade.track(sessionData) + console.log("[EventHook] trackResult.summary:", JSON.stringify(trackResult.summary, null, 2)) const description = trackResult.summary.description // Build entry data from trackResult.entry (CSV entry comes directly from Lib!) From bd457f9c9a22be52fc1f9279d4a9f1c2740efb80 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 27 Apr 2026 16:56:28 +0200 Subject: [PATCH 27/29] COPSPA-489: Fix --- package.json | 2 +- plugins/time-tracking.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 plugins/time-tracking.ts diff --git a/package.json b/package.json index 6d9e20e..5ed3e73 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.14", - "@techdivision/lib-ts-time-tracking": "file:../lib-ts-time-tracking" + "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489" }, "devDependencies": { "@types/bun": "latest", diff --git a/plugins/time-tracking.ts b/plugins/time-tracking.ts new file mode 100644 index 0000000..34d319d --- /dev/null +++ b/plugins/time-tracking.ts @@ -0,0 +1,20 @@ +/** + * OpenCode Time Tracking Plugin + * + * @package @techdivision/opencode-plugin-time-tracking + * @author TechDivision GmbH + * @license MIT + * @version 1.0.0 + * + * @description + * Automatically tracks session duration, tool usage, and token consumption, + * exporting data to CSV for time tracking integration (e.g., Jira/Tempo). + * + * @usage + * The plugin automatically tracks all sessions and exports data to CSV. + * No manual configuration required beyond setting up the time_tracking + * section in .opencode/opencode-project.json. + */ + +// Re-export the plugin from the main package +export { plugin } from "../src/Plugin" From cb60196c109cbe1f6f1e0d14fe925a9acf33d189 Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 28 Apr 2026 15:53:58 +0200 Subject: [PATCH 28/29] Fix version --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index 329c8f0..ae1bad4 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "name": "time-tracking", "description": "Automatic time tracking plugin for OpenCode. Tracks session duration and tool usage, writing entries to a CSV file compatible with Jira worklog sync incl. commands, skills and agents.", "category": "standard", - "version": "1.5.1", + "version": "2.0.0", "hooks": { "postlink": "scripts/postlink.js" } From 612c7233c36e67013f1daceac72084077f1d6d6f Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 28 Apr 2026 16:46:20 +0200 Subject: [PATCH 29/29] Fix version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5ed3e73..8efbd55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@techdivision/opencode-plugin-time-tracking", - "version": "1.5.2", + "version": "2.0.0", "description": "Automatic time tracking plugin for OpenCode. Tracks session duration and tool usage, writing entries to a CSV file compatible with Jira worklog sync incl. commands, skills and agents.", "type": "module", "main": "src/Plugin.ts",