quotacap 0.0.4 → 0.0.6
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/dist/adapters/parse.js +37 -12
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +20 -3
- package/dist/src/adapters/parse.js +37 -12
- package/dist/src/mcp/server.d.ts +1 -0
- package/dist/src/mcp/server.js +20 -3
- package/dist/src/version.js +1 -1
- package/dist/tests/adapters/manual.test.js +13 -0
- package/dist/tests/mcp/server.test.js +20 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/parse.js
CHANGED
|
@@ -21,22 +21,14 @@ function tryParseWithYear(raw, year) {
|
|
|
21
21
|
}
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
// Last "resets" match wins: claude output lists the session reset first and
|
|
26
|
-
// the weekly (period) reset last; manual pastes carry a single line.
|
|
27
|
-
const matches = [...text.matchAll(/resets\s+([^\n(]+?)\s*(?:\(|$)/gm)];
|
|
28
|
-
if (!matches.length)
|
|
29
|
-
return null;
|
|
30
|
-
const trimmed = matches[matches.length - 1][1].trim();
|
|
31
|
-
if (!trimmed)
|
|
32
|
-
return null;
|
|
24
|
+
function parseAbsoluteReset(raw, now) {
|
|
33
25
|
const yearStr = formatInTimeZone(now, BRISBANE_TZ, "yyyy");
|
|
34
26
|
const year = parseInt(yearStr, 10);
|
|
35
|
-
let utc = tryParseWithYear(
|
|
27
|
+
let utc = tryParseWithYear(raw, year);
|
|
36
28
|
if (!utc)
|
|
37
29
|
return null;
|
|
38
30
|
if (utc.getTime() < now.getTime()) {
|
|
39
|
-
const utcNext = tryParseWithYear(
|
|
31
|
+
const utcNext = tryParseWithYear(raw, year + 1);
|
|
40
32
|
if (utcNext) {
|
|
41
33
|
const diff = utcNext.getTime() - now.getTime();
|
|
42
34
|
if (diff >= 0 && diff < 8 * 86400000) {
|
|
@@ -52,5 +44,38 @@ export function parseResetText(text, now) {
|
|
|
52
44
|
if (utc.getTime() < now.getTime())
|
|
53
45
|
return null;
|
|
54
46
|
}
|
|
55
|
-
return
|
|
47
|
+
return utc;
|
|
48
|
+
}
|
|
49
|
+
function parseRelativeDuration(raw, now) {
|
|
50
|
+
const m = raw.match(/^in\s+(.+)$/);
|
|
51
|
+
if (!m)
|
|
52
|
+
return null;
|
|
53
|
+
const inner = m[1].trim();
|
|
54
|
+
const d = inner.match(/(\d+)d/)?.[1];
|
|
55
|
+
const h = inner.match(/(\d+)h/)?.[1];
|
|
56
|
+
const min = inner.match(/(\d+)m/)?.[1];
|
|
57
|
+
if (!d && !h && !min)
|
|
58
|
+
return null;
|
|
59
|
+
const ms = (parseInt(d ?? "0", 10) * 86400 + parseInt(h ?? "0", 10) * 3600 + parseInt(min ?? "0", 10) * 60) * 1000;
|
|
60
|
+
return new Date(now.getTime() + ms);
|
|
61
|
+
}
|
|
62
|
+
export function parseResetText(text, now) {
|
|
63
|
+
// Farthest future reset wins. Claude output lists the session reset before
|
|
64
|
+
// the weekly one; kimi lists the weekly window before its 5h rolling one —
|
|
65
|
+
// the period reset is always the farthest, so one rule serves both.
|
|
66
|
+
const matches = [...text.matchAll(/resets\s+([^\n(]+?)\s*(?:\(|$)/gm)];
|
|
67
|
+
let best = null;
|
|
68
|
+
for (const m of matches) {
|
|
69
|
+
const raw = m[1].trim();
|
|
70
|
+
if (!raw)
|
|
71
|
+
continue;
|
|
72
|
+
const candidates = [parseAbsoluteReset(raw, now), parseRelativeDuration(raw, now)];
|
|
73
|
+
for (const c of candidates) {
|
|
74
|
+
if (c && (!best || c.getTime() > best.getTime()))
|
|
75
|
+
best = c;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!best)
|
|
79
|
+
return null;
|
|
80
|
+
return formatInTimeZone(best, BRISBANE_TZ, "yyyy-MM-dd'T'HH:mm:ssXXX");
|
|
56
81
|
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -37,5 +37,6 @@ export declare const tools: ({
|
|
|
37
37
|
required: string[];
|
|
38
38
|
};
|
|
39
39
|
})[];
|
|
40
|
+
export declare function recommendationTable(rec: any): string;
|
|
40
41
|
export declare function handleTool(name: string, args: any): Promise<any>;
|
|
41
42
|
export declare function runMcpServer(): Promise<void>;
|
package/dist/mcp/server.js
CHANGED
|
@@ -16,11 +16,28 @@ async function fetchJson(path) {
|
|
|
16
16
|
throw new Error(`daemon not running, run quotacap web — ${e?.message ?? String(e)}`);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
export function recommendationTable(rec) {
|
|
20
|
+
const rows = (rec.advisories ?? []).map((a) => {
|
|
21
|
+
const q = (rec.alternatives ?? []).find((x) => x.provider === a.provider);
|
|
22
|
+
const used = Math.round(q?.usedPct ?? 0);
|
|
23
|
+
const resets = q?.resetsAt ? new Date(q.resetsAt).toLocaleString() : "—";
|
|
24
|
+
return `| ${a.provider} | ${used}% | ${100 - used}% | ${resets} | ${Math.round(a.wastePct)}% |`;
|
|
25
|
+
});
|
|
26
|
+
return [
|
|
27
|
+
"| Provider | Used | Remaining | Resets | Waste if unused |",
|
|
28
|
+
"|---|---|---|---|---|",
|
|
29
|
+
...rows,
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
19
32
|
export async function handleTool(name, args) {
|
|
20
33
|
if (name === "get_quotas")
|
|
21
34
|
return fetchJson("/api/quotas");
|
|
22
|
-
if (name === "get_recommendation")
|
|
23
|
-
|
|
35
|
+
if (name === "get_recommendation") {
|
|
36
|
+
const rec = await fetchJson(`/api/recommendation?task=${args?.task ?? "any"}`);
|
|
37
|
+
const table = recommendationTable(rec);
|
|
38
|
+
const json = JSON.stringify(rec, null, 2);
|
|
39
|
+
return { content: [{ type: "text", text: table }, { type: "text", text: json }] };
|
|
40
|
+
}
|
|
24
41
|
if (name === "forecast") {
|
|
25
42
|
if (!args?.provider)
|
|
26
43
|
throw new Error("provider required");
|
|
@@ -72,7 +89,7 @@ export async function runMcpServer() {
|
|
|
72
89
|
const toolArgs = params?.arguments ?? {};
|
|
73
90
|
try {
|
|
74
91
|
const result = await handleTool(toolName, toolArgs);
|
|
75
|
-
const content = [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
92
|
+
const content = Array.isArray(result?.content) ? result.content : [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
76
93
|
if (!isNotification)
|
|
77
94
|
respond(id, { content });
|
|
78
95
|
}
|
|
@@ -21,22 +21,14 @@ function tryParseWithYear(raw, year) {
|
|
|
21
21
|
}
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
// Last "resets" match wins: claude output lists the session reset first and
|
|
26
|
-
// the weekly (period) reset last; manual pastes carry a single line.
|
|
27
|
-
const matches = [...text.matchAll(/resets\s+([^\n(]+?)\s*(?:\(|$)/gm)];
|
|
28
|
-
if (!matches.length)
|
|
29
|
-
return null;
|
|
30
|
-
const trimmed = matches[matches.length - 1][1].trim();
|
|
31
|
-
if (!trimmed)
|
|
32
|
-
return null;
|
|
24
|
+
function parseAbsoluteReset(raw, now) {
|
|
33
25
|
const yearStr = formatInTimeZone(now, BRISBANE_TZ, "yyyy");
|
|
34
26
|
const year = parseInt(yearStr, 10);
|
|
35
|
-
let utc = tryParseWithYear(
|
|
27
|
+
let utc = tryParseWithYear(raw, year);
|
|
36
28
|
if (!utc)
|
|
37
29
|
return null;
|
|
38
30
|
if (utc.getTime() < now.getTime()) {
|
|
39
|
-
const utcNext = tryParseWithYear(
|
|
31
|
+
const utcNext = tryParseWithYear(raw, year + 1);
|
|
40
32
|
if (utcNext) {
|
|
41
33
|
const diff = utcNext.getTime() - now.getTime();
|
|
42
34
|
if (diff >= 0 && diff < 8 * 86400000) {
|
|
@@ -52,5 +44,38 @@ export function parseResetText(text, now) {
|
|
|
52
44
|
if (utc.getTime() < now.getTime())
|
|
53
45
|
return null;
|
|
54
46
|
}
|
|
55
|
-
return
|
|
47
|
+
return utc;
|
|
48
|
+
}
|
|
49
|
+
function parseRelativeDuration(raw, now) {
|
|
50
|
+
const m = raw.match(/^in\s+(.+)$/);
|
|
51
|
+
if (!m)
|
|
52
|
+
return null;
|
|
53
|
+
const inner = m[1].trim();
|
|
54
|
+
const d = inner.match(/(\d+)d/)?.[1];
|
|
55
|
+
const h = inner.match(/(\d+)h/)?.[1];
|
|
56
|
+
const min = inner.match(/(\d+)m/)?.[1];
|
|
57
|
+
if (!d && !h && !min)
|
|
58
|
+
return null;
|
|
59
|
+
const ms = (parseInt(d ?? "0", 10) * 86400 + parseInt(h ?? "0", 10) * 3600 + parseInt(min ?? "0", 10) * 60) * 1000;
|
|
60
|
+
return new Date(now.getTime() + ms);
|
|
61
|
+
}
|
|
62
|
+
export function parseResetText(text, now) {
|
|
63
|
+
// Farthest future reset wins. Claude output lists the session reset before
|
|
64
|
+
// the weekly one; kimi lists the weekly window before its 5h rolling one —
|
|
65
|
+
// the period reset is always the farthest, so one rule serves both.
|
|
66
|
+
const matches = [...text.matchAll(/resets\s+([^\n(]+?)\s*(?:\(|$)/gm)];
|
|
67
|
+
let best = null;
|
|
68
|
+
for (const m of matches) {
|
|
69
|
+
const raw = m[1].trim();
|
|
70
|
+
if (!raw)
|
|
71
|
+
continue;
|
|
72
|
+
const candidates = [parseAbsoluteReset(raw, now), parseRelativeDuration(raw, now)];
|
|
73
|
+
for (const c of candidates) {
|
|
74
|
+
if (c && (!best || c.getTime() > best.getTime()))
|
|
75
|
+
best = c;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!best)
|
|
79
|
+
return null;
|
|
80
|
+
return formatInTimeZone(best, BRISBANE_TZ, "yyyy-MM-dd'T'HH:mm:ssXXX");
|
|
56
81
|
}
|
package/dist/src/mcp/server.d.ts
CHANGED
|
@@ -37,5 +37,6 @@ export declare const tools: ({
|
|
|
37
37
|
required: string[];
|
|
38
38
|
};
|
|
39
39
|
})[];
|
|
40
|
+
export declare function recommendationTable(rec: any): string;
|
|
40
41
|
export declare function handleTool(name: string, args: any): Promise<any>;
|
|
41
42
|
export declare function runMcpServer(): Promise<void>;
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -16,11 +16,28 @@ async function fetchJson(path) {
|
|
|
16
16
|
throw new Error(`daemon not running, run quotacap web — ${e?.message ?? String(e)}`);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
export function recommendationTable(rec) {
|
|
20
|
+
const rows = (rec.advisories ?? []).map((a) => {
|
|
21
|
+
const q = (rec.alternatives ?? []).find((x) => x.provider === a.provider);
|
|
22
|
+
const used = Math.round(q?.usedPct ?? 0);
|
|
23
|
+
const resets = q?.resetsAt ? new Date(q.resetsAt).toLocaleString() : "—";
|
|
24
|
+
return `| ${a.provider} | ${used}% | ${100 - used}% | ${resets} | ${Math.round(a.wastePct)}% |`;
|
|
25
|
+
});
|
|
26
|
+
return [
|
|
27
|
+
"| Provider | Used | Remaining | Resets | Waste if unused |",
|
|
28
|
+
"|---|---|---|---|---|",
|
|
29
|
+
...rows,
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
19
32
|
export async function handleTool(name, args) {
|
|
20
33
|
if (name === "get_quotas")
|
|
21
34
|
return fetchJson("/api/quotas");
|
|
22
|
-
if (name === "get_recommendation")
|
|
23
|
-
|
|
35
|
+
if (name === "get_recommendation") {
|
|
36
|
+
const rec = await fetchJson(`/api/recommendation?task=${args?.task ?? "any"}`);
|
|
37
|
+
const table = recommendationTable(rec);
|
|
38
|
+
const json = JSON.stringify(rec, null, 2);
|
|
39
|
+
return { content: [{ type: "text", text: table }, { type: "text", text: json }] };
|
|
40
|
+
}
|
|
24
41
|
if (name === "forecast") {
|
|
25
42
|
if (!args?.provider)
|
|
26
43
|
throw new Error("provider required");
|
|
@@ -72,7 +89,7 @@ export async function runMcpServer() {
|
|
|
72
89
|
const toolArgs = params?.arguments ?? {};
|
|
73
90
|
try {
|
|
74
91
|
const result = await handleTool(toolName, toolArgs);
|
|
75
|
-
const content = [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
92
|
+
const content = Array.isArray(result?.content) ? result.content : [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
76
93
|
if (!isNotification)
|
|
77
94
|
respond(id, { content });
|
|
78
95
|
}
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// generated by scripts/build-embed.mjs — do not edit
|
|
2
|
-
export const VERSION = "0.0.
|
|
2
|
+
export const VERSION = "0.0.6";
|
|
@@ -12,4 +12,17 @@ describe("manual", () => {
|
|
|
12
12
|
const q = parseManualUsage("kimi", `Current week: 22% used · resets Aug 29 at 11am`, now);
|
|
13
13
|
expect(q.resetsAt).toMatch(/^2026-08-29/);
|
|
14
14
|
});
|
|
15
|
+
it("parses kimi's real dashboard format (relative duration, weekly window)", () => {
|
|
16
|
+
const now = new Date("2026-08-28T22:00:00Z");
|
|
17
|
+
const text = [
|
|
18
|
+
"Usage",
|
|
19
|
+
"Session usage: No active session.",
|
|
20
|
+
"Weekly limit ███░░░░░░░░░░░░░░░░░ 16% used resets in 3d 1h 24m",
|
|
21
|
+
"5h limit ░░░░░░░░░░░░░░░░░░░░ 0% used resets in 3h 24m",
|
|
22
|
+
].join("\n");
|
|
23
|
+
const q = parseManualUsage("kimi", text, now);
|
|
24
|
+
expect(q.usedPct).toBe(16);
|
|
25
|
+
const expected = now.getTime() + ((3 * 24 + 1) * 3600 + 24 * 60) * 1000;
|
|
26
|
+
expect(new Date(q.resetsAt).getTime()).toBe(expected);
|
|
27
|
+
});
|
|
15
28
|
});
|
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { tools } from "../../src/mcp/server.js";
|
|
2
|
+
import { tools, recommendationTable } from "../../src/mcp/server.js";
|
|
3
3
|
describe("mcp", () => {
|
|
4
4
|
it("exposes three tools", () => {
|
|
5
5
|
expect(tools.map(t => t.name)).toEqual(expect.arrayContaining(["get_quotas", "get_recommendation", "forecast"]));
|
|
6
6
|
});
|
|
7
|
+
it("renders a markdown table with one row per provider", () => {
|
|
8
|
+
const rec = {
|
|
9
|
+
use: "kimi",
|
|
10
|
+
reason: "78% waste in 3.0d",
|
|
11
|
+
alternatives: [
|
|
12
|
+
{ provider: "kimi", usedPct: 16, resetsAt: "2026-09-01T09:02:00+10:00" },
|
|
13
|
+
{ provider: "claude", usedPct: 37, resetsAt: "2026-09-03T21:00:00+10:00" },
|
|
14
|
+
],
|
|
15
|
+
advisories: [
|
|
16
|
+
{ provider: "kimi", wastePct: 78.0 },
|
|
17
|
+
{ provider: "claude", wastePct: 52.9 },
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
const table = recommendationTable(rec);
|
|
21
|
+
expect(table).toMatch(/\| Provider \| Used \| Remaining \| Resets \| Waste if unused \|/);
|
|
22
|
+
expect(table).toMatch(/\| kimi \| 16% \| 84% \|/);
|
|
23
|
+
expect(table).toMatch(/\| claude \| 37% \| 63% \|/);
|
|
24
|
+
expect(table).toMatch(/\| 78% \|$/m);
|
|
25
|
+
});
|
|
7
26
|
});
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// generated by scripts/build-embed.mjs — do not edit
|
|
2
|
-
export const VERSION = "0.0.
|
|
2
|
+
export const VERSION = "0.0.6";
|