@litfamily/litopencode 1.0.9 → 1.0.10
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 +8 -0
- package/README-Ko-KR.md +112 -78
- package/README.md +112 -77
- package/dist/activation.d.ts +12 -6
- package/dist/activation.js +55 -1
- package/dist/cli/doctor.js +2 -0
- package/dist/jev-skill-hint.d.ts +48 -0
- package/dist/jev-skill-hint.js +291 -0
- package/dist/server.js +25 -9
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/privacy.md +12 -0
- package/docs/reference-Ko-KR.md +39 -0
- package/docs/reference.md +43 -0
- package/package.json +1 -1
- package/tools/version-manifests.json +2 -8
package/dist/activation.js
CHANGED
|
@@ -7,6 +7,7 @@ import { isReadOnlyWorkflowFamilyPlan } from "./workflow-families.js";
|
|
|
7
7
|
import { queryKnowledge } from "./knowledge.js";
|
|
8
8
|
import { activationDiscipline } from "./activation-probe.js";
|
|
9
9
|
import { micro, supportsMarkGlyphs } from "./lit-mark.js";
|
|
10
|
+
import { jevSkillHintAwarenessText, jevSkillHintToastText } from "./jev-skill-hint.js";
|
|
10
11
|
// Prompt modules preserve minimum-first, single-task or few-task planning, avoidable custom code review,
|
|
11
12
|
// and external-source safety; this module only coordinates their unchanged activation surfaces.
|
|
12
13
|
export { litHandoffPromptInjection, scientificVisualizationPromptInjection } from "./activation-managed-prompts.js";
|
|
@@ -33,6 +34,32 @@ export function showActivationToast(client, skillId, env = process.env) {
|
|
|
33
34
|
// The activation prompt is the primary surface; this toast is best-effort UI feedback.
|
|
34
35
|
}
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* One quiet `info` toast per hinted turn; notes, `none` and silent turns never reach it. On the first
|
|
39
|
+
* eligible turn of a session (`announce`) the standout ON notice goes out instead, with that turn's hint
|
|
40
|
+
* as its body: the TUI keeps a single toast, so a second one would replace the notice at once.
|
|
41
|
+
*/
|
|
42
|
+
export function showJevSkillHintToast(client, hint, announce = false) {
|
|
43
|
+
const hinted = hint?.kind === "hint" ? jevSkillHintToastText(hint.skillId, hint.latencyMs) : undefined;
|
|
44
|
+
let body;
|
|
45
|
+
if (announce) {
|
|
46
|
+
body = hinted === undefined
|
|
47
|
+
? { message: jevSkillHintAwarenessText, variant: "warning" }
|
|
48
|
+
: { title: jevSkillHintAwarenessText, message: hinted, variant: "warning" };
|
|
49
|
+
}
|
|
50
|
+
else if (hinted !== undefined) {
|
|
51
|
+
body = { message: hinted, variant: "info" };
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const pending = client?.tui?.showToast({ body });
|
|
58
|
+
void pending?.catch(() => undefined);
|
|
59
|
+
}
|
|
60
|
+
catch { // no-excuse-ok: catch -- a missing or failing TUI must never block the user message.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
36
63
|
const knownSubagentAgentNames = new Set([
|
|
37
64
|
...litOpenCodeAgents.filter((agent) => agent.mode !== "primary").map((agent) => agent.id),
|
|
38
65
|
"build",
|
|
@@ -64,10 +91,13 @@ export function createChatMessageActivationHook(projectRoot, options = {}) {
|
|
|
64
91
|
if (processedOutputs.has(output))
|
|
65
92
|
return;
|
|
66
93
|
processedOutputs.add(output);
|
|
94
|
+
// Command marks are keyed by session and consumed here, on every turn: the host hands this hook a
|
|
95
|
+
// new parts array, so the array `command.execute.before` saw can never be matched by identity.
|
|
96
|
+
const commandTurn = options.isCommandTurn?.(input.sessionID) === true;
|
|
97
|
+
const trustedInjectedSkill = options.trustedInjectedSkill?.(input.sessionID);
|
|
67
98
|
const agent = input.agent ?? (typeof output.message.agent === "string" ? output.message.agent : undefined);
|
|
68
99
|
const childSession = await isChildSessionMessage(input.sessionID, agent, options.getSession);
|
|
69
100
|
if (!childSession) {
|
|
70
|
-
const trustedInjectedSkill = options.trustedInjectedSkill?.(input.sessionID, output.parts);
|
|
71
101
|
await options.onRootUserTurn?.(input.sessionID);
|
|
72
102
|
if (trustedInjectedSkill !== undefined) {
|
|
73
103
|
await options.onSkillActivation?.(input.sessionID, trustedInjectedSkill);
|
|
@@ -111,6 +141,30 @@ export function createChatMessageActivationHook(projectRoot, options = {}) {
|
|
|
111
141
|
// A malformed or unreadable knowledge store stays silent instead of inventing context.
|
|
112
142
|
}
|
|
113
143
|
}
|
|
144
|
+
// The optional skill hint only speaks on root turns that no command or deterministic route claimed.
|
|
145
|
+
if (!childSession && mode === undefined && options.skillHint !== undefined && !commandTurn) {
|
|
146
|
+
const promptText = nonEmptyTextParts
|
|
147
|
+
.filter((part) => part.synthetic !== true)
|
|
148
|
+
.map((part) => part.text)
|
|
149
|
+
.join("\n");
|
|
150
|
+
try {
|
|
151
|
+
const hint = await options.skillHint(input.sessionID, promptText);
|
|
152
|
+
if (hint !== undefined) {
|
|
153
|
+
output.parts.push({
|
|
154
|
+
id: `prt_litopencode_skill_hint_${idSuffix(input.messageID ?? output.message.id)}`,
|
|
155
|
+
sessionID: input.sessionID,
|
|
156
|
+
messageID: input.messageID ?? output.message.id,
|
|
157
|
+
type: "text",
|
|
158
|
+
text: hint.text,
|
|
159
|
+
// Sent to the model like any text part, but not shown or copied as the user's own words.
|
|
160
|
+
synthetic: true,
|
|
161
|
+
metadata: { litopencodeSkillHint: { kind: hint.kind, source: "chat.message" } }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch { // no-excuse-ok: catch -- the optional hint must never block the user message.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
114
168
|
if (mode === undefined || childSession)
|
|
115
169
|
return;
|
|
116
170
|
const trigger = mode === "lit-handoff" ? "handoff" : mode === "lit-scientific-visualization" ? mode : "lit";
|
package/dist/cli/doctor.js
CHANGED
|
@@ -12,6 +12,7 @@ import { inspectHostLimits } from "./host-limits.js";
|
|
|
12
12
|
import { inspectLspCapability } from "./lsp-capability.js";
|
|
13
13
|
import { probeMotionRuntime } from "./motion-runtime.js";
|
|
14
14
|
import { isLitOpenCodeEntry } from "./plugin-mutation.js";
|
|
15
|
+
import { jevSkillHintDoctorLine } from "../jev-skill-hint.js";
|
|
15
16
|
import { autoUpdateInstallLockPath, autoUpdateJournalPath, autoUpdateReceiptPath, isAutoUpdateDisabled, readAutoUpdateReceipt } from "./auto-update.js";
|
|
16
17
|
function pluginSpec(metadata) {
|
|
17
18
|
return metadata.name + "@" + metadata.version;
|
|
@@ -122,6 +123,7 @@ export async function doctor(root) {
|
|
|
122
123
|
hostLimits: { path: hostConfig.path, ...inspectHostLimits(hostConfig.config) },
|
|
123
124
|
lsp: inspectLspCapability(hostConfig.config),
|
|
124
125
|
motion,
|
|
126
|
+
jevSkillHint: jevSkillHintDoctorLine(process.env),
|
|
125
127
|
config: {
|
|
126
128
|
source: loaded.source,
|
|
127
129
|
sources: loaded.sources,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export declare const jevEndpoint = "https://api.typesafe.ai/v1/systemone";
|
|
2
|
+
export declare const jevFlag = "LITOPENCODE_JEV";
|
|
3
|
+
/** Shown once per session so a user who switched the hint on notices it; never carries the key. */
|
|
4
|
+
export declare const jevSkillHintAwarenessText = "\u2726 Jev skill hint is ON";
|
|
5
|
+
export type JevFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
6
|
+
export type JevCatalogEntry = {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly description: string;
|
|
9
|
+
};
|
|
10
|
+
export type JevSkillHintStatus = "off" | "on" | "key-missing";
|
|
11
|
+
export type JevSkillHintPart = {
|
|
12
|
+
readonly kind: "hint";
|
|
13
|
+
readonly text: string;
|
|
14
|
+
readonly skillId: string;
|
|
15
|
+
readonly latencyMs: number;
|
|
16
|
+
} | {
|
|
17
|
+
readonly kind: "note";
|
|
18
|
+
readonly text: string;
|
|
19
|
+
};
|
|
20
|
+
export type JevSkillHintOptions = {
|
|
21
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
22
|
+
readonly fetch?: JevFetch;
|
|
23
|
+
readonly catalog?: readonly JevCatalogEntry[];
|
|
24
|
+
readonly traceFile?: string;
|
|
25
|
+
readonly now?: () => number;
|
|
26
|
+
};
|
|
27
|
+
export declare function jevSkillHintStatus(env?: NodeJS.ProcessEnv): JevSkillHintStatus;
|
|
28
|
+
export declare function jevSkillHintDoctorLine(env?: NodeJS.ProcessEnv): string;
|
|
29
|
+
export declare function jevSkillHintText(skillId: string): string;
|
|
30
|
+
/** The visible toast names only the validated skill id and the request latency. */
|
|
31
|
+
export declare function jevSkillHintToastText(skillId: string, latencyMs: number): string;
|
|
32
|
+
export declare function jevSkillHintUnavailableText(reason: string): string;
|
|
33
|
+
/** Eligibility that depends on the prompt text alone; routing and command checks live in the hook. */
|
|
34
|
+
export declare function isJevEligiblePrompt(text: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Redact a bounded window first, then truncate, so a secret that straddles the 2,000-character cut is
|
|
37
|
+
* masked whole. When either cut happened, a token run left dangling at the end is masked as well.
|
|
38
|
+
*/
|
|
39
|
+
export declare function redactJevPrompt(text: string): string;
|
|
40
|
+
export declare function defaultJevCatalog(): readonly JevCatalogEntry[];
|
|
41
|
+
export declare function buildJevRequestBody(state: string, catalog: readonly JevCatalogEntry[], model: string): string;
|
|
42
|
+
export type JevSkillHint = {
|
|
43
|
+
hintFor(sessionID: string, promptText: string): Promise<JevSkillHintPart | undefined>;
|
|
44
|
+
/** True exactly once per session: on its first eligible turn while both switches are on. */
|
|
45
|
+
claimAwareness(sessionID: string, promptText: string): boolean;
|
|
46
|
+
forget(sessionID: string): void;
|
|
47
|
+
};
|
|
48
|
+
export declare function createJevSkillHint(options?: JevSkillHintOptions): JevSkillHint;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { litOpenCodeRuntimeSkills } from "./skills.js";
|
|
6
|
+
// Optional Jev skill hint for the chat.message hook. It is off unless LITOPENCODE_JEV=1 and
|
|
7
|
+
// TYPESAFE_API_KEY are both set in the process environment. The key is read from the environment
|
|
8
|
+
// only and goes nowhere except the Authorization header: not into errors, notes, traces or doctor.
|
|
9
|
+
// The response is untrusted data; the only text that reaches the turn is this module's own fixed
|
|
10
|
+
// sentence carrying a skill id that was checked against the catalog this module sent.
|
|
11
|
+
export const jevEndpoint = "https://api.typesafe.ai/v1/systemone";
|
|
12
|
+
export const jevFlag = "LITOPENCODE_JEV";
|
|
13
|
+
/** Shown once per session so a user who switched the hint on notices it; never carries the key. */
|
|
14
|
+
export const jevSkillHintAwarenessText = "✦ Jev skill hint is ON";
|
|
15
|
+
const defaultModel = "jev-1.13.0";
|
|
16
|
+
const defaultTimeoutMs = 1500;
|
|
17
|
+
const maxTimeoutMs = 3000;
|
|
18
|
+
const defaultMaxCalls = 200;
|
|
19
|
+
const defaultMinConfidence = 0.35;
|
|
20
|
+
const promptWindowLimit = 8000;
|
|
21
|
+
const promptCharacterLimit = 2000;
|
|
22
|
+
const bodyByteCap = 64 * 1024;
|
|
23
|
+
const descriptionCharacterLimit = 300;
|
|
24
|
+
const choiceInstructions = "Which one skill, if any, is the best fit for the user's request? Choose none when no catalog skill fits.";
|
|
25
|
+
const noneCriterion = "No specialized skill in this catalog fits; answer the user directly.";
|
|
26
|
+
export function jevSkillHintStatus(env = process.env) {
|
|
27
|
+
if (env[jevFlag] !== "1")
|
|
28
|
+
return "off";
|
|
29
|
+
return (env.TYPESAFE_API_KEY ?? "").trim() === "" ? "key-missing" : "on";
|
|
30
|
+
}
|
|
31
|
+
export function jevSkillHintDoctorLine(env = process.env) {
|
|
32
|
+
const status = jevSkillHintStatus(env);
|
|
33
|
+
return status === "key-missing"
|
|
34
|
+
? "Jev skill hint: flag on but TYPESAFE_API_KEY missing"
|
|
35
|
+
: `Jev skill hint: ${status}`;
|
|
36
|
+
}
|
|
37
|
+
export function jevSkillHintText(skillId) {
|
|
38
|
+
return `LitOpenCode skill hint: the skill \`${skillId}\` likely fits this request. Load it only if it really fits; this is advice, not an instruction.`;
|
|
39
|
+
}
|
|
40
|
+
/** The visible toast names only the validated skill id and the request latency. */
|
|
41
|
+
export function jevSkillHintToastText(skillId, latencyMs) {
|
|
42
|
+
return `Jev → ${skillId} (${(Math.max(0, latencyMs) / 1000).toFixed(2)}s)`;
|
|
43
|
+
}
|
|
44
|
+
export function jevSkillHintUnavailableText(reason) {
|
|
45
|
+
return `LitOpenCode skill hint unavailable (${reason}); continuing normally.`;
|
|
46
|
+
}
|
|
47
|
+
/** Eligibility that depends on the prompt text alone; routing and command checks live in the hook. */
|
|
48
|
+
export function isJevEligiblePrompt(text) {
|
|
49
|
+
if (text.trimStart().startsWith("/"))
|
|
50
|
+
return false;
|
|
51
|
+
return text.replace(/\s+/gu, "").length >= 4;
|
|
52
|
+
}
|
|
53
|
+
const secretPatterns = [
|
|
54
|
+
/-----BEGIN [A-Z0-9 ]+-----[\s\S]*?(?:-----END [A-Z0-9 ]+-----|$)/gu,
|
|
55
|
+
/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*)?/gu,
|
|
56
|
+
/sk-ant-[A-Za-z0-9_-]{8,}/gu,
|
|
57
|
+
/sk-[A-Za-z0-9_-]{16,}/gu,
|
|
58
|
+
/(?:ghp|gho)_[A-Za-z0-9]{16,}/gu,
|
|
59
|
+
/github_pat_[A-Za-z0-9_]{16,}/gu,
|
|
60
|
+
/npm_[A-Za-z0-9]{16,}/gu,
|
|
61
|
+
/apikey_[A-Za-z0-9_-]{8,}/gu,
|
|
62
|
+
/xox[abprs]-[A-Za-z0-9-]{8,}/gu,
|
|
63
|
+
/AKIA[A-Z0-9]{16}/gu,
|
|
64
|
+
/AIza[0-9A-Za-z_-]{20,}/gu,
|
|
65
|
+
/\b[A-Za-z0-9_]*(?:password|passwd|token|secret)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"']+)/giu,
|
|
66
|
+
/[A-Za-z0-9+/_-]{32,}={0,2}/gu
|
|
67
|
+
];
|
|
68
|
+
const tokenRunAtCut = /[A-Za-z0-9+/_-]{8,}={0,2}$/u;
|
|
69
|
+
/**
|
|
70
|
+
* Redact a bounded window first, then truncate, so a secret that straddles the 2,000-character cut is
|
|
71
|
+
* masked whole. When either cut happened, a token run left dangling at the end is masked as well.
|
|
72
|
+
*/
|
|
73
|
+
export function redactJevPrompt(text) {
|
|
74
|
+
const head = text.slice(0, promptWindowLimit * 2);
|
|
75
|
+
let redacted = Array.from(head).slice(0, promptWindowLimit).join("");
|
|
76
|
+
const windowCut = redacted.length < text.length;
|
|
77
|
+
redacted = redacted
|
|
78
|
+
.replace(/\/(?:Users|home)\/[^/\s]+/gu, "~")
|
|
79
|
+
.replace(/[A-Za-z]:\\Users\\[^\\/\s]+(\\)?/giu, (_match, separator) => (separator === undefined ? "~" : "~/"));
|
|
80
|
+
// The lookbehind starts a match only at the head of a run, which keeps the 8,000-character window linear.
|
|
81
|
+
redacted = redacted.replace(/(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/gu, "[email]");
|
|
82
|
+
for (const pattern of secretPatterns)
|
|
83
|
+
redacted = redacted.replace(pattern, "[secret]");
|
|
84
|
+
const characters = Array.from(redacted);
|
|
85
|
+
if (!windowCut && characters.length <= promptCharacterLimit)
|
|
86
|
+
return redacted;
|
|
87
|
+
return characters.slice(0, promptCharacterLimit).join("").replace(tokenRunAtCut, "[secret]");
|
|
88
|
+
}
|
|
89
|
+
export function defaultJevCatalog() {
|
|
90
|
+
return litOpenCodeRuntimeSkills.map((skill) => ({ id: skill.id, description: skill.summary }));
|
|
91
|
+
}
|
|
92
|
+
export function buildJevRequestBody(state, catalog, model) {
|
|
93
|
+
const criteria = {};
|
|
94
|
+
for (const entry of catalog)
|
|
95
|
+
criteria[entry.id] = Array.from(entry.description).slice(0, descriptionCharacterLimit).join("");
|
|
96
|
+
criteria.none = noneCriterion;
|
|
97
|
+
return JSON.stringify({
|
|
98
|
+
model,
|
|
99
|
+
state,
|
|
100
|
+
questions: { which: { type: "choice", instructions: choiceInstructions, criteria } }
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function boundedInteger(raw, fallback, min, max) {
|
|
104
|
+
if (raw === undefined || !/^\d+$/u.test(raw.trim()))
|
|
105
|
+
return fallback;
|
|
106
|
+
return Math.min(max, Math.max(min, Number(raw.trim())));
|
|
107
|
+
}
|
|
108
|
+
function minConfidence(raw) {
|
|
109
|
+
if (raw === undefined || raw.trim() === "")
|
|
110
|
+
return defaultMinConfidence;
|
|
111
|
+
const value = Number(raw);
|
|
112
|
+
return Number.isFinite(value) && value >= 0 && value <= 1 ? value : defaultMinConfidence;
|
|
113
|
+
}
|
|
114
|
+
async function readCappedBody(response) {
|
|
115
|
+
const declared = Number(response.headers.get("content-length") ?? "");
|
|
116
|
+
if (Number.isFinite(declared) && declared > bodyByteCap) {
|
|
117
|
+
await response.body?.cancel().catch(() => undefined);
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
if (response.body === null)
|
|
121
|
+
return "";
|
|
122
|
+
const reader = response.body.getReader();
|
|
123
|
+
const chunks = [];
|
|
124
|
+
let total = 0;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const { done, value } = await reader.read();
|
|
127
|
+
if (done)
|
|
128
|
+
break;
|
|
129
|
+
total += value.byteLength;
|
|
130
|
+
if (total > bodyByteCap) {
|
|
131
|
+
await reader.cancel().catch(() => undefined);
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
chunks.push(value);
|
|
135
|
+
}
|
|
136
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
137
|
+
}
|
|
138
|
+
function validatedAnswer(raw, allowed, threshold) {
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(raw);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return { failure: "invalid response" };
|
|
145
|
+
}
|
|
146
|
+
const which = parsed?.answers?.which;
|
|
147
|
+
if (typeof which !== "object" || which === null)
|
|
148
|
+
return { failure: "invalid response" };
|
|
149
|
+
const { choice, confidence } = which;
|
|
150
|
+
if (typeof choice !== "string" || !allowed.has(choice))
|
|
151
|
+
return { failure: "invalid response" };
|
|
152
|
+
if (typeof confidence !== "number" || !Number.isFinite(confidence))
|
|
153
|
+
return { failure: "invalid response" };
|
|
154
|
+
if (choice === "none" || confidence < threshold)
|
|
155
|
+
return { confidence };
|
|
156
|
+
return { choice, confidence };
|
|
157
|
+
}
|
|
158
|
+
export function createJevSkillHint(options = {}) {
|
|
159
|
+
const env = options.env ?? process.env;
|
|
160
|
+
const fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
|
|
161
|
+
const now = options.now ?? Date.now;
|
|
162
|
+
const sessions = new Map();
|
|
163
|
+
async function trace(record) {
|
|
164
|
+
if (env[`${jevFlag}_TRACE`] !== "1" || options.traceFile === undefined)
|
|
165
|
+
return;
|
|
166
|
+
try {
|
|
167
|
+
await fs.mkdir(path.dirname(options.traceFile), { recursive: true });
|
|
168
|
+
// Refuse a symlink or other non-file at the trace path; O_NOFOLLOW closes the lstat race.
|
|
169
|
+
const existing = await fs.lstat(options.traceFile).catch(() => undefined);
|
|
170
|
+
if (existing !== undefined && !existing.isFile())
|
|
171
|
+
return;
|
|
172
|
+
const flags = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | (fsConstants.O_NOFOLLOW ?? 0);
|
|
173
|
+
const handle = await fs.open(options.traceFile, flags, 0o600);
|
|
174
|
+
try {
|
|
175
|
+
await handle.appendFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await handle.close();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch { // no-excuse-ok: catch -- the opt-in debug trace must never affect the turn.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function ask(state, key) {
|
|
185
|
+
const catalog = options.catalog ?? defaultJevCatalog();
|
|
186
|
+
const model = (env[`${jevFlag}_MODEL`] ?? "").trim() || defaultModel;
|
|
187
|
+
const body = buildJevRequestBody(state, catalog, model);
|
|
188
|
+
if (Buffer.byteLength(body, "utf8") > bodyByteCap)
|
|
189
|
+
return { failure: "request too large" };
|
|
190
|
+
const timeoutMs = boundedInteger(env[`${jevFlag}_TIMEOUT_MS`], defaultTimeoutMs, 1, maxTimeoutMs);
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
let timer;
|
|
193
|
+
const timedOut = new Promise((resolve) => {
|
|
194
|
+
timer = setTimeout(() => {
|
|
195
|
+
controller.abort();
|
|
196
|
+
resolve({ failure: "timeout" });
|
|
197
|
+
}, timeoutMs);
|
|
198
|
+
});
|
|
199
|
+
const request = (async () => {
|
|
200
|
+
let response;
|
|
201
|
+
try {
|
|
202
|
+
response = await fetchImpl(jevEndpoint, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
205
|
+
body,
|
|
206
|
+
redirect: "error",
|
|
207
|
+
signal: controller.signal
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return { failure: controller.signal.aborted ? "timeout" : "network" };
|
|
212
|
+
}
|
|
213
|
+
if (response.status !== 200) {
|
|
214
|
+
await response.body?.cancel().catch(() => undefined);
|
|
215
|
+
return { httpStatus: response.status, failure: `HTTP ${response.status}` };
|
|
216
|
+
}
|
|
217
|
+
let raw;
|
|
218
|
+
try {
|
|
219
|
+
raw = await readCappedBody(response);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return { httpStatus: 200, failure: controller.signal.aborted ? "timeout" : "network" };
|
|
223
|
+
}
|
|
224
|
+
if (raw === undefined)
|
|
225
|
+
return { httpStatus: 200, failure: "response too large" };
|
|
226
|
+
const allowed = new Set([...catalog.map((entry) => entry.id), "none"]);
|
|
227
|
+
return { httpStatus: 200, ...validatedAnswer(raw, allowed, minConfidence(env[`${jevFlag}_MIN_CONFIDENCE`])) };
|
|
228
|
+
})();
|
|
229
|
+
try {
|
|
230
|
+
return await Promise.race([request, timedOut]);
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
forget(sessionID) {
|
|
238
|
+
sessions.delete(sessionID);
|
|
239
|
+
},
|
|
240
|
+
claimAwareness(sessionID, promptText) {
|
|
241
|
+
if (jevSkillHintStatus(env) !== "on" || !isJevEligiblePrompt(promptText))
|
|
242
|
+
return false;
|
|
243
|
+
const session = sessions.get(sessionID) ?? { calls: 0, noted: false, announced: false };
|
|
244
|
+
sessions.set(sessionID, session);
|
|
245
|
+
if (session.announced)
|
|
246
|
+
return false;
|
|
247
|
+
session.announced = true;
|
|
248
|
+
return true;
|
|
249
|
+
},
|
|
250
|
+
async hintFor(sessionID, promptText) {
|
|
251
|
+
if (jevSkillHintStatus(env) !== "on" || !isJevEligiblePrompt(promptText))
|
|
252
|
+
return undefined;
|
|
253
|
+
const key = (env.TYPESAFE_API_KEY ?? "").trim();
|
|
254
|
+
const session = sessions.get(sessionID) ?? { calls: 0, noted: false, announced: false };
|
|
255
|
+
sessions.set(sessionID, session);
|
|
256
|
+
const started = now();
|
|
257
|
+
// A key pasted into the prompt is masked even when it is too short for a token shape.
|
|
258
|
+
const state = redactJevPrompt(promptText.split(key).join("[secret]"));
|
|
259
|
+
let outcome;
|
|
260
|
+
if (session.calls >= boundedInteger(env[`${jevFlag}_MAX_CALLS`], defaultMaxCalls, 0, Number.MAX_SAFE_INTEGER)) {
|
|
261
|
+
outcome = { failure: "call cap reached" };
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
session.calls += 1;
|
|
265
|
+
try {
|
|
266
|
+
outcome = await ask(state, key);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
outcome = { failure: "network" };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const latencyMs = now() - started;
|
|
273
|
+
await trace({
|
|
274
|
+
time: new Date(started).toISOString(),
|
|
275
|
+
promptSha256: createHash("sha256").update(state).digest("hex"),
|
|
276
|
+
choice: outcome.choice ?? null,
|
|
277
|
+
confidence: outcome.confidence ?? null,
|
|
278
|
+
latencyMs,
|
|
279
|
+
httpStatus: outcome.httpStatus ?? null,
|
|
280
|
+
fallback: outcome.failure ?? null
|
|
281
|
+
});
|
|
282
|
+
if (outcome.choice !== undefined) {
|
|
283
|
+
return { kind: "hint", text: jevSkillHintText(outcome.choice), skillId: outcome.choice, latencyMs };
|
|
284
|
+
}
|
|
285
|
+
if (outcome.failure === undefined || session.noted)
|
|
286
|
+
return undefined;
|
|
287
|
+
session.noted = true;
|
|
288
|
+
return { kind: "note", text: jevSkillHintUnavailableText(outcome.failure) };
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { fileURLToPath } from "node:url";
|
|
2
|
-
import { createChatMessageActivationHook, showActivationToast } from "./activation.js";
|
|
3
|
+
import { createChatMessageActivationHook, showActivationToast, showJevSkillHintToast } from "./activation.js";
|
|
3
4
|
import { registerLitOpenCodeAgents } from "./agents.js";
|
|
4
5
|
import { createBoundedAuthorityEventHook } from "./bounded-authority-hooks.js";
|
|
5
6
|
import { createCommandActivationHook } from "./commands.js";
|
|
@@ -7,6 +8,7 @@ import { loadConfig } from "./config.js";
|
|
|
7
8
|
import { createDeliverableHedgeGuard } from "./deliverable-hedge-guard.js";
|
|
8
9
|
import { createToolExecuteAfterHook, createToolExecuteBeforeHook } from "./hooks.js";
|
|
9
10
|
import { createLogger } from "./logger.js";
|
|
11
|
+
import { createJevSkillHint } from "./jev-skill-hint.js";
|
|
10
12
|
import { createIgnitionState } from "./ignition.js";
|
|
11
13
|
import { createSessionLookup } from "./session-lineage.js";
|
|
12
14
|
import { applyCompactionRuleReset, applyStaticRuleInjection } from "./rules/hooks.js";
|
|
@@ -52,7 +54,10 @@ export function createLitOpenCodePlugin(autoUpdateRunner = runPluginAutoUpdate)
|
|
|
52
54
|
ignitionState.activate(sessionID, skillId, !appendedLit);
|
|
53
55
|
};
|
|
54
56
|
const boundedAuthorityEvent = createBoundedAuthorityEventHook(root, input?.client, loaded.config.boundedAuthority);
|
|
55
|
-
const
|
|
57
|
+
const skillHint = createJevSkillHint({ traceFile: path.join(loaded.paths.logsDir, "jev-skill-hint.jsonl") });
|
|
58
|
+
// Keyed by session: the host resolves command parts into a new array before chat.message runs.
|
|
59
|
+
const pendingCommand = new Set();
|
|
60
|
+
const trustedCommandActivations = new Map();
|
|
56
61
|
const commandActivation = createCommandActivationHook(root, {
|
|
57
62
|
commandAliasRoot: defaultOpenCodeConfigRoot(),
|
|
58
63
|
boundedAuthority: loaded.config.boundedAuthority,
|
|
@@ -66,6 +71,9 @@ export function createLitOpenCodePlugin(autoUpdateRunner = runPluginAutoUpdate)
|
|
|
66
71
|
event: async (eventInput) => {
|
|
67
72
|
if (eventInput.event.type === "session.deleted") {
|
|
68
73
|
ignitionState.clear(eventInput.event.properties.info.id);
|
|
74
|
+
skillHint.forget(eventInput.event.properties.info.id);
|
|
75
|
+
pendingCommand.delete(eventInput.event.properties.info.id);
|
|
76
|
+
trustedCommandActivations.delete(eventInput.event.properties.info.id);
|
|
69
77
|
}
|
|
70
78
|
await boundedAuthorityEvent(eventInput);
|
|
71
79
|
},
|
|
@@ -78,20 +86,28 @@ export function createLitOpenCodePlugin(autoUpdateRunner = runPluginAutoUpdate)
|
|
|
78
86
|
ignitionState.resetDiscipline(sessionID);
|
|
79
87
|
},
|
|
80
88
|
onSkillActivation: recordSkillActivation,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
isCommandTurn: (sessionID) => pendingCommand.delete(sessionID),
|
|
90
|
+
skillHint: async (sessionID, promptText) => {
|
|
91
|
+
const hint = await skillHint.hintFor(sessionID, promptText);
|
|
92
|
+
showJevSkillHintToast(input?.client, hint, skillHint.claimAwareness(sessionID, promptText));
|
|
93
|
+
return hint;
|
|
94
|
+
},
|
|
95
|
+
trustedInjectedSkill: (sessionID) => {
|
|
96
|
+
const skillId = trustedCommandActivations.get(sessionID);
|
|
97
|
+
trustedCommandActivations.delete(sessionID);
|
|
98
|
+
return skillId;
|
|
85
99
|
}
|
|
86
100
|
}),
|
|
87
101
|
"command.execute.before": async (commandInput, commandOutput) => {
|
|
102
|
+
pendingCommand.add(commandInput.sessionID);
|
|
88
103
|
const priorLength = commandOutput.parts.length;
|
|
89
104
|
await commandActivation(commandInput, commandOutput);
|
|
90
105
|
const activationPart = commandOutput.parts.slice(priorLength).reverse().find((part) => trustedCommandSkillId(part) !== undefined);
|
|
91
106
|
const skillId = trustedCommandSkillId(activationPart);
|
|
92
|
-
if (skillId
|
|
93
|
-
trustedCommandActivations.
|
|
94
|
-
|
|
107
|
+
if (skillId === undefined)
|
|
108
|
+
trustedCommandActivations.delete(commandInput.sessionID);
|
|
109
|
+
else
|
|
110
|
+
trustedCommandActivations.set(commandInput.sessionID, skillId);
|
|
95
111
|
},
|
|
96
112
|
"experimental.text.complete": ignitionState.complete,
|
|
97
113
|
"experimental.chat.system.transform": async (transformInput, transformOutput) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.10"><title>release: 1.0.10</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.10</text></g></svg>
|
package/docs/privacy.md
CHANGED
|
@@ -14,6 +14,7 @@ providers, npm, and other tools have their own behavior and policies.
|
|
|
14
14
|
| Project `.litopencode/litgoal/lit-loop/` | Goals, criteria, checkpoints, session identifiers, evidence references, a brief, ledger events, and bounded lifecycle state used to resume work. Evidence files can contain commands, output, or project material. |
|
|
15
15
|
| Project `.litopencode/knowledge/` | Locally captured knowledge claims and review state. Legacy skill-learning files from earlier releases are left untouched and are no longer read. |
|
|
16
16
|
| Project `.litopencode/logs/litopencode.log` | The logger's JSON-lines destination for timestamps, levels, and messages when explicitly enabled. The current server creates the logger with logging disabled by default. |
|
|
17
|
+
| Project `.litopencode/logs/jev-skill-hint.jsonl` | Written only when `LITOPENCODE_JEV_TRACE=1`: one record per Jev skill hint request with time, prompt SHA-256, chosen skill id, confidence, latency, HTTP status and fallback reason. No prompt text, key or response body. |
|
|
17
18
|
| OpenCode config root | Plugin registration, `litopencode.json` routes and choices, and installed command/skill files. The default is `~/.config/opencode`; `XDG_CONFIG_HOME` or a CLI `--root` changes the applicable root. |
|
|
18
19
|
| Home `~/.litopencode/` | Update cache, locks, transaction journal and receipt, and retained recovery backups after an unsuccessful automatic update. Backups can contain OpenCode configuration and installed command/skill content. |
|
|
19
20
|
|
|
@@ -50,6 +51,17 @@ path and query string and normal network metadata. Never put secrets in a URL:
|
|
|
50
51
|
redacting a returned result cannot retract a request already sent to its server.
|
|
51
52
|
Retrieved text and source addresses can become part of tool output and host context.
|
|
52
53
|
|
|
54
|
+
**Jev skill hint (off by default).** When `LITOPENCODE_JEV=1` and `TYPESAFE_API_KEY`
|
|
55
|
+
are both set, the `chat.message` hook sends each eligible user prompt to TypeSafe at
|
|
56
|
+
`api.typesafe.ai`, together with the LitOpenCode skill ids and summaries. The prompt is cut
|
|
57
|
+
to 2,000 characters and home paths, e-mail addresses and token-shaped strings are replaced
|
|
58
|
+
first. Anything in the prompt without a token shape is sent as written, for example hostnames,
|
|
59
|
+
customer names, or a password that is not written as `password=...`. Files, tool output and
|
|
60
|
+
earlier turns are not sent. Because `TYPESAFE_API_KEY` is exported in the shell that starts
|
|
61
|
+
OpenCode, the agent's own tools can read it; use a key made only for this feature, with a low
|
|
62
|
+
spending limit. TypeSafe's own retention and billing terms apply. Unset `LITOPENCODE_JEV` to
|
|
63
|
+
stop these requests.
|
|
64
|
+
|
|
53
65
|
**OpenCode and providers.** Agent prompts, user requests, selected files, and tool
|
|
54
66
|
results are processed through the OpenCode host and configured provider. LitOpenCode
|
|
55
67
|
does not establish their retention or training policy. Review the provider and
|
package/docs/reference-Ko-KR.md
CHANGED
|
@@ -253,6 +253,45 @@ npm uninstall -g @litfamily/litopencode
|
|
|
253
253
|
- Public-source retrieval에는 SSRF 검사, redirect 검증, byte 제한, access verdict가
|
|
254
254
|
있습니다. 가져온 본문은 명령이 아니라 데이터로 처리합니다.
|
|
255
255
|
|
|
256
|
+
## Jev 스킬 힌트 (선택)
|
|
257
|
+
|
|
258
|
+
`chat.message` hook이 TypeSafe의 판단 모델 Jev에게 사용자 턴에 맞는 runtime 스킬을 물을 수
|
|
259
|
+
있습니다. `LITOPENCODE_JEV=1`과 `TYPESAFE_API_KEY`가 모두 프로세스 환경에 있어야 켜지고, 그렇지
|
|
260
|
+
않으면 네트워크 요청을 하지 않습니다. 키는 환경 변수에서만 읽고 `Authorization` header로만
|
|
261
|
+
보내며, 파일·로그·화면에 남기지 않습니다.
|
|
262
|
+
|
|
263
|
+
- **대상 턴.** 루트 세션만 해당합니다. 슬래시 명령(`command.execute.before`가 펼친 명령 포함),
|
|
264
|
+
lit 경로가 이미 처리한 턴, 첨부 파일 본문 같은 synthetic part, 공백을 뺀 4자 미만의
|
|
265
|
+
프롬프트는 건너뜁니다. OpenCode는 `command.execute.before`가 받은 parts 배열이 아니라 새 배열을
|
|
266
|
+
`chat.message`에 넘기므로, 명령은 세션에 표시를 남기고 그 세션의 다음 `chat.message`가 이
|
|
267
|
+
표시를 소비합니다.
|
|
268
|
+
- **요청.** 조건에 맞는 턴마다 `POST https://api.typesafe.ai/v1/systemone`을 한 번 보내고
|
|
269
|
+
다시 시도하지 않으며 리다이렉트도 따르지 않습니다(`redirect: "error"`). 본문에는 `model`,
|
|
270
|
+
`state`, `questions`만 들어갑니다. `state`는 프롬프트 앞 8,000자에서 홈 경로를 `~`로, 이메일을
|
|
271
|
+
`[email]`로, 토큰 형태의 문자열(`password=`/`token=`/`secret=` 형식의 대입과 키 자체 포함)을
|
|
272
|
+
`[secret]`으로 바꾼 뒤 2,000자에서 자릅니다. 잘린 자리에 토큰 문자 8개 이상이 남으면 그것도
|
|
273
|
+
`[secret]`이 됩니다. 호스트 이름이나 고객 이름처럼 토큰 형태가 아닌 내용은 그대로 전송됩니다.
|
|
274
|
+
후보 목록은 runtime 스킬과 `none`입니다.
|
|
275
|
+
- **붙는 문장.** 힌트와 실패 안내는 `synthetic` text part로 붙습니다. OpenCode는 이 part를
|
|
276
|
+
모델에 그대로 보내지만, 사용자가 쓴 글로 표시하거나 복사하지 않습니다.
|
|
277
|
+
- **응답.** HTTP 200, 올바른 JSON, 후보 id와 정확히 같은 `answers.which.choice`, 기준 이상의
|
|
278
|
+
`answers.which.confidence`를 모두 만족할 때만 힌트를 붙입니다. 붙는 문장은 LitOpenCode가 정한
|
|
279
|
+
고정 문장이며 응답 본문을 옮기지 않습니다.
|
|
280
|
+
- **실패 시.** 시간 초과, 네트워크 오류, 200이 아닌 상태, 잘못된 응답, 호출 한도 도달 때는 힌트
|
|
281
|
+
없이 평소대로 진행합니다. 세션의 첫 실패에만 짧은 안내 한 줄이 붙습니다.
|
|
282
|
+
- **상태.** `litopencode doctor`의 `jevSkillHint` 값이 `Jev skill hint: off`, `on`,
|
|
283
|
+
`flag on but TYPESAFE_API_KEY missing` 가운데 하나로 나옵니다.
|
|
284
|
+
|
|
285
|
+
| 변수 | 기본값 | 효과 |
|
|
286
|
+
| --- | --- | --- |
|
|
287
|
+
| `LITOPENCODE_JEV` | 없음 | `1`이면 키가 있을 때 힌트를 켭니다. |
|
|
288
|
+
| `TYPESAFE_API_KEY` | 없음 | 본인의 TypeSafe 키입니다. 요금은 입력 토큰 100만 개당 약 0.04달러입니다. |
|
|
289
|
+
| `LITOPENCODE_JEV_MODEL` | `jev-1.13.0` | 요청에 담는 모델 이름입니다. |
|
|
290
|
+
| `LITOPENCODE_JEV_TIMEOUT_MS` | `1500` | 제한 시간(ms)이며 최대 `3000`입니다. |
|
|
291
|
+
| `LITOPENCODE_JEV_MAX_CALLS` | `200` | 세션당 요청 수 한도입니다. |
|
|
292
|
+
| `LITOPENCODE_JEV_MIN_CONFIDENCE` | `0.35` | 힌트를 붙이는 최소 확신도입니다. |
|
|
293
|
+
| `LITOPENCODE_JEV_TRACE` | 없음 | `1`이면 요청마다 `.litopencode/logs/jev-skill-hint.jsonl`에 시각, 프롬프트 SHA-256, 고른 id, 확신도, 지연 시간, HTTP 상태, 실패 사유를 한 줄씩 남깁니다. 프롬프트 본문, 키, 응답 본문은 남기지 않습니다. |
|
|
294
|
+
|
|
256
295
|
## 패키지 경로
|
|
257
296
|
|
|
258
297
|
- 패키지와 plugin id: `litopencode`
|
package/docs/reference.md
CHANGED
|
@@ -364,6 +364,49 @@ when discarding that project's ledger and receipts is intentional.
|
|
|
364
364
|
Metadata-only overflow emits a bounded digest/omission rule fragment.
|
|
365
365
|
- session-scoped rule delivery deduplicates matching content and stays bounded after compaction.
|
|
366
366
|
|
|
367
|
+
## Jev skill hint (optional)
|
|
368
|
+
|
|
369
|
+
The `chat.message` hook can ask Jev, TypeSafe's hosted typed-decision model, which runtime skill
|
|
370
|
+
fits a user turn. It is off unless `LITOPENCODE_JEV=1` and `TYPESAFE_API_KEY` are both set in the
|
|
371
|
+
process environment; otherwise it makes no network call. The key is read from the environment
|
|
372
|
+
only. It is sent only in the `Authorization` header and is never written, logged or shown.
|
|
373
|
+
|
|
374
|
+
- **Eligible turns.** Root sessions only. Slash commands (including every command expanded by
|
|
375
|
+
`command.execute.before`), turns a deterministic lit route already claimed, synthetic parts
|
|
376
|
+
such as attached file text, and prompts under four non-space characters are skipped. OpenCode
|
|
377
|
+
hands `chat.message` a new parts array rather than the one `command.execute.before` saw, so a
|
|
378
|
+
command marks its session instead, and that session's next `chat.message` consumes the mark.
|
|
379
|
+
- **Request.** One `POST https://api.typesafe.ai/v1/systemone` per eligible turn, no retry and no
|
|
380
|
+
redirects (`redirect: "error"`). The body holds only `model`, `state` and `questions`. `state`
|
|
381
|
+
is built from the first 8,000 characters of the user's prompt text: home paths become `~`,
|
|
382
|
+
e-mail addresses `[email]`, and token-shaped strings (provider key prefixes, JWTs, PEM blocks,
|
|
383
|
+
`password=`/`token=`/`secret=`-style assignments, runs of 32 or more hex or base64
|
|
384
|
+
characters, and the key itself) `[secret]`. Only then is it cut to 2,000 characters, and a
|
|
385
|
+
run of eight or more token characters left at the cut also becomes `[secret]`. Anything
|
|
386
|
+
without a token shape, such as a hostname or a customer name, is sent as written. The catalog
|
|
387
|
+
is the Runtime Skills list above, each with its summary cut to 300 characters, plus `none`.
|
|
388
|
+
- **Added text.** The hint or the fallback note is a `synthetic` text part: OpenCode still sends
|
|
389
|
+
it to the model, but does not show or copy it as the user's own words.
|
|
390
|
+
- **Answer.** A hint is added only for HTTP 200, parseable JSON, an `answers.which.choice` that is
|
|
391
|
+
exactly a catalog id, and an `answers.which.confidence` at or above the threshold. The added text is
|
|
392
|
+
LitOpenCode's own fixed sentence with that id; no response text is copied into the turn.
|
|
393
|
+
- **Fallback.** On a timeout, network error, non-200 status, invalid answer or reached cap, the
|
|
394
|
+
turn continues as it would without the hint. The first failure in a session adds one short
|
|
395
|
+
`LitOpenCode skill hint unavailable (<reason>); continuing normally.` line; later failures are
|
|
396
|
+
silent.
|
|
397
|
+
- **Status.** `litopencode doctor` reports `jevSkillHint` as `Jev skill hint: off`, `on`, or
|
|
398
|
+
`flag on but TYPESAFE_API_KEY missing`.
|
|
399
|
+
|
|
400
|
+
| Variable | Default | Effect |
|
|
401
|
+
| --- | --- | --- |
|
|
402
|
+
| `LITOPENCODE_JEV` | unset | `1` turns the hint on when the key is also set. |
|
|
403
|
+
| `TYPESAFE_API_KEY` | unset | Your own TypeSafe key. TypeSafe bills about $0.04 per million input tokens. |
|
|
404
|
+
| `LITOPENCODE_JEV_MODEL` | `jev-1.13.0` | Model name sent with each request. |
|
|
405
|
+
| `LITOPENCODE_JEV_TIMEOUT_MS` | `1500` | Hard timeout in milliseconds, capped at `3000`. |
|
|
406
|
+
| `LITOPENCODE_JEV_MAX_CALLS` | `200` | Requests allowed per session. |
|
|
407
|
+
| `LITOPENCODE_JEV_MIN_CONFIDENCE` | `0.35` | Lowest confidence that produces a hint. |
|
|
408
|
+
| `LITOPENCODE_JEV_TRACE` | unset | `1` appends one record per request to `.litopencode/logs/jev-skill-hint.jsonl`: time, prompt SHA-256, chosen id, confidence, latency, HTTP status and fallback reason. It holds no prompt text, key or response body. |
|
|
409
|
+
|
|
367
410
|
## Package surface
|
|
368
411
|
|
|
369
412
|
- Package and plugin id: `litopencode`
|