maqcli 0.2.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/README.md +223 -0
- package/dist/core/audit.d.ts +43 -0
- package/dist/core/audit.js +77 -0
- package/dist/core/board.d.ts +78 -0
- package/dist/core/board.js +256 -0
- package/dist/core/catalog.d.ts +50 -0
- package/dist/core/catalog.js +103 -0
- package/dist/core/command-catalog.d.ts +44 -0
- package/dist/core/command-catalog.js +86 -0
- package/dist/core/completion.d.ts +24 -0
- package/dist/core/completion.js +309 -0
- package/dist/core/complexity.d.ts +17 -0
- package/dist/core/complexity.js +87 -0
- package/dist/core/config-store.d.ts +33 -0
- package/dist/core/config-store.js +61 -0
- package/dist/core/connectivity.d.ts +34 -0
- package/dist/core/connectivity.js +49 -0
- package/dist/core/cost-tracker.d.ts +89 -0
- package/dist/core/cost-tracker.js +189 -0
- package/dist/core/cost.d.ts +35 -0
- package/dist/core/cost.js +89 -0
- package/dist/core/exec.d.ts +43 -0
- package/dist/core/exec.js +154 -0
- package/dist/core/flows.d.ts +36 -0
- package/dist/core/flows.js +96 -0
- package/dist/core/headroom.d.ts +36 -0
- package/dist/core/headroom.js +88 -0
- package/dist/core/help-topics.d.ts +26 -0
- package/dist/core/help-topics.js +294 -0
- package/dist/core/init-wizard.d.ts +26 -0
- package/dist/core/init-wizard.js +168 -0
- package/dist/core/interactive-registry.d.ts +50 -0
- package/dist/core/interactive-registry.js +86 -0
- package/dist/core/interactive.d.ts +48 -0
- package/dist/core/interactive.js +137 -0
- package/dist/core/logger.d.ts +16 -0
- package/dist/core/logger.js +46 -0
- package/dist/core/memory.d.ts +28 -0
- package/dist/core/memory.js +70 -0
- package/dist/core/metered.d.ts +9 -0
- package/dist/core/metered.js +16 -0
- package/dist/core/model.d.ts +74 -0
- package/dist/core/model.js +199 -0
- package/dist/core/pipeline.d.ts +33 -0
- package/dist/core/pipeline.js +223 -0
- package/dist/core/plugins.d.ts +21 -0
- package/dist/core/plugins.js +38 -0
- package/dist/core/probe.d.ts +48 -0
- package/dist/core/probe.js +156 -0
- package/dist/core/profiles.d.ts +42 -0
- package/dist/core/profiles.js +153 -0
- package/dist/core/providers.d.ts +84 -0
- package/dist/core/providers.js +275 -0
- package/dist/core/recall.d.ts +29 -0
- package/dist/core/recall.js +83 -0
- package/dist/core/registry.d.ts +41 -0
- package/dist/core/registry.js +162 -0
- package/dist/core/router.d.ts +33 -0
- package/dist/core/router.js +40 -0
- package/dist/core/sandbox.d.ts +78 -0
- package/dist/core/sandbox.js +268 -0
- package/dist/core/session.d.ts +105 -0
- package/dist/core/session.js +252 -0
- package/dist/core/skills.d.ts +56 -0
- package/dist/core/skills.js +289 -0
- package/dist/core/subagent.d.ts +40 -0
- package/dist/core/subagent.js +55 -0
- package/dist/core/supervisor.d.ts +37 -0
- package/dist/core/supervisor.js +40 -0
- package/dist/core/tools.d.ts +39 -0
- package/dist/core/tools.js +159 -0
- package/dist/core/types.d.ts +87 -0
- package/dist/core/types.js +10 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1032 -0
- package/dist/phases/execute.d.ts +39 -0
- package/dist/phases/execute.js +166 -0
- package/dist/phases/plan.d.ts +11 -0
- package/dist/phases/plan.js +118 -0
- package/dist/phases/scout.d.ts +10 -0
- package/dist/phases/scout.js +113 -0
- package/dist/phases/verify.d.ts +22 -0
- package/dist/phases/verify.js +81 -0
- package/dist/server/daemon.d.ts +50 -0
- package/dist/server/daemon.js +377 -0
- package/dist/server/relay-bridge.d.ts +44 -0
- package/dist/server/relay-bridge.js +175 -0
- package/package.json +39 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task complexity classifier (deterministic, zero-token).
|
|
3
|
+
*
|
|
4
|
+
* Used to gate the cost/quality dial: trivial tasks skip the Scout+Plan phases
|
|
5
|
+
* and go straight to Execute; complex tasks get the full pipeline. This is the
|
|
6
|
+
* lever that keeps multi-agent overhead (which can reach ~15x single-turn
|
|
7
|
+
* tokens) from being paid on work that does not need it.
|
|
8
|
+
*/
|
|
9
|
+
const COMPLEX_SIGNALS = [
|
|
10
|
+
"refactor",
|
|
11
|
+
"architecture",
|
|
12
|
+
"redesign",
|
|
13
|
+
"migrate",
|
|
14
|
+
"migration",
|
|
15
|
+
"across",
|
|
16
|
+
"multiple files",
|
|
17
|
+
"whole",
|
|
18
|
+
"entire",
|
|
19
|
+
"everything",
|
|
20
|
+
"improve the codebase",
|
|
21
|
+
"optimize",
|
|
22
|
+
"security",
|
|
23
|
+
"concurrency",
|
|
24
|
+
"distributed",
|
|
25
|
+
"design",
|
|
26
|
+
];
|
|
27
|
+
const TRIVIAL_SIGNALS = [
|
|
28
|
+
"typo",
|
|
29
|
+
"rename",
|
|
30
|
+
"bump version",
|
|
31
|
+
"add a comment",
|
|
32
|
+
"fix the comment",
|
|
33
|
+
"update the readme",
|
|
34
|
+
"change the string",
|
|
35
|
+
"one line",
|
|
36
|
+
"single line",
|
|
37
|
+
];
|
|
38
|
+
export function classifyComplexity(task, fileHints = []) {
|
|
39
|
+
const t = task.toLowerCase();
|
|
40
|
+
const reasons = [];
|
|
41
|
+
let score = 0;
|
|
42
|
+
for (const s of TRIVIAL_SIGNALS) {
|
|
43
|
+
if (t.includes(s)) {
|
|
44
|
+
score -= 2;
|
|
45
|
+
reasons.push(`trivial signal: "${s}"`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const s of COMPLEX_SIGNALS) {
|
|
49
|
+
if (t.includes(s)) {
|
|
50
|
+
score += 2;
|
|
51
|
+
reasons.push(`complex signal: "${s}"`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Length is a weak proxy for scope.
|
|
55
|
+
const words = t.split(/\s+/).filter(Boolean).length;
|
|
56
|
+
if (words > 40) {
|
|
57
|
+
score += 2;
|
|
58
|
+
reasons.push("long task description");
|
|
59
|
+
}
|
|
60
|
+
else if (words <= 8) {
|
|
61
|
+
score -= 1;
|
|
62
|
+
reasons.push("short task description");
|
|
63
|
+
}
|
|
64
|
+
// Touching many files => complex; exactly one scoped file => simpler.
|
|
65
|
+
if (fileHints.length > 3) {
|
|
66
|
+
score += 2;
|
|
67
|
+
reasons.push(`${fileHints.length} files in scope`);
|
|
68
|
+
}
|
|
69
|
+
else if (fileHints.length === 1) {
|
|
70
|
+
score -= 1;
|
|
71
|
+
reasons.push("single scoped file");
|
|
72
|
+
}
|
|
73
|
+
let complexity;
|
|
74
|
+
// Trivial requires a genuinely low score (typically an explicit trivial
|
|
75
|
+
// signal), not just an accumulation of weak proxies like "short + one file".
|
|
76
|
+
if (score <= -3)
|
|
77
|
+
complexity = "trivial";
|
|
78
|
+
else if (score >= 3)
|
|
79
|
+
complexity = "complex";
|
|
80
|
+
else
|
|
81
|
+
complexity = "standard";
|
|
82
|
+
return { complexity, score, reasons };
|
|
83
|
+
}
|
|
84
|
+
/** Whether the Scout+Plan pre-flight phases should run for this complexity. */
|
|
85
|
+
export function shouldRunPreflight(complexity) {
|
|
86
|
+
return complexity !== "trivial";
|
|
87
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Persistent config store at ~/.maqcli/config.json (dependency-free). */
|
|
2
|
+
export interface MaqConfig {
|
|
3
|
+
/** The lightest / user-chosen model the master uses for Scout/Plan/Verify. */
|
|
4
|
+
masterModel: string;
|
|
5
|
+
/** Default worker target: auto | claude | codex | gemini | none */
|
|
6
|
+
defaultTarget: string;
|
|
7
|
+
/** Provider for the master model. "heuristic" runs fully offline. */
|
|
8
|
+
provider: string;
|
|
9
|
+
/** RouteLLM-style tier models: cheap for triage, strong for hard steps. */
|
|
10
|
+
cheapModel: string;
|
|
11
|
+
strongModel: string;
|
|
12
|
+
/** Directory (relative to cwd) holding project skill/rule .md files. */
|
|
13
|
+
skillsDir: string;
|
|
14
|
+
/** Extended-thinking effort hint for providers that support it. */
|
|
15
|
+
thinkingEffort: string;
|
|
16
|
+
/** Write run artifacts + a hash-chained audit log for every run. */
|
|
17
|
+
auditRuns: boolean;
|
|
18
|
+
/** Optional outbound webhook URL for event forwarding (plugins). */
|
|
19
|
+
webhookUrl: string;
|
|
20
|
+
/** Proactive compaction threshold (fraction of context). */
|
|
21
|
+
compactionThreshold: number;
|
|
22
|
+
/** Per-project remembered target choices, keyed by cwd. */
|
|
23
|
+
projectTargets: Record<string, string>;
|
|
24
|
+
/** Default permission level (strict or standard) */
|
|
25
|
+
defaultPermission: string;
|
|
26
|
+
}
|
|
27
|
+
export declare const DEFAULT_CONFIG: MaqConfig;
|
|
28
|
+
export declare function configDir(): string;
|
|
29
|
+
export declare function configPath(): string;
|
|
30
|
+
export declare function loadConfig(): MaqConfig;
|
|
31
|
+
export declare function saveConfig(cfg: MaqConfig): void;
|
|
32
|
+
/** Set a single top-level scalar key by string, coercing to the existing type. */
|
|
33
|
+
export declare function setConfigKey(key: string, value: string): MaqConfig;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Persistent config store at ~/.maqcli/config.json (dependency-free). */
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
masterModel: "heuristic-local",
|
|
7
|
+
defaultTarget: "auto",
|
|
8
|
+
provider: "heuristic",
|
|
9
|
+
cheapModel: "heuristic-local",
|
|
10
|
+
strongModel: "heuristic-local",
|
|
11
|
+
skillsDir: ".maq/skills",
|
|
12
|
+
thinkingEffort: "high",
|
|
13
|
+
auditRuns: false,
|
|
14
|
+
webhookUrl: "",
|
|
15
|
+
compactionThreshold: 0.6,
|
|
16
|
+
projectTargets: {},
|
|
17
|
+
defaultPermission: "standard",
|
|
18
|
+
};
|
|
19
|
+
export function configDir() {
|
|
20
|
+
return process.env.MAQ_CONFIG_DIR ?? join(homedir(), ".maqcli");
|
|
21
|
+
}
|
|
22
|
+
export function configPath() {
|
|
23
|
+
return join(configDir(), "config.json");
|
|
24
|
+
}
|
|
25
|
+
export function loadConfig() {
|
|
26
|
+
const p = configPath();
|
|
27
|
+
if (!existsSync(p))
|
|
28
|
+
return { ...DEFAULT_CONFIG };
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(readFileSync(p, "utf8"));
|
|
31
|
+
return { ...DEFAULT_CONFIG, ...raw };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { ...DEFAULT_CONFIG };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function saveConfig(cfg) {
|
|
38
|
+
const dir = configDir();
|
|
39
|
+
if (!existsSync(dir))
|
|
40
|
+
mkdirSync(dir, { recursive: true });
|
|
41
|
+
writeFileSync(configPath(), JSON.stringify(cfg, null, 2), "utf8");
|
|
42
|
+
}
|
|
43
|
+
/** Set a single top-level scalar key by string, coercing to the existing type. */
|
|
44
|
+
export function setConfigKey(key, value) {
|
|
45
|
+
const cfg = loadConfig();
|
|
46
|
+
const current = cfg[key];
|
|
47
|
+
if (current === undefined) {
|
|
48
|
+
throw new Error(`Unknown config key: ${key}`);
|
|
49
|
+
}
|
|
50
|
+
let coerced = value;
|
|
51
|
+
if (typeof current === "number")
|
|
52
|
+
coerced = Number(value);
|
|
53
|
+
if (typeof current === "boolean")
|
|
54
|
+
coerced = value === "true";
|
|
55
|
+
if (typeof current === "object") {
|
|
56
|
+
throw new Error(`Cannot set object key '${key}' from the CLI`);
|
|
57
|
+
}
|
|
58
|
+
cfg[key] = coerced;
|
|
59
|
+
saveConfig(cfg);
|
|
60
|
+
return cfg;
|
|
61
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connectivity tier selection (pure decision logic).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the product's automatic fallback chain:
|
|
5
|
+
* LAN (mDNS, lowest latency) -> P2P (WebRTC + STUN) -> relay/tunnel -> warn.
|
|
6
|
+
* The actual transports live in the native tracks; this module encodes the
|
|
7
|
+
* decision so it can be unit-tested and reused by daemon and app alike.
|
|
8
|
+
*/
|
|
9
|
+
export type Tier = "lan" | "p2p" | "relay" | "blocked";
|
|
10
|
+
export interface NetworkObservation {
|
|
11
|
+
/** A candidate peer was found on the local network (mDNS/Bonjour). */
|
|
12
|
+
lanPeerFound: boolean;
|
|
13
|
+
/** Round-trip latency to the LAN peer, if measured (ms). */
|
|
14
|
+
lanLatencyMs?: number;
|
|
15
|
+
/** WebRTC ICE reached a direct P2P connection (STUN succeeded). */
|
|
16
|
+
p2pConnected: boolean;
|
|
17
|
+
/** A relay/tunnel path (Cloudflare Tunnel / Tailscale DERP) is available. */
|
|
18
|
+
relayAvailable: boolean;
|
|
19
|
+
/** Relay round-trip latency, if known (ms). */
|
|
20
|
+
relayLatencyMs?: number;
|
|
21
|
+
/** ICE stuck in "checking"/"failed" => strict/symmetric NAT suspected. */
|
|
22
|
+
symmetricNatSuspected: boolean;
|
|
23
|
+
/** User has premium (TURN relay) entitlement. */
|
|
24
|
+
premium: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface TierDecision {
|
|
27
|
+
tier: Tier;
|
|
28
|
+
reason: string;
|
|
29
|
+
/** High-latency advisory the UI should surface. */
|
|
30
|
+
latencyWarning: boolean;
|
|
31
|
+
/** Whether to show the "strict network -> go premium" upsell. */
|
|
32
|
+
upsell: boolean;
|
|
33
|
+
}
|
|
34
|
+
export declare function selectTier(obs: NetworkObservation): TierDecision;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connectivity tier selection (pure decision logic).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the product's automatic fallback chain:
|
|
5
|
+
* LAN (mDNS, lowest latency) -> P2P (WebRTC + STUN) -> relay/tunnel -> warn.
|
|
6
|
+
* The actual transports live in the native tracks; this module encodes the
|
|
7
|
+
* decision so it can be unit-tested and reused by daemon and app alike.
|
|
8
|
+
*/
|
|
9
|
+
const HIGH_LATENCY_MS = 300;
|
|
10
|
+
export function selectTier(obs) {
|
|
11
|
+
// 1. Same room / LAN: lowest latency, no internet required.
|
|
12
|
+
if (obs.lanPeerFound) {
|
|
13
|
+
return {
|
|
14
|
+
tier: "lan",
|
|
15
|
+
reason: "LAN peer discovered via mDNS",
|
|
16
|
+
latencyWarning: (obs.lanLatencyMs ?? 0) > HIGH_LATENCY_MS,
|
|
17
|
+
upsell: false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// 2. Direct P2P via WebRTC + STUN.
|
|
21
|
+
if (obs.p2pConnected) {
|
|
22
|
+
return {
|
|
23
|
+
tier: "p2p",
|
|
24
|
+
reason: "direct P2P established (STUN)",
|
|
25
|
+
latencyWarning: false,
|
|
26
|
+
upsell: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// 3. Relay / tunnel fallback. Free-tier users get pass-through/DERP; if a
|
|
30
|
+
// symmetric NAT blocked P2P and no free relay works, premium TURN is the fix.
|
|
31
|
+
if (obs.relayAvailable) {
|
|
32
|
+
return {
|
|
33
|
+
tier: "relay",
|
|
34
|
+
reason: "P2P unavailable; using relay/tunnel",
|
|
35
|
+
latencyWarning: (obs.relayLatencyMs ?? 0) > HIGH_LATENCY_MS,
|
|
36
|
+
upsell: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// 4. Blocked. If a strict/symmetric NAT is the cause and the user is not
|
|
40
|
+
// premium, surface the premium TURN upsell instead of an infinite spinner.
|
|
41
|
+
return {
|
|
42
|
+
tier: "blocked",
|
|
43
|
+
reason: obs.symmetricNatSuspected
|
|
44
|
+
? "strict/symmetric NAT blocked direct connection and no relay available"
|
|
45
|
+
: "no connectivity path available",
|
|
46
|
+
latencyWarning: false,
|
|
47
|
+
upsell: obs.symmetricNatSuspected && !obs.premium,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cost-tracker
|
|
3
|
+
* @description Aggregated cost tracking across maq runs.
|
|
4
|
+
*
|
|
5
|
+
* Persists individual cost entries to `<cwd>/.maq/costs.jsonl` and
|
|
6
|
+
* provides reporting aggregated by model and day.
|
|
7
|
+
*
|
|
8
|
+
* Zero npm dependencies — uses only Node built-ins.
|
|
9
|
+
*/
|
|
10
|
+
/** A single cost-tracking entry written per LLM call or task. */
|
|
11
|
+
export interface CostEntry {
|
|
12
|
+
/** ISO-8601 timestamp of the call. */
|
|
13
|
+
ts: string;
|
|
14
|
+
/** Unique identifier for the parent task. */
|
|
15
|
+
taskId: string;
|
|
16
|
+
/** Human-readable task description. */
|
|
17
|
+
task: string;
|
|
18
|
+
/** LLM provider name (e.g. "openai", "anthropic"). */
|
|
19
|
+
provider: string;
|
|
20
|
+
/** Model identifier (e.g. "gpt-4o", "claude-3-opus"). */
|
|
21
|
+
model: string;
|
|
22
|
+
/** Number of API calls represented by this entry. */
|
|
23
|
+
calls: number;
|
|
24
|
+
/** Prompt / input tokens consumed. */
|
|
25
|
+
promptTokens: number;
|
|
26
|
+
/** Completion / output tokens produced. */
|
|
27
|
+
completionTokens: number;
|
|
28
|
+
/** Total tokens (prompt + completion). */
|
|
29
|
+
totalTokens: number;
|
|
30
|
+
/** Estimated cost in US dollars. */
|
|
31
|
+
usd: number;
|
|
32
|
+
}
|
|
33
|
+
/** Aggregated cost report produced by {@link CostTracker.report}. */
|
|
34
|
+
export interface CostReport {
|
|
35
|
+
totalUsd: number;
|
|
36
|
+
totalTokens: number;
|
|
37
|
+
totalCalls: number;
|
|
38
|
+
byModel: Record<string, {
|
|
39
|
+
usd: number;
|
|
40
|
+
tokens: number;
|
|
41
|
+
calls: number;
|
|
42
|
+
}>;
|
|
43
|
+
byDay: Record<string, {
|
|
44
|
+
usd: number;
|
|
45
|
+
tokens: number;
|
|
46
|
+
calls: number;
|
|
47
|
+
}>;
|
|
48
|
+
entries: CostEntry[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Tracks and reports LLM usage costs across maq runs.
|
|
52
|
+
*
|
|
53
|
+
* Data is persisted as newline-delimited JSON (JSONL) at
|
|
54
|
+
* `<cwd>/.maq/costs.jsonl`.
|
|
55
|
+
*/
|
|
56
|
+
export declare class CostTracker {
|
|
57
|
+
private readonly filePath;
|
|
58
|
+
/**
|
|
59
|
+
* @param cwd - The project working directory containing the `.maq/` folder.
|
|
60
|
+
*/
|
|
61
|
+
constructor(cwd: string);
|
|
62
|
+
/**
|
|
63
|
+
* Appends a cost entry as a single JSON line.
|
|
64
|
+
* Creates the parent directory if it does not exist.
|
|
65
|
+
*/
|
|
66
|
+
record(entry: CostEntry): void;
|
|
67
|
+
/**
|
|
68
|
+
* Deletes the cost ledger file (if it exists).
|
|
69
|
+
*/
|
|
70
|
+
reset(): void;
|
|
71
|
+
/**
|
|
72
|
+
* Reads all recorded entries and produces an aggregated {@link CostReport}.
|
|
73
|
+
*
|
|
74
|
+
* Returns a zero-value report when the file is missing or empty.
|
|
75
|
+
*/
|
|
76
|
+
report(): CostReport;
|
|
77
|
+
/**
|
|
78
|
+
* Formats a {@link CostReport} into a human-readable multi-section string.
|
|
79
|
+
*
|
|
80
|
+
* Sections: **Total**, **By model**, **By day**, **Recent runs**.
|
|
81
|
+
*/
|
|
82
|
+
static renderReport(report: CostReport): string;
|
|
83
|
+
/**
|
|
84
|
+
* Reads and parses the JSONL ledger file.
|
|
85
|
+
* Returns an empty array if the file is missing, empty, or contains
|
|
86
|
+
* only malformed lines (which are silently skipped).
|
|
87
|
+
*/
|
|
88
|
+
private _readEntries;
|
|
89
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cost-tracker
|
|
3
|
+
* @description Aggregated cost tracking across maq runs.
|
|
4
|
+
*
|
|
5
|
+
* Persists individual cost entries to `<cwd>/.maq/costs.jsonl` and
|
|
6
|
+
* provides reporting aggregated by model and day.
|
|
7
|
+
*
|
|
8
|
+
* Zero npm dependencies — uses only Node built-ins.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
/* ------------------------------------------------------------------ */
|
|
13
|
+
/* Helpers */
|
|
14
|
+
/* ------------------------------------------------------------------ */
|
|
15
|
+
/** Extracts the YYYY-MM-DD date portion from an ISO timestamp. */
|
|
16
|
+
function dayKey(ts) {
|
|
17
|
+
return ts.slice(0, 10);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Pads or truncates `str` to exactly `len` characters, right-aligned
|
|
21
|
+
* when `align` is `"right"`, left-aligned otherwise.
|
|
22
|
+
*/
|
|
23
|
+
function pad(str, len, align = "left") {
|
|
24
|
+
if (align === "right") {
|
|
25
|
+
return str.padStart(len);
|
|
26
|
+
}
|
|
27
|
+
return str.padEnd(len);
|
|
28
|
+
}
|
|
29
|
+
/** Formats a USD amount to four decimal places with a `$` prefix. */
|
|
30
|
+
function fmtUsd(usd) {
|
|
31
|
+
return `$${usd.toFixed(4)}`;
|
|
32
|
+
}
|
|
33
|
+
/** Formats a number with locale-aware thousands separators. */
|
|
34
|
+
function fmtNum(n) {
|
|
35
|
+
return n.toLocaleString("en-US");
|
|
36
|
+
}
|
|
37
|
+
/* ------------------------------------------------------------------ */
|
|
38
|
+
/* CostTracker */
|
|
39
|
+
/* ------------------------------------------------------------------ */
|
|
40
|
+
/**
|
|
41
|
+
* Tracks and reports LLM usage costs across maq runs.
|
|
42
|
+
*
|
|
43
|
+
* Data is persisted as newline-delimited JSON (JSONL) at
|
|
44
|
+
* `<cwd>/.maq/costs.jsonl`.
|
|
45
|
+
*/
|
|
46
|
+
export class CostTracker {
|
|
47
|
+
filePath;
|
|
48
|
+
/**
|
|
49
|
+
* @param cwd - The project working directory containing the `.maq/` folder.
|
|
50
|
+
*/
|
|
51
|
+
constructor(cwd) {
|
|
52
|
+
this.filePath = join(cwd, ".maq", "costs.jsonl");
|
|
53
|
+
}
|
|
54
|
+
/* ── Mutation ─────────────────────────────────────────────────── */
|
|
55
|
+
/**
|
|
56
|
+
* Appends a cost entry as a single JSON line.
|
|
57
|
+
* Creates the parent directory if it does not exist.
|
|
58
|
+
*/
|
|
59
|
+
record(entry) {
|
|
60
|
+
const dir = dirname(this.filePath);
|
|
61
|
+
if (!existsSync(dir)) {
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
appendFileSync(this.filePath, JSON.stringify(entry) + "\n", "utf-8");
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Deletes the cost ledger file (if it exists).
|
|
68
|
+
*/
|
|
69
|
+
reset() {
|
|
70
|
+
if (existsSync(this.filePath)) {
|
|
71
|
+
unlinkSync(this.filePath);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/* ── Reporting ────────────────────────────────────────────────── */
|
|
75
|
+
/**
|
|
76
|
+
* Reads all recorded entries and produces an aggregated {@link CostReport}.
|
|
77
|
+
*
|
|
78
|
+
* Returns a zero-value report when the file is missing or empty.
|
|
79
|
+
*/
|
|
80
|
+
report() {
|
|
81
|
+
const entries = this._readEntries();
|
|
82
|
+
let totalUsd = 0;
|
|
83
|
+
let totalTokens = 0;
|
|
84
|
+
let totalCalls = 0;
|
|
85
|
+
const byModel = {};
|
|
86
|
+
const byDay = {};
|
|
87
|
+
for (const e of entries) {
|
|
88
|
+
totalUsd += e.usd;
|
|
89
|
+
totalTokens += e.totalTokens;
|
|
90
|
+
totalCalls += e.calls;
|
|
91
|
+
// Aggregate by model
|
|
92
|
+
const m = byModel[e.model] ??= { usd: 0, tokens: 0, calls: 0 };
|
|
93
|
+
m.usd += e.usd;
|
|
94
|
+
m.tokens += e.totalTokens;
|
|
95
|
+
m.calls += e.calls;
|
|
96
|
+
// Aggregate by day
|
|
97
|
+
const dk = dayKey(e.ts);
|
|
98
|
+
const d = byDay[dk] ??= { usd: 0, tokens: 0, calls: 0 };
|
|
99
|
+
d.usd += e.usd;
|
|
100
|
+
d.tokens += e.totalTokens;
|
|
101
|
+
d.calls += e.calls;
|
|
102
|
+
}
|
|
103
|
+
// Sort entries newest-first
|
|
104
|
+
const sorted = [...entries].sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
|
|
105
|
+
return { totalUsd, totalTokens, totalCalls, byModel, byDay, entries: sorted };
|
|
106
|
+
}
|
|
107
|
+
/* ── Static rendering ─────────────────────────────────────────── */
|
|
108
|
+
/**
|
|
109
|
+
* Formats a {@link CostReport} into a human-readable multi-section string.
|
|
110
|
+
*
|
|
111
|
+
* Sections: **Total**, **By model**, **By day**, **Recent runs**.
|
|
112
|
+
*/
|
|
113
|
+
static renderReport(report) {
|
|
114
|
+
const lines = [];
|
|
115
|
+
/* ── Total ───────────────────────────────────────────────── */
|
|
116
|
+
lines.push("┌─────────────────────────────────────────┐");
|
|
117
|
+
lines.push("│ Cost Report │");
|
|
118
|
+
lines.push("├─────────────────────────────────────────┤");
|
|
119
|
+
lines.push(`│ Total cost: ${pad(fmtUsd(report.totalUsd), 24, "right")} │`);
|
|
120
|
+
lines.push(`│ Total tokens: ${pad(fmtNum(report.totalTokens), 24, "right")} │`);
|
|
121
|
+
lines.push(`│ Total calls: ${pad(fmtNum(report.totalCalls), 24, "right")} │`);
|
|
122
|
+
lines.push("└─────────────────────────────────────────┘");
|
|
123
|
+
lines.push("");
|
|
124
|
+
/* ── By model ────────────────────────────────────────────── */
|
|
125
|
+
const modelKeys = Object.keys(report.byModel).sort();
|
|
126
|
+
if (modelKeys.length > 0) {
|
|
127
|
+
lines.push(" By model:");
|
|
128
|
+
for (const model of modelKeys) {
|
|
129
|
+
const m = report.byModel[model];
|
|
130
|
+
lines.push(` ${pad(model, 24)} ${pad(fmtUsd(m.usd), 12, "right")} ${pad(fmtNum(m.tokens) + " tok", 16, "right")} ${pad(String(m.calls) + " calls", 10, "right")}`);
|
|
131
|
+
}
|
|
132
|
+
lines.push("");
|
|
133
|
+
}
|
|
134
|
+
/* ── By day ──────────────────────────────────────────────── */
|
|
135
|
+
const dayKeys = Object.keys(report.byDay).sort().reverse();
|
|
136
|
+
if (dayKeys.length > 0) {
|
|
137
|
+
lines.push(" By day:");
|
|
138
|
+
for (const day of dayKeys) {
|
|
139
|
+
const d = report.byDay[day];
|
|
140
|
+
lines.push(` ${day} ${pad(fmtUsd(d.usd), 12, "right")} ${pad(fmtNum(d.tokens) + " tok", 16, "right")} ${pad(String(d.calls) + " calls", 10, "right")}`);
|
|
141
|
+
}
|
|
142
|
+
lines.push("");
|
|
143
|
+
}
|
|
144
|
+
/* ── Recent runs ─────────────────────────────────────────── */
|
|
145
|
+
const recent = report.entries.slice(0, 10);
|
|
146
|
+
if (recent.length > 0) {
|
|
147
|
+
lines.push(" Recent runs:");
|
|
148
|
+
for (const e of recent) {
|
|
149
|
+
const ts = e.ts.slice(0, 19).replace("T", " ");
|
|
150
|
+
const desc = e.task.length > 30 ? e.task.slice(0, 27) + "..." : e.task;
|
|
151
|
+
lines.push(` ${ts} ${pad(desc, 30)} ${pad(e.model, 18)} ${pad(fmtUsd(e.usd), 10, "right")}`);
|
|
152
|
+
}
|
|
153
|
+
lines.push("");
|
|
154
|
+
}
|
|
155
|
+
if (report.entries.length === 0) {
|
|
156
|
+
lines.push(" No cost data recorded yet.");
|
|
157
|
+
lines.push("");
|
|
158
|
+
}
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
/* ── Private ──────────────────────────────────────────────────── */
|
|
162
|
+
/**
|
|
163
|
+
* Reads and parses the JSONL ledger file.
|
|
164
|
+
* Returns an empty array if the file is missing, empty, or contains
|
|
165
|
+
* only malformed lines (which are silently skipped).
|
|
166
|
+
*/
|
|
167
|
+
_readEntries() {
|
|
168
|
+
if (!existsSync(this.filePath)) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
const raw = readFileSync(this.filePath, "utf-8");
|
|
172
|
+
if (!raw.trim()) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const entries = [];
|
|
176
|
+
for (const line of raw.split("\n")) {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed)
|
|
179
|
+
continue;
|
|
180
|
+
try {
|
|
181
|
+
entries.push(JSON.parse(trimmed));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Skip malformed lines gracefully
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return entries;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token / cost accounting (LiteLLM-style spend visibility, dependency-free).
|
|
3
|
+
*
|
|
4
|
+
* Prices are USD per 1,000,000 tokens and are ESTIMATES for budgeting only —
|
|
5
|
+
* they change over time and per-region. Override any of them at runtime via
|
|
6
|
+
* `MAQ_PRICES` (a JSON map of "model" -> { in, out }) or programmatically with
|
|
7
|
+
* `setPrice`. Unknown models cost $0 (est) so accounting never throws.
|
|
8
|
+
*/
|
|
9
|
+
export interface Price {
|
|
10
|
+
/** USD per 1M input/prompt tokens. */
|
|
11
|
+
in: number;
|
|
12
|
+
/** USD per 1M output/completion tokens. */
|
|
13
|
+
out: number;
|
|
14
|
+
}
|
|
15
|
+
/** Register or override a model price at runtime. */
|
|
16
|
+
export declare function setPrice(model: string, price: Price): void;
|
|
17
|
+
/** Look up the price for a model by longest-prefix match; $0 if unknown. */
|
|
18
|
+
export declare function priceFor(model: string): Price;
|
|
19
|
+
/** Compute USD cost for a completed call. Ollama/local models are free. */
|
|
20
|
+
export declare function costUsd(model: string, promptTokens: number, completionTokens: number): number;
|
|
21
|
+
/** Running spend accumulator. Thread one through a pipeline/session. */
|
|
22
|
+
export declare class CostMeter {
|
|
23
|
+
private _promptTokens;
|
|
24
|
+
private _completionTokens;
|
|
25
|
+
private _usd;
|
|
26
|
+
private _calls;
|
|
27
|
+
record(model: string, promptTokens: number, completionTokens: number): void;
|
|
28
|
+
get summary(): {
|
|
29
|
+
calls: number;
|
|
30
|
+
promptTokens: number;
|
|
31
|
+
completionTokens: number;
|
|
32
|
+
totalTokens: number;
|
|
33
|
+
usd: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token / cost accounting (LiteLLM-style spend visibility, dependency-free).
|
|
3
|
+
*
|
|
4
|
+
* Prices are USD per 1,000,000 tokens and are ESTIMATES for budgeting only —
|
|
5
|
+
* they change over time and per-region. Override any of them at runtime via
|
|
6
|
+
* `MAQ_PRICES` (a JSON map of "model" -> { in, out }) or programmatically with
|
|
7
|
+
* `setPrice`. Unknown models cost $0 (est) so accounting never throws.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Built-in price table (USD / 1M tokens). Estimates as of 2026; treat as
|
|
11
|
+
* defaults, not billing truth. Keyed by a normalized model name prefix.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_PRICES = {
|
|
14
|
+
// OpenAI
|
|
15
|
+
"gpt-4o-mini": { in: 0.15, out: 0.6 },
|
|
16
|
+
"gpt-4o": { in: 2.5, out: 10 },
|
|
17
|
+
"gpt-4.1-mini": { in: 0.4, out: 1.6 },
|
|
18
|
+
"gpt-4.1": { in: 2, out: 8 },
|
|
19
|
+
"o4-mini": { in: 1.1, out: 4.4 },
|
|
20
|
+
// Anthropic
|
|
21
|
+
"claude-haiku": { in: 0.8, out: 4 },
|
|
22
|
+
"claude-3-5-haiku": { in: 0.8, out: 4 },
|
|
23
|
+
"claude-sonnet": { in: 3, out: 15 },
|
|
24
|
+
"claude-3-5-sonnet": { in: 3, out: 15 },
|
|
25
|
+
"claude-opus": { in: 15, out: 75 },
|
|
26
|
+
// Local / offline
|
|
27
|
+
"heuristic": { in: 0, out: 0 },
|
|
28
|
+
"heuristic-local": { in: 0, out: 0 },
|
|
29
|
+
"ollama": { in: 0, out: 0 },
|
|
30
|
+
};
|
|
31
|
+
const overrides = loadEnvPrices();
|
|
32
|
+
function loadEnvPrices() {
|
|
33
|
+
const raw = process.env.MAQ_PRICES;
|
|
34
|
+
if (!raw)
|
|
35
|
+
return {};
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Register or override a model price at runtime. */
|
|
45
|
+
export function setPrice(model, price) {
|
|
46
|
+
overrides[model.toLowerCase()] = price;
|
|
47
|
+
}
|
|
48
|
+
/** Look up the price for a model by longest-prefix match; $0 if unknown. */
|
|
49
|
+
export function priceFor(model) {
|
|
50
|
+
const key = model.toLowerCase();
|
|
51
|
+
if (overrides[key])
|
|
52
|
+
return overrides[key];
|
|
53
|
+
// Longest matching known prefix wins (so "claude-3-5-haiku-20241022" -> haiku).
|
|
54
|
+
const table = { ...DEFAULT_PRICES, ...overrides };
|
|
55
|
+
let best = null;
|
|
56
|
+
for (const [name, price] of Object.entries(table)) {
|
|
57
|
+
if (key.includes(name) && (!best || name.length > best.len)) {
|
|
58
|
+
best = { len: name.length, price };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return best?.price ?? { in: 0, out: 0 };
|
|
62
|
+
}
|
|
63
|
+
/** Compute USD cost for a completed call. Ollama/local models are free. */
|
|
64
|
+
export function costUsd(model, promptTokens, completionTokens) {
|
|
65
|
+
const p = priceFor(model);
|
|
66
|
+
return (promptTokens / 1e6) * p.in + (completionTokens / 1e6) * p.out;
|
|
67
|
+
}
|
|
68
|
+
/** Running spend accumulator. Thread one through a pipeline/session. */
|
|
69
|
+
export class CostMeter {
|
|
70
|
+
_promptTokens = 0;
|
|
71
|
+
_completionTokens = 0;
|
|
72
|
+
_usd = 0;
|
|
73
|
+
_calls = 0;
|
|
74
|
+
record(model, promptTokens, completionTokens) {
|
|
75
|
+
this._promptTokens += promptTokens;
|
|
76
|
+
this._completionTokens += completionTokens;
|
|
77
|
+
this._usd += costUsd(model, promptTokens, completionTokens);
|
|
78
|
+
this._calls += 1;
|
|
79
|
+
}
|
|
80
|
+
get summary() {
|
|
81
|
+
return {
|
|
82
|
+
calls: this._calls,
|
|
83
|
+
promptTokens: this._promptTokens,
|
|
84
|
+
completionTokens: this._completionTokens,
|
|
85
|
+
totalTokens: this._promptTokens + this._completionTokens,
|
|
86
|
+
usd: Number(this._usd.toFixed(6)),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|