@litfamily/litopencode 1.0.9 → 1.0.11
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 +15 -0
- package/README-Ko-KR.md +39 -490
- package/README.md +37 -485
- package/dist/activation.d.ts +14 -6
- package/dist/activation.js +56 -2
- package/dist/cli/doctor.js +2 -0
- package/dist/jev-skill-hint.d.ts +50 -0
- package/dist/jev-skill-hint.js +318 -0
- package/dist/knowledge.js +3 -0
- package/dist/server.js +31 -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 +4 -2
- package/skills/lit-typographic-motion/package-lock.json +4 -4
- package/skills/lit-typographic-motion/package.json +1 -1
- package/skills/managed-skill-manifest.json +2 -2
- package/tools/check-pack-payload.mjs +21 -3
- package/tools/version-manifests.json +18 -6
package/dist/activation.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
|
2
2
|
import type { SessionLookup } from "./session-lineage.ts";
|
|
3
3
|
import type { BoundedAuthorityOptions } from "./bounded-authority.ts";
|
|
4
|
+
import { type JevSkillHintPart } from "./jev-skill-hint.ts";
|
|
4
5
|
export { litHandoffPromptInjection, scientificVisualizationPromptInjection } from "./activation-managed-prompts.ts";
|
|
5
6
|
export { litActivationBanner } from "./activation-prompt-utils.ts";
|
|
6
7
|
export { litLoopPromptInjection, litPlanPromptInjection } from "./activation-primary-prompts.ts";
|
|
@@ -9,6 +10,12 @@ export { comprehendPromptInjection, debuggingPromptInjection, deepInterviewPromp
|
|
|
9
10
|
type ChatMessageHook = NonNullable<Hooks["chat.message"]>;
|
|
10
11
|
type ActivationToastClient = Pick<PluginInput["client"], "tui">;
|
|
11
12
|
export declare function showActivationToast(client: ActivationToastClient | undefined, skillId: string, env?: NodeJS.ProcessEnv): void;
|
|
13
|
+
/**
|
|
14
|
+
* One quiet `info` toast per hinted turn; notes, `none` and silent turns never reach it. On the first
|
|
15
|
+
* eligible turn of a session (`announce`) the standout ON notice goes out instead, with that turn's hint
|
|
16
|
+
* as its body: the TUI keeps a single toast, so a second one would replace the notice at once.
|
|
17
|
+
*/
|
|
18
|
+
export declare function showJevSkillHintToast(client: ActivationToastClient | undefined, hint: JevSkillHintPart | undefined, announce?: boolean): void;
|
|
12
19
|
export type ChatMessageActivationOptions = {
|
|
13
20
|
readonly getSession?: SessionLookup;
|
|
14
21
|
readonly hostArgv?: readonly string[];
|
|
@@ -17,11 +24,12 @@ export type ChatMessageActivationOptions = {
|
|
|
17
24
|
readonly onScientificVisualizationActivation?: (sessionID: string) => void;
|
|
18
25
|
readonly onRootUserTurn?: (sessionID: string) => void | Promise<void>;
|
|
19
26
|
readonly onSkillActivation?: (sessionID: string, skillId: string, appendedLit?: boolean) => void | Promise<void>;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
/** Consumes the skill a trusted command activation recorded for this session, if any. */
|
|
28
|
+
readonly trustedInjectedSkill?: (sessionID: string) => string | undefined;
|
|
29
|
+
/** Restores that skill after the root-turn reset; the command already showed its activation toast. */
|
|
30
|
+
readonly onTrustedSkillResume?: (sessionID: string, skillId: string) => void | Promise<void>;
|
|
31
|
+
/** Consumes the mark `command.execute.before` left for this session's next message. */
|
|
32
|
+
readonly isCommandTurn?: (sessionID: string) => boolean;
|
|
33
|
+
readonly skillHint?: (sessionID: string, promptText: string) => Promise<JevSkillHintPart | undefined>;
|
|
26
34
|
};
|
|
27
35
|
export declare function createChatMessageActivationHook(projectRoot: string, options?: ChatMessageActivationOptions): ChatMessageHook;
|
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,13 +91,16 @@ 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
|
-
await options.
|
|
103
|
+
await options.onTrustedSkillResume?.(input.sessionID, trustedInjectedSkill);
|
|
74
104
|
return;
|
|
75
105
|
}
|
|
76
106
|
}
|
|
@@ -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,50 @@
|
|
|
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
|
+
/** Project root: every folder between it and the trace file must be a real folder, never a symlink. */
|
|
26
|
+
readonly traceRoot?: string;
|
|
27
|
+
readonly now?: () => number;
|
|
28
|
+
};
|
|
29
|
+
export declare function jevSkillHintStatus(env?: NodeJS.ProcessEnv): JevSkillHintStatus;
|
|
30
|
+
export declare function jevSkillHintDoctorLine(env?: NodeJS.ProcessEnv): string;
|
|
31
|
+
export declare function jevSkillHintText(skillId: string): string;
|
|
32
|
+
/** The visible toast names only the validated skill id and the request latency. */
|
|
33
|
+
export declare function jevSkillHintToastText(skillId: string, latencyMs: number): string;
|
|
34
|
+
export declare function jevSkillHintUnavailableText(reason: string): string;
|
|
35
|
+
/** Eligibility that depends on the prompt text alone; routing and command checks live in the hook. */
|
|
36
|
+
export declare function isJevEligiblePrompt(text: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Redact a bounded window first, then truncate, so a secret that straddles the 2,000-character cut is
|
|
39
|
+
* masked whole. When either cut happened, a token run left dangling at the end is masked as well.
|
|
40
|
+
*/
|
|
41
|
+
export declare function redactJevPrompt(text: string): string;
|
|
42
|
+
export declare function defaultJevCatalog(): readonly JevCatalogEntry[];
|
|
43
|
+
export declare function buildJevRequestBody(state: string, catalog: readonly JevCatalogEntry[], model: string): string;
|
|
44
|
+
export type JevSkillHint = {
|
|
45
|
+
hintFor(sessionID: string, promptText: string): Promise<JevSkillHintPart | undefined>;
|
|
46
|
+
/** True exactly once per session: on its first eligible turn while both switches are on. */
|
|
47
|
+
claimAwareness(sessionID: string, promptText: string): boolean;
|
|
48
|
+
forget(sessionID: string): void;
|
|
49
|
+
};
|
|
50
|
+
export declare function createJevSkillHint(options?: JevSkillHintOptions): JevSkillHint;
|
|
@@ -0,0 +1,318 @@
|
|
|
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
|
+
/**
|
|
159
|
+
* Walks the trace folder below the project root one level at a time, creating a missing folder
|
|
160
|
+
* without following links. Any symlink or non-folder on the way means no trace.
|
|
161
|
+
*/
|
|
162
|
+
async function realTraceFolder(root, folder) {
|
|
163
|
+
const relative = path.relative(root, folder);
|
|
164
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
165
|
+
return false;
|
|
166
|
+
let current = root;
|
|
167
|
+
for (const segment of relative.split(path.sep).filter((part) => part !== "")) {
|
|
168
|
+
current = path.join(current, segment);
|
|
169
|
+
let stat = await fs.lstat(current).catch(() => undefined);
|
|
170
|
+
if (stat === undefined) {
|
|
171
|
+
await fs.mkdir(current).catch(() => undefined);
|
|
172
|
+
stat = await fs.lstat(current).catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
if (stat === undefined || stat.isSymbolicLink() || !stat.isDirectory())
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
export function createJevSkillHint(options = {}) {
|
|
180
|
+
const env = options.env ?? process.env;
|
|
181
|
+
const fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
|
|
182
|
+
const now = options.now ?? Date.now;
|
|
183
|
+
const sessions = new Map();
|
|
184
|
+
async function trace(record) {
|
|
185
|
+
if (env[`${jevFlag}_TRACE`] !== "1" || options.traceFile === undefined)
|
|
186
|
+
return;
|
|
187
|
+
const folder = path.dirname(options.traceFile);
|
|
188
|
+
const root = options.traceRoot ?? path.dirname(folder);
|
|
189
|
+
try {
|
|
190
|
+
if (!(await realTraceFolder(root, folder)))
|
|
191
|
+
return;
|
|
192
|
+
// Refuse a symlink or other non-file at the trace path; O_NOFOLLOW closes the lstat race.
|
|
193
|
+
const existing = await fs.lstat(options.traceFile).catch(() => undefined);
|
|
194
|
+
if (existing !== undefined && !existing.isFile())
|
|
195
|
+
return;
|
|
196
|
+
const flags = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | (fsConstants.O_NOFOLLOW ?? 0);
|
|
197
|
+
const handle = await fs.open(options.traceFile, flags, 0o600);
|
|
198
|
+
try {
|
|
199
|
+
// A folder swapped for a symlink after the first check still gets no record.
|
|
200
|
+
if (!(await realTraceFolder(root, folder)))
|
|
201
|
+
return;
|
|
202
|
+
await handle.appendFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
await handle.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch { // no-excuse-ok: catch -- the opt-in debug trace must never affect the turn.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function ask(state, key) {
|
|
212
|
+
const catalog = options.catalog ?? defaultJevCatalog();
|
|
213
|
+
const model = (env[`${jevFlag}_MODEL`] ?? "").trim() || defaultModel;
|
|
214
|
+
const body = buildJevRequestBody(state, catalog, model);
|
|
215
|
+
if (Buffer.byteLength(body, "utf8") > bodyByteCap)
|
|
216
|
+
return { failure: "request too large" };
|
|
217
|
+
const timeoutMs = boundedInteger(env[`${jevFlag}_TIMEOUT_MS`], defaultTimeoutMs, 1, maxTimeoutMs);
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
let timer;
|
|
220
|
+
const timedOut = new Promise((resolve) => {
|
|
221
|
+
timer = setTimeout(() => {
|
|
222
|
+
controller.abort();
|
|
223
|
+
resolve({ failure: "timeout" });
|
|
224
|
+
}, timeoutMs);
|
|
225
|
+
});
|
|
226
|
+
const request = (async () => {
|
|
227
|
+
let response;
|
|
228
|
+
try {
|
|
229
|
+
response = await fetchImpl(jevEndpoint, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
232
|
+
body,
|
|
233
|
+
redirect: "error",
|
|
234
|
+
signal: controller.signal
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return { failure: controller.signal.aborted ? "timeout" : "network" };
|
|
239
|
+
}
|
|
240
|
+
if (response.status !== 200) {
|
|
241
|
+
await response.body?.cancel().catch(() => undefined);
|
|
242
|
+
return { httpStatus: response.status, failure: `HTTP ${response.status}` };
|
|
243
|
+
}
|
|
244
|
+
let raw;
|
|
245
|
+
try {
|
|
246
|
+
raw = await readCappedBody(response);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return { httpStatus: 200, failure: controller.signal.aborted ? "timeout" : "network" };
|
|
250
|
+
}
|
|
251
|
+
if (raw === undefined)
|
|
252
|
+
return { httpStatus: 200, failure: "response too large" };
|
|
253
|
+
const allowed = new Set([...catalog.map((entry) => entry.id), "none"]);
|
|
254
|
+
return { httpStatus: 200, ...validatedAnswer(raw, allowed, minConfidence(env[`${jevFlag}_MIN_CONFIDENCE`])) };
|
|
255
|
+
})();
|
|
256
|
+
try {
|
|
257
|
+
return await Promise.race([request, timedOut]);
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
forget(sessionID) {
|
|
265
|
+
sessions.delete(sessionID);
|
|
266
|
+
},
|
|
267
|
+
claimAwareness(sessionID, promptText) {
|
|
268
|
+
if (jevSkillHintStatus(env) !== "on" || !isJevEligiblePrompt(promptText))
|
|
269
|
+
return false;
|
|
270
|
+
const session = sessions.get(sessionID) ?? { calls: 0, noted: false, announced: false };
|
|
271
|
+
sessions.set(sessionID, session);
|
|
272
|
+
if (session.announced)
|
|
273
|
+
return false;
|
|
274
|
+
session.announced = true;
|
|
275
|
+
return true;
|
|
276
|
+
},
|
|
277
|
+
async hintFor(sessionID, promptText) {
|
|
278
|
+
if (jevSkillHintStatus(env) !== "on" || !isJevEligiblePrompt(promptText))
|
|
279
|
+
return undefined;
|
|
280
|
+
const key = (env.TYPESAFE_API_KEY ?? "").trim();
|
|
281
|
+
const session = sessions.get(sessionID) ?? { calls: 0, noted: false, announced: false };
|
|
282
|
+
sessions.set(sessionID, session);
|
|
283
|
+
const started = now();
|
|
284
|
+
// A key pasted into the prompt is masked even when it is too short for a token shape.
|
|
285
|
+
const state = redactJevPrompt(promptText.split(key).join("[secret]"));
|
|
286
|
+
let outcome;
|
|
287
|
+
if (session.calls >= boundedInteger(env[`${jevFlag}_MAX_CALLS`], defaultMaxCalls, 0, Number.MAX_SAFE_INTEGER)) {
|
|
288
|
+
outcome = { failure: "call cap reached" };
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
session.calls += 1;
|
|
292
|
+
try {
|
|
293
|
+
outcome = await ask(state, key);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
outcome = { failure: "network" };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const latencyMs = now() - started;
|
|
300
|
+
await trace({
|
|
301
|
+
time: new Date(started).toISOString(),
|
|
302
|
+
promptSha256: createHash("sha256").update(state).digest("hex"),
|
|
303
|
+
choice: outcome.choice ?? null,
|
|
304
|
+
confidence: outcome.confidence ?? null,
|
|
305
|
+
latencyMs,
|
|
306
|
+
httpStatus: outcome.httpStatus ?? null,
|
|
307
|
+
fallback: outcome.failure ?? null
|
|
308
|
+
});
|
|
309
|
+
if (outcome.choice !== undefined) {
|
|
310
|
+
return { kind: "hint", text: jevSkillHintText(outcome.choice), skillId: outcome.choice, latencyMs };
|
|
311
|
+
}
|
|
312
|
+
if (outcome.failure === undefined || session.noted)
|
|
313
|
+
return undefined;
|
|
314
|
+
session.noted = true;
|
|
315
|
+
return { kind: "note", text: jevSkillHintUnavailableText(outcome.failure) };
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
package/dist/knowledge.js
CHANGED
|
@@ -340,6 +340,9 @@ async function readRegularFileIfPresent(filePath, paths, options = {}) {
|
|
|
340
340
|
try {
|
|
341
341
|
await assertParentStable(parent, paths);
|
|
342
342
|
const descriptorStat = await handle.stat();
|
|
343
|
+
if (descriptorStat.isFile() && descriptorStat.nlink === 0) {
|
|
344
|
+
throw new KnowledgeFileDisappearedError(`Knowledge file disappeared after descriptor open at ${filePath}.`);
|
|
345
|
+
}
|
|
343
346
|
if (!descriptorStat.isFile() || !sameFileIdentity(fileIdentityOf(descriptorStat), fileIdentityOf(pathStat))) {
|
|
344
347
|
throw new KnowledgeStoreError(`Unsafe knowledge file replacement at ${filePath}.`);
|
|
345
348
|
}
|
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,13 @@ 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({
|
|
58
|
+
traceFile: path.join(loaded.paths.logsDir, "jev-skill-hint.jsonl"),
|
|
59
|
+
traceRoot: loaded.paths.projectRoot
|
|
60
|
+
});
|
|
61
|
+
// Keyed by session: the host resolves command parts into a new array before chat.message runs.
|
|
62
|
+
const pendingCommand = new Set();
|
|
63
|
+
const trustedCommandActivations = new Map();
|
|
56
64
|
const commandActivation = createCommandActivationHook(root, {
|
|
57
65
|
commandAliasRoot: defaultOpenCodeConfigRoot(),
|
|
58
66
|
boundedAuthority: loaded.config.boundedAuthority,
|
|
@@ -66,6 +74,9 @@ export function createLitOpenCodePlugin(autoUpdateRunner = runPluginAutoUpdate)
|
|
|
66
74
|
event: async (eventInput) => {
|
|
67
75
|
if (eventInput.event.type === "session.deleted") {
|
|
68
76
|
ignitionState.clear(eventInput.event.properties.info.id);
|
|
77
|
+
skillHint.forget(eventInput.event.properties.info.id);
|
|
78
|
+
pendingCommand.delete(eventInput.event.properties.info.id);
|
|
79
|
+
trustedCommandActivations.delete(eventInput.event.properties.info.id);
|
|
69
80
|
}
|
|
70
81
|
await boundedAuthorityEvent(eventInput);
|
|
71
82
|
},
|
|
@@ -78,20 +89,31 @@ export function createLitOpenCodePlugin(autoUpdateRunner = runPluginAutoUpdate)
|
|
|
78
89
|
ignitionState.resetDiscipline(sessionID);
|
|
79
90
|
},
|
|
80
91
|
onSkillActivation: recordSkillActivation,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
isCommandTurn: (sessionID) => pendingCommand.delete(sessionID),
|
|
93
|
+
skillHint: async (sessionID, promptText) => {
|
|
94
|
+
const hint = await skillHint.hintFor(sessionID, promptText);
|
|
95
|
+
showJevSkillHintToast(input?.client, hint, skillHint.claimAwareness(sessionID, promptText));
|
|
96
|
+
return hint;
|
|
97
|
+
},
|
|
98
|
+
trustedInjectedSkill: (sessionID) => {
|
|
99
|
+
const skillId = trustedCommandActivations.get(sessionID);
|
|
100
|
+
trustedCommandActivations.delete(sessionID);
|
|
101
|
+
return skillId;
|
|
102
|
+
},
|
|
103
|
+
onTrustedSkillResume: (sessionID, skillId) => {
|
|
104
|
+
ignitionState.activate(sessionID, skillId);
|
|
85
105
|
}
|
|
86
106
|
}),
|
|
87
107
|
"command.execute.before": async (commandInput, commandOutput) => {
|
|
108
|
+
pendingCommand.add(commandInput.sessionID);
|
|
88
109
|
const priorLength = commandOutput.parts.length;
|
|
89
110
|
await commandActivation(commandInput, commandOutput);
|
|
90
111
|
const activationPart = commandOutput.parts.slice(priorLength).reverse().find((part) => trustedCommandSkillId(part) !== undefined);
|
|
91
112
|
const skillId = trustedCommandSkillId(activationPart);
|
|
92
|
-
if (skillId
|
|
93
|
-
trustedCommandActivations.
|
|
94
|
-
|
|
113
|
+
if (skillId === undefined)
|
|
114
|
+
trustedCommandActivations.delete(commandInput.sessionID);
|
|
115
|
+
else
|
|
116
|
+
trustedCommandActivations.set(commandInput.sessionID, skillId);
|
|
95
117
|
},
|
|
96
118
|
"experimental.text.complete": ignitionState.complete,
|
|
97
119
|
"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.11"><title>release: 1.0.11</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.11</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
|