mod8-cli 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +239 -0
- package/bin/mod8.js +2 -0
- package/dist/cli.js +302 -0
- package/dist/commands/addProvider.js +105 -0
- package/dist/commands/all.js +158 -0
- package/dist/commands/chat.js +855 -0
- package/dist/commands/config.js +29 -0
- package/dist/commands/devAuthStatus.js +34 -0
- package/dist/commands/devHostAsk.js +51 -0
- package/dist/commands/devHostSystem.js +15 -0
- package/dist/commands/devResolve.js +54 -0
- package/dist/commands/devSimulate.js +235 -0
- package/dist/commands/devWorkAsk.js +55 -0
- package/dist/commands/intentRouting.js +280 -0
- package/dist/commands/keys.js +55 -0
- package/dist/commands/list.js +27 -0
- package/dist/commands/login.js +147 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/prompt.js +63 -0
- package/dist/commands/providers.js +30 -0
- package/dist/commands/verify.js +5 -0
- package/dist/input/compose.js +37 -0
- package/dist/input/files.js +49 -0
- package/dist/input/stdin.js +14 -0
- package/dist/providers/anthropic.js +115 -0
- package/dist/providers/displayName.js +25 -0
- package/dist/providers/errorHints.js +175 -0
- package/dist/providers/generic.js +331 -0
- package/dist/providers/genericChat.js +265 -0
- package/dist/providers/google.js +63 -0
- package/dist/providers/hostSystem.js +173 -0
- package/dist/providers/index.js +38 -0
- package/dist/providers/mock.js +87 -0
- package/dist/providers/modelResolution.js +42 -0
- package/dist/providers/openai.js +75 -0
- package/dist/providers/pricing.js +47 -0
- package/dist/providers/proxy.js +148 -0
- package/dist/providers/registry.js +196 -0
- package/dist/providers/types.js +1 -0
- package/dist/providers/workSystem.js +33 -0
- package/dist/storage/auth.js +65 -0
- package/dist/storage/config.js +35 -0
- package/dist/storage/keys.js +59 -0
- package/dist/storage/providers.js +337 -0
- package/dist/storage/sessions.js +150 -0
- package/dist/types.js +9 -0
- package/dist/util/debug.js +79 -0
- package/dist/util/errors.js +157 -0
- package/dist/util/prompt.js +111 -0
- package/dist/util/secrets.js +110 -0
- package/dist/util/text.js +53 -0
- package/dist/util/time.js +25 -0
- package/dist/verify/runner.js +437 -0
- package/package.json +69 -0
- package/specs/all-mode.yaml +44 -0
- package/specs/behavior/auto-fallback.yaml +49 -0
- package/specs/behavior/bare-name-routing.yaml +223 -0
- package/specs/behavior/bare-paste-confirm.yaml +125 -0
- package/specs/behavior/env-var-respected.yaml +108 -0
- package/specs/behavior/error-fidelity.yaml +92 -0
- package/specs/behavior/error-hints.yaml +160 -0
- package/specs/behavior/fresh-vs-resume.yaml +94 -0
- package/specs/behavior/fuzzy-match.yaml +208 -0
- package/specs/behavior/host-self-knowledge-fresh.yaml +66 -0
- package/specs/behavior/intent-no-mismatch.yaml +115 -0
- package/specs/behavior/login-logout.yaml +97 -0
- package/specs/behavior/no-model-allowlist.yaml +80 -0
- package/specs/behavior/paste-key.yaml +342 -0
- package/specs/behavior/provider-switching.yaml +186 -0
- package/specs/behavior/providers-json-respected.yaml +106 -0
- package/specs/behavior/self-knowledge.yaml +119 -0
- package/specs/behavior/stress-session.yaml +226 -0
- package/specs/behavior/switch-back-when-failing.yaml +90 -0
- package/specs/behavior/work-character.yaml +109 -0
- package/specs/chat-meta.yaml +349 -0
- package/specs/chat-startup.yaml +148 -0
- package/specs/chat.yaml +91 -0
- package/specs/config.yaml +42 -0
- package/specs/install.yaml +112 -0
- package/specs/keys.yaml +81 -0
- package/specs/one-shot.yaml +65 -0
- package/specs/pipe-and-files.yaml +40 -0
- package/specs/providers.yaml +172 -0
- package/specs/sessions.yaml +115 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull a sensible short text out of the SDK error, stripping prefix noise
|
|
3
|
+
* (so we don't double-print the HTTP code) and provider-SDK wrapper text
|
|
4
|
+
* (e.g. `[GoogleGenerativeAI Error]: Error fetching from <url>:`).
|
|
5
|
+
*/
|
|
6
|
+
function extractRawMessage(err) {
|
|
7
|
+
let cleaned = err.message
|
|
8
|
+
// SDK wrapper prefixes
|
|
9
|
+
.replace(/^\s*\[GoogleGenerativeAI Error\]:\s*/i, '')
|
|
10
|
+
.replace(/^\s*Error fetching from\s+\S+\s*:\s*/i, '')
|
|
11
|
+
// HTTP status prefixes
|
|
12
|
+
.replace(/^\s*\[\d{3}[^\]]*\]\s*/, '') // [403 Forbidden]
|
|
13
|
+
.replace(/^\s*Status:?\s*\d{3}[\s,:-]*/i, '') // Status: 403
|
|
14
|
+
.replace(/^\s*\d{3}\s+(?=[A-Z])/, '') // bare "403 Forbidden..."
|
|
15
|
+
.replace(/^\s*Error:?\s+/i, '');
|
|
16
|
+
cleaned = cleaned.split('\n')[0].trim();
|
|
17
|
+
// Google SDK errors stuff structured detail JSON onto the same line —
|
|
18
|
+
// strip that tail so the user sees just the human-readable summary.
|
|
19
|
+
cleaned = cleaned.replace(/\s*\[?\{["']@type["'].*$/, '').trim();
|
|
20
|
+
return cleaned.slice(0, 240);
|
|
21
|
+
}
|
|
22
|
+
function extractCode(err) {
|
|
23
|
+
const e = err;
|
|
24
|
+
if (typeof e['status'] === 'number')
|
|
25
|
+
return e['status'];
|
|
26
|
+
if (typeof e['statusCode'] === 'number')
|
|
27
|
+
return e['statusCode'];
|
|
28
|
+
// Fall back to scanning the message — most SDK errors include the code in
|
|
29
|
+
// brackets or as a leading token.
|
|
30
|
+
const m = err.message.match(/\b(\d{3})\b/);
|
|
31
|
+
if (m) {
|
|
32
|
+
const code = Number.parseInt(m[1], 10);
|
|
33
|
+
if (code >= 400 && code < 600)
|
|
34
|
+
return code;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function extractRetryDelay(msg) {
|
|
39
|
+
const m1 = msg.match(/retry[\s-]*after\s*:?\s*(\d+)/i);
|
|
40
|
+
if (m1)
|
|
41
|
+
return Number.parseInt(m1[1], 10);
|
|
42
|
+
const m2 = msg.match(/try\s+again\s+in\s+(\d+)\s*(?:seconds?|s)\b/i);
|
|
43
|
+
if (m2)
|
|
44
|
+
return Number.parseInt(m2[1], 10);
|
|
45
|
+
const m3 = msg.match(/wait\s+(\d+)\s*(?:seconds?|s)\b/i);
|
|
46
|
+
if (m3)
|
|
47
|
+
return Number.parseInt(m3[1], 10);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
function detectKind(err, code) {
|
|
51
|
+
const msg = err.message;
|
|
52
|
+
// Network/timeout get evaluated FIRST because their messages also often
|
|
53
|
+
// contain digits that look like codes.
|
|
54
|
+
if (/ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|fetch failed|network/i.test(msg)) {
|
|
55
|
+
return 'network';
|
|
56
|
+
}
|
|
57
|
+
if (/timeout|timed out|ETIMEDOUT/i.test(msg))
|
|
58
|
+
return 'timeout';
|
|
59
|
+
if (code === 401)
|
|
60
|
+
return 'auth';
|
|
61
|
+
if (code === 403)
|
|
62
|
+
return 'forbidden';
|
|
63
|
+
if (code === 429)
|
|
64
|
+
return 'rate-limit';
|
|
65
|
+
if (code === 402)
|
|
66
|
+
return 'no-credit';
|
|
67
|
+
if (code !== undefined && code >= 500 && code < 600)
|
|
68
|
+
return 'server';
|
|
69
|
+
if (/\bunauthor/i.test(msg) ||
|
|
70
|
+
/\bauthentication[_ -]?(?:failed|error)/i.test(msg) ||
|
|
71
|
+
/\binvalid[_ -]?api[_ -]?key\b/i.test(msg) ||
|
|
72
|
+
/\bapi[_ -]?key[_ -]?invalid\b/i.test(msg) || // Google's API_KEY_INVALID
|
|
73
|
+
/\bapi\s+key\s+(?:is\s+)?(?:not\s+valid|invalid)/i.test(msg) || // "API key not valid"
|
|
74
|
+
/\bincorrect[_ -]?api[_ -]?key\b/i.test(msg)) {
|
|
75
|
+
return 'auth';
|
|
76
|
+
}
|
|
77
|
+
if (/\bforbidden\b|\bdenied\b|\baccess\s+(?:has\s+been\s+)?denied\b|\bblocked\b|\bnot[_ -]?authorized\b/i.test(msg)) {
|
|
78
|
+
return 'forbidden';
|
|
79
|
+
}
|
|
80
|
+
if (/\brate[_ -]?limit|\btoo[_ -]many[_ -]requests\b|\brate_limit_exceeded\b/i.test(msg)) {
|
|
81
|
+
return 'rate-limit';
|
|
82
|
+
}
|
|
83
|
+
if (/\binsufficient[_ -](?:balance|credit|funds)\b|\bout[_ -]of[_ -](?:credit|balance)\b|\bpayment[_ -]required\b|\bno[_ -]balance\b|\bbilling[_ -]not[_ -]active\b|\bfree.*tier.*exceed/i.test(msg)) {
|
|
84
|
+
return 'no-credit';
|
|
85
|
+
}
|
|
86
|
+
if (/\bquota\b|\bbilling\b/i.test(msg))
|
|
87
|
+
return 'no-credit';
|
|
88
|
+
// Tightened: word boundary on "model" (so "models/<name>" in URLs DOESN'T
|
|
89
|
+
// match), bounded gap (no 200-char .* spans), and no "invalid" — too
|
|
90
|
+
// generic; was over-matching against "API key not valid" elsewhere in
|
|
91
|
+
// the same message.
|
|
92
|
+
if (/\bmodel\b[^.\n]{0,80}\b(?:not\s+found|does\s+not\s+exist|unsupported|not\s+supported|deprecated|no\s+longer\s+available)\b/i.test(msg)) {
|
|
93
|
+
return 'model';
|
|
94
|
+
}
|
|
95
|
+
if (/\b(?:not\s+found|does\s+not\s+exist|unsupported|deprecated|no\s+longer\s+available)\b[^.\n]{0,80}\bmodel\b/i.test(msg)) {
|
|
96
|
+
return 'model';
|
|
97
|
+
}
|
|
98
|
+
return 'other';
|
|
99
|
+
}
|
|
100
|
+
export function diagnose(err) {
|
|
101
|
+
if (!(err instanceof Error)) {
|
|
102
|
+
return { kind: 'other', rawMessage: String(err).slice(0, 240) };
|
|
103
|
+
}
|
|
104
|
+
const code = extractCode(err);
|
|
105
|
+
const kind = detectKind(err, code);
|
|
106
|
+
const rawMessage = extractRawMessage(err);
|
|
107
|
+
const retryDelaySeconds = kind === 'rate-limit' ? extractRetryDelay(err.message) : undefined;
|
|
108
|
+
const result = { kind, rawMessage };
|
|
109
|
+
if (code !== undefined)
|
|
110
|
+
result.code = code;
|
|
111
|
+
if (retryDelaySeconds !== undefined)
|
|
112
|
+
result.retryDelaySeconds = retryDelaySeconds;
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Map a raw provider error into a short human-readable description.
|
|
117
|
+
* Backwards-compat shim used by the --all parallel path (which displays a
|
|
118
|
+
* single line per block, no room for the multi-line explainError output).
|
|
119
|
+
*
|
|
120
|
+
* If `provider` is supplied, the "invalid API key" path can append a
|
|
121
|
+
* `Run: mod8 keys set <provider>` remedy hint.
|
|
122
|
+
*/
|
|
123
|
+
export function classifyError(err, provider) {
|
|
124
|
+
if (!(err instanceof Error))
|
|
125
|
+
return String(err);
|
|
126
|
+
const msg = err.message;
|
|
127
|
+
// Already-friendly errors from our own code (e.g. getKey)
|
|
128
|
+
if (msg.startsWith('No ') && /key configured/.test(msg))
|
|
129
|
+
return msg;
|
|
130
|
+
const diag = diagnose(err);
|
|
131
|
+
switch (diag.kind) {
|
|
132
|
+
case 'auth':
|
|
133
|
+
return provider
|
|
134
|
+
? `invalid API key. Run: mod8 keys set ${provider}`
|
|
135
|
+
: 'invalid API key';
|
|
136
|
+
case 'forbidden': {
|
|
137
|
+
const tail = diag.rawMessage ? ` — ${diag.rawMessage}` : '';
|
|
138
|
+
return `forbidden (HTTP ${diag.code ?? 403})${tail}`;
|
|
139
|
+
}
|
|
140
|
+
case 'rate-limit':
|
|
141
|
+
return diag.retryDelaySeconds
|
|
142
|
+
? `rate limited — try again in ${diag.retryDelaySeconds}s`
|
|
143
|
+
: 'rate limited — try again shortly';
|
|
144
|
+
case 'no-credit':
|
|
145
|
+
return 'quota or billing issue — check your provider dashboard';
|
|
146
|
+
case 'server':
|
|
147
|
+
return `provider server error (HTTP ${diag.code ?? '5xx'}) — try again shortly`;
|
|
148
|
+
case 'network':
|
|
149
|
+
return 'network error — check your connection';
|
|
150
|
+
case 'timeout':
|
|
151
|
+
return 'request timed out — try again';
|
|
152
|
+
case 'model':
|
|
153
|
+
return `model not available — set MOD8_${(provider ?? '').toUpperCase()}_MODEL to override`;
|
|
154
|
+
default:
|
|
155
|
+
return diag.rawMessage || msg.split('\n')[0].slice(0, 240);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createInterface } from 'readline';
|
|
2
|
+
import { Writable } from 'stream';
|
|
3
|
+
/**
|
|
4
|
+
* Read a line from stdin without echoing characters (password-style).
|
|
5
|
+
*
|
|
6
|
+
* - Non-TTY (piped input): plain readline.
|
|
7
|
+
* - TTY: readline in terminal mode with a muted output stream so typed/pasted
|
|
8
|
+
* characters don't echo, but the line is still reassembled correctly when
|
|
9
|
+
* the terminal delivers a long paste in multiple chunks.
|
|
10
|
+
*
|
|
11
|
+
* The earlier hand-rolled raw-mode reader had three bugs that combined to
|
|
12
|
+
* truncate pasted input: stdin.resume() called before the data listener was
|
|
13
|
+
* attached (first chunk could be dropped), setEncoding after resume, and no
|
|
14
|
+
* cross-chunk reassembly. readline handles all of that internally.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Read a single line from stdin (echoed). Optional default returned if user
|
|
18
|
+
* presses Enter without typing.
|
|
19
|
+
*/
|
|
20
|
+
export async function readLine(promptText, fallback = '') {
|
|
21
|
+
const rl = createInterface({
|
|
22
|
+
input: process.stdin,
|
|
23
|
+
output: process.stdout,
|
|
24
|
+
terminal: process.stdin.isTTY ?? false,
|
|
25
|
+
});
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
rl.question(promptText, (line) => {
|
|
28
|
+
rl.close();
|
|
29
|
+
const value = line.trim();
|
|
30
|
+
resolve(value === '' ? fallback : value);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function maskKey(key) {
|
|
35
|
+
if (key.length <= 8)
|
|
36
|
+
return '*'.repeat(Math.max(key.length, 4));
|
|
37
|
+
return `${key.slice(0, 4)}…${key.slice(-4)}`;
|
|
38
|
+
}
|
|
39
|
+
export async function readSecret(promptText) {
|
|
40
|
+
const stdin = process.stdin;
|
|
41
|
+
const stdout = process.stdout;
|
|
42
|
+
if (!stdin.isTTY) {
|
|
43
|
+
const rl = createInterface({ input: stdin });
|
|
44
|
+
stdout.write(promptText);
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
rl.once('line', (line) => {
|
|
47
|
+
rl.close();
|
|
48
|
+
resolve(line);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// Muted writable so readline's terminal mode doesn't echo characters.
|
|
53
|
+
const muted = new Writable({
|
|
54
|
+
write(_chunk, _encoding, callback) {
|
|
55
|
+
callback();
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const rl = createInterface({
|
|
59
|
+
input: stdin,
|
|
60
|
+
output: muted,
|
|
61
|
+
terminal: true,
|
|
62
|
+
});
|
|
63
|
+
stdout.write(promptText);
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
rl.once('line', (line) => {
|
|
66
|
+
rl.close();
|
|
67
|
+
stdout.write('\n');
|
|
68
|
+
resolve(line);
|
|
69
|
+
});
|
|
70
|
+
rl.once('SIGINT', () => {
|
|
71
|
+
rl.close();
|
|
72
|
+
stdout.write('\n');
|
|
73
|
+
process.exit(130);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Yes/no prompt. Returns true only if the answer starts with 'y' or 'Y'.
|
|
79
|
+
* Uses readline for both TTY and non-TTY so paste/edit behavior is identical.
|
|
80
|
+
*/
|
|
81
|
+
export async function confirm(promptText) {
|
|
82
|
+
const stdin = process.stdin;
|
|
83
|
+
const stdout = process.stdout;
|
|
84
|
+
if (!stdin.isTTY) {
|
|
85
|
+
const rl = createInterface({ input: stdin });
|
|
86
|
+
stdout.write(promptText);
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
rl.once('line', (line) => {
|
|
89
|
+
rl.close();
|
|
90
|
+
resolve(/^y/i.test(line.trim()));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// TTY path: readline in terminal mode (chars echo so the user can see y/n)
|
|
95
|
+
const rl = createInterface({
|
|
96
|
+
input: stdin,
|
|
97
|
+
output: stdout,
|
|
98
|
+
terminal: true,
|
|
99
|
+
});
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
rl.question(promptText, (answer) => {
|
|
102
|
+
rl.close();
|
|
103
|
+
resolve(/^y/i.test(answer.trim()));
|
|
104
|
+
});
|
|
105
|
+
rl.once('SIGINT', () => {
|
|
106
|
+
rl.close();
|
|
107
|
+
stdout.write('\n');
|
|
108
|
+
process.exit(130);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret detection + masking.
|
|
3
|
+
*
|
|
4
|
+
* Used by the inline paste-key flow and as a universal sanitizer over user
|
|
5
|
+
* input: every message added to the transcript, persisted to a session, or
|
|
6
|
+
* forwarded to an LLM is run through `sanitizeKeys` so a real API key never
|
|
7
|
+
* leaves the local machine via session JSON, terminal scrollback, or
|
|
8
|
+
* subsequent LLM turns. The full key only lives in providers.json.
|
|
9
|
+
*
|
|
10
|
+
* Detection is intentionally limited to KNOWN provider key prefixes. That
|
|
11
|
+
* keeps false-positive risk near zero (we don't accidentally mask normal
|
|
12
|
+
* prose). Custom providers that don't match a known prefix go through the
|
|
13
|
+
* existing `mod8 add-provider` flow.
|
|
14
|
+
*/
|
|
15
|
+
import { templateById } from '../providers/registry.js';
|
|
16
|
+
/**
|
|
17
|
+
* Match known key shapes by prefix + a body of safe key characters. Listed
|
|
18
|
+
* most-specific-first so `sk-ant-`/`sk-or-`/`sk-proj-` win over the generic
|
|
19
|
+
* legacy `sk-` fallback (which still has to be long enough to look like a
|
|
20
|
+
* real key — see the 32-char minimum).
|
|
21
|
+
*/
|
|
22
|
+
const KEY_RES = [
|
|
23
|
+
{ re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, templateId: 'anthropic' },
|
|
24
|
+
{ re: /\bsk-or-(?:v\d-)?[A-Za-z0-9_-]{20,}\b/g, templateId: 'openrouter' },
|
|
25
|
+
{ re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/g, templateId: 'openai' },
|
|
26
|
+
{ re: /\bgsk_[A-Za-z0-9]{20,}\b/g, templateId: 'groq' },
|
|
27
|
+
{ re: /\bxai-[A-Za-z0-9]{20,}\b/g, templateId: 'xai' },
|
|
28
|
+
{ re: /\bAIza[A-Za-z0-9_-]{20,}\b/g, templateId: 'google' },
|
|
29
|
+
// Legacy OpenAI / DeepSeek / Mistral — bare "sk-" with a long body. Keep
|
|
30
|
+
// this LAST so the more-specific prefixes above win.
|
|
31
|
+
{ re: /\bsk-[A-Za-z0-9]{32,}\b/g, templateId: 'openai' },
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Find the FIRST API key in a piece of text, if any. Used by the chat REPL
|
|
35
|
+
* to detect a paste, save the provider, and replace the raw key with its
|
|
36
|
+
* masked form before storing/forwarding the message.
|
|
37
|
+
*/
|
|
38
|
+
export function findApiKey(text) {
|
|
39
|
+
let best = null;
|
|
40
|
+
for (const { re, templateId } of KEY_RES) {
|
|
41
|
+
re.lastIndex = 0;
|
|
42
|
+
const m = re.exec(text);
|
|
43
|
+
if (!m)
|
|
44
|
+
continue;
|
|
45
|
+
const tpl = templateById(templateId);
|
|
46
|
+
if (!tpl)
|
|
47
|
+
continue;
|
|
48
|
+
const candidate = {
|
|
49
|
+
key: m[0],
|
|
50
|
+
template: tpl,
|
|
51
|
+
start: m.index,
|
|
52
|
+
end: m.index + m[0].length,
|
|
53
|
+
};
|
|
54
|
+
// Earliest match wins; on ties, longest match (more-specific prefix).
|
|
55
|
+
if (!best ||
|
|
56
|
+
candidate.start < best.start ||
|
|
57
|
+
(candidate.start === best.start && candidate.key.length > best.key.length)) {
|
|
58
|
+
best = candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return best;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Mask an API key for display. Preserves enough of the prefix that the
|
|
65
|
+
* provider stays recognizable (e.g. `sk-ant-…AAAA`) without leaking enough
|
|
66
|
+
* material to be useful if the transcript is shared.
|
|
67
|
+
*/
|
|
68
|
+
export function maskApiKey(key) {
|
|
69
|
+
const trimmed = key.trim();
|
|
70
|
+
if (trimmed.length <= 12)
|
|
71
|
+
return '*'.repeat(Math.max(trimmed.length, 4));
|
|
72
|
+
const prefixLen = trimmed.startsWith('sk-ant-') ? 7
|
|
73
|
+
: trimmed.startsWith('sk-proj-') ? 8
|
|
74
|
+
: trimmed.startsWith('sk-or-') ? 6
|
|
75
|
+
: trimmed.startsWith('AIza') ? 6
|
|
76
|
+
: trimmed.startsWith('gsk_') ? 4
|
|
77
|
+
: trimmed.startsWith('xai-') ? 4
|
|
78
|
+
: trimmed.startsWith('sk-') ? 3
|
|
79
|
+
: 4;
|
|
80
|
+
return `${trimmed.slice(0, prefixLen)}…${trimmed.slice(-4)}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Replace every API key in the input with its masked form. Idempotent —
|
|
84
|
+
* applying twice yields the same string (masked forms don't match the
|
|
85
|
+
* detection regexes). Use this on every user message before persisting or
|
|
86
|
+
* forwarding to an LLM so a key never lands in session JSON or a remote
|
|
87
|
+
* provider's request body.
|
|
88
|
+
*/
|
|
89
|
+
export function sanitizeKeys(text) {
|
|
90
|
+
let out = '';
|
|
91
|
+
let cursor = 0;
|
|
92
|
+
while (cursor < text.length) {
|
|
93
|
+
const remainder = text.slice(cursor);
|
|
94
|
+
const found = findApiKey(remainder);
|
|
95
|
+
if (!found) {
|
|
96
|
+
out += remainder;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
out += remainder.slice(0, found.start) + maskApiKey(found.key);
|
|
100
|
+
cursor += found.end;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Convenience: did the input contain a key? Used by the chat REPL to decide
|
|
106
|
+
* whether to surface the masking confirmation or stay silent.
|
|
107
|
+
*/
|
|
108
|
+
export function containsApiKey(text) {
|
|
109
|
+
return findApiKey(text) !== null;
|
|
110
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small text helpers shared across the routing matchers.
|
|
3
|
+
*/
|
|
4
|
+
/** Levenshtein edit distance. O(|a|·|b|) time, O(min(|a|,|b|)) space. */
|
|
5
|
+
export function levenshtein(a, b) {
|
|
6
|
+
if (a === b)
|
|
7
|
+
return 0;
|
|
8
|
+
if (!a)
|
|
9
|
+
return b.length;
|
|
10
|
+
if (!b)
|
|
11
|
+
return a.length;
|
|
12
|
+
const an = a.length;
|
|
13
|
+
const bn = b.length;
|
|
14
|
+
let prev = new Array(bn + 1);
|
|
15
|
+
let curr = new Array(bn + 1);
|
|
16
|
+
for (let j = 0; j <= bn; j++)
|
|
17
|
+
prev[j] = j;
|
|
18
|
+
for (let i = 1; i <= an; i++) {
|
|
19
|
+
curr[0] = i;
|
|
20
|
+
for (let j = 1; j <= bn; j++) {
|
|
21
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
22
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
23
|
+
}
|
|
24
|
+
[prev, curr] = [curr, prev];
|
|
25
|
+
}
|
|
26
|
+
return prev[bn];
|
|
27
|
+
}
|
|
28
|
+
/** Variants of "key" / synonyms. Used to fuzzy-match typo'd nouns like "kew" / "kee" / "keey". */
|
|
29
|
+
const KEY_NOUN_VARIANTS = [
|
|
30
|
+
'key', 'keys',
|
|
31
|
+
'credentials', 'credential',
|
|
32
|
+
'secret', 'secrets',
|
|
33
|
+
'token', 'tokens',
|
|
34
|
+
'apikey', 'api-key',
|
|
35
|
+
];
|
|
36
|
+
/**
|
|
37
|
+
* True when the word LOOKS like the user meant "key" (or a synonym) —
|
|
38
|
+
* accepts exact matches AND single-edit typos like "kew" / "kee" / "kez".
|
|
39
|
+
*
|
|
40
|
+
* Used by the paste-key intent matcher: phrases like "change the google
|
|
41
|
+
* kew" should still trigger the inline paste flow even though "kew" is
|
|
42
|
+
* mistyped.
|
|
43
|
+
*/
|
|
44
|
+
export function looksLikeKeyNoun(word) {
|
|
45
|
+
const lower = word.toLowerCase().replace(/[!?.,]+$/, '');
|
|
46
|
+
if (lower.length < 2 || lower.length > 16)
|
|
47
|
+
return false;
|
|
48
|
+
for (const v of KEY_NOUN_VARIANTS) {
|
|
49
|
+
if (levenshtein(lower, v) <= 1)
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a millisecond timestamp as "X ago".
|
|
3
|
+
*/
|
|
4
|
+
export function humanTimeAgo(ts) {
|
|
5
|
+
const diff = Math.max(0, Date.now() - ts);
|
|
6
|
+
const sec = Math.floor(diff / 1000);
|
|
7
|
+
const min = Math.floor(sec / 60);
|
|
8
|
+
const hr = Math.floor(min / 60);
|
|
9
|
+
const day = Math.floor(hr / 24);
|
|
10
|
+
if (sec < 60)
|
|
11
|
+
return 'just now';
|
|
12
|
+
if (min === 1)
|
|
13
|
+
return '1 minute ago';
|
|
14
|
+
if (min < 60)
|
|
15
|
+
return `${min} minutes ago`;
|
|
16
|
+
if (hr === 1)
|
|
17
|
+
return '1 hour ago';
|
|
18
|
+
if (hr < 24)
|
|
19
|
+
return `${hr} hours ago`;
|
|
20
|
+
if (day === 1)
|
|
21
|
+
return '1 day ago';
|
|
22
|
+
if (day < 30)
|
|
23
|
+
return `${day} days ago`;
|
|
24
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
25
|
+
}
|