@litfamily/litclaude 1.0.12 → 1.0.13

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.
@@ -0,0 +1,445 @@
1
+ // Optional Jev skill hint for the UserPromptSubmit hook.
2
+ //
3
+ // Off unless LITCLAUDE_JEV=1 and TYPESAFE_API_KEY are both in the environment. On an eligible
4
+ // turn it asks TypeSafe's Jev model which one of this plugin's own skills fits the prompt, and
5
+ // returns at most one fixed advisory sentence naming a skill ID it validated against the catalog
6
+ // it sent. The response is untrusted data: no response text ever reaches the model context.
7
+ //
8
+ // The key is read from the environment only. It is never written, printed, traced, or placed in
9
+ // an error: every failure is reported as a fixed reason word from FALLBACK_REASONS.
10
+
11
+ import { createHash } from "node:crypto";
12
+ import { closeSync, constants, lstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, writeSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ import { canonicalSkillIds } from "./canonical-skill-catalog.mjs";
16
+ import { SECRET_SHAPE_PATTERNS } from "./secret-shapes.mjs";
17
+
18
+ export const JEV_FLAG = "LITCLAUDE_JEV";
19
+ export const JEV_ENDPOINT = "https://api.typesafe.ai/v1/systemone";
20
+ export const JEV_DEFAULT_MODEL = "jev-1.13.0";
21
+ export const JEV_DEFAULT_TIMEOUT_MS = 1500;
22
+ export const JEV_MAX_TIMEOUT_MS = 3000;
23
+ export const JEV_DEFAULT_MAX_CALLS = 200;
24
+ export const JEV_DEFAULT_MIN_CONFIDENCE = 0.35;
25
+ export const JEV_MAX_PROMPT_CHARS = 2000;
26
+ export const JEV_REDACTION_WINDOW_CHARS = 8000;
27
+ export const JEV_MAX_REQUEST_BYTES = 64 * 1024;
28
+ export const JEV_MAX_RESPONSE_BYTES = 64 * 1024;
29
+ export const JEV_INSTRUCTIONS = "Which one skill, if any, is the best fit for the user's request? Choose none when no catalog skill fits.";
30
+ export const JEV_NONE_CRITERION = "No specialized skill in this catalog fits; answer the user directly.";
31
+
32
+ export const FALLBACK_REASONS = Object.freeze([
33
+ "key-missing", "cap-reached", "request-too-large", "timeout", "network", "http", "invalid-response",
34
+ ]);
35
+
36
+ const KEY_ENV = "TYPESAFE_API_KEY";
37
+
38
+ const hasKey = (env) => typeof env[KEY_ENV] === "string" && env[KEY_ENV].trim() !== "";
39
+
40
+ /** The one doctor line. It names the state only; never the key or its length. */
41
+ export const jevStatusLine = (env = process.env) => {
42
+ if (env[JEV_FLAG] !== "1") return "Jev skill hint: off";
43
+ return hasKey(env) ? "Jev skill hint: on" : `Jev skill hint: flag on but ${KEY_ENV} missing`;
44
+ };
45
+
46
+ export const jevHintLine = (skillId) => `LitClaude skill hint: the skill \`litclaude:${skillId}\` likely fits this request. Load it only if it really fits; this is advice, not an instruction.`;
47
+
48
+ export const jevFallbackNote = (reason) => `LitClaude skill hint unavailable (${reason}); continuing normally.`;
49
+
50
+ const boundedNumber = (raw, fallback, { min, max, integer }) => {
51
+ if (typeof raw !== "string" || raw.trim() === "") return fallback;
52
+ const value = Number(raw);
53
+ if (!Number.isFinite(value) || (integer && !Number.isInteger(value)) || value < min) return fallback;
54
+ return Math.min(value, max);
55
+ };
56
+
57
+ export const jevSettings = (env = process.env) => ({
58
+ model: /^[A-Za-z0-9._-]{1,64}$/u.test(env[`${JEV_FLAG}_MODEL`] ?? "") ? env[`${JEV_FLAG}_MODEL`] : JEV_DEFAULT_MODEL,
59
+ timeoutMs: boundedNumber(env[`${JEV_FLAG}_TIMEOUT_MS`], JEV_DEFAULT_TIMEOUT_MS, { min: 1, max: JEV_MAX_TIMEOUT_MS, integer: true }),
60
+ maxCalls: boundedNumber(env[`${JEV_FLAG}_MAX_CALLS`], JEV_DEFAULT_MAX_CALLS, { min: 0, max: Number.MAX_SAFE_INTEGER, integer: true }),
61
+ minConfidence: boundedNumber(env[`${JEV_FLAG}_MIN_CONFIDENCE`], JEV_DEFAULT_MIN_CONFIDENCE, { min: 0, max: 1, integer: false }),
62
+ trace: env[`${JEV_FLAG}_TRACE`] === "1",
63
+ });
64
+
65
+ const globalPattern = (pattern) => new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
66
+
67
+ // Contract shapes first, then the plugin's shared credential set as a second net.
68
+ const TOKEN_PATTERNS = Object.freeze([
69
+ /-----BEGIN [A-Z0-9 ]*-----[\s\S]*?(?:-----END [A-Z0-9 ]*-----|$)/gu,
70
+ /eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]*)?/gu,
71
+ /(?<![A-Za-z0-9_])sk-(?:ant-)?[A-Za-z0-9_-]{8,}/gu,
72
+ /(?<![A-Za-z0-9_])(?:ghp|gho|github_pat)_[A-Za-z0-9_]{8,}/gu,
73
+ /(?<![A-Za-z0-9_])npm_[A-Za-z0-9]{8,}/gu,
74
+ /(?<![A-Za-z0-9_])apikey_[A-Za-z0-9_-]{8,}/gu,
75
+ /(?<![A-Za-z0-9_])xox[abprs]-[A-Za-z0-9-]{8,}/gu,
76
+ /(?<![A-Za-z0-9])AKIA[A-Z0-9]{12,}/gu,
77
+ /(?<![A-Za-z0-9_])AIza[A-Za-z0-9_-]{16,}/gu,
78
+ /[A-Za-z0-9+/_-]{32,}={0,2}/gu,
79
+ ...SECRET_SHAPE_PATTERNS.map(globalPattern),
80
+ ]);
81
+
82
+ /**
83
+ * Redaction in the contract's order: an 8,000-character window, the literal key, home paths,
84
+ * e-mail addresses, token shapes, the 2,000-character cut, then any run of 8 or more token
85
+ * characters left at the cut, so a secret split by the cut never leaves a fragment behind.
86
+ */
87
+ export const redactPromptForJev = (text, key) => {
88
+ let value = Array.from(typeof text === "string" ? text : "").slice(0, JEV_REDACTION_WINDOW_CHARS).join("");
89
+ // A key pasted into the prompt itself is removed first, even when its shape escapes the patterns.
90
+ if (typeof key === "string" && key !== "") value = value.split(key).join("[secret]");
91
+ // A home path ends at the next separator or punctuation; a trailing dot stays outside it.
92
+ value = value
93
+ .replace(/\/(?:Users|home)\/[^/\\\s,;:!?'"`()<>[\]{}]*[^/\\\s,;:!?'"`()<>[\]{}.]/gu, "~")
94
+ .replace(/[A-Za-z]:\\Users\\[^/\\\s,;:!?'"`()<>[\]{}]*[^/\\\s,;:!?'"`()<>[\]{}.]/gu, "~");
95
+ value = value.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/gu, "[email]");
96
+ for (const pattern of TOKEN_PATTERNS) value = value.replace(pattern, "[secret]");
97
+ const characters = Array.from(value);
98
+ if (characters.length <= JEV_MAX_PROMPT_CHARS) return value;
99
+ return characters.slice(0, JEV_MAX_PROMPT_CHARS).join("").replace(/[A-Za-z0-9+/_=-]{8,}$/u, "[secret]");
100
+ };
101
+
102
+ const frontmatterField = (frontmatter, name) => {
103
+ const match = new RegExp(`^${name}:[ \\t]*(.*)$`, "mu").exec(frontmatter);
104
+ if (!match) return "";
105
+ const raw = match[1].trim();
106
+ if (raw.startsWith('"')) {
107
+ try {
108
+ return JSON.parse(raw);
109
+ } catch {
110
+ return raw.slice(1, -1);
111
+ }
112
+ }
113
+ return raw.startsWith("'") && raw.endsWith("'") ? raw.slice(1, -1) : raw;
114
+ };
115
+
116
+ /**
117
+ * The enrolled skills the model may load itself: every canonical skill except those marked
118
+ * `disable-model-invocation: true`, which only a user slash command can start.
119
+ */
120
+ export const buildJevCatalog = (pluginRoot) => {
121
+ const catalog = [];
122
+ for (const skillId of canonicalSkillIds) {
123
+ let content;
124
+ try {
125
+ content = readFileSync(join(pluginRoot, "skills", skillId, "SKILL.md"), "utf8");
126
+ } catch {
127
+ continue;
128
+ }
129
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(content)?.[1] ?? "";
130
+ if (frontmatterField(frontmatter, "disable-model-invocation") === "true") continue;
131
+ const description = String(frontmatterField(frontmatter, "description")).slice(0, 300);
132
+ if (description) catalog.push({ id: skillId, description });
133
+ }
134
+ return catalog;
135
+ };
136
+
137
+ export const buildJevRequestBody = ({ model, state, catalog }) => ({
138
+ model,
139
+ state,
140
+ questions: {
141
+ which: {
142
+ type: "choice",
143
+ instructions: JEV_INSTRUCTIONS,
144
+ criteria: {
145
+ ...Object.fromEntries(catalog.map(({ id, description }) => [id, description])),
146
+ none: JEV_NONE_CRITERION,
147
+ },
148
+ },
149
+ },
150
+ });
151
+
152
+ /** Validates an untrusted response. Returns the accepted choice ID, "none", or a failure. */
153
+ export const parseJevResponse = ({ status, text, catalogIds, minConfidence }) => {
154
+ if (status !== 200) return { ok: false, reason: "http" };
155
+ let parsed;
156
+ try {
157
+ parsed = JSON.parse(text);
158
+ } catch {
159
+ return { ok: false, reason: "invalid-response" };
160
+ }
161
+ const answer = parsed?.answers?.which;
162
+ const choice = answer?.choice;
163
+ if (typeof choice !== "string" || (choice !== "none" && !catalogIds.includes(choice))) {
164
+ return { ok: false, reason: "invalid-response" };
165
+ }
166
+ const confidence = answer.confidence;
167
+ if (typeof confidence !== "number" || !Number.isFinite(confidence)) return { ok: false, reason: "invalid-response" };
168
+ return { ok: true, choice, confidence, accepted: choice !== "none" && confidence >= minConfidence };
169
+ };
170
+
171
+ const readBounded = async (response) => {
172
+ const reader = response.body?.getReader?.();
173
+ if (!reader) {
174
+ const text = await response.text();
175
+ return Buffer.byteLength(text, "utf8") > JEV_MAX_RESPONSE_BYTES ? null : text;
176
+ }
177
+ const chunks = [];
178
+ let size = 0;
179
+ for (;;) {
180
+ const { done, value } = await reader.read();
181
+ if (done) break;
182
+ size += value.byteLength;
183
+ if (size > JEV_MAX_RESPONSE_BYTES) {
184
+ await reader.cancel().catch(() => {});
185
+ return null;
186
+ }
187
+ chunks.push(Buffer.from(value));
188
+ }
189
+ return Buffer.concat(chunks).toString("utf8");
190
+ };
191
+
192
+ /** One POST, no retry. Resolves to { status, text } or { reason }; never throws, never rejects. */
193
+ const postOnce = async ({ fetchImpl, key, body, timeoutMs }) => {
194
+ const controller = new AbortController();
195
+ let timer;
196
+ const timeout = new Promise((resolveTimeout) => {
197
+ timer = setTimeout(() => {
198
+ controller.abort();
199
+ resolveTimeout({ reason: "timeout" });
200
+ }, timeoutMs);
201
+ });
202
+ const request = (async () => {
203
+ try {
204
+ const response = await fetchImpl(JEV_ENDPOINT, {
205
+ method: "POST",
206
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
207
+ body,
208
+ redirect: "error",
209
+ signal: controller.signal,
210
+ });
211
+ const status = response.status;
212
+ if (status !== 200) {
213
+ // An unread body would hold the connection open; release it before reporting.
214
+ await response.body?.cancel?.().catch(() => {});
215
+ return { status, text: "" };
216
+ }
217
+ const text = await readBounded(response);
218
+ return text === null ? { status, reason: "invalid-response" } : { status, text };
219
+ } catch {
220
+ return { reason: controller.signal.aborted ? "timeout" : "network" };
221
+ }
222
+ })();
223
+ try {
224
+ return await Promise.race([request, timeout]);
225
+ } finally {
226
+ clearTimeout(timer);
227
+ }
228
+ };
229
+
230
+ const safeSessionId = (sessionId) => {
231
+ const cleaned = typeof sessionId === "string" ? sessionId.replace(/[^A-Za-z0-9_-]/gu, "").slice(0, 64) : "";
232
+ return cleaned || "unknown";
233
+ };
234
+
235
+ const jevStateDirectory = (stateRoot) => join(stateRoot, ".litclaude", "jev");
236
+
237
+ // Opening with O_NOFOLLOW refuses a symlink planted at a state or trace path, so a write never
238
+ // lands outside the state folder. Where the platform lacks the flag, lstat refuses it instead.
239
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
240
+ const refuseSymlink = (path) => {
241
+ if (NO_FOLLOW) return;
242
+ try {
243
+ if (lstatSync(path).isSymbolicLink()) throw new Error("symlink");
244
+ } catch (error) {
245
+ if (error?.code !== "ENOENT") throw error;
246
+ }
247
+ };
248
+
249
+ // The last hinted skill and its latency, kept for the HUD. Only a catalog ID and a whole
250
+ // number of milliseconds survive a read, so the status line never echoes anything else.
251
+ const validLast = (value) => (
252
+ typeof value?.skill === "string" && canonicalSkillIds.includes(value.skill)
253
+ && Number.isSafeInteger(value.latency_ms) && value.latency_ms >= 0
254
+ ? { skill: value.skill, latency_ms: value.latency_ms }
255
+ : null
256
+ );
257
+
258
+ const readSession = (stateRoot, sessionId) => {
259
+ const empty = { calls: 0, noted: false, banner: false, last: null };
260
+ try {
261
+ const path = join(jevStateDirectory(stateRoot), `session-${safeSessionId(sessionId)}.json`);
262
+ if (lstatSync(path).isSymbolicLink()) return empty;
263
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
264
+ return {
265
+ calls: Number.isSafeInteger(parsed?.calls) && parsed.calls >= 0 ? parsed.calls : 0,
266
+ noted: parsed?.noted === true,
267
+ banner: parsed?.banner === true,
268
+ last: validLast(parsed?.last),
269
+ };
270
+ } catch {
271
+ return empty;
272
+ }
273
+ };
274
+
275
+ const writeSession = (stateRoot, sessionId, session) => {
276
+ try {
277
+ const directory = jevStateDirectory(stateRoot);
278
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
279
+ const target = join(directory, `session-${safeSessionId(sessionId)}.json`);
280
+ const temporary = `${target}.${process.pid}.tmp`;
281
+ refuseSymlink(temporary);
282
+ writeFileSync(temporary, JSON.stringify(session), {
283
+ mode: 0o600,
284
+ flag: constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NO_FOLLOW,
285
+ });
286
+ renameSync(temporary, target);
287
+ return true;
288
+ } catch {
289
+ // Session bookkeeping is best-effort; an unwritable state folder never alters the turn.
290
+ return false;
291
+ }
292
+ };
293
+
294
+ const writeTrace = (stateRoot, record) => {
295
+ try {
296
+ const directory = jevStateDirectory(stateRoot);
297
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
298
+ const target = join(directory, "trace.jsonl");
299
+ refuseSymlink(target);
300
+ const fd = openSync(target, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | NO_FOLLOW, 0o600);
301
+ try {
302
+ writeSync(fd, `${JSON.stringify(record)}\n`);
303
+ } finally {
304
+ closeSync(fd);
305
+ }
306
+ } catch {
307
+ // The trace is an opt-in debug aid; failing to write it never alters the turn.
308
+ }
309
+ };
310
+
311
+ /**
312
+ * Called by the prompt hook at the start of every turn while the flag is on, so the HUD shows a
313
+ * skill only for the turn that was hinted. Touches nothing when there is no hint to clear.
314
+ */
315
+ export const clearJevTurn = (stateRoot, sessionId) => {
316
+ const session = readSession(stateRoot, sessionId);
317
+ if (session.last) writeSession(stateRoot, sessionId, { ...session, last: null });
318
+ };
319
+
320
+ export const JEV_BANNER = "✦ Jev skill hint ON ✦";
321
+
322
+ /**
323
+ * True exactly once per session, on the first turn with both switches on. The "shown" mark is
324
+ * written before the caller emits the banner; when it cannot be written, no banner is shown,
325
+ * so an unwritable state folder never repeats it.
326
+ */
327
+ export const claimJevBanner = ({ env = process.env, stateRoot, sessionId } = {}) => {
328
+ if (env[JEV_FLAG] !== "1" || !hasKey(env) || !stateRoot) return false;
329
+ const session = readSession(stateRoot, sessionId);
330
+ if (session.banner) return false;
331
+ return writeSession(stateRoot, sessionId, { ...session, banner: true });
332
+ };
333
+
334
+ /**
335
+ * What the HUD may show: null when the flag is off, otherwise the key's presence and this
336
+ * turn's hint. It reports whether a key exists, never the key or its length.
337
+ */
338
+ export const jevHudState = ({ env = process.env, stateRoot, sessionId } = {}) => {
339
+ if (env[JEV_FLAG] !== "1") return null;
340
+ if (!hasKey(env)) return { keyMissing: true, last: null };
341
+ return { keyMissing: false, last: stateRoot ? readSession(stateRoot, sessionId).last : null };
342
+ };
343
+
344
+ export const formatJevLatency = (latencyMs) => `${(latencyMs / 1000).toFixed(2)}s`;
345
+
346
+ /** Host-side eligibility that needs no router: a leading command character or too little text. */
347
+ export const isJevEligibleText = (text) => {
348
+ if (typeof text !== "string") return false;
349
+ const trimmed = text.trimStart();
350
+ if (/^[/!]/u.test(trimmed) || /<command-name>/u.test(text)) return false;
351
+ return text.replace(/\s/gu, "").length >= 4;
352
+ };
353
+
354
+ /**
355
+ * Runs the hint for one turn. `eligible` is the hook's own verdict (not routed, not a slash
356
+ * command). Returns { hint, note }: each is a fixed sentence or null, and neither ever carries
357
+ * response text. Never throws.
358
+ */
359
+ export const suggestJevSkill = async ({
360
+ env = process.env,
361
+ prompt,
362
+ eligible = true,
363
+ sessionId,
364
+ stateRoot,
365
+ pluginRoot,
366
+ fetchImpl = globalThis.fetch,
367
+ now = () => Date.now(),
368
+ }) => {
369
+ const quiet = { hint: null, note: null };
370
+ if (env[JEV_FLAG] !== "1") return quiet;
371
+ if (!eligible || !isJevEligibleText(prompt)) return quiet;
372
+ const settings = jevSettings(env);
373
+ const session = readSession(stateRoot, sessionId);
374
+ const state = redactPromptForJev(prompt, hasKey(env) ? env[KEY_ENV] : undefined);
375
+ const started = now();
376
+ let result = { choice: null, confidence: null, status: null, reason: null };
377
+ const fail = (reason) => {
378
+ result = { ...result, reason };
379
+ return reason;
380
+ };
381
+
382
+ let hint = null;
383
+ let hinted = null;
384
+ let failure = null;
385
+ if (!hasKey(env)) {
386
+ failure = fail("key-missing");
387
+ } else if (session.calls >= settings.maxCalls) {
388
+ failure = fail("cap-reached");
389
+ } else {
390
+ const catalog = buildJevCatalog(pluginRoot);
391
+ const body = JSON.stringify(buildJevRequestBody({ model: settings.model, state, catalog }));
392
+ if (Buffer.byteLength(body, "utf8") > JEV_MAX_REQUEST_BYTES) {
393
+ failure = fail("request-too-large");
394
+ } else {
395
+ // Fail closed: the call is counted on disk before it is made, and an uncountable call is
396
+ // never made, so an unwritable state folder cannot lift the per-session cap.
397
+ session.calls += 1;
398
+ if (!writeSession(stateRoot, sessionId, session)) return quiet;
399
+ const response = typeof fetchImpl === "function"
400
+ ? await postOnce({ fetchImpl, key: env[KEY_ENV], body, timeoutMs: settings.timeoutMs })
401
+ : { reason: "network" };
402
+ result.status = response.status ?? null;
403
+ if (response.reason) {
404
+ failure = fail(response.reason);
405
+ } else {
406
+ const parsed = parseJevResponse({
407
+ status: response.status,
408
+ text: response.text,
409
+ catalogIds: catalog.map(({ id }) => id),
410
+ minConfidence: settings.minConfidence,
411
+ });
412
+ if (!parsed.ok) {
413
+ failure = fail(parsed.reason);
414
+ } else {
415
+ result = { ...result, choice: parsed.choice, confidence: parsed.confidence };
416
+ if (parsed.accepted) {
417
+ hint = jevHintLine(parsed.choice);
418
+ hinted = parsed.choice;
419
+ }
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ const latencyMs = Math.max(0, Math.round(now() - started));
426
+ let note = null;
427
+ if (failure && !session.noted) {
428
+ session.noted = true;
429
+ note = jevFallbackNote(failure);
430
+ }
431
+ session.last = hinted ? { skill: hinted, latency_ms: latencyMs } : null;
432
+ writeSession(stateRoot, sessionId, session);
433
+ if (settings.trace) {
434
+ writeTrace(stateRoot, {
435
+ ts: new Date(started).toISOString(),
436
+ prompt_sha256: createHash("sha256").update(state).digest("hex"),
437
+ choice: result.choice,
438
+ confidence: result.confidence,
439
+ latency_ms: latencyMs,
440
+ http_status: result.status,
441
+ fallback: result.reason,
442
+ });
443
+ }
444
+ return { hint, note };
445
+ };