claude-usage-limits 1.11.7 → 1.13.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +133 -3
- package/bin/cli.js +1 -0
- package/commands/relay.md +45 -0
- package/commands/voice.md +33 -0
- package/hooks/hooks.json +24 -1
- package/package.json +1 -1
- package/skills/usage-limits/SKILL.md +130 -5
- package/skills/usage-limits/scripts/bars.js +56 -0
- package/skills/usage-limits/scripts/brief.js +257 -17
- package/skills/usage-limits/scripts/codex-lowpower.js +135 -0
- package/skills/usage-limits/scripts/codex.js +92 -22
- package/skills/usage-limits/scripts/feed.js +136 -5
- package/skills/usage-limits/scripts/install-codex-hook.js +71 -20
- package/skills/usage-limits/scripts/lowpower.js +10 -2
- package/skills/usage-limits/scripts/panel.js +180 -15
- package/skills/usage-limits/scripts/pulse.js +82 -22
- package/skills/usage-limits/scripts/reading.js +121 -0
- package/skills/usage-limits/scripts/recommend.js +16 -3
- package/skills/usage-limits/scripts/relay.js +859 -0
- package/skills/usage-limits/scripts/tally.js +7 -8
- package/skills/usage-limits/scripts/usage.js +842 -53
- package/skills/usage-limits/scripts/view.js +150 -8
- package/skills/usage-limits/scripts/voice.js +416 -0
- package/skills/usage-limits/scripts/wake.js +312 -0
|
@@ -102,6 +102,137 @@ function row(key, title, family, picked, now) {
|
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// The Codex block
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
//
|
|
109
|
+
// The other agent's meter, drawn under Claude's in the same visual language and
|
|
110
|
+
// counting the other way.
|
|
111
|
+
//
|
|
112
|
+
// Codex reports what it has SPENT on the wire - every rollout carries
|
|
113
|
+
// `used_percent`, rising - but it shows the user what is LEFT: its own status
|
|
114
|
+
// card computes `100 - used_percent` and prints "82% left". Claude Code does
|
|
115
|
+
// the opposite and says "62% used". Both are right about their own product, and
|
|
116
|
+
// a plugin that reported one product in the other's direction would be
|
|
117
|
+
// misreading a number every time it was checked against the real thing.
|
|
118
|
+
//
|
|
119
|
+
// So the Codex rows carry both figures and every display draws `percentLeft`:
|
|
120
|
+
// the bar drains rather than fills, and the colours turn at the same real
|
|
121
|
+
// moment as Claude's because levelLeft() mirrors level() exactly.
|
|
122
|
+
//
|
|
123
|
+
// The titles are Codex's own, from its status card.
|
|
124
|
+
const CODEX_TITLES = { five_hour: '5h limit', seven_day: 'Weekly limit' };
|
|
125
|
+
const CODEX_TITLE = 'Codex usage';
|
|
126
|
+
|
|
127
|
+
function codexRow(key, title, picked, now) {
|
|
128
|
+
const used = picked && Number.isFinite(picked.percent) ? picked.percent : null;
|
|
129
|
+
const resetsAtMs = picked && Number.isFinite(picked.resetsAtMs) ? picked.resetsAtMs : null;
|
|
130
|
+
const msToReset = resetsAtMs === null ? null : resetsAtMs - now;
|
|
131
|
+
const stale = msToReset !== null && msToReset <= 0;
|
|
132
|
+
// A window past its reset has turned over, so the figure describes an
|
|
133
|
+
// allowance that no longer exists. Drawing "6% left" in red from it would
|
|
134
|
+
// claim Codex is nearly out when it has just been given a fresh window, so
|
|
135
|
+
// the remaining figure is dropped and the row draws as unknown.
|
|
136
|
+
const left = used === null || stale ? null : Math.min(100, Math.max(0, 100 - used));
|
|
137
|
+
return {
|
|
138
|
+
key,
|
|
139
|
+
title,
|
|
140
|
+
family: null,
|
|
141
|
+
// The spent figure is kept because it is what came off the wire and what
|
|
142
|
+
// the arithmetic elsewhere is written in; percentLeft is what is drawn.
|
|
143
|
+
percent: used,
|
|
144
|
+
percentLeft: left,
|
|
145
|
+
remaining: true,
|
|
146
|
+
percentText: stale ? 'rolling' : left === null ? 'no reading' : Math.floor(left) + '% left',
|
|
147
|
+
resetsAtMs,
|
|
148
|
+
msToReset,
|
|
149
|
+
level: bars.levelLeft(left),
|
|
150
|
+
source: picked ? picked.source : null,
|
|
151
|
+
at: picked ? picked.at : null,
|
|
152
|
+
stale,
|
|
153
|
+
idle: false,
|
|
154
|
+
unreported: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Built from whatever codex.collect() returned, which is the newest meter Codex
|
|
159
|
+
// has written to disk. Nothing here spawns Codex or touches the network.
|
|
160
|
+
function buildCodex(input) {
|
|
161
|
+
const opts = input || {};
|
|
162
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
163
|
+
const utilization = opts.utilization && typeof opts.utilization === 'object' ? opts.utilization : null;
|
|
164
|
+
const fetchedAtMs = Number.isFinite(opts.fetchedAtMs) ? opts.fetchedAtMs : null;
|
|
165
|
+
const source = opts.source === 'api' || opts.source === 'live' ? 'api' : 'cache';
|
|
166
|
+
|
|
167
|
+
const rows = [];
|
|
168
|
+
const specs = Array.isArray(opts.windowSpecs) && opts.windowSpecs.length
|
|
169
|
+
? opts.windowSpecs
|
|
170
|
+
: [{ key: 'five_hour', label: '5-hour' }, { key: 'seven_day', label: 'weekly' }];
|
|
171
|
+
for (const spec of specs) {
|
|
172
|
+
if (!spec || !spec.key) continue;
|
|
173
|
+
const picked = fromSnapshot(spec.key, utilization, fetchedAtMs, source);
|
|
174
|
+
if (!picked) continue;
|
|
175
|
+
const title = CODEX_TITLES[spec.key] || 'Current ' + (spec.label || spec.key) + ' window';
|
|
176
|
+
rows.push(codexRow(spec.key, title, picked, now));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ageMs = fetchedAtMs === null ? null : Math.max(0, now - fetchedAtMs);
|
|
180
|
+
const hasData = rows.some((item) => item.percentLeft !== null);
|
|
181
|
+
const state = !hasData ? 'none' : ageMs !== null && ageMs < LIVE_AGE_MS ? 'live' : 'cached';
|
|
182
|
+
|
|
183
|
+
let note = null;
|
|
184
|
+
if (opts.windowless) {
|
|
185
|
+
note = 'this plan meters no rolling window';
|
|
186
|
+
} else if (!hasData) {
|
|
187
|
+
// Codex only writes its meter when it makes a request, so a machine that
|
|
188
|
+
// has Codex installed but has not run it has nothing to report, and that
|
|
189
|
+
// is not an error.
|
|
190
|
+
note = 'no reading yet, run Codex once';
|
|
191
|
+
} else if (rows.every((item) => item.stale)) {
|
|
192
|
+
note = 'every window has rolled over since Codex last ran';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
host: 'codex',
|
|
197
|
+
title: CODEX_TITLE,
|
|
198
|
+
rows,
|
|
199
|
+
plan: opts.plan || null,
|
|
200
|
+
windowless: Boolean(opts.windowless),
|
|
201
|
+
// What every surface checks before drawing anything at all.
|
|
202
|
+
present: rows.length > 0 || Boolean(opts.windowless),
|
|
203
|
+
state,
|
|
204
|
+
ageMs,
|
|
205
|
+
note,
|
|
206
|
+
now,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Which effort a display should report.
|
|
211
|
+
//
|
|
212
|
+
// Two sources say what a session is running at, and they fail in different
|
|
213
|
+
// ways. The status line is told by Claude Code itself and is exact, but only
|
|
214
|
+
// for a session that HAS a status line, and a VS Code window has none. The
|
|
215
|
+
// transcript is stamped with `effort` on every assistant line, so it is always
|
|
216
|
+
// current and it exists for every session, but only once the model has
|
|
217
|
+
// answered once. Whichever was written later is the truer one.
|
|
218
|
+
//
|
|
219
|
+
// The setting is the last resort, and only a resort: it says what the NEXT
|
|
220
|
+
// session will start at, not what this one is doing, it does not move when
|
|
221
|
+
// /effort does, and it is not even allowed to hold "max". Showing it as though
|
|
222
|
+
// it were the live value is what made the panel report xhigh through a whole
|
|
223
|
+
// session running at max.
|
|
224
|
+
function pickEffort(fromLine, fromTranscript, setting) {
|
|
225
|
+
let best = null;
|
|
226
|
+
for (const item of [fromLine, fromTranscript]) {
|
|
227
|
+
if (!item || typeof item.effort !== 'string' || !item.effort) continue;
|
|
228
|
+
const at = Number.isFinite(item.at) ? item.at : 0;
|
|
229
|
+
if (!best || at >= best.at) best = { effort: item.effort, at };
|
|
230
|
+
}
|
|
231
|
+
if (best) return best.effort;
|
|
232
|
+
const named = typeof setting === 'string' ? setting.trim() : '';
|
|
233
|
+
return named && named !== 'default' ? named : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
105
236
|
function ageText(ageMs) {
|
|
106
237
|
return Number.isFinite(ageMs) ? 'showing the reading from ' + usage.formatDuration(ageMs) + ' ago' : 'no reading yet';
|
|
107
238
|
}
|
|
@@ -201,15 +332,21 @@ function build(input) {
|
|
|
201
332
|
// appears in ordinary requests, and the marks are machine-wide, so one
|
|
202
333
|
// session mentioning it turned every panel purple while the effort was
|
|
203
334
|
// xhigh.
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
// the
|
|
335
|
+
// Ultracode is a session mode, not a level: Claude Code reports its effort
|
|
336
|
+
// as "xhigh" while it is on, so the level alone can never say. It comes
|
|
337
|
+
// from the `ultracode` setting (a real settings.json key) or from the
|
|
338
|
+
// session's own mark, written when the prompt used the keyword - which is
|
|
339
|
+
// Claude Code's own trigger for it. The level is kept for any build that
|
|
340
|
+
// does report it that way.
|
|
341
|
+
const ultracode = effort === 'ultracode' || Boolean(opts.ultracode);
|
|
342
|
+
// Ultrathink is a word in the prompt. Claude Code paints THE WORD in the
|
|
343
|
+
// rainbow and nothing else, so the display shows the word that way and the
|
|
344
|
+
// bars stay their own colour.
|
|
207
345
|
const ultrathink = Boolean(opts.ultrathink);
|
|
208
|
-
// What the bars do: the
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
const style = ultrathink || effort === 'max' ? 'rainbow' : ultracode ? 'ultra' : null;
|
|
346
|
+
// What the bars do: the purple shimmer of the effort picker under ultracode,
|
|
347
|
+
// the rainbow for max effort, their own level colour otherwise. The title is
|
|
348
|
+
// never painted in either.
|
|
349
|
+
const style = ultracode ? 'ultra' : effort === 'max' ? 'rainbow' : null;
|
|
213
350
|
|
|
214
351
|
return {
|
|
215
352
|
rows,
|
|
@@ -232,6 +369,11 @@ module.exports = {
|
|
|
232
369
|
TITLES,
|
|
233
370
|
LIVE_AGE_MS,
|
|
234
371
|
scopedTitle,
|
|
372
|
+
CODEX_TITLES,
|
|
373
|
+
CODEX_TITLE,
|
|
374
|
+
codexRow,
|
|
375
|
+
buildCodex,
|
|
376
|
+
pickEffort,
|
|
235
377
|
epochMs,
|
|
236
378
|
build,
|
|
237
379
|
noteFor,
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// How the person at the keyboard writes, learned from the prompts they already
|
|
4
|
+
// typed, so that text written on their behalf sounds like them.
|
|
5
|
+
//
|
|
6
|
+
// This exists for one job: when the relay carries a project across a limit
|
|
7
|
+
// reset, the prompt that restarts the work is written by the plugin, not by
|
|
8
|
+
// the user. A prompt that reads like a form letter produces a reply that reads
|
|
9
|
+
// like a form letter. The cheapest way to keep the register is to have watched
|
|
10
|
+
// how they actually write.
|
|
11
|
+
//
|
|
12
|
+
// Three constraints shaped all of it:
|
|
13
|
+
//
|
|
14
|
+
// Cheap. No model call, ever. Counters updated in the prompt hook, a card
|
|
15
|
+
// rendered from those counters. The whole update is microseconds.
|
|
16
|
+
// Private. Nothing leaves the machine, and almost nothing is kept. What is
|
|
17
|
+
// stored is counts, plus at most two short fragments the user can
|
|
18
|
+
// read and delete with one command.
|
|
19
|
+
// Honest. Style research is clear that a profile from a handful of short
|
|
20
|
+
// messages is noise, so the card says how many prompts it has seen
|
|
21
|
+
// and stays quiet until it has enough to mean something.
|
|
22
|
+
//
|
|
23
|
+
// One deliberate omission. Misspellings are the most individual thing in
|
|
24
|
+
// anyone's writing and the worst thing to put in a prompt: told that someone
|
|
25
|
+
// makes mistakes, a model makes mistakes everywhere, and the result is a
|
|
26
|
+
// caricature rather than a voice. Only patterns that are choices are recorded -
|
|
27
|
+
// dropped apostrophes, lowercase openings, capitals for emphasis - and the card
|
|
28
|
+
// says outright not to introduce errors.
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const os = require('os');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
|
|
34
|
+
// Enough for the stable signals - message length, punctuation, openers - to
|
|
35
|
+
// stop moving. Short-message authorship work stops gaining accuracy at around
|
|
36
|
+
// a hundred and twenty messages, and says very little below a dozen.
|
|
37
|
+
const PROVISIONAL = 12;
|
|
38
|
+
const SETTLED = 50;
|
|
39
|
+
|
|
40
|
+
// What is kept of the raw text: two fragments, short, only as long as a chat
|
|
41
|
+
// line. Everything else in the file is a number.
|
|
42
|
+
const KEEP_SAMPLES = 2;
|
|
43
|
+
const SAMPLE_MAX = 140;
|
|
44
|
+
|
|
45
|
+
// Longer than this is a pasted log or a spec, not the way someone talks, and
|
|
46
|
+
// including it would drag every average sideways.
|
|
47
|
+
const PROMPT_MAX_WORDS = 400;
|
|
48
|
+
|
|
49
|
+
const OPENERS_KEPT = 6;
|
|
50
|
+
|
|
51
|
+
function configDir() {
|
|
52
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function voiceFile() {
|
|
56
|
+
return path.join(configDir(), 'usage-limits-voice.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function empty() {
|
|
60
|
+
return {
|
|
61
|
+
version: 1,
|
|
62
|
+
prompts: 0,
|
|
63
|
+
words: 0,
|
|
64
|
+
updated: null,
|
|
65
|
+
counts: {
|
|
66
|
+
lowercaseStart: 0,
|
|
67
|
+
endsWithStop: 0,
|
|
68
|
+
question: 0,
|
|
69
|
+
exclaim: 0,
|
|
70
|
+
commas: 0,
|
|
71
|
+
capsWords: 0,
|
|
72
|
+
capsMessages: 0,
|
|
73
|
+
contractions: 0,
|
|
74
|
+
apostropheDropped: 0,
|
|
75
|
+
emoji: 0,
|
|
76
|
+
multiSentence: 0,
|
|
77
|
+
runOn: 0,
|
|
78
|
+
hedge: 0,
|
|
79
|
+
imperative: 0,
|
|
80
|
+
firstPerson: 0,
|
|
81
|
+
},
|
|
82
|
+
openers: {},
|
|
83
|
+
samples: [],
|
|
84
|
+
note: null,
|
|
85
|
+
mode: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function read() {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(fs.readFileSync(voiceFile(), 'utf8'));
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return empty();
|
|
95
|
+
}
|
|
96
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return empty();
|
|
97
|
+
const base = empty();
|
|
98
|
+
const state = {
|
|
99
|
+
version: 1,
|
|
100
|
+
prompts: Number.isFinite(parsed.prompts) ? parsed.prompts : 0,
|
|
101
|
+
words: Number.isFinite(parsed.words) ? parsed.words : 0,
|
|
102
|
+
updated: Number.isFinite(parsed.updated) ? parsed.updated : null,
|
|
103
|
+
counts: Object.assign({}, base.counts),
|
|
104
|
+
openers: {},
|
|
105
|
+
samples: Array.isArray(parsed.samples) ? parsed.samples.filter((s) => typeof s === 'string').slice(0, KEEP_SAMPLES) : [],
|
|
106
|
+
note: typeof parsed.note === 'string' ? parsed.note : null,
|
|
107
|
+
mode: typeof parsed.mode === 'string' ? parsed.mode : null,
|
|
108
|
+
};
|
|
109
|
+
if (parsed.counts && typeof parsed.counts === 'object') {
|
|
110
|
+
for (const key of Object.keys(state.counts)) {
|
|
111
|
+
if (Number.isFinite(parsed.counts[key])) state.counts[key] = parsed.counts[key];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (parsed.openers && typeof parsed.openers === 'object' && !Array.isArray(parsed.openers)) {
|
|
115
|
+
for (const key of Object.keys(parsed.openers)) {
|
|
116
|
+
if (Number.isFinite(parsed.openers[key])) state.openers[key] = parsed.openers[key];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return state;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function write(state) {
|
|
123
|
+
try {
|
|
124
|
+
fs.mkdirSync(configDir(), { recursive: true });
|
|
125
|
+
fs.writeFileSync(voiceFile(), JSON.stringify(state, null, 2) + '\n');
|
|
126
|
+
return true;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Slash commands, file paths pasted in, and fenced code are someone else's
|
|
133
|
+
// words or a machine's. Strip them before measuring, and if what is left is
|
|
134
|
+
// too thin to be a sentence, measure nothing.
|
|
135
|
+
function speech(prompt) {
|
|
136
|
+
let text = String(prompt || '');
|
|
137
|
+
text = text.replace(/```[\s\S]*?```/g, ' ');
|
|
138
|
+
text = text.replace(/`[^`\n]*`/g, ' ');
|
|
139
|
+
text = text.replace(/https?:\/\/\S+/g, ' ');
|
|
140
|
+
text = text.replace(/[A-Za-z]:[\\/][^\s"']+/g, ' ');
|
|
141
|
+
text = text.replace(/(^|\s)[~.]{0,2}[\\/][^\s"']{3,}/g, ' ');
|
|
142
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const CONTRACTION = /\b\w+'(?:s|t|re|ve|ll|d|m)\b/gi;
|
|
146
|
+
const DROPPED = /\b(?:dont|doesnt|didnt|isnt|arent|wasnt|werent|cant|cannt|wont|couldnt|shouldnt|wouldnt|hasnt|havent|hadnt|im|ive|ill|id|youre|youve|youll|theyre|theyve|thats|whats|whos|theres|heres|its\s+(?:a|the|not|just|been|going|pretty|super)|lets|aint|yall)\b/gi;
|
|
147
|
+
// Kept deliberately small and literal. A part-of-speech tagger would be more
|
|
148
|
+
// accurate and would also be a dependency and a millisecond budget this does
|
|
149
|
+
// not have; in aggregate a fixed lexicon points the same way.
|
|
150
|
+
const HEDGE = /\b(?:maybe|perhaps|i think|i guess|kind of|kinda|sort of|sorta|probably|possibly|might|could be|if possible|not sure|somehow|or something|i feel like)\b/gi;
|
|
151
|
+
const IMPERATIVE = /^(?:please\s+)?(?:add|make|fix|check|build|do|run|write|change|remove|delete|update|use|put|give|show|find|get|set|keep|try|start|stop|finish|release|push|test|move|rename|open|close|send|create|install|upgrade|refactor|clean|verify)\b/i;
|
|
152
|
+
const FIRST_PERSON = /\b(?:i|i'm|im|ive|i've|my|me|mine)\b/i;
|
|
153
|
+
// Written as code points rather than as the characters themselves, for two
|
|
154
|
+
// reasons: the file stays plain ASCII and cannot be broken by an encoding
|
|
155
|
+
// round-trip, and a source file that detects emoji should not contain any.
|
|
156
|
+
// The surrogate pairs cover the pictographic planes; the rest is dingbats,
|
|
157
|
+
// arrows, and the variation selector that turns a plain glyph into an emoji.
|
|
158
|
+
const EMOJI = /[\u203C\u2049\u2600-\u27BF\u2B00-\u2BFF\uFE0F]|[\uD83C-\uD83E][\uDC00-\uDFFF]/g;
|
|
159
|
+
|
|
160
|
+
function countOf(text, pattern) {
|
|
161
|
+
const found = text.match(pattern);
|
|
162
|
+
return found ? found.length : 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A short, typical line: long enough to show a habit, short enough that keeping
|
|
166
|
+
// it is not keeping a log. Preferred over a one-word prompt or a paragraph.
|
|
167
|
+
function sampleWorth(text, averageWords) {
|
|
168
|
+
const words = text.split(' ').length;
|
|
169
|
+
if (words < 6 || text.length > SAMPLE_MAX) return 0;
|
|
170
|
+
const target = averageWords > 0 ? averageWords : 18;
|
|
171
|
+
return 1 / (1 + Math.abs(words - target) / target);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Fold one prompt into the counters. Never throws and never blocks: this runs
|
|
175
|
+
// inside a hook that has to answer in milliseconds.
|
|
176
|
+
function observe(prompt, now) {
|
|
177
|
+
const text = speech(prompt);
|
|
178
|
+
if (!text) return null;
|
|
179
|
+
const words = text.split(' ').filter(Boolean);
|
|
180
|
+
if (words.length < 3 || words.length > PROMPT_MAX_WORDS) return null;
|
|
181
|
+
|
|
182
|
+
const state = read();
|
|
183
|
+
if (state.mode === 'off') return null;
|
|
184
|
+
|
|
185
|
+
const counts = state.counts;
|
|
186
|
+
state.prompts += 1;
|
|
187
|
+
state.words += words.length;
|
|
188
|
+
if (/^[a-z]/.test(text)) counts.lowercaseStart += 1;
|
|
189
|
+
if (/[.!?]$/.test(text)) counts.endsWithStop += 1;
|
|
190
|
+
if (/\?/.test(text)) counts.question += 1;
|
|
191
|
+
if (/!/.test(text)) counts.exclaim += 1;
|
|
192
|
+
counts.commas += countOf(text, /,/g);
|
|
193
|
+
const caps = countOf(text, /\b[A-Z]{2,}\b/g);
|
|
194
|
+
counts.capsWords += caps;
|
|
195
|
+
if (caps) counts.capsMessages += 1;
|
|
196
|
+
counts.contractions += countOf(text, CONTRACTION);
|
|
197
|
+
counts.apostropheDropped += countOf(text, DROPPED);
|
|
198
|
+
counts.emoji += countOf(text, EMOJI);
|
|
199
|
+
const sentences = text.split(/[.!?]+\s/).filter((s) => s.trim().length > 1);
|
|
200
|
+
if (sentences.length > 1) counts.multiSentence += 1;
|
|
201
|
+
// Two or more clauses joined by commas and never closed: the shape of
|
|
202
|
+
// someone thinking out loud rather than drafting.
|
|
203
|
+
if (countOf(text, /,/g) >= 2 && !/[.!?]$/.test(text)) counts.runOn += 1;
|
|
204
|
+
if (HEDGE.test(text)) counts.hedge += 1;
|
|
205
|
+
HEDGE.lastIndex = 0;
|
|
206
|
+
if (IMPERATIVE.test(text)) counts.imperative += 1;
|
|
207
|
+
if (FIRST_PERSON.test(text)) counts.firstPerson += 1;
|
|
208
|
+
|
|
209
|
+
const opener = words.slice(0, 2).join(' ').toLowerCase().replace(/[^a-z' ]/g, '').trim();
|
|
210
|
+
if (opener && opener.length > 1) state.openers[opener] = (state.openers[opener] || 0) + 1;
|
|
211
|
+
// Openers are a long tail of things said once. Keep the head; a file that
|
|
212
|
+
// grows a key per prompt is a log by another name.
|
|
213
|
+
const openerKeys = Object.keys(state.openers);
|
|
214
|
+
if (openerKeys.length > 60) {
|
|
215
|
+
const kept = {};
|
|
216
|
+
for (const key of openerKeys.sort((a, b) => state.openers[b] - state.openers[a]).slice(0, 24)) {
|
|
217
|
+
kept[key] = state.openers[key];
|
|
218
|
+
}
|
|
219
|
+
state.openers = kept;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const average = state.prompts ? state.words / state.prompts : 0;
|
|
223
|
+
const worth = sampleWorth(text, average);
|
|
224
|
+
if (worth > 0) {
|
|
225
|
+
const held = state.samples.map((s) => ({ text: s, worth: sampleWorth(s, average) }));
|
|
226
|
+
held.push({ text, worth });
|
|
227
|
+
held.sort((a, b) => b.worth - a.worth);
|
|
228
|
+
state.samples = [];
|
|
229
|
+
for (const item of held) {
|
|
230
|
+
if (state.samples.length >= KEEP_SAMPLES) break;
|
|
231
|
+
if (!state.samples.includes(item.text)) state.samples.push(item.text);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
state.updated = Number.isFinite(now) ? now : Date.now();
|
|
236
|
+
write(state);
|
|
237
|
+
return state;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function pct(part, whole) {
|
|
241
|
+
if (!whole) return 0;
|
|
242
|
+
return (part / whole) * 100;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// The traits worth saying out loud, strongest first. Each one is a choice
|
|
246
|
+
// somebody makes, phrased so that a reader could follow it.
|
|
247
|
+
function traits(state) {
|
|
248
|
+
const n = state.prompts;
|
|
249
|
+
if (!n) return [];
|
|
250
|
+
const counts = state.counts;
|
|
251
|
+
const average = Math.round(state.words / n);
|
|
252
|
+
const out = [];
|
|
253
|
+
|
|
254
|
+
out.push('around ' + average + ' words a message');
|
|
255
|
+
if (pct(counts.lowercaseStart, n) >= 40) out.push('starts lowercase');
|
|
256
|
+
if (pct(counts.endsWithStop, n) <= 40) out.push('often no full stop at the end');
|
|
257
|
+
if (counts.commas / n >= 2 && pct(counts.runOn, n) >= 30) out.push('long comma-joined sentences rather than short ones');
|
|
258
|
+
if (pct(counts.apostropheDropped, n) >= 25) out.push("drops the apostrophe in contractions (dont, doesnt, im)");
|
|
259
|
+
// Counted per message, not per word: one prompt shouting three words is
|
|
260
|
+
// one habit, and totals let a single message decide the trait.
|
|
261
|
+
if (pct(counts.capsMessages, n) >= 20) out.push('puts a word in CAPITALS for emphasis');
|
|
262
|
+
if (counts.emoji === 0 && n >= PROVISIONAL) out.push('never emojis');
|
|
263
|
+
else if (counts.emoji / n >= 0.5) out.push('uses emoji');
|
|
264
|
+
if (pct(counts.question, n) >= 50) out.push('asks rather than instructs');
|
|
265
|
+
else if (pct(counts.imperative, n) >= 40) out.push('opens with the verb - do this, fix that');
|
|
266
|
+
if (pct(counts.hedge, n) >= 35) out.push('hedges (maybe, kind of, i think)');
|
|
267
|
+
if (pct(counts.firstPerson, n) >= 50) out.push('speaks in the first person about what they want');
|
|
268
|
+
if (pct(counts.exclaim, n) >= 25) out.push('exclamation marks');
|
|
269
|
+
|
|
270
|
+
const openers = Object.keys(state.openers)
|
|
271
|
+
.sort((a, b) => state.openers[b] - state.openers[a])
|
|
272
|
+
.filter((key) => state.openers[key] >= 2)
|
|
273
|
+
.slice(0, OPENERS_KEPT);
|
|
274
|
+
if (openers.length >= 2) out.push('opens with "' + openers.slice(0, 3).join('", "') + '"');
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function confidence(state) {
|
|
279
|
+
if (!state || !state.prompts) return 'none';
|
|
280
|
+
if (state.prompts >= SETTLED) return 'settled';
|
|
281
|
+
if (state.prompts >= PROVISIONAL) return 'provisional';
|
|
282
|
+
return 'thin';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The line that goes into a prompt. Deliberately short: style research finds
|
|
286
|
+
// no gain past a few signals, and a long instruction crowds out the work.
|
|
287
|
+
//
|
|
288
|
+
// `note` is whatever the user typed at /usage-limits:voice set, and it wins
|
|
289
|
+
// outright. Being told how to sound beats being guessed at.
|
|
290
|
+
function card(state, options) {
|
|
291
|
+
const opts = options || {};
|
|
292
|
+
const held = state || read();
|
|
293
|
+
const parts = [];
|
|
294
|
+
if (held.note) parts.push(held.note);
|
|
295
|
+
const level = confidence(held);
|
|
296
|
+
if (!held.note && level === 'thin') return null;
|
|
297
|
+
if (level !== 'thin') {
|
|
298
|
+
const list = traits(held);
|
|
299
|
+
if (list.length) parts.push('Writes like this: ' + list.join('; ') + '.');
|
|
300
|
+
if (opts.samples !== false && held.samples.length) {
|
|
301
|
+
// One real line does more than any description of one. Style-imitation
|
|
302
|
+
// work is consistent on this: examples beat adjectives.
|
|
303
|
+
parts.push('For example: "' + held.samples[0] + '"');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!parts.length) return null;
|
|
307
|
+
parts.push(
|
|
308
|
+
'Match the register, not the mistakes: do not add typos, and do not exaggerate any of this into a caricature.'
|
|
309
|
+
);
|
|
310
|
+
return parts.join(' ');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// What the /usage-limits:voice command prints. Plain, so that "what does it
|
|
314
|
+
// know about me" is answered by reading it rather than by trusting a claim.
|
|
315
|
+
function describe(state) {
|
|
316
|
+
const held = state || read();
|
|
317
|
+
const lines = [];
|
|
318
|
+
const level = confidence(held);
|
|
319
|
+
lines.push(
|
|
320
|
+
'Voice profile: ' + held.prompts + ' prompt' + (held.prompts === 1 ? '' : 's') + ' seen, ' +
|
|
321
|
+
(level === 'settled' ? 'settled' : level === 'provisional' ? 'provisional - it firms up around ' + SETTLED : 'too thin to use yet - it starts at ' + PROVISIONAL) +
|
|
322
|
+
(held.mode === 'off' ? ', collection is OFF' : '') + '.'
|
|
323
|
+
);
|
|
324
|
+
if (held.note) lines.push('Your instruction: ' + held.note);
|
|
325
|
+
const list = traits(held);
|
|
326
|
+
if (list.length && level !== 'thin') lines.push('Observed: ' + list.join('; ') + '.');
|
|
327
|
+
if (held.samples.length) {
|
|
328
|
+
lines.push('Kept lines (the only raw text stored):');
|
|
329
|
+
for (const sample of held.samples) lines.push(' "' + sample + '"');
|
|
330
|
+
}
|
|
331
|
+
lines.push('File: ' + voiceFile());
|
|
332
|
+
return lines.join('\n');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function forget() {
|
|
336
|
+
try {
|
|
337
|
+
fs.unlinkSync(voiceFile());
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// Already gone is the outcome that was asked for.
|
|
340
|
+
}
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function setNote(note) {
|
|
345
|
+
const state = read();
|
|
346
|
+
state.note = note ? String(note).slice(0, 400) : null;
|
|
347
|
+
write(state);
|
|
348
|
+
return state;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function setMode(mode) {
|
|
352
|
+
const state = read();
|
|
353
|
+
state.mode = mode === 'off' ? 'off' : null;
|
|
354
|
+
write(state);
|
|
355
|
+
return state;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function main(argv) {
|
|
359
|
+
const args = argv || [];
|
|
360
|
+
const command = (args[0] || 'show').toLowerCase();
|
|
361
|
+
if (command === 'show' || command === 'status') return describe();
|
|
362
|
+
if (command === 'card') return card() || 'Not enough prompts yet to describe a voice.';
|
|
363
|
+
if (command === 'forget' || command === 'wipe') {
|
|
364
|
+
forget();
|
|
365
|
+
return 'Voice profile deleted. Nothing of it is left on disk.';
|
|
366
|
+
}
|
|
367
|
+
if (command === 'off') {
|
|
368
|
+
setMode('off');
|
|
369
|
+
return 'Voice learning is off. Existing counts are kept until you run: voice forget';
|
|
370
|
+
}
|
|
371
|
+
if (command === 'on') {
|
|
372
|
+
setMode(null);
|
|
373
|
+
return 'Voice learning is on. It reads only prompts typed in this agent, and stores counts plus at most ' + KEEP_SAMPLES + ' short lines.';
|
|
374
|
+
}
|
|
375
|
+
if (command === 'set') {
|
|
376
|
+
const text = args.slice(1).join(' ');
|
|
377
|
+
if (!text) return 'Give the instruction, for example: voice set "blunt, lowercase, no preamble"';
|
|
378
|
+
setNote(text);
|
|
379
|
+
return 'Voice instruction set. It goes in front of the learned traits whenever the plugin writes as you.';
|
|
380
|
+
}
|
|
381
|
+
if (command === 'clear') {
|
|
382
|
+
setNote(null);
|
|
383
|
+
return 'Voice instruction cleared; the learned traits stand on their own again.';
|
|
384
|
+
}
|
|
385
|
+
return 'usage: voice.js [show|card|set TEXT|clear|off|on|forget]\n\n' + describe();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (require.main === module) {
|
|
389
|
+
try {
|
|
390
|
+
process.stdout.write(main(process.argv.slice(2)) + '\n');
|
|
391
|
+
} catch (err) {
|
|
392
|
+
process.stdout.write('voice: ' + err.message + '\n');
|
|
393
|
+
}
|
|
394
|
+
process.exit(0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
module.exports = {
|
|
398
|
+
main,
|
|
399
|
+
PROVISIONAL,
|
|
400
|
+
SETTLED,
|
|
401
|
+
KEEP_SAMPLES,
|
|
402
|
+
SAMPLE_MAX,
|
|
403
|
+
voiceFile,
|
|
404
|
+
empty,
|
|
405
|
+
read,
|
|
406
|
+
write,
|
|
407
|
+
speech,
|
|
408
|
+
observe,
|
|
409
|
+
traits,
|
|
410
|
+
confidence,
|
|
411
|
+
card,
|
|
412
|
+
describe,
|
|
413
|
+
forget,
|
|
414
|
+
setNote,
|
|
415
|
+
setMode,
|
|
416
|
+
};
|