hacklab 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -25
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +18 -19
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/scan.d.ts.map +1 -1
- package/dist/commands/scan.js +5 -2
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +32 -26
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +32 -17
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.d.ts +8 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/prompt-consent.d.ts +37 -23
- package/dist/prompt-consent.d.ts.map +1 -1
- package/dist/prompt-consent.js +75 -58
- package/dist/prompt-consent.js.map +1 -1
- package/dist/prompt-stats.d.ts +149 -7
- package/dist/prompt-stats.d.ts.map +1 -1
- package/dist/prompt-stats.js +288 -28
- package/dist/prompt-stats.js.map +1 -1
- package/dist/scanners/incremental.d.ts +131 -17
- package/dist/scanners/incremental.d.ts.map +1 -1
- package/dist/scanners/incremental.js +252 -74
- package/dist/scanners/incremental.js.map +1 -1
- package/dist/scanners/index.d.ts +7 -3
- package/dist/scanners/index.d.ts.map +1 -1
- package/dist/scanners/index.js +8 -40
- package/dist/scanners/index.js.map +1 -1
- package/dist/scanners/util.d.ts +32 -15
- package/dist/scanners/util.d.ts.map +1 -1
- package/dist/scanners/util.js +10 -34
- package/dist/scanners/util.js.map +1 -1
- package/dist/sync.d.ts +3 -3
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +32 -14
- package/dist/sync.js.map +1 -1
- package/package.json +1 -1
package/dist/prompt-stats.d.ts
CHANGED
|
@@ -1,15 +1,61 @@
|
|
|
1
1
|
/** Buckets are `1..bucketMax`, so the axis stays readable at any prompt length. */
|
|
2
2
|
export declare const PROMPT_LENGTH_BUCKET_MIN = 10;
|
|
3
3
|
export declare const PROMPT_LENGTH_BUCKET_MAX = 100;
|
|
4
|
-
/** The percentile that sets `bucketMax`; everything above lands in
|
|
5
|
-
export declare const
|
|
4
|
+
/** The percentile that sets `bucketMax`; everything above it lands in `tail`. */
|
|
5
|
+
export declare const PROMPT_LENGTH_AXIS_PERCENTILE = 0.9;
|
|
6
|
+
/** The server's cap on `tail` entries; longer distributions are coarsened. */
|
|
7
|
+
export declare const PROMPT_TAIL_MAX_ENTRIES = 400;
|
|
6
8
|
/** Matches the backend's cap — anything longer is truncated before upload. */
|
|
7
9
|
export declare const CONVERSATION_SAMPLE_MAX_CHARS = 20000;
|
|
10
|
+
/**
|
|
11
|
+
* Which definition of "a prompt the user typed" produced this upload.
|
|
12
|
+
*
|
|
13
|
+
* Version 1 is implicit — every CLI shipped before this field existed counted
|
|
14
|
+
* harness-generated `user` entries (subagent task notifications, skill bodies,
|
|
15
|
+
* slash-command echoes, interrupt markers) as prompts, which inflated the
|
|
16
|
+
* counts and blew out the word tallies. Version 2 is the scan with
|
|
17
|
+
* `isHarnessNoise` in it.
|
|
18
|
+
*
|
|
19
|
+
* It rides on every sync as a top-level `scannerVersion`; the server ignores
|
|
20
|
+
* `promptStats` / `promptActivity` from anything below 2, so an un-upgraded CLI
|
|
21
|
+
* can't keep writing the old numbers into a table that was wiped to fix them.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PROMPT_SCANNER_VERSION = 2;
|
|
24
|
+
/**
|
|
25
|
+
* Is this entry text something the harness wrote rather than the user?
|
|
26
|
+
*
|
|
27
|
+
* Deliberately a prefix test and nothing more — no stripping of inline
|
|
28
|
+
* `<system-reminder>` blocks out of otherwise-typed prompts. Those measured at
|
|
29
|
+
* zero words of real contribution, and rewriting prompt text in place is a
|
|
30
|
+
* bigger risk than the noise it would remove.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isHarnessNoise(text: string): boolean;
|
|
8
33
|
export type PromptStatsProject = {
|
|
9
34
|
repoUrl: string;
|
|
10
35
|
promptCount: number;
|
|
11
36
|
lastActiveAt: string;
|
|
12
37
|
};
|
|
38
|
+
/** One session's running aggregate, as both the tick and a full scan build it. */
|
|
39
|
+
export type PromptSessionAggregate = {
|
|
40
|
+
/** ISO timestamp of the first prompt seen in this session. */
|
|
41
|
+
startedAt: string;
|
|
42
|
+
/** ISO timestamp of the most recent one. */
|
|
43
|
+
lastActiveAt: string;
|
|
44
|
+
promptCount: number;
|
|
45
|
+
};
|
|
46
|
+
/** One day's running tally. Cumulative for this machine, never a delta. */
|
|
47
|
+
export type PromptDayAggregate = {
|
|
48
|
+
prompts: number;
|
|
49
|
+
words: number;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The whole prompt-activity aggregate, keyed for cheap merging: sessions by
|
|
53
|
+
* session id, days by YYYY-MM-DD.
|
|
54
|
+
*/
|
|
55
|
+
export type PromptActivityAggregate = {
|
|
56
|
+
sessions: Record<string, PromptSessionAggregate>;
|
|
57
|
+
daily: Record<string, PromptDayAggregate>;
|
|
58
|
+
};
|
|
13
59
|
export type PromptStats = {
|
|
14
60
|
totalPrompts: number;
|
|
15
61
|
bucketMax: number;
|
|
@@ -17,7 +63,27 @@ export type PromptStats = {
|
|
|
17
63
|
length: number;
|
|
18
64
|
count: number;
|
|
19
65
|
}[];
|
|
66
|
+
/**
|
|
67
|
+
* The exact distribution above `bucketMax`, and the only place those prompts
|
|
68
|
+
* are reported — the histogram stops at `bucketMax`. One entry per distinct
|
|
69
|
+
* word count, ascending, coarsened if there are more than
|
|
70
|
+
* `PROMPT_TAIL_MAX_ENTRIES` of them. Always present, empty when nothing
|
|
71
|
+
* exceeds `bucketMax`: the field's presence is what tells the server this
|
|
72
|
+
* histogram is exact, as opposed to a legacy snapshot whose last bar lumped
|
|
73
|
+
* everything at or above `bucketMax`.
|
|
74
|
+
*/
|
|
75
|
+
tail: {
|
|
76
|
+
length: number;
|
|
77
|
+
count: number;
|
|
78
|
+
}[];
|
|
20
79
|
projects: PromptStatsProject[];
|
|
80
|
+
/**
|
|
81
|
+
* Sessions and per-day counts for the whole local history. Not part of the
|
|
82
|
+
* `promptStats` block on the wire — the caller hands it to `stageFullScan`,
|
|
83
|
+
* which re-bases the tick's incremental state on it and works out which rows
|
|
84
|
+
* this upload still has to carry.
|
|
85
|
+
*/
|
|
86
|
+
activity: PromptActivityAggregate;
|
|
21
87
|
/** Only ever set under the `full` consent tier. */
|
|
22
88
|
conversationSample?: string;
|
|
23
89
|
};
|
|
@@ -30,13 +96,57 @@ export type PromptStats = {
|
|
|
30
96
|
* blocks, so a line only counts when its content is a plain string or an array
|
|
31
97
|
* of nothing but `text` blocks. Sidechain entries (subagent conversations) are
|
|
32
98
|
* the agent talking to itself, not the person typing, so they're excluded.
|
|
99
|
+
*
|
|
100
|
+
* That still leaves the harness's own writing, which is indistinguishable by
|
|
101
|
+
* shape: subagent reports, injected skill bodies, slash-command echoes,
|
|
102
|
+
* interrupt markers. Those are dropped two ways — `isMeta`, which Claude Code
|
|
103
|
+
* sets on the entries it generates, and the text prefixes in
|
|
104
|
+
* `isHarnessNoise` for the ones it doesn't.
|
|
105
|
+
*
|
|
106
|
+
* This is the single chokepoint: both the full scan and the minutely tick go
|
|
107
|
+
* through here, so the histogram, the tail, the per-project counts, the
|
|
108
|
+
* activity aggregate and the technical-score sample all see the same prompts.
|
|
33
109
|
*/
|
|
34
110
|
export declare function promptTextFrom(entry: unknown): string | null;
|
|
35
111
|
/** Whitespace-separated word count. Zero-word prompts are dropped by callers. */
|
|
36
112
|
export declare function countWords(text: string): number;
|
|
113
|
+
/** The server's cap on a session id. Longer than this and the row is rejected. */
|
|
114
|
+
export declare const PROMPT_SESSION_ID_MAX_CHARS = 128;
|
|
115
|
+
/** One prompt, reduced to the three facts the activity aggregate needs. */
|
|
116
|
+
export type PromptLine = {
|
|
117
|
+
sessionId: string;
|
|
118
|
+
/** Canonical ISO-8601 UTC, so string order is time order. */
|
|
119
|
+
timestamp: string;
|
|
120
|
+
words: number;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* A transcript line as prompt activity, or null when it isn't one.
|
|
124
|
+
*
|
|
125
|
+
* Stricter than `promptTextFrom` on purpose: a prompt with no session id or no
|
|
126
|
+
* usable timestamp can't be placed on a session or a day, and the server's
|
|
127
|
+
* schema would reject it, so it counts towards the histogram (which needs
|
|
128
|
+
* neither) and nothing else.
|
|
129
|
+
*/
|
|
130
|
+
export declare function parsePromptLine(line: string): PromptLine | null;
|
|
131
|
+
/** An ISO-8601 UTC string, or null when the value isn't a usable instant. */
|
|
132
|
+
export declare function normalizeTimestamp(value: unknown): string | null;
|
|
133
|
+
export declare function emptyPromptActivity(): PromptActivityAggregate;
|
|
134
|
+
/**
|
|
135
|
+
* Fold one prompt into an activity aggregate, returning the session and date it
|
|
136
|
+
* landed on so an incremental caller can mark exactly those dirty.
|
|
137
|
+
*
|
|
138
|
+
* The date is the UTC day of the timestamp — the same attribution
|
|
139
|
+
* `toDateStr` gives every token daily row, so the two halves of a sync agree
|
|
140
|
+
* about which day a piece of work belongs to.
|
|
141
|
+
*/
|
|
142
|
+
export declare function addPromptToActivity(activity: PromptActivityAggregate, line: PromptLine): {
|
|
143
|
+
sessionId: string;
|
|
144
|
+
date: string;
|
|
145
|
+
};
|
|
37
146
|
/**
|
|
38
|
-
*
|
|
39
|
-
* length rounded up to a multiple of 10, clamped to [10, 100].
|
|
147
|
+
* Where the histogram's axis ends for this user's own distribution: their p90
|
|
148
|
+
* prompt length rounded up to a multiple of 10, clamped to [10, 100]. Anything
|
|
149
|
+
* longer is reported by `buildTail` instead.
|
|
40
150
|
*
|
|
41
151
|
* Per-user rather than fixed because prompt length varies enormously between
|
|
42
152
|
* people — a fixed axis would either crush a terse user's histogram into the
|
|
@@ -44,14 +154,40 @@ export declare function countWords(text: string): number;
|
|
|
44
154
|
*/
|
|
45
155
|
export declare function bucketMaxFor(wordCounts: number[]): number;
|
|
46
156
|
/**
|
|
47
|
-
* Bucket the word counts.
|
|
48
|
-
*
|
|
49
|
-
*
|
|
157
|
+
* Bucket the word counts. Every bucket `1..bucketMax` is an exact word count —
|
|
158
|
+
* there is no overflow bar. Empty buckets are omitted; the chart fills the gaps.
|
|
159
|
+
*
|
|
160
|
+
* Prompts longer than `bucketMax` are not in here at all: they are reported by
|
|
161
|
+
* `buildTail`, and only there. So the histogram and the tail partition the
|
|
162
|
+
* scan — sum(histogram counts) + sum(tail counts) === totalPrompts, always.
|
|
50
163
|
*/
|
|
51
164
|
export declare function buildHistogram(wordCounts: number[], bucketMax: number): {
|
|
52
165
|
length: number;
|
|
53
166
|
count: number;
|
|
54
167
|
}[];
|
|
168
|
+
/**
|
|
169
|
+
* The exact distribution of everything *longer* than `bucketMax`, which the
|
|
170
|
+
* histogram does not cover at all. One entry per distinct word count,
|
|
171
|
+
* ascending; empty (never absent) when nothing exceeds `bucketMax` — the
|
|
172
|
+
* field's presence on the wire is the marker that the histogram's bars are
|
|
173
|
+
* exact.
|
|
174
|
+
*
|
|
175
|
+
* Lengths only — no prompt text is involved here, or anywhere near it.
|
|
176
|
+
*
|
|
177
|
+
* A machine with a long history can have thousands of distinct long lengths,
|
|
178
|
+
* far past what the server accepts, so an over-cap tail is coarsened: lengths
|
|
179
|
+
* are rounded to multiples of 2, then 4, 8, … until at most
|
|
180
|
+
* `PROMPT_TAIL_MAX_ENTRIES` entries remain, summing the counts that collapse
|
|
181
|
+
* together. Rounding is *up* so that a coarsened entry still sits above
|
|
182
|
+
* `bucketMax` — rounding down could claim a length inside the histogram's own
|
|
183
|
+
* exact range for a prompt the histogram never counted.
|
|
184
|
+
*
|
|
185
|
+
* Deterministic: the result depends only on the multiset of word counts.
|
|
186
|
+
*/
|
|
187
|
+
export declare function buildTail(wordCounts: number[], bucketMax: number): {
|
|
188
|
+
length: number;
|
|
189
|
+
count: number;
|
|
190
|
+
}[];
|
|
55
191
|
/**
|
|
56
192
|
* The `origin` remote of the repo at `cwd`, or null when there isn't one (not
|
|
57
193
|
* a repo, no origin, no git binary, or the directory is gone). The URL is sent
|
|
@@ -69,4 +205,10 @@ export declare function gitOriginUrl(cwd: string): Promise<string | null>;
|
|
|
69
205
|
export declare function scanPromptStats(options?: {
|
|
70
206
|
includeSample?: boolean;
|
|
71
207
|
}): Promise<PromptStats | null>;
|
|
208
|
+
/**
|
|
209
|
+
* The `promptStats` block as the server takes it. `activity` is deliberately
|
|
210
|
+
* dropped: it is local bookkeeping for the tick's incremental state, and it
|
|
211
|
+
* travels under its own top-level `promptActivity` field instead.
|
|
212
|
+
*/
|
|
213
|
+
export declare function promptStatsPayload(stats: PromptStats): Omit<PromptStats, 'activity'>;
|
|
72
214
|
//# sourceMappingURL=prompt-stats.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prompt-stats.d.ts","sourceRoot":"","sources":["../src/prompt-stats.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"prompt-stats.d.ts","sourceRoot":"","sources":["../src/prompt-stats.ts"],"names":[],"mappings":"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"}
|
package/dist/prompt-stats.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { findFiles } from './scanners/util.js';
|
|
6
|
+
import { findFiles, toDateStr } from './scanners/util.js';
|
|
7
7
|
/**
|
|
8
8
|
* Prompt statistics, computed entirely on this machine from the local Claude
|
|
9
9
|
* Code transcripts and uploaded only under an explicit consent tier (see
|
|
10
10
|
* prompt-consent.ts).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Three things come out of a full scan:
|
|
13
13
|
*
|
|
14
|
-
* - a histogram of how long the user's own prompts are, in words
|
|
14
|
+
* - a histogram of how long the user's own prompts are, in words — every
|
|
15
|
+
* bucket an exact word count — plus the tail, which is where the prompts
|
|
16
|
+
* longer than the axis are reported, and their only home
|
|
15
17
|
* - a per-project prompt count, keyed by the project's git origin
|
|
18
|
+
* - the prompt *activity* aggregate: per-session start/end/count and a
|
|
19
|
+
* per-day prompt/word tally. That one is also what the minutely tick
|
|
20
|
+
* accumulates incrementally (scanners/incremental.ts), so a full scan can
|
|
21
|
+
* re-base the tick's state without either drifting from the other.
|
|
16
22
|
*
|
|
17
23
|
* and, under the `full` tier only, a sample of the raw prompt text so the
|
|
18
24
|
* backend can score "how technical is this person's prompting". The backend
|
|
@@ -26,10 +32,76 @@ const execFileAsync = promisify(execFile);
|
|
|
26
32
|
/** Buckets are `1..bucketMax`, so the axis stays readable at any prompt length. */
|
|
27
33
|
export const PROMPT_LENGTH_BUCKET_MIN = 10;
|
|
28
34
|
export const PROMPT_LENGTH_BUCKET_MAX = 100;
|
|
29
|
-
/** The percentile that sets `bucketMax`; everything above lands in
|
|
30
|
-
export const
|
|
35
|
+
/** The percentile that sets `bucketMax`; everything above it lands in `tail`. */
|
|
36
|
+
export const PROMPT_LENGTH_AXIS_PERCENTILE = 0.9;
|
|
37
|
+
/** The server's cap on `tail` entries; longer distributions are coarsened. */
|
|
38
|
+
export const PROMPT_TAIL_MAX_ENTRIES = 400;
|
|
31
39
|
/** Matches the backend's cap — anything longer is truncated before upload. */
|
|
32
40
|
export const CONVERSATION_SAMPLE_MAX_CHARS = 20_000;
|
|
41
|
+
/**
|
|
42
|
+
* Which definition of "a prompt the user typed" produced this upload.
|
|
43
|
+
*
|
|
44
|
+
* Version 1 is implicit — every CLI shipped before this field existed counted
|
|
45
|
+
* harness-generated `user` entries (subagent task notifications, skill bodies,
|
|
46
|
+
* slash-command echoes, interrupt markers) as prompts, which inflated the
|
|
47
|
+
* counts and blew out the word tallies. Version 2 is the scan with
|
|
48
|
+
* `isHarnessNoise` in it.
|
|
49
|
+
*
|
|
50
|
+
* It rides on every sync as a top-level `scannerVersion`; the server ignores
|
|
51
|
+
* `promptStats` / `promptActivity` from anything below 2, so an un-upgraded CLI
|
|
52
|
+
* can't keep writing the old numbers into a table that was wiped to fix them.
|
|
53
|
+
*/
|
|
54
|
+
export const PROMPT_SCANNER_VERSION = 2;
|
|
55
|
+
/**
|
|
56
|
+
* Prefixes that mark a `user` transcript entry as written by Claude Code
|
|
57
|
+
* itself rather than typed by the person at the keyboard.
|
|
58
|
+
*
|
|
59
|
+
* These all arrive in exactly the shape a real prompt does — `type: 'user'`,
|
|
60
|
+
* not a sidechain, content a plain string or `text` blocks — so nothing but the
|
|
61
|
+
* text tells them apart. Measured over 194 local transcripts they were 39% of
|
|
62
|
+
* everything the scan called a prompt and 65% of its words, because a machine's
|
|
63
|
+
* report or an injected skill body is an order of magnitude longer than
|
|
64
|
+
* anything a human types.
|
|
65
|
+
*
|
|
66
|
+
* Matched against the entry's text after `trimStart()`, and anchored to the
|
|
67
|
+
* start on purpose: a prompt that *quotes* one of these tags mid-sentence
|
|
68
|
+
* ("why does <command-name> show up twice?") is a real prompt and still counts.
|
|
69
|
+
*/
|
|
70
|
+
const HARNESS_NOISE_PREFIXES = [
|
|
71
|
+
// The body of a skill, injected when one is invoked. Up to ~11.5k words.
|
|
72
|
+
'Base directory for this skill:',
|
|
73
|
+
// A background subagent's report, handed back as a user turn: ~400 words
|
|
74
|
+
// each and the single largest source of fake prompts.
|
|
75
|
+
'<task-notification>',
|
|
76
|
+
// The wrapper around a slash command's expansion, its echo and its output.
|
|
77
|
+
'<local-command-caveat>',
|
|
78
|
+
'<command-name>',
|
|
79
|
+
'<command-message>',
|
|
80
|
+
'<command-args>',
|
|
81
|
+
'<local-command-stdout>',
|
|
82
|
+
// Context the harness injects into a turn. Nobody types it.
|
|
83
|
+
'<system-reminder>',
|
|
84
|
+
// Written when the user hits escape — the absence of a prompt, not one.
|
|
85
|
+
'[Request interrupted',
|
|
86
|
+
// `!`-prefixed bash mode: the command and whatever it printed.
|
|
87
|
+
'<bash-input>',
|
|
88
|
+
'<bash-stdout>',
|
|
89
|
+
'<bash-stderr>',
|
|
90
|
+
// Text a UserPromptSubmit hook appended to the turn.
|
|
91
|
+
'<user-prompt-submit-hook>',
|
|
92
|
+
];
|
|
93
|
+
/**
|
|
94
|
+
* Is this entry text something the harness wrote rather than the user?
|
|
95
|
+
*
|
|
96
|
+
* Deliberately a prefix test and nothing more — no stripping of inline
|
|
97
|
+
* `<system-reminder>` blocks out of otherwise-typed prompts. Those measured at
|
|
98
|
+
* zero words of real contribution, and rewriting prompt text in place is a
|
|
99
|
+
* bigger risk than the noise it would remove.
|
|
100
|
+
*/
|
|
101
|
+
export function isHarnessNoise(text) {
|
|
102
|
+
const start = text.trimStart();
|
|
103
|
+
return HARNESS_NOISE_PREFIXES.some((prefix) => start.startsWith(prefix));
|
|
104
|
+
}
|
|
33
105
|
/**
|
|
34
106
|
* The user's own prompt text from one transcript line, or null when the line
|
|
35
107
|
* isn't one.
|
|
@@ -39,6 +111,16 @@ export const CONVERSATION_SAMPLE_MAX_CHARS = 20_000;
|
|
|
39
111
|
* blocks, so a line only counts when its content is a plain string or an array
|
|
40
112
|
* of nothing but `text` blocks. Sidechain entries (subagent conversations) are
|
|
41
113
|
* the agent talking to itself, not the person typing, so they're excluded.
|
|
114
|
+
*
|
|
115
|
+
* That still leaves the harness's own writing, which is indistinguishable by
|
|
116
|
+
* shape: subagent reports, injected skill bodies, slash-command echoes,
|
|
117
|
+
* interrupt markers. Those are dropped two ways — `isMeta`, which Claude Code
|
|
118
|
+
* sets on the entries it generates, and the text prefixes in
|
|
119
|
+
* `isHarnessNoise` for the ones it doesn't.
|
|
120
|
+
*
|
|
121
|
+
* This is the single chokepoint: both the full scan and the minutely tick go
|
|
122
|
+
* through here, so the histogram, the tail, the per-project counts, the
|
|
123
|
+
* activity aggregate and the technical-score sample all see the same prompts.
|
|
42
124
|
*/
|
|
43
125
|
export function promptTextFrom(entry) {
|
|
44
126
|
if (!entry || typeof entry !== 'object')
|
|
@@ -48,9 +130,12 @@ export function promptTextFrom(entry) {
|
|
|
48
130
|
return null;
|
|
49
131
|
if (line.isSidechain === true)
|
|
50
132
|
return null;
|
|
133
|
+
if (line.isMeta === true)
|
|
134
|
+
return null;
|
|
51
135
|
const content = line.message?.content;
|
|
52
|
-
if (typeof content === 'string')
|
|
53
|
-
return content;
|
|
136
|
+
if (typeof content === 'string') {
|
|
137
|
+
return isHarnessNoise(content) ? null : content;
|
|
138
|
+
}
|
|
54
139
|
if (!Array.isArray(content))
|
|
55
140
|
return null;
|
|
56
141
|
const texts = [];
|
|
@@ -64,16 +149,103 @@ export function promptTextFrom(entry) {
|
|
|
64
149
|
return null;
|
|
65
150
|
texts.push(text);
|
|
66
151
|
}
|
|
67
|
-
|
|
152
|
+
if (texts.length === 0)
|
|
153
|
+
return null;
|
|
154
|
+
const joined = texts.join('\n');
|
|
155
|
+
return isHarnessNoise(joined) ? null : joined;
|
|
68
156
|
}
|
|
69
157
|
/** Whitespace-separated word count. Zero-word prompts are dropped by callers. */
|
|
70
158
|
export function countWords(text) {
|
|
71
159
|
const matches = text.match(/\S+/g);
|
|
72
160
|
return matches ? matches.length : 0;
|
|
73
161
|
}
|
|
162
|
+
/** The server's cap on a session id. Longer than this and the row is rejected. */
|
|
163
|
+
export const PROMPT_SESSION_ID_MAX_CHARS = 128;
|
|
164
|
+
/**
|
|
165
|
+
* A transcript line as prompt activity, or null when it isn't one.
|
|
166
|
+
*
|
|
167
|
+
* Stricter than `promptTextFrom` on purpose: a prompt with no session id or no
|
|
168
|
+
* usable timestamp can't be placed on a session or a day, and the server's
|
|
169
|
+
* schema would reject it, so it counts towards the histogram (which needs
|
|
170
|
+
* neither) and nothing else.
|
|
171
|
+
*/
|
|
172
|
+
export function parsePromptLine(line) {
|
|
173
|
+
if (!line.trim())
|
|
174
|
+
return null;
|
|
175
|
+
let parsed;
|
|
176
|
+
try {
|
|
177
|
+
parsed = JSON.parse(line);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const text = promptTextFrom(parsed);
|
|
183
|
+
if (text === null)
|
|
184
|
+
return null;
|
|
185
|
+
const words = countWords(text);
|
|
186
|
+
if (words <= 0)
|
|
187
|
+
return null;
|
|
188
|
+
const entry = parsed;
|
|
189
|
+
const sessionId = typeof entry.sessionId === 'string' ? entry.sessionId.trim() : '';
|
|
190
|
+
if (!sessionId || sessionId.length > PROMPT_SESSION_ID_MAX_CHARS)
|
|
191
|
+
return null;
|
|
192
|
+
const timestamp = normalizeTimestamp(entry.timestamp);
|
|
193
|
+
if (!timestamp)
|
|
194
|
+
return null;
|
|
195
|
+
return { sessionId, timestamp, words };
|
|
196
|
+
}
|
|
197
|
+
/** An ISO-8601 UTC string, or null when the value isn't a usable instant. */
|
|
198
|
+
export function normalizeTimestamp(value) {
|
|
199
|
+
if (typeof value !== 'string' && typeof value !== 'number')
|
|
200
|
+
return null;
|
|
201
|
+
const at = typeof value === 'number' ? value : Date.parse(value);
|
|
202
|
+
if (!Number.isFinite(at))
|
|
203
|
+
return null;
|
|
204
|
+
return new Date(at).toISOString();
|
|
205
|
+
}
|
|
206
|
+
export function emptyPromptActivity() {
|
|
207
|
+
return { sessions: {}, daily: {} };
|
|
208
|
+
}
|
|
74
209
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
210
|
+
* Fold one prompt into an activity aggregate, returning the session and date it
|
|
211
|
+
* landed on so an incremental caller can mark exactly those dirty.
|
|
212
|
+
*
|
|
213
|
+
* The date is the UTC day of the timestamp — the same attribution
|
|
214
|
+
* `toDateStr` gives every token daily row, so the two halves of a sync agree
|
|
215
|
+
* about which day a piece of work belongs to.
|
|
216
|
+
*/
|
|
217
|
+
export function addPromptToActivity(activity, line) {
|
|
218
|
+
const session = activity.sessions[line.sessionId];
|
|
219
|
+
if (session) {
|
|
220
|
+
if (line.timestamp < session.startedAt)
|
|
221
|
+
session.startedAt = line.timestamp;
|
|
222
|
+
if (line.timestamp > session.lastActiveAt) {
|
|
223
|
+
session.lastActiveAt = line.timestamp;
|
|
224
|
+
}
|
|
225
|
+
session.promptCount += 1;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
activity.sessions[line.sessionId] = {
|
|
229
|
+
startedAt: line.timestamp,
|
|
230
|
+
lastActiveAt: line.timestamp,
|
|
231
|
+
promptCount: 1,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const date = toDateStr(line.timestamp);
|
|
235
|
+
const day = activity.daily[date];
|
|
236
|
+
if (day) {
|
|
237
|
+
day.prompts += 1;
|
|
238
|
+
day.words += line.words;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
activity.daily[date] = { prompts: 1, words: line.words };
|
|
242
|
+
}
|
|
243
|
+
return { sessionId: line.sessionId, date };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Where the histogram's axis ends for this user's own distribution: their p90
|
|
247
|
+
* prompt length rounded up to a multiple of 10, clamped to [10, 100]. Anything
|
|
248
|
+
* longer is reported by `buildTail` instead.
|
|
77
249
|
*
|
|
78
250
|
* Per-user rather than fixed because prompt length varies enormously between
|
|
79
251
|
* people — a fixed axis would either crush a terse user's histogram into the
|
|
@@ -83,23 +255,72 @@ export function bucketMaxFor(wordCounts) {
|
|
|
83
255
|
if (wordCounts.length === 0)
|
|
84
256
|
return PROMPT_LENGTH_BUCKET_MIN;
|
|
85
257
|
const sorted = [...wordCounts].sort((a, b) => a - b);
|
|
86
|
-
const index = Math.min(sorted.length - 1, Math.floor(sorted.length *
|
|
258
|
+
const index = Math.min(sorted.length - 1, Math.floor(sorted.length * PROMPT_LENGTH_AXIS_PERCENTILE));
|
|
87
259
|
const p90 = sorted[index] ?? PROMPT_LENGTH_BUCKET_MIN;
|
|
88
260
|
const rounded = Math.ceil(p90 / 10) * 10;
|
|
89
261
|
return Math.min(PROMPT_LENGTH_BUCKET_MAX, Math.max(PROMPT_LENGTH_BUCKET_MIN, rounded));
|
|
90
262
|
}
|
|
91
263
|
/**
|
|
92
|
-
* Bucket the word counts.
|
|
93
|
-
*
|
|
94
|
-
*
|
|
264
|
+
* Bucket the word counts. Every bucket `1..bucketMax` is an exact word count —
|
|
265
|
+
* there is no overflow bar. Empty buckets are omitted; the chart fills the gaps.
|
|
266
|
+
*
|
|
267
|
+
* Prompts longer than `bucketMax` are not in here at all: they are reported by
|
|
268
|
+
* `buildTail`, and only there. So the histogram and the tail partition the
|
|
269
|
+
* scan — sum(histogram counts) + sum(tail counts) === totalPrompts, always.
|
|
95
270
|
*/
|
|
96
271
|
export function buildHistogram(wordCounts, bucketMax) {
|
|
97
272
|
const counts = new Map();
|
|
98
273
|
for (const words of wordCounts) {
|
|
99
274
|
if (words <= 0)
|
|
100
275
|
continue;
|
|
101
|
-
|
|
102
|
-
|
|
276
|
+
if (words > bucketMax)
|
|
277
|
+
continue;
|
|
278
|
+
counts.set(words, (counts.get(words) ?? 0) + 1);
|
|
279
|
+
}
|
|
280
|
+
return [...counts.entries()]
|
|
281
|
+
.map(([length, count]) => ({ length, count }))
|
|
282
|
+
.sort((a, b) => a.length - b.length);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The exact distribution of everything *longer* than `bucketMax`, which the
|
|
286
|
+
* histogram does not cover at all. One entry per distinct word count,
|
|
287
|
+
* ascending; empty (never absent) when nothing exceeds `bucketMax` — the
|
|
288
|
+
* field's presence on the wire is the marker that the histogram's bars are
|
|
289
|
+
* exact.
|
|
290
|
+
*
|
|
291
|
+
* Lengths only — no prompt text is involved here, or anywhere near it.
|
|
292
|
+
*
|
|
293
|
+
* A machine with a long history can have thousands of distinct long lengths,
|
|
294
|
+
* far past what the server accepts, so an over-cap tail is coarsened: lengths
|
|
295
|
+
* are rounded to multiples of 2, then 4, 8, … until at most
|
|
296
|
+
* `PROMPT_TAIL_MAX_ENTRIES` entries remain, summing the counts that collapse
|
|
297
|
+
* together. Rounding is *up* so that a coarsened entry still sits above
|
|
298
|
+
* `bucketMax` — rounding down could claim a length inside the histogram's own
|
|
299
|
+
* exact range for a prompt the histogram never counted.
|
|
300
|
+
*
|
|
301
|
+
* Deterministic: the result depends only on the multiset of word counts.
|
|
302
|
+
*/
|
|
303
|
+
export function buildTail(wordCounts, bucketMax) {
|
|
304
|
+
const exact = new Map();
|
|
305
|
+
for (const words of wordCounts) {
|
|
306
|
+
if (words <= bucketMax)
|
|
307
|
+
continue;
|
|
308
|
+
exact.set(words, (exact.get(words) ?? 0) + 1);
|
|
309
|
+
}
|
|
310
|
+
if (exact.size === 0)
|
|
311
|
+
return [];
|
|
312
|
+
let counts = exact;
|
|
313
|
+
let step = 1;
|
|
314
|
+
while (counts.size > PROMPT_TAIL_MAX_ENTRIES) {
|
|
315
|
+
step *= 2;
|
|
316
|
+
const coarser = new Map();
|
|
317
|
+
// Always coarsen from the exact counts, so the rounding is one clean
|
|
318
|
+
// division rather than a rounding of a rounding.
|
|
319
|
+
for (const [length, count] of exact) {
|
|
320
|
+
const rounded = Math.ceil(length / step) * step;
|
|
321
|
+
coarser.set(rounded, (coarser.get(rounded) ?? 0) + count);
|
|
322
|
+
}
|
|
323
|
+
counts = coarser;
|
|
103
324
|
}
|
|
104
325
|
return [...counts.entries()]
|
|
105
326
|
.map(([length, count]) => ({ length, count }))
|
|
@@ -121,6 +342,22 @@ export async function gitOriginUrl(cwd) {
|
|
|
121
342
|
return null;
|
|
122
343
|
}
|
|
123
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Newest transcript first. The sample is meant to be the user's *recent*
|
|
347
|
+
* prompting, so the walk order has to be time order — the directory walk's own
|
|
348
|
+
* order is alphabetical and would hand the scorer whatever happens to sort
|
|
349
|
+
* first, which for a long-lived machine is usually a project abandoned years
|
|
350
|
+
* ago. A file we can't stat sorts last rather than dropping out.
|
|
351
|
+
*/
|
|
352
|
+
async function byMtimeDesc(paths) {
|
|
353
|
+
const stamped = await Promise.all(paths.map(async (path) => ({
|
|
354
|
+
path,
|
|
355
|
+
mtimeMs: await stat(path)
|
|
356
|
+
.then((s) => s.mtimeMs)
|
|
357
|
+
.catch(() => 0),
|
|
358
|
+
})));
|
|
359
|
+
return stamped.sort((a, b) => b.mtimeMs - a.mtimeMs).map((f) => f.path);
|
|
360
|
+
}
|
|
124
361
|
/**
|
|
125
362
|
* Scan the local Claude Code transcripts.
|
|
126
363
|
*
|
|
@@ -130,10 +367,11 @@ export async function gitOriginUrl(cwd) {
|
|
|
130
367
|
*/
|
|
131
368
|
export async function scanPromptStats(options = {}) {
|
|
132
369
|
const root = join(homedir(), '.claude', 'projects');
|
|
133
|
-
const files = await findFiles(root, '.jsonl');
|
|
370
|
+
const files = await byMtimeDesc(await findFiles(root, '.jsonl'));
|
|
134
371
|
if (files.length === 0)
|
|
135
372
|
return null;
|
|
136
373
|
const wordCounts = [];
|
|
374
|
+
const activity = emptyPromptActivity();
|
|
137
375
|
const sampleParts = [];
|
|
138
376
|
let sampleChars = 0;
|
|
139
377
|
// Keyed by the transcript's project directory, which is Claude Code's own
|
|
@@ -154,6 +392,12 @@ export async function scanPromptStats(options = {}) {
|
|
|
154
392
|
catch {
|
|
155
393
|
continue;
|
|
156
394
|
}
|
|
395
|
+
// Within a file the newest prompts are last, so the sample is drained in
|
|
396
|
+
// reverse after the file is read. Only collected while the budget is still
|
|
397
|
+
// open, so a full sample doesn't hold a whole history in memory.
|
|
398
|
+
const collectSample = options.includeSample === true &&
|
|
399
|
+
sampleChars < CONVERSATION_SAMPLE_MAX_CHARS;
|
|
400
|
+
const fileSample = [];
|
|
157
401
|
for (const line of content.split('\n')) {
|
|
158
402
|
if (!line.trim())
|
|
159
403
|
continue;
|
|
@@ -176,17 +420,22 @@ export async function scanPromptStats(options = {}) {
|
|
|
176
420
|
continue;
|
|
177
421
|
wordCounts.push(words);
|
|
178
422
|
project.promptCount += 1;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
project.lastActiveAt = at;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
if (options.includeSample &&
|
|
186
|
-
sampleChars < CONVERSATION_SAMPLE_MAX_CHARS) {
|
|
187
|
-
sampleParts.push(text);
|
|
188
|
-
sampleChars += text.length + 2;
|
|
423
|
+
const at = Date.parse(String(entry.timestamp));
|
|
424
|
+
if (Number.isFinite(at) && at > project.lastActiveAt) {
|
|
425
|
+
project.lastActiveAt = at;
|
|
189
426
|
}
|
|
427
|
+
const promptLine = parsePromptLine(line);
|
|
428
|
+
if (promptLine)
|
|
429
|
+
addPromptToActivity(activity, promptLine);
|
|
430
|
+
if (collectSample)
|
|
431
|
+
fileSample.push(text);
|
|
432
|
+
}
|
|
433
|
+
for (let i = fileSample.length - 1; i >= 0; i--) {
|
|
434
|
+
if (sampleChars >= CONVERSATION_SAMPLE_MAX_CHARS)
|
|
435
|
+
break;
|
|
436
|
+
const text = fileSample[i];
|
|
437
|
+
sampleParts.push(text);
|
|
438
|
+
sampleChars += text.length + 2;
|
|
190
439
|
}
|
|
191
440
|
}
|
|
192
441
|
if (wordCounts.length === 0)
|
|
@@ -196,7 +445,9 @@ export async function scanPromptStats(options = {}) {
|
|
|
196
445
|
totalPrompts: wordCounts.length,
|
|
197
446
|
bucketMax,
|
|
198
447
|
histogram: buildHistogram(wordCounts, bucketMax),
|
|
448
|
+
tail: buildTail(wordCounts, bucketMax),
|
|
199
449
|
projects: await resolveProjects(byProjectDir),
|
|
450
|
+
activity,
|
|
200
451
|
};
|
|
201
452
|
if (options.includeSample && sampleParts.length > 0) {
|
|
202
453
|
stats.conversationSample = sampleParts
|
|
@@ -205,6 +456,15 @@ export async function scanPromptStats(options = {}) {
|
|
|
205
456
|
}
|
|
206
457
|
return stats;
|
|
207
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* The `promptStats` block as the server takes it. `activity` is deliberately
|
|
461
|
+
* dropped: it is local bookkeeping for the tick's incremental state, and it
|
|
462
|
+
* travels under its own top-level `promptActivity` field instead.
|
|
463
|
+
*/
|
|
464
|
+
export function promptStatsPayload(stats) {
|
|
465
|
+
const { activity: _activity, ...wire } = stats;
|
|
466
|
+
return wire;
|
|
467
|
+
}
|
|
208
468
|
/**
|
|
209
469
|
* Turn the per-directory tallies into repo-keyed entries. Directories with no
|
|
210
470
|
* prompts, no recorded `cwd`, or no git origin drop out — the backend can only
|