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/.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/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/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!** 🚀 diff --git a/README.md b/README.md index 6b9e7db..37f9298 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 | @@ -12,6 +27,20 @@ Automatic time tracking plugin for OpenCode. Tracks session duration and tool us | 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) diff --git a/package-lock.json b/package-lock.json index f88dad8..e8bbd58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "@techdivision/opencode-plugin-time-tracking", - "version": "0.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@techdivision/opencode-plugin-time-tracking", - "version": "0.1.0", + "version": "2.0.0", + "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.15" + "@opencode-ai/plugin": "^1.14", + "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489" }, "devDependencies": { "@types/bun": "latest", @@ -19,26 +21,120 @@ "bun": ">=1.0.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", + "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.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" ], @@ -50,9 +146,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" ], @@ -64,9 +160,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" ], @@ -78,9 +174,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" ], @@ -92,9 +188,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" ], @@ -106,9 +202,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" ], @@ -120,9 +216,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" ], @@ -134,9 +230,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" ], @@ -148,9 +244,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" ], @@ -162,9 +258,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" ], @@ -176,9 +272,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" ], @@ -190,9 +286,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" ], @@ -203,30 +299,44 @@ ], "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": { + "version": "5.0.0", + "resolved": "git+ssh://git@github.com/techdivision/lib-ts-time-tracking.git#40b419fb597fda4826bbfa5afbdbd1fdb90a19e5", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "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" @@ -244,30 +354,228 @@ "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": { "@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", @@ -283,12 +591,55 @@ } }, "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" }, + "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 351765e..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", @@ -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" @@ -26,7 +32,8 @@ "author": "TechDivision GmbH", "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.2.15" + "@opencode-ai/plugin": "^1.14", + "@techdivision/lib-ts-time-tracking": "github:techdivision/lib-ts-time-tracking#copspa-489" }, "devDependencies": { "@types/bun": "latest", 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" } 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" diff --git a/src/Plugin.ts b/src/Plugin.ts index e6e401c..a3d482b 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,24 @@ 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..d89336e --- /dev/null +++ b/src/adapters/SessionDataMapper.ts @@ -0,0 +1,183 @@ +/** + * @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" + +/** + * Extracts text content from an OpenCode message object. + * Handles the SDK's parts array structure. + * + * @param message - The message object from OpenCode SDK + * @returns The extracted text, or empty string if no text found + */ +function extractTextFromMessage(message: any): string { + if (!message) return "" + + // Try to extract from parts array (main SDK structure) + if (message.parts && Array.isArray(message.parts)) { + const textParts = message.parts + .filter((p: any) => p.type === "text" && !p.synthetic && p.text) + .map((p: any) => p.text as string) + if (textParts.length > 0) { + return textParts.join("\n") + } + } + + // Fallback to direct content/text fields + if (message.content && typeof message.content === "string") { + return message.content + } + if (message.text && typeof message.text === "string") { + return message.text + } + + return "" +} + +/** + * 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 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; ticket?: string | null } + ): 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 { + + // Use a timeout to avoid hanging the entire session + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + resolve(null) + }, 3000) + }) + + const messagesPromise = client.session.messages({ + path: { id: sessionID }, + } as Parameters[0]) + + const result = await Promise.race([messagesPromise, timeoutPromise]) + + if (!result?.data || result.data.length === 0) { + return null + } + + + // Extract text from messages - only last 3 turns like main-branch MessageExtractor + const RECENT_TURNS = 3 + const messages = result.data + + // Step 1: Find indices of last N user messages + const userIndices: number[] = [] + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].info?.role === "user") { + const text = extractTextFromMessage(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 + first assistant response after it + const turns: string[] = [] + + // Process in chronological order (oldest first) + for (const idx of userIndices.reverse()) { + const userText = extractTextFromMessage(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 = extractTextFromMessage(messages[j]) + if (assistantText) { + // Truncate long responses to avoid huge context + const truncated = assistantText.length > 500 + ? assistantText.slice(0, 500) + "..." + : assistantText + turns.push(`assistant: ${truncated}`) + break + } + } + // Stop if we hit next user message + if (messages[j].info?.role === "user") break + } + } + + const result_text = turns.length > 0 ? turns.join("\n") : null + return result_text + } catch (e) { + // 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: config.ticket ?? session.ticket ?? undefined, + } + } +} diff --git a/src/hooks/EventHook.ts b/src/hooks/EventHook.ts index ed64d06..4e57d3a 100644 --- a/src/hooks/EventHook.ts +++ b/src/hooks/EventHook.ts @@ -2,22 +2,20 @@ * @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, WriteResultInterface } 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" 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 { DescriptionGenerator } from "../utils/DescriptionGenerator" +import { SessionDataMapper } from "../adapters/SessionDataMapper" +import { resolveEnvVarsInObject } from "../utils/EnvResolver" /** * Properties for message.updated events. @@ -26,54 +24,14 @@ 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. * * @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 - * @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,26 +39,24 @@ 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, client, ticketResolver, config, getFacade), * } * ``` */ export function createEventHook( sessionManager: SessionManager, - writers: WriterService[], 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 +150,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 +174,105 @@ 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 + // Note: Don't resolve summary config here - let the lib handle both summary and title_generation + // 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, + }, + }, + }, + ], + } + + // Build webhook config from environment variables (if available) + const webhookConfig = process.env.TT_WEBHOOK_URL + ? { + url: process.env.TT_WEBHOOK_URL, + bearer_token: process.env.TT_WEBHOOK_BEARER_TOKEN, + } + : undefined + + 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 || [], + // Pass both summary and title_generation to lib - lib will handle the mapping + // 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) }), + // Pass webhook config to lib (read from environment variables) + ...(webhookConfig && { webhook: webhookConfig }), + } + + 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: WriteResultInterface[] = [ + trackResult.csv, + trackResult.webhook, + ].filter((r) => r !== undefined && r !== null) as WriteResultInterface[] // 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 ?? 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}` : ""}` - 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/ConfigLoader.ts b/src/services/ConfigLoader.ts index 119c411..f83cc09 100644 --- a/src/services/ConfigLoader.ts +++ b/src/services/ConfigLoader.ts @@ -2,6 +2,7 @@ * @fileoverview Configuration loader for the time tracking plugin. */ +import fs from "fs" import { userInfo } from "os" import type { @@ -16,6 +17,46 @@ import "../types/Bun" */ const ENV_USER_EMAIL = "OPENCODE_USER_EMAIL" +/** + * Resolves {env:KEY} placeholders in configuration strings. + * Recursively processes objects and arrays. + * + * @param value - The value to process (string, object, array, or primitive) + * @returns The resolved value with {env:...} placeholders replaced + * + * @example + * ```typescript + * resolveEnvPlaceholders("{env:TT_AGENT_API_KEY}") // Returns process.env.TT_AGENT_API_KEY + * resolveEnvPlaceholders({ api_key: "{env:TT_AGENT_API_KEY}" }) + * // Returns { api_key: "sk-..." } + * ``` + */ +function resolveEnvPlaceholders(value: any): any { + // Handle strings: replace {env:KEY} with process.env.KEY + if (typeof value === "string") { + return value.replace(/{env:([^}]+)}/g, (_, key) => { + return process.env[key] || `{env:${key}}` + }) + } + + // Handle arrays: recursively process each element + if (Array.isArray(value)) { + return value.map(resolveEnvPlaceholders) + } + + // Handle objects: recursively process each property + if (value !== null && typeof value === "object") { + const resolved: Record = {} + for (const [key, val] of Object.entries(value)) { + resolved[key] = resolveEnvPlaceholders(val) + } + return resolved + } + + // Return primitives (numbers, booleans, null) as-is + return value +} + /** * Loads the plugin configuration from the project directory. * @@ -44,6 +85,28 @@ export class ConfigLoader { * ``` */ static async load(directory: string): Promise { + // Load .env as a fallback for process.env (in case plugins load in parallel in production). + // During local development with symlinks in .opencode/plugins/, plugins load sequentially + // and shell-env will have already populated process.env. In production with npm packages, + // plugins may load in parallel, so we read .env directly as a safety net. + // See: how-to-local-plugin-development.md for details. + const envPath = `${directory}/.opencode/.env` + try { + const envFile = Bun.file(envPath) + if (await envFile.exists()) { + const envContent = await envFile.text() + for (const line of envContent.split("\n")) { + const trimmed = line.trim() + if (trimmed.startsWith("OPENCODE_USER_EMAIL=")) { + const value = trimmed.split("=")[1]?.replace(/["']/g, "").trim() + if (value && !process.env[ENV_USER_EMAIL]) { + process.env[ENV_USER_EMAIL] = value + } + } + } + } + } catch {} + const configPath = `${directory}/.opencode/opencode-project.json` try { @@ -56,12 +119,15 @@ export class ConfigLoader { const jsonConfig = projectConfig.time_tracking // Resolve user_email with fallback chain: - // 1. Environment variable (loaded by opencode-plugin-shell-env from .env) + // 1. Environment variable (loaded from .env or opencode-plugin-shell-env) // 2. System username const userEmail = process.env[ENV_USER_EMAIL] || userInfo().username + // Resolve all {env:...} placeholders in the config recursively + const resolvedConfig = resolveEnvPlaceholders(jsonConfig) + return { - ...jsonConfig, + ...resolvedConfig, user_email: userEmail, } } 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/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..26649bb 100644 --- a/src/types/TimeTrackingConfig.ts +++ b/src/types/TimeTrackingConfig.ts @@ -4,7 +4,7 @@ import type { AgentDefaultConfig } from "./AgentDefaultConfig" import type { GlobalDefaultConfig } from "./GlobalDefaultConfig" -import type { TitleGenerationConfig } from "./TitleGenerationConfig" +import type { SessionSummaryConfigInterface } from "@techdivision/lib-ts-time-tracking" /** * Time tracking configuration as stored in `.opencode/opencode-project.json`. @@ -64,13 +64,25 @@ export interface TimeTrackingJsonConfig { valid_projects?: string[] /** - * LLM-based title generation configuration. + * LLM-based session summary configuration. * * @remarks - * When not set or partially configured, smart defaults are used - * and title generation is enabled. Set `enabled: false` to disable. + * 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?: TitleGenerationConfig + title_generation?: SessionSummaryConfigInterface } /** 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/ConfigMigration.ts b/src/utils/ConfigMigration.ts new file mode 100644 index 0000000..c43e1a3 --- /dev/null +++ b/src/utils/ConfigMigration.ts @@ -0,0 +1,64 @@ +/** + * @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' + // 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 + } + + // Check if explicitly disabled + if (summaryConfig.enabled === false) { + return undefined + } + + // Validate required fields for LLM summary generation + if (!summaryConfig.model || !summaryConfig.api_url) { + return undefined + } + + return summaryConfig +} 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/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/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 +} 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..edc7a27 100644 --- a/tools/track-time.ts +++ b/tools/track-time.ts @@ -4,10 +4,12 @@ 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" +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 @@ -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] - 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) + // 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,