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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +119 -0
  3. package/dist/adapters/claude.d.ts +7 -0
  4. package/dist/adapters/claude.js +78 -0
  5. package/dist/adapters/index.d.ts +18 -0
  6. package/dist/adapters/index.js +33 -0
  7. package/dist/adapters/manual.d.ts +8 -0
  8. package/dist/adapters/manual.js +25 -0
  9. package/dist/adapters/types.d.ts +17 -0
  10. package/dist/adapters/types.js +1 -0
  11. package/dist/advisory/engine.d.ts +11 -0
  12. package/dist/advisory/engine.js +26 -0
  13. package/dist/advisory/types.d.ts +10 -0
  14. package/dist/advisory/types.js +1 -0
  15. package/dist/cli/index.d.ts +2 -0
  16. package/dist/cli/index.js +86 -0
  17. package/dist/config.d.ts +20 -0
  18. package/dist/config.js +32 -0
  19. package/dist/daemon.d.ts +22 -0
  20. package/dist/daemon.js +30 -0
  21. package/dist/http/server.d.ts +4 -0
  22. package/dist/http/server.js +142 -0
  23. package/dist/mcp/server.d.ts +41 -0
  24. package/dist/mcp/server.js +101 -0
  25. package/dist/src/adapters/claude.d.ts +7 -0
  26. package/dist/src/adapters/claude.js +78 -0
  27. package/dist/src/adapters/index.d.ts +18 -0
  28. package/dist/src/adapters/index.js +33 -0
  29. package/dist/src/adapters/manual.d.ts +8 -0
  30. package/dist/src/adapters/manual.js +25 -0
  31. package/dist/src/adapters/types.d.ts +17 -0
  32. package/dist/src/adapters/types.js +1 -0
  33. package/dist/src/advisory/engine.d.ts +11 -0
  34. package/dist/src/advisory/engine.js +26 -0
  35. package/dist/src/advisory/types.d.ts +10 -0
  36. package/dist/src/advisory/types.js +1 -0
  37. package/dist/src/cli/index.d.ts +2 -0
  38. package/dist/src/cli/index.js +86 -0
  39. package/dist/src/config.d.ts +20 -0
  40. package/dist/src/config.js +32 -0
  41. package/dist/src/daemon.d.ts +22 -0
  42. package/dist/src/daemon.js +30 -0
  43. package/dist/src/http/server.d.ts +4 -0
  44. package/dist/src/http/server.js +142 -0
  45. package/dist/src/mcp/server.d.ts +41 -0
  46. package/dist/src/mcp/server.js +101 -0
  47. package/dist/src/store/db.d.ts +2 -0
  48. package/dist/src/store/db.js +18 -0
  49. package/dist/src/store/quotas.d.ts +5 -0
  50. package/dist/src/store/quotas.js +28 -0
  51. package/dist/src/webAssets.d.ts +1 -0
  52. package/dist/src/webAssets.js +2 -0
  53. package/dist/src/webHtml.d.ts +1 -0
  54. package/dist/src/webHtml.js +2 -0
  55. package/dist/store/db.d.ts +2 -0
  56. package/dist/store/db.js +18 -0
  57. package/dist/store/quotas.d.ts +5 -0
  58. package/dist/store/quotas.js +28 -0
  59. package/dist/tests/adapters/claude.test.d.ts +1 -0
  60. package/dist/tests/adapters/claude.test.js +42 -0
  61. package/dist/tests/adapters/manual.test.d.ts +1 -0
  62. package/dist/tests/adapters/manual.test.js +10 -0
  63. package/dist/tests/advisory/engine.test.d.ts +1 -0
  64. package/dist/tests/advisory/engine.test.js +11 -0
  65. package/dist/tests/bootstrap.test.d.ts +1 -0
  66. package/dist/tests/bootstrap.test.js +14 -0
  67. package/dist/tests/cli/cli.test.d.ts +1 -0
  68. package/dist/tests/cli/cli.test.js +10 -0
  69. package/dist/tests/http/api.test.d.ts +1 -0
  70. package/dist/tests/http/api.test.js +14 -0
  71. package/dist/tests/integration.test.d.ts +1 -0
  72. package/dist/tests/integration.test.js +48 -0
  73. package/dist/tests/mcp/server.test.d.ts +1 -0
  74. package/dist/tests/mcp/server.test.js +7 -0
  75. package/dist/tests/store/db.test.d.ts +1 -0
  76. package/dist/tests/store/db.test.js +13 -0
  77. package/dist/tests/web/build.test.d.ts +1 -0
  78. package/dist/tests/web/build.test.js +8 -0
  79. package/dist/web/src/App.d.ts +3 -0
  80. package/dist/web/src/App.js +78 -0
  81. package/dist/web/src/api.d.ts +2 -0
  82. package/dist/web/src/api.js +8 -0
  83. package/dist/webAssets.d.ts +1 -0
  84. package/dist/webAssets.js +2 -0
  85. package/dist/webHtml.d.ts +1 -0
  86. package/dist/webHtml.js +2 -0
  87. package/package.json +44 -0
  88. package/web/dist/assets/index-G2N84rBQ.js +40 -0
  89. package/web/dist/index.html +2 -0
@@ -0,0 +1,142 @@
1
+ import Fastify from "fastify";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { webHtml } from "../webHtml.js";
6
+ import { webAssets } from "../webAssets.js";
7
+ // per-app state registry for backward-compat helpers
8
+ const appStates = new WeakMap();
9
+ const allStates = new Set();
10
+ export function buildApp(db) {
11
+ const state = { lastPollAt: null, lastRefreshAt: 0, lastRefreshResult: null };
12
+ const app = Fastify({ logger: false });
13
+ appStates.set(app, state);
14
+ allStates.add(state);
15
+ app.addHook("onClose", async () => {
16
+ appStates.delete(app);
17
+ allStates.delete(state);
18
+ });
19
+ // expose for per-app helpers
20
+ app._quotacapState = state;
21
+ app.get("/health", async () => ({ ok: true, uptime: process.uptime(), lastPollAt: state.lastPollAt }));
22
+ app.get("/api/quotas", async () => {
23
+ const { getAllLatest } = await import("../store/quotas.js");
24
+ const quotas = getAllLatest(db);
25
+ const now = Date.now();
26
+ return quotas.map((q) => {
27
+ const fetched = q.fetchedAt ? new Date(q.fetchedAt).getTime() : 0;
28
+ const ageMs = fetched ? now - fetched : Infinity;
29
+ const stale = ageMs > 60 * 60 * 1000;
30
+ return { ...q, stale, ageMs };
31
+ });
32
+ });
33
+ app.get("/api/recommendation", async (req) => {
34
+ const { getAllLatest } = await import("../store/quotas.js");
35
+ const quotas = getAllLatest(db);
36
+ if (!quotas.length) {
37
+ return { use: "none", reason: "no quotas yet", alternatives: [], advisories: [] };
38
+ }
39
+ let recommend;
40
+ try {
41
+ recommend = (await import("../advisory/engine.js")).recommend;
42
+ }
43
+ catch {
44
+ return { use: quotas[0]?.provider ?? "none", reason: "advisory not yet implemented", alternatives: quotas };
45
+ }
46
+ try {
47
+ const task = req.query?.task ?? "any";
48
+ return recommend(quotas, task);
49
+ }
50
+ catch (e) {
51
+ return { use: quotas[0]?.provider ?? "none", reason: `advisory error: ${e?.message ?? String(e)}`, alternatives: quotas };
52
+ }
53
+ });
54
+ app.post("/api/refresh", async () => {
55
+ const now = Date.now();
56
+ if (now - state.lastRefreshAt < 60_000 && state.lastRefreshResult) {
57
+ return state.lastRefreshResult;
58
+ }
59
+ try {
60
+ const { pollOnce } = await import("../daemon.js");
61
+ const { readConfig } = await import("../config.js");
62
+ const cfg = await readConfig();
63
+ const results = await pollOnce(db, cfg.enabledProviders);
64
+ const fulfilled = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
65
+ const rejected = results.filter((r) => r.status === "rejected").map((r) => ({ provider: r.provider, reason: String(r.reason?.message ?? r.reason) }));
66
+ state.lastPollAt = new Date().toISOString();
67
+ state.lastRefreshAt = now;
68
+ state.lastRefreshResult = { fulfilled, rejected, lastPollAt: state.lastPollAt, results, degraded: rejected.length > 0 };
69
+ return state.lastRefreshResult;
70
+ }
71
+ catch (e) {
72
+ return { fulfilled: [], rejected: [{ provider: "all", reason: String(e?.message ?? e) }], lastPollAt: state.lastPollAt, degraded: true, error: String(e?.message ?? e) };
73
+ }
74
+ });
75
+ // serve built vite assets at /assets/* (web/dist/assets/*)
76
+ app.get("/assets/*", async (req, reply) => {
77
+ const assetPath = req.params["*"] ?? "";
78
+ // guard path traversal
79
+ if (assetPath.includes(".."))
80
+ return reply.status(400).send("bad path");
81
+ const candidates = [
82
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "../../web/dist/assets", assetPath),
83
+ path.join(process.cwd(), "web/dist/assets", assetPath),
84
+ ];
85
+ for (const p of candidates) {
86
+ try {
87
+ const data = fs.readFileSync(p);
88
+ const ext = path.extname(p);
89
+ const type = ext === ".js" ? "application/javascript" : ext === ".css" ? "text/css" : ext === ".map" ? "application/json" : "application/octet-stream";
90
+ return reply.type(type).send(data);
91
+ }
92
+ catch { }
93
+ }
94
+ const embedded = webAssets[assetPath];
95
+ if (embedded !== undefined) {
96
+ const ext = path.extname(assetPath);
97
+ const type = ext === ".js" ? "application/javascript" : ext === ".css" ? "text/css" : ext === ".map" ? "application/json" : "application/octet-stream";
98
+ return reply.type(type).send(embedded);
99
+ }
100
+ return reply.status(404).send("not found");
101
+ });
102
+ app.get("/", async (_req, reply) => {
103
+ const candidates = [
104
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "../../web/dist/index.html"),
105
+ path.join(path.dirname(fileURLToPath(import.meta.url)), "../../web/index.html"),
106
+ path.join(process.cwd(), "web/dist/index.html"),
107
+ path.join(process.cwd(), "web/index.html"),
108
+ ];
109
+ for (const p of candidates) {
110
+ try {
111
+ const html = fs.readFileSync(p, "utf8");
112
+ return reply.type("text/html").send(html);
113
+ }
114
+ catch { }
115
+ }
116
+ return reply.type("text/html").send(webHtml ?? `<!doctype html><title>QuotaCap</title><div id=app>loading…</div>`);
117
+ });
118
+ return app;
119
+ }
120
+ export function getLastPollAt(app) {
121
+ if (app && appStates.has(app))
122
+ return appStates.get(app).lastPollAt;
123
+ // fallback: most recent state (for tests without app arg)
124
+ let last = null;
125
+ for (const s of allStates)
126
+ last = s.lastPollAt;
127
+ return last;
128
+ }
129
+ export function resetRefreshState(app) {
130
+ if (app && appStates.has(app)) {
131
+ const s = appStates.get(app);
132
+ s.lastPollAt = null;
133
+ s.lastRefreshAt = 0;
134
+ s.lastRefreshResult = null;
135
+ return;
136
+ }
137
+ for (const s of allStates) {
138
+ s.lastPollAt = null;
139
+ s.lastRefreshAt = 0;
140
+ s.lastRefreshResult = null;
141
+ }
142
+ }
@@ -0,0 +1,41 @@
1
+ export declare const tools: ({
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: string;
6
+ properties: {
7
+ task?: undefined;
8
+ provider?: undefined;
9
+ };
10
+ required: never[];
11
+ };
12
+ } | {
13
+ name: string;
14
+ description: string;
15
+ inputSchema: {
16
+ type: string;
17
+ properties: {
18
+ task: {
19
+ type: string;
20
+ enum: string[];
21
+ };
22
+ provider?: undefined;
23
+ };
24
+ required?: undefined;
25
+ };
26
+ } | {
27
+ name: string;
28
+ description: string;
29
+ inputSchema: {
30
+ type: string;
31
+ properties: {
32
+ provider: {
33
+ type: string;
34
+ };
35
+ task?: undefined;
36
+ };
37
+ required: string[];
38
+ };
39
+ })[];
40
+ export declare function handleTool(name: string, args: any): Promise<any>;
41
+ export declare function runMcpServer(): Promise<void>;
@@ -0,0 +1,101 @@
1
+ export const tools = [
2
+ { name: "get_quotas", description: "All quotas with resets and health", inputSchema: { type: "object", properties: {}, required: [] } },
3
+ { name: "get_recommendation", description: "Which provider to use next", inputSchema: { type: "object", properties: { task: { type: "string", enum: ["any", "heavy", "light"] } } } },
4
+ { name: "forecast", description: "Burn vs ideal + waste for a provider", inputSchema: { type: "object", properties: { provider: { type: "string" } }, required: ["provider"] } },
5
+ ];
6
+ async function fetchJson(path) {
7
+ const base = process.env.QUOTACAP_URL ?? "http://localhost:8787";
8
+ try {
9
+ const r = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(5000) });
10
+ if (!r.ok)
11
+ throw new Error(`HTTP ${r.status}`);
12
+ return r.json();
13
+ }
14
+ catch (e) {
15
+ // any transport failure (node or bun wording) is a daemon-down situation
16
+ throw new Error(`daemon not running, run quotacap web — ${e?.message ?? String(e)}`);
17
+ }
18
+ }
19
+ export async function handleTool(name, args) {
20
+ if (name === "get_quotas")
21
+ return fetchJson("/api/quotas");
22
+ if (name === "get_recommendation")
23
+ return fetchJson(`/api/recommendation?task=${args?.task ?? "any"}`);
24
+ if (name === "forecast") {
25
+ if (!args?.provider)
26
+ throw new Error("provider required");
27
+ const [quotas, rec] = await Promise.all([fetchJson("/api/quotas"), fetchJson(`/api/recommendation`)]);
28
+ const q = quotas.find((x) => x.provider === args.provider);
29
+ if (!q)
30
+ throw new Error(`unknown provider ${args.provider}`);
31
+ return { quota: q, advisory: rec.advisories?.find((a) => a.provider === args.provider) };
32
+ }
33
+ throw new Error(`unknown tool ${name}`);
34
+ }
35
+ export async function runMcpServer() {
36
+ // Minimal MCP JSON-RPC stdio server — handles initialize, tools/list, tools/call, ping
37
+ const readline = await import("node:readline");
38
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
39
+ const respond = (id, result) => {
40
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
41
+ };
42
+ const error = (id, code, message) => {
43
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
44
+ };
45
+ rl.on("line", async (line) => {
46
+ if (!line.trim())
47
+ return;
48
+ let msg;
49
+ try {
50
+ msg = JSON.parse(line);
51
+ }
52
+ catch {
53
+ return;
54
+ }
55
+ const { id, method, params } = msg;
56
+ // notifications have no id — no response
57
+ const isNotification = id === undefined;
58
+ try {
59
+ if (method === "initialize") {
60
+ if (!isNotification)
61
+ respond(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "quotacap", version: "0.0.1" } });
62
+ }
63
+ else if (method === "notifications/initialized") {
64
+ // no-op
65
+ }
66
+ else if (method === "tools/list") {
67
+ if (!isNotification)
68
+ respond(id, { tools });
69
+ }
70
+ else if (method === "tools/call") {
71
+ const toolName = params?.name;
72
+ const toolArgs = params?.arguments ?? {};
73
+ try {
74
+ const result = await handleTool(toolName, toolArgs);
75
+ const content = [{ type: "text", text: JSON.stringify(result, null, 2) }];
76
+ if (!isNotification)
77
+ respond(id, { content });
78
+ }
79
+ catch (e) {
80
+ const content = [{ type: "text", text: e?.message ?? String(e) }];
81
+ if (!isNotification)
82
+ respond(id, { content, isError: true });
83
+ }
84
+ }
85
+ else if (method === "ping") {
86
+ if (!isNotification)
87
+ respond(id, {});
88
+ }
89
+ else {
90
+ if (!isNotification)
91
+ error(id, -32601, `Method not found: ${method}`);
92
+ }
93
+ }
94
+ catch (e) {
95
+ if (!isNotification)
96
+ error(id, -32603, e?.message ?? String(e));
97
+ }
98
+ });
99
+ // keep alive
100
+ process.stdin.resume();
101
+ }
@@ -0,0 +1,7 @@
1
+ import type { Quota } from "./types.js";
2
+ export declare function parseClaudeUsage(result: string, now?: Date): Quota;
3
+ export declare const claudeAdapter: {
4
+ id: string;
5
+ requiresAuth: string;
6
+ poll(): Promise<Quota>;
7
+ };
@@ -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,10 @@
1
+ export type Urgency = "burn now" | "use soon" | "slow down" | "save" | "on track";
2
+ export interface Advisory {
3
+ provider: string;
4
+ daysLeft: number;
5
+ remaining: number;
6
+ idealRate: number;
7
+ burnRate: number;
8
+ wastePct: number;
9
+ urgency: Urgency;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ 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();
@@ -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 {};