From a41d80b32e2575f007a3c17483d132efa7808def Mon Sep 17 00:00:00 2001 From: Anuj7411 Date: Thu, 25 Jun 2026 12:58:15 +0530 Subject: [PATCH] =?UTF-8?q?release:=20v1.6.17=20=E2=80=94=20sipcode=20upda?= =?UTF-8?q?te=20command,=20session-pollution=20filter,=20opus=204.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added - `sipcode update`: prints version + `npm i -g sipcode@latest` (or --run to upgrade in place). Zero network calls preserved; npm resolved as npm.cmd on Windows (no shell:true). README "Updating Sipcode" section + FAQ. Fixed - Exclude observer/telemetry project dirs (e.g. claude-mem) and empty zero-token sessions from discovery and every aggregating command (stats, today, why, forecast, trend). One machine dropped from 1,446 to ~21 real sessions; today no longer reports 0 tokens across empty sessions. The MCP surface inherits the fix via the shared discover.ts. - estimate now shows opus 4.8 (was 4.7). - stats on a fresh machine says "no Claude Code sessions found yet" instead of falsely claiming transcripts exist. Release - Bump 1.6.16 -> 1.6.17. CHANGELOG 1.6.17 entry + repaired missing [1.6.16] link ref. README test badge, llms.txt / llms-full.txt, and landing page (Hero + Footer) bumped to 1.6.17 / 1,373 tests. Tests: 1,363 -> 1,373, full suite green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 21 ++++- README.md | 34 +++++++- docs/site/public/llms-full.txt | 4 +- docs/site/public/llms.txt | 6 +- docs/site/src/components/Footer.astro | 4 +- docs/site/src/components/Hero.astro | 6 +- package-lock.json | 4 +- package.json | 2 +- src/cli.ts | 10 +++ src/commands/forecast.ts | 3 +- src/commands/stats.ts | 11 ++- src/commands/today.ts | 3 +- src/commands/trend.ts | 3 +- src/commands/update.ts | 79 +++++++++++++++++++ src/lib/messages.ts | 9 +++ src/lib/pricing/load.ts | 1 + src/modules/estimate/format-terminal.ts | 2 +- src/modules/estimate/predict.ts | 2 +- src/modules/estimate/recommend.ts | 2 +- src/modules/transcript/analyzers/tokens.ts | 16 ++++ src/modules/transcript/discover.ts | 17 ++++ tests/commands/update.test.ts | 54 +++++++++++++ .../integration/estimate.integration.test.ts | 2 +- tests/integration/stats.integration.test.ts | 21 +++++ .../format-terminal.test.ts.snap | 2 +- tests/modules/estimate/recommend.test.ts | 2 +- .../analyzers/empty-session.test.ts | 38 +++++++++ .../transcript/discover.observer.test.ts | 40 ++++++++++ 28 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 src/commands/update.ts create mode 100644 tests/commands/update.test.ts create mode 100644 tests/modules/transcript/analyzers/empty-session.test.ts create mode 100644 tests/modules/transcript/discover.observer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d0233..e55c967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,22 @@ This log starts at v1.6.5 (the reliability-pillar repositioning). Earlier histor ## [Unreleased] -_Nothing landed since [1.6.16]._ +_Nothing landed since [1.6.17]._ + +## [1.6.17] — 2026-06-25 + +Quality-of-life release: a real update path, cleaner numbers, and the current model name. + +### Added +- **`sipcode update`** — a one-stop command that shows your installed version and the exact command to upgrade. Run it plain to print `npm i -g sipcode@latest` (plus a link to this changelog), or `sipcode update --run` to upgrade in place. Sipcode still never checks for updates on its own — zero network calls — so updating stays a deliberate, manual step. README gained a matching "Updating Sipcode" section and FAQ entry. + +### Fixed +- **Stats no longer counts sessions that aren't yours.** Observer/telemetry plugins (e.g. claude-mem) create synthetic, zero-work session folders under `~/.claude/projects`. Those were inflating `stats`, `today`, and `why` — one machine showed 1,446 sessions when only ~21 were real, and `today` reported "0 tokens" across dozens of empty sessions. Sipcode now skips observer project folders and empty (zero-token) sessions everywhere, so counts, daily spend, and the "latest session" pick reflect real work. The same fix flows through the MCP tools, which read the identical discovery path. +- **`estimate` now names the current model.** It showed "opus 4.7"; it now shows "opus 4.8" (alongside sonnet 4.6 / haiku 4.5). +- **Friendlier first run.** On a machine with no Claude Code sessions yet, `sipcode stats` used to claim "transcripts exist, but none in the window" — false for a brand-new user. It now says "no Claude Code sessions found yet" and points you at getting started. + +### Internal +- Test count: 1,363 → 1,373. Full suite green. The new `sipcode update` command stays inside the zero-network privacy guard (no `node:http` / `node:https` / `node:net` / `node:dns`) and the no-shell-args guard (npm is resolved as `npm.cmd` on Windows, no `shell: true`). --- @@ -215,7 +230,9 @@ This release rolls v1.6.9's B3 work (bumped but never published to npm) together --- -[Unreleased]: https://github.com/Anuj7411/sipcode/compare/v1.6.15...HEAD +[Unreleased]: https://github.com/Anuj7411/sipcode/compare/v1.6.17...HEAD +[1.6.17]: https://github.com/Anuj7411/sipcode/compare/v1.6.16...v1.6.17 +[1.6.16]: https://github.com/Anuj7411/sipcode/compare/v1.6.15...v1.6.16 [1.6.15]: https://github.com/Anuj7411/sipcode/compare/v1.6.14...v1.6.15 [1.6.14]: https://github.com/Anuj7411/sipcode/compare/v1.6.13...v1.6.14 [1.6.13]: https://github.com/Anuj7411/sipcode/compare/v1.6.12...v1.6.13 diff --git a/README.md b/README.md index 037fa8c..3a7b260 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

npm MIT licensed - 1363 tests passing + 1373 tests passing zero network calls

@@ -209,6 +209,29 @@ If drift says "no drift" and proxy --stats shows a few dozen rewrites with savin --- +## Updating Sipcode + +Sipcode never checks for updates on its own. That would require a network call, and zero network calls in normal use is a guaranteed property of the codebase (a test fails the build otherwise). Updating is always a manual, explicit step. + +To update to the latest version: + +```bash +npm i -g sipcode@latest +``` + +Or use the built-in helper: + +```bash +sipcode update # prints your current version + the update command +sipcode update --run # runs the update in place for you +``` + +After updating, confirm with `sipcode --version` and see what changed in the [CHANGELOG](CHANGELOG.md). The MCP config never needs re-pasting; Claude asks the server for its tools on every reconnect, so new tools appear automatically. + +**Release cadence:** patch releases ship whenever a fix or improvement is ready, often within a day or two of a reported issue. Watch or star the [repo](https://github.com/Anuj7411/sipcode) to be notified of new releases. + +--- + ## What you get | Feature | What it does for you | @@ -244,6 +267,14 @@ When Claude's context window fills up with stale, repeated, or off-topic informa Today, only with Claude Code (since Claude Code is the only tool that exposes the hooks Sipcode needs). Support for other CLIs is something we are exploring. +### How do I update? + +```bash +npm i -g sipcode@latest +``` + +Or run `sipcode update` to see your version and the command, or `sipcode update --run` to update in place. Sipcode never auto-checks for updates (zero network calls), so updating is always explicit. See the [CHANGELOG](CHANGELOG.md) for what changed. + ### How do I uninstall? ```bash @@ -258,6 +289,7 @@ npm uninstall -g sipcode | Command | What it does | |---|---| | `sipcode init` | Set up Sipcode in a project (manifest + CLAUDE.md + proxy hook) | +| `sipcode update` | Show how to update to the latest version (or `--run` to update now) | | `sipcode drift` | Check if the current session is drifting from your norm | | `sipcode proxy --stats` | See what the proxy caught this session | | `sipcode benchmark` | Run the locked 20-task corpus | diff --git a/docs/site/public/llms-full.txt b/docs/site/public/llms-full.txt index 9abcd8b..66d2d1b 100644 --- a/docs/site/public/llms-full.txt +++ b/docs/site/public/llms-full.txt @@ -6,7 +6,7 @@ Sipcode is a context-hygiene proxy for [Claude Code](https://www.anthropic.com/c On a locked, public 20-task benchmark corpus, Sipcode delivers 62.6% median tool-output savings (range 37.4% to 80.6%), totalling 3,567,170 tokens saved and $67.43 at current Claude Sonnet pricing. Anyone can reproduce these numbers with `sipcode benchmark`. Anthropic's published research finds cleaner context gives a 29% quality lift and 40% fewer agent errors, which is the mechanism Sipcode targets. -Sipcode is solo-maintained, MIT licensed, has 1,363 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. +Sipcode is solo-maintained, MIT licensed, has 1,373 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. ## How Sipcode differs from neighboring tools @@ -140,7 +140,7 @@ Verify: `node --version` should show v18.0.0 or higher. npm i -g sipcode ``` -Verify: `sipcode --version` should show 1.6.16 or higher. +Verify: `sipcode --version` should show 1.6.17 or higher. ### Step 3. Run `sipcode init` diff --git a/docs/site/public/llms.txt b/docs/site/public/llms.txt index ea8f46c..b285b08 100644 --- a/docs/site/public/llms.txt +++ b/docs/site/public/llms.txt @@ -6,7 +6,7 @@ Sipcode is a context-hygiene proxy for [Claude Code](https://www.anthropic.com/c On a locked, public 20-task benchmark corpus, Sipcode delivers 62.6% median tool-output savings (range 37.4% to 80.6%), totalling 3,567,170 tokens saved and $67.43 at current Claude Sonnet pricing. Anyone can reproduce these numbers with `sipcode benchmark`. Anthropic's published research finds cleaner context gives a 29% quality lift and 40% fewer agent errors, which is the mechanism Sipcode targets. -Sipcode is solo-maintained, MIT licensed, has 1,363 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. +Sipcode is solo-maintained, MIT licensed, has 1,373 passing tests, and makes zero network calls during normal use. A privacy test in the repo fails the build if any `node:http`, `node:https`, `node:net`, or `node:dns` import is added to `src/`. Your code and transcripts never leave your laptop. ## How Sipcode differs from neighboring tools @@ -53,8 +53,8 @@ Sipcode ships an MCP server exposing 15 tools so Claude Code can introspect its ## Source code -- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 1,363 tests -- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.6.16 +- [GitHub repository](https://github.com/Anuj7411/sipcode): MIT licensed source, 1,373 tests +- [npm package](https://www.npmjs.com/package/sipcode): `sipcode` on npm, current version 1.6.17 - [Privacy test](https://github.com/Anuj7411/sipcode/blob/main/src): build-blocking check that no network modules are imported in `src/` ## License diff --git a/docs/site/src/components/Footer.astro b/docs/site/src/components/Footer.astro index 642aed7..e8048ab 100644 --- a/docs/site/src/components/Footer.astro +++ b/docs/site/src/components/Footer.astro @@ -25,11 +25,11 @@
- v1.6.15 + v1.6.17 - 1,317 tests passing + 1,373 tests passing
diff --git a/docs/site/src/components/Hero.astro b/docs/site/src/components/Hero.astro index fa5beb2..25af584 100644 --- a/docs/site/src/components/Hero.astro +++ b/docs/site/src/components/Hero.astro @@ -8,8 +8,8 @@ import PlotBackground from "./PlotBackground.astro"; // Tier-1 fix #2 — test count from the project root. Single source of truth. // Update on each release alongside Hero/Footer version labels. -// Last bumped: 2026-06-15 (v1.6.15 added 51 warmfill + init system-setup tests). -const TEST_COUNT = 1317; +// Last bumped: 2026-06-25 (v1.6.17 — sipcode update command, session-pollution filter, opus 4.8). +const TEST_COUNT = 1373; ---
@@ -19,7 +19,7 @@ const TEST_COUNT = 1317;
- v1.6.15 · MIT licensed + v1.6.17 · MIT licensed {TEST_COUNT.toLocaleString()} tests passing
diff --git a/package-lock.json b/package-lock.json index 2d27f4b..f661587 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sipcode", - "version": "1.6.14", + "version": "1.6.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sipcode", - "version": "1.6.14", + "version": "1.6.17", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 7616b26..c3d6c78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sipcode", - "version": "1.6.16", + "version": "1.6.17", "description": "Sip your tokens, don't gulp them. Keep Claude Code's context clean: drift detection, re-read dedup, integrity scoring, AST-aware reads, and 15 MCP tools for Claude Desktop.", "keywords": [ "claude-code", diff --git a/src/cli.ts b/src/cli.ts index c1fed01..912faa7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -180,6 +180,16 @@ program if (r?.exitCode) process.exit(r.exitCode); }); +program + .command("update") + .description("Show how to update Sipcode to the latest version (or --run to update now).") + .option("--run", "run `npm i -g sipcode@latest` now instead of just printing it") + .action(async (opts) => { + const { runUpdate } = await import("./commands/update.js"); + const r = await runUpdate(opts, { version: pkg.version }); + if (r?.exitCode) process.exit(r.exitCode); + }); + program .command("hygiene") .description("Install Sipcode Session Hygiene: read-once rules + context-pressure hooks.") diff --git a/src/commands/forecast.ts b/src/commands/forecast.ts index 4a516ef..e3929c5 100644 --- a/src/commands/forecast.ts +++ b/src/commands/forecast.ts @@ -10,7 +10,7 @@ import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; import { getAgentById } from "../modules/agents/registry.js"; import type { AgentId } from "../modules/agents/types.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { runForecast, type ForecastSession } from "../modules/forecast/runForecast.js"; import { formatForecastTerminal } from "../modules/forecast/format-terminal.js"; import { formatForecastJson } from "../modules/forecast/format-json.js"; @@ -66,6 +66,7 @@ export async function runForecastCmd( const parsed = parseResult.value; const startedAt = parsed.startedAt ?? new Date(meta.mtimeMs).toISOString(); const tokens = analyzeTokens(parsed, pricing); + if (isEmptySession(tokens)) continue; sessions.push({ startedAt, estCostUSD: tokens.estCostUSD }); } diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 843c7e0..72adc0f 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -15,7 +15,7 @@ import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; import { MESSAGES } from "../lib/messages.js"; import { resolveAgentFromOpts } from "../modules/agents/cli.js"; import { resolveProjectsDir } from "../modules/transcript/discover.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { analyzeIdleContext } from "../modules/transcript/analyzers/idleContext.js"; import { @@ -145,6 +145,9 @@ export async function runStats( return { exitCode: 1 }; } let metas = discovery.value; + // Raw count before any --here scoping — lets us tell a brand-new user + // (zero transcripts anywhere) from "none in this window/cwd". + const totalDiscovered = discovery.value.length; // --here filter: scope to the cwd's projectHash. if (opts.here) { @@ -189,6 +192,7 @@ export async function runStats( if (!isInWindow(window, startedAt)) continue; const totals = analyzeTokens(parsed, pricing); + if (isEmptySession(totals)) continue; const dups = analyzeDuplicateReads(parsed); const idle = analyzeIdleContext(parsed); @@ -224,6 +228,11 @@ export async function runStats( stdout(formatJson(empty)); return { exitCode: 0 }; } + if (totalDiscovered === 0) { + // Brand-new user: no transcripts exist anywhere. Don't claim they do. + stdout(MESSAGES.statsNoSessionsYet()); + return { exitCode: 0 }; + } stderr(MESSAGES.statsNoSessionsInWindow(window.raw)); return { exitCode: 1 }; } diff --git a/src/commands/today.ts b/src/commands/today.ts index 78417b3..1779f94 100644 --- a/src/commands/today.ts +++ b/src/commands/today.ts @@ -12,7 +12,7 @@ import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; import { getAgentById } from "../modules/agents/registry.js"; import type { AgentId } from "../modules/agents/types.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { runToday, type TodaySession } from "../modules/today/runToday.js"; import { formatTodayTerminal } from "../modules/today/format-terminal.js"; @@ -70,6 +70,7 @@ export async function runTodayCmd( const startedAt = parsed.startedAt ?? new Date(meta.mtimeMs).toISOString(); const tokens = analyzeTokens(parsed, pricing); + if (isEmptySession(tokens)) continue; const dups = analyzeDuplicateReads(parsed); const totalTokens = tokens.inputTokens + diff --git a/src/commands/trend.ts b/src/commands/trend.ts index dafb504..135fed0 100644 --- a/src/commands/trend.ts +++ b/src/commands/trend.ts @@ -16,7 +16,7 @@ import { RealProcessEnv, type ProcessEnv } from "../lib/process.js"; import { getAgentById } from "../modules/agents/registry.js"; import type { AgentId } from "../modules/agents/types.js"; import { loadPricingForDate } from "../lib/pricing/load.js"; -import { analyzeTokens } from "../modules/transcript/analyzers/tokens.js"; +import { analyzeTokens, isEmptySession } from "../modules/transcript/analyzers/tokens.js"; import { analyzeDuplicateReads } from "../modules/transcript/analyzers/duplicateReads.js"; import { computeTrend, @@ -116,6 +116,7 @@ export async function runTrend( if (startedDay < sinceIso || startedDay > untilIso) continue; const totals = analyzeTokens(parsed, pricing); + if (isEmptySession(totals)) continue; const dups = analyzeDuplicateReads(parsed); const totalTokens = totals.inputTokens + diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..4fd66ed --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,79 @@ +/** + * `sipcode update` — shows how to update Sipcode to the latest version. + * + * Sipcode never checks for updates on its own: that would require a network + * call, and zero-network-in-normal-use is a guaranteed property of this + * codebase (enforced by tests/privacy/no-network.test.ts). So this command + * just PRINTS the npm command by default. With --run, it executes + * `npm i -g sipcode@latest` locally on the user's behalf — the user's own npm + * talks to the registry, Sipcode itself still phones nothing home. + */ +import { ASSERT_NO_NETWORK } from "../lib/privacy.js"; +void ASSERT_NO_NETWORK; +import { spawnSync } from "node:child_process"; + +export interface UpdateOptions { + /** Execute the update instead of just printing it. */ + run?: boolean; +} + +export interface UpdateDeps { + stdout?: (s: string) => void; + stderr?: (s: string) => void; + /** Current installed version, injected from cli.ts (reads package.json). */ + version?: string; + /** + * Injectable npm runner. Tests pass a fake so no real process spawns. + * Returns the child's exit status (null if it was killed by a signal). + */ + runNpm?: (args: readonly string[]) => { status: number | null }; +} + +export interface UpdateResult { + exitCode: 0 | 1; +} + +const UPDATE_CMD = "npm i -g sipcode@latest"; +const CHANGELOG_URL = + "https://github.com/Anuj7411/sipcode/blob/main/CHANGELOG.md"; + +/** Default npm runner. Resolves npm.cmd on Windows (no shell — DEP0190 safe). */ +function defaultRunNpm(args: readonly string[]): { status: number | null } { + const npmBin = process.platform === "win32" ? "npm.cmd" : "npm"; + const res = spawnSync(npmBin, [...args], { stdio: "inherit" }); + return { status: res.status }; +} + +export async function runUpdate( + opts: UpdateOptions, + deps: UpdateDeps = {}, +): Promise { + const stdout = deps.stdout ?? ((s: string) => process.stdout.write(s + "\n")); + const stderr = deps.stderr ?? ((s: string) => process.stderr.write(s + "\n")); + const version = deps.version ?? "unknown"; + + if (!opts.run) { + stdout(`sipcode is currently v${version}.`); + stdout(""); + stdout("to update to the latest version, run:"); + stdout(` ${UPDATE_CMD}`); + stdout(""); + stdout("sipcode never checks for updates on its own (zero network calls)."); + stdout(`see what changed: ${CHANGELOG_URL}`); + stdout("or re-run with --run to update now."); + return { exitCode: 0 }; + } + + const runNpm = deps.runNpm ?? defaultRunNpm; + stdout(`updating sipcode from v${version} to latest...`); + const result = runNpm(["i", "-g", "sipcode@latest"]); + if (result.status === 0) { + stdout("done. run `sipcode --version` to confirm."); + stdout(`see what changed: ${CHANGELOG_URL}`); + return { exitCode: 0 }; + } + stderr("update failed. try running it manually:"); + stderr(` ${UPDATE_CMD}`); + stderr(`on macOS/Linux you may need: sudo ${UPDATE_CMD}`); + return { exitCode: 1 }; +} diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 362ba2a..8a105fc 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -272,6 +272,15 @@ export const MESSAGES = { `next: npx sipcode stats --group-by project`, ].join("\n"), + statsNoSessionsYet: () => + [ + `no Claude Code sessions found yet.`, + ``, + `why: sipcode reads the transcripts Claude Code writes per session, and none exist yet.`, + ``, + `fix: open Claude Code, run any prompt, then come back and run this again.`, + ].join("\n"), + statsNoSessionsInWindow: (raw: string) => [ `no sessions found in the last ${raw}.`, diff --git a/src/lib/pricing/load.ts b/src/lib/pricing/load.ts index 617bf65..e932877 100644 --- a/src/lib/pricing/load.ts +++ b/src/lib/pricing/load.ts @@ -67,6 +67,7 @@ export function pricingAgeDays(pricing: PricingFile, now: Date): number { const MODEL_ALIASES: Record = { // Internal/dotted Claude Code model names → canonical pricing keys. + "claude-opus-4-8": "claude-opus-4", "claude-opus-4-7": "claude-opus-4", "claude-opus-4-6": "claude-opus-4", "claude-opus-4-5": "claude-opus-4", diff --git a/src/modules/estimate/format-terminal.ts b/src/modules/estimate/format-terminal.ts index 95f9e0f..1b1a23e 100644 --- a/src/modules/estimate/format-terminal.ts +++ b/src/modules/estimate/format-terminal.ts @@ -38,7 +38,7 @@ function money(n: number): string { /** Short label for a model id — used in the table. */ function modelLabel(m: string): string { - if (m.includes("opus")) return "opus 4.7 "; + if (m.includes("opus")) return "opus 4.8 "; if (m.includes("sonnet")) return "sonnet 4.6"; if (m.includes("haiku")) return "haiku 4.5 "; return m.padEnd(10); diff --git a/src/modules/estimate/predict.ts b/src/modules/estimate/predict.ts index 51a5009..eeb5610 100644 --- a/src/modules/estimate/predict.ts +++ b/src/modules/estimate/predict.ts @@ -31,7 +31,7 @@ import type { /** The three canonical models we always predict against. */ export const PREDICTION_MODELS: ReadonlyArray = [ - "claude-opus-4-7", + "claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5", ]; diff --git a/src/modules/estimate/recommend.ts b/src/modules/estimate/recommend.ts index f42c9ab..b6a579f 100644 --- a/src/modules/estimate/recommend.ts +++ b/src/modules/estimate/recommend.ts @@ -26,7 +26,7 @@ export function recommend( complexity: ComplexityScore, anchors: HistoricalAnchors, ): Recommendation { - const opus = predictions.find((p) => p.model === "claude-opus-4-7"); + const opus = predictions.find((p) => p.model === "claude-opus-4-8"); const sonnet = predictions.find((p) => p.model === "claude-sonnet-4-6"); const haiku = predictions.find((p) => p.model === "claude-haiku-4-5"); diff --git a/src/modules/transcript/analyzers/tokens.ts b/src/modules/transcript/analyzers/tokens.ts index 677ae76..1b90ba3 100644 --- a/src/modules/transcript/analyzers/tokens.ts +++ b/src/modules/transcript/analyzers/tokens.ts @@ -42,6 +42,22 @@ function costForTurn( ); } +/** + * True if a session has no real token activity — either usage data was entirely + * absent (missingAllUsage) or every billable token field is zero. Used to filter + * synthetic / observer / empty sessions out of counts and "latest session" picks. + */ +export function isEmptySession(totals: TokenTotals): boolean { + if (totals.missingAllUsage) return true; + return ( + totals.inputTokens + + totals.outputTokens + + totals.cacheReadTokens + + totals.cacheCreationTokens === + 0 + ); +} + export function analyzeTokens( session: ParsedSession, pricing: PricingFile, diff --git a/src/modules/transcript/discover.ts b/src/modules/transcript/discover.ts index 2ee2e41..44ff85e 100644 --- a/src/modules/transcript/discover.ts +++ b/src/modules/transcript/discover.ts @@ -34,9 +34,25 @@ export async function projectsDirExists( return fs.exists(dir); } +/** + * Project dirs created by observer/telemetry plugins (e.g. claude-mem) that + * hold synthetic, zero-work sessions. They pollute session counts and the + * "latest session" pick, so they are excluded from discovery entirely. + */ +const OBSERVER_DIR_PATTERNS: readonly RegExp[] = [ + /-observer-sessions$/i, + /claude-mem-observer/i, +]; + +/** True if a project-hash dir name belongs to an observer/telemetry plugin. */ +export function isObserverProjectDir(name: string): boolean { + return OBSERVER_DIR_PATTERNS.some((re) => re.test(name)); +} + /** * List all sessions across all project directories. * Sorted by mtime descending (most recent first). + * Observer/telemetry project dirs (see {@link isObserverProjectDir}) are skipped. */ export async function listAllSessions( fs: FileSystem, @@ -47,6 +63,7 @@ export async function listAllSessions( const projectDirs = await fs.readDir(projectsDir); for (const proj of projectDirs) { if (!proj.isDirectory) continue; + if (isObserverProjectDir(proj.name)) continue; const projPath = path.join(projectsDir, proj.name); let entries; try { diff --git a/tests/commands/update.test.ts b/tests/commands/update.test.ts new file mode 100644 index 0000000..653e5cf --- /dev/null +++ b/tests/commands/update.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { runUpdate } from "../../src/commands/update.js"; + +function capture() { + const out: string[] = []; + const err: string[] = []; + return { + out, + err, + stdout: (s: string) => out.push(s), + stderr: (s: string) => err.push(s), + }; +} + +describe("runUpdate", () => { + it("default: prints version + npm command + changelog, never spawns npm", async () => { + const c = capture(); + const runNpm = vi.fn(); + const r = await runUpdate( + {}, + { stdout: c.stdout, stderr: c.stderr, version: "1.6.16", runNpm }, + ); + const text = c.out.join("\n"); + expect(r.exitCode).toBe(0); + expect(text).toContain("v1.6.16"); + expect(text).toContain("npm i -g sipcode@latest"); + expect(text).toContain("CHANGELOG.md"); + expect(text).toContain("zero network calls"); + expect(runNpm).not.toHaveBeenCalled(); + }); + + it("--run success: invokes npm with the right args and reports done", async () => { + const c = capture(); + const runNpm = vi.fn(() => ({ status: 0 })); + const r = await runUpdate( + { run: true }, + { stdout: c.stdout, stderr: c.stderr, version: "1.6.16", runNpm }, + ); + expect(runNpm).toHaveBeenCalledWith(["i", "-g", "sipcode@latest"]); + expect(r.exitCode).toBe(0); + expect(c.out.join("\n")).toContain("done"); + }); + + it("--run failure: non-zero status returns exit 1 with manual fallback", async () => { + const c = capture(); + const runNpm = vi.fn(() => ({ status: 1 })); + const r = await runUpdate( + { run: true }, + { stdout: c.stdout, stderr: c.stderr, version: "1.6.16", runNpm }, + ); + expect(r.exitCode).toBe(1); + expect(c.err.join("\n")).toContain("npm i -g sipcode@latest"); + }); +}); diff --git a/tests/integration/estimate.integration.test.ts b/tests/integration/estimate.integration.test.ts index cf73527..e1db3fe 100644 --- a/tests/integration/estimate.integration.test.ts +++ b/tests/integration/estimate.integration.test.ts @@ -156,7 +156,7 @@ describe("sipcode estimate (integration)", () => { .split("\n") .filter((l) => /\bopus 4|sonnet 4|haiku 4\b/.test(l) && /\$/.test(l)); expect(tableLines.length).toBeGreaterThan(0); - expect(tableLines.every((l) => /opus 4.7/.test(l))).toBe(true); + expect(tableLines.every((l) => /opus 4.8/.test(l))).toBe(true); }); it("falls back to quick scan when no manifest", async () => { diff --git a/tests/integration/stats.integration.test.ts b/tests/integration/stats.integration.test.ts index d8b1a75..6154c6f 100644 --- a/tests/integration/stats.integration.test.ts +++ b/tests/integration/stats.integration.test.ts @@ -218,6 +218,27 @@ describe("runStats integration", () => { expect(err.join("\n")).toContain("[E003]"); }); + it("empty projects dir (brand-new user) is a friendly exit 0, not a false 'transcripts exist'", async () => { + const out: string[] = []; + const err: string[] = []; + const fs = new InMemoryFs(); + fs.mkdir("/home/u/.claude/projects"); // dir exists, but holds no transcripts + const result = await runStats( + { since: "30d" }, + { + fs, + env: makeEnv(), + clock: new FakeClock(NOW), + stdout: (s) => out.push(s), + stderr: (s) => err.push(s), + }, + ); + expect(result.exitCode).toBe(0); + expect(out.join("\n")).toContain("no Claude Code sessions found yet"); + // Must NOT claim transcripts exist when none do. + expect(out.join("\n")).not.toContain("transcripts exist"); + }); + it("--html writes .sipcode/stats.html via injected writeFile", async () => { const wrote: { path: string; content: string } = { path: "", content: "" }; const out: string[] = []; diff --git a/tests/modules/estimate/__snapshots__/format-terminal.test.ts.snap b/tests/modules/estimate/__snapshots__/format-terminal.test.ts.snap index bb6462f..1d8cc2e 100644 --- a/tests/modules/estimate/__snapshots__/format-terminal.test.ts.snap +++ b/tests/modules/estimate/__snapshots__/format-terminal.test.ts.snap @@ -9,7 +9,7 @@ historical anchors: 4 matched (median 187,000 tokens) — confidence: high estimated cost (0.7×-1.4× confidence band): - opus 4.7 $1.83 ($1.28 - $2.56) 195,000 tokens (137,000 - 274,000) + opus 4.8 $1.83 ($1.28 - $2.56) 195,000 tokens (137,000 - 274,000) sonnet 4.6 $0.37 ($0.26 - $0.51) 195,000 tokens (137,000 - 274,000) haiku 4.5 $0.10 ($0.07 - $0.14) 195,000 tokens (137,000 - 274,000) diff --git a/tests/modules/estimate/recommend.test.ts b/tests/modules/estimate/recommend.test.ts index 29951d8..8298be7 100644 --- a/tests/modules/estimate/recommend.test.ts +++ b/tests/modules/estimate/recommend.test.ts @@ -83,7 +83,7 @@ describe("recommend", () => { classifyTask("refactor the auth module"), goodAnchors, ); - expect(r.model).toBe("claude-opus-4-7"); + expect(r.model).toBe("claude-opus-4-8"); }); it("high debug → sonnet (iterates a lot)", () => { diff --git a/tests/modules/transcript/analyzers/empty-session.test.ts b/tests/modules/transcript/analyzers/empty-session.test.ts new file mode 100644 index 0000000..fd21358 --- /dev/null +++ b/tests/modules/transcript/analyzers/empty-session.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + isEmptySession, + type TokenTotals, +} from "../../../../src/modules/transcript/analyzers/tokens.js"; + +function totals(p: Partial): TokenTotals { + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + durationSec: 0, + toolCallCount: 0, + outputRatio: 0, + estCostUSD: 0, + costByModel: [], + missingAllUsage: false, + ...p, + }; +} + +describe("isEmptySession", () => { + it("is true when usage data is entirely missing", () => { + expect(isEmptySession(totals({ missingAllUsage: true }))).toBe(true); + }); + + it("is true when every billable token field is zero", () => { + expect(isEmptySession(totals({}))).toBe(true); + }); + + it("is false when any billable tokens are present", () => { + expect(isEmptySession(totals({ inputTokens: 5 }))).toBe(false); + expect(isEmptySession(totals({ outputTokens: 1 }))).toBe(false); + expect(isEmptySession(totals({ cacheReadTokens: 10 }))).toBe(false); + expect(isEmptySession(totals({ cacheCreationTokens: 3 }))).toBe(false); + }); +}); diff --git a/tests/modules/transcript/discover.observer.test.ts b/tests/modules/transcript/discover.observer.test.ts new file mode 100644 index 0000000..11b4119 --- /dev/null +++ b/tests/modules/transcript/discover.observer.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { InMemoryFs } from "../../../src/lib/fs.js"; +import { + isObserverProjectDir, + listAllSessions, +} from "../../../src/modules/transcript/discover.js"; + +describe("isObserverProjectDir", () => { + it("matches observer/telemetry project dirs", () => { + expect( + isObserverProjectDir("C--Users-ojhaa--claude-mem-observer-sessions"), + ).toBe(true); + expect(isObserverProjectDir("something-observer-sessions")).toBe(true); + expect(isObserverProjectDir("x-claude-mem-observer-anything")).toBe(true); + }); + + it("does not match normal project dirs", () => { + expect(isObserverProjectDir("C--Projects-Sipcode")).toBe(false); + expect(isObserverProjectDir("proj-a")).toBe(false); + // "observer" alone is not enough — needs the -observer-sessions suffix + // or the claude-mem-observer marker. + expect(isObserverProjectDir("my-observer-app")).toBe(false); + }); +}); + +describe("listAllSessions observer exclusion", () => { + it("skips sessions inside observer project dirs", async () => { + const fs = new InMemoryFs(); + fs.writeFile("/home/u/.claude/projects/real-proj/aaa.jsonl", "{}", 100); + fs.writeFile( + "/home/u/.claude/projects/x--claude-mem-observer-sessions/bbb.jsonl", + "{}", + 100, + ); + const sessions = await listAllSessions(fs, "/home/u/.claude/projects"); + const ids = sessions.map((s) => s.sessionId); + expect(ids).toContain("aaa"); + expect(ids).not.toContain("bbb"); + }); +});