@revealyst/agent 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +59 -0
- package/dist/cli.js +126 -0
- package/dist/config.js +85 -0
- package/dist/discover.js +74 -0
- package/dist/identity.js +65 -0
- package/dist/index.js +95 -0
- package/dist/parse.js +152 -0
- package/dist/prices.js +59 -0
- package/dist/push.js +49 -0
- package/dist/stream.js +60 -0
- package/dist/summarize.js +180 -0
- package/dist/sync-run.js +94 -0
- package/dist/types.js +8 -0
- package/dist/window.js +14 -0
- package/package.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2026 Revealyst. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software is provided for use with the Revealyst service. Use of the
|
|
4
|
+
Revealyst service is governed by the Revealyst Terms of Service:
|
|
5
|
+
https://revealyst.com/legal/terms
|
|
6
|
+
|
|
7
|
+
Redistribution or modification of this software outside the above is not
|
|
8
|
+
permitted without prior written permission.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# revealyst-agent
|
|
2
|
+
|
|
3
|
+
The Revealyst Agent — the sanctioned local-ingest path for Claude Code
|
|
4
|
+
(product spec §10). Reads your local Claude Code session logs, summarizes
|
|
5
|
+
them **on your machine** into daily metric records, and pushes only those
|
|
6
|
+
records to Revealyst with a device token.
|
|
7
|
+
|
|
8
|
+
## What leaves your machine — and what never does
|
|
9
|
+
|
|
10
|
+
Transmitted (the full list — enforced by the privacy test suite):
|
|
11
|
+
- daily counts: sessions, prompts, active days
|
|
12
|
+
- daily token totals (input / output / cache read / cache write)
|
|
13
|
+
- per-model request/token counts (model *ids* only)
|
|
14
|
+
- estimated spend (public list prices; marked as an estimate)
|
|
15
|
+
- a 24-slot hourly activity histogram + peak session concurrency per day
|
|
16
|
+
- your Claude account email — **only** if you opt in with
|
|
17
|
+
`--consent-identity`; otherwise a one-way device hash
|
|
18
|
+
|
|
19
|
+
Never transmitted: prompt or completion text, tool inputs/outputs, file
|
|
20
|
+
contents or paths, session titles, working directories, git branches,
|
|
21
|
+
session ids. The parser structurally cannot carry these fields
|
|
22
|
+
(`src/parse.ts`), and `tests/privacy.test.ts` proves no content survives
|
|
23
|
+
into an outgoing payload using sentinel-seeded fixtures.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
revealyst-agent login --token rva1.… [--api <url>] [--consent-identity]
|
|
29
|
+
revealyst-agent sync [--days 30] [--dry-run]
|
|
30
|
+
revealyst-agent status
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Create the device token in Revealyst → Connections → Revealyst Agent (it is
|
|
34
|
+
shown once; re-issuing rotates it). `sync --dry-run` shows exactly what
|
|
35
|
+
would be pushed without pushing. Schedule `sync` daily with Task Scheduler
|
|
36
|
+
(Windows) or cron if you want hands-off updates.
|
|
37
|
+
|
|
38
|
+
For CI/headless one-shot runs, `sync` also honors `REVEALYST_TOKEN` (and
|
|
39
|
+
optionally `REVEALYST_API`) without persisting anything — interactive users
|
|
40
|
+
should prefer `login`, which keeps the token out of shell history. An
|
|
41
|
+
env-supplied token is always device-scoped: identity consent applies only
|
|
42
|
+
to the `login` that granted it, never to env runs.
|
|
43
|
+
|
|
44
|
+
`sync` pins the pushed window to the earliest day your local logs still
|
|
45
|
+
cover, so a wide `--days` lookback can never erase previously-synced days
|
|
46
|
+
whose logs have since been pruned. If no activity falls inside the window,
|
|
47
|
+
nothing is pushed (and nothing is deleted).
|
|
48
|
+
|
|
49
|
+
Log locations scanned: `%USERPROFILE%\.claude\projects\` (or
|
|
50
|
+
`~/.claude/projects/`), honoring `CLAUDE_CONFIG_DIR` (multi-path). Local
|
|
51
|
+
logs retain ~30 days by default, so backfill depth is bounded by that.
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
Lives in the Revealyst monorepo; tests run from the repo root (`npm test`)
|
|
56
|
+
against recorded-shape fixtures in `fixtures/vendor-payloads/claude-code-local/`.
|
|
57
|
+
Dev invocation without a build: `npm run agent -- sync --dry-run`.
|
|
58
|
+
Build the distributable CLI: `npm run build` in this directory (emits
|
|
59
|
+
`dist/`, CommonJS, `revealyst-agent` bin).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Revealyst Agent CLI — the sanctioned local-ingest path (spec §10).
|
|
4
|
+
//
|
|
5
|
+
// revealyst-agent login --token rva1.… [--api <url>] [--consent-identity]
|
|
6
|
+
// revealyst-agent sync [--days 30] [--dry-run]
|
|
7
|
+
// revealyst-agent status
|
|
8
|
+
//
|
|
9
|
+
// Everything printed here is structural (counts, days, masked token) —
|
|
10
|
+
// never log content, never file paths of individual sessions.
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
const node_os_1 = require("node:os");
|
|
13
|
+
const node_util_1 = require("node:util");
|
|
14
|
+
const config_1 = require("./config");
|
|
15
|
+
const discover_1 = require("./discover");
|
|
16
|
+
const push_1 = require("./push");
|
|
17
|
+
const stream_1 = require("./stream");
|
|
18
|
+
const sync_run_1 = require("./sync-run");
|
|
19
|
+
const AGENT_VERSION = "0.2.0";
|
|
20
|
+
const DEFAULT_API = "https://app.revealyst.com";
|
|
21
|
+
const DEFAULT_DAYS = 30;
|
|
22
|
+
function fail(message) {
|
|
23
|
+
console.error(`revealyst-agent: ${message}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
function deviceSeed() {
|
|
27
|
+
let user = "unknown";
|
|
28
|
+
try {
|
|
29
|
+
user = (0, node_os_1.userInfo)().username;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Some environments (restricted containers) cannot resolve the user.
|
|
33
|
+
}
|
|
34
|
+
return `${(0, node_os_1.hostname)()}:${user}`;
|
|
35
|
+
}
|
|
36
|
+
async function login(args) {
|
|
37
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
38
|
+
args,
|
|
39
|
+
options: {
|
|
40
|
+
token: { type: "string" },
|
|
41
|
+
api: { type: "string", default: DEFAULT_API },
|
|
42
|
+
"consent-identity": { type: "boolean", default: false },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const token = values.token;
|
|
46
|
+
if (!token || !(0, config_1.isValidTokenShape)(token)) {
|
|
47
|
+
fail("a device token is required: revealyst-agent login --token rva1.… " +
|
|
48
|
+
"(create one in Revealyst → Connections → Revealyst Agent)");
|
|
49
|
+
}
|
|
50
|
+
(0, config_1.saveConfig)((0, node_os_1.homedir)(), {
|
|
51
|
+
token,
|
|
52
|
+
apiBaseUrl: values.api ?? DEFAULT_API,
|
|
53
|
+
consentIdentity: values["consent-identity"] === true,
|
|
54
|
+
});
|
|
55
|
+
console.log(`Logged in (${(0, config_1.maskToken)(token)} → ${values.api}).`);
|
|
56
|
+
console.log(values["consent-identity"]
|
|
57
|
+
? "Identity: your Claude account email will be attached (person-level metrics)."
|
|
58
|
+
: "Identity: device-scoped only. Re-run login with --consent-identity for person-level metrics.");
|
|
59
|
+
}
|
|
60
|
+
async function sync(args) {
|
|
61
|
+
const { values } = (0, node_util_1.parseArgs)({
|
|
62
|
+
args,
|
|
63
|
+
options: {
|
|
64
|
+
days: { type: "string", default: String(DEFAULT_DAYS) },
|
|
65
|
+
"dry-run": { type: "boolean", default: false },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const days = Number(values.days);
|
|
69
|
+
if (!Number.isInteger(days) || days < 1 || days > 90) {
|
|
70
|
+
fail("--days must be an integer between 1 and 90");
|
|
71
|
+
}
|
|
72
|
+
const dryRun = values["dry-run"] === true;
|
|
73
|
+
const outcome = await (0, sync_run_1.runSync)({ days, dryRun }, {
|
|
74
|
+
homeDir: (0, node_os_1.homedir)(),
|
|
75
|
+
env: process.env,
|
|
76
|
+
defaultApi: DEFAULT_API,
|
|
77
|
+
agentVersion: AGENT_VERSION,
|
|
78
|
+
deviceSeed: deviceSeed(),
|
|
79
|
+
now: () => new Date(),
|
|
80
|
+
listFiles: () => (0, discover_1.listSessionFiles)((0, discover_1.claudeConfigDirs)(process.env, (0, node_os_1.homedir)())),
|
|
81
|
+
parseFiles: stream_1.parseSessionFilesStreaming,
|
|
82
|
+
push: push_1.pushBatch,
|
|
83
|
+
log: (message) => console.log(message),
|
|
84
|
+
warn: (message) => console.error(`revealyst-agent: ${message}`),
|
|
85
|
+
});
|
|
86
|
+
if (outcome.kind === "fail") {
|
|
87
|
+
fail(outcome.message);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function status() {
|
|
91
|
+
const resolved = (0, config_1.resolveConfig)(process.env, (0, node_os_1.homedir)(), DEFAULT_API);
|
|
92
|
+
const dirs = (0, discover_1.claudeConfigDirs)(process.env, (0, node_os_1.homedir)());
|
|
93
|
+
const files = (0, discover_1.listSessionFiles)(dirs);
|
|
94
|
+
console.log(`revealyst-agent ${AGENT_VERSION}`);
|
|
95
|
+
if (resolved.source === "invalid-env") {
|
|
96
|
+
console.log("Login: REVEALYST_TOKEN is set but malformed");
|
|
97
|
+
}
|
|
98
|
+
else if (resolved.source === "none") {
|
|
99
|
+
console.log("Login: not configured");
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const { config } = resolved;
|
|
103
|
+
const suffix = resolved.source === "env" ? " [from REVEALYST_TOKEN env]" : "";
|
|
104
|
+
console.log(`Login: ${(0, config_1.maskToken)(config.token)} → ${config.apiBaseUrl} ` +
|
|
105
|
+
`(identity consent: ${config.consentIdentity ? "yes" : "no"})${suffix}`);
|
|
106
|
+
}
|
|
107
|
+
console.log(`Log dirs: ${dirs.length} (${files.length} session files)`);
|
|
108
|
+
}
|
|
109
|
+
async function main() {
|
|
110
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
111
|
+
switch (command) {
|
|
112
|
+
case "login":
|
|
113
|
+
return login(rest);
|
|
114
|
+
case "sync":
|
|
115
|
+
return sync(rest);
|
|
116
|
+
case "status":
|
|
117
|
+
return status();
|
|
118
|
+
default:
|
|
119
|
+
console.log("usage: revealyst-agent <login|sync|status>\n" +
|
|
120
|
+
" login --token rva1.… [--api <url>] [--consent-identity]\n" +
|
|
121
|
+
" sync [--days 30] [--dry-run]\n" +
|
|
122
|
+
" status");
|
|
123
|
+
process.exit(command ? 1 : 0);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
void main();
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Local agent config: <home>/.revealyst/agent.json — the device token, the
|
|
3
|
+
// API base URL, and the identity-consent flag captured at `login`. The
|
|
4
|
+
// token is a bearer secret: the file is written 0o600 (owner-only on
|
|
5
|
+
// POSIX; on Windows the user-profile ACL covers it).
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.configPath = configPath;
|
|
8
|
+
exports.isValidTokenShape = isValidTokenShape;
|
|
9
|
+
exports.maskToken = maskToken;
|
|
10
|
+
exports.loadConfig = loadConfig;
|
|
11
|
+
exports.resolveConfig = resolveConfig;
|
|
12
|
+
exports.saveConfig = saveConfig;
|
|
13
|
+
const node_fs_1 = require("node:fs");
|
|
14
|
+
const node_path_1 = require("node:path");
|
|
15
|
+
function configPath(homeDir) {
|
|
16
|
+
return (0, node_path_1.join)(homeDir, ".revealyst", "agent.json");
|
|
17
|
+
}
|
|
18
|
+
/** Structural token check (mirrors the server's parseAgentToken; the
|
|
19
|
+
* server remains the authority). */
|
|
20
|
+
function isValidTokenShape(token) {
|
|
21
|
+
const parts = token.split(".");
|
|
22
|
+
return (parts.length === 4 &&
|
|
23
|
+
parts[0] === "rva1" &&
|
|
24
|
+
parts.slice(1).every((p) => p.length > 0));
|
|
25
|
+
}
|
|
26
|
+
function maskToken(token) {
|
|
27
|
+
return token.length > 8 ? `rva1.…${token.slice(-4)}` : "…";
|
|
28
|
+
}
|
|
29
|
+
function loadConfig(homeDir) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(configPath(homeDir), "utf8"));
|
|
32
|
+
if (typeof parsed.token !== "string" ||
|
|
33
|
+
typeof parsed.apiBaseUrl !== "string") {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
token: parsed.token,
|
|
38
|
+
apiBaseUrl: parsed.apiBaseUrl,
|
|
39
|
+
consentIdentity: parsed.consentIdentity === true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Runtime config resolution: `REVEALYST_TOKEN` (with optional
|
|
47
|
+
* `REVEALYST_API`) takes precedence over the login-written file, is never
|
|
48
|
+
* persisted, and is intended for CI/headless one-shot runs — interactive
|
|
49
|
+
* users should prefer `login`, which keeps the token out of shell history
|
|
50
|
+
* and process listings. A malformed env token fails loudly rather than
|
|
51
|
+
* silently falling back to the file (the caller's token intent was
|
|
52
|
+
* explicit).
|
|
53
|
+
*
|
|
54
|
+
* An env credential is FULLY env-defined — it never inherits anything
|
|
55
|
+
* from a leftover agent.json belonging to some other login: not the api
|
|
56
|
+
* base (a prod token must not be replayed at a stale staging host) and
|
|
57
|
+
* not identity consent (consent was given for THAT login's token, not
|
|
58
|
+
* this one — env runs are always device-scoped). */
|
|
59
|
+
function resolveConfig(env, homeDir, defaultApiBaseUrl) {
|
|
60
|
+
const envToken = env.REVEALYST_TOKEN;
|
|
61
|
+
if (typeof envToken === "string" && envToken.length > 0) {
|
|
62
|
+
if (!isValidTokenShape(envToken)) {
|
|
63
|
+
return { source: "invalid-env" };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
source: "env",
|
|
67
|
+
config: {
|
|
68
|
+
token: envToken,
|
|
69
|
+
apiBaseUrl: env.REVEALYST_API ?? defaultApiBaseUrl,
|
|
70
|
+
consentIdentity: false,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const fileConfig = loadConfig(homeDir);
|
|
75
|
+
return fileConfig
|
|
76
|
+
? { source: "file", config: fileConfig }
|
|
77
|
+
: { source: "none" };
|
|
78
|
+
}
|
|
79
|
+
function saveConfig(homeDir, config) {
|
|
80
|
+
const path = configPath(homeDir);
|
|
81
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
|
|
82
|
+
(0, node_fs_1.writeFileSync)(path, `${JSON.stringify(config, null, 2)}\n`, {
|
|
83
|
+
mode: 0o600,
|
|
84
|
+
});
|
|
85
|
+
}
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Session-log discovery (docs/connector-facts.md §5):
|
|
3
|
+
// Windows %USERPROFILE%\.claude\projects\<encoded-cwd>\<sessionId>.jsonl
|
|
4
|
+
// mac/linux ~/.claude/projects/...
|
|
5
|
+
// Also ~/.config/claude/projects (ccusage parity)
|
|
6
|
+
// Override CLAUDE_CONFIG_DIR — COMMA-separated multi-path (ccusage), NOT
|
|
7
|
+
// the OS path delimiter (which would shred a Windows "C:\…" path
|
|
8
|
+
// on POSIX and disagrees with the documented format).
|
|
9
|
+
// Subagent sidechains live at projects/<proj>/<sessionId>/subagents/*.jsonl
|
|
10
|
+
// and MUST be included (they carry their own usage) — hence a recursive scan.
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.claudeConfigDirs = claudeConfigDirs;
|
|
13
|
+
exports.listSessionFiles = listSessionFiles;
|
|
14
|
+
const node_fs_1 = require("node:fs");
|
|
15
|
+
const node_path_1 = require("node:path");
|
|
16
|
+
/** Config dirs to scan. §5: "Agent must scan ~/.claude/projects,
|
|
17
|
+
* ~/.config/claude/projects, and every CLAUDE_CONFIG_DIR path." The
|
|
18
|
+
* override is additive (ccusage scans defaults + override), comma-split,
|
|
19
|
+
* and the set is de-duplicated. Pure over its inputs for testability. */
|
|
20
|
+
function claudeConfigDirs(env, homeDir) {
|
|
21
|
+
const dirs = [(0, node_path_1.join)(homeDir, ".claude"), (0, node_path_1.join)(homeDir, ".config", "claude")];
|
|
22
|
+
const override = env.CLAUDE_CONFIG_DIR;
|
|
23
|
+
if (override && override.trim() !== "") {
|
|
24
|
+
for (const path of override.split(",")) {
|
|
25
|
+
const trimmed = path.trim();
|
|
26
|
+
if (trimmed !== "") {
|
|
27
|
+
dirs.push(trimmed);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [...new Set(dirs)];
|
|
32
|
+
}
|
|
33
|
+
/** Real layouts nest: sessions at projects/<proj>/*.jsonl, but subagent
|
|
34
|
+
* sidechains at projects/<proj>/<sessionId>/subagents/*.jsonl (verified on
|
|
35
|
+
* the founder's machine, where a flat scan missed 97 of 129 files — i.e.
|
|
36
|
+
* ALL sidechain usage). Bounded depth guards against symlink cycles. */
|
|
37
|
+
const MAX_SCAN_DEPTH = 6;
|
|
38
|
+
/** All session .jsonl files under <dir>/projects/**, recursively, for each
|
|
39
|
+
* config dir. Missing dirs are skipped silently — an empty machine is not
|
|
40
|
+
* an error. */
|
|
41
|
+
function listSessionFiles(configDirs) {
|
|
42
|
+
const refs = [];
|
|
43
|
+
const walk = (dir, depth) => {
|
|
44
|
+
if (depth > MAX_SCAN_DEPTH) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = (0, node_fs_1.readdirSync)(dir);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const path = (0, node_path_1.join)(dir, entry);
|
|
56
|
+
try {
|
|
57
|
+
const stat = (0, node_fs_1.statSync)(path);
|
|
58
|
+
if (stat.isDirectory()) {
|
|
59
|
+
walk(path, depth + 1);
|
|
60
|
+
}
|
|
61
|
+
else if (entry.endsWith(".jsonl")) {
|
|
62
|
+
refs.push({ path, sizeBytes: stat.size, mtimeMs: stat.mtimeMs });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Deleted between readdir and stat — skip.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
for (const dir of configDirs) {
|
|
71
|
+
walk((0, node_path_1.join)(dir, "projects"), 0);
|
|
72
|
+
}
|
|
73
|
+
return refs.sort((a, b) => a.path.localeCompare(b.path));
|
|
74
|
+
}
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Local identity resolution (docs/connector-facts.md §5): the machine's
|
|
3
|
+
// user via the consented oauthAccount in <home>/.claude.json. When absent
|
|
4
|
+
// (or consent withheld), fall back to a stable device-scoped ACCOUNT
|
|
5
|
+
// subject — never fabricate a person (review invariant b). Attribution
|
|
6
|
+
// follows the subject kind: person ⇢ "person", device ⇢ "account".
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.readOauthEmail = readOauthEmail;
|
|
9
|
+
exports.resolveLocalIdentity = resolveLocalIdentity;
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const node_fs_1 = require("node:fs");
|
|
12
|
+
const node_path_1 = require("node:path");
|
|
13
|
+
/** Reads oauthAccount.emailAddress from <home>/.claude.json. Never throws;
|
|
14
|
+
* any read/parse failure means "no identity available". */
|
|
15
|
+
function readOauthEmail(homeDir) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(homeDir, ".claude.json"), "utf8");
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
const email = parsed.oauthAccount?.emailAddress;
|
|
20
|
+
if (typeof email !== "string" || !email.includes("@")) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const displayName = parsed.oauthAccount?.displayName;
|
|
24
|
+
return {
|
|
25
|
+
email: email.toLowerCase(),
|
|
26
|
+
displayName: typeof displayName === "string" ? displayName : null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* @param consentIdentity user agreed (at `login`) to attach their Claude
|
|
35
|
+
* account email; without it the device-scoped fallback is used even when
|
|
36
|
+
* the email is readable.
|
|
37
|
+
* @param deviceSeed stable machine-scoped seed (e.g. hostname+username);
|
|
38
|
+
* only its hash ever leaves the machine.
|
|
39
|
+
*/
|
|
40
|
+
function resolveLocalIdentity(homeDir, consentIdentity, deviceSeed) {
|
|
41
|
+
if (consentIdentity) {
|
|
42
|
+
const oauth = readOauthEmail(homeDir);
|
|
43
|
+
if (oauth) {
|
|
44
|
+
return {
|
|
45
|
+
descriptor: {
|
|
46
|
+
kind: "person",
|
|
47
|
+
externalId: oauth.email,
|
|
48
|
+
email: oauth.email,
|
|
49
|
+
displayName: oauth.displayName,
|
|
50
|
+
},
|
|
51
|
+
attribution: "person",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const hash = (0, node_crypto_1.createHash)("sha256").update(deviceSeed).digest("hex");
|
|
56
|
+
return {
|
|
57
|
+
descriptor: {
|
|
58
|
+
kind: "account",
|
|
59
|
+
externalId: `device:${hash.slice(0, 16)}`,
|
|
60
|
+
email: null,
|
|
61
|
+
displayName: null,
|
|
62
|
+
},
|
|
63
|
+
attribution: "account",
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Composition root for the pure pipeline: session-file contents in →
|
|
3
|
+
// ingest-ready AgentIngestRequest out. File I/O stays in the caller (CLI /
|
|
4
|
+
// tests), so this module remains fixture-testable end-to-end.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.summarize = exports.SUMMARIZER_VERSION = exports.parseSessionFilesStreaming = exports.parseSessionContent = exports.createSessionParser = exports.resolveLocalIdentity = exports.readOauthEmail = exports.listSessionFiles = exports.claudeConfigDirs = void 0;
|
|
7
|
+
exports.buildIngestRequest = buildIngestRequest;
|
|
8
|
+
const parse_1 = require("./parse");
|
|
9
|
+
const prices_1 = require("./prices");
|
|
10
|
+
const summarize_1 = require("./summarize");
|
|
11
|
+
var discover_1 = require("./discover");
|
|
12
|
+
Object.defineProperty(exports, "claudeConfigDirs", { enumerable: true, get: function () { return discover_1.claudeConfigDirs; } });
|
|
13
|
+
Object.defineProperty(exports, "listSessionFiles", { enumerable: true, get: function () { return discover_1.listSessionFiles; } });
|
|
14
|
+
var identity_1 = require("./identity");
|
|
15
|
+
Object.defineProperty(exports, "readOauthEmail", { enumerable: true, get: function () { return identity_1.readOauthEmail; } });
|
|
16
|
+
Object.defineProperty(exports, "resolveLocalIdentity", { enumerable: true, get: function () { return identity_1.resolveLocalIdentity; } });
|
|
17
|
+
var parse_2 = require("./parse");
|
|
18
|
+
Object.defineProperty(exports, "createSessionParser", { enumerable: true, get: function () { return parse_2.createSessionParser; } });
|
|
19
|
+
Object.defineProperty(exports, "parseSessionContent", { enumerable: true, get: function () { return parse_2.parseSessionContent; } });
|
|
20
|
+
var stream_1 = require("./stream");
|
|
21
|
+
Object.defineProperty(exports, "parseSessionFilesStreaming", { enumerable: true, get: function () { return stream_1.parseSessionFilesStreaming; } });
|
|
22
|
+
var prices_2 = require("./prices");
|
|
23
|
+
Object.defineProperty(exports, "SUMMARIZER_VERSION", { enumerable: true, get: function () { return prices_2.SUMMARIZER_VERSION; } });
|
|
24
|
+
var summarize_2 = require("./summarize");
|
|
25
|
+
Object.defineProperty(exports, "summarize", { enumerable: true, get: function () { return summarize_2.summarize; } });
|
|
26
|
+
/** Pin the declared window's start to the earliest surviving event day.
|
|
27
|
+
* The server treats the declared window as authoritative (delete-then-
|
|
28
|
+
* upsert), so a lookback wider than local log retention would DELETE
|
|
29
|
+
* previously-captured days whose logs are pruned and upsert nothing in
|
|
30
|
+
* their place (plan R2 / research Fix 1). Pinning is global across all
|
|
31
|
+
* config dirs (events are already flattened). With no events at all the
|
|
32
|
+
* requested window passes through — callers must not push such a batch
|
|
33
|
+
* (an empty authoritative window is pure history destruction; the CLI
|
|
34
|
+
* aborts on zero records). */
|
|
35
|
+
function pinWindow(window, events) {
|
|
36
|
+
// Numeric min first — one Date/ISO conversion total, not one per event.
|
|
37
|
+
let earliestMs = Infinity;
|
|
38
|
+
for (const event of events) {
|
|
39
|
+
if (event.timestampMs < earliestMs) {
|
|
40
|
+
earliestMs = event.timestampMs;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!Number.isFinite(earliestMs)) {
|
|
44
|
+
return window;
|
|
45
|
+
}
|
|
46
|
+
const earliest = (0, summarize_1.utcDay)(earliestMs);
|
|
47
|
+
if (earliest <= window.start) {
|
|
48
|
+
return window;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
start: earliest > window.end ? window.end : earliest,
|
|
52
|
+
end: window.end,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function buildIngestRequest(opts) {
|
|
56
|
+
const contents = opts.sessionContents ?? [];
|
|
57
|
+
// Don't copy the (potentially multi-million-entry) streamed events array
|
|
58
|
+
// unless there are string contents to merge in.
|
|
59
|
+
const events = contents.length > 0 ? [...(opts.parsed?.events ?? [])] : (opts.parsed?.events ?? []);
|
|
60
|
+
let skippedLines = opts.parsed?.skippedLines ?? 0;
|
|
61
|
+
let unknownTypes = opts.parsed?.unknownTypes ?? 0;
|
|
62
|
+
for (const content of contents) {
|
|
63
|
+
const result = (0, parse_1.parseSessionContent)(content);
|
|
64
|
+
events.push(...result.events);
|
|
65
|
+
skippedLines += result.skippedLines;
|
|
66
|
+
unknownTypes += result.unknownTypes;
|
|
67
|
+
}
|
|
68
|
+
const window = pinWindow(opts.window, events);
|
|
69
|
+
const { records, signals, gaps } = (0, summarize_1.summarize)(events, {
|
|
70
|
+
subject: {
|
|
71
|
+
kind: opts.identity.descriptor.kind,
|
|
72
|
+
externalId: opts.identity.descriptor.externalId,
|
|
73
|
+
},
|
|
74
|
+
attribution: opts.identity.attribution,
|
|
75
|
+
window,
|
|
76
|
+
});
|
|
77
|
+
const allGaps = [...gaps];
|
|
78
|
+
if (skippedLines > 0 || unknownTypes > 0) {
|
|
79
|
+
allGaps.push({
|
|
80
|
+
kind: "other",
|
|
81
|
+
detail: `log parse drift: ${skippedLines} lines skipped, ${unknownTypes} unknown record types`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
agentVersion: opts.agentVersion,
|
|
86
|
+
summarizerVersion: prices_1.SUMMARIZER_VERSION,
|
|
87
|
+
// The PINNED window — never the requested one. batch.window is the
|
|
88
|
+
// range the server deletes; it must match what summarize() covered.
|
|
89
|
+
window,
|
|
90
|
+
subjects: [opts.identity.descriptor],
|
|
91
|
+
records,
|
|
92
|
+
signals,
|
|
93
|
+
gaps: allGaps,
|
|
94
|
+
};
|
|
95
|
+
}
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Lenient structural parser for Claude Code session JSONL
|
|
3
|
+
// (docs/connector-facts.md §5). THE PRIVACY LINE LIVES HERE: a ParsedEvent
|
|
4
|
+
// carries only allowlisted structural fields — type, ids, timestamps, the
|
|
5
|
+
// model id, usage NUMBERS, and block-type presence. Denylisted fields
|
|
6
|
+
// (content, tool output, titles, paths, branches) are never read at all.
|
|
7
|
+
// The one free-text field the §5 allowlist permits, `message.model`, IS
|
|
8
|
+
// transmitted (as a metric `dim`), but it's vendor free text, so
|
|
9
|
+
// sanitizeModel bounds it — charset-clamped to [A-Za-z0-9._:-] and capped
|
|
10
|
+
// at 64 chars — so it cannot carry spaces, punctuation, newlines, a URL,
|
|
11
|
+
// JSON, or a large payload. We can't prove a string is a "real" model
|
|
12
|
+
// without a brittle hardcoded list, so this is a BOUND, not a semantic
|
|
13
|
+
// filter; the server dim guard is the second bound.
|
|
14
|
+
//
|
|
15
|
+
// Lenient by design (format drift is the #1 risk): unparseable lines and
|
|
16
|
+
// unknown record types are counted and skipped, never fatal.
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.sanitizeModel = sanitizeModel;
|
|
19
|
+
exports.createSessionParser = createSessionParser;
|
|
20
|
+
exports.parseSessionContent = parseSessionContent;
|
|
21
|
+
/** Record types that exist but carry nothing we may transmit (titles,
|
|
22
|
+
* prompt snapshots, mode switches) — ignored without reading payloads. */
|
|
23
|
+
const IGNORED_TYPES = new Set([
|
|
24
|
+
"summary",
|
|
25
|
+
"ai-title",
|
|
26
|
+
"custom-title",
|
|
27
|
+
"last-prompt",
|
|
28
|
+
"mode",
|
|
29
|
+
"queue-operation",
|
|
30
|
+
]);
|
|
31
|
+
function asNumber(v) {
|
|
32
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
33
|
+
}
|
|
34
|
+
function asString(v) {
|
|
35
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
36
|
+
}
|
|
37
|
+
/** Model ids look like "claude-fable-5" / "claude-haiku-4-5-20251001". We
|
|
38
|
+
* transmit the model (it's §5-allowlisted) but it is still vendor free
|
|
39
|
+
* text, so clamp it to a safe charset and length before it can become a
|
|
40
|
+
* metric `dim` ("model=<id>") or a gap detail — a hostile/corrupted log
|
|
41
|
+
* must not be able to smuggle content through it. Disallowed characters
|
|
42
|
+
* are dropped; an empty or overlong result collapses to a marker. */
|
|
43
|
+
function sanitizeModel(raw) {
|
|
44
|
+
const s = asString(raw);
|
|
45
|
+
if (s === null) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const cleaned = s.replace(/[^A-Za-z0-9._:-]/g, "").slice(0, 64);
|
|
49
|
+
return cleaned.length > 0 ? cleaned : "unknown";
|
|
50
|
+
}
|
|
51
|
+
/** True when a user record is a tool-result carrier, not a human prompt:
|
|
52
|
+
* a toolUseResult key is present, or any message.content block has type
|
|
53
|
+
* "tool_result". Only block TYPE is inspected — never block content. */
|
|
54
|
+
function isToolResultCarrier(record) {
|
|
55
|
+
if ("toolUseResult" in record) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
const message = record.message;
|
|
59
|
+
const content = message?.content;
|
|
60
|
+
if (Array.isArray(content)) {
|
|
61
|
+
return content.some((block) => typeof block === "object" &&
|
|
62
|
+
block !== null &&
|
|
63
|
+
block.type === "tool_result");
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
function createSessionParser() {
|
|
68
|
+
const events = [];
|
|
69
|
+
let skippedLines = 0;
|
|
70
|
+
let unknownTypes = 0;
|
|
71
|
+
function pushLine(line) {
|
|
72
|
+
if (line.trim() === "") {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
let record;
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(line);
|
|
78
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
79
|
+
skippedLines++;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
record = parsed;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
skippedLines++;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const type = asString(record.type);
|
|
89
|
+
if (!type) {
|
|
90
|
+
skippedLines++;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (IGNORED_TYPES.has(type)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const sessionId = asString(record.sessionId);
|
|
97
|
+
const timestampMs = Date.parse(asString(record.timestamp) ?? "");
|
|
98
|
+
if (!sessionId || Number.isNaN(timestampMs)) {
|
|
99
|
+
skippedLines++;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const isSidechain = record.isSidechain === true;
|
|
103
|
+
if (type === "assistant") {
|
|
104
|
+
const message = record.message;
|
|
105
|
+
const usageRaw = message?.usage;
|
|
106
|
+
events.push({
|
|
107
|
+
kind: "assistant",
|
|
108
|
+
sessionId,
|
|
109
|
+
timestampMs,
|
|
110
|
+
isSidechain,
|
|
111
|
+
dedupKey: asString(record.requestId) ??
|
|
112
|
+
asString(message?.id) ??
|
|
113
|
+
asString(record.uuid) ??
|
|
114
|
+
`${sessionId}:${timestampMs}`,
|
|
115
|
+
model: sanitizeModel(message?.model),
|
|
116
|
+
usage: usageRaw
|
|
117
|
+
? {
|
|
118
|
+
input: asNumber(usageRaw.input_tokens),
|
|
119
|
+
output: asNumber(usageRaw.output_tokens),
|
|
120
|
+
cacheRead: asNumber(usageRaw.cache_read_input_tokens),
|
|
121
|
+
cacheWrite: asNumber(usageRaw.cache_creation_input_tokens),
|
|
122
|
+
}
|
|
123
|
+
: null,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
else if (type === "user") {
|
|
127
|
+
events.push({
|
|
128
|
+
kind: isSidechain || isToolResultCarrier(record) ? "activity" : "prompt",
|
|
129
|
+
sessionId,
|
|
130
|
+
timestampMs,
|
|
131
|
+
isSidechain,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
else if (type === "system" || type === "attachment") {
|
|
135
|
+
events.push({ kind: "activity", sessionId, timestampMs, isSidechain });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
unknownTypes++;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
pushLine,
|
|
143
|
+
finish: () => ({ events, skippedLines, unknownTypes }),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function parseSessionContent(content) {
|
|
147
|
+
const parser = createSessionParser();
|
|
148
|
+
for (const line of content.split("\n")) {
|
|
149
|
+
parser.pushLine(line);
|
|
150
|
+
}
|
|
151
|
+
return parser.finish();
|
|
152
|
+
}
|
package/dist/prices.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Public list prices used for spend_cents_estimated — an ESTIMATE, always
|
|
3
|
+
// reported with an honesty gap. Cents per million tokens, matched by
|
|
4
|
+
// substring on the model id (vendor ids look like "claude-opus-4-8",
|
|
5
|
+
// "claude-sonnet-5", "claude-haiku-4-5-20251001", "claude-fable-5").
|
|
6
|
+
//
|
|
7
|
+
// Bump SUMMARIZER_VERSION whenever summarization semantics change — the
|
|
8
|
+
// server stamps source_connector as `claude-code-local@<version>` so
|
|
9
|
+
// restatements are traceable to summarizer behavior.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.FALLBACK_RATES = exports.SUMMARIZER_VERSION = void 0;
|
|
12
|
+
exports.ratesForModel = ratesForModel;
|
|
13
|
+
exports.estimateCents = estimateCents;
|
|
14
|
+
exports.SUMMARIZER_VERSION = 1;
|
|
15
|
+
const OPUS = {
|
|
16
|
+
input: 1_500,
|
|
17
|
+
output: 7_500,
|
|
18
|
+
cacheWrite: 1_875,
|
|
19
|
+
cacheRead: 150,
|
|
20
|
+
};
|
|
21
|
+
const SONNET = {
|
|
22
|
+
input: 300,
|
|
23
|
+
output: 1_500,
|
|
24
|
+
cacheWrite: 375,
|
|
25
|
+
cacheRead: 30,
|
|
26
|
+
};
|
|
27
|
+
const HAIKU = {
|
|
28
|
+
input: 100,
|
|
29
|
+
output: 500,
|
|
30
|
+
cacheWrite: 125,
|
|
31
|
+
cacheRead: 10,
|
|
32
|
+
};
|
|
33
|
+
/** Ordered — first substring match wins. Unknown models fall back to the
|
|
34
|
+
* top (most expensive known) tier so estimates err high, with a gap noting
|
|
35
|
+
* the unknown model. */
|
|
36
|
+
const RATE_TABLE = [
|
|
37
|
+
{ match: "opus", rates: OPUS },
|
|
38
|
+
{ match: "fable", rates: OPUS },
|
|
39
|
+
{ match: "mythos", rates: OPUS },
|
|
40
|
+
{ match: "sonnet", rates: SONNET },
|
|
41
|
+
{ match: "haiku", rates: HAIKU },
|
|
42
|
+
];
|
|
43
|
+
exports.FALLBACK_RATES = OPUS;
|
|
44
|
+
function ratesForModel(model) {
|
|
45
|
+
const lower = model.toLowerCase();
|
|
46
|
+
for (const { match, rates } of RATE_TABLE) {
|
|
47
|
+
if (lower.includes(match)) {
|
|
48
|
+
return { rates, known: true };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { rates: exports.FALLBACK_RATES, known: false };
|
|
52
|
+
}
|
|
53
|
+
function estimateCents(rates, usage) {
|
|
54
|
+
return ((usage.input * rates.input +
|
|
55
|
+
usage.output * rates.output +
|
|
56
|
+
usage.cacheWrite * rates.cacheWrite +
|
|
57
|
+
usage.cacheRead * rates.cacheRead) /
|
|
58
|
+
1_000_000);
|
|
59
|
+
}
|
package/dist/push.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// HTTP push of a locally-built batch to POST /api/agent/ingest. The only
|
|
3
|
+
// network call the agent makes; the payload is the privacy-tested
|
|
4
|
+
// AgentIngestRequest and nothing else.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.pushBatch = pushBatch;
|
|
7
|
+
async function pushBatch(apiBaseUrl, token, batch, fetchImpl = fetch) {
|
|
8
|
+
const url = `${apiBaseUrl.replace(/\/+$/, "")}/api/agent/ingest`;
|
|
9
|
+
let response;
|
|
10
|
+
try {
|
|
11
|
+
response = await fetchImpl(url, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
authorization: `Bearer ${token}`,
|
|
15
|
+
"content-type": "application/json",
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify(batch),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
status: null,
|
|
24
|
+
error: `network error: ${error instanceof Error ? error.message : String(error)}`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
let body = null;
|
|
28
|
+
try {
|
|
29
|
+
body = await response.json();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Non-JSON error page — fall through with the status alone.
|
|
33
|
+
}
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
const message = typeof body === "object" &&
|
|
36
|
+
body !== null &&
|
|
37
|
+
typeof body.error === "string"
|
|
38
|
+
? body.error
|
|
39
|
+
: `server returned ${response.status}`;
|
|
40
|
+
return { ok: false, status: response.status, error: message };
|
|
41
|
+
}
|
|
42
|
+
const counts = body;
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
subjects: counts?.subjects ?? 0,
|
|
46
|
+
records: counts?.records ?? 0,
|
|
47
|
+
signals: counts?.signals ?? 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Streaming session-file reader: feeds JSONL lines into the allowlist
|
|
3
|
+
// parser without ever materializing a whole file as one string. Documented
|
|
4
|
+
// multi-GB session files (claude-code#18905/#22365) exceed V8's ~0.5 GB
|
|
5
|
+
// string ceiling, so `readFileSync` throws before parsing — a line stream
|
|
6
|
+
// has no such ceiling. Unreadable files are counted, never fatal (the
|
|
7
|
+
// lenient-by-design rule from parse.ts).
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.parseSessionFilesStreaming = parseSessionFilesStreaming;
|
|
10
|
+
const promises_1 = require("node:fs/promises");
|
|
11
|
+
const node_readline_1 = require("node:readline");
|
|
12
|
+
const parse_1 = require("./parse");
|
|
13
|
+
async function parseSessionFilesStreaming(paths) {
|
|
14
|
+
// Per-file accounting is all-or-nothing, mirroring the old readFileSync
|
|
15
|
+
// semantics: a file that errors mid-read (rotation, truncation, revoked
|
|
16
|
+
// permission) contributes NOTHING — its already-read prefix must not
|
|
17
|
+
// leak partial events into the batch while the file is simultaneously
|
|
18
|
+
// reported unreadable.
|
|
19
|
+
const merged = { events: [], skippedLines: 0, unknownTypes: 0 };
|
|
20
|
+
let unreadableFiles = 0;
|
|
21
|
+
for (const path of paths) {
|
|
22
|
+
// Open explicitly first so ENOENT/EACCES surface here, not as a late
|
|
23
|
+
// async 'error' event racing the line loop.
|
|
24
|
+
let handle;
|
|
25
|
+
try {
|
|
26
|
+
handle = await (0, promises_1.open)(path, "r");
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
unreadableFiles++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const parser = (0, parse_1.createSessionParser)();
|
|
33
|
+
try {
|
|
34
|
+
const rl = (0, node_readline_1.createInterface)({
|
|
35
|
+
input: handle.createReadStream({ encoding: "utf8" }),
|
|
36
|
+
crlfDelay: Infinity,
|
|
37
|
+
});
|
|
38
|
+
// for-await propagates mid-read stream errors into the catch below.
|
|
39
|
+
for await (const line of rl) {
|
|
40
|
+
parser.pushLine(line);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
unreadableFiles++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
// autoClose usually closed it already; double-close is a no-op error.
|
|
49
|
+
await handle.close().catch(() => { });
|
|
50
|
+
}
|
|
51
|
+
const result = parser.finish();
|
|
52
|
+
// No spread here: a multi-million-event file would overflow the stack.
|
|
53
|
+
for (const event of result.events) {
|
|
54
|
+
merged.events.push(event);
|
|
55
|
+
}
|
|
56
|
+
merged.skippedLines += result.skippedLines;
|
|
57
|
+
merged.unknownTypes += result.unknownTypes;
|
|
58
|
+
}
|
|
59
|
+
return { parsed: merged, unreadableFiles };
|
|
60
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Pure local summarizer: ParsedEvents in → metric records + sub-daily
|
|
3
|
+
// signals out. No I/O, no clock (the window comes from the caller), no
|
|
4
|
+
// content — deterministic over the same events, mirroring the frozen
|
|
5
|
+
// Connector.normalize() purity rule.
|
|
6
|
+
//
|
|
7
|
+
// Two correctness rules from docs/connector-facts.md §5 are load-bearing:
|
|
8
|
+
// • Dedup usage by requestId, **last-wins** ("keep final entry") — the
|
|
9
|
+
// final streamed line restates cumulative usage; first-wins undercounts.
|
|
10
|
+
// • Sessions = distinct sessionId with isSidechain:false ("human
|
|
11
|
+
// sessions"); subagent usage is still summed, but a sidechain is not a
|
|
12
|
+
// session. Streamed duplicate lines are not extra activity either.
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.utcDay = utcDay;
|
|
15
|
+
exports.summarize = summarize;
|
|
16
|
+
const prices_1 = require("./prices");
|
|
17
|
+
/** Exported so window pinning (index.ts) buckets days identically —
|
|
18
|
+
* one formatter, no silent drift between pinning and aggregation. */
|
|
19
|
+
function utcDay(timestampMs) {
|
|
20
|
+
return new Date(timestampMs).toISOString().slice(0, 10);
|
|
21
|
+
}
|
|
22
|
+
function utcHour(timestampMs) {
|
|
23
|
+
return new Date(timestampMs).getUTCHours();
|
|
24
|
+
}
|
|
25
|
+
/** Peak simultaneous sessions: the max number of inclusive [min,max] event
|
|
26
|
+
* intervals overlapping at any instant (which always occurs at some
|
|
27
|
+
* interval's start). Real temporal overlap — not an hourly bucket count —
|
|
28
|
+
* so "peak concurrency" means what it says. */
|
|
29
|
+
function peakConcurrency(intervals) {
|
|
30
|
+
let peak = 0;
|
|
31
|
+
for (const a of intervals) {
|
|
32
|
+
let count = 0;
|
|
33
|
+
for (const b of intervals) {
|
|
34
|
+
if (b.min <= a.min && a.min <= b.max) {
|
|
35
|
+
count++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (count > peak) {
|
|
39
|
+
peak = count;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return peak;
|
|
43
|
+
}
|
|
44
|
+
function emptyDay() {
|
|
45
|
+
return {
|
|
46
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
47
|
+
spendCents: 0,
|
|
48
|
+
prompts: 0,
|
|
49
|
+
humanSessions: new Set(),
|
|
50
|
+
sessionIntervals: new Map(),
|
|
51
|
+
modelRequests: new Map(),
|
|
52
|
+
modelTokens: new Map(),
|
|
53
|
+
hours: Array.from({ length: 24 }, () => 0),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function summarize(events, opts) {
|
|
57
|
+
// Pass 1 — collapse streamed assistant lines to ONE event per request,
|
|
58
|
+
// last-wins (the final line carries cumulative usage). Non-assistant
|
|
59
|
+
// events pass through unchanged. This dedup happens BEFORE any
|
|
60
|
+
// aggregation, so histograms and session presence never double-count a
|
|
61
|
+
// streamed turn.
|
|
62
|
+
const assistantByKey = new Map();
|
|
63
|
+
const otherEvents = [];
|
|
64
|
+
for (const event of events) {
|
|
65
|
+
if (event.kind === "assistant") {
|
|
66
|
+
assistantByKey.set(event.dedupKey, event); // last-wins
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
otherEvents.push(event);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const dedupedEvents = [...otherEvents, ...assistantByKey.values()];
|
|
73
|
+
const days = new Map();
|
|
74
|
+
const unknownModels = new Set();
|
|
75
|
+
for (const event of dedupedEvents) {
|
|
76
|
+
const day = utcDay(event.timestampMs);
|
|
77
|
+
if (day < opts.window.start || day > opts.window.end) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let agg = days.get(day);
|
|
81
|
+
if (!agg) {
|
|
82
|
+
agg = emptyDay();
|
|
83
|
+
days.set(day, agg);
|
|
84
|
+
}
|
|
85
|
+
agg.hours[utcHour(event.timestampMs)]++;
|
|
86
|
+
if (!event.isSidechain) {
|
|
87
|
+
agg.humanSessions.add(event.sessionId);
|
|
88
|
+
}
|
|
89
|
+
const interval = agg.sessionIntervals.get(event.sessionId);
|
|
90
|
+
if (interval) {
|
|
91
|
+
interval.min = Math.min(interval.min, event.timestampMs);
|
|
92
|
+
interval.max = Math.max(interval.max, event.timestampMs);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
agg.sessionIntervals.set(event.sessionId, {
|
|
96
|
+
min: event.timestampMs,
|
|
97
|
+
max: event.timestampMs,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (event.kind === "prompt") {
|
|
101
|
+
agg.prompts++;
|
|
102
|
+
}
|
|
103
|
+
else if (event.kind === "assistant" && event.usage) {
|
|
104
|
+
agg.usage.input += event.usage.input;
|
|
105
|
+
agg.usage.output += event.usage.output;
|
|
106
|
+
agg.usage.cacheRead += event.usage.cacheRead;
|
|
107
|
+
agg.usage.cacheWrite += event.usage.cacheWrite;
|
|
108
|
+
const model = event.model ?? "unknown";
|
|
109
|
+
const { rates, known } = (0, prices_1.ratesForModel)(model);
|
|
110
|
+
if (!known) {
|
|
111
|
+
unknownModels.add(model);
|
|
112
|
+
}
|
|
113
|
+
agg.spendCents += (0, prices_1.estimateCents)(rates, event.usage);
|
|
114
|
+
agg.modelRequests.set(model, (agg.modelRequests.get(model) ?? 0) + 1);
|
|
115
|
+
agg.modelTokens.set(model, (agg.modelTokens.get(model) ?? 0) +
|
|
116
|
+
event.usage.input +
|
|
117
|
+
event.usage.output);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const records = [];
|
|
121
|
+
const signals = [];
|
|
122
|
+
const base = { subject: opts.subject, attribution: opts.attribution };
|
|
123
|
+
for (const day of [...days.keys()].sort()) {
|
|
124
|
+
const agg = days.get(day);
|
|
125
|
+
const flat = [
|
|
126
|
+
["active_day", 1],
|
|
127
|
+
["sessions", agg.humanSessions.size],
|
|
128
|
+
["prompts", agg.prompts],
|
|
129
|
+
["tokens_input", agg.usage.input],
|
|
130
|
+
["tokens_output", agg.usage.output],
|
|
131
|
+
["tokens_cache_read", agg.usage.cacheRead],
|
|
132
|
+
["tokens_cache_write", agg.usage.cacheWrite],
|
|
133
|
+
["spend_cents_estimated", Math.round(agg.spendCents * 100) / 100],
|
|
134
|
+
];
|
|
135
|
+
for (const [metricKey, value] of flat) {
|
|
136
|
+
records.push({ ...base, metricKey, day, dim: "", value });
|
|
137
|
+
}
|
|
138
|
+
for (const [model, count] of [...agg.modelRequests].sort()) {
|
|
139
|
+
records.push({
|
|
140
|
+
...base,
|
|
141
|
+
metricKey: "model_requests",
|
|
142
|
+
day,
|
|
143
|
+
dim: `model=${model}`,
|
|
144
|
+
value: count,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
for (const [model, tokens] of [...agg.modelTokens].sort()) {
|
|
148
|
+
records.push({
|
|
149
|
+
...base,
|
|
150
|
+
metricKey: "model_tokens",
|
|
151
|
+
day,
|
|
152
|
+
dim: `model=${model}`,
|
|
153
|
+
value: tokens,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
signals.push({
|
|
157
|
+
subject: opts.subject,
|
|
158
|
+
day,
|
|
159
|
+
hours: agg.hours,
|
|
160
|
+
peakConcurrency: peakConcurrency([...agg.sessionIntervals.values()]),
|
|
161
|
+
sourceGranularity: "event",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const gaps = [
|
|
165
|
+
{
|
|
166
|
+
kind: "other",
|
|
167
|
+
detail: "spend_cents_estimated uses public list prices, not invoices",
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
if (unknownModels.size > 0) {
|
|
171
|
+
gaps.push({
|
|
172
|
+
kind: "other",
|
|
173
|
+
detail: `unknown model rates defaulted high: ${[...unknownModels]
|
|
174
|
+
.sort()
|
|
175
|
+
.slice(0, 5)
|
|
176
|
+
.join(", ")}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return { records, signals, gaps };
|
|
180
|
+
}
|
package/dist/sync-run.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The sync flow with injected I/O (file listing, streaming parse, push,
|
|
3
|
+
// output), so tests can lock the R2-critical invariant directly: a push is
|
|
4
|
+
// NEVER attempted for an empty batch — the server treats the declared
|
|
5
|
+
// window as authoritative (delete-then-upsert), so pushing an empty
|
|
6
|
+
// authoritative window would erase previously-captured days and restate
|
|
7
|
+
// nothing. The CLI (cli.ts) supplies the real deps; tests supply fakes.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.runSync = runSync;
|
|
10
|
+
const config_1 = require("./config");
|
|
11
|
+
const identity_1 = require("./identity");
|
|
12
|
+
const index_1 = require("./index");
|
|
13
|
+
const window_1 = require("./window");
|
|
14
|
+
async function runSync(opts, deps) {
|
|
15
|
+
const resolved = (0, config_1.resolveConfig)(deps.env, deps.homeDir, deps.defaultApi);
|
|
16
|
+
let config = null;
|
|
17
|
+
if (resolved.source === "invalid-env") {
|
|
18
|
+
if (!opts.dryRun) {
|
|
19
|
+
return {
|
|
20
|
+
kind: "fail",
|
|
21
|
+
message: "REVEALYST_TOKEN is set but is not a valid rva1.… device token",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// Dry run never needs credentials — keep that invariant even for a
|
|
25
|
+
// malformed env token, but say so loudly.
|
|
26
|
+
deps.warn("warning: REVEALYST_TOKEN is set but malformed — continuing without it (dry run)");
|
|
27
|
+
}
|
|
28
|
+
else if (resolved.source !== "none") {
|
|
29
|
+
config = resolved.config;
|
|
30
|
+
}
|
|
31
|
+
if (!config && !opts.dryRun) {
|
|
32
|
+
return {
|
|
33
|
+
kind: "fail",
|
|
34
|
+
message: "not logged in — run: revealyst-agent login --token rva1.… " +
|
|
35
|
+
"(or set REVEALYST_TOKEN for one-shot/CI use)",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const files = deps.listFiles();
|
|
39
|
+
if (files.length === 0) {
|
|
40
|
+
deps.log("No Claude Code session logs found — nothing to sync.");
|
|
41
|
+
return { kind: "ok" };
|
|
42
|
+
}
|
|
43
|
+
// Streamed, never readFileSync: documented multi-GB session files exceed
|
|
44
|
+
// V8's string ceiling before parse (plan R4).
|
|
45
|
+
const { parsed, unreadableFiles } = await deps.parseFiles(files.map((f) => f.path));
|
|
46
|
+
// Messaging fast-path only — the records guard below is the safety
|
|
47
|
+
// mechanism (zero events always implies zero records).
|
|
48
|
+
if (parsed.events.length === 0) {
|
|
49
|
+
deps.log("No parseable Claude Code activity found in your logs — nothing to sync.");
|
|
50
|
+
return { kind: "ok" };
|
|
51
|
+
}
|
|
52
|
+
const identity = (0, identity_1.resolveLocalIdentity)(deps.homeDir, config?.consentIdentity ?? false, deps.deviceSeed);
|
|
53
|
+
const requestedWindow = (0, window_1.trailingWindow)(deps.now(), opts.days);
|
|
54
|
+
const batch = (0, index_1.buildIngestRequest)({
|
|
55
|
+
parsed,
|
|
56
|
+
window: requestedWindow,
|
|
57
|
+
identity,
|
|
58
|
+
agentVersion: deps.agentVersion,
|
|
59
|
+
});
|
|
60
|
+
// R2 safety mechanism: never push an empty authoritative window.
|
|
61
|
+
if (batch.records.length === 0) {
|
|
62
|
+
deps.log(`No Claude Code activity within the last ${opts.days} days — nothing ` +
|
|
63
|
+
"to sync (nothing was deleted or pushed).");
|
|
64
|
+
return { kind: "ok" };
|
|
65
|
+
}
|
|
66
|
+
const activeDays = new Set(batch.records.map((r) => r.day)).size;
|
|
67
|
+
deps.log(`Summarized ${files.length} session files (${unreadableFiles} unreadable) → ` +
|
|
68
|
+
`${batch.records.length} metric records, ${batch.signals.length} day signals ` +
|
|
69
|
+
`across ${activeDays} active days [window ${batch.window.start}..${batch.window.end}]`);
|
|
70
|
+
if (batch.window.start !== requestedWindow.start) {
|
|
71
|
+
deps.log(`Window pinned to ${batch.window.start} (earliest surviving local ` +
|
|
72
|
+
"log day) so older captured history is preserved.");
|
|
73
|
+
}
|
|
74
|
+
deps.log(`Identity: ${identity.descriptor.kind} (${identity.attribution}-level attribution)`);
|
|
75
|
+
if (opts.dryRun) {
|
|
76
|
+
deps.log("Dry run — nothing pushed.");
|
|
77
|
+
return { kind: "ok" };
|
|
78
|
+
}
|
|
79
|
+
if (!config) {
|
|
80
|
+
// Unreachable: guarded above. Kept as a narrow + tripwire so a future
|
|
81
|
+
// reorder of the guards cannot compile its way into a null deref here.
|
|
82
|
+
return { kind: "fail", message: "not logged in" };
|
|
83
|
+
}
|
|
84
|
+
const result = await deps.push(config.apiBaseUrl, config.token, batch);
|
|
85
|
+
if (!result.ok) {
|
|
86
|
+
return {
|
|
87
|
+
kind: "fail",
|
|
88
|
+
message: `push failed (${result.status ?? "network"}): ${result.error}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
deps.log(`Pushed: ${result.records} records, ${result.signals} signals, ` +
|
|
92
|
+
`${result.subjects} subject(s) upserted.`);
|
|
93
|
+
return { kind: "ok" };
|
|
94
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Local mirrors of the frozen W0-C contract shapes (src/contracts/**) the
|
|
3
|
+
// agent emits. The CLI is a separate package that runs on end-user machines
|
|
4
|
+
// and must not import the monolith; drift between these mirrors and the
|
|
5
|
+
// frozen zod schemas is caught by the repo-side contract test
|
|
6
|
+
// (tests/agent-cli-contract.test.ts), which validates a real built batch
|
|
7
|
+
// against the frozen schemas — the rule-2 seam.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/window.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.trailingWindow = trailingWindow;
|
|
4
|
+
/** Trailing inclusive UTC-day window ending today: the sync default.
|
|
5
|
+
* Local logs only retain ~30 days (connector-facts §5), so `days` beyond
|
|
6
|
+
* retention just yields empty days. */
|
|
7
|
+
function trailingWindow(now, days) {
|
|
8
|
+
if (!Number.isInteger(days) || days < 1) {
|
|
9
|
+
throw new Error("days must be a positive integer");
|
|
10
|
+
}
|
|
11
|
+
const end = now.toISOString().slice(0, 10);
|
|
12
|
+
const startDate = new Date(now.getTime() - (days - 1) * 86_400_000);
|
|
13
|
+
return { start: startDate.toISOString().slice(0, 10), end };
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@revealyst/agent",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Revealyst Agent — reads Claude Code session logs, summarizes them locally to metric records (never raw prompt content), pushes via device token.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/instashop-dev/revealyst.git",
|
|
9
|
+
"directory": "packages/revealyst-agent"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=20"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"revealyst-agent": "dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": ["dist"],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"provenance": true
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
26
|
+
}
|
|
27
|
+
}
|