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 @@
-
+
@@ -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");
+ });
+});