quotacap 0.0.1
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/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +78 -0
- package/dist/adapters/index.d.ts +18 -0
- package/dist/adapters/index.js +33 -0
- package/dist/adapters/manual.d.ts +8 -0
- package/dist/adapters/manual.js +25 -0
- package/dist/adapters/types.d.ts +17 -0
- package/dist/adapters/types.js +1 -0
- package/dist/advisory/engine.d.ts +11 -0
- package/dist/advisory/engine.js +26 -0
- package/dist/advisory/types.d.ts +10 -0
- package/dist/advisory/types.js +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +86 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +32 -0
- package/dist/daemon.d.ts +22 -0
- package/dist/daemon.js +30 -0
- package/dist/http/server.d.ts +4 -0
- package/dist/http/server.js +142 -0
- package/dist/mcp/server.d.ts +41 -0
- package/dist/mcp/server.js +101 -0
- package/dist/src/adapters/claude.d.ts +7 -0
- package/dist/src/adapters/claude.js +78 -0
- package/dist/src/adapters/index.d.ts +18 -0
- package/dist/src/adapters/index.js +33 -0
- package/dist/src/adapters/manual.d.ts +8 -0
- package/dist/src/adapters/manual.js +25 -0
- package/dist/src/adapters/types.d.ts +17 -0
- package/dist/src/adapters/types.js +1 -0
- package/dist/src/advisory/engine.d.ts +11 -0
- package/dist/src/advisory/engine.js +26 -0
- package/dist/src/advisory/types.d.ts +10 -0
- package/dist/src/advisory/types.js +1 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +86 -0
- package/dist/src/config.d.ts +20 -0
- package/dist/src/config.js +32 -0
- package/dist/src/daemon.d.ts +22 -0
- package/dist/src/daemon.js +30 -0
- package/dist/src/http/server.d.ts +4 -0
- package/dist/src/http/server.js +142 -0
- package/dist/src/mcp/server.d.ts +41 -0
- package/dist/src/mcp/server.js +101 -0
- package/dist/src/store/db.d.ts +2 -0
- package/dist/src/store/db.js +18 -0
- package/dist/src/store/quotas.d.ts +5 -0
- package/dist/src/store/quotas.js +28 -0
- package/dist/src/webAssets.d.ts +1 -0
- package/dist/src/webAssets.js +2 -0
- package/dist/src/webHtml.d.ts +1 -0
- package/dist/src/webHtml.js +2 -0
- package/dist/store/db.d.ts +2 -0
- package/dist/store/db.js +18 -0
- package/dist/store/quotas.d.ts +5 -0
- package/dist/store/quotas.js +28 -0
- package/dist/tests/adapters/claude.test.d.ts +1 -0
- package/dist/tests/adapters/claude.test.js +42 -0
- package/dist/tests/adapters/manual.test.d.ts +1 -0
- package/dist/tests/adapters/manual.test.js +10 -0
- package/dist/tests/advisory/engine.test.d.ts +1 -0
- package/dist/tests/advisory/engine.test.js +11 -0
- package/dist/tests/bootstrap.test.d.ts +1 -0
- package/dist/tests/bootstrap.test.js +14 -0
- package/dist/tests/cli/cli.test.d.ts +1 -0
- package/dist/tests/cli/cli.test.js +10 -0
- package/dist/tests/http/api.test.d.ts +1 -0
- package/dist/tests/http/api.test.js +14 -0
- package/dist/tests/integration.test.d.ts +1 -0
- package/dist/tests/integration.test.js +48 -0
- package/dist/tests/mcp/server.test.d.ts +1 -0
- package/dist/tests/mcp/server.test.js +7 -0
- package/dist/tests/store/db.test.d.ts +1 -0
- package/dist/tests/store/db.test.js +13 -0
- package/dist/tests/web/build.test.d.ts +1 -0
- package/dist/tests/web/build.test.js +8 -0
- package/dist/web/src/App.d.ts +3 -0
- package/dist/web/src/App.js +78 -0
- package/dist/web/src/api.d.ts +2 -0
- package/dist/web/src/api.js +8 -0
- package/dist/webAssets.d.ts +1 -0
- package/dist/webAssets.js +2 -0
- package/dist/webHtml.d.ts +1 -0
- package/dist/webHtml.js +2 -0
- package/package.json +44 -0
- package/web/dist/assets/index-G2N84rBQ.js +40 -0
- package/web/dist/index.html +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos Boeing
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# QuotaCap
|
|
2
|
+
|
|
3
|
+
**Cross-harness AI quota dashboard + harness-callable advice.**
|
|
4
|
+
|
|
5
|
+
One place to see how much of each AI subscription you've used. Shows when it resets and what to burn next. Goal is 100% use at reset.
|
|
6
|
+
|
|
7
|
+
Polls pluggable adapters (Claude, Codex, Gemini, Kimi, Grok, OpenCode). Stores history in SQLite. Serves:
|
|
8
|
+
|
|
9
|
+
* **Web dashboard** at `http://localhost:8787` — banner + 7-day strip + table with burn vs ideal. Includes stale badges and degraded banner.
|
|
10
|
+
* **CLI** `quotacap` / `npx quotacap` — `status`, `advise --json`, `ingest`, `web`, `init`, `daemon`
|
|
11
|
+
* **MCP server** — `get_quotas`, `get_recommendation`, `forecast` for any harness
|
|
12
|
+
|
|
13
|
+
Runs local-only by default. No prompts or file contents leave your machine.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Binary — no Node needed (macOS + Linux, arm64/x64)
|
|
19
|
+
curl -fsSL https://raw.githubusercontent.com/carlosboeing/quotacap/main/install.sh | sh
|
|
20
|
+
|
|
21
|
+
# Or via npm (requires Node 22.13+; node:sqlite needs --experimental-sqlite before 22.13)
|
|
22
|
+
npm install -g quotacap # global install
|
|
23
|
+
npx quotacap # run without install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The binary is a self-contained Bun-compiled executable from GitHub Releases.
|
|
27
|
+
The npm package runs the same CLI on Node. Both put `quotacap` on your PATH.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
quotacap init
|
|
33
|
+
quotacap ingest --provider kimi --text "Current week: 22% used · resets Aug 29 at 11am"
|
|
34
|
+
quotacap status --json
|
|
35
|
+
quotacap web # http://localhost:8787
|
|
36
|
+
quotacap advise --json # tries HTTP, falls back to local DB
|
|
37
|
+
quotacap advise --json --task heavy
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## HTTP API
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
curl http://localhost:8787/health
|
|
44
|
+
curl http://localhost:8787/api/quotas
|
|
45
|
+
curl "http://localhost:8787/api/recommendation?task=any"
|
|
46
|
+
curl -X POST http://localhost:8787/api/refresh
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
* `GET /health` → `{ok, uptime, lastPollAt}`
|
|
50
|
+
* `GET /api/quotas` → `Quota[]` with `stale`, `ageMs`
|
|
51
|
+
* `GET /api/recommendation?task=any` → `{use, reason, advisories}`
|
|
52
|
+
* `POST /api/refresh` → `{fulfilled, rejected, lastPollAt, degraded}` — debounced 60s, always 200
|
|
53
|
+
* `GET /` → `web/dist/index.html` if built, else `web/index.html`
|
|
54
|
+
|
|
55
|
+
## CLI
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
quotacap status [--json]
|
|
59
|
+
quotacap advise [--json] [--task any|heavy|light]
|
|
60
|
+
quotacap ingest --provider <id> --text "..."
|
|
61
|
+
quotacap web [--port 8787]
|
|
62
|
+
quotacap daemon [--foreground]
|
|
63
|
+
quotacap init
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`advise` fetches `http://localhost:$port/api/recommendation` with 2s timeout. Falls back to local `recommend()` when daemon is down.
|
|
67
|
+
|
|
68
|
+
## MCP
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"mcpServers": {
|
|
73
|
+
"quotacap": { "command": "quotacap", "args": ["mcp"] }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use `"command": "npx", "args": ["quotacap", "mcp"]` when installed via npm only.
|
|
79
|
+
Tools: `get_quotas`, `get_recommendation`, `forecast`. Wrapper over HTTP. Set `QUOTACAP_URL` to override.
|
|
80
|
+
|
|
81
|
+
## How it works
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
Adapters (claude-cli ✓, manual ✓, others → manual-paste)
|
|
85
|
+
→ daemon (poll 15m + jitter, SQLite ~/.quotacap/quotacap.db)
|
|
86
|
+
→ HTTP :8787
|
|
87
|
+
→ dashboard + CLI + MCP (same handler)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Adapters are isolated. One failure does not block others. Uses `Promise.allSettled` and per-adapter catch. `POST /api/refresh` always 200.
|
|
91
|
+
|
|
92
|
+
## Config
|
|
93
|
+
|
|
94
|
+
`~/.quotacap/config.json`:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{ "port": 8787, "pollMinutes": 15, "enabledProviders": ["claude"] }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
DB is `~/.quotacap/quotacap.db` (`quotas`, `snapshots`).
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm test # vitest run (10 files) — builds first via pretest
|
|
106
|
+
bun test tests/bun/ # bun-runtime tests (sqlite adapter, MCP translation)
|
|
107
|
+
npm run build # vite build → embed web assets → tsc → flatten dist → chmod bin
|
|
108
|
+
npm run build:bin # bun build --compile (current platform, or pass targets)
|
|
109
|
+
npx tsc --noEmit
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The `store/db.ts` adapter picks `node:sqlite` on Node and `bun:sqlite` on Bun, so
|
|
113
|
+
the same code runs as an npm package and as a compiled binary.
|
|
114
|
+
|
|
115
|
+
See `.workbench/2-design/2026-08-28-quotacap-design.md` for spec. See `docs/ROADMAP.md` for next steps.
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { parse, isValid } from "date-fns";
|
|
4
|
+
import { fromZonedTime, formatInTimeZone } from "date-fns-tz";
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
const BRISBANE_TZ = "Australia/Brisbane";
|
|
7
|
+
const RESET_FORMATS = [
|
|
8
|
+
"MMM d 'at' h:mma yyyy",
|
|
9
|
+
"MMM d 'at' ha yyyy",
|
|
10
|
+
"MMM d 'at' h:mm a yyyy",
|
|
11
|
+
"MMM d 'at' h a yyyy",
|
|
12
|
+
];
|
|
13
|
+
function tryParseWithYear(raw, year) {
|
|
14
|
+
const withYear = `${raw.trim()} ${year}`;
|
|
15
|
+
const normalized = withYear.replace(/([ap]m)\b/gi, (m) => m.toUpperCase());
|
|
16
|
+
for (const fmt of RESET_FORMATS) {
|
|
17
|
+
const d = parse(normalized, fmt, new Date());
|
|
18
|
+
if (isValid(d)) {
|
|
19
|
+
return fromZonedTime(d, BRISBANE_TZ);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function parseBrisbaneReset(resetsRaw, now) {
|
|
25
|
+
const trimmed = resetsRaw.trim();
|
|
26
|
+
if (!trimmed)
|
|
27
|
+
return null;
|
|
28
|
+
const yearStr = formatInTimeZone(now, BRISBANE_TZ, "yyyy");
|
|
29
|
+
const year = parseInt(yearStr, 10);
|
|
30
|
+
let utc = tryParseWithYear(trimmed, year);
|
|
31
|
+
if (!utc)
|
|
32
|
+
return null;
|
|
33
|
+
if (utc.getTime() < now.getTime()) {
|
|
34
|
+
const utcNext = tryParseWithYear(trimmed, year + 1);
|
|
35
|
+
if (utcNext) {
|
|
36
|
+
const diff = utcNext.getTime() - now.getTime();
|
|
37
|
+
if (diff >= 0 && diff < 8 * 86400000) {
|
|
38
|
+
utc = utcNext;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// stale / out-of-range (e.g. monthly-plan string) — fallback to now+7d
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
// if utc was past and no valid next-year within window, treat as stale
|
|
49
|
+
if (utc.getTime() < now.getTime())
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return formatInTimeZone(utc, BRISBANE_TZ, "yyyy-MM-dd'T'HH:mm:ssXXX");
|
|
53
|
+
}
|
|
54
|
+
export function parseClaudeUsage(result, now = new Date()) {
|
|
55
|
+
const sessionMatch = result.match(/Current session:\s+(\d+)% used[^·]*·\s*resets\s+([^\n(]+?)\s*\(/);
|
|
56
|
+
const weeklyMatch = result.match(/Current week \(all models\):\s+(\d+)% used[^·]*·\s*resets\s+([^\n(]+?)\s*\(/);
|
|
57
|
+
const usedPct = weeklyMatch ? parseInt(weeklyMatch[1], 10) : 0;
|
|
58
|
+
const sessionPct = sessionMatch ? parseInt(sessionMatch[1], 10) : undefined;
|
|
59
|
+
const resetsRaw = weeklyMatch?.[2].trim() ?? "";
|
|
60
|
+
let resetsAt = parseBrisbaneReset(resetsRaw, now);
|
|
61
|
+
if (!resetsAt)
|
|
62
|
+
resetsAt = new Date(now.getTime() + 7 * 86400000).toISOString();
|
|
63
|
+
return {
|
|
64
|
+
provider: "claude", plan: "max", usedPct, sessionPct,
|
|
65
|
+
resetsAt, periodStart: new Date(now.getTime() - 7 * 86400000).toISOString(),
|
|
66
|
+
raw: result, source: "cli", fetchedAt: now.toISOString()
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export const claudeAdapter = {
|
|
70
|
+
id: "claude",
|
|
71
|
+
requiresAuth: "keychain:Claude Code-credentials",
|
|
72
|
+
async poll() {
|
|
73
|
+
const { stdout } = await exec("claude", ["-p", "/usage", "--output-format", "json"], { timeout: 8000 });
|
|
74
|
+
const parsed = JSON.parse(stdout);
|
|
75
|
+
const result = parsed.result ?? stdout;
|
|
76
|
+
return parseClaudeUsage(result);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Adapter } from "./types.js";
|
|
2
|
+
export declare const adapters: Record<string, Adapter>;
|
|
3
|
+
export declare function pollAll(enabled: string[]): Promise<({
|
|
4
|
+
provider: string;
|
|
5
|
+
status: "fulfilled";
|
|
6
|
+
value: import("./types.js").Quota;
|
|
7
|
+
reason?: undefined;
|
|
8
|
+
} | {
|
|
9
|
+
provider: string;
|
|
10
|
+
status: "skipped";
|
|
11
|
+
reason: any;
|
|
12
|
+
value?: undefined;
|
|
13
|
+
} | {
|
|
14
|
+
provider: string;
|
|
15
|
+
status: "rejected";
|
|
16
|
+
reason: any;
|
|
17
|
+
value?: undefined;
|
|
18
|
+
})[]>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { claudeAdapter } from "./claude.js";
|
|
2
|
+
import { manualAdapter } from "./manual.js";
|
|
3
|
+
export const adapters = {
|
|
4
|
+
claude: claudeAdapter,
|
|
5
|
+
manual: manualAdapter,
|
|
6
|
+
};
|
|
7
|
+
function withTimeout(p, ms) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
|
|
10
|
+
Promise.resolve(p).then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export async function pollAll(enabled) {
|
|
14
|
+
const rawJobs = enabled.map(id => {
|
|
15
|
+
const a = adapters[id];
|
|
16
|
+
if (!a)
|
|
17
|
+
return Promise.reject(new Error(`unknown adapter ${id}`));
|
|
18
|
+
// manual adapter has no poll capability — skip without degraded
|
|
19
|
+
if (id === "manual")
|
|
20
|
+
return Promise.reject(new Error("manual skipped — use ingest"));
|
|
21
|
+
return withTimeout(a.poll(), 8000);
|
|
22
|
+
});
|
|
23
|
+
const settled = await Promise.allSettled(rawJobs);
|
|
24
|
+
return settled.map((s, i) => {
|
|
25
|
+
const id = enabled[i];
|
|
26
|
+
if (s.status === "fulfilled")
|
|
27
|
+
return { provider: id, status: "fulfilled", value: s.value };
|
|
28
|
+
const msg = String(s.reason?.message ?? s.reason);
|
|
29
|
+
if (msg.includes("manual skipped"))
|
|
30
|
+
return { provider: id, status: "skipped", reason: s.reason };
|
|
31
|
+
return { provider: id, status: "rejected", reason: s.reason };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Quota } from "./types.js";
|
|
2
|
+
export declare function parseManualUsage(provider: string, text: string, now?: Date): Quota;
|
|
3
|
+
export declare const manualAdapter: {
|
|
4
|
+
id: string;
|
|
5
|
+
requiresAuth: string;
|
|
6
|
+
ingest(provider: string, text: string): Promise<Quota>;
|
|
7
|
+
poll(): Promise<Quota>;
|
|
8
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function parseManualUsage(provider, text, now = new Date()) {
|
|
2
|
+
const m = text.match(/(\d+)% used/);
|
|
3
|
+
const usedPct = m ? parseInt(m[1], 10) : 0;
|
|
4
|
+
const resetsAt = new Date(now.getTime() + 3 * 86400000).toISOString();
|
|
5
|
+
return {
|
|
6
|
+
provider,
|
|
7
|
+
plan: "unknown",
|
|
8
|
+
usedPct,
|
|
9
|
+
resetsAt,
|
|
10
|
+
periodStart: new Date(now.getTime() - 7 * 86400000).toISOString(),
|
|
11
|
+
raw: text,
|
|
12
|
+
source: "manual",
|
|
13
|
+
fetchedAt: now.toISOString(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export const manualAdapter = {
|
|
17
|
+
id: "manual",
|
|
18
|
+
requiresAuth: "none",
|
|
19
|
+
async ingest(provider, text) {
|
|
20
|
+
return parseManualUsage(provider, text);
|
|
21
|
+
},
|
|
22
|
+
async poll() {
|
|
23
|
+
throw new Error("manual has no poll — use ingest");
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface Quota {
|
|
2
|
+
provider: string;
|
|
3
|
+
plan: string;
|
|
4
|
+
usedPct: number;
|
|
5
|
+
sessionPct?: number;
|
|
6
|
+
resetsAt: string;
|
|
7
|
+
periodStart: string;
|
|
8
|
+
raw: string;
|
|
9
|
+
source: "cli" | "api" | "scrape" | "manual";
|
|
10
|
+
fetchedAt: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Adapter {
|
|
13
|
+
id: string;
|
|
14
|
+
requiresAuth: string;
|
|
15
|
+
poll(): Promise<Quota>;
|
|
16
|
+
}
|
|
17
|
+
export declare const UNIMPLEMENTED: (id: string) => Error;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const UNIMPLEMENTED = (id) => new Error(`${id} adapter not implemented — use manual-paste`);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Quota } from "../adapters/types.js";
|
|
2
|
+
import type { Advisory } from "./types.js";
|
|
3
|
+
export declare function computeAdvisory(q: Quota, burnRate: number, now?: Date): Advisory;
|
|
4
|
+
export declare function recommend(quotas: Quota[], _task: string, burnByProvider?: Map<string, number>, now?: Date): {
|
|
5
|
+
use: string;
|
|
6
|
+
reason: string;
|
|
7
|
+
wastePct: number;
|
|
8
|
+
idealRate: number;
|
|
9
|
+
alternatives: Quota[];
|
|
10
|
+
advisories: Advisory[];
|
|
11
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function computeAdvisory(q, burnRate, now = new Date()) {
|
|
2
|
+
const resets = new Date(q.resetsAt);
|
|
3
|
+
const daysLeft = Math.max(0.1, (resets.getTime() - now.getTime()) / 86400000);
|
|
4
|
+
const remaining = 100 - q.usedPct;
|
|
5
|
+
const idealRate = remaining / daysLeft;
|
|
6
|
+
const wastePct = Math.max(0, remaining - burnRate * daysLeft);
|
|
7
|
+
let urgency = "on track";
|
|
8
|
+
if (wastePct > 30 && daysLeft < 3)
|
|
9
|
+
urgency = "burn now";
|
|
10
|
+
else if (wastePct > 20 && daysLeft < 7)
|
|
11
|
+
urgency = "use soon";
|
|
12
|
+
else if (burnRate > idealRate * 1.4)
|
|
13
|
+
urgency = "slow down";
|
|
14
|
+
else if (wastePct > 10)
|
|
15
|
+
urgency = "save";
|
|
16
|
+
return { provider: q.provider, daysLeft, remaining, idealRate, burnRate, wastePct, urgency };
|
|
17
|
+
}
|
|
18
|
+
export function recommend(quotas, _task, burnByProvider = new Map(), now = new Date()) {
|
|
19
|
+
if (!quotas.length)
|
|
20
|
+
return { use: "none", reason: "no quotas yet", wastePct: 0, idealRate: 0, alternatives: [], advisories: [] };
|
|
21
|
+
const advisories = quotas.map(q => computeAdvisory(q, burnByProvider.get(q.provider) ?? 2, now));
|
|
22
|
+
const burnNow = advisories.filter(a => a.urgency === "burn now");
|
|
23
|
+
const pool = burnNow.length ? burnNow : advisories;
|
|
24
|
+
const use = pool.sort((a, b) => b.wastePct - a.wastePct)[0];
|
|
25
|
+
return { use: use.provider, reason: `${Math.round(use.wastePct)}% waste in ${use.daysLeft.toFixed(1)}d`, wastePct: use.wastePct, idealRate: use.idealRate, alternatives: quotas, advisories };
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { buildApp } from "../http/server.js";
|
|
6
|
+
import { openDb, migrate } from "../store/db.js";
|
|
7
|
+
import { getDbPath, readConfig } from "../config.js";
|
|
8
|
+
function ensureDbDir() { try {
|
|
9
|
+
fs.mkdirSync(path.dirname(getDbPath()), { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
catch { } }
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program.name("quotacap").version("0.0.1");
|
|
14
|
+
program.command("status").option("--json", "json").action(async (opts) => {
|
|
15
|
+
ensureDbDir();
|
|
16
|
+
const db = openDb(getDbPath());
|
|
17
|
+
migrate(db);
|
|
18
|
+
const { getAllLatest } = await import("../store/quotas.js");
|
|
19
|
+
const quotas = getAllLatest(db);
|
|
20
|
+
if (opts.json)
|
|
21
|
+
console.log(JSON.stringify(quotas, null, 2));
|
|
22
|
+
else
|
|
23
|
+
console.table(quotas);
|
|
24
|
+
});
|
|
25
|
+
program.command("advise").option("--json", "json").option("--task <t>", "task", "any").action(async (opts) => {
|
|
26
|
+
ensureDbDir();
|
|
27
|
+
const db = openDb(getDbPath());
|
|
28
|
+
migrate(db);
|
|
29
|
+
try {
|
|
30
|
+
const r = await fetch(`http://localhost:${(await readConfig()).port}/api/recommendation?task=${opts.task}`, { signal: AbortSignal.timeout(2000) });
|
|
31
|
+
const j = await r.json();
|
|
32
|
+
console.log(opts.json ? JSON.stringify(j, null, 2) : `${j.use}: ${j.reason}`);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
const { recommend } = await import("../advisory/engine.js");
|
|
36
|
+
const { getAllLatest } = await import("../store/quotas.js");
|
|
37
|
+
const quotas = getAllLatest(db);
|
|
38
|
+
if (!quotas.length) {
|
|
39
|
+
console.log(opts.json ? JSON.stringify({ use: "none", reason: "no quotas yet" }, null, 2) : "none: no quotas yet");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const rec = recommend(quotas, opts.task);
|
|
43
|
+
console.log(opts.json ? JSON.stringify(rec, null, 2) : `${rec.use}: ${rec.reason}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
program.command("ingest").requiredOption("--provider <p>").requiredOption("--text <t>").action(async (o) => {
|
|
47
|
+
ensureDbDir();
|
|
48
|
+
const db = openDb(getDbPath());
|
|
49
|
+
migrate(db);
|
|
50
|
+
const { parseManualUsage } = await import("../adapters/manual.js");
|
|
51
|
+
const { upsertQuota } = await import("../store/quotas.js");
|
|
52
|
+
upsertQuota(db, parseManualUsage(o.provider, o.text));
|
|
53
|
+
console.log("ingested");
|
|
54
|
+
});
|
|
55
|
+
program.command("web").option("--port <n>").action(async (o) => {
|
|
56
|
+
ensureDbDir();
|
|
57
|
+
const db = openDb(getDbPath());
|
|
58
|
+
migrate(db);
|
|
59
|
+
const app = buildApp(db);
|
|
60
|
+
const port = o.port ? parseInt(o.port) : (await readConfig()).port;
|
|
61
|
+
await app.listen({ port, host: "127.0.0.1" });
|
|
62
|
+
console.log(`QuotaCap at http://localhost:${port}`);
|
|
63
|
+
});
|
|
64
|
+
program.command("init").action(async () => {
|
|
65
|
+
const { readConfig, writeConfig } = await import("../config.js");
|
|
66
|
+
const c = await readConfig();
|
|
67
|
+
await writeConfig(c);
|
|
68
|
+
console.log(JSON.stringify(c, null, 2));
|
|
69
|
+
});
|
|
70
|
+
program.command("daemon").option("--foreground", "keep foreground (default: true)").action(async (o) => {
|
|
71
|
+
const { startDaemon } = await import("../daemon.js");
|
|
72
|
+
const { timer } = await startDaemon();
|
|
73
|
+
console.log("QuotaCap daemon started" + (o.foreground !== false ? " (foreground)" : ""));
|
|
74
|
+
// keep alive until SIGINT/SIGTERM — timer is ref'd so event loop stays alive
|
|
75
|
+
process.on("SIGINT", () => { clearInterval(timer); process.exit(0); });
|
|
76
|
+
process.on("SIGTERM", () => { clearInterval(timer); process.exit(0); });
|
|
77
|
+
// explicitly keep process alive if interval was somehow unref'd elsewhere
|
|
78
|
+
if (timer.ref)
|
|
79
|
+
timer.ref();
|
|
80
|
+
});
|
|
81
|
+
program.command("mcp").description("start MCP server (stdio over HTTP)").action(async () => {
|
|
82
|
+
const mod = await import("../mcp/server.js");
|
|
83
|
+
// if run with --help, commander handles it before action; this is the real server
|
|
84
|
+
await mod.runMcpServer();
|
|
85
|
+
});
|
|
86
|
+
program.parseAsync();
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare function getConfigPath(p?: string): string;
|
|
3
|
+
export declare function getDbPath(p?: string): string;
|
|
4
|
+
declare const ConfigSchema: z.ZodObject<{
|
|
5
|
+
port: z.ZodDefault<z.ZodNumber>;
|
|
6
|
+
pollMinutes: z.ZodDefault<z.ZodNumber>;
|
|
7
|
+
enabledProviders: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
port: number;
|
|
10
|
+
pollMinutes: number;
|
|
11
|
+
enabledProviders: string[];
|
|
12
|
+
}, {
|
|
13
|
+
port?: number | undefined;
|
|
14
|
+
pollMinutes?: number | undefined;
|
|
15
|
+
enabledProviders?: string[] | undefined;
|
|
16
|
+
}>;
|
|
17
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
18
|
+
export declare function readConfig(p?: string): Promise<Config>;
|
|
19
|
+
export declare function writeConfig(c: Config, p?: string): Promise<void>;
|
|
20
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export function getConfigPath(p) {
|
|
6
|
+
if (p)
|
|
7
|
+
return p;
|
|
8
|
+
return path.join(os.homedir(), ".quotacap", "config.json");
|
|
9
|
+
}
|
|
10
|
+
export function getDbPath(p) {
|
|
11
|
+
if (p)
|
|
12
|
+
return p;
|
|
13
|
+
return path.join(os.homedir(), ".quotacap", "quotacap.db");
|
|
14
|
+
}
|
|
15
|
+
const ConfigSchema = z.object({
|
|
16
|
+
port: z.number().default(8787),
|
|
17
|
+
pollMinutes: z.number().default(15),
|
|
18
|
+
enabledProviders: z.array(z.string()).default(["claude"]),
|
|
19
|
+
});
|
|
20
|
+
export async function readConfig(p) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = await fs.readFile(getConfigPath(p), "utf8");
|
|
23
|
+
return ConfigSchema.parse(JSON.parse(raw));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return ConfigSchema.parse({});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export async function writeConfig(c, p) {
|
|
30
|
+
await fs.mkdir(path.dirname(getConfigPath(p)), { recursive: true });
|
|
31
|
+
await fs.writeFile(getConfigPath(p), JSON.stringify(c, null, 2));
|
|
32
|
+
}
|
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare function pollOnce(db: any, enabled: string[]): Promise<({
|
|
2
|
+
provider: string;
|
|
3
|
+
status: "fulfilled";
|
|
4
|
+
value: import("./adapters/types.js").Quota;
|
|
5
|
+
reason?: undefined;
|
|
6
|
+
} | {
|
|
7
|
+
provider: string;
|
|
8
|
+
status: "skipped";
|
|
9
|
+
reason: any;
|
|
10
|
+
value?: undefined;
|
|
11
|
+
} | {
|
|
12
|
+
provider: string;
|
|
13
|
+
status: "rejected";
|
|
14
|
+
reason: any;
|
|
15
|
+
value?: undefined;
|
|
16
|
+
})[]>;
|
|
17
|
+
export declare function startDaemon(): Promise<{
|
|
18
|
+
db: any;
|
|
19
|
+
timer: NodeJS.Timeout;
|
|
20
|
+
stop: () => void;
|
|
21
|
+
}>;
|
|
22
|
+
export declare const start: typeof startDaemon;
|
package/dist/daemon.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { pollAll } from "./adapters/index.js";
|
|
2
|
+
import { upsertQuota } from "./store/quotas.js";
|
|
3
|
+
export async function pollOnce(db, enabled) {
|
|
4
|
+
const results = await pollAll(enabled);
|
|
5
|
+
for (const r of results) {
|
|
6
|
+
if (r.status === "fulfilled" && r.value)
|
|
7
|
+
upsertQuota(db, r.value);
|
|
8
|
+
else if (r.status === "rejected")
|
|
9
|
+
console.warn(`[quotacap] poll ${r.provider} failed: ${String(r.reason?.message ?? r.reason)}`);
|
|
10
|
+
// skipped (e.g. manual) is not a failure — no warning, not degraded
|
|
11
|
+
}
|
|
12
|
+
return results;
|
|
13
|
+
}
|
|
14
|
+
export async function startDaemon() {
|
|
15
|
+
const { getDbPath, readConfig } = await import("./config.js");
|
|
16
|
+
const { openDb, migrate } = await import("./store/db.js");
|
|
17
|
+
const db = openDb(getDbPath());
|
|
18
|
+
migrate(db);
|
|
19
|
+
const cfg = await readConfig();
|
|
20
|
+
const intervalMs = (cfg.pollMinutes ?? 15) * 60 * 1000;
|
|
21
|
+
// initial poll (fire-and-forget, don't block start)
|
|
22
|
+
pollOnce(db, cfg.enabledProviders).catch(e => console.warn("[quotacap] initial poll failed", e?.message ?? String(e)));
|
|
23
|
+
const jitter = Math.floor(Math.random() * 5000);
|
|
24
|
+
const timer = setInterval(() => { pollOnce(db, cfg.enabledProviders).catch(e => console.warn("[quotacap] interval poll failed", e?.message ?? String(e))); }, intervalMs + jitter);
|
|
25
|
+
// keep event loop alive — this is the only long-lived process per design
|
|
26
|
+
// (previous unref() caused immediate exit after the initial poll)
|
|
27
|
+
return { db, timer, stop: () => clearInterval(timer) };
|
|
28
|
+
}
|
|
29
|
+
// alias for plan's daemon.start() naming
|
|
30
|
+
export const start = startDaemon;
|