@hasna/economy 0.4.0 → 0.5.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/CHANGELOG.md +31 -0
- package/README.md +12 -9
- package/dist/chunks/agent-registry-store-tpnhx3wj.js +39 -0
- package/dist/chunks/billing-bkpxy4kv.js +13 -0
- package/dist/chunks/config-j9mv3ehk.js +15 -0
- package/dist/chunks/index-2hwzmfgv.js +3407 -0
- package/dist/chunks/index-49f12css.js +124 -0
- package/dist/chunks/index-d480p2wh.js +1322 -0
- package/dist/chunks/index-ez9thsr7.js +3440 -0
- package/dist/chunks/index-hesxq90g.js +62 -0
- package/dist/chunks/index-k540hfww.js +258 -0
- package/dist/chunks/index-p35cyhc4.js +4 -0
- package/dist/chunks/index-r1jv3kpy.js +451 -0
- package/dist/chunks/index-w3bvv0ns.js +26 -0
- package/dist/chunks/index-xsah93s2.js +2085 -0
- package/dist/chunks/index-zpvea08j.js +3372 -0
- package/dist/chunks/menubar-6xtkt17h.js +296 -0
- package/dist/chunks/open-projects-6z4pq6mx.js +39 -0
- package/dist/chunks/pricing-f80p81gr.js +29 -0
- package/dist/chunks/pricing-vf2pycmh.js +30 -0
- package/dist/chunks/pricing-y21mcjsa.js +30 -0
- package/dist/chunks/serve-2knem98x.js +4655 -0
- package/dist/chunks/sqlite-store-501f32ts.js +258 -0
- package/dist/chunks/sqlite-store-fq4natnf.js +256 -0
- package/dist/chunks/sqlite-store-st99garx.js +258 -0
- package/dist/chunks/third-party-sqlite-5e85fxa2.js +11 -0
- package/dist/chunks/tui-khmnkat2.js +22 -0
- package/dist/chunks/watch-4y064yys.js +196 -0
- package/dist/chunks/webhooks-k4fh2t9c.js +109 -0
- package/dist/cli/commands/brief.d.ts.map +1 -1
- package/dist/cli/commands/extras.d.ts.map +1 -1
- package/dist/cli/commands/journal.d.ts +3 -0
- package/dist/cli/commands/journal.d.ts.map +1 -0
- package/dist/cli/commands/menubar.d.ts +97 -4
- package/dist/cli/commands/menubar.d.ts.map +1 -1
- package/dist/cli/commands/tui.d.ts +1 -1
- package/dist/cli/commands/tui.d.ts.map +1 -1
- package/dist/cli/commands/watch.d.ts.map +1 -1
- package/dist/cli/index.js +1358 -10114
- package/dist/db/cloud.d.ts +7 -9
- package/dist/db/cloud.d.ts.map +1 -1
- package/dist/db/database.d.ts +14 -12
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/ingest-concurrency-worker.d.ts +2 -0
- package/dist/db/ingest-concurrency-worker.d.ts.map +1 -0
- package/dist/db/ingest-errors.d.ts +9 -0
- package/dist/db/ingest-errors.d.ts.map +1 -0
- package/dist/db/ingest-schema.d.ts +3 -0
- package/dist/db/ingest-schema.d.ts.map +1 -0
- package/dist/db/ingest-specs.d.ts +8 -0
- package/dist/db/ingest-specs.d.ts.map +1 -0
- package/dist/db/ingest-test-contract.d.ts +3 -0
- package/dist/db/ingest-test-contract.d.ts.map +1 -0
- package/dist/db/ingest-validation.d.ts +12 -0
- package/dist/db/ingest-validation.d.ts.map +1 -0
- package/dist/db/ingest.d.ts +27 -0
- package/dist/db/ingest.d.ts.map +1 -0
- package/dist/db/legacy-schema.d.ts +7 -0
- package/dist/db/legacy-schema.d.ts.map +1 -0
- package/dist/db/observation-contract.d.ts +3 -0
- package/dist/db/observation-contract.d.ts.map +1 -0
- package/dist/db/observation-provenance.d.ts +23 -0
- package/dist/db/observation-provenance.d.ts.map +1 -0
- package/dist/db/observation-schema.d.ts +24 -0
- package/dist/db/observation-schema.d.ts.map +1 -0
- package/dist/db/pg-connection.d.ts +10 -0
- package/dist/db/pg-connection.d.ts.map +1 -0
- package/dist/db/pg-legacy-schema.d.ts +12 -0
- package/dist/db/pg-legacy-schema.d.ts.map +1 -0
- package/dist/db/pg-migrate.d.ts +4 -9
- package/dist/db/pg-migrate.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/pg-protocol.d.ts +28 -0
- package/dist/db/pg-protocol.d.ts.map +1 -0
- package/dist/db/sqlite-store.d.ts +4 -0
- package/dist/db/sqlite-store.d.ts.map +1 -0
- package/dist/db/sync-pg.d.ts +17 -21
- package/dist/db/sync-pg.d.ts.map +1 -1
- package/dist/db/third-party-sqlite.d.ts +10 -0
- package/dist/db/third-party-sqlite.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1171 -2465
- package/dist/ingest/claude-quota.d.ts.map +1 -1
- package/dist/ingest/claude.d.ts.map +1 -1
- package/dist/ingest/codex-quota.d.ts.map +1 -1
- package/dist/ingest/codex.d.ts +25 -0
- package/dist/ingest/codex.d.ts.map +1 -1
- package/dist/ingest/collection-outcome.d.ts +17 -0
- package/dist/ingest/collection-outcome.d.ts.map +1 -0
- package/dist/ingest/cursor.d.ts.map +1 -1
- package/dist/ingest/gemini.d.ts.map +1 -1
- package/dist/ingest/hermes.d.ts +1 -1
- package/dist/ingest/hermes.d.ts.map +1 -1
- package/dist/ingest/loops.d.ts.map +1 -1
- package/dist/ingest/opencode.d.ts +1 -1
- package/dist/ingest/opencode.d.ts.map +1 -1
- package/dist/ingest/pi.d.ts +1 -1
- package/dist/ingest/pi.d.ts.map +1 -1
- package/dist/ingest/quota-observation.d.ts +16 -0
- package/dist/ingest/quota-observation.d.ts.map +1 -0
- package/dist/ingest/source-time.d.ts +4 -0
- package/dist/ingest/source-time.d.ts.map +1 -0
- package/dist/lib/accounts-store.d.ts +39 -13
- package/dist/lib/accounts-store.d.ts.map +1 -1
- package/dist/lib/analytics.d.ts.map +1 -1
- package/dist/lib/api-display-url.d.ts +1 -1
- package/dist/lib/autosync-gate.d.ts +12 -5
- package/dist/lib/autosync-gate.d.ts.map +1 -1
- package/dist/lib/billing-diff.d.ts +18 -40
- package/dist/lib/billing-diff.d.ts.map +1 -1
- package/dist/lib/brief.d.ts +2 -0
- package/dist/lib/brief.d.ts.map +1 -1
- package/dist/lib/budget-decimal.d.ts +14 -0
- package/dist/lib/budget-decimal.d.ts.map +1 -0
- package/dist/lib/cloud-ingest.d.ts +15 -0
- package/dist/lib/cloud-ingest.d.ts.map +1 -1
- package/dist/lib/cloud-storage.d.ts.map +1 -1
- package/dist/lib/ingest-batches.d.ts +21 -0
- package/dist/lib/ingest-batches.d.ts.map +1 -0
- package/dist/lib/ingest-journal-client.d.ts +37 -0
- package/dist/lib/ingest-journal-client.d.ts.map +1 -0
- package/dist/lib/ingest-journal-lock.d.ts +5 -0
- package/dist/lib/ingest-journal-lock.d.ts.map +1 -0
- package/dist/lib/ingest-journal-maintenance.d.ts +92 -0
- package/dist/lib/ingest-journal-maintenance.d.ts.map +1 -0
- package/dist/lib/ingest-journal-operation.d.ts +55 -0
- package/dist/lib/ingest-journal-operation.d.ts.map +1 -0
- package/dist/lib/ingest-journal.d.ts +73 -0
- package/dist/lib/ingest-journal.d.ts.map +1 -0
- package/dist/lib/money-display.d.ts +13 -0
- package/dist/lib/money-display.d.ts.map +1 -0
- package/dist/lib/periods.d.ts +12 -0
- package/dist/lib/periods.d.ts.map +1 -1
- package/dist/lib/pricing.d.ts +14 -0
- package/dist/lib/pricing.d.ts.map +1 -1
- package/dist/lib/savings.d.ts +64 -7
- package/dist/lib/savings.d.ts.map +1 -1
- package/dist/lib/store/index.d.ts +25 -21
- package/dist/lib/store/index.d.ts.map +1 -1
- package/dist/lib/summary-contract.d.ts +32 -0
- package/dist/lib/summary-contract.d.ts.map +1 -0
- package/dist/lib/summary-test-contract.d.ts +3 -0
- package/dist/lib/summary-test-contract.d.ts.map +1 -0
- package/dist/lib/sync-all.d.ts +4 -0
- package/dist/lib/sync-all.d.ts.map +1 -1
- package/dist/lib/sync-maintenance.d.ts.map +1 -1
- package/dist/lib/sync-report.d.ts +4 -0
- package/dist/lib/sync-report.d.ts.map +1 -0
- package/dist/lib/test-keychain-fixture.d.ts +9 -0
- package/dist/lib/test-keychain-fixture.d.ts.map +1 -0
- package/dist/mcp/agent-registry-store.d.ts +30 -0
- package/dist/mcp/agent-registry-store.d.ts.map +1 -0
- package/dist/mcp/agent-registry.d.ts +9 -16
- package/dist/mcp/agent-registry.d.ts.map +1 -1
- package/dist/mcp/index.js +2291 -3458
- package/dist/mcp/ingest-journal-tool.d.ts +2 -0
- package/dist/mcp/ingest-journal-tool.d.ts.map +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/otel/index.js +1960 -289
- package/dist/server/human-auth.d.ts +20 -0
- package/dist/server/human-auth.d.ts.map +1 -0
- package/dist/server/index.js +10639 -6720
- package/dist/server/legacy-inventory.d.ts +52 -0
- package/dist/server/legacy-inventory.d.ts.map +1 -0
- package/dist/server/legacy-operations.d.ts +64 -0
- package/dist/server/legacy-operations.d.ts.map +1 -0
- package/dist/server/legacy-plan.d.ts +55 -0
- package/dist/server/legacy-plan.d.ts.map +1 -0
- package/dist/server/legacy-preservation.d.ts +58 -0
- package/dist/server/legacy-preservation.d.ts.map +1 -0
- package/dist/server/pg-sync-worker.js +195 -21
- package/dist/server/serve.d.ts +20 -16
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/service-admission.d.ts +10 -0
- package/dist/server/service-admission.d.ts.map +1 -0
- package/dist/server/service-dispatcher.d.ts +45 -0
- package/dist/server/service-dispatcher.d.ts.map +1 -0
- package/dist/server/service-operations.d.ts +5 -0
- package/dist/server/service-operations.d.ts.map +1 -0
- package/dist/server/service-protocol.d.ts +50 -0
- package/dist/server/service-protocol.d.ts.map +1 -0
- package/dist/server/service-worker.d.ts +2 -0
- package/dist/server/service-worker.d.ts.map +1 -0
- package/dist/server/service-worker.js +8038 -0
- package/dist/types/index.d.ts +66 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/configuration.md +5 -5
- package/docs/cost-reporting.md +49 -0
- package/docs/historical-data-reconciliation.md +203 -0
- package/docs/ingest-journal.md +108 -0
- package/docs/ingestion.md +60 -4
- package/docs/mcp.md +2 -2
- package/docs/native-budget-save.md +84 -0
- package/docs/native-human-auth.md +102 -0
- package/docs/postgresql-legacy-schema-repair.md +30 -0
- package/docs/rest-api.md +2 -2
- package/package.json +25 -13
- package/dist/lib/test-hermetic-accounts.d.ts +0 -7
- package/dist/lib/test-hermetic-accounts.d.ts.map +0 -1
- package/dist/lib/test-hermetic-fleet-env.d.ts +0 -4
- package/dist/lib/test-hermetic-fleet-env.d.ts.map +0 -1
|
@@ -0,0 +1,2085 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
LOCAL_STORAGE_OPT_IN_KEYS
|
|
4
|
+
} from "./index-d480p2wh.js";
|
|
5
|
+
import {
|
|
6
|
+
assessCostFromDb,
|
|
7
|
+
getIngestState,
|
|
8
|
+
getMachineId,
|
|
9
|
+
loadIngestState,
|
|
10
|
+
normalizeModelName,
|
|
11
|
+
observationId,
|
|
12
|
+
rollupSession,
|
|
13
|
+
scanReceiptId,
|
|
14
|
+
setIngestState,
|
|
15
|
+
subscriptionId,
|
|
16
|
+
upsertCostCenter,
|
|
17
|
+
upsertRequest,
|
|
18
|
+
upsertScanReceipt,
|
|
19
|
+
upsertSession,
|
|
20
|
+
upsertSubscription,
|
|
21
|
+
upsertUsageSnapshot
|
|
22
|
+
} from "./index-ez9thsr7.js";
|
|
23
|
+
import {
|
|
24
|
+
__require
|
|
25
|
+
} from "./index-p35cyhc4.js";
|
|
26
|
+
|
|
27
|
+
// src/ingest/collection-outcome.ts
|
|
28
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
29
|
+
var context = new AsyncLocalStorage;
|
|
30
|
+
function collectionProblem(status, code) {
|
|
31
|
+
const state = context.getStore();
|
|
32
|
+
if (state && state.status !== "failed") {
|
|
33
|
+
state.status = status;
|
|
34
|
+
state.code = /^[A-Z][A-Z0-9_]{0,63}$/.test(code.toUpperCase()) ? code.toUpperCase() : "COLLECTION_FAILED";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function collectionUnchanged() {
|
|
38
|
+
const state = context.getStore();
|
|
39
|
+
if (state)
|
|
40
|
+
state.unchanged = true;
|
|
41
|
+
}
|
|
42
|
+
async function collectWithOutcome(source, run, empty) {
|
|
43
|
+
const attempted_at = new Date().toISOString();
|
|
44
|
+
const state = { unchanged: false };
|
|
45
|
+
let result = empty;
|
|
46
|
+
await context.run(state, async () => {
|
|
47
|
+
try {
|
|
48
|
+
result = await run();
|
|
49
|
+
} catch {
|
|
50
|
+
collectionProblem("failed", "collection_failed");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const changes = Object.entries(result).some(([key, value]) => ["requests", "snapshots", "sessions"].includes(key) && typeof value === "number" && value > 0);
|
|
54
|
+
return { result, outcome: { source, attempted_at, completed_at: new Date().toISOString(), status: state.status ?? (!changes && state.unchanged ? "unchanged" : "success"), parser_version: "economy-collector-v4", error_code: state.code ?? null } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/ingest/source-time.ts
|
|
58
|
+
function sourceTime(value) {
|
|
59
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value))
|
|
60
|
+
return null;
|
|
61
|
+
const day = value.slice(0, 10), midnight = Date.parse(day + "T00:00:00Z"), ms = Date.parse(value);
|
|
62
|
+
if (!Number.isFinite(midnight) || new Date(midnight).toISOString().slice(0, 10) !== day || !Number.isFinite(ms) || Number(value.slice(11, 13)) > 23 || Number(value.slice(14, 16)) > 59 || Number(value.slice(17, 19)) > 59)
|
|
63
|
+
return null;
|
|
64
|
+
return new Date(ms).toISOString();
|
|
65
|
+
}
|
|
66
|
+
function sourceTimeMillis(value) {
|
|
67
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || !Number.isFinite(new Date(value).getTime()))
|
|
68
|
+
return null;
|
|
69
|
+
return new Date(value).toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/ingest/claude.ts
|
|
73
|
+
import { readdirSync, readFileSync as readFileSync2, existsSync as existsSync2, statSync } from "fs";
|
|
74
|
+
import { homedir as homedir2 } from "os";
|
|
75
|
+
import { join as join2, basename } from "path";
|
|
76
|
+
|
|
77
|
+
// src/lib/accounts-store.ts
|
|
78
|
+
import { existsSync, readFileSync } from "fs";
|
|
79
|
+
import { homedir } from "os";
|
|
80
|
+
import { join } from "path";
|
|
81
|
+
import { z } from "zod";
|
|
82
|
+
import { resolveClientTransport, resolveCredential } from "@hasna/contracts/client";
|
|
83
|
+
var ACCOUNTS_APP = "accounts";
|
|
84
|
+
var profileNameSchema = z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "must be lowercase alphanumeric/hyphen and start with a letter or digit");
|
|
85
|
+
var profileSchema = z.object({
|
|
86
|
+
name: z.string(),
|
|
87
|
+
tool: z.string(),
|
|
88
|
+
email: z.string().optional(),
|
|
89
|
+
displayName: z.string().optional(),
|
|
90
|
+
dir: z.string(),
|
|
91
|
+
description: z.string().optional(),
|
|
92
|
+
createdAt: z.string(),
|
|
93
|
+
lastUsedAt: z.string().optional()
|
|
94
|
+
});
|
|
95
|
+
var toolDefSchema = z.object({
|
|
96
|
+
id: z.string(),
|
|
97
|
+
label: z.string(),
|
|
98
|
+
envVar: z.string(),
|
|
99
|
+
defaultDir: z.string(),
|
|
100
|
+
bin: z.string()
|
|
101
|
+
});
|
|
102
|
+
var storeSchema = z.object({
|
|
103
|
+
version: z.literal(1),
|
|
104
|
+
current: z.record(z.string(), z.string()).default({}),
|
|
105
|
+
applied: z.record(z.string(), z.string()).default({}),
|
|
106
|
+
toolLocks: z.record(z.string(), z.string()).default({}),
|
|
107
|
+
profiles: z.array(profileSchema).default([]),
|
|
108
|
+
tools: z.array(toolDefSchema).default([])
|
|
109
|
+
});
|
|
110
|
+
function localRegistryAllowed(env = process.env) {
|
|
111
|
+
return LOCAL_STORAGE_OPT_IN_KEYS.some((key) => {
|
|
112
|
+
const raw = env[key];
|
|
113
|
+
if (raw === undefined)
|
|
114
|
+
return false;
|
|
115
|
+
const value = raw.trim().toLowerCase();
|
|
116
|
+
return value !== "" && value !== "0" && value !== "false" && value !== "no" && value !== "off";
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function validateEnvPath(value, label) {
|
|
120
|
+
const trimmed = value.trim();
|
|
121
|
+
if (!trimmed || trimmed.includes("\x00") || /[\r\n]/.test(trimmed))
|
|
122
|
+
throw new Error(`invalid ${label}`);
|
|
123
|
+
return trimmed;
|
|
124
|
+
}
|
|
125
|
+
function accountsHome() {
|
|
126
|
+
const override = process.env.ACCOUNTS_HOME;
|
|
127
|
+
if (override && override.trim())
|
|
128
|
+
return validateEnvPath(override, "ACCOUNTS_HOME");
|
|
129
|
+
return join(homedir(), ".hasna", "accounts");
|
|
130
|
+
}
|
|
131
|
+
function storePath() {
|
|
132
|
+
const override = process.env.ACCOUNTS_STORE_PATH;
|
|
133
|
+
if (override && override.trim())
|
|
134
|
+
return validateEnvPath(override, "ACCOUNTS_STORE_PATH");
|
|
135
|
+
return join(accountsHome(), "accounts.json");
|
|
136
|
+
}
|
|
137
|
+
var EMPTY_STORE = {
|
|
138
|
+
version: 1,
|
|
139
|
+
current: {},
|
|
140
|
+
applied: {},
|
|
141
|
+
toolLocks: {},
|
|
142
|
+
profiles: [],
|
|
143
|
+
tools: []
|
|
144
|
+
};
|
|
145
|
+
function parseStoreFile() {
|
|
146
|
+
if (!localRegistryAllowed())
|
|
147
|
+
return structuredClone(EMPTY_STORE);
|
|
148
|
+
const path = storePath();
|
|
149
|
+
if (!existsSync(path))
|
|
150
|
+
return structuredClone(EMPTY_STORE);
|
|
151
|
+
let raw;
|
|
152
|
+
try {
|
|
153
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
throw new Error(`could not parse store at ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
156
|
+
}
|
|
157
|
+
const parsed = storeSchema.safeParse(raw);
|
|
158
|
+
if (!parsed.success) {
|
|
159
|
+
throw new Error(`invalid store at ${path}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
|
|
160
|
+
}
|
|
161
|
+
return parsed.data;
|
|
162
|
+
}
|
|
163
|
+
function loadAppliedMap() {
|
|
164
|
+
const applied = {};
|
|
165
|
+
for (const [toolId, name] of Object.entries(parseStoreFile().applied)) {
|
|
166
|
+
if (name && profileNameSchema.safeParse(name).success)
|
|
167
|
+
applied[toolId] = name;
|
|
168
|
+
}
|
|
169
|
+
return applied;
|
|
170
|
+
}
|
|
171
|
+
function appliedProfileName(toolId) {
|
|
172
|
+
if (!localRegistryAllowed())
|
|
173
|
+
return;
|
|
174
|
+
return loadAppliedMap()[toolId];
|
|
175
|
+
}
|
|
176
|
+
var BUILTIN_TOOLS = [
|
|
177
|
+
{ id: "claude", label: "Claude Code", envVar: "CLAUDE_CONFIG_DIR", defaultDir: join(homedir(), ".claude"), bin: "claude" },
|
|
178
|
+
{ id: "codex-app", label: "Codex App", envVar: "CODEX_HOME", defaultDir: join(homedir(), ".codex"), bin: "/Applications/Codex.app/Contents/MacOS/Codex" },
|
|
179
|
+
{ id: "codex", label: "Codex CLI", envVar: "CODEX_HOME", defaultDir: join(homedir(), ".codex"), bin: "codex" },
|
|
180
|
+
{ id: "codewith", label: "Codewith", envVar: "CODEWITH_HOME", defaultDir: join(homedir(), ".codewith"), bin: "codewith" },
|
|
181
|
+
{ id: "takumi", label: "Takumi", envVar: "TAKUMI_CONFIG_DIR", defaultDir: join(homedir(), ".takumi"), bin: "takumi" },
|
|
182
|
+
{ id: "gemini", label: "Gemini CLI", envVar: "GEMINI_CONFIG_DIR", defaultDir: join(homedir(), ".gemini"), bin: "gemini" },
|
|
183
|
+
{ id: "opencode", label: "opencode", envVar: "OPENCODE_CONFIG_DIR", defaultDir: join(homedir(), ".config", "opencode"), bin: "opencode" },
|
|
184
|
+
{ id: "cursor", label: "Cursor Agent", envVar: "CURSOR_CONFIG_DIR", defaultDir: join(homedir(), ".cursor"), bin: "cursor-agent" },
|
|
185
|
+
{ id: "pi", label: "Pi Coding Agent", envVar: "PI_CODING_AGENT_HOME", defaultDir: join(homedir(), ".pi"), bin: "pi" },
|
|
186
|
+
{ id: "hermes", label: "Hermes", envVar: "HERMES_HOME", defaultDir: join(homedir(), ".hermes"), bin: "hermes" },
|
|
187
|
+
{ id: "kimi", label: "Kimi Code", envVar: "KIMI_CODE_HOME", defaultDir: join(homedir(), ".kimi-code"), bin: "kimi" },
|
|
188
|
+
{ id: "grok", label: "Grok Build", envVar: "HOME", defaultDir: join(homedir(), ".grok"), bin: "grok" }
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
class LocalStore {
|
|
192
|
+
transport = "local";
|
|
193
|
+
async listProfiles(tool) {
|
|
194
|
+
const profiles = parseStoreFile().profiles;
|
|
195
|
+
const filtered = tool ? profiles.filter((profile) => profile.tool === tool) : profiles;
|
|
196
|
+
return filtered.slice().sort((a, b) => a.tool.localeCompare(b.tool) || a.name.localeCompare(b.name));
|
|
197
|
+
}
|
|
198
|
+
async findProfile(name, tool) {
|
|
199
|
+
const matches = parseStoreFile().profiles.filter((profile) => profile.name === name && (!tool || profile.tool === tool));
|
|
200
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
201
|
+
}
|
|
202
|
+
async currentProfile(tool) {
|
|
203
|
+
const store = parseStoreFile();
|
|
204
|
+
const name = store.current[tool];
|
|
205
|
+
if (!name)
|
|
206
|
+
return;
|
|
207
|
+
return store.profiles.find((profile) => profile.name === name && profile.tool === tool);
|
|
208
|
+
}
|
|
209
|
+
async listTools() {
|
|
210
|
+
const custom = parseStoreFile().tools;
|
|
211
|
+
const byId = new Map;
|
|
212
|
+
for (const tool of BUILTIN_TOOLS)
|
|
213
|
+
byId.set(tool.id, tool);
|
|
214
|
+
for (const tool of custom)
|
|
215
|
+
byId.set(tool.id, tool);
|
|
216
|
+
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
class NoRegistryStore {
|
|
221
|
+
transport = "none";
|
|
222
|
+
async listProfiles() {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
async findProfile() {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
async currentProfile() {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
async listTools() {
|
|
232
|
+
return BUILTIN_TOOLS.slice().sort((a, b) => a.id.localeCompare(b.id));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
var RETRY_STATUSES = [408, 425, 429, 500, 502, 503, 504];
|
|
236
|
+
|
|
237
|
+
class AccountsApiError extends Error {
|
|
238
|
+
status;
|
|
239
|
+
body;
|
|
240
|
+
constructor(method, path, status, body) {
|
|
241
|
+
super(`accounts cloud request failed: ${method} ${path} -> ${status}`);
|
|
242
|
+
this.name = "AccountsApiError";
|
|
243
|
+
this.status = status;
|
|
244
|
+
this.body = body;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function isNotFound(err) {
|
|
248
|
+
return Boolean(err && typeof err === "object" && err.status === 404);
|
|
249
|
+
}
|
|
250
|
+
function sleep(ms) {
|
|
251
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
252
|
+
}
|
|
253
|
+
async function cloudRequest(baseUrl, apiKey, path, query) {
|
|
254
|
+
const rel = path.startsWith("/") ? path : `/${path}`;
|
|
255
|
+
const url = query && Object.keys(query).length > 0 ? `${baseUrl}${rel}?${new URLSearchParams(query).toString()}` : `${baseUrl}${rel}`;
|
|
256
|
+
let lastError;
|
|
257
|
+
for (let attempt = 0;attempt < 3; attempt += 1) {
|
|
258
|
+
const controller = new AbortController;
|
|
259
|
+
const timer = setTimeout(() => controller.abort(), 30000);
|
|
260
|
+
let res;
|
|
261
|
+
try {
|
|
262
|
+
res = await fetch(url, {
|
|
263
|
+
method: "GET",
|
|
264
|
+
headers: {
|
|
265
|
+
"x-api-key": apiKey,
|
|
266
|
+
Authorization: `Bearer ${apiKey}`,
|
|
267
|
+
Accept: "application/json"
|
|
268
|
+
},
|
|
269
|
+
signal: controller.signal
|
|
270
|
+
});
|
|
271
|
+
} catch (error) {
|
|
272
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
if (attempt < 2) {
|
|
275
|
+
const backoff = Math.min(2000, 200 * 2 ** attempt);
|
|
276
|
+
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
277
|
+
await sleep(backoff + jitter);
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
} finally {
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
}
|
|
283
|
+
const text = await res.text();
|
|
284
|
+
let parsed;
|
|
285
|
+
if (text.length > 0) {
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(text);
|
|
288
|
+
} catch {
|
|
289
|
+
parsed = text;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
lastError = new AccountsApiError("GET", rel, res.status, parsed);
|
|
294
|
+
if (attempt < 2 && RETRY_STATUSES.includes(res.status)) {
|
|
295
|
+
const backoff = Math.min(2000, 200 * 2 ** attempt);
|
|
296
|
+
const jitter = Math.floor(Math.random() * (backoff / 2 + 1));
|
|
297
|
+
await sleep(backoff + jitter);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
return parsed;
|
|
303
|
+
}
|
|
304
|
+
throw lastError;
|
|
305
|
+
}
|
|
306
|
+
function toProfile(account) {
|
|
307
|
+
const profile = {
|
|
308
|
+
name: String(account.name),
|
|
309
|
+
tool: String(account.tool),
|
|
310
|
+
dir: typeof account.dir === "string" ? account.dir : "",
|
|
311
|
+
createdAt: String(account.createdAt)
|
|
312
|
+
};
|
|
313
|
+
if (typeof account.email === "string")
|
|
314
|
+
profile.email = account.email;
|
|
315
|
+
if (typeof account.displayName === "string")
|
|
316
|
+
profile.displayName = account.displayName;
|
|
317
|
+
if (typeof account.description === "string")
|
|
318
|
+
profile.description = account.description;
|
|
319
|
+
if (typeof account.lastUsedAt === "string")
|
|
320
|
+
profile.lastUsedAt = account.lastUsedAt;
|
|
321
|
+
return profile;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
class ApiStore {
|
|
325
|
+
baseUrl;
|
|
326
|
+
apiKey;
|
|
327
|
+
transport = "api";
|
|
328
|
+
constructor(baseUrl, apiKey) {
|
|
329
|
+
this.baseUrl = baseUrl;
|
|
330
|
+
this.apiKey = apiKey;
|
|
331
|
+
}
|
|
332
|
+
async listProfiles(tool) {
|
|
333
|
+
const raw = await cloudRequest(this.baseUrl, this.apiKey, "/accounts", tool ? { tool } : undefined);
|
|
334
|
+
const accounts = Array.isArray(raw?.accounts) ? raw.accounts : [];
|
|
335
|
+
return accounts.map((account) => toProfile(account)).sort((a, b) => a.tool.localeCompare(b.tool) || a.name.localeCompare(b.name));
|
|
336
|
+
}
|
|
337
|
+
async findProfile(name, tool) {
|
|
338
|
+
if (tool) {
|
|
339
|
+
try {
|
|
340
|
+
const account = await cloudRequest(this.baseUrl, this.apiKey, `/accounts/${encodeURIComponent(tool)}/${encodeURIComponent(name)}`);
|
|
341
|
+
return account ? toProfile(account) : undefined;
|
|
342
|
+
} catch (err) {
|
|
343
|
+
if (isNotFound(err))
|
|
344
|
+
return;
|
|
345
|
+
throw err;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const matches = (await this.listProfiles()).filter((profile) => profile.name === name);
|
|
349
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
350
|
+
}
|
|
351
|
+
async currentProfile(tool) {
|
|
352
|
+
let current;
|
|
353
|
+
try {
|
|
354
|
+
const raw = await cloudRequest(this.baseUrl, this.apiKey, `/current/${encodeURIComponent(tool)}`);
|
|
355
|
+
current = raw && typeof raw === "object" ? raw : null;
|
|
356
|
+
} catch (err) {
|
|
357
|
+
if (isNotFound(err))
|
|
358
|
+
return;
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
if (!current || typeof current.name !== "string")
|
|
362
|
+
return;
|
|
363
|
+
return this.findProfile(current.name, tool);
|
|
364
|
+
}
|
|
365
|
+
async listTools() {
|
|
366
|
+
const raw = await cloudRequest(this.baseUrl, this.apiKey, "/tools");
|
|
367
|
+
const tools = Array.isArray(raw?.tools) ? raw.tools : [];
|
|
368
|
+
const custom = [];
|
|
369
|
+
for (const item of tools) {
|
|
370
|
+
if (item === null || typeof item !== "object")
|
|
371
|
+
continue;
|
|
372
|
+
const record = item;
|
|
373
|
+
if (record.builtin !== false)
|
|
374
|
+
continue;
|
|
375
|
+
const parsed = toolDefSchema.safeParse(item);
|
|
376
|
+
if (!parsed.success) {
|
|
377
|
+
throw new Error(`invalid custom tool "${String(record.id)}" returned by accounts-serve: ` + parsed.error.issues.map((issue) => issue.message).join("; "));
|
|
378
|
+
}
|
|
379
|
+
custom.push(parsed.data);
|
|
380
|
+
}
|
|
381
|
+
const byId = new Map;
|
|
382
|
+
for (const tool of BUILTIN_TOOLS)
|
|
383
|
+
byId.set(tool.id, tool);
|
|
384
|
+
for (const tool of custom)
|
|
385
|
+
byId.set(tool.id, tool);
|
|
386
|
+
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function resolveStore(env = process.env, options = {}) {
|
|
390
|
+
const clientEnv = env;
|
|
391
|
+
const credential = resolveCredential(ACCOUNTS_APP, clientEnv, options.credentials);
|
|
392
|
+
if (credential === null) {
|
|
393
|
+
try {
|
|
394
|
+
resolveClientTransport(ACCOUNTS_APP, clientEnv, options.credentials ? { credentials: options.credentials } : {});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
if (isNoAccountsCredentialConfigurationError(err))
|
|
397
|
+
return unconfiguredStore(env);
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
return unconfiguredStore(env);
|
|
401
|
+
}
|
|
402
|
+
if (credential.tier === "pointer") {
|
|
403
|
+
throw new Error(`accounts: ${credential.source} names a secrets-vault item, and the local accounts API store resolves ` + "credentials synchronously, so it cannot complete the pointer per request. It is a deliberate " + "selection and is not resolved around. Use a literal tier instead \u2014 the Keychain item " + "hasna.credentials.accounts.api-key, ~/.hasna/accounts/config/credentials, or HASNA_ACCOUNTS_API_KEY.");
|
|
404
|
+
}
|
|
405
|
+
const resolution = resolveClientTransport(ACCOUNTS_APP, clientEnv, {
|
|
406
|
+
credentials: { ...options.credentials, apiKey: credential.apiKey }
|
|
407
|
+
});
|
|
408
|
+
return new ApiStore(resolution.baseUrl, credential.apiKey);
|
|
409
|
+
}
|
|
410
|
+
function isNoAccountsCredentialConfigurationError(error) {
|
|
411
|
+
if (typeof error !== "object" || error === null)
|
|
412
|
+
return false;
|
|
413
|
+
const candidate = error;
|
|
414
|
+
return candidate.name === "ClientTransportConfigurationError" && typeof candidate.message === "string" && candidate.message.includes("is not set and no API key could be resolved");
|
|
415
|
+
}
|
|
416
|
+
function unconfiguredStore(env) {
|
|
417
|
+
if (localRegistryAllowed(env)) {
|
|
418
|
+
announceAccountsLocalMode();
|
|
419
|
+
return new LocalStore;
|
|
420
|
+
}
|
|
421
|
+
announceAccountsRefusal();
|
|
422
|
+
return new NoRegistryStore;
|
|
423
|
+
}
|
|
424
|
+
function accountsLocalModeNotice() {
|
|
425
|
+
return `accounts: LOCAL mode (${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1) \u2014 no Hasna Accounts credential resolved, so ` + "attribution reads the on-box JSON registry (~/.hasna/accounts/accounts.json) instead of accounts-serve. " + "To go hosted, put the fleet key in the Keychain item hasna.credentials.accounts.api-key, write " + "~/.hasna/accounts/config/credentials, or set HASNA_ACCOUNTS_API_KEY.";
|
|
426
|
+
}
|
|
427
|
+
function accountsRefusalNotice() {
|
|
428
|
+
return "accounts: no Hasna Accounts credential resolved (Keychain item hasna.credentials.accounts.api-key, " + "~/.hasna/accounts/config/credentials, HASNA_ACCOUNTS_API_KEY), so spend attribution is omitted. The " + `on-box registry (~/.hasna/accounts/accounts.json) is NOT read without ${LOCAL_STORAGE_OPT_IN_KEYS[0]}=1 \u2014 ` + "economy fails closed instead of attributing hosted spend from an on-box file.";
|
|
429
|
+
}
|
|
430
|
+
var accountsLocalNoticePrinted = false;
|
|
431
|
+
function announceAccountsLocalMode() {
|
|
432
|
+
if (accountsLocalNoticePrinted)
|
|
433
|
+
return;
|
|
434
|
+
accountsLocalNoticePrinted = true;
|
|
435
|
+
process.stderr.write(`${accountsLocalModeNotice()}
|
|
436
|
+
`);
|
|
437
|
+
}
|
|
438
|
+
function announceAccountsRefusal() {
|
|
439
|
+
if (accountsLocalNoticePrinted)
|
|
440
|
+
return;
|
|
441
|
+
accountsLocalNoticePrinted = true;
|
|
442
|
+
process.stderr.write(`${accountsRefusalNotice()}
|
|
443
|
+
`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/lib/accounts.ts
|
|
447
|
+
var AGENT_ACCOUNT_TOOLS = {
|
|
448
|
+
claude: ["claude"],
|
|
449
|
+
takumi: ["takumi", "claude"],
|
|
450
|
+
codex: ["codex"],
|
|
451
|
+
gemini: ["gemini"],
|
|
452
|
+
opencode: ["opencode"],
|
|
453
|
+
cursor: ["cursor"],
|
|
454
|
+
pi: ["pi"],
|
|
455
|
+
hermes: ["hermes"]
|
|
456
|
+
};
|
|
457
|
+
function normalizeEmail(email) {
|
|
458
|
+
return (email ?? "").trim().toLowerCase();
|
|
459
|
+
}
|
|
460
|
+
function accountKey(tool, name, email) {
|
|
461
|
+
const normalizedEmail = normalizeEmail(email);
|
|
462
|
+
return `${tool}:${normalizedEmail || name}`;
|
|
463
|
+
}
|
|
464
|
+
function normalizeDir(value) {
|
|
465
|
+
return value.replace(/\/+$/, "");
|
|
466
|
+
}
|
|
467
|
+
function fromProfile(profile, source) {
|
|
468
|
+
return {
|
|
469
|
+
account_key: accountKey(profile.tool, profile.name, profile.email),
|
|
470
|
+
account_tool: profile.tool,
|
|
471
|
+
account_name: profile.name,
|
|
472
|
+
...profile.email ? { account_email: normalizeEmail(profile.email) } : {},
|
|
473
|
+
account_source: source
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function fromOverride(raw, agent) {
|
|
477
|
+
const value = raw.trim();
|
|
478
|
+
if (!value)
|
|
479
|
+
return null;
|
|
480
|
+
const candidateTool = AGENT_ACCOUNT_TOOLS[agent][0] ?? agent;
|
|
481
|
+
const [tool, name] = value.includes(":") ? value.split(":", 2) : [candidateTool, value];
|
|
482
|
+
if (!tool || !name)
|
|
483
|
+
return null;
|
|
484
|
+
const email = name.includes("@") ? normalizeEmail(name) : undefined;
|
|
485
|
+
return {
|
|
486
|
+
account_key: accountKey(tool, name, email),
|
|
487
|
+
account_tool: tool,
|
|
488
|
+
account_name: name,
|
|
489
|
+
...email ? { account_email: email } : {},
|
|
490
|
+
account_source: "override"
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function envOverride(agent, env) {
|
|
494
|
+
const agentPrefix = agent.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
495
|
+
const raw = env[`ECONOMY_${agentPrefix}_ACCOUNT_KEY`] ?? env[`ECONOMY_${agentPrefix}_ACCOUNT`] ?? env["ECONOMY_ACCOUNT_KEY"] ?? env["ECONOMY_ACCOUNT"];
|
|
496
|
+
if (raw)
|
|
497
|
+
return fromOverride(raw, agent);
|
|
498
|
+
const tool = env[`ECONOMY_${agentPrefix}_ACCOUNT_TOOL`] ?? env["ECONOMY_ACCOUNT_TOOL"];
|
|
499
|
+
const name = env[`ECONOMY_${agentPrefix}_ACCOUNT_NAME`] ?? env["ECONOMY_ACCOUNT_NAME"];
|
|
500
|
+
if (!tool || !name)
|
|
501
|
+
return null;
|
|
502
|
+
const email = normalizeEmail(env[`ECONOMY_${agentPrefix}_ACCOUNT_EMAIL`] ?? env["ECONOMY_ACCOUNT_EMAIL"]);
|
|
503
|
+
return {
|
|
504
|
+
account_key: accountKey(tool, name, email),
|
|
505
|
+
account_tool: tool,
|
|
506
|
+
account_name: name,
|
|
507
|
+
...email ? { account_email: email } : {},
|
|
508
|
+
account_source: "override"
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
async function profileForEnvDir(store, tool, env) {
|
|
512
|
+
const configuredDir = env[tool.envVar];
|
|
513
|
+
if (!configuredDir)
|
|
514
|
+
return null;
|
|
515
|
+
const normalized = normalizeDir(configuredDir);
|
|
516
|
+
return (await store.listProfiles(tool.id)).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
|
|
517
|
+
}
|
|
518
|
+
async function resolveAccountForAgent(agent, env = process.env, options = {}) {
|
|
519
|
+
const override = envOverride(agent, env);
|
|
520
|
+
if (override)
|
|
521
|
+
return override;
|
|
522
|
+
const store = resolveStore(env, options);
|
|
523
|
+
const tools = new Map((await store.listTools()).map((tool) => [tool.id, tool]));
|
|
524
|
+
for (const toolId of AGENT_ACCOUNT_TOOLS[agent]) {
|
|
525
|
+
const tool = tools.get(toolId);
|
|
526
|
+
if (!tool)
|
|
527
|
+
continue;
|
|
528
|
+
const envProfile = await profileForEnvDir(store, tool, env);
|
|
529
|
+
if (envProfile)
|
|
530
|
+
return fromProfile(envProfile, "env");
|
|
531
|
+
const appliedName = appliedProfileName(toolId);
|
|
532
|
+
if (appliedName) {
|
|
533
|
+
const applied = await store.findProfile(appliedName, toolId);
|
|
534
|
+
if (applied)
|
|
535
|
+
return fromProfile(applied, "applied");
|
|
536
|
+
}
|
|
537
|
+
const current = await store.currentProfile(toolId);
|
|
538
|
+
if (current)
|
|
539
|
+
return fromProfile(current, "current");
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
function withAccount(record, account) {
|
|
544
|
+
if (!account)
|
|
545
|
+
return record;
|
|
546
|
+
return {
|
|
547
|
+
...record,
|
|
548
|
+
account_key: account.account_key,
|
|
549
|
+
account_tool: account.account_tool,
|
|
550
|
+
account_name: account.account_name,
|
|
551
|
+
account_email: account.account_email ?? "",
|
|
552
|
+
account_source: account.account_source
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// src/ingest/claude.ts
|
|
557
|
+
function autoDetectProject(cwd, projects) {
|
|
558
|
+
return projects.find((p) => cwd === p.path || cwd.startsWith(p.path + "/"));
|
|
559
|
+
}
|
|
560
|
+
var CLAUDE_PROJECTS_DIR = process.env["HASNA_ECONOMY_CLAUDE_PROJECTS_DIR"] ?? join2(homedir2(), ".claude", "projects");
|
|
561
|
+
var TAKUMI_PROJECTS_DIR = process.env["HASNA_ECONOMY_TAKUMI_PROJECTS_DIR"] ?? join2(homedir2(), ".takumi", "projects");
|
|
562
|
+
function dirNameToPath(dirName) {
|
|
563
|
+
return dirName.replace(/^-/, "/").replace(/-/g, "/").replace(/\/\//g, "/-");
|
|
564
|
+
}
|
|
565
|
+
function collectJsonlFiles(projectDir) {
|
|
566
|
+
const files = [];
|
|
567
|
+
function walk(dir) {
|
|
568
|
+
try {
|
|
569
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
570
|
+
if (entry.isDirectory())
|
|
571
|
+
walk(join2(dir, entry.name));
|
|
572
|
+
else if (entry.name.endsWith(".jsonl"))
|
|
573
|
+
files.push(join2(dir, entry.name));
|
|
574
|
+
}
|
|
575
|
+
} catch {
|
|
576
|
+
collectionProblem("failed", "source_unreadable");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
walk(projectDir);
|
|
580
|
+
return files;
|
|
581
|
+
}
|
|
582
|
+
async function ingestClaude(db, verbose = false, projectsDir = CLAUDE_PROJECTS_DIR) {
|
|
583
|
+
return ingestJsonlProjects(db, projectsDir, "claude", verbose);
|
|
584
|
+
}
|
|
585
|
+
async function ingestTakumi(db, verbose = false, projectsDir = TAKUMI_PROJECTS_DIR) {
|
|
586
|
+
return ingestJsonlProjects(db, projectsDir, "takumi", verbose);
|
|
587
|
+
}
|
|
588
|
+
async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false) {
|
|
589
|
+
if (!existsSync2(projectsDir)) {
|
|
590
|
+
collectionProblem("missing", "source_missing");
|
|
591
|
+
if (verbose)
|
|
592
|
+
console.log(`${agentName} projects dir not found:`, projectsDir);
|
|
593
|
+
return { files: 0, requests: 0, sessions: 0 };
|
|
594
|
+
}
|
|
595
|
+
const machineId = getMachineId();
|
|
596
|
+
let totalFiles = 0;
|
|
597
|
+
let totalRequests = 0;
|
|
598
|
+
const touchedSessions = new Set;
|
|
599
|
+
const registeredProjects = db.prepare(`SELECT path, name FROM projects ORDER BY LENGTH(path) DESC`).all();
|
|
600
|
+
const account = await resolveAccountForAgent(agentName);
|
|
601
|
+
const ingestState = loadIngestState(db, agentName);
|
|
602
|
+
const projectDirs = readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
603
|
+
for (const projectDirEntry of projectDirs) {
|
|
604
|
+
const projectDirPath = join2(projectsDir, projectDirEntry.name);
|
|
605
|
+
const projectPath = dirNameToPath(projectDirEntry.name);
|
|
606
|
+
const jsonlFiles = collectJsonlFiles(projectDirPath);
|
|
607
|
+
for (const filePath of jsonlFiles) {
|
|
608
|
+
const stateKey = filePath.replace(projectsDir, "");
|
|
609
|
+
let fileMtime = "0";
|
|
610
|
+
try {
|
|
611
|
+
fileMtime = statSync(filePath).mtimeMs.toString();
|
|
612
|
+
} catch {
|
|
613
|
+
collectionProblem("failed", "source_unreadable");
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const processed = ingestState.get(stateKey) ?? null;
|
|
617
|
+
if (processed === fileMtime) {
|
|
618
|
+
collectionUnchanged();
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
let lines;
|
|
622
|
+
try {
|
|
623
|
+
lines = readFileSync2(filePath, "utf-8").split(`
|
|
624
|
+
`).filter((l) => l.trim());
|
|
625
|
+
} catch {
|
|
626
|
+
collectionProblem("failed", "source_unreadable");
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const fileBasename = basename(filePath, ".jsonl");
|
|
630
|
+
const isUuid = /^[0-9a-f-]{36}$/.test(fileBasename);
|
|
631
|
+
let sessionId = isUuid ? fileBasename : fileBasename.replace(/^agent-/, "");
|
|
632
|
+
let sessionCwd = projectPath;
|
|
633
|
+
let fileInvalid = false;
|
|
634
|
+
for (const line of lines) {
|
|
635
|
+
let entry;
|
|
636
|
+
try {
|
|
637
|
+
entry = JSON.parse(line);
|
|
638
|
+
} catch {
|
|
639
|
+
fileInvalid = true;
|
|
640
|
+
collectionProblem("failed", "source_invalid");
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (entry.sessionId)
|
|
644
|
+
sessionId = entry.sessionId;
|
|
645
|
+
if (entry.cwd)
|
|
646
|
+
sessionCwd = entry.cwd;
|
|
647
|
+
if (entry.message?.role !== "assistant")
|
|
648
|
+
continue;
|
|
649
|
+
const usage = entry.message.usage;
|
|
650
|
+
if (!usage)
|
|
651
|
+
continue;
|
|
652
|
+
const model = entry.message.model;
|
|
653
|
+
if (!model)
|
|
654
|
+
continue;
|
|
655
|
+
const inputTokens = usage.input_tokens ?? 0;
|
|
656
|
+
const outputTokens = usage.output_tokens ?? 0;
|
|
657
|
+
const cacheWrite5mTokens = usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? 0;
|
|
658
|
+
const cacheWrite1hTokens = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
659
|
+
const cacheWriteTokens = cacheWrite5mTokens + cacheWrite1hTokens;
|
|
660
|
+
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
|
|
661
|
+
if (inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens === 0)
|
|
662
|
+
continue;
|
|
663
|
+
const timestamp = sourceTime(entry.timestamp);
|
|
664
|
+
if (timestamp === null) {
|
|
665
|
+
fileInvalid = true;
|
|
666
|
+
collectionProblem("failed", "source_invalid");
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const assessment = assessCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens);
|
|
670
|
+
let costUsd = assessment.usd === null ? 0 : applyClaudeModifiers(assessment.usd, model, usage, entry);
|
|
671
|
+
const serverToolUse = usage.server_tool_use;
|
|
672
|
+
if (assessment.usd !== null && serverToolUse?.web_search_requests) {
|
|
673
|
+
costUsd += serverToolUse.web_search_requests * 0.01;
|
|
674
|
+
}
|
|
675
|
+
const sourceRequestId = entry.requestId ?? entry.request_id ?? entry.message.id ?? entry.uuid ?? `${sessionId}-${timestamp}`;
|
|
676
|
+
const reqId = `${agentName}-${sourceRequestId}`;
|
|
677
|
+
upsertRequest(db, withAccount({
|
|
678
|
+
id: reqId,
|
|
679
|
+
agent: agentName,
|
|
680
|
+
session_id: sessionId,
|
|
681
|
+
model,
|
|
682
|
+
input_tokens: inputTokens,
|
|
683
|
+
output_tokens: outputTokens,
|
|
684
|
+
cache_read_tokens: cacheReadTokens,
|
|
685
|
+
cache_create_tokens: cacheWriteTokens,
|
|
686
|
+
cache_create_5m_tokens: cacheWrite5mTokens,
|
|
687
|
+
cache_create_1h_tokens: cacheWrite1hTokens,
|
|
688
|
+
cost_usd: costUsd,
|
|
689
|
+
cost_basis: assessment.usd === null ? "unknown" : "estimated",
|
|
690
|
+
duration_ms: 0,
|
|
691
|
+
timestamp,
|
|
692
|
+
source_request_id: sourceRequestId,
|
|
693
|
+
machine_id: machineId
|
|
694
|
+
}, account));
|
|
695
|
+
if (!touchedSessions.has(sessionId)) {
|
|
696
|
+
const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
|
|
697
|
+
if (!existing) {
|
|
698
|
+
const effectiveCwd = sessionCwd || projectPath;
|
|
699
|
+
const detectedProject = autoDetectProject(effectiveCwd, registeredProjects);
|
|
700
|
+
const session = {
|
|
701
|
+
id: sessionId,
|
|
702
|
+
agent: agentName,
|
|
703
|
+
project_path: detectedProject ? detectedProject.path : effectiveCwd,
|
|
704
|
+
project_name: detectedProject ? detectedProject.name : "",
|
|
705
|
+
started_at: timestamp,
|
|
706
|
+
ended_at: null,
|
|
707
|
+
total_cost_usd: 0,
|
|
708
|
+
total_tokens: 0,
|
|
709
|
+
request_count: 0,
|
|
710
|
+
machine_id: machineId
|
|
711
|
+
};
|
|
712
|
+
upsertSession(db, withAccount(session, account));
|
|
713
|
+
}
|
|
714
|
+
touchedSessions.add(sessionId);
|
|
715
|
+
}
|
|
716
|
+
totalRequests++;
|
|
717
|
+
}
|
|
718
|
+
if (!fileInvalid)
|
|
719
|
+
setIngestState(db, agentName, stateKey, fileMtime);
|
|
720
|
+
totalFiles++;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
for (const sessionId of touchedSessions) {
|
|
724
|
+
rollupSession(db, sessionId);
|
|
725
|
+
}
|
|
726
|
+
return { files: totalFiles, requests: totalRequests, sessions: touchedSessions.size };
|
|
727
|
+
}
|
|
728
|
+
function applyClaudeModifiers(costUsd, model, usage, entry) {
|
|
729
|
+
let multiplier = 1;
|
|
730
|
+
const speed = usage.speed ?? entry.message?.speed ?? entry.speed;
|
|
731
|
+
if (speed === "fast" && model.includes("opus-4-6")) {
|
|
732
|
+
multiplier *= 6;
|
|
733
|
+
}
|
|
734
|
+
const inferenceGeo = usage.inference_geo ?? entry.message?.inference_geo ?? entry.inference_geo;
|
|
735
|
+
if (inferenceGeo && ["us", "us-only", "us_only"].includes(inferenceGeo) && supportsClaudeDataResidencyPricing(model)) {
|
|
736
|
+
multiplier *= 1.1;
|
|
737
|
+
}
|
|
738
|
+
return costUsd * multiplier;
|
|
739
|
+
}
|
|
740
|
+
function supportsClaudeDataResidencyPricing(model) {
|
|
741
|
+
const normalized = normalizeModelName(model);
|
|
742
|
+
const match = normalized.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d+))?(?:-|$)/);
|
|
743
|
+
if (!match)
|
|
744
|
+
return false;
|
|
745
|
+
const major = Number(match[2]);
|
|
746
|
+
const minor = match[3] ? Number(match[3]) : 0;
|
|
747
|
+
return major > 4 || major === 4 && minor >= 6;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/ingest/codex.ts
|
|
751
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
|
|
752
|
+
import { createHash } from "crypto";
|
|
753
|
+
import { homedir as homedir3 } from "os";
|
|
754
|
+
import { join as join3, basename as basename2 } from "path";
|
|
755
|
+
var DEFAULT_CODEX_DB_PATH = join3(homedir3(), ".codex", "state_5.sqlite");
|
|
756
|
+
var DEFAULT_CODEX_CONFIG_PATH = join3(homedir3(), ".codex", "config.toml");
|
|
757
|
+
var DEFAULT_CODEWITH_DB_PATH = join3(homedir3(), ".codewith", "state_5.sqlite");
|
|
758
|
+
var DEFAULT_CODEWITH_CONFIG_PATH = join3(homedir3(), ".codewith", "config.toml");
|
|
759
|
+
var CODEX_INGEST_VERSION = "rollout-events-v4";
|
|
760
|
+
function codexDbPath() {
|
|
761
|
+
return process.env["HASNA_ECONOMY_CODEX_DB_PATH"] ?? DEFAULT_CODEX_DB_PATH;
|
|
762
|
+
}
|
|
763
|
+
function codexConfigPath() {
|
|
764
|
+
return process.env["HASNA_ECONOMY_CODEX_CONFIG_PATH"] ?? DEFAULT_CODEX_CONFIG_PATH;
|
|
765
|
+
}
|
|
766
|
+
function codewithDbPath() {
|
|
767
|
+
return process.env["HASNA_ECONOMY_CODEWITH_DB_PATH"] ?? DEFAULT_CODEWITH_DB_PATH;
|
|
768
|
+
}
|
|
769
|
+
function codewithConfigPath() {
|
|
770
|
+
return process.env["HASNA_ECONOMY_CODEWITH_CONFIG_PATH"] ?? DEFAULT_CODEWITH_CONFIG_PATH;
|
|
771
|
+
}
|
|
772
|
+
function codexSources() {
|
|
773
|
+
const explicitCodexPath = process.env["HASNA_ECONOMY_CODEX_DB_PATH"];
|
|
774
|
+
const explicitCodewithPath = process.env["HASNA_ECONOMY_CODEWITH_DB_PATH"];
|
|
775
|
+
const sources = [{
|
|
776
|
+
label: "Codex",
|
|
777
|
+
dbPath: codexDbPath(),
|
|
778
|
+
configPath: codexConfigPath(),
|
|
779
|
+
sessionPrefix: "codex",
|
|
780
|
+
stateSource: "codex"
|
|
781
|
+
}];
|
|
782
|
+
if (!explicitCodexPath || explicitCodewithPath) {
|
|
783
|
+
sources.push({
|
|
784
|
+
label: "Codewith",
|
|
785
|
+
dbPath: codewithDbPath(),
|
|
786
|
+
configPath: codewithConfigPath(),
|
|
787
|
+
sessionPrefix: "codex-codewith",
|
|
788
|
+
stateSource: "codex-codewith"
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
const seen = new Set;
|
|
792
|
+
return sources.filter((source) => {
|
|
793
|
+
if (seen.has(source.dbPath))
|
|
794
|
+
return false;
|
|
795
|
+
seen.add(source.dbPath);
|
|
796
|
+
return true;
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
function buildThreadQuery(codexDb) {
|
|
800
|
+
const cols = new Set(codexDb.prepare(`PRAGMA table_info(threads)`).all().map((c) => c.name));
|
|
801
|
+
const modelSelect = cols.has("model") ? "model" : "NULL AS model";
|
|
802
|
+
const rolloutSelect = cols.has("rollout_path") ? "rollout_path" : "NULL AS rollout_path";
|
|
803
|
+
const providerSelect = cols.has("model_provider") ? "model_provider" : "NULL AS model_provider";
|
|
804
|
+
return `
|
|
805
|
+
SELECT id, ${rolloutSelect}, cwd, created_at, updated_at, tokens_used, title,
|
|
806
|
+
${providerSelect}, ${modelSelect}
|
|
807
|
+
FROM threads WHERE tokens_used > 0
|
|
808
|
+
`;
|
|
809
|
+
}
|
|
810
|
+
async function openCodexDb(dbPath) {
|
|
811
|
+
const { openThirdPartySqlite } = await import("./third-party-sqlite-5e85fxa2.js");
|
|
812
|
+
const db = openThirdPartySqlite(dbPath, { readonly: true });
|
|
813
|
+
try {
|
|
814
|
+
db.prepare("PRAGMA schema_version").get();
|
|
815
|
+
return db;
|
|
816
|
+
} catch (error) {
|
|
817
|
+
db.close();
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function validUsage(value) {
|
|
822
|
+
if (!value || typeof value !== "object")
|
|
823
|
+
return null;
|
|
824
|
+
const input = value;
|
|
825
|
+
for (const key of ["input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"]) {
|
|
826
|
+
if (input[key] !== undefined && (!Number.isSafeInteger(input[key]) || input[key] < 0))
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
if ((input.cached_input_tokens ?? 0) > (input.input_tokens ?? 0))
|
|
830
|
+
return null;
|
|
831
|
+
return input;
|
|
832
|
+
}
|
|
833
|
+
function tokenTotal(usage) {
|
|
834
|
+
return (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
|
|
835
|
+
}
|
|
836
|
+
function parseCodexEvents(content) {
|
|
837
|
+
const events = [];
|
|
838
|
+
let model = "unknown";
|
|
839
|
+
let turn = null;
|
|
840
|
+
let previous = null;
|
|
841
|
+
let incomplete = false;
|
|
842
|
+
const occurrences = new Map;
|
|
843
|
+
for (const line of content.split(`
|
|
844
|
+
`)) {
|
|
845
|
+
if (!line.trim())
|
|
846
|
+
continue;
|
|
847
|
+
let entry;
|
|
848
|
+
try {
|
|
849
|
+
entry = JSON.parse(line);
|
|
850
|
+
} catch {
|
|
851
|
+
incomplete = true;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (!entry || typeof entry !== "object")
|
|
855
|
+
continue;
|
|
856
|
+
const payload = entry.payload;
|
|
857
|
+
if (!payload || typeof payload !== "object")
|
|
858
|
+
continue;
|
|
859
|
+
if (entry.type === "turn_context") {
|
|
860
|
+
if (typeof payload.model === "string" && payload.model)
|
|
861
|
+
model = payload.model;
|
|
862
|
+
turn = typeof payload.turn_id === "string" ? payload.turn_id : null;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (payload.type !== "token_count" || !payload.info)
|
|
866
|
+
continue;
|
|
867
|
+
const total = validUsage(payload.info.total_token_usage);
|
|
868
|
+
const last = validUsage(payload.info.last_token_usage);
|
|
869
|
+
if (total && previous && ["input_tokens", "output_tokens", "cached_input_tokens"].every((key) => total[key] === previous[key]))
|
|
870
|
+
continue;
|
|
871
|
+
const reset = total && previous && ((total.input_tokens ?? 0) < (previous.input_tokens ?? 0) || (total.output_tokens ?? 0) < (previous.output_tokens ?? 0));
|
|
872
|
+
let usage = last;
|
|
873
|
+
if (!usage && total && previous && !reset) {
|
|
874
|
+
usage = { input_tokens: (total.input_tokens ?? 0) - (previous.input_tokens ?? 0), output_tokens: (total.output_tokens ?? 0) - (previous.output_tokens ?? 0), cached_input_tokens: (total.cached_input_tokens ?? 0) - (previous.cached_input_tokens ?? 0) };
|
|
875
|
+
if (!validUsage(usage))
|
|
876
|
+
usage = null;
|
|
877
|
+
}
|
|
878
|
+
previous = total ?? previous;
|
|
879
|
+
if (!usage || tokenTotal(usage) <= 0 || reset && !last) {
|
|
880
|
+
incomplete = true;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const timestamp = typeof entry.timestamp === "string" && Number.isFinite(Date.parse(entry.timestamp)) ? new Date(entry.timestamp).toISOString() : null;
|
|
884
|
+
if (!timestamp) {
|
|
885
|
+
incomplete = true;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const eventModel = typeof payload.info.model === "string" ? payload.info.model : model;
|
|
889
|
+
const fingerprint = JSON.stringify([timestamp, eventModel, turn, payload.request_id ?? entry.id ?? null, usage]);
|
|
890
|
+
const ordinal = occurrences.get(fingerprint) ?? 0;
|
|
891
|
+
occurrences.set(fingerprint, ordinal + 1);
|
|
892
|
+
const identity = createHash("sha256").update(JSON.stringify([fingerprint, ordinal])).digest("hex");
|
|
893
|
+
events.push({ usage, timestamp, model: eventModel, identity });
|
|
894
|
+
}
|
|
895
|
+
return { events, incomplete };
|
|
896
|
+
}
|
|
897
|
+
async function ingestCodex(db, verbose = false) {
|
|
898
|
+
const machineId = getMachineId();
|
|
899
|
+
let ingested = 0;
|
|
900
|
+
let requests = 0;
|
|
901
|
+
const issues = [];
|
|
902
|
+
const account = await resolveAccountForAgent("codex");
|
|
903
|
+
for (const source of codexSources()) {
|
|
904
|
+
if (!existsSync3(source.dbPath)) {
|
|
905
|
+
if (source.stateSource === "codex")
|
|
906
|
+
collectionProblem("missing", "source_missing");
|
|
907
|
+
if (verbose)
|
|
908
|
+
console.log(`${source.label} DB not found:`, source.dbPath);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
let codexDb = null;
|
|
912
|
+
try {
|
|
913
|
+
try {
|
|
914
|
+
codexDb = await openCodexDb(source.dbPath);
|
|
915
|
+
} catch {
|
|
916
|
+
collectionProblem("failed", "source_unreadable");
|
|
917
|
+
issues.push({ code: "source_unreadable" });
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
const threads = codexDb.prepare(buildThreadQuery(codexDb)).all();
|
|
921
|
+
for (const thread of threads) {
|
|
922
|
+
let rolloutStamp = "missing";
|
|
923
|
+
try {
|
|
924
|
+
const stat = statSync2(thread.rollout_path);
|
|
925
|
+
rolloutStamp = `${stat.size}:${stat.mtimeMs}`;
|
|
926
|
+
} catch {}
|
|
927
|
+
const stateValue = `${CODEX_INGEST_VERSION}:${thread.updated_at}:${thread.tokens_used}:${rolloutStamp}`;
|
|
928
|
+
const processed = getIngestState(db, source.stateSource, thread.id);
|
|
929
|
+
if (processed === stateValue) {
|
|
930
|
+
collectionUnchanged();
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
const projectPath = thread.cwd ?? "";
|
|
934
|
+
const projectName = projectPath ? basename2(projectPath) : "unknown";
|
|
935
|
+
const sessionId = `${source.sessionPrefix}-${thread.id}`;
|
|
936
|
+
const legacy = db.prepare("SELECT id FROM economy_effective_requests WHERE session_id = ? AND id = ?").get(sessionId, `${sessionId}-0`);
|
|
937
|
+
if (legacy) {
|
|
938
|
+
collectionProblem("unsupported", "legacy_aggregate_requires_migration");
|
|
939
|
+
issues.push({ code: "legacy_aggregate_requires_migration", session_id: sessionId });
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
let parsed;
|
|
943
|
+
try {
|
|
944
|
+
parsed = parseCodexEvents(readFileSync3(thread.rollout_path, "utf8"));
|
|
945
|
+
} catch {
|
|
946
|
+
collectionProblem("unsupported", "usage_events_unavailable");
|
|
947
|
+
issues.push({ code: "usage_events_unavailable", session_id: sessionId });
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (parsed.incomplete) {
|
|
951
|
+
collectionProblem("failed", "usage_events_incomplete");
|
|
952
|
+
issues.push({ code: "usage_events_incomplete", session_id: sessionId });
|
|
953
|
+
}
|
|
954
|
+
if (parsed.events.length === 0) {
|
|
955
|
+
collectionProblem("unsupported", "usage_events_unavailable");
|
|
956
|
+
issues.push({ code: "usage_events_unavailable", session_id: sessionId });
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
const startedAt = thread.created_at ? new Date(thread.created_at * 1000).toISOString() : parsed.events[0].timestamp;
|
|
960
|
+
const endedAt = thread.updated_at ? new Date(thread.updated_at * 1000).toISOString() : null;
|
|
961
|
+
upsertSession(db, withAccount({
|
|
962
|
+
id: sessionId,
|
|
963
|
+
agent: "codex",
|
|
964
|
+
project_path: projectPath,
|
|
965
|
+
project_name: projectName,
|
|
966
|
+
started_at: startedAt,
|
|
967
|
+
ended_at: endedAt,
|
|
968
|
+
total_cost_usd: 0,
|
|
969
|
+
total_tokens: 0,
|
|
970
|
+
request_count: 0,
|
|
971
|
+
machine_id: machineId
|
|
972
|
+
}, account));
|
|
973
|
+
const tokenEvents = parsed.events;
|
|
974
|
+
tokenEvents.forEach((event) => {
|
|
975
|
+
const usage = event.usage;
|
|
976
|
+
const cacheReadTokens = usage.cached_input_tokens ?? 0;
|
|
977
|
+
const inputTokens = (usage.input_tokens ?? 0) - cacheReadTokens;
|
|
978
|
+
const outputTokens = usage.output_tokens ?? 0;
|
|
979
|
+
const model = event.model;
|
|
980
|
+
const assessment = assessCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, 0);
|
|
981
|
+
const timestamp = event.timestamp;
|
|
982
|
+
const requestId = `${sessionId}-event-${event.identity}`;
|
|
983
|
+
if (db.prepare("SELECT id FROM requests WHERE id = ?").get(requestId))
|
|
984
|
+
return;
|
|
985
|
+
upsertRequest(db, withAccount({
|
|
986
|
+
id: requestId,
|
|
987
|
+
agent: "codex",
|
|
988
|
+
session_id: sessionId,
|
|
989
|
+
model,
|
|
990
|
+
input_tokens: inputTokens,
|
|
991
|
+
output_tokens: outputTokens,
|
|
992
|
+
cache_read_tokens: cacheReadTokens,
|
|
993
|
+
cache_create_tokens: 0,
|
|
994
|
+
cost_usd: assessment.usd ?? 0,
|
|
995
|
+
cost_basis: assessment.usd === null ? "unknown" : "estimated",
|
|
996
|
+
duration_ms: 0,
|
|
997
|
+
timestamp,
|
|
998
|
+
source_request_id: requestId,
|
|
999
|
+
machine_id: machineId
|
|
1000
|
+
}, account));
|
|
1001
|
+
requests++;
|
|
1002
|
+
});
|
|
1003
|
+
rollupSession(db, sessionId);
|
|
1004
|
+
if (!parsed.incomplete)
|
|
1005
|
+
setIngestState(db, source.stateSource, thread.id, stateValue);
|
|
1006
|
+
ingested++;
|
|
1007
|
+
if (verbose)
|
|
1008
|
+
console.log(`${source.label}: observed ${tokenEvents.length} usage events`);
|
|
1009
|
+
}
|
|
1010
|
+
} finally {
|
|
1011
|
+
codexDb?.close();
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return { sessions: ingested, requests, ...issues.length ? { issues } : {} };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/ingest/gemini.ts
|
|
1018
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync4, existsSync as existsSync4, statSync as statSync3 } from "fs";
|
|
1019
|
+
import { homedir as homedir4 } from "os";
|
|
1020
|
+
import { join as join4, basename as basename3 } from "path";
|
|
1021
|
+
var DEFAULT_GEMINI_TMP_DIR = join4(homedir4(), ".gemini", "tmp");
|
|
1022
|
+
var DEFAULT_GEMINI_HISTORY_DIR = join4(homedir4(), ".gemini", "history");
|
|
1023
|
+
function geminiTmpDir() {
|
|
1024
|
+
return process.env["HASNA_ECONOMY_GEMINI_TMP_DIR"] ?? DEFAULT_GEMINI_TMP_DIR;
|
|
1025
|
+
}
|
|
1026
|
+
function geminiHistoryDir() {
|
|
1027
|
+
return process.env["HASNA_ECONOMY_GEMINI_HISTORY_DIR"] ?? DEFAULT_GEMINI_HISTORY_DIR;
|
|
1028
|
+
}
|
|
1029
|
+
function numberField(...values) {
|
|
1030
|
+
for (const value of values) {
|
|
1031
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
1032
|
+
return value;
|
|
1033
|
+
}
|
|
1034
|
+
return 0;
|
|
1035
|
+
}
|
|
1036
|
+
function listProjectDirs(...roots) {
|
|
1037
|
+
const dirs = new Set;
|
|
1038
|
+
for (const root of roots) {
|
|
1039
|
+
if (!existsSync4(root))
|
|
1040
|
+
continue;
|
|
1041
|
+
try {
|
|
1042
|
+
for (const entry of readdirSync2(root, { withFileTypes: true })) {
|
|
1043
|
+
if (entry.isDirectory())
|
|
1044
|
+
dirs.add(join4(root, entry.name));
|
|
1045
|
+
}
|
|
1046
|
+
} catch {
|
|
1047
|
+
collectionProblem("failed", "source_unreadable");
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return [...dirs];
|
|
1051
|
+
}
|
|
1052
|
+
function projectRoot(projectDir, chatData) {
|
|
1053
|
+
if (chatData.projectPath)
|
|
1054
|
+
return chatData.projectPath;
|
|
1055
|
+
if (chatData.project_path)
|
|
1056
|
+
return chatData.project_path;
|
|
1057
|
+
const rootFile = join4(projectDir, ".project_root");
|
|
1058
|
+
try {
|
|
1059
|
+
if (existsSync4(rootFile))
|
|
1060
|
+
return readFileSync4(rootFile, "utf-8").trim();
|
|
1061
|
+
} catch {
|
|
1062
|
+
collectionProblem("failed", "source_unreadable");
|
|
1063
|
+
}
|
|
1064
|
+
return "";
|
|
1065
|
+
}
|
|
1066
|
+
async function ingestGemini(db, verbose) {
|
|
1067
|
+
const tmpDir = geminiTmpDir();
|
|
1068
|
+
const historyDir = geminiHistoryDir();
|
|
1069
|
+
if (!existsSync4(tmpDir) && !existsSync4(historyDir)) {
|
|
1070
|
+
collectionProblem("missing", "source_missing");
|
|
1071
|
+
if (verbose)
|
|
1072
|
+
console.log("Gemini tmp/history dirs not found:", tmpDir, historyDir);
|
|
1073
|
+
return { sessions: 0, requests: 0 };
|
|
1074
|
+
}
|
|
1075
|
+
const machineId = getMachineId();
|
|
1076
|
+
let totalSessions = 0;
|
|
1077
|
+
let totalRequests = 0;
|
|
1078
|
+
const touchedSessions = new Set;
|
|
1079
|
+
const account = await resolveAccountForAgent("gemini");
|
|
1080
|
+
const projectDirs = listProjectDirs(tmpDir, historyDir);
|
|
1081
|
+
for (const projectDir of projectDirs) {
|
|
1082
|
+
const chatsDir = join4(projectDir, "chats");
|
|
1083
|
+
if (!existsSync4(chatsDir))
|
|
1084
|
+
continue;
|
|
1085
|
+
let chatFiles = [];
|
|
1086
|
+
try {
|
|
1087
|
+
chatFiles = readdirSync2(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join4(chatsDir, f));
|
|
1088
|
+
} catch {
|
|
1089
|
+
collectionProblem("failed", "source_unreadable");
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
for (const filePath of chatFiles) {
|
|
1093
|
+
const stateKey = filePath.replace(homedir4(), "~");
|
|
1094
|
+
let fileMtime = "0";
|
|
1095
|
+
try {
|
|
1096
|
+
fileMtime = statSync3(filePath).mtimeMs.toString();
|
|
1097
|
+
} catch {
|
|
1098
|
+
collectionProblem("failed", "source_unreadable");
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
const processed = getIngestState(db, "gemini", stateKey);
|
|
1102
|
+
if (processed === fileMtime) {
|
|
1103
|
+
collectionUnchanged();
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
let chatData;
|
|
1107
|
+
try {
|
|
1108
|
+
chatData = JSON.parse(readFileSync4(filePath, "utf-8"));
|
|
1109
|
+
} catch {
|
|
1110
|
+
collectionProblem("failed", "source_unreadable");
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
const sessionId = chatData.sessionId ?? chatData.id ?? basename3(filePath, ".json");
|
|
1114
|
+
if (!sessionId)
|
|
1115
|
+
continue;
|
|
1116
|
+
const startTime = sourceTime(chatData.startTime);
|
|
1117
|
+
const projectPath = projectRoot(projectDir, chatData);
|
|
1118
|
+
const projectName = projectPath ? basename3(projectPath) : "";
|
|
1119
|
+
let fileInvalid = false;
|
|
1120
|
+
for (const [index, message] of (chatData.messages ?? []).entries()) {
|
|
1121
|
+
const usage = message.usage ?? message.usageMetadata ?? message.response?.usageMetadata;
|
|
1122
|
+
if (!usage)
|
|
1123
|
+
continue;
|
|
1124
|
+
const model = message.model ?? message.response?.modelVersion ?? message.response?.model ?? chatData.model;
|
|
1125
|
+
if (!model)
|
|
1126
|
+
continue;
|
|
1127
|
+
const toolUsePromptTokens = numberField(usage.toolUsePromptTokenCount, usage.tool_use_prompt_token_count);
|
|
1128
|
+
const inputTotal = numberField(usage.inputTokens, usage.input_tokens, usage.promptTokenCount, usage.prompt_token_count) + toolUsePromptTokens;
|
|
1129
|
+
const cacheReadTokens = numberField(usage.cachedInputTokens, usage.cache_read_tokens, usage.cachedContentTokenCount, usage.cached_content_token_count);
|
|
1130
|
+
const inputTokens = Math.max(inputTotal - cacheReadTokens, 0);
|
|
1131
|
+
const thoughtsTokens = numberField(usage.thoughtsTokenCount, usage.thoughts_token_count);
|
|
1132
|
+
const outputTokens = numberField(usage.outputTokens, usage.output_tokens, usage.candidatesTokenCount, usage.candidates_token_count) + thoughtsTokens;
|
|
1133
|
+
const totalTokens = numberField(usage.totalTokens, usage.total_tokens, usage.totalTokenCount, usage.total_token_count);
|
|
1134
|
+
if (inputTokens + outputTokens + cacheReadTokens + totalTokens === 0)
|
|
1135
|
+
continue;
|
|
1136
|
+
const assessment = assessCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, 0);
|
|
1137
|
+
const sourceCost = message.costUsd ?? message.cost_usd;
|
|
1138
|
+
const hasSourceCost = typeof sourceCost === "number" && Number.isFinite(sourceCost) && sourceCost >= 0;
|
|
1139
|
+
const costUsd = hasSourceCost ? sourceCost : assessment.usd ?? 0;
|
|
1140
|
+
const timestamp = sourceTime(message.timestamp);
|
|
1141
|
+
if (timestamp === null) {
|
|
1142
|
+
fileInvalid = true;
|
|
1143
|
+
collectionProblem("failed", "source_invalid");
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
if (!touchedSessions.has(sessionId)) {
|
|
1147
|
+
if (!db.get("SELECT id FROM sessions WHERE id=?", sessionId)) {
|
|
1148
|
+
const session = {
|
|
1149
|
+
id: sessionId,
|
|
1150
|
+
agent: "gemini",
|
|
1151
|
+
project_path: projectPath,
|
|
1152
|
+
project_name: projectName,
|
|
1153
|
+
started_at: startTime ?? timestamp,
|
|
1154
|
+
ended_at: sourceTime(chatData.lastUpdated),
|
|
1155
|
+
total_cost_usd: 0,
|
|
1156
|
+
total_tokens: 0,
|
|
1157
|
+
request_count: 0,
|
|
1158
|
+
machine_id: machineId
|
|
1159
|
+
};
|
|
1160
|
+
upsertSession(db, withAccount(session, account));
|
|
1161
|
+
totalSessions++;
|
|
1162
|
+
}
|
|
1163
|
+
touchedSessions.add(sessionId);
|
|
1164
|
+
}
|
|
1165
|
+
const requestId = `gemini-${sessionId}-${message.id ?? index}`;
|
|
1166
|
+
upsertRequest(db, withAccount({
|
|
1167
|
+
id: requestId,
|
|
1168
|
+
agent: "gemini",
|
|
1169
|
+
session_id: sessionId,
|
|
1170
|
+
model,
|
|
1171
|
+
input_tokens: inputTokens,
|
|
1172
|
+
output_tokens: outputTokens,
|
|
1173
|
+
cache_read_tokens: cacheReadTokens,
|
|
1174
|
+
cache_create_tokens: 0,
|
|
1175
|
+
cost_usd: costUsd,
|
|
1176
|
+
cost_basis: !hasSourceCost && assessment.usd === null ? "unknown" : "estimated",
|
|
1177
|
+
duration_ms: 0,
|
|
1178
|
+
timestamp,
|
|
1179
|
+
source_request_id: message.id ?? requestId,
|
|
1180
|
+
machine_id: machineId
|
|
1181
|
+
}, account));
|
|
1182
|
+
totalRequests++;
|
|
1183
|
+
}
|
|
1184
|
+
if (!fileInvalid)
|
|
1185
|
+
setIngestState(db, "gemini", stateKey, fileMtime);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
for (const sessionId of touchedSessions) {
|
|
1189
|
+
rollupSession(db, sessionId);
|
|
1190
|
+
}
|
|
1191
|
+
return { sessions: totalSessions, requests: totalRequests };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// src/ingest/opencode.ts
|
|
1195
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
|
|
1196
|
+
import { homedir as homedir5 } from "os";
|
|
1197
|
+
import { join as join5 } from "path";
|
|
1198
|
+
var OPENCODE_STORAGE = join5(homedir5(), ".local", "share", "opencode", "storage");
|
|
1199
|
+
function walkJsonFiles(dir, acc = []) {
|
|
1200
|
+
if (!existsSync5(dir))
|
|
1201
|
+
return acc;
|
|
1202
|
+
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
1203
|
+
const p = join5(dir, entry.name);
|
|
1204
|
+
if (entry.isDirectory())
|
|
1205
|
+
walkJsonFiles(p, acc);
|
|
1206
|
+
else if (entry.name.endsWith(".json"))
|
|
1207
|
+
acc.push(p);
|
|
1208
|
+
}
|
|
1209
|
+
return acc;
|
|
1210
|
+
}
|
|
1211
|
+
function parseSessionIdFromPath(filePath) {
|
|
1212
|
+
const parts = filePath.split("/");
|
|
1213
|
+
const msgIdx = parts.indexOf("message");
|
|
1214
|
+
if (msgIdx >= 0 && parts[msgIdx + 1])
|
|
1215
|
+
return parts[msgIdx + 1];
|
|
1216
|
+
const sessionIdx = parts.indexOf("session");
|
|
1217
|
+
if (sessionIdx >= 0 && parts[parts.length - 1]?.endsWith(".json")) {
|
|
1218
|
+
return parts[parts.length - 1].replace(/\.json$/, "");
|
|
1219
|
+
}
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
async function ingestOpenCode(db, verbose = false, sourceDir = OPENCODE_STORAGE) {
|
|
1223
|
+
const messageDir = join5(sourceDir, "message");
|
|
1224
|
+
if (!existsSync5(messageDir))
|
|
1225
|
+
collectionProblem("missing", "source_missing");
|
|
1226
|
+
const files = walkJsonFiles(messageDir);
|
|
1227
|
+
let requests = 0;
|
|
1228
|
+
const touched = new Set;
|
|
1229
|
+
const machineId = getMachineId();
|
|
1230
|
+
const account = await resolveAccountForAgent("opencode");
|
|
1231
|
+
for (const file of files) {
|
|
1232
|
+
const mtime = statSync4(file).mtimeMs;
|
|
1233
|
+
const stateKey = file;
|
|
1234
|
+
const prev = getIngestState(db, "opencode", stateKey);
|
|
1235
|
+
if (prev && Number(prev) >= mtime) {
|
|
1236
|
+
collectionUnchanged();
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
let parsed;
|
|
1240
|
+
try {
|
|
1241
|
+
parsed = JSON.parse(readFileSync5(file, "utf-8"));
|
|
1242
|
+
} catch {
|
|
1243
|
+
collectionProblem("failed", "source_invalid");
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (parsed.role !== "assistant")
|
|
1247
|
+
continue;
|
|
1248
|
+
const usage = parsed.usage;
|
|
1249
|
+
if (!usage)
|
|
1250
|
+
continue;
|
|
1251
|
+
const sessionId = parseSessionIdFromPath(file) ?? `opencode-${statSync4(file).ino}`;
|
|
1252
|
+
const model = normalizeModelName(parsed.model ?? "unknown");
|
|
1253
|
+
const input = usage.inputTokens ?? 0;
|
|
1254
|
+
const output = usage.outputTokens ?? 0;
|
|
1255
|
+
const cacheRead = usage.cacheReadTokens ?? 0;
|
|
1256
|
+
const cacheWrite = usage.cacheWriteTokens ?? 0;
|
|
1257
|
+
if (input + output + cacheRead + cacheWrite === 0 && !usage.cost)
|
|
1258
|
+
continue;
|
|
1259
|
+
const timestamp = sourceTimeMillis(parsed.time?.created);
|
|
1260
|
+
if (timestamp === null) {
|
|
1261
|
+
collectionProblem("failed", "source_invalid");
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
const sourceId = file.replace(sourceDir, "");
|
|
1265
|
+
const reqId = `opencode-${sourceId}`;
|
|
1266
|
+
const assessment = assessCostFromDb(db, model, input, output, cacheRead, cacheWrite, 0);
|
|
1267
|
+
const hasSourceCost = typeof usage.cost === "number" && Number.isFinite(usage.cost) && usage.cost >= 0;
|
|
1268
|
+
const costUsd = hasSourceCost ? usage.cost : assessment.usd ?? 0;
|
|
1269
|
+
upsertRequest(db, withAccount({
|
|
1270
|
+
id: reqId,
|
|
1271
|
+
agent: "opencode",
|
|
1272
|
+
session_id: sessionId,
|
|
1273
|
+
model,
|
|
1274
|
+
input_tokens: input,
|
|
1275
|
+
output_tokens: output,
|
|
1276
|
+
cache_read_tokens: cacheRead,
|
|
1277
|
+
cache_create_tokens: cacheWrite,
|
|
1278
|
+
cost_usd: costUsd,
|
|
1279
|
+
cost_basis: !hasSourceCost && assessment.usd === null ? "unknown" : "estimated",
|
|
1280
|
+
duration_ms: 0,
|
|
1281
|
+
timestamp,
|
|
1282
|
+
source_request_id: sourceId,
|
|
1283
|
+
machine_id: machineId,
|
|
1284
|
+
updated_at: timestamp
|
|
1285
|
+
}, account));
|
|
1286
|
+
requests++;
|
|
1287
|
+
if (!touched.has(sessionId)) {
|
|
1288
|
+
upsertSession(db, withAccount({
|
|
1289
|
+
id: sessionId,
|
|
1290
|
+
agent: "opencode",
|
|
1291
|
+
project_path: "",
|
|
1292
|
+
project_name: "",
|
|
1293
|
+
started_at: timestamp,
|
|
1294
|
+
ended_at: null,
|
|
1295
|
+
total_cost_usd: 0,
|
|
1296
|
+
total_tokens: 0,
|
|
1297
|
+
request_count: 0,
|
|
1298
|
+
machine_id: machineId,
|
|
1299
|
+
updated_at: timestamp
|
|
1300
|
+
}, account));
|
|
1301
|
+
touched.add(sessionId);
|
|
1302
|
+
}
|
|
1303
|
+
setIngestState(db, "opencode", stateKey, String(mtime));
|
|
1304
|
+
if (verbose)
|
|
1305
|
+
console.log(` opencode: ${reqId} ${model} $${costUsd.toFixed(4)}`);
|
|
1306
|
+
}
|
|
1307
|
+
for (const sid of touched)
|
|
1308
|
+
rollupSession(db, sid);
|
|
1309
|
+
return { files: files.length, requests, sessions: touched.size };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// src/ingest/quota-observation.ts
|
|
1313
|
+
function isoTime(value) {
|
|
1314
|
+
const ms = typeof value === "number" ? value * 1000 : typeof value === "string" ? Date.parse(value) : NaN;
|
|
1315
|
+
return Number.isFinite(ms) && ms >= 0 ? new Date(ms).toISOString() : null;
|
|
1316
|
+
}
|
|
1317
|
+
function quotaObservation(db, ctx, metric, value, unit, detail = {}) {
|
|
1318
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
1319
|
+
return 0;
|
|
1320
|
+
const machine_id = getMachineId();
|
|
1321
|
+
const window_seconds = typeof detail.window_seconds === "number" && Number.isSafeInteger(detail.window_seconds) && detail.window_seconds > 0 ? detail.window_seconds : null;
|
|
1322
|
+
upsertUsageSnapshot(db, {
|
|
1323
|
+
...ctx,
|
|
1324
|
+
id: observationId({ ...ctx, machine_id, metric }),
|
|
1325
|
+
date: ctx.observed_at.slice(0, 10),
|
|
1326
|
+
metric,
|
|
1327
|
+
value,
|
|
1328
|
+
unit,
|
|
1329
|
+
machine_id,
|
|
1330
|
+
source_timestamp: null,
|
|
1331
|
+
window_start: null,
|
|
1332
|
+
window_end: null,
|
|
1333
|
+
reset_at: isoTime(detail.reset_at),
|
|
1334
|
+
window_seconds,
|
|
1335
|
+
value_kind: detail.value_kind ?? "gauge",
|
|
1336
|
+
updated_at: ctx.observed_at
|
|
1337
|
+
});
|
|
1338
|
+
return 1;
|
|
1339
|
+
}
|
|
1340
|
+
function quotaSubscription(db, ctx, plan) {
|
|
1341
|
+
upsertSubscription(db, {
|
|
1342
|
+
id: subscriptionId(ctx),
|
|
1343
|
+
agent: ctx.agent,
|
|
1344
|
+
provider: ctx.provider,
|
|
1345
|
+
account_key: ctx.account_key,
|
|
1346
|
+
plan,
|
|
1347
|
+
monthly_fee_usd: 0,
|
|
1348
|
+
included_usage_usd: 0,
|
|
1349
|
+
fee_basis: "unknown",
|
|
1350
|
+
included_basis: "unknown",
|
|
1351
|
+
observed_station: getMachineId(),
|
|
1352
|
+
observed_at: ctx.observed_at,
|
|
1353
|
+
billing_cycle_start: null,
|
|
1354
|
+
reset_policy: "unknown",
|
|
1355
|
+
active: 1,
|
|
1356
|
+
created_at: ctx.observed_at,
|
|
1357
|
+
updated_at: ctx.observed_at
|
|
1358
|
+
}, "quota_observation");
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/ingest/cursor.ts
|
|
1362
|
+
function getCursorSessionToken() {
|
|
1363
|
+
return process.env["CURSOR_SESSION_TOKEN"] ?? process.env["CURSOR_API_TOKEN"] ?? null;
|
|
1364
|
+
}
|
|
1365
|
+
async function cursorFetch(path, token) {
|
|
1366
|
+
try {
|
|
1367
|
+
const res = await fetch(`https://cursor.com${path}`, {
|
|
1368
|
+
headers: {
|
|
1369
|
+
Cookie: `WorkosCursorSessionToken=${token}`,
|
|
1370
|
+
Accept: "application/json"
|
|
1371
|
+
},
|
|
1372
|
+
signal: AbortSignal.timeout(1e4)
|
|
1373
|
+
});
|
|
1374
|
+
if (!res.ok) {
|
|
1375
|
+
collectionProblem("failed", res.status === 401 || res.status === 403 ? "provider_auth_refused" : "provider_refused");
|
|
1376
|
+
return null;
|
|
1377
|
+
}
|
|
1378
|
+
return await res.json();
|
|
1379
|
+
} catch {
|
|
1380
|
+
collectionProblem("failed", "provider_unavailable");
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
async function ingestCursor(db, verbose = false) {
|
|
1385
|
+
const token = getCursorSessionToken();
|
|
1386
|
+
if (!token) {
|
|
1387
|
+
collectionProblem("missing", "provider_credential_unavailable");
|
|
1388
|
+
if (verbose)
|
|
1389
|
+
console.log(" cursor: skipped \u2014 set CURSOR_SESSION_TOKEN");
|
|
1390
|
+
return { requests: 0, snapshots: 0 };
|
|
1391
|
+
}
|
|
1392
|
+
const account = await resolveAccountForAgent("cursor");
|
|
1393
|
+
if (!account?.account_key) {
|
|
1394
|
+
collectionProblem("unsupported", "account_identity_unavailable");
|
|
1395
|
+
return { requests: 0, snapshots: 0 };
|
|
1396
|
+
}
|
|
1397
|
+
const ctx = { provider: "cursor", agent: "cursor", account_key: account.account_key, observed_at: new Date().toISOString() };
|
|
1398
|
+
let snapshots = 0;
|
|
1399
|
+
const usage = await cursorFetch("/api/usage", token);
|
|
1400
|
+
snapshots += quotaObservation(db, ctx, "premium_requests_used", usage?.premiumRequests, "count", { value_kind: "unknown" });
|
|
1401
|
+
snapshots += quotaObservation(db, ctx, "premium_requests_limit", usage?.maxPremiumRequests, "count", { value_kind: "unknown" });
|
|
1402
|
+
const summary = await cursorFetch("/api/usage-summary", token);
|
|
1403
|
+
snapshots += quotaObservation(db, ctx, "individual_spend", summary?.individualUsage?.spend, "source_units", { value_kind: "unknown" });
|
|
1404
|
+
snapshots += quotaObservation(db, ctx, "individual_included_spend", summary?.individualUsage?.includedSpend, "source_units", { value_kind: "unknown" });
|
|
1405
|
+
if (summary?.teamUsage?.spend !== undefined)
|
|
1406
|
+
collectionProblem("unsupported", "team_usage_attribution_unavailable");
|
|
1407
|
+
if (verbose)
|
|
1408
|
+
console.log(` cursor: ${snapshots} observations`);
|
|
1409
|
+
return { requests: 0, snapshots };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// src/ingest/pi.ts
|
|
1413
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
|
|
1414
|
+
import { homedir as homedir6 } from "os";
|
|
1415
|
+
import { join as join6 } from "path";
|
|
1416
|
+
var PI_SESSION_DIR = process.env["PI_CODING_AGENT_SESSION_DIR"] ?? join6(homedir6(), ".pi", "agent", "sessions");
|
|
1417
|
+
function walkSessions(dir, acc = []) {
|
|
1418
|
+
if (!existsSync6(dir))
|
|
1419
|
+
return acc;
|
|
1420
|
+
for (const entry of readdirSync4(dir, { withFileTypes: true })) {
|
|
1421
|
+
const p = join6(dir, entry.name);
|
|
1422
|
+
if (entry.isDirectory())
|
|
1423
|
+
walkSessions(p, acc);
|
|
1424
|
+
else if (entry.name.endsWith(".json"))
|
|
1425
|
+
acc.push(p);
|
|
1426
|
+
}
|
|
1427
|
+
return acc;
|
|
1428
|
+
}
|
|
1429
|
+
async function ingestPi(db, verbose = false, sourceDir = PI_SESSION_DIR) {
|
|
1430
|
+
if (!existsSync6(sourceDir))
|
|
1431
|
+
collectionProblem("missing", "source_missing");
|
|
1432
|
+
const files = walkSessions(sourceDir);
|
|
1433
|
+
let requests = 0;
|
|
1434
|
+
const touched = new Set;
|
|
1435
|
+
const machineId = getMachineId();
|
|
1436
|
+
const account = await resolveAccountForAgent("pi");
|
|
1437
|
+
for (const file of files) {
|
|
1438
|
+
const mtime = statSync5(file).mtimeMs;
|
|
1439
|
+
const prev = getIngestState(db, "pi", file);
|
|
1440
|
+
if (prev && Number(prev) >= mtime) {
|
|
1441
|
+
collectionUnchanged();
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
let data;
|
|
1445
|
+
try {
|
|
1446
|
+
data = JSON.parse(readFileSync6(file, "utf-8"));
|
|
1447
|
+
} catch {
|
|
1448
|
+
collectionProblem("failed", "source_invalid");
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
const sessionId = data.id ?? file.replace(/\.json$/, "").split("/").pop() ?? `pi-${statSync5(file).ino}`;
|
|
1452
|
+
const turns = data.turns ?? data.messages?.filter((m) => m.role === "assistant") ?? [];
|
|
1453
|
+
let firstEvent = null, fileInvalid = false;
|
|
1454
|
+
for (let i = 0;i < turns.length; i++) {
|
|
1455
|
+
const turn = turns[i];
|
|
1456
|
+
const usage = turn.usage;
|
|
1457
|
+
if (!usage)
|
|
1458
|
+
continue;
|
|
1459
|
+
const input = usage.input ?? 0;
|
|
1460
|
+
const output = usage.output ?? 0;
|
|
1461
|
+
if (input + output === 0 && !usage.cost)
|
|
1462
|
+
continue;
|
|
1463
|
+
const model = turn.model ?? turn.provider ?? "unknown";
|
|
1464
|
+
const timestamp = sourceTime(turn.timestamp);
|
|
1465
|
+
if (timestamp === null) {
|
|
1466
|
+
fileInvalid = true;
|
|
1467
|
+
collectionProblem("failed", "source_invalid");
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
if (firstEvent === null || timestamp < firstEvent)
|
|
1471
|
+
firstEvent = timestamp;
|
|
1472
|
+
const reqId = `pi-${sessionId}-${i}`;
|
|
1473
|
+
upsertRequest(db, withAccount({
|
|
1474
|
+
id: reqId,
|
|
1475
|
+
agent: "pi",
|
|
1476
|
+
session_id: sessionId,
|
|
1477
|
+
model,
|
|
1478
|
+
input_tokens: input,
|
|
1479
|
+
output_tokens: output,
|
|
1480
|
+
cache_read_tokens: usage.cacheRead ?? 0,
|
|
1481
|
+
cache_create_tokens: usage.cacheWrite ?? 0,
|
|
1482
|
+
cost_usd: usage.cost ?? 0,
|
|
1483
|
+
cost_basis: usage.cost === undefined ? "unknown" : "estimated",
|
|
1484
|
+
duration_ms: 0,
|
|
1485
|
+
timestamp,
|
|
1486
|
+
source_request_id: `${sessionId}-${i}`,
|
|
1487
|
+
machine_id: machineId,
|
|
1488
|
+
updated_at: timestamp
|
|
1489
|
+
}, account));
|
|
1490
|
+
requests++;
|
|
1491
|
+
}
|
|
1492
|
+
if (firstEvent !== null) {
|
|
1493
|
+
upsertSession(db, withAccount({
|
|
1494
|
+
id: sessionId,
|
|
1495
|
+
agent: "pi",
|
|
1496
|
+
project_path: "",
|
|
1497
|
+
project_name: "",
|
|
1498
|
+
started_at: firstEvent,
|
|
1499
|
+
ended_at: null,
|
|
1500
|
+
total_cost_usd: 0,
|
|
1501
|
+
total_tokens: 0,
|
|
1502
|
+
request_count: 0,
|
|
1503
|
+
machine_id: machineId,
|
|
1504
|
+
updated_at: firstEvent
|
|
1505
|
+
}, account));
|
|
1506
|
+
touched.add(sessionId);
|
|
1507
|
+
}
|
|
1508
|
+
if (!fileInvalid)
|
|
1509
|
+
setIngestState(db, "pi", file, String(mtime));
|
|
1510
|
+
if (verbose)
|
|
1511
|
+
console.log(` pi: ${sessionId} (${turns.length} turns)`);
|
|
1512
|
+
}
|
|
1513
|
+
for (const sid of touched)
|
|
1514
|
+
rollupSession(db, sid);
|
|
1515
|
+
return { files: files.length, requests, sessions: touched.size };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// src/ingest/hermes.ts
|
|
1519
|
+
import { existsSync as existsSync7, statSync as statSync6 } from "fs";
|
|
1520
|
+
import { homedir as homedir7 } from "os";
|
|
1521
|
+
import { join as join7 } from "path";
|
|
1522
|
+
var HERMES_DB = join7(homedir7(), ".hermes", "state.db");
|
|
1523
|
+
async function ingestHermes(db, verbose = false, sourcePath = HERMES_DB) {
|
|
1524
|
+
if (!existsSync7(sourcePath)) {
|
|
1525
|
+
collectionProblem("missing", "source_missing");
|
|
1526
|
+
return { sessions: 0, requests: 0 };
|
|
1527
|
+
}
|
|
1528
|
+
const { openThirdPartySqlite } = await import("./third-party-sqlite-5e85fxa2.js");
|
|
1529
|
+
const hermes = openThirdPartySqlite(sourcePath, { readonly: true });
|
|
1530
|
+
const rows = hermes.prepare(`
|
|
1531
|
+
SELECT id, source, model, started_at, ended_at,
|
|
1532
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
1533
|
+
reasoning_tokens, estimated_cost_usd, actual_cost_usd, billing_mode, parent_session_id
|
|
1534
|
+
FROM sessions
|
|
1535
|
+
ORDER BY started_at DESC
|
|
1536
|
+
`).all();
|
|
1537
|
+
const stateKey = "state.db";
|
|
1538
|
+
const mtime = statSyncSafe(sourcePath);
|
|
1539
|
+
const prev = getIngestState(db, "hermes", stateKey);
|
|
1540
|
+
if (prev && Number(prev) >= mtime && rows.length === 0) {
|
|
1541
|
+
hermes.close();
|
|
1542
|
+
return { sessions: 0, requests: 0 };
|
|
1543
|
+
}
|
|
1544
|
+
const machineId = getMachineId();
|
|
1545
|
+
let sessions = 0;
|
|
1546
|
+
const account = await resolveAccountForAgent("hermes");
|
|
1547
|
+
for (const row of rows) {
|
|
1548
|
+
const sessionId = `hermes-${row.id}`;
|
|
1549
|
+
if (db.get("SELECT id FROM economy_effective_requests WHERE session_id=? AND id=?", sessionId, `${sessionId}-rollup`)) {
|
|
1550
|
+
collectionProblem("unsupported", "legacy_aggregate_requires_migration");
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
const startedAt = new Date(row.started_at * 1000).toISOString();
|
|
1554
|
+
const endedAt = row.ended_at ? new Date(row.ended_at * 1000).toISOString() : null;
|
|
1555
|
+
const cost = row.actual_cost_usd ?? row.estimated_cost_usd ?? 0;
|
|
1556
|
+
const tokens = row.input_tokens + row.output_tokens + row.cache_read_tokens + row.cache_write_tokens + row.reasoning_tokens;
|
|
1557
|
+
upsertSession(db, withAccount({
|
|
1558
|
+
id: sessionId,
|
|
1559
|
+
agent: "hermes",
|
|
1560
|
+
project_path: row.source ?? "",
|
|
1561
|
+
project_name: row.source ?? "",
|
|
1562
|
+
started_at: startedAt,
|
|
1563
|
+
ended_at: endedAt,
|
|
1564
|
+
total_cost_usd: cost,
|
|
1565
|
+
total_tokens: tokens,
|
|
1566
|
+
request_count: 0,
|
|
1567
|
+
machine_id: machineId,
|
|
1568
|
+
updated_at: endedAt ?? startedAt
|
|
1569
|
+
}, account));
|
|
1570
|
+
collectionProblem("unsupported", "usage_events_unavailable");
|
|
1571
|
+
sessions++;
|
|
1572
|
+
if (verbose)
|
|
1573
|
+
console.log(` hermes: ${sessionId} $${cost.toFixed(4)}`);
|
|
1574
|
+
}
|
|
1575
|
+
setIngestState(db, "hermes", stateKey, String(mtime));
|
|
1576
|
+
hermes.close();
|
|
1577
|
+
return { sessions, requests: 0 };
|
|
1578
|
+
}
|
|
1579
|
+
function statSyncSafe(path) {
|
|
1580
|
+
try {
|
|
1581
|
+
return statSync6(path).mtimeMs;
|
|
1582
|
+
} catch {
|
|
1583
|
+
return 0;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// src/ingest/loops.ts
|
|
1588
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1589
|
+
import { homedir as homedir8 } from "os";
|
|
1590
|
+
import { join as join8 } from "path";
|
|
1591
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
1592
|
+
import { join as pathsResolverJoin } from "path";
|
|
1593
|
+
var PATHS_RESOLVER_KIND_ENV = {
|
|
1594
|
+
config: "HASNA_CONFIG_HOME",
|
|
1595
|
+
data: "HASNA_DATA_HOME",
|
|
1596
|
+
state: "HASNA_STATE_HOME",
|
|
1597
|
+
cache: "HASNA_CACHE_HOME"
|
|
1598
|
+
};
|
|
1599
|
+
var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1600
|
+
function pathsResolverAssertApp(app) {
|
|
1601
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
1602
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
1603
|
+
}
|
|
1604
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
1605
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
function pathsResolverAssertKind(kind) {
|
|
1609
|
+
if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
|
|
1610
|
+
throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
function pathsResolverBaseDir(kind, options) {
|
|
1614
|
+
pathsResolverAssertKind(kind);
|
|
1615
|
+
const env = options.env ?? process.env;
|
|
1616
|
+
const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
|
|
1617
|
+
if (typeof override === "string" && override.length > 0)
|
|
1618
|
+
return override;
|
|
1619
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
1620
|
+
const platform = options.platform ?? process.platform;
|
|
1621
|
+
if (platform === "darwin") {
|
|
1622
|
+
switch (kind) {
|
|
1623
|
+
case "config":
|
|
1624
|
+
case "data":
|
|
1625
|
+
return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
|
|
1626
|
+
case "cache":
|
|
1627
|
+
return pathsResolverJoin(home, "Library", "Caches", "Hasna");
|
|
1628
|
+
case "state":
|
|
1629
|
+
return pathsResolverJoin(home, "Library", "Logs", "Hasna");
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
switch (kind) {
|
|
1633
|
+
case "config":
|
|
1634
|
+
return pathsResolverJoin(home, ".config", "hasna");
|
|
1635
|
+
case "data":
|
|
1636
|
+
return pathsResolverJoin(home, ".local", "share", "hasna");
|
|
1637
|
+
case "state":
|
|
1638
|
+
return pathsResolverJoin(home, ".local", "state", "hasna");
|
|
1639
|
+
case "cache":
|
|
1640
|
+
return pathsResolverJoin(home, ".cache", "hasna");
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
function pathsResolverResolve(kind, options) {
|
|
1644
|
+
pathsResolverAssertApp(options.app);
|
|
1645
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
1646
|
+
return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
|
|
1647
|
+
}
|
|
1648
|
+
function dataDir(options) {
|
|
1649
|
+
return pathsResolverResolve("data", options);
|
|
1650
|
+
}
|
|
1651
|
+
var LOOPS_INGEST_VERSION = "goal-runs-orchestration-v1";
|
|
1652
|
+
function defaultLoopsDbPath() {
|
|
1653
|
+
const resolved = dataDir({
|
|
1654
|
+
app: "loops",
|
|
1655
|
+
home: process.env["HOME"] || process.env["USERPROFILE"] || undefined
|
|
1656
|
+
});
|
|
1657
|
+
const legacy = join8(process.env["HOME"] || process.env["USERPROFILE"] || homedir8(), ".hasna", "loops", "loops.db");
|
|
1658
|
+
return existsSync8(join8(resolved, "loops.db")) ? join8(resolved, "loops.db") : legacy;
|
|
1659
|
+
}
|
|
1660
|
+
function loopsDbPath() {
|
|
1661
|
+
return process.env["HASNA_ECONOMY_LOOPS_DB_PATH"] ?? defaultLoopsDbPath();
|
|
1662
|
+
}
|
|
1663
|
+
function loopsModel() {
|
|
1664
|
+
return process.env["HASNA_ECONOMY_LOOPS_MODEL"] ?? process.env["ECONOMY_LOOPS_MODEL"] ?? "gpt-5.3-codex";
|
|
1665
|
+
}
|
|
1666
|
+
async function openLoopsDb(dbPath, verbose) {
|
|
1667
|
+
const { openThirdPartySqlite } = await import("./third-party-sqlite-5e85fxa2.js");
|
|
1668
|
+
let lastError;
|
|
1669
|
+
for (const readonly of [true, false]) {
|
|
1670
|
+
let loopsDb = null;
|
|
1671
|
+
try {
|
|
1672
|
+
loopsDb = openThirdPartySqlite(dbPath, { readonly });
|
|
1673
|
+
loopsDb.prepare("PRAGMA schema_version").get();
|
|
1674
|
+
return loopsDb;
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
lastError = error;
|
|
1677
|
+
loopsDb?.close();
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
if (verbose) {
|
|
1681
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
1682
|
+
console.log("OpenLoops DB unreadable:", dbPath, message);
|
|
1683
|
+
}
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
function slug(value) {
|
|
1687
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
|
1688
|
+
}
|
|
1689
|
+
function parseTarget(raw) {
|
|
1690
|
+
if (!raw)
|
|
1691
|
+
return {};
|
|
1692
|
+
try {
|
|
1693
|
+
const parsed = JSON.parse(raw);
|
|
1694
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1695
|
+
} catch {
|
|
1696
|
+
return {};
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
function stringValue(value) {
|
|
1700
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1701
|
+
}
|
|
1702
|
+
function accountFields(row) {
|
|
1703
|
+
const target = parseTarget(row.target_json);
|
|
1704
|
+
const tool = stringValue(row.account_tool) || stringValue(target["account_tool"]) || stringValue(target["tool"]) || stringValue(target["agent"]);
|
|
1705
|
+
const profile = stringValue(row.account_profile) || stringValue(target["account_profile"]) || stringValue(target["profile"]) || stringValue(target["account"]);
|
|
1706
|
+
if (!tool || !profile) {
|
|
1707
|
+
return { account_key: "", account_tool: tool, account_name: profile, account_source: "" };
|
|
1708
|
+
}
|
|
1709
|
+
return {
|
|
1710
|
+
account_key: `${tool}:${profile}`,
|
|
1711
|
+
account_tool: tool,
|
|
1712
|
+
account_name: profile,
|
|
1713
|
+
account_source: "loops"
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
function readLoopGoalRuns(loopsDb) {
|
|
1717
|
+
return loopsDb.prepare(`
|
|
1718
|
+
SELECT
|
|
1719
|
+
gr.id as goal_run_id,
|
|
1720
|
+
gr.goal_id as goal_id,
|
|
1721
|
+
COALESCE(gr.loop_id, g.loop_id, lr.loop_id) as loop_id,
|
|
1722
|
+
COALESCE(gr.loop_run_id, g.loop_run_id, lr.id) as loop_run_id,
|
|
1723
|
+
COALESCE(l.name, lr.loop_name, 'unknown-loop') as loop_name,
|
|
1724
|
+
l.labels_json as labels_json,
|
|
1725
|
+
l.target_json as target_json,
|
|
1726
|
+
g.objective as objective,
|
|
1727
|
+
gr.phase as phase,
|
|
1728
|
+
gr.status as status,
|
|
1729
|
+
COALESCE(gr.tokens_used, 0) as tokens_used,
|
|
1730
|
+
gr.created_at as created_at,
|
|
1731
|
+
gr.updated_at as updated_at,
|
|
1732
|
+
lr.started_at as loop_run_started_at,
|
|
1733
|
+
wsr.account_profile as account_profile,
|
|
1734
|
+
wsr.account_tool as account_tool
|
|
1735
|
+
FROM goal_runs gr
|
|
1736
|
+
LEFT JOIN goals g ON g.id = gr.goal_id
|
|
1737
|
+
LEFT JOIN loop_runs lr ON lr.id = COALESCE(gr.loop_run_id, g.loop_run_id)
|
|
1738
|
+
LEFT JOIN loops l ON l.id = COALESCE(gr.loop_id, g.loop_id, lr.loop_id)
|
|
1739
|
+
LEFT JOIN (
|
|
1740
|
+
SELECT goal_run_id, MIN(account_profile) as account_profile, MIN(account_tool) as account_tool
|
|
1741
|
+
FROM workflow_step_runs
|
|
1742
|
+
WHERE goal_run_id IS NOT NULL
|
|
1743
|
+
GROUP BY goal_run_id
|
|
1744
|
+
) wsr ON wsr.goal_run_id = gr.id
|
|
1745
|
+
WHERE COALESCE(gr.tokens_used, 0) > 0
|
|
1746
|
+
ORDER BY gr.created_at ASC, gr.id ASC
|
|
1747
|
+
`).all();
|
|
1748
|
+
}
|
|
1749
|
+
async function ingestLoops(db, verbose = false) {
|
|
1750
|
+
const dbPath = loopsDbPath();
|
|
1751
|
+
if (!existsSync8(dbPath)) {
|
|
1752
|
+
collectionProblem("missing", "source_missing");
|
|
1753
|
+
if (verbose)
|
|
1754
|
+
console.log("OpenLoops DB not found:", dbPath);
|
|
1755
|
+
return { sessions: 0, requests: 0 };
|
|
1756
|
+
}
|
|
1757
|
+
const model = loopsModel();
|
|
1758
|
+
const machineId = getMachineId();
|
|
1759
|
+
let loopsDb = null;
|
|
1760
|
+
let requests = 0;
|
|
1761
|
+
const sessions = new Set;
|
|
1762
|
+
try {
|
|
1763
|
+
loopsDb = await openLoopsDb(dbPath, verbose);
|
|
1764
|
+
if (!loopsDb) {
|
|
1765
|
+
collectionProblem("failed", "source_unreadable");
|
|
1766
|
+
return { sessions: 0, requests: 0 };
|
|
1767
|
+
}
|
|
1768
|
+
for (const row of readLoopGoalRuns(loopsDb)) {
|
|
1769
|
+
const loopName = row.loop_name || "unknown-loop";
|
|
1770
|
+
const costCenterId = `loop:${slug(loopName)}`;
|
|
1771
|
+
const sessionId = `loops-${row.loop_run_id ?? row.loop_id ?? row.goal_id}`;
|
|
1772
|
+
const requestId = `loops-${row.goal_run_id}`;
|
|
1773
|
+
const timestamp = row.updated_at ?? row.created_at ?? row.loop_run_started_at ?? new Date().toISOString();
|
|
1774
|
+
const stateValue = `${LOOPS_INGEST_VERSION}:${row.updated_at ?? ""}:${row.tokens_used}:${model}:${costCenterId}`;
|
|
1775
|
+
if (getIngestState(db, "loops", row.goal_run_id) === stateValue)
|
|
1776
|
+
continue;
|
|
1777
|
+
upsertCostCenter(db, {
|
|
1778
|
+
id: costCenterId,
|
|
1779
|
+
kind: "loop",
|
|
1780
|
+
name: loopName,
|
|
1781
|
+
repo_path: null,
|
|
1782
|
+
labels_json: row.labels_json ?? "{}",
|
|
1783
|
+
created_at: row.created_at ?? timestamp
|
|
1784
|
+
});
|
|
1785
|
+
const account = accountFields(row);
|
|
1786
|
+
upsertSession(db, {
|
|
1787
|
+
id: sessionId,
|
|
1788
|
+
agent: "loop",
|
|
1789
|
+
project_path: "",
|
|
1790
|
+
project_name: loopName,
|
|
1791
|
+
started_at: row.loop_run_started_at ?? row.created_at ?? timestamp,
|
|
1792
|
+
ended_at: row.updated_at ?? null,
|
|
1793
|
+
total_cost_usd: 0,
|
|
1794
|
+
total_tokens: 0,
|
|
1795
|
+
request_count: 0,
|
|
1796
|
+
machine_id: machineId,
|
|
1797
|
+
cost_center_id: costCenterId,
|
|
1798
|
+
attribution_tag: "loops-orchestration",
|
|
1799
|
+
...account
|
|
1800
|
+
});
|
|
1801
|
+
const inputTokens = row.tokens_used;
|
|
1802
|
+
const outputTokens = 0;
|
|
1803
|
+
const assessment = assessCostFromDb(db, model, inputTokens, outputTokens);
|
|
1804
|
+
upsertRequest(db, {
|
|
1805
|
+
id: requestId,
|
|
1806
|
+
agent: "loop",
|
|
1807
|
+
session_id: sessionId,
|
|
1808
|
+
model,
|
|
1809
|
+
input_tokens: inputTokens,
|
|
1810
|
+
output_tokens: outputTokens,
|
|
1811
|
+
cache_read_tokens: 0,
|
|
1812
|
+
cache_create_tokens: 0,
|
|
1813
|
+
cost_usd: assessment.usd ?? 0,
|
|
1814
|
+
cost_basis: assessment.usd === null ? "unknown" : "estimated",
|
|
1815
|
+
duration_ms: 0,
|
|
1816
|
+
timestamp,
|
|
1817
|
+
source_request_id: requestId,
|
|
1818
|
+
machine_id: machineId,
|
|
1819
|
+
cost_center_id: costCenterId,
|
|
1820
|
+
attribution_tag: "loops-orchestration",
|
|
1821
|
+
...account
|
|
1822
|
+
});
|
|
1823
|
+
sessions.add(sessionId);
|
|
1824
|
+
requests++;
|
|
1825
|
+
setIngestState(db, "loops", row.goal_run_id, stateValue);
|
|
1826
|
+
if (verbose)
|
|
1827
|
+
console.log(`OpenLoops ${loopName}: ${row.tokens_used} orchestration tokens`);
|
|
1828
|
+
}
|
|
1829
|
+
} catch {
|
|
1830
|
+
collectionProblem("failed", "source_unreadable");
|
|
1831
|
+
return { sessions: 0, requests: 0 };
|
|
1832
|
+
} finally {
|
|
1833
|
+
loopsDb?.close();
|
|
1834
|
+
}
|
|
1835
|
+
for (const sessionId of sessions)
|
|
1836
|
+
rollupSession(db, sessionId);
|
|
1837
|
+
return { sessions: sessions.size, requests };
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// src/ingest/claude-quota.ts
|
|
1841
|
+
import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
|
|
1842
|
+
|
|
1843
|
+
// src/lib/paths.ts
|
|
1844
|
+
import { homedir as homedir9 } from "os";
|
|
1845
|
+
import { join as join9 } from "path";
|
|
1846
|
+
function getHomeDir() {
|
|
1847
|
+
return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir9();
|
|
1848
|
+
}
|
|
1849
|
+
function agentPaths() {
|
|
1850
|
+
const home = getHomeDir();
|
|
1851
|
+
return {
|
|
1852
|
+
claudeProjects: join9(home, ".claude", "projects"),
|
|
1853
|
+
claudeCredentials: join9(home, ".claude", ".credentials.json"),
|
|
1854
|
+
takumiProjects: join9(home, ".takumi", "projects"),
|
|
1855
|
+
codexDir: join9(home, ".codex"),
|
|
1856
|
+
codexDb: join9(home, ".codex", "state_5.sqlite"),
|
|
1857
|
+
codexAuth: join9(home, ".codex", "auth.json"),
|
|
1858
|
+
codexConfig: join9(home, ".codex", "config.toml"),
|
|
1859
|
+
geminiTmp: join9(home, ".gemini", "tmp"),
|
|
1860
|
+
geminiHistory: join9(home, ".gemini", "history"),
|
|
1861
|
+
opencodeMessages: join9(home, ".local", "share", "opencode", "storage", "message"),
|
|
1862
|
+
piSessions: join9(home, ".pi", "agent", "sessions"),
|
|
1863
|
+
hermesDir: join9(home, ".hermes"),
|
|
1864
|
+
hermesDb: join9(home, ".hermes", "state.db")
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// src/ingest/claude-quota.ts
|
|
1869
|
+
var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
1870
|
+
var OAUTH_BETA = "oauth-2025-04-20";
|
|
1871
|
+
function readClaudeToken() {
|
|
1872
|
+
const fromEnv = process.env["CLAUDE_OAUTH_TOKEN"] ?? process.env["ANTHROPIC_OAUTH_TOKEN"];
|
|
1873
|
+
if (fromEnv)
|
|
1874
|
+
return { token: fromEnv };
|
|
1875
|
+
const CREDENTIALS_PATH = agentPaths().claudeCredentials;
|
|
1876
|
+
if (!existsSync9(CREDENTIALS_PATH))
|
|
1877
|
+
return null;
|
|
1878
|
+
try {
|
|
1879
|
+
const creds = JSON.parse(readFileSync7(CREDENTIALS_PATH, "utf-8"));
|
|
1880
|
+
const oauth = creds.claudeAiOauth;
|
|
1881
|
+
if (!oauth?.accessToken)
|
|
1882
|
+
return null;
|
|
1883
|
+
return {
|
|
1884
|
+
token: oauth.accessToken,
|
|
1885
|
+
subscriptionType: oauth.subscriptionType,
|
|
1886
|
+
rateLimitTier: oauth.rateLimitTier
|
|
1887
|
+
};
|
|
1888
|
+
} catch {
|
|
1889
|
+
collectionProblem("failed", "provider_credential_unreadable");
|
|
1890
|
+
return null;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
async function fetchClaudeOAuthUsage(token) {
|
|
1894
|
+
try {
|
|
1895
|
+
const res = await fetch(USAGE_URL, {
|
|
1896
|
+
headers: {
|
|
1897
|
+
Authorization: `Bearer ${token}`,
|
|
1898
|
+
"anthropic-beta": OAUTH_BETA,
|
|
1899
|
+
Accept: "application/json"
|
|
1900
|
+
},
|
|
1901
|
+
signal: AbortSignal.timeout(1e4)
|
|
1902
|
+
});
|
|
1903
|
+
if (!res.ok) {
|
|
1904
|
+
collectionProblem("failed", res.status === 401 || res.status === 403 ? "provider_auth_refused" : "provider_refused");
|
|
1905
|
+
return null;
|
|
1906
|
+
}
|
|
1907
|
+
return await res.json();
|
|
1908
|
+
} catch {
|
|
1909
|
+
collectionProblem("failed", "provider_unavailable");
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
async function ingestClaudeQuota(db, verbose = false) {
|
|
1914
|
+
const auth = readClaudeToken();
|
|
1915
|
+
if (!auth) {
|
|
1916
|
+
collectionProblem("missing", "provider_credential_unavailable");
|
|
1917
|
+
if (verbose)
|
|
1918
|
+
console.log(" claude quota: skipped \u2014 no OAuth token (~/.claude/.credentials.json or CLAUDE_OAUTH_TOKEN)");
|
|
1919
|
+
return { snapshots: 0 };
|
|
1920
|
+
}
|
|
1921
|
+
const account = await resolveAccountForAgent("claude");
|
|
1922
|
+
if (!account?.account_key) {
|
|
1923
|
+
collectionProblem("unsupported", "account_identity_unavailable");
|
|
1924
|
+
return { snapshots: 0 };
|
|
1925
|
+
}
|
|
1926
|
+
const usage = await fetchClaudeOAuthUsage(auth.token);
|
|
1927
|
+
if (!usage)
|
|
1928
|
+
return { snapshots: 0 };
|
|
1929
|
+
const ctx = { provider: "anthropic", agent: "claude", account_key: account.account_key, observed_at: new Date().toISOString() };
|
|
1930
|
+
let snapshots = 0;
|
|
1931
|
+
for (const name of ["five_hour", "seven_day", "seven_day_sonnet", "seven_day_opus"]) {
|
|
1932
|
+
const window = usage[name];
|
|
1933
|
+
if (window)
|
|
1934
|
+
snapshots += quotaObservation(db, ctx, `${name}_utilization`, window.utilization, "percent", { reset_at: window.resets_at });
|
|
1935
|
+
}
|
|
1936
|
+
for (const field of ["spend", "limit", "used_credits", "monthly_limit"]) {
|
|
1937
|
+
snapshots += quotaObservation(db, ctx, `extra_usage_${field}`, usage.extra_usage?.[field], "source_units", { value_kind: "unknown" });
|
|
1938
|
+
}
|
|
1939
|
+
quotaSubscription(db, ctx, auth.rateLimitTier ?? auth.subscriptionType ?? "unknown");
|
|
1940
|
+
if (verbose)
|
|
1941
|
+
console.log(` claude quota: ${snapshots} observations`);
|
|
1942
|
+
return { snapshots };
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// src/ingest/codex-quota.ts
|
|
1946
|
+
import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
|
|
1947
|
+
var WHAM_USAGE_URL = process.env["CODEX_USAGE_URL"] ?? "https://chatgpt.com/backend-api/wham/usage";
|
|
1948
|
+
function readCodexAuth() {
|
|
1949
|
+
const fromEnv = process.env["CODEX_OAUTH_TOKEN"];
|
|
1950
|
+
if (fromEnv)
|
|
1951
|
+
return { token: fromEnv, authMode: "chatgpt" };
|
|
1952
|
+
const authPath = agentPaths().codexAuth;
|
|
1953
|
+
if (!existsSync10(authPath))
|
|
1954
|
+
return null;
|
|
1955
|
+
try {
|
|
1956
|
+
const auth = JSON.parse(readFileSync8(authPath, "utf-8"));
|
|
1957
|
+
const token = auth.tokens?.access_token;
|
|
1958
|
+
if (!token)
|
|
1959
|
+
return null;
|
|
1960
|
+
return {
|
|
1961
|
+
token,
|
|
1962
|
+
accountId: auth.tokens?.account_id,
|
|
1963
|
+
authMode: auth.auth_mode ?? "chatgpt"
|
|
1964
|
+
};
|
|
1965
|
+
} catch {
|
|
1966
|
+
collectionProblem("failed", "provider_credential_unreadable");
|
|
1967
|
+
return null;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
async function fetchCodexUsage(token, accountId) {
|
|
1971
|
+
try {
|
|
1972
|
+
const headers = {
|
|
1973
|
+
Authorization: `Bearer ${token}`,
|
|
1974
|
+
Accept: "application/json",
|
|
1975
|
+
"User-Agent": "economy-cli"
|
|
1976
|
+
};
|
|
1977
|
+
if (accountId)
|
|
1978
|
+
headers["ChatGPT-Account-Id"] = accountId;
|
|
1979
|
+
const res = await fetch(WHAM_USAGE_URL, {
|
|
1980
|
+
headers,
|
|
1981
|
+
signal: AbortSignal.timeout(1e4)
|
|
1982
|
+
});
|
|
1983
|
+
if (!res.ok) {
|
|
1984
|
+
collectionProblem("failed", res.status === 401 || res.status === 403 ? "provider_auth_refused" : "provider_refused");
|
|
1985
|
+
return null;
|
|
1986
|
+
}
|
|
1987
|
+
return await res.json();
|
|
1988
|
+
} catch {
|
|
1989
|
+
collectionProblem("failed", "provider_unavailable");
|
|
1990
|
+
return null;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
async function ingestCodexQuota(db, verbose = false) {
|
|
1994
|
+
const auth = readCodexAuth();
|
|
1995
|
+
if (!auth) {
|
|
1996
|
+
collectionProblem("missing", "provider_credential_unavailable");
|
|
1997
|
+
if (verbose)
|
|
1998
|
+
console.log(" codex quota: skipped \u2014 no ~/.codex/auth.json or CODEX_OAUTH_TOKEN");
|
|
1999
|
+
return { snapshots: 0 };
|
|
2000
|
+
}
|
|
2001
|
+
if (auth.authMode === "api_key" || auth.authMode === "api") {
|
|
2002
|
+
collectionProblem("unsupported", "subscription_quota_not_applicable");
|
|
2003
|
+
if (verbose)
|
|
2004
|
+
console.log(" codex quota: skipped \u2014 API key mode (no subscription quota)");
|
|
2005
|
+
return { snapshots: 0 };
|
|
2006
|
+
}
|
|
2007
|
+
const account = await resolveAccountForAgent("codex");
|
|
2008
|
+
const account_key = account?.account_key ?? (auth.accountId ? `openai:${auth.accountId}` : "");
|
|
2009
|
+
if (!account_key) {
|
|
2010
|
+
collectionProblem("unsupported", "account_identity_unavailable");
|
|
2011
|
+
return { snapshots: 0 };
|
|
2012
|
+
}
|
|
2013
|
+
const usage = await fetchCodexUsage(auth.token, auth.accountId);
|
|
2014
|
+
if (!usage)
|
|
2015
|
+
return { snapshots: 0 };
|
|
2016
|
+
const ctx = { provider: "openai", agent: "codex", account_key, observed_at: new Date().toISOString() };
|
|
2017
|
+
let snapshots = 0;
|
|
2018
|
+
for (const [name, window] of Object.entries({ primary_window: usage.rate_limit?.primary_window, secondary_window: usage.rate_limit?.secondary_window })) {
|
|
2019
|
+
if (window)
|
|
2020
|
+
snapshots += quotaObservation(db, ctx, `${name}_utilization`, window.used_percent, "percent", { reset_at: window.reset_at, window_seconds: window.limit_window_seconds });
|
|
2021
|
+
}
|
|
2022
|
+
snapshots += quotaObservation(db, ctx, "credits_balance", usage.credits?.balance, "credits");
|
|
2023
|
+
quotaSubscription(db, ctx, usage.plan_type ?? "unknown");
|
|
2024
|
+
if (verbose)
|
|
2025
|
+
console.log(` codex quota: ${snapshots} observations`);
|
|
2026
|
+
return { snapshots };
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// src/lib/sync-all.ts
|
|
2030
|
+
var loopsRefusalPrinted = false;
|
|
2031
|
+
function announceLoopsRefusal() {
|
|
2032
|
+
if (loopsRefusalPrinted)
|
|
2033
|
+
return;
|
|
2034
|
+
loopsRefusalPrinted = true;
|
|
2035
|
+
process.stderr.write("economy: loops ingest skipped \u2014 it reads the loops app's on-box SQLite " + "(~/.hasna/loops/loops.db), which a hosted economy client never treats as fleet data. " + "Set HASNA_ECONOMY_LOCAL=1 to run economy on the on-box store (where the read is allowed), " + `or wait for the hosted loops read (PORT-TO-API).
|
|
2036
|
+
`);
|
|
2037
|
+
}
|
|
2038
|
+
async function syncAll(db, opts = {}) {
|
|
2039
|
+
const anySpecific = Boolean(opts.claude || opts.takumi || opts.codex || opts.gemini || opts.opencode || opts.cursor || opts.pi || opts.hermes || opts.loops);
|
|
2040
|
+
const all = !anySpecific;
|
|
2041
|
+
const result = { deduped: 0 };
|
|
2042
|
+
const collections = [];
|
|
2043
|
+
async function collect(key, run, empty) {
|
|
2044
|
+
const item = await collectWithOutcome(key, run, empty);
|
|
2045
|
+
result[key] = item.result;
|
|
2046
|
+
collections.push(item.outcome);
|
|
2047
|
+
const machine_id = getMachineId();
|
|
2048
|
+
const source = key;
|
|
2049
|
+
const event = db.prepare("SELECT MAX(timestamp) AS latest FROM economy_effective_requests WHERE agent = ?").get(key);
|
|
2050
|
+
upsertScanReceipt(db, { ...item.outcome, machine_id, id: scanReceiptId({ machine_id, source, attempted_at: item.outcome.attempted_at }), latest_event_at: event?.latest ?? null });
|
|
2051
|
+
}
|
|
2052
|
+
if (all || opts.claude) {
|
|
2053
|
+
await collect("claude", () => ingestClaude(db, opts.verbose, opts.projectsDir), { files: 0, sessions: 0, requests: 0 });
|
|
2054
|
+
await collect("claudeQuota", () => ingestClaudeQuota(db, opts.verbose), { snapshots: 0 });
|
|
2055
|
+
}
|
|
2056
|
+
if (all || opts.takumi)
|
|
2057
|
+
await collect("takumi", () => ingestTakumi(db, opts.verbose, opts.projectsDir), { files: 0, sessions: 0, requests: 0 });
|
|
2058
|
+
if (all || opts.codex) {
|
|
2059
|
+
await collect("codex", () => ingestCodex(db, opts.verbose), { sessions: 0, requests: 0 });
|
|
2060
|
+
await collect("codexQuota", () => ingestCodexQuota(db, opts.verbose), { snapshots: 0 });
|
|
2061
|
+
}
|
|
2062
|
+
if (all || opts.gemini)
|
|
2063
|
+
await collect("gemini", () => ingestGemini(db, opts.verbose), { sessions: 0, requests: 0 });
|
|
2064
|
+
if (all || opts.opencode)
|
|
2065
|
+
await collect("opencode", () => ingestOpenCode(db, opts.verbose), { files: 0, sessions: 0, requests: 0 });
|
|
2066
|
+
if (all || opts.cursor)
|
|
2067
|
+
await collect("cursor", () => ingestCursor(db, opts.verbose), { snapshots: 0, requests: 0 });
|
|
2068
|
+
if (all || opts.pi)
|
|
2069
|
+
await collect("pi", () => ingestPi(db, opts.verbose), { files: 0, sessions: 0, requests: 0 });
|
|
2070
|
+
if (all || opts.hermes)
|
|
2071
|
+
await collect("hermes", () => ingestHermes(db, opts.verbose), { sessions: 0, requests: 0 });
|
|
2072
|
+
if (all || opts.loops)
|
|
2073
|
+
await collect("loops", async () => {
|
|
2074
|
+
if (opts.noCrossAppLocalReads) {
|
|
2075
|
+
announceLoopsRefusal();
|
|
2076
|
+
collectionProblem("unsupported", "cross_app_local_read_refused");
|
|
2077
|
+
return { sessions: 0, requests: 0 };
|
|
2078
|
+
}
|
|
2079
|
+
return ingestLoops(db, opts.verbose);
|
|
2080
|
+
}, { sessions: 0, requests: 0 });
|
|
2081
|
+
result.collections = collections;
|
|
2082
|
+
return result;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
export { agentPaths, syncAll };
|