tokentrace 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,28 @@
2
2
 
3
3
  All notable changes to TokenTrace are documented here.
4
4
 
5
- ## Unreleased
5
+ ## [0.7.0] - 2026-05-12
6
+
7
+ ### Added
8
+
9
+ - 0.7.0 Usage Intelligence roadmap for local guardrails, savings review, session comparison, project intelligence, and daily digest work.
10
+ - Local monthly cost and token guardrails stored in Settings.
11
+ - Overview Monthly Guardrails panel showing month-to-date local usage against configured limits.
12
+ - Recommendation rules for monthly guardrails that are near or over limit.
13
+ - Evidence-backed Review Queue on Insights, ranked from guardrails, unknown cost repair, high-impact sessions, dominant projects, model review, and cache reuse.
14
+ - `tokentrace insights --json` now includes the Review Queue for local automation.
15
+ - `tokentrace digest` and `tokentrace digest --json` for current-month local usage, guardrails, top review item, unknown-cost count, top project, and latest scan status.
16
+ - Session Comparison Flags on Sessions to highlight token and cost outliers compared with matching project, tool, and primary-model peers.
17
+ - Project Signals on Projects for dominant usage, unknown cost, estimated-token confidence, and model concentration patterns.
18
+
19
+ ### Changed
20
+
21
+ - Renamed the Insights page header to Usage Intelligence to match the 0.7.0 product theme.
22
+ - Refreshed README screenshots for Overview, Usage Intelligence, Sessions, Projects, and local guardrail Settings using public-safe synthetic local data.
23
+
24
+ ### Fixed
25
+
26
+ - Increased the forced packed-install smoke test timeout so native SQLite dependency installation is not killed on slower machines.
6
27
 
7
28
  ## [0.6.0] - 2026-05-10
8
29
 
package/README.md CHANGED
@@ -8,7 +8,7 @@ Local-first analytics for AI CLI usage. TokenTrace scans local CLI logs, normali
8
8
 
9
9
  TokenTrace is designed for local development machines first, with macOS-oriented defaults. It does not require a cloud account and does not send telemetry or logs anywhere.
10
10
 
11
- ![TokenTrace overview dashboard](docs/assets/overview.png)
11
+ ![TokenTrace 0.7.0 overview dashboard](docs/assets/overview-0.7.0.png)
12
12
 
13
13
  ## Start In Seconds
14
14
 
@@ -37,6 +37,8 @@ tokentrace serve --port 3210 --no-open
37
37
  tokentrace scan # Scan local AI CLI usage logs
38
38
  tokentrace doctor --json
39
39
  # Inspect scan health and repair recommendations
40
+ tokentrace digest --json
41
+ # Print current-month local usage digest
40
42
  tokentrace insights --json
41
43
  # Print local recommendations as JSON
42
44
  tokentrace status --json
@@ -82,7 +84,7 @@ npm run db:migrate # Create/update local SQLite tables
82
84
  npm run db:seed # Seed editable provider/model prices
83
85
  npm run reset # Clear imported data and scan history
84
86
  npm test # Run parser and cost tests
85
- npm run verify # Run Vitest and TypeScript checks
87
+ npm run verify # Run Vitest, TypeScript, and ESLint checks
86
88
  npm run package:test # Verify, build, and dry-run the npm package
87
89
  npm run package:inspect
88
90
  # Check package transparency guardrails
@@ -129,6 +131,10 @@ Default discovery checks these locations when present:
129
131
 
130
132
  Use **Settings** in the dashboard to add custom folders, toggle raw message storage, and trigger scans. Use **Doctor**, **Discovery**, **Parser Debug**, and **Raw Data** to inspect discovered files, parser decisions, warnings, failures, extracted metadata, and confidence levels.
131
133
 
134
+ Settings also supports optional local monthly usage guardrails. Set a cost
135
+ limit, token limit, or both, and Overview will show month-to-date progress from
136
+ imported local CLI usage.
137
+
132
138
  ## Ingestion Architecture
133
139
 
134
140
  TokenTrace's primary ingestion architecture is direct local filesystem ingestion:
@@ -191,6 +197,22 @@ Codex CLI status-line integration is intentionally deferred until its status-lin
191
197
 
192
198
  ## Screenshots
193
199
 
200
+ Usage Intelligence views from `0.7.0`:
201
+
202
+ ![TokenTrace 0.7.0 overview dashboard](docs/assets/overview-0.7.0.png)
203
+
204
+ ![TokenTrace 0.7.0 Usage Intelligence review queue](docs/assets/usage-intelligence-0.7.0.png)
205
+
206
+ ![TokenTrace 0.7.0 session comparison flags](docs/assets/sessions-0.7.0.png)
207
+
208
+ ![TokenTrace 0.7.0 project signals](docs/assets/projects-0.7.0.png)
209
+
210
+ ![TokenTrace 0.7.0 local guardrail settings](docs/assets/settings-guardrails-0.7.0.png)
211
+
212
+ Stable Daily Tool views from `0.6.0`:
213
+
214
+ ![TokenTrace 0.6.0 Scan Doctor](docs/assets/doctor-0.6.0.png)
215
+
194
216
  CLI startup and help:
195
217
 
196
218
  ![TokenTrace CLI help](docs/assets/cli-help.gif)
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { getDatabasePath } from "@/src/db/client";
3
- import { getAppSettings, saveAppSettings } from "@/src/db/settings";
3
+ import { getAppSettings, normalizeUsageGuardrails, saveAppSettings } from "@/src/db/settings";
4
4
 
5
5
  export const dynamic = "force-dynamic";
6
6
 
@@ -18,7 +18,8 @@ export async function PUT(request: Request) {
18
18
  : [];
19
19
  const saved = saveAppSettings({
20
20
  customFolders,
21
- storeRawMessageContent: Boolean(body.storeRawMessageContent)
21
+ storeRawMessageContent: Boolean(body.storeRawMessageContent),
22
+ usageGuardrails: normalizeUsageGuardrails(body.usageGuardrails)
22
23
  });
23
24
 
24
25
  return NextResponse.json(saved);
@@ -1,6 +1,9 @@
1
+ import Link from "next/link";
1
2
  import { AlertTriangle, CheckCircle2, Info, Lightbulb } from "lucide-react";
2
3
  import { Badge } from "@/components/ui/badge";
4
+ import { Button } from "@/components/ui/button";
3
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
4
7
  import { PageHeader } from "@/components/ui/typography";
5
8
  import { getAnalyticsData } from "@/src/lib/analytics";
6
9
 
@@ -24,10 +27,60 @@ export default function OptimisationPage() {
24
27
  return (
25
28
  <div className="space-y-6">
26
29
  <PageHeader
27
- title="Optimisation Insights"
28
- description="Deterministic recommendations based on imported usage patterns."
30
+ title="Usage Intelligence"
31
+ description="Deterministic local review queue based on guardrails, repair work, and usage impact."
29
32
  />
30
33
 
34
+ <Card>
35
+ <CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
36
+ <div>
37
+ <CardTitle>Review Queue</CardTitle>
38
+ <CardDescription>
39
+ Evidence-backed next actions ordered by guardrails, repair work, and usage impact.
40
+ </CardDescription>
41
+ </div>
42
+ <Button asChild variant="outline" size="sm">
43
+ <Link href="/sessions">Open sessions</Link>
44
+ </Button>
45
+ </CardHeader>
46
+ <CardContent className="table-scroll">
47
+ <Table>
48
+ <TableHeader>
49
+ <TableRow>
50
+ <TableHead>Priority</TableHead>
51
+ <TableHead>Category</TableHead>
52
+ <TableHead>Review item</TableHead>
53
+ <TableHead>Impact</TableHead>
54
+ <TableHead>Action</TableHead>
55
+ </TableRow>
56
+ </TableHeader>
57
+ <TableBody>
58
+ {data.reviewQueue.map((item) => (
59
+ <TableRow key={item.id}>
60
+ <TableCell>
61
+ <Badge variant={severityVariant[item.severity]}>{item.severity}</Badge>
62
+ </TableCell>
63
+ <TableCell className="capitalize">{item.category.replace("-", " ")}</TableCell>
64
+ <TableCell className="max-w-xl">
65
+ <div className="font-medium">{item.title}</div>
66
+ <div className="mt-1 text-xs leading-relaxed text-muted-foreground">{item.evidence}</div>
67
+ </TableCell>
68
+ <TableCell>
69
+ <div className="text-sm font-medium">{item.impactValue}</div>
70
+ <div className="text-xs text-muted-foreground">{item.impactLabel}</div>
71
+ </TableCell>
72
+ <TableCell>
73
+ <Link href={item.href} className="font-medium text-primary underline-offset-4 hover:underline">
74
+ {item.action}
75
+ </Link>
76
+ </TableCell>
77
+ </TableRow>
78
+ ))}
79
+ </TableBody>
80
+ </Table>
81
+ </CardContent>
82
+ </Card>
83
+
31
84
  <div className="grid gap-4">
32
85
  {data.insights.map((insight) => {
33
86
  const Icon = severityIcon[insight.severity] ?? CheckCircle2;
package/app/page.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import Link from "next/link";
2
- import { ArrowRight, Coins, Database, MessageSquare, Minus, Sparkles, TrendingDown, TrendingUp } from "lucide-react";
2
+ import { ArrowRight, Coins, Database, Gauge, MessageSquare, Minus, Sparkles, TrendingDown, TrendingUp } from "lucide-react";
3
3
  import { RankBarChart } from "@/components/charts/rank-bar-chart";
4
4
  import { TrendChart } from "@/components/charts/trend-chart";
5
5
  import { PeriodFilter } from "@/components/period-filter";
@@ -16,6 +16,7 @@ import { buildFirstRunStatus, type FirstRunStatus } from "@/src/lib/first-run-st
16
16
  import { resolveDateRange } from "@/src/lib/date-range";
17
17
  import { formatCurrency, formatTokens, percent } from "@/src/lib/format";
18
18
  import { cn } from "@/src/lib/utils";
19
+ import type { UsageGuardrailMetric } from "@/src/lib/usage-guardrails";
19
20
 
20
21
  export const dynamic = "force-dynamic";
21
22
 
@@ -127,6 +128,98 @@ function DeltaMetric({
127
128
  );
128
129
  }
129
130
 
131
+ function guardrailBadgeVariant(status: UsageGuardrailMetric["status"]) {
132
+ if (status === "exceeded") return "destructive";
133
+ if (status === "warning") return "warning";
134
+ if (status === "ok") return "success";
135
+ return "secondary";
136
+ }
137
+
138
+ function guardrailLabel(status: UsageGuardrailMetric["status"]) {
139
+ if (status === "exceeded") return "exceeded";
140
+ if (status === "warning") return "watch";
141
+ if (status === "ok") return "within limit";
142
+ return "not set";
143
+ }
144
+
145
+ function GuardrailMetricPanel({
146
+ label,
147
+ metric,
148
+ formatValue
149
+ }: {
150
+ label: string;
151
+ metric: UsageGuardrailMetric;
152
+ formatValue: (value: number | null) => string;
153
+ }) {
154
+ const cappedPercent = Math.min(1, Math.max(0, metric.percent));
155
+ const barClass =
156
+ metric.status === "exceeded"
157
+ ? "bg-destructive"
158
+ : metric.status === "warning"
159
+ ? "bg-amber-500"
160
+ : "bg-primary";
161
+
162
+ return (
163
+ <div className="min-w-0 border-t p-3 first:border-t-0 md:border-l md:border-t-0 md:first:border-l-0">
164
+ <div className="flex flex-wrap items-center justify-between gap-2">
165
+ <FieldLabel>{label}</FieldLabel>
166
+ <Badge variant={guardrailBadgeVariant(metric.status)}>{guardrailLabel(metric.status)}</Badge>
167
+ </div>
168
+ <DataValue className="mt-1" size="md">
169
+ {metric.configured ? `${formatValue(metric.used)} / ${formatValue(metric.limit)}` : formatValue(metric.used)}
170
+ </DataValue>
171
+ <div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
172
+ <div className={cn("h-full rounded-full", barClass)} style={{ width: `${cappedPercent * 100}%` }} />
173
+ </div>
174
+ <div className="mt-2 text-xs text-muted-foreground">
175
+ {metric.configured
176
+ ? `${percent(metric.percent)} used, ${formatValue(metric.remaining)} remaining`
177
+ : "Set a local monthly limit in Settings."}
178
+ </div>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ function UsageGuardrailsPanel({
184
+ progress
185
+ }: {
186
+ progress: {
187
+ monthLabel: string;
188
+ cost: UsageGuardrailMetric;
189
+ tokens: UsageGuardrailMetric;
190
+ };
191
+ }) {
192
+ const configured = progress.cost.configured || progress.tokens.configured;
193
+ return (
194
+ <Card>
195
+ <CardHeader className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
196
+ <div>
197
+ <CardTitle className="flex items-center gap-2">
198
+ <Gauge className="h-4 w-4 text-primary" />
199
+ Monthly Guardrails
200
+ </CardTitle>
201
+ <CardDescription>
202
+ {configured
203
+ ? `Month-to-date checks for ${progress.monthLabel}, based only on imported local CLI usage.`
204
+ : "Optional local limits for monthly cost and tokens."}
205
+ </CardDescription>
206
+ </div>
207
+ <Button asChild variant="outline" size="sm">
208
+ <Link href="/settings">
209
+ Configure <ArrowRight className="h-4 w-4" />
210
+ </Link>
211
+ </Button>
212
+ </CardHeader>
213
+ <CardContent className="p-0">
214
+ <div className="grid border-t md:grid-cols-2">
215
+ <GuardrailMetricPanel label="Cost" metric={progress.cost} formatValue={formatCurrency} />
216
+ <GuardrailMetricPanel label="Tokens" metric={progress.tokens} formatValue={formatTokens} />
217
+ </div>
218
+ </CardContent>
219
+ </Card>
220
+ );
221
+ }
222
+
130
223
  function FirstRunPanel({ status }: { status: FirstRunStatus }) {
131
224
  return (
132
225
  <Card className={status.tone === "warning" ? "border-amber-300 bg-amber-50/50" : undefined}>
@@ -253,6 +346,8 @@ export default async function OverviewPage({ searchParams }: OverviewPageProps)
253
346
  </CardContent>
254
347
  </Card>
255
348
 
349
+ <UsageGuardrailsPanel progress={data.usageGuardrails} />
350
+
256
351
  <Card>
257
352
  <CardHeader>
258
353
  <h2 className="text-sm font-semibold leading-tight">Recommended Next Actions</h2>
@@ -1,6 +1,7 @@
1
1
  import Link from "next/link";
2
2
  import { RankBarChart } from "@/components/charts/rank-bar-chart";
3
3
  import { TrendChart } from "@/components/charts/trend-chart";
4
+ import { Badge } from "@/components/ui/badge";
4
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
6
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
6
7
  import { DataValue, MonoText, PageHeader } from "@/components/ui/typography";
@@ -20,6 +21,56 @@ export default function ProjectAnalyticsPage() {
20
21
  description="Group usage by local repository or inferred project path."
21
22
  />
22
23
 
24
+ {data.projectSignals.length ? (
25
+ <Card>
26
+ <CardHeader>
27
+ <CardTitle>Project Signals</CardTitle>
28
+ <CardDescription>
29
+ Local project-level patterns that deserve review before optimizing individual sessions.
30
+ </CardDescription>
31
+ </CardHeader>
32
+ <CardContent className="table-scroll">
33
+ <Table>
34
+ <TableHeader>
35
+ <TableRow>
36
+ <TableHead>Priority</TableHead>
37
+ <TableHead>Signal</TableHead>
38
+ <TableHead>Project</TableHead>
39
+ <TableHead>Metric</TableHead>
40
+ <TableHead>Action</TableHead>
41
+ </TableRow>
42
+ </TableHeader>
43
+ <TableBody>
44
+ {data.projectSignals.slice(0, 8).map((signal) => (
45
+ <TableRow key={signal.id}>
46
+ <TableCell>
47
+ <Badge variant={signal.severity === "high" ? "destructive" : signal.severity === "medium" ? "warning" : "secondary"}>
48
+ {signal.severity}
49
+ </Badge>
50
+ </TableCell>
51
+ <TableCell className="capitalize">{signal.signal}</TableCell>
52
+ <TableCell className="max-w-lg">
53
+ <div className="font-medium">{signal.project}</div>
54
+ <MonoText className="mt-1 block truncate text-muted-foreground">{signal.path}</MonoText>
55
+ <div className="mt-1 text-xs text-muted-foreground">{signal.evidence}</div>
56
+ </TableCell>
57
+ <TableCell>
58
+ <div className="font-medium">{signal.metricValue}</div>
59
+ <div className="text-xs text-muted-foreground">{signal.metricLabel}</div>
60
+ </TableCell>
61
+ <TableCell>
62
+ <Link href={signal.href} className="font-medium text-primary underline-offset-4 hover:underline">
63
+ {signal.action}
64
+ </Link>
65
+ </TableCell>
66
+ </TableRow>
67
+ ))}
68
+ </TableBody>
69
+ </Table>
70
+ </CardContent>
71
+ </Card>
72
+ ) : null}
73
+
23
74
  <div className="grid gap-4 lg:grid-cols-[1fr_1.4fr]">
24
75
  <Card>
25
76
  <CardHeader>
@@ -1,6 +1,11 @@
1
+ import Link from "next/link";
1
2
  import { SessionExplorer } from "@/components/session-explorer";
3
+ import { Badge } from "@/components/ui/badge";
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
2
6
  import { PageHeader } from "@/components/ui/typography";
3
7
  import { getAnalyticsData } from "@/src/lib/analytics";
8
+ import { formatCurrency, formatTokens } from "@/src/lib/format";
4
9
 
5
10
  export const dynamic = "force-dynamic";
6
11
 
@@ -24,6 +29,56 @@ export default async function SessionsPage({
24
29
  title="Session Explorer"
25
30
  description="Search and filter imported sessions by tool, model, project, cost, and estimation status."
26
31
  />
32
+ {data.sessionComparisons.length ? (
33
+ <Card>
34
+ <CardHeader>
35
+ <CardTitle>Session Comparison Flags</CardTitle>
36
+ <CardDescription>
37
+ Sessions that are unusual compared with the same project, tool, and primary model.
38
+ </CardDescription>
39
+ </CardHeader>
40
+ <CardContent className="table-scroll">
41
+ <Table>
42
+ <TableHeader>
43
+ <TableRow>
44
+ <TableHead>Priority</TableHead>
45
+ <TableHead>Flag</TableHead>
46
+ <TableHead>Session</TableHead>
47
+ <TableHead>Tokens</TableHead>
48
+ <TableHead>Cost</TableHead>
49
+ <TableHead>Peer median</TableHead>
50
+ <TableHead>Action</TableHead>
51
+ </TableRow>
52
+ </TableHeader>
53
+ <TableBody>
54
+ {data.sessionComparisons.slice(0, 6).map((row) => (
55
+ <TableRow key={row.sessionId}>
56
+ <TableCell>
57
+ <Badge variant={row.severity === "high" ? "destructive" : "warning"}>{row.severity}</Badge>
58
+ </TableCell>
59
+ <TableCell>{row.flag}</TableCell>
60
+ <TableCell className="max-w-lg">
61
+ <div className="font-medium">{row.title}</div>
62
+ <div className="mt-1 text-xs text-muted-foreground">{row.project} / {row.tool} / {row.models}</div>
63
+ </TableCell>
64
+ <TableCell>{formatTokens(row.totalTokens)}</TableCell>
65
+ <TableCell>{formatCurrency(row.cost)}</TableCell>
66
+ <TableCell>
67
+ <div>{formatTokens(row.peerMedianTokens)}</div>
68
+ <div className="text-xs text-muted-foreground">{row.peerSessions} peer sessions</div>
69
+ </TableCell>
70
+ <TableCell>
71
+ <Link href={row.href} className="font-medium text-primary underline-offset-4 hover:underline">
72
+ Compare evidence
73
+ </Link>
74
+ </TableCell>
75
+ </TableRow>
76
+ ))}
77
+ </TableBody>
78
+ </Table>
79
+ </CardContent>
80
+ </Card>
81
+ ) : null}
27
82
  <SessionExplorer
28
83
  sessions={data.sessions}
29
84
  initialProject={params?.project}
package/bin/tokentrace.js CHANGED
@@ -28,6 +28,8 @@ Usage:
28
28
  tokentrace scan Scan local AI CLI usage logs
29
29
  tokentrace doctor --json
30
30
  Inspect scan health and repair recommendations
31
+ tokentrace digest --json
32
+ Print current-month local usage digest
31
33
  tokentrace insights --json
32
34
  Print local recommendations as JSON
33
35
  tokentrace status --json
@@ -365,6 +367,11 @@ async function doctor(args) {
365
367
  await runNodeScript("doctor", args);
366
368
  }
367
369
 
370
+ async function digest(args) {
371
+ await initializeDatabase({ quiet: true, refreshPrices: false });
372
+ await runNodeScript("digest", args);
373
+ }
374
+
368
375
  async function insights(args) {
369
376
  await initializeDatabase({ quiet: true, refreshPrices: false });
370
377
  await runNodeScript("insights", args);
@@ -527,6 +534,10 @@ async function main() {
527
534
  await doctor(args);
528
535
  return;
529
536
  }
537
+ if (command === "digest") {
538
+ await digest(args);
539
+ return;
540
+ }
530
541
  if (command === "insights") {
531
542
  await insights(args);
532
543
  return;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useTransition } from "react";
4
- import { FolderPlus, Play, RotateCcw, ShieldCheck, Trash2 } from "lucide-react";
4
+ import { FolderPlus, Gauge, Play, RotateCcw, ShieldCheck, Trash2 } from "lucide-react";
5
5
  import type { ScanHealth } from "@/src/lib/scan-health";
6
6
  import { formatAppVersion } from "@/src/lib/app-version";
7
7
  import { formatDate, percent } from "@/src/lib/format";
@@ -15,6 +15,10 @@ import { DataValue, FieldLabel, MonoText } from "@/components/ui/typography";
15
15
  type SettingsPayload = {
16
16
  customFolders: string[];
17
17
  storeRawMessageContent: boolean;
18
+ usageGuardrails: {
19
+ monthlyCostLimitUsd: number | null;
20
+ monthlyTokenLimit: number | null;
21
+ };
18
22
  databasePath: string;
19
23
  appVersion: string;
20
24
  };
@@ -38,6 +42,13 @@ function toneVariant(tone: ScanHealth["tone"]) {
38
42
  return "secondary";
39
43
  }
40
44
 
45
+ function parseLimitInput(value: string) {
46
+ const trimmed = value.trim();
47
+ if (!trimmed) return null;
48
+ const parsed = Number(trimmed);
49
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
50
+ }
51
+
41
52
  export function SettingsPanel({
42
53
  initialSettings,
43
54
  initialScanHealth
@@ -47,6 +58,12 @@ export function SettingsPanel({
47
58
  }) {
48
59
  const [customFolders, setCustomFolders] = useState(initialSettings.customFolders);
49
60
  const [storeRaw, setStoreRaw] = useState(initialSettings.storeRawMessageContent);
61
+ const [monthlyCostLimitUsd, setMonthlyCostLimitUsd] = useState(
62
+ initialSettings.usageGuardrails.monthlyCostLimitUsd?.toString() ?? ""
63
+ );
64
+ const [monthlyTokenLimit, setMonthlyTokenLimit] = useState(
65
+ initialSettings.usageGuardrails.monthlyTokenLimit?.toString() ?? ""
66
+ );
50
67
  const [newFolder, setNewFolder] = useState("");
51
68
  const [force, setForce] = useState(false);
52
69
  const [message, setMessage] = useState("");
@@ -64,16 +81,24 @@ export function SettingsPanel({
64
81
  setCustomFolders((current) => current.filter((item) => item !== folder));
65
82
  }
66
83
 
84
+ function settingsPayload() {
85
+ return {
86
+ customFolders,
87
+ storeRawMessageContent: storeRaw,
88
+ usageGuardrails: {
89
+ monthlyCostLimitUsd: parseLimitInput(monthlyCostLimitUsd),
90
+ monthlyTokenLimit: parseLimitInput(monthlyTokenLimit)
91
+ }
92
+ };
93
+ }
94
+
67
95
  function saveSettings() {
68
96
  startTransition(async () => {
69
97
  setMessage("");
70
98
  const response = await fetch("/api/settings", {
71
99
  method: "PUT",
72
100
  headers: { "content-type": "application/json" },
73
- body: JSON.stringify({
74
- customFolders,
75
- storeRawMessageContent: storeRaw
76
- })
101
+ body: JSON.stringify(settingsPayload())
77
102
  });
78
103
  setMessage(response.ok ? "Settings saved." : "Settings save failed.");
79
104
  });
@@ -86,10 +111,7 @@ export function SettingsPanel({
86
111
  await fetch("/api/settings", {
87
112
  method: "PUT",
88
113
  headers: { "content-type": "application/json" },
89
- body: JSON.stringify({
90
- customFolders,
91
- storeRawMessageContent: storeRaw
92
- })
114
+ body: JSON.stringify(settingsPayload())
93
115
  });
94
116
  const response = await fetch("/api/scan", {
95
117
  method: "POST",
@@ -189,6 +211,48 @@ export function SettingsPanel({
189
211
  </CardContent>
190
212
  </Card>
191
213
 
214
+ <Card>
215
+ <CardHeader>
216
+ <CardTitle className="flex items-center gap-2">
217
+ <Gauge className="h-4 w-4 text-primary" />
218
+ Local Usage Guardrails
219
+ </CardTitle>
220
+ <CardDescription>
221
+ Optional month-to-date limits for local cost and token awareness.
222
+ </CardDescription>
223
+ </CardHeader>
224
+ <CardContent className="grid gap-4 border-y p-4 md:grid-cols-2">
225
+ <div className="space-y-2">
226
+ <Label htmlFor="monthly-cost-limit">Monthly cost limit</Label>
227
+ <Input
228
+ id="monthly-cost-limit"
229
+ type="number"
230
+ min="0"
231
+ step="0.01"
232
+ inputMode="decimal"
233
+ value={monthlyCostLimitUsd}
234
+ onChange={(event) => setMonthlyCostLimitUsd(event.target.value)}
235
+ placeholder="250"
236
+ />
237
+ <p className="text-xs text-muted-foreground">USD limit for imported CLI usage this calendar month.</p>
238
+ </div>
239
+ <div className="space-y-2">
240
+ <Label htmlFor="monthly-token-limit">Monthly token limit</Label>
241
+ <Input
242
+ id="monthly-token-limit"
243
+ type="number"
244
+ min="0"
245
+ step="1000"
246
+ inputMode="numeric"
247
+ value={monthlyTokenLimit}
248
+ onChange={(event) => setMonthlyTokenLimit(event.target.value)}
249
+ placeholder="10000000"
250
+ />
251
+ <p className="text-xs text-muted-foreground">Leave either field blank to disable that guardrail.</p>
252
+ </div>
253
+ </CardContent>
254
+ </Card>
255
+
192
256
  <Card>
193
257
  <CardHeader>
194
258
  <CardTitle className="flex flex-wrap items-center gap-2">