diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 766c0fc..9125d3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,3 +143,77 @@ jobs: generate_release_notes: false draft: true prerelease: true + + auto-release: + name: Push(main) / Auto Release + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: test-merge + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Check if already tagged + id: check + run: | + # Skip if this commit already has a stable version tag + for tag in $(git tag --points-at HEAD 2>/dev/null); do + if echo "$tag" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Commit already tagged as $tag, skipping." + exit 0 + fi + done + echo "skip=false" >> "$GITHUB_OUTPUT" + + - name: Setup Bun + if: steps.check.outputs.skip != 'true' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + run: bun install + + - name: Compute release metadata + if: steps.check.outputs.skip != 'true' + id: meta + run: | + bun run scripts/release-meta.ts --allow-invalid --github-output "$GITHUB_OUTPUT" + + # Squash merges lose individual commit types, so if bump is + # "none" but there are unreleased commits, default to patch. + BUMP=$(grep '^bump=' "$GITHUB_OUTPUT" | cut -d= -f2) + COUNT=$(grep '^commit_count=' "$GITHUB_OUTPUT" | cut -d= -f2) + if [ "$BUMP" = "none" ] && [ "$COUNT" -gt 0 ]; then + echo "Bump was 'none' with $COUNT commits — overriding to 'patch'" + LATEST=$(git tag --list 'v*.*.*' --sort=-version:refname \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [ -n "$LATEST" ]; then + IFS='.' read -r MAJ MIN PAT <<< "${LATEST#v}" + NEXT="v${MAJ}.${MIN}.$((PAT + 1))" + else + NEXT="v0.1.0" + fi + echo "next_version=${NEXT}" >> "$GITHUB_OUTPUT" + echo "bump=patch" >> "$GITHUB_OUTPUT" + fi + + - name: Create release + if: steps.check.outputs.skip != 'true' && steps.meta.outputs.bump != 'none' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.meta.outputs.next_version }} + target_commitish: ${{ github.sha }} + name: ${{ steps.meta.outputs.next_version }} + body: ${{ steps.meta.outputs.release_notes }} + generate_release_notes: false + draft: false + prerelease: false diff --git a/src/db.ts b/src/db.ts index d468696..8533d56 100644 --- a/src/db.ts +++ b/src/db.ts @@ -719,6 +719,31 @@ export function insertError( ).run(messageId, sessionId, errorType, message, createdAt); } +// Per-million-token pricing by model family +const MODEL_PRICING: Record = { + "claude-opus-4": { input: 15.0, output: 75.0, cacheRead: 1.5 }, + "claude-sonnet-4": { input: 3.0, output: 15.0, cacheRead: 0.3 }, + "claude-haiku-4": { input: 0.8, output: 4.0, cacheRead: 0.08 }, +}; +const DEFAULT_PRICING = { input: 3.0, output: 15.0, cacheRead: 0.3 }; + +export function estimateCost( + model: string, + inputTokens: number, + outputTokens: number, + cacheTokens: number +): number { + // Match model family: "claude-sonnet-4-20250514" → "claude-sonnet-4" + const family = Object.keys(MODEL_PRICING).find((k) => model.startsWith(k)); + const pricing = family ? MODEL_PRICING[family] : DEFAULT_PRICING; + return ( + (inputTokens * pricing.input + + outputTokens * pricing.output + + cacheTokens * pricing.cacheRead) / + 1_000_000 + ); +} + export function upsertSessionCosts( db: Database, sessionId: string, @@ -728,16 +753,28 @@ export function upsertSessionCosts( cacheTokens: number, durationMs: number ): void { + const modelName = model || "unknown"; + const cost = estimateCost(modelName, inputTokens, outputTokens, cacheTokens); db.prepare( - `INSERT INTO smriti_session_costs(session_id, model, total_input_tokens, total_output_tokens, total_cache_tokens, turn_count, total_duration_ms) - VALUES(?, ?, ?, ?, ?, 1, ?) + `INSERT INTO smriti_session_costs(session_id, model, total_input_tokens, total_output_tokens, total_cache_tokens, estimated_cost_usd, turn_count, total_duration_ms) + VALUES(?, ?, ?, ?, ?, ?, 1, ?) ON CONFLICT(session_id, model) DO UPDATE SET total_input_tokens = total_input_tokens + excluded.total_input_tokens, total_output_tokens = total_output_tokens + excluded.total_output_tokens, total_cache_tokens = total_cache_tokens + excluded.total_cache_tokens, + estimated_cost_usd = estimated_cost_usd + excluded.estimated_cost_usd, turn_count = turn_count + 1, total_duration_ms = total_duration_ms + excluded.total_duration_ms` - ).run(sessionId, model || "unknown", inputTokens, outputTokens, cacheTokens, durationMs); + ).run(sessionId, modelName, inputTokens, outputTokens, cacheTokens, cost, durationMs); +} + +export function deleteSidecarRows(db: Database, sessionId: string): void { + db.prepare(`DELETE FROM smriti_tool_usage WHERE session_id = ?`).run(sessionId); + db.prepare(`DELETE FROM smriti_file_operations WHERE session_id = ?`).run(sessionId); + db.prepare(`DELETE FROM smriti_commands WHERE session_id = ?`).run(sessionId); + db.prepare(`DELETE FROM smriti_errors WHERE session_id = ?`).run(sessionId); + db.prepare(`DELETE FROM smriti_git_operations WHERE session_id = ?`).run(sessionId); + db.prepare(`DELETE FROM smriti_session_costs WHERE session_id = ?`).run(sessionId); } export function insertGitOperation(