@a13xu/lucid 1.16.2 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/database.d.ts +51 -0
- package/build/database.js +86 -0
- package/build/guardian/session-tracker.d.ts +34 -0
- package/build/guardian/session-tracker.js +105 -0
- package/build/guardian/truncate-guard.d.ts +54 -0
- package/build/guardian/truncate-guard.js +136 -0
- package/build/index.js +254 -0
- package/build/local-llm/client.d.ts +20 -0
- package/build/local-llm/client.js +140 -0
- package/build/local-llm/config.d.ts +11 -0
- package/build/local-llm/config.js +50 -0
- package/build/local-llm/runtimes.d.ts +16 -0
- package/build/local-llm/runtimes.js +82 -0
- package/build/local-llm/setup-cli.d.ts +5 -0
- package/build/local-llm/setup-cli.js +298 -0
- package/build/local-llm/types.d.ts +34 -0
- package/build/local-llm/types.js +5 -0
- package/build/tools/backup.d.ts +47 -0
- package/build/tools/backup.js +107 -0
- package/build/tools/delegate-local.d.ts +23 -0
- package/build/tools/delegate-local.js +75 -0
- package/build/tools/init.js +124 -2
- package/build/tools/session.d.ts +13 -0
- package/build/tools/session.js +59 -0
- package/package.json +1 -1
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive `lucid local <subcmd>` CLI.
|
|
3
|
+
* Subcommands: init | status | test | disable | pull
|
|
4
|
+
*/
|
|
5
|
+
import { createInterface } from "readline";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import { loadLocalConfig, saveLocalConfig, disableLocalConfig, getConfigPath, } from "./config.js";
|
|
8
|
+
import { autoDetectLocal, probeEndpoint, describeRuntime } from "./runtimes.js";
|
|
9
|
+
import { generate, ping } from "./client.js";
|
|
10
|
+
const RECOMMENDED_MODELS = [
|
|
11
|
+
{ name: "qwen2.5-coder:1.5b", size: "~1 GB", note: "fast on CPU (~30 tok/s) — recommended for brief synthesis" },
|
|
12
|
+
{ name: "qwen2.5-coder:3b", size: "~3 GB", note: "balanced (~15 tok/s on CPU)" },
|
|
13
|
+
{ name: "qwen2.5-coder:7b", size: "~7 GB", note: "best quality (~7 tok/s on CPU; 60+ on GPU)" },
|
|
14
|
+
];
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Entrypoint
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
export async function runLocalLlmCli(args) {
|
|
19
|
+
const sub = args[0];
|
|
20
|
+
if (sub === "init")
|
|
21
|
+
return await cmdInit(args.slice(1));
|
|
22
|
+
if (sub === "status")
|
|
23
|
+
return await cmdStatus();
|
|
24
|
+
if (sub === "test")
|
|
25
|
+
return await cmdTest();
|
|
26
|
+
if (sub === "disable")
|
|
27
|
+
return cmdDisable();
|
|
28
|
+
if (sub === "pull")
|
|
29
|
+
return await cmdPull(args.slice(1));
|
|
30
|
+
process.stderr.write(`Usage: lucid local <init|status|test|disable|pull <model>>\n`);
|
|
31
|
+
return 64;
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// init — guided 5-step setup
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
async function cmdInit(_args) {
|
|
37
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
38
|
+
try {
|
|
39
|
+
process.stdout.write("\n🤖 Lucid Local LLM — interactive setup\n");
|
|
40
|
+
process.stdout.write(` Config will be saved to ${getConfigPath()}\n\n`);
|
|
41
|
+
// ── Step 1: detect or accept remote endpoint ──────────────────────────
|
|
42
|
+
process.stdout.write("Step 1/5 Detecting local runtimes…\n");
|
|
43
|
+
const detected = await autoDetectLocal();
|
|
44
|
+
let chosen = null;
|
|
45
|
+
if (detected.length > 0) {
|
|
46
|
+
process.stdout.write(` Found ${detected.length} runtime(s):\n`);
|
|
47
|
+
detected.forEach((d, i) => {
|
|
48
|
+
process.stdout.write(` [${i + 1}] ${describeRuntime(d.kind)} ${d.endpoint} (${d.latency_ms}ms, ${d.models?.length ?? 0} models)\n`);
|
|
49
|
+
});
|
|
50
|
+
process.stdout.write(` [r] Enter remote endpoint URL\n`);
|
|
51
|
+
process.stdout.write(` [s] Skip — show install instructions\n`);
|
|
52
|
+
const ans = (await ask(rl, " Choice [1]: ")).trim().toLowerCase() || "1";
|
|
53
|
+
if (ans === "s") {
|
|
54
|
+
showInstallInstructions();
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (ans === "r") {
|
|
58
|
+
chosen = await promptRemoteEndpoint(rl);
|
|
59
|
+
if (!chosen)
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const idx = Number(ans) - 1;
|
|
64
|
+
if (Number.isFinite(idx) && idx >= 0 && idx < detected.length) {
|
|
65
|
+
chosen = detected[idx];
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
process.stderr.write(" Invalid choice.\n");
|
|
69
|
+
return 64;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
process.stdout.write(" No local runtime detected on common ports (11434, 1234, 8080, 8000).\n");
|
|
75
|
+
process.stdout.write(" [r] Enter remote endpoint URL\n");
|
|
76
|
+
process.stdout.write(" [s] Show install instructions and exit\n");
|
|
77
|
+
const ans = (await ask(rl, " Choice [r]: ")).trim().toLowerCase() || "r";
|
|
78
|
+
if (ans === "s") {
|
|
79
|
+
showInstallInstructions();
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
chosen = await promptRemoteEndpoint(rl);
|
|
83
|
+
if (!chosen)
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
// ── Step 2: choose model ──────────────────────────────────────────────
|
|
87
|
+
process.stdout.write("\nStep 2/5 Choose model\n");
|
|
88
|
+
if (chosen.models && chosen.models.length > 0) {
|
|
89
|
+
process.stdout.write(" Already pulled on this runtime:\n");
|
|
90
|
+
chosen.models.slice(0, 10).forEach((m, i) => process.stdout.write(` [${i + 1}] ${m}\n`));
|
|
91
|
+
process.stdout.write(" [n] None of these — show recommended downloads\n");
|
|
92
|
+
const ans = (await ask(rl, " Choice [n]: ")).trim().toLowerCase() || "n";
|
|
93
|
+
if (ans !== "n") {
|
|
94
|
+
const models = chosen.models;
|
|
95
|
+
const idx = Number(ans) - 1;
|
|
96
|
+
if (Number.isFinite(idx) && idx >= 0 && idx < models.length) {
|
|
97
|
+
const model = models[idx];
|
|
98
|
+
return await finalizeSetup(rl, chosen.kind, chosen.endpoint, model, false);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write("\n Recommended (Python-specialized coders):\n");
|
|
103
|
+
RECOMMENDED_MODELS.forEach((m, i) => {
|
|
104
|
+
process.stdout.write(` [${i + 1}] ${m.name.padEnd(24)} ${m.size.padEnd(7)} ${m.note}\n`);
|
|
105
|
+
});
|
|
106
|
+
process.stdout.write(" [c] Custom model name (already pulled or to pull)\n");
|
|
107
|
+
const mAns = (await ask(rl, " Choice [1]: ")).trim().toLowerCase() || "1";
|
|
108
|
+
let modelName;
|
|
109
|
+
if (mAns === "c") {
|
|
110
|
+
modelName = (await ask(rl, " Model name (e.g. qwen2.5-coder:7b): ")).trim();
|
|
111
|
+
if (!modelName) {
|
|
112
|
+
process.stderr.write(" Empty name.\n");
|
|
113
|
+
return 64;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
const idx = Number(mAns) - 1;
|
|
118
|
+
if (!Number.isFinite(idx) || idx < 0 || idx >= RECOMMENDED_MODELS.length) {
|
|
119
|
+
process.stderr.write(" Invalid choice.\n");
|
|
120
|
+
return 64;
|
|
121
|
+
}
|
|
122
|
+
modelName = RECOMMENDED_MODELS[idx].name;
|
|
123
|
+
}
|
|
124
|
+
return await finalizeSetup(rl, chosen.kind, chosen.endpoint, modelName, true);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
rl.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function finalizeSetup(rl, kind, endpoint, model, mayPull) {
|
|
131
|
+
// ── Step 3: optional pull ─────────────────────────────────────────────
|
|
132
|
+
if (mayPull && kind === "ollama") {
|
|
133
|
+
const pullAns = (await ask(rl, `\nStep 3/5 Pull "${model}" via ollama now? [Y/n]: `)).trim().toLowerCase();
|
|
134
|
+
if (pullAns === "" || pullAns === "y" || pullAns === "yes") {
|
|
135
|
+
const code = await streamPull(model);
|
|
136
|
+
if (code !== 0) {
|
|
137
|
+
process.stderr.write(` ⚠️ ollama pull exited with code ${code}. You can rerun: ollama pull ${model}\n`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
process.stdout.write("\nStep 3/5 Skipping model pull (handled by runtime).\n");
|
|
143
|
+
}
|
|
144
|
+
// ── Step 4: test ──────────────────────────────────────────────────────
|
|
145
|
+
process.stdout.write("\nStep 4/5 Testing endpoint…\n");
|
|
146
|
+
const probeCfg = {
|
|
147
|
+
enabled: true, runtime: kind, endpoint, model,
|
|
148
|
+
timeout_ms: 30_000,
|
|
149
|
+
configured_at: new Date().toISOString(),
|
|
150
|
+
};
|
|
151
|
+
const reach = await ping(probeCfg);
|
|
152
|
+
if (!reach.ok) {
|
|
153
|
+
process.stderr.write(` ❌ Endpoint not reachable: ${reach.detail ?? "?"}\n`);
|
|
154
|
+
const cont = (await ask(rl, " Save config anyway? [y/N]: ")).trim().toLowerCase();
|
|
155
|
+
if (cont !== "y" && cont !== "yes")
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
process.stdout.write(` ✓ Endpoint reachable (${reach.latency_ms}ms). Running 1-token generate…\n`);
|
|
160
|
+
try {
|
|
161
|
+
const out = await generate(probeCfg, { prompt: "Say OK.", max_tokens: 8, temperature: 0 });
|
|
162
|
+
const preview = out.text.replace(/\s+/g, " ").trim().slice(0, 60);
|
|
163
|
+
process.stdout.write(` ✓ Model responded in ${out.latency_ms}ms: "${preview}"\n`);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
process.stderr.write(` ⚠️ Generation failed: ${e.message}\n`);
|
|
167
|
+
const cont = (await ask(rl, " Save config anyway? [y/N]: ")).trim().toLowerCase();
|
|
168
|
+
if (cont !== "y" && cont !== "yes")
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// ── Step 5: save ──────────────────────────────────────────────────────
|
|
173
|
+
saveLocalConfig(probeCfg);
|
|
174
|
+
process.stdout.write(`\nStep 5/5 ✅ Saved → ${getConfigPath()}\n`);
|
|
175
|
+
process.stdout.write(`\nRestart Claude Code to activate. delegate_local() will then be available.\n\n`);
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
async function promptRemoteEndpoint(rl) {
|
|
179
|
+
const url = (await ask(rl, " Endpoint URL (e.g. http://gpu.lan:11434): ")).trim();
|
|
180
|
+
if (!url)
|
|
181
|
+
return null;
|
|
182
|
+
const apiKey = (await ask(rl, " Bearer token (optional, press Enter to skip): ")).trim();
|
|
183
|
+
process.stdout.write(` Probing ${url}…\n`);
|
|
184
|
+
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined;
|
|
185
|
+
const det = await probeEndpoint(url, headers);
|
|
186
|
+
if (!det) {
|
|
187
|
+
process.stderr.write(` ❌ No Ollama or OpenAI-compatible endpoint found at ${url}\n`);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
process.stdout.write(` ✓ ${describeRuntime(det.kind)} detected (${det.latency_ms}ms, ${det.models?.length ?? 0} models)\n`);
|
|
191
|
+
// Stash the api key on the returned struct via a side channel (set on cfg later).
|
|
192
|
+
if (apiKey)
|
|
193
|
+
det.api_key = apiKey;
|
|
194
|
+
return det;
|
|
195
|
+
}
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// status / test / disable / pull
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
async function cmdStatus() {
|
|
200
|
+
const cfg = loadLocalConfig();
|
|
201
|
+
if (!cfg) {
|
|
202
|
+
process.stdout.write("Local LLM: not configured. Run `lucid local init`.\n");
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
process.stdout.write([
|
|
206
|
+
`Local LLM: ${cfg.enabled ? "enabled" : "disabled"}`,
|
|
207
|
+
` runtime: ${describeRuntime(cfg.runtime)}`,
|
|
208
|
+
` endpoint: ${cfg.endpoint}`,
|
|
209
|
+
` model: ${cfg.model}`,
|
|
210
|
+
` api_key: ${cfg.api_key ? "(set)" : "(none)"}`,
|
|
211
|
+
` config: ${getConfigPath()}`,
|
|
212
|
+
` saved at: ${cfg.configured_at}`,
|
|
213
|
+
].join("\n") + "\n");
|
|
214
|
+
const reach = await ping(cfg);
|
|
215
|
+
process.stdout.write(` reachable: ${reach.ok ? `✓ ${reach.latency_ms}ms` : `✗ ${reach.detail ?? "?"}`}\n`);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
async function cmdTest() {
|
|
219
|
+
const cfg = loadLocalConfig();
|
|
220
|
+
if (!cfg) {
|
|
221
|
+
process.stderr.write("Not configured. Run `lucid local init` first.\n");
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
process.stdout.write(`Testing ${cfg.model} on ${cfg.endpoint}…\n`);
|
|
225
|
+
try {
|
|
226
|
+
const out = await generate(cfg, {
|
|
227
|
+
prompt: "Write a one-line Python function that returns the square of its argument.",
|
|
228
|
+
max_tokens: 64, temperature: 0.1,
|
|
229
|
+
});
|
|
230
|
+
process.stdout.write(`✓ ${out.latency_ms}ms (prompt=${out.prompt_tokens ?? "?"}, completion=${out.completion_tokens ?? "?"})\n`);
|
|
231
|
+
process.stdout.write(`---\n${out.text.trim()}\n---\n`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
process.stderr.write(`✗ ${e.message}\n`);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function cmdDisable() {
|
|
240
|
+
const ok = disableLocalConfig();
|
|
241
|
+
process.stdout.write(ok ? "Local LLM disabled.\n" : "Nothing to disable (not configured).\n");
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
async function cmdPull(args) {
|
|
245
|
+
const cfg = loadLocalConfig();
|
|
246
|
+
const model = args[0] ?? cfg?.model;
|
|
247
|
+
if (!model) {
|
|
248
|
+
process.stderr.write("Usage: lucid local pull <model>\n");
|
|
249
|
+
return 64;
|
|
250
|
+
}
|
|
251
|
+
if (cfg && cfg.runtime !== "ollama") {
|
|
252
|
+
process.stderr.write(`pull is only supported for Ollama runtimes. For ${describeRuntime(cfg.runtime)}, fetch the model via its own UI.\n`);
|
|
253
|
+
return 64;
|
|
254
|
+
}
|
|
255
|
+
return await streamPull(model);
|
|
256
|
+
}
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Helpers
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
function ask(rl, prompt) {
|
|
261
|
+
return new Promise((resolveAns) => rl.question(prompt, resolveAns));
|
|
262
|
+
}
|
|
263
|
+
async function streamPull(model) {
|
|
264
|
+
return new Promise((resolveCode) => {
|
|
265
|
+
const proc = spawn("ollama", ["pull", model], { stdio: "inherit" });
|
|
266
|
+
proc.on("error", (e) => {
|
|
267
|
+
process.stderr.write(` ⚠️ Could not run ollama: ${e.message}\n`);
|
|
268
|
+
process.stderr.write(` Install Ollama first (see \`lucid local init\` step 1 instructions).\n`);
|
|
269
|
+
resolveCode(127);
|
|
270
|
+
});
|
|
271
|
+
proc.on("exit", (code) => resolveCode(code ?? 0));
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
function showInstallInstructions() {
|
|
275
|
+
process.stdout.write([
|
|
276
|
+
"",
|
|
277
|
+
"──────────────────────────────────────────────────────────────",
|
|
278
|
+
"Install a local LLM runtime, then re-run `lucid local init`.",
|
|
279
|
+
"",
|
|
280
|
+
" Ollama (recommended, simplest):",
|
|
281
|
+
" Windows: winget install Ollama.Ollama",
|
|
282
|
+
" macOS: brew install ollama (or download from ollama.com/download)",
|
|
283
|
+
" Linux: curl -fsSL https://ollama.com/install.sh | sh",
|
|
284
|
+
"",
|
|
285
|
+
" LM Studio (GUI, OpenAI-compatible server):",
|
|
286
|
+
" Download: https://lmstudio.ai/",
|
|
287
|
+
" Start the local server in the GUI before re-running setup.",
|
|
288
|
+
"",
|
|
289
|
+
" llama.cpp server (advanced):",
|
|
290
|
+
" https://github.com/ggerganov/llama.cpp → ./server -m model.gguf",
|
|
291
|
+
"",
|
|
292
|
+
" Remote endpoint:",
|
|
293
|
+
" Any of the above hosted on another machine — re-run init and",
|
|
294
|
+
" pick `[r] Enter remote endpoint URL`. Bearer auth supported.",
|
|
295
|
+
"──────────────────────────────────────────────────────────────",
|
|
296
|
+
"",
|
|
297
|
+
].join("\n"));
|
|
298
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the local-LLM subsystem (Ollama / LM Studio / llama.cpp /
|
|
3
|
+
* any OpenAI-compatible self-hosted endpoint).
|
|
4
|
+
*/
|
|
5
|
+
export type RuntimeKind = "ollama" | "openai-compat" | "unknown";
|
|
6
|
+
export interface LocalLlmConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
runtime: RuntimeKind;
|
|
9
|
+
endpoint: string;
|
|
10
|
+
model: string;
|
|
11
|
+
api_key?: string;
|
|
12
|
+
timeout_ms: number;
|
|
13
|
+
configured_at: string;
|
|
14
|
+
}
|
|
15
|
+
export interface DetectedRuntime {
|
|
16
|
+
kind: RuntimeKind;
|
|
17
|
+
endpoint: string;
|
|
18
|
+
models?: string[];
|
|
19
|
+
latency_ms?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface GenerateRequest {
|
|
22
|
+
prompt: string;
|
|
23
|
+
system?: string;
|
|
24
|
+
max_tokens?: number;
|
|
25
|
+
temperature?: number;
|
|
26
|
+
stop?: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface GenerateResponse {
|
|
29
|
+
text: string;
|
|
30
|
+
model: string;
|
|
31
|
+
latency_ms: number;
|
|
32
|
+
prompt_tokens?: number;
|
|
33
|
+
completion_tokens?: number;
|
|
34
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Statements } from "../database.js";
|
|
3
|
+
export declare const BackupFileSchema: z.ZodObject<{
|
|
4
|
+
path: z.ZodString;
|
|
5
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
6
|
+
}, "strip", z.ZodTypeAny, {
|
|
7
|
+
path: string;
|
|
8
|
+
reason?: string | undefined;
|
|
9
|
+
}, {
|
|
10
|
+
path: string;
|
|
11
|
+
reason?: string | undefined;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function handleBackupFile(stmts: Statements, args: z.infer<typeof BackupFileSchema>): string;
|
|
14
|
+
export declare const RestoreFileSchema: z.ZodObject<{
|
|
15
|
+
path: z.ZodString;
|
|
16
|
+
version: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
backup_id: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
dry_run: z.ZodOptional<z.ZodBoolean>;
|
|
19
|
+
}, "strip", z.ZodTypeAny, {
|
|
20
|
+
path: string;
|
|
21
|
+
version?: number | undefined;
|
|
22
|
+
backup_id?: number | undefined;
|
|
23
|
+
dry_run?: boolean | undefined;
|
|
24
|
+
}, {
|
|
25
|
+
path: string;
|
|
26
|
+
version?: number | undefined;
|
|
27
|
+
backup_id?: number | undefined;
|
|
28
|
+
dry_run?: boolean | undefined;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function handleRestoreFile(stmts: Statements, args: z.infer<typeof RestoreFileSchema>): string;
|
|
31
|
+
export declare const CheckTruncateRiskSchema: z.ZodObject<{
|
|
32
|
+
path: z.ZodString;
|
|
33
|
+
new_content: z.ZodOptional<z.ZodString>;
|
|
34
|
+
new_size: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
record: z.ZodOptional<z.ZodBoolean>;
|
|
36
|
+
}, "strip", z.ZodTypeAny, {
|
|
37
|
+
path: string;
|
|
38
|
+
new_content?: string | undefined;
|
|
39
|
+
new_size?: number | undefined;
|
|
40
|
+
record?: boolean | undefined;
|
|
41
|
+
}, {
|
|
42
|
+
path: string;
|
|
43
|
+
new_content?: string | undefined;
|
|
44
|
+
new_size?: number | undefined;
|
|
45
|
+
record?: boolean | undefined;
|
|
46
|
+
}>;
|
|
47
|
+
export declare function handleCheckTruncateRisk(stmts: Statements, args: z.infer<typeof CheckTruncateRiskSchema>): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { writeFileSync } from "fs";
|
|
4
|
+
import { decompress } from "../store/content.js";
|
|
5
|
+
import { assessTruncate, backupFile, recordTruncateEvent, TUNABLES, } from "../guardian/truncate-guard.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// backup_file
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
export const BackupFileSchema = z.object({
|
|
10
|
+
path: z.string().min(1).describe("File to snapshot"),
|
|
11
|
+
reason: z.string().optional().describe("Why this snapshot was taken (logged)"),
|
|
12
|
+
});
|
|
13
|
+
export function handleBackupFile(stmts, args) {
|
|
14
|
+
const result = backupFile(stmts, args.path, args.reason ?? "manual");
|
|
15
|
+
const absPath = resolve(args.path);
|
|
16
|
+
const total = stmts.countBackups.get(absPath)?.count ?? 0;
|
|
17
|
+
if (!result.saved)
|
|
18
|
+
return `⏭️ ${result.reason} (${absPath})`;
|
|
19
|
+
return [
|
|
20
|
+
`📸 Backup created: ${absPath}`,
|
|
21
|
+
` size: ${result.size}B hash: ${result.hash?.slice(0, 12)}…`,
|
|
22
|
+
` versions retained: ${Math.min(total, TUNABLES.BACKUP_RETENTION)}/${TUNABLES.BACKUP_RETENTION}`,
|
|
23
|
+
].join("\n");
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// restore_file
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
export const RestoreFileSchema = z.object({
|
|
29
|
+
path: z.string().min(1).describe("File to restore"),
|
|
30
|
+
version: z.number().int().positive().optional()
|
|
31
|
+
.describe("1 = latest backup, 2 = previous, etc. Default: 1"),
|
|
32
|
+
backup_id: z.number().int().positive().optional()
|
|
33
|
+
.describe("Specific backup row id (overrides version)"),
|
|
34
|
+
dry_run: z.boolean().optional().describe("Show what would be restored without writing"),
|
|
35
|
+
});
|
|
36
|
+
export function handleRestoreFile(stmts, args) {
|
|
37
|
+
const absPath = resolve(args.path);
|
|
38
|
+
if (args.backup_id !== undefined) {
|
|
39
|
+
const row = stmts.getBackupById.get(args.backup_id);
|
|
40
|
+
if (!row)
|
|
41
|
+
return `❌ Backup id=${args.backup_id} not found`;
|
|
42
|
+
if (row.filepath !== absPath) {
|
|
43
|
+
return `❌ Backup id=${args.backup_id} belongs to ${row.filepath}, not ${absPath}`;
|
|
44
|
+
}
|
|
45
|
+
return doRestore(absPath, row.content, row.created_at, row.original_size, args.dry_run === true);
|
|
46
|
+
}
|
|
47
|
+
const all = stmts.getBackupsByPath.all(absPath);
|
|
48
|
+
if (all.length === 0)
|
|
49
|
+
return `❌ No backups found for: ${absPath}`;
|
|
50
|
+
const idx = (args.version ?? 1) - 1;
|
|
51
|
+
if (idx < 0 || idx >= all.length) {
|
|
52
|
+
return `❌ Version ${args.version} out of range (have ${all.length} backups for this file)`;
|
|
53
|
+
}
|
|
54
|
+
const row = all[idx];
|
|
55
|
+
return doRestore(absPath, row.content, row.created_at, row.original_size, args.dry_run === true);
|
|
56
|
+
}
|
|
57
|
+
function doRestore(absPath, blob, createdAt, originalSize, dryRun) {
|
|
58
|
+
const content = decompress(blob);
|
|
59
|
+
const ts = new Date(createdAt * 1000).toISOString();
|
|
60
|
+
if (dryRun) {
|
|
61
|
+
return [
|
|
62
|
+
`🔍 DRY RUN — would restore ${absPath}`,
|
|
63
|
+
` from snapshot at ${ts}`,
|
|
64
|
+
` size: ${originalSize}B (${content.split("\n").length} lines)`,
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
writeFileSync(absPath, content, "utf-8");
|
|
68
|
+
return [
|
|
69
|
+
`♻️ Restored: ${absPath}`,
|
|
70
|
+
` from snapshot at ${ts}`,
|
|
71
|
+
` size: ${originalSize}B`,
|
|
72
|
+
].join("\n");
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// check_truncate_risk
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
export const CheckTruncateRiskSchema = z.object({
|
|
78
|
+
path: z.string().min(1).describe("File path the write would target"),
|
|
79
|
+
new_content: z.string().optional()
|
|
80
|
+
.describe("Proposed new content. Omit to query cascade-lock status only."),
|
|
81
|
+
new_size: z.number().int().nonnegative().optional()
|
|
82
|
+
.describe("Proposed new size in bytes (alternative to new_content)"),
|
|
83
|
+
record: z.boolean().optional()
|
|
84
|
+
.describe("If true, log this as a truncate event (used by hook). Default: false"),
|
|
85
|
+
});
|
|
86
|
+
export function handleCheckTruncateRisk(stmts, args) {
|
|
87
|
+
const probeContent = args.new_content
|
|
88
|
+
?? (args.new_size !== undefined ? " ".repeat(args.new_size) : null);
|
|
89
|
+
const verdict = assessTruncate(args.path, probeContent, stmts);
|
|
90
|
+
if (args.record === true && verdict.blocked) {
|
|
91
|
+
recordTruncateEvent(stmts, args.path, verdict.prevSize, verdict.newSize, true);
|
|
92
|
+
}
|
|
93
|
+
if (!verdict.blocked) {
|
|
94
|
+
return [
|
|
95
|
+
`✅ Safe write: ${resolve(args.path)}`,
|
|
96
|
+
` prev: ${verdict.prevSize}B → new: ${verdict.newSize >= 0 ? verdict.newSize + "B" : "?"} ` +
|
|
97
|
+
`(keeps ${Math.round(verdict.shrinkRatio * 100)}%)`,
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
return [
|
|
101
|
+
`🛑 BLOCK [${verdict.rule}]: ${resolve(args.path)}`,
|
|
102
|
+
` ${verdict.reason}`,
|
|
103
|
+
verdict.cascade
|
|
104
|
+
? ` cascade_count=${verdict.cascadeCount} within ${TUNABLES.CASCADE_WINDOW_SECONDS}s`
|
|
105
|
+
: ` prev=${verdict.prevSize}B new=${verdict.newSize}B ratio=${verdict.shrinkRatio.toFixed(2)}`,
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const DelegateLocalSchema: z.ZodObject<{
|
|
3
|
+
prompt: z.ZodString;
|
|
4
|
+
system: z.ZodOptional<z.ZodString>;
|
|
5
|
+
max_tokens: z.ZodOptional<z.ZodNumber>;
|
|
6
|
+
temperature: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
model: z.ZodOptional<z.ZodString>;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
prompt: string;
|
|
10
|
+
model?: string | undefined;
|
|
11
|
+
system?: string | undefined;
|
|
12
|
+
temperature?: number | undefined;
|
|
13
|
+
max_tokens?: number | undefined;
|
|
14
|
+
}, {
|
|
15
|
+
prompt: string;
|
|
16
|
+
model?: string | undefined;
|
|
17
|
+
system?: string | undefined;
|
|
18
|
+
temperature?: number | undefined;
|
|
19
|
+
max_tokens?: number | undefined;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function handleDelegateLocal(args: z.infer<typeof DelegateLocalSchema>): Promise<string>;
|
|
22
|
+
export declare const LocalLlmStatusSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
23
|
+
export declare function handleLocalLlmStatus(): Promise<string>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { loadLocalConfig } from "../local-llm/config.js";
|
|
3
|
+
import { generate, ping, LocalLlmError } from "../local-llm/client.js";
|
|
4
|
+
import { describeRuntime } from "../local-llm/runtimes.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// delegate_local — direct passthrough to the configured local LLM
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
export const DelegateLocalSchema = z.object({
|
|
9
|
+
prompt: z.string().min(1).describe("User prompt for the local model."),
|
|
10
|
+
system: z.string().optional().describe("Optional system prompt (Python coding role, conventions, …)."),
|
|
11
|
+
max_tokens: z.number().int().positive().max(8192).optional().describe("Cap on completion tokens. Default 2048."),
|
|
12
|
+
temperature: z.number().min(0).max(2).optional().describe("Sampling temperature. Default 0.2 (deterministic)."),
|
|
13
|
+
model: z.string().optional().describe("Override the configured default model."),
|
|
14
|
+
});
|
|
15
|
+
export async function handleDelegateLocal(args) {
|
|
16
|
+
const cfg = loadLocalConfig();
|
|
17
|
+
if (!cfg) {
|
|
18
|
+
return [
|
|
19
|
+
`❌ Local LLM not configured.`,
|
|
20
|
+
` Run in your terminal: lucid local init`,
|
|
21
|
+
` Then restart Claude Code so the new config is picked up.`,
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
|
24
|
+
if (!cfg.enabled) {
|
|
25
|
+
return `❌ Local LLM is disabled in ${cfg.endpoint} config. Run \`lucid local init\` to re-enable.`;
|
|
26
|
+
}
|
|
27
|
+
const effective = args.model ? { ...cfg, model: args.model } : cfg;
|
|
28
|
+
try {
|
|
29
|
+
const res = await generate(effective, {
|
|
30
|
+
prompt: args.prompt,
|
|
31
|
+
system: args.system,
|
|
32
|
+
max_tokens: args.max_tokens,
|
|
33
|
+
temperature: args.temperature,
|
|
34
|
+
});
|
|
35
|
+
const tokens = res.prompt_tokens !== undefined && res.completion_tokens !== undefined
|
|
36
|
+
? `prompt=${res.prompt_tokens}, completion=${res.completion_tokens}`
|
|
37
|
+
: "tokens=?";
|
|
38
|
+
return [
|
|
39
|
+
`🤖 ${effective.model} via ${describeRuntime(effective.runtime)} (${res.latency_ms}ms, ${tokens})`,
|
|
40
|
+
``,
|
|
41
|
+
res.text.trim(),
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (e instanceof LocalLlmError) {
|
|
46
|
+
return `❌ ${e.message}`;
|
|
47
|
+
}
|
|
48
|
+
return `❌ Unexpected error: ${e instanceof Error ? e.message : String(e)}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// local_llm_status — informational
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
export const LocalLlmStatusSchema = z.object({});
|
|
55
|
+
export async function handleLocalLlmStatus() {
|
|
56
|
+
const cfg = loadLocalConfig();
|
|
57
|
+
if (!cfg) {
|
|
58
|
+
return [
|
|
59
|
+
`Local LLM: not configured.`,
|
|
60
|
+
``,
|
|
61
|
+
`To set it up, run in your terminal: lucid local init`,
|
|
62
|
+
`It walks you through runtime detection (Ollama / LM Studio / llama.cpp /`,
|
|
63
|
+
`remote endpoint), model selection, and a reachability test.`,
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
|
66
|
+
const reach = await ping(cfg);
|
|
67
|
+
return [
|
|
68
|
+
`Local LLM: ${cfg.enabled ? "enabled" : "disabled"}`,
|
|
69
|
+
` runtime: ${describeRuntime(cfg.runtime)}`,
|
|
70
|
+
` endpoint: ${cfg.endpoint}`,
|
|
71
|
+
` model: ${cfg.model}`,
|
|
72
|
+
` reachable: ${reach.ok ? `✓ ${reach.latency_ms}ms` : `✗ ${reach.detail ?? "?"}`}`,
|
|
73
|
+
` saved at: ${cfg.configured_at}`,
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|