@mnemom/mnemom 0.17.4-next.3 → 0.17.4-next.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base-url-proxy.mjs +154 -0
- package/dist/commands/agent-first-run.d.ts +35 -0
- package/dist/commands/agent-first-run.js +78 -0
- package/dist/commands/agent.d.ts +24 -0
- package/dist/commands/agent.js +130 -14
- package/dist/index.js +10 -0
- package/dist/lib/agent.d.ts +112 -0
- package/dist/lib/agent.js +184 -0
- package/dist/lib/first-run/backtest.d.ts +92 -0
- package/dist/lib/first-run/backtest.js +196 -0
- package/dist/lib/first-run/governed.d.ts +65 -0
- package/dist/lib/first-run/governed.js +253 -0
- package/dist/lib/first-run/index.d.ts +76 -0
- package/dist/lib/first-run/index.js +243 -0
- package/dist/lib/first-run/lines.d.ts +16 -0
- package/dist/lib/first-run/lines.js +80 -0
- package/dist/lib/first-run/scope.d.ts +108 -0
- package/dist/lib/first-run/scope.js +308 -0
- package/dist/lib/first-run/screen.d.ts +93 -0
- package/dist/lib/first-run/screen.js +210 -0
- package/dist/lib/first-run/seat.d.ts +75 -0
- package/dist/lib/first-run/seat.js +250 -0
- package/dist/rc-proxy.mjs +16 -1
- package/dist/turn-origin.mjs +330 -0
- package/package.json +1 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Did a Claude Code transcript already run through Mnemom? A session is GOVERNED — and
|
|
3
|
+
* left out of the first-run look — if ANY signal fires. When in doubt, exclude.
|
|
4
|
+
*
|
|
5
|
+
* registry_cc_id the transcript's session id is a `cc_session_id` in the launch
|
|
6
|
+
* ledger (~/.mnemom/sessions.jsonl, lib/agent-ledger.ts)
|
|
7
|
+
* registry_conv_id … or a ledger `conversation_id`
|
|
8
|
+
* registry_window its first event is within [launch-60s, launch+10min] of a ledger
|
|
9
|
+
* launch that recorded no cc_session_id (--server, older rows)
|
|
10
|
+
* rc_proxy_window the same window match against a Remote Control proxy log
|
|
11
|
+
* text_marker the bytes carry a gateway marker ('[mnemom', 'x-mnemom', 'covers=',
|
|
12
|
+
* …). Also fires on sessions that merely DISCUSS Mnemom: over-excludes,
|
|
13
|
+
* the safe direction
|
|
14
|
+
* settings_env a settings file (global or the session cwd's) or the transcript
|
|
15
|
+
* text points ANTHROPIC_BASE_URL / HTTPS_PROXY at a mnemom host or a
|
|
16
|
+
* localhost proxy
|
|
17
|
+
* agent_sdk_host entrypoint sdk-py / sdk-ts: an Agent-SDK host can route through
|
|
18
|
+
* the gateway with env no transcript records
|
|
19
|
+
* (a session is governed if its top transcript OR any subagent transcript fires)
|
|
20
|
+
*
|
|
21
|
+
* Read-only on ~/.claude and ~/.mnemom. No network.
|
|
22
|
+
*/
|
|
23
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
24
|
+
import { homedir, tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { forEachLine, isoSeconds, parseJson } from "./lines.js";
|
|
27
|
+
export const AGENT_SDK_ENTRYPOINTS = new Set(["sdk-py", "sdk-ts"]);
|
|
28
|
+
/** Lowercase byte markers. Candidate lines are found by the case-variant probes below. */
|
|
29
|
+
export const TEXT_MARKERS = [
|
|
30
|
+
"[mnemom",
|
|
31
|
+
"x-mnemom",
|
|
32
|
+
"mnemom goal contract",
|
|
33
|
+
"mnemom context digest",
|
|
34
|
+
"mnemom inferred goal",
|
|
35
|
+
"mnemom guardrail",
|
|
36
|
+
"covers=",
|
|
37
|
+
];
|
|
38
|
+
const PROBES = ["nemom", "NEMOM", "covers=", "COVERS="].map((s) => Buffer.from(s));
|
|
39
|
+
const ENV_PROBES = ["ANTHROPIC_BASE_URL", "HTTPS_PROXY", "anthropic_base_url", "https_proxy"].map((s) => Buffer.from(s));
|
|
40
|
+
// The variable, its separator, then a host. Bounded, no nested quantifiers.
|
|
41
|
+
const ENV_RE = /(?:ANTHROPIC_BASE_URL|HTTPS_PROXY)["'\s:=]{1,8}(?:https?:\/\/)?([a-z0-9.-]{1,253})/gi;
|
|
42
|
+
const WINDOW_BEFORE_S = 60;
|
|
43
|
+
const WINDOW_AFTER_S = 600;
|
|
44
|
+
/** The head scan reads this much of a file for its first records. */
|
|
45
|
+
const HEAD_BYTES = 256 * 1024;
|
|
46
|
+
export function lineHasMarker(line) {
|
|
47
|
+
if (!PROBES.some((p) => line.indexOf(p) !== -1))
|
|
48
|
+
return false;
|
|
49
|
+
const low = line.toString("utf8").toLowerCase();
|
|
50
|
+
return TEXT_MARKERS.some((m) => low.includes(m));
|
|
51
|
+
}
|
|
52
|
+
function governedHost(host) {
|
|
53
|
+
const h = host.toLowerCase();
|
|
54
|
+
return h.includes("mnemom") || h === "127.0.0.1" || h === "localhost";
|
|
55
|
+
}
|
|
56
|
+
export function lineHasEnv(line) {
|
|
57
|
+
if (!ENV_PROBES.some((p) => line.indexOf(p) !== -1))
|
|
58
|
+
return false;
|
|
59
|
+
const s = line.toString("utf8");
|
|
60
|
+
for (const m of s.matchAll(ENV_RE)) {
|
|
61
|
+
if (governedHost(m[1]))
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
function settingsEnvHits(path) {
|
|
67
|
+
let d;
|
|
68
|
+
try {
|
|
69
|
+
d = parseJson(readFileSync(path, "utf8"));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const env = d?.env ?? {};
|
|
75
|
+
for (const k of ["ANTHROPIC_BASE_URL", "HTTPS_PROXY", "https_proxy"]) {
|
|
76
|
+
const v = String(env[k] ?? "").toLowerCase();
|
|
77
|
+
if (v && (v.includes("mnemom") || v.includes("127.0.0.1") || v.includes("localhost"))) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
/** Session id of a transcript path: its own uuid, or the parent's for a subagent file. */
|
|
84
|
+
export function sessionIdFor(path) {
|
|
85
|
+
const parts = path.split("/");
|
|
86
|
+
const i = parts.lastIndexOf("subagents");
|
|
87
|
+
if (i > 0)
|
|
88
|
+
return parts[i - 1];
|
|
89
|
+
const base = parts[parts.length - 1];
|
|
90
|
+
return base.endsWith(".jsonl") ? base.slice(0, -6) : base;
|
|
91
|
+
}
|
|
92
|
+
export class Governance {
|
|
93
|
+
ccIds = new Set();
|
|
94
|
+
convIds = new Set();
|
|
95
|
+
windows = [];
|
|
96
|
+
globalSettingsEnv;
|
|
97
|
+
registryRows = 0;
|
|
98
|
+
cwdCache = new Map();
|
|
99
|
+
constructor(inp = {}) {
|
|
100
|
+
const home = inp.home ?? homedir();
|
|
101
|
+
const mnemomDir = inp.mnemomDir ?? join(home, ".mnemom");
|
|
102
|
+
const tmp = inp.tmp ?? tmpdir();
|
|
103
|
+
let ledger;
|
|
104
|
+
try {
|
|
105
|
+
ledger = readFileSync(join(mnemomDir, "sessions.jsonl"), "utf8");
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
ledger = "";
|
|
109
|
+
}
|
|
110
|
+
for (const line of ledger.split("\n")) {
|
|
111
|
+
if (!line.trim())
|
|
112
|
+
continue;
|
|
113
|
+
const r = parseJson(line);
|
|
114
|
+
if (!r)
|
|
115
|
+
continue;
|
|
116
|
+
this.registryRows++;
|
|
117
|
+
const t = isoSeconds(r.started_at);
|
|
118
|
+
if (typeof r.cc_session_id === "string" && r.cc_session_id)
|
|
119
|
+
this.ccIds.add(r.cc_session_id);
|
|
120
|
+
if (typeof r.conversation_id === "string" && r.conversation_id) {
|
|
121
|
+
this.convIds.add(r.conversation_id);
|
|
122
|
+
}
|
|
123
|
+
if (!r.cc_session_id && t !== null)
|
|
124
|
+
this.windows.push(["registry_window", t]);
|
|
125
|
+
}
|
|
126
|
+
// Remote Control proxy logs: $TMPDIR/mnemom-code-rc.*/proxy.log, ~/.mnemom/*rc-proxy*.log
|
|
127
|
+
const logs = [];
|
|
128
|
+
for (const d of safeList(tmp)) {
|
|
129
|
+
if (d.startsWith("mnemom-code-rc."))
|
|
130
|
+
logs.push(join(tmp, d, "proxy.log"));
|
|
131
|
+
}
|
|
132
|
+
for (const f of safeList(mnemomDir)) {
|
|
133
|
+
if (f.includes("rc-proxy") && f.endsWith(".log"))
|
|
134
|
+
logs.push(join(mnemomDir, f));
|
|
135
|
+
}
|
|
136
|
+
for (const lp of logs) {
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = readFileSync(lp, "utf8");
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
for (const line of text.split("\n")) {
|
|
145
|
+
if (!line.includes(" listening "))
|
|
146
|
+
continue;
|
|
147
|
+
const t = isoSeconds(line.split(" ", 1)[0]);
|
|
148
|
+
if (t !== null)
|
|
149
|
+
this.windows.push(["rc_proxy_window", t]);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
this.globalSettingsEnv = ["settings.json", "settings.local.json"].some((f) => settingsEnvHits(join(home, ".claude", f)));
|
|
153
|
+
}
|
|
154
|
+
cwdEnv(cwd) {
|
|
155
|
+
if (!cwd)
|
|
156
|
+
return false;
|
|
157
|
+
let v = this.cwdCache.get(cwd);
|
|
158
|
+
if (v === undefined) {
|
|
159
|
+
v = ["settings.json", "settings.local.json"].some((f) => settingsEnvHits(join(cwd, ".claude", f)));
|
|
160
|
+
this.cwdCache.set(cwd, v);
|
|
161
|
+
}
|
|
162
|
+
return v;
|
|
163
|
+
}
|
|
164
|
+
/** The first records of a file: first timestamp, cwd, session id, entrypoint. */
|
|
165
|
+
head(path, maxBytes = HEAD_BYTES) {
|
|
166
|
+
const h = { firstTs: null, cwd: null, sessionId: null, entrypoint: null };
|
|
167
|
+
forEachLine(path, (line) => {
|
|
168
|
+
fillHead(h, line);
|
|
169
|
+
return !(h.firstTs !== null && h.cwd && h.sessionId && h.entrypoint);
|
|
170
|
+
}, maxBytes);
|
|
171
|
+
return h;
|
|
172
|
+
}
|
|
173
|
+
/** Signals that follow from a file's head records alone. */
|
|
174
|
+
headSignals(sid, h, sig) {
|
|
175
|
+
if (h.sessionId && h.sessionId !== sid) {
|
|
176
|
+
if (this.ccIds.has(h.sessionId) || this.convIds.has(h.sessionId))
|
|
177
|
+
sig.add("registry_cc_id");
|
|
178
|
+
}
|
|
179
|
+
if (h.entrypoint && AGENT_SDK_ENTRYPOINTS.has(h.entrypoint))
|
|
180
|
+
sig.add("agent_sdk_host");
|
|
181
|
+
if (this.cwdEnv(h.cwd))
|
|
182
|
+
sig.add("settings_env");
|
|
183
|
+
if (h.firstTs !== null) {
|
|
184
|
+
for (const [name, t] of this.windows) {
|
|
185
|
+
if (t - WINDOW_BEFORE_S <= h.firstTs && h.firstTs <= t + WINDOW_AFTER_S) {
|
|
186
|
+
sig.add(name);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Classify one transcript. The cheap signals (ledger, head records, settings, windows)
|
|
194
|
+
* are checked first; the full byte scan for markers runs only when none fired (the
|
|
195
|
+
* session is excluded either way, so the scan would change nothing). The full scan
|
|
196
|
+
* also completes head fields that a large first record pushed past the head read.
|
|
197
|
+
*/
|
|
198
|
+
classify(path) {
|
|
199
|
+
const sid = sessionIdFor(path);
|
|
200
|
+
const sig = new Set();
|
|
201
|
+
if (this.ccIds.has(sid))
|
|
202
|
+
sig.add("registry_cc_id");
|
|
203
|
+
if (this.convIds.has(sid))
|
|
204
|
+
sig.add("registry_conv_id");
|
|
205
|
+
if (this.globalSettingsEnv)
|
|
206
|
+
sig.add("settings_env");
|
|
207
|
+
const h = this.head(path);
|
|
208
|
+
this.headSignals(sid, h, sig);
|
|
209
|
+
if (sig.size === 0) {
|
|
210
|
+
const complete = () => h.firstTs !== null && h.cwd !== null && h.sessionId !== null && h.entrypoint !== null;
|
|
211
|
+
let marker = false;
|
|
212
|
+
let env = false;
|
|
213
|
+
const ok = forEachLine(path, (line) => {
|
|
214
|
+
// The scan stops at the first hit, so neither flag is ever already set here.
|
|
215
|
+
marker = lineHasMarker(line);
|
|
216
|
+
env = lineHasEnv(line);
|
|
217
|
+
if (!complete())
|
|
218
|
+
fillHead(h, line);
|
|
219
|
+
// Either hit excludes the session; the rest of the file cannot change that.
|
|
220
|
+
return !(marker || env);
|
|
221
|
+
});
|
|
222
|
+
if (!ok)
|
|
223
|
+
sig.add("unreadable");
|
|
224
|
+
if (marker)
|
|
225
|
+
sig.add("text_marker");
|
|
226
|
+
if (env)
|
|
227
|
+
sig.add("settings_env");
|
|
228
|
+
this.headSignals(sid, h, sig);
|
|
229
|
+
}
|
|
230
|
+
return { ...h, signals: [...sig].sort() };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function fillHead(h, line) {
|
|
234
|
+
const d = parseJson(line);
|
|
235
|
+
if (!d)
|
|
236
|
+
return;
|
|
237
|
+
if (h.firstTs === null && d.timestamp)
|
|
238
|
+
h.firstTs = isoSeconds(d.timestamp);
|
|
239
|
+
if (h.cwd === null && typeof d.cwd === "string")
|
|
240
|
+
h.cwd = d.cwd;
|
|
241
|
+
if (h.sessionId === null && typeof d.sessionId === "string")
|
|
242
|
+
h.sessionId = d.sessionId;
|
|
243
|
+
if (h.entrypoint === null && typeof d.entrypoint === "string")
|
|
244
|
+
h.entrypoint = d.entrypoint;
|
|
245
|
+
}
|
|
246
|
+
function safeList(dir) {
|
|
247
|
+
try {
|
|
248
|
+
return readdirSync(dir);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mnemom agent` first-run look: on the first interactive launch, offer (opt-in) to
|
|
3
|
+
* backtest the user's last 30 days of Claude Code sessions on this machine and show what
|
|
4
|
+
* Mnemom would have saved. `mnemom agent --first-run` shows it again at any time.
|
|
5
|
+
*
|
|
6
|
+
* Privacy: READ-ONLY on ~/.claude and every transcript; no network call anywhere in this
|
|
7
|
+
* module tree; only token counts, model ids, timestamps and Claude Code's own limit
|
|
8
|
+
* records are read. Writes only the offer state file and a local audit (both 0600,
|
|
9
|
+
* under ~/.mnemom).
|
|
10
|
+
*
|
|
11
|
+
* State file (~/.mnemom/first-run.json): { offers, last_offer, last_answer, accepted_at?,
|
|
12
|
+
* never? }. The offer is made while neither `accepted_at` nor `never` is set:
|
|
13
|
+
* Enter / y shows the screen and records acceptance; n skips until the next launch;
|
|
14
|
+
* never stops asking. A non-interactive launch is never asked and writes nothing.
|
|
15
|
+
*/
|
|
16
|
+
import { type Audit, type Variation } from "./screen.js";
|
|
17
|
+
export declare const STATE_FILE = "first-run.json";
|
|
18
|
+
export declare const AUDIT_FILE = "first-run-audit.json";
|
|
19
|
+
export declare const OFFER_TEXT: string[];
|
|
20
|
+
export declare const OFFER_QUESTION = "Take a look? [Y/n/never]";
|
|
21
|
+
export interface FirstRunState {
|
|
22
|
+
offers?: number;
|
|
23
|
+
last_offer?: string;
|
|
24
|
+
last_answer?: OfferAnswer;
|
|
25
|
+
accepted_at?: string;
|
|
26
|
+
never?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export type OfferAnswer = "yes" | "no" | "never";
|
|
29
|
+
export declare function statePath(mnemomDir: string): string;
|
|
30
|
+
export declare function readState(path: string): FirstRunState;
|
|
31
|
+
export declare function writeState(path: string, st: FirstRunState): void;
|
|
32
|
+
/** Should this launch offer the first-run look? Only interactive, only until answered. */
|
|
33
|
+
export declare function shouldOffer(st: FirstRunState, interactive: boolean): boolean;
|
|
34
|
+
export declare function parseAnswer(raw: string | null): OfferAnswer;
|
|
35
|
+
export declare function recordAnswer(st: FirstRunState, ans: OfferAnswer, now: Date): FirstRunState;
|
|
36
|
+
export interface ScanOptions {
|
|
37
|
+
home?: string;
|
|
38
|
+
mnemomDir?: string;
|
|
39
|
+
tmp?: string;
|
|
40
|
+
roots?: string[];
|
|
41
|
+
now?: number;
|
|
42
|
+
retentionDays?: number;
|
|
43
|
+
progress?: (msg: string) => void;
|
|
44
|
+
}
|
|
45
|
+
export interface FirstRunResult {
|
|
46
|
+
lines: string[];
|
|
47
|
+
audit: Audit;
|
|
48
|
+
variation?: Variation;
|
|
49
|
+
counts: Record<string, number>;
|
|
50
|
+
seconds: number;
|
|
51
|
+
}
|
|
52
|
+
/** Scan, classify, price and build the screen. Read-only on every transcript. */
|
|
53
|
+
export declare function computeFirstRun(opts?: ScanOptions): FirstRunResult;
|
|
54
|
+
/** The screen's hard budget: a regression in buildScreen's line dropping must fail loudly. */
|
|
55
|
+
export declare function assertScreenBudget(lines: readonly string[]): void;
|
|
56
|
+
/** A transcript's mtime (ms), 0 when it vanished since discovery (it then sorts first). */
|
|
57
|
+
export declare function mtimeOf(p: string): number;
|
|
58
|
+
export interface FirstRunDeps {
|
|
59
|
+
mnemomDir: string;
|
|
60
|
+
interactive: boolean;
|
|
61
|
+
/** Ask a question, return the answer, or null on EOF. */
|
|
62
|
+
ask(question: string): Promise<string | null>;
|
|
63
|
+
/** Screen output (stdout). */
|
|
64
|
+
print(line: string): void;
|
|
65
|
+
/** Progress / notes (stderr). */
|
|
66
|
+
note(line: string): void;
|
|
67
|
+
now(): Date;
|
|
68
|
+
compute(): FirstRunResult;
|
|
69
|
+
}
|
|
70
|
+
/** Show the screen and write the local audit. Never throws: a failed look never blocks a launch. */
|
|
71
|
+
export declare function showFirstRun(deps: FirstRunDeps): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* The first-launch hook: offer once (interactive only), record the answer, show on yes.
|
|
74
|
+
* Returns what happened, for tests and the caller's logging.
|
|
75
|
+
*/
|
|
76
|
+
export declare function maybeOfferFirstRun(deps: FirstRunDeps): Promise<"shown" | "declined" | "never" | "skipped" | "failed">;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mnemom agent` first-run look: on the first interactive launch, offer (opt-in) to
|
|
3
|
+
* backtest the user's last 30 days of Claude Code sessions on this machine and show what
|
|
4
|
+
* Mnemom would have saved. `mnemom agent --first-run` shows it again at any time.
|
|
5
|
+
*
|
|
6
|
+
* Privacy: READ-ONLY on ~/.claude and every transcript; no network call anywhere in this
|
|
7
|
+
* module tree; only token counts, model ids, timestamps and Claude Code's own limit
|
|
8
|
+
* records are read. Writes only the offer state file and a local audit (both 0600,
|
|
9
|
+
* under ~/.mnemom).
|
|
10
|
+
*
|
|
11
|
+
* State file (~/.mnemom/first-run.json): { offers, last_offer, last_answer, accepted_at?,
|
|
12
|
+
* never? }. The offer is made while neither `accepted_at` nor `never` is set:
|
|
13
|
+
* Enter / y shows the screen and records acceptance; n skips until the next launch;
|
|
14
|
+
* never stops asking. A non-interactive launch is never asked and writes nothing.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { estimate, loadTranscript, origContext, ruleRate } from "./backtest.js";
|
|
20
|
+
import { analyzeSeat } from "./seat.js";
|
|
21
|
+
import { ORG_SEAT_TYPES, authShape, billingClass, buildScope, configDirOf, prettyProject, } from "./scope.js";
|
|
22
|
+
import { DAYS, MAX_LINES, WIDTH, buildScreen, labelFor, } from "./screen.js";
|
|
23
|
+
export const STATE_FILE = "first-run.json";
|
|
24
|
+
export const AUDIT_FILE = "first-run-audit.json";
|
|
25
|
+
export const OFFER_TEXT = [
|
|
26
|
+
"Mnemom can look at your recent Claude Code sessions and show you what it would have saved.",
|
|
27
|
+
"It runs on this machine. Nothing is uploaded.",
|
|
28
|
+
];
|
|
29
|
+
export const OFFER_QUESTION = "Take a look? [Y/n/never]";
|
|
30
|
+
export function statePath(mnemomDir) {
|
|
31
|
+
return join(mnemomDir, STATE_FILE);
|
|
32
|
+
}
|
|
33
|
+
export function readState(path) {
|
|
34
|
+
try {
|
|
35
|
+
const v = JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function writePrivate(path, body) {
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
44
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
45
|
+
writeFileSync(tmp, body, { mode: 0o600 });
|
|
46
|
+
renameSync(tmp, path);
|
|
47
|
+
}
|
|
48
|
+
export function writeState(path, st) {
|
|
49
|
+
writePrivate(path, `${JSON.stringify(st, null, 1)}\n`);
|
|
50
|
+
}
|
|
51
|
+
/** Should this launch offer the first-run look? Only interactive, only until answered. */
|
|
52
|
+
export function shouldOffer(st, interactive) {
|
|
53
|
+
return interactive && !st.never && !st.accepted_at;
|
|
54
|
+
}
|
|
55
|
+
export function parseAnswer(raw) {
|
|
56
|
+
if (raw === null)
|
|
57
|
+
return "no"; // EOF: skip this time
|
|
58
|
+
const a = raw.trim().toLowerCase();
|
|
59
|
+
if (a === "" || a === "y" || a === "yes")
|
|
60
|
+
return "yes";
|
|
61
|
+
if (a === "never" || a === "nev")
|
|
62
|
+
return "never";
|
|
63
|
+
return "no"; // n, no, or anything unclear
|
|
64
|
+
}
|
|
65
|
+
export function recordAnswer(st, ans, now) {
|
|
66
|
+
const iso = now.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
67
|
+
const out = {
|
|
68
|
+
...st,
|
|
69
|
+
offers: (st.offers ?? 0) + 1,
|
|
70
|
+
last_offer: iso,
|
|
71
|
+
last_answer: ans,
|
|
72
|
+
};
|
|
73
|
+
if (ans === "never")
|
|
74
|
+
out.never = true;
|
|
75
|
+
if (ans === "yes")
|
|
76
|
+
out.accepted_at = iso;
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Scan, classify, price and build the screen. Read-only on every transcript. */
|
|
80
|
+
export function computeFirstRun(opts = {}) {
|
|
81
|
+
const t0 = Date.now();
|
|
82
|
+
const home = opts.home ?? homedir();
|
|
83
|
+
const now = opts.now ?? Date.now() / 1000;
|
|
84
|
+
const cut = now - DAYS * 86400;
|
|
85
|
+
const scope = buildScope({
|
|
86
|
+
home,
|
|
87
|
+
roots: opts.roots,
|
|
88
|
+
mnemomDir: opts.mnemomDir,
|
|
89
|
+
tmp: opts.tmp,
|
|
90
|
+
since: cut,
|
|
91
|
+
retentionDays: opts.retentionDays,
|
|
92
|
+
now,
|
|
93
|
+
progress: opts.progress,
|
|
94
|
+
});
|
|
95
|
+
const counts = {
|
|
96
|
+
sessions_total: scope.sessions.size,
|
|
97
|
+
sessions_recent: scope.inWindow,
|
|
98
|
+
excluded_governed_or_doubt: scope.excluded.size,
|
|
99
|
+
kept: scope.kept.size,
|
|
100
|
+
};
|
|
101
|
+
for (const sigs of scope.excluded.values()) {
|
|
102
|
+
for (const s of sigs)
|
|
103
|
+
counts[`signal_${s}`] = (counts[`signal_${s}`] ?? 0) + 1;
|
|
104
|
+
}
|
|
105
|
+
// Oldest first, so a message copied into a later (resumed / forked) transcript is billed
|
|
106
|
+
// once, to its origin.
|
|
107
|
+
const ordered = [...scope.kept.values()]
|
|
108
|
+
.map((s) => ({ s, m: mtimeOf(s.top) }))
|
|
109
|
+
.sort((a, b) => a.m - b.m)
|
|
110
|
+
.map((x) => x.s);
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const shapes = new Map();
|
|
113
|
+
const sessions = [];
|
|
114
|
+
const subSessions = [];
|
|
115
|
+
// A Team / Enterprise subscriber: a subscription-billed session, or a Team / Enterprise
|
|
116
|
+
// login in any kept session's config dir. Only these plans can run through Mnemom on
|
|
117
|
+
// their subscription (lib/subscription-login.ts), so only they get the usage sentence.
|
|
118
|
+
let orgLogin = false;
|
|
119
|
+
const seatInputs = new Map();
|
|
120
|
+
ordered.forEach((s, i) => {
|
|
121
|
+
if (i % 25 === 0)
|
|
122
|
+
opts.progress?.(`pricing sessions ${i + 1}/${ordered.length}`);
|
|
123
|
+
const paths = [s.top, ...[...s.subs].sort()];
|
|
124
|
+
const rows = paths.flatMap((p) => loadTranscript(p, seen));
|
|
125
|
+
if (!rows.length) {
|
|
126
|
+
counts.no_usage = (counts.no_usage ?? 0) + 1;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const cfg = configDirOf(s.root);
|
|
130
|
+
let shape = shapes.get(cfg);
|
|
131
|
+
if (!shape) {
|
|
132
|
+
shape = authShape(cfg, home);
|
|
133
|
+
shapes.set(cfg, shape);
|
|
134
|
+
}
|
|
135
|
+
const subLogin = shape.oauth &&
|
|
136
|
+
(shape.billingType ?? "").includes("subscription") &&
|
|
137
|
+
ORG_SEAT_TYPES.has(shape.orgType ?? "");
|
|
138
|
+
const { cls } = billingClass(s, rows, shape);
|
|
139
|
+
counts[`billing_${cls}`] = (counts[`billing_${cls}`] ?? 0) + 1;
|
|
140
|
+
if (cls === "subscription_org") {
|
|
141
|
+
// Recorded in the audit only: the screen never shows a hit count or overage dollars.
|
|
142
|
+
const acct = seatInputs.get(cfg) ?? { extra: shape.extraUsage, ss: [] };
|
|
143
|
+
acct.ss.push({ rows, rates: rows.map((r) => ruleRate(origContext(r)).screen), paths });
|
|
144
|
+
seatInputs.set(cfg, acct);
|
|
145
|
+
}
|
|
146
|
+
const rowsIn = rows.filter((r) => r.t >= cut);
|
|
147
|
+
if (!rowsIn.length)
|
|
148
|
+
return;
|
|
149
|
+
// Every class is priced at API list prices; the screen says so.
|
|
150
|
+
const e = estimate(rowsIn);
|
|
151
|
+
const end = rowsIn.reduce((m, r) => Math.max(m, r.t), -Infinity);
|
|
152
|
+
const sc = {
|
|
153
|
+
label: labelFor(prettyProject(s.pdir)),
|
|
154
|
+
end,
|
|
155
|
+
actual: e.actual,
|
|
156
|
+
saved: e.saved,
|
|
157
|
+
};
|
|
158
|
+
sessions.push(sc);
|
|
159
|
+
if (cls === "subscription_org")
|
|
160
|
+
subSessions.push(sc);
|
|
161
|
+
orgLogin ||= subLogin;
|
|
162
|
+
});
|
|
163
|
+
const seat = [...seatInputs.values()].map((a) => analyzeSeat(a.ss, a.extra, now));
|
|
164
|
+
const use = subSessions.length ? subSessions : sessions;
|
|
165
|
+
const sub = subSessions.length || orgLogin
|
|
166
|
+
? {
|
|
167
|
+
sessions: use.length,
|
|
168
|
+
actual: use.reduce((t, r) => t + r.actual, 0),
|
|
169
|
+
saved: use.reduce((t, r) => t + r.saved, 0),
|
|
170
|
+
}
|
|
171
|
+
: null;
|
|
172
|
+
const { lines, audit, variation } = buildScreen({ sessions, sub });
|
|
173
|
+
assertScreenBudget(lines);
|
|
174
|
+
audit.seat = {
|
|
175
|
+
shown: null,
|
|
176
|
+
raw: seat,
|
|
177
|
+
method: "seat.analyzeSeat per Team/Enterprise account; recorded, never shown",
|
|
178
|
+
};
|
|
179
|
+
return { lines, audit, variation, counts, seconds: (Date.now() - t0) / 1000 };
|
|
180
|
+
}
|
|
181
|
+
/** The screen's hard budget: a regression in buildScreen's line dropping must fail loudly. */
|
|
182
|
+
export function assertScreenBudget(lines) {
|
|
183
|
+
if (lines.length > MAX_LINES || lines.some((l) => l.length > WIDTH)) {
|
|
184
|
+
throw new Error("first-run screen exceeded its 16 x 78 budget");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** A transcript's mtime (ms), 0 when it vanished since discovery (it then sorts first). */
|
|
188
|
+
export function mtimeOf(p) {
|
|
189
|
+
try {
|
|
190
|
+
return statSync(p).mtimeMs;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Show the screen and write the local audit. Never throws: a failed look never blocks a launch. */
|
|
197
|
+
export function showFirstRun(deps) {
|
|
198
|
+
let res;
|
|
199
|
+
try {
|
|
200
|
+
res = deps.compute();
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
deps.note(`mnemom agent: could not look at your sessions (${err instanceof Error ? err.message : String(err)}).`);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
deps.print("");
|
|
207
|
+
for (const l of res.lines)
|
|
208
|
+
deps.print(l);
|
|
209
|
+
deps.print("");
|
|
210
|
+
try {
|
|
211
|
+
writePrivate(join(deps.mnemomDir, AUDIT_FILE), `${JSON.stringify({ at: deps.now().toISOString(), seconds: res.seconds, counts: res.counts, numbers: res.audit, lines: res.lines }, null, 1)}\n`);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
/* the audit is a convenience; the screen already printed */
|
|
215
|
+
}
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The first-launch hook: offer once (interactive only), record the answer, show on yes.
|
|
220
|
+
* Returns what happened, for tests and the caller's logging.
|
|
221
|
+
*/
|
|
222
|
+
export async function maybeOfferFirstRun(deps) {
|
|
223
|
+
if (!deps.interactive)
|
|
224
|
+
return "skipped"; // never read or write state off a terminal
|
|
225
|
+
const path = statePath(deps.mnemomDir);
|
|
226
|
+
const st = readState(path);
|
|
227
|
+
if (!shouldOffer(st, deps.interactive))
|
|
228
|
+
return "skipped";
|
|
229
|
+
for (const l of OFFER_TEXT)
|
|
230
|
+
deps.print(l);
|
|
231
|
+
const ans = parseAnswer(await deps.ask(OFFER_QUESTION));
|
|
232
|
+
try {
|
|
233
|
+
writeState(path, recordAnswer(st, ans, deps.now()));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* an unwritable state dir means we may ask again; never block the launch */
|
|
237
|
+
}
|
|
238
|
+
if (ans === "never")
|
|
239
|
+
return "never";
|
|
240
|
+
if (ans === "no")
|
|
241
|
+
return "declined";
|
|
242
|
+
return showFirstRun(deps) ? "shown" : "failed";
|
|
243
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only, streamed line access to Claude Code transcripts for the first-run look
|
|
3
|
+
* (lib/first-run). Transcripts can be hundreds of MB, so files are read in fixed chunks
|
|
4
|
+
* and handed out one line (a Buffer view, no copy) at a time; callers decode only the
|
|
5
|
+
* lines they need. Opened read-only; never written.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Call `onLine` for every line of `path` (without the trailing newline). Return `false`
|
|
9
|
+
* from `onLine` to stop early. `maxBytes` caps how much of the file is read (the head
|
|
10
|
+
* scan). Returns false when the file cannot be opened.
|
|
11
|
+
*/
|
|
12
|
+
export declare function forEachLine(path: string, onLine: (line: Buffer) => unknown, maxBytes?: number): boolean;
|
|
13
|
+
/** JSON.parse that returns null instead of throwing. */
|
|
14
|
+
export declare function parseJson(line: Buffer | string): Record<string, unknown> | null;
|
|
15
|
+
/** ISO-8601 → epoch seconds, or null. */
|
|
16
|
+
export declare function isoSeconds(v: unknown): number | null;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only, streamed line access to Claude Code transcripts for the first-run look
|
|
3
|
+
* (lib/first-run). Transcripts can be hundreds of MB, so files are read in fixed chunks
|
|
4
|
+
* and handed out one line (a Buffer view, no copy) at a time; callers decode only the
|
|
5
|
+
* lines they need. Opened read-only; never written.
|
|
6
|
+
*/
|
|
7
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
8
|
+
const CHUNK = 4 * 1024 * 1024;
|
|
9
|
+
const NL = 0x0a;
|
|
10
|
+
/**
|
|
11
|
+
* Call `onLine` for every line of `path` (without the trailing newline). Return `false`
|
|
12
|
+
* from `onLine` to stop early. `maxBytes` caps how much of the file is read (the head
|
|
13
|
+
* scan). Returns false when the file cannot be opened.
|
|
14
|
+
*/
|
|
15
|
+
export function forEachLine(path, onLine, maxBytes = Infinity) {
|
|
16
|
+
let fd;
|
|
17
|
+
try {
|
|
18
|
+
fd = openSync(path, "r");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const buf = Buffer.allocUnsafe(CHUNK);
|
|
25
|
+
let carry = null;
|
|
26
|
+
let readTotal = 0;
|
|
27
|
+
for (;;) {
|
|
28
|
+
if (readTotal >= maxBytes)
|
|
29
|
+
break;
|
|
30
|
+
const n = readSync(fd, buf, 0, Math.min(CHUNK, maxBytes - readTotal), null);
|
|
31
|
+
if (n <= 0)
|
|
32
|
+
break;
|
|
33
|
+
readTotal += n;
|
|
34
|
+
const view = buf.subarray(0, n);
|
|
35
|
+
let start = 0;
|
|
36
|
+
let nl = view.indexOf(NL, start);
|
|
37
|
+
while (nl !== -1) {
|
|
38
|
+
let line = view.subarray(start, nl);
|
|
39
|
+
if (carry) {
|
|
40
|
+
line = Buffer.concat([carry, line]);
|
|
41
|
+
carry = null;
|
|
42
|
+
}
|
|
43
|
+
if (onLine(line) === false)
|
|
44
|
+
return true;
|
|
45
|
+
start = nl + 1;
|
|
46
|
+
nl = view.indexOf(NL, start);
|
|
47
|
+
}
|
|
48
|
+
if (start < n) {
|
|
49
|
+
const rest = Buffer.from(view.subarray(start, n));
|
|
50
|
+
carry = carry ? Buffer.concat([carry, rest]) : rest;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (carry && carry.length)
|
|
54
|
+
onLine(carry);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** JSON.parse that returns null instead of throwing. */
|
|
65
|
+
export function parseJson(line) {
|
|
66
|
+
try {
|
|
67
|
+
const v = JSON.parse(typeof line === "string" ? line : line.toString("utf8"));
|
|
68
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** ISO-8601 → epoch seconds, or null. */
|
|
75
|
+
export function isoSeconds(v) {
|
|
76
|
+
if (typeof v !== "string" || !v)
|
|
77
|
+
return null;
|
|
78
|
+
const ms = Date.parse(v);
|
|
79
|
+
return Number.isFinite(ms) ? ms / 1000 : null;
|
|
80
|
+
}
|