@a13xu/lucid 1.16.2 → 1.20.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/index.js CHANGED
@@ -25,6 +25,10 @@ import { GenerateComponentSchema, handleGenerateComponent, ScaffoldPageSchema, h
25
25
  import { handleSmartContext, SmartContextSchema } from "./tools/smart-context.js";
26
26
  import { handleSuggestModel, SuggestModelSchema } from "./tools/model-advisor.js";
27
27
  import { handleCompressText, CompressTextSchema } from "./tools/compress.js";
28
+ import { handleBackupFile, BackupFileSchema, handleRestoreFile, RestoreFileSchema, handleCheckTruncateRisk, CheckTruncateRiskSchema, } from "./tools/backup.js";
29
+ import { handleSessionStatus, SessionStatusSchema } from "./tools/session.js";
30
+ import { handleDelegateLocal, DelegateLocalSchema, handleLocalLlmStatus, LocalLlmStatusSchema, } from "./tools/delegate-local.js";
31
+ import { loadLocalConfig } from "./local-llm/config.js";
28
32
  // ---------------------------------------------------------------------------
29
33
  // CLI mode: lucid watch | lucid status | lucid stop
30
34
  // ---------------------------------------------------------------------------
@@ -33,6 +37,201 @@ if (_cliCmd === "watch" || _cliCmd === "status" || _cliCmd === "stop") {
33
37
  await runCli(_cliCmd, _cliArgs);
34
38
  process.exit(0);
35
39
  }
40
+ if (_cliCmd === "guard") {
41
+ const exitCode = await runGuardCli(_cliArgs);
42
+ process.exit(exitCode);
43
+ }
44
+ if (_cliCmd === "session") {
45
+ const exitCode = await runSessionCli(_cliArgs);
46
+ process.exit(exitCode);
47
+ }
48
+ if (_cliCmd === "local") {
49
+ const { runLocalLlmCli } = await import("./local-llm/setup-cli.js");
50
+ const exitCode = await runLocalLlmCli(_cliArgs);
51
+ process.exit(exitCode);
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // `lucid guard <subcmd>` — invoked from Claude Code hooks (PreToolUse, etc.)
55
+ //
56
+ // Subcommands:
57
+ // pre-edit Read PreToolUse JSON from stdin, snapshot the file,
58
+ // then assess truncate risk. Exit 2 = block (hard).
59
+ // pre-edit --path P Same, but path provided as flag (no stdin parse).
60
+ // clear Clear cascade lock by purging recent truncate_events.
61
+ // status Show cascade-lock status + last events.
62
+ // ---------------------------------------------------------------------------
63
+ async function runGuardCli(args) {
64
+ const sub = args[0];
65
+ const { initDatabase, prepareStatements } = await import("./database.js");
66
+ const db = initDatabase();
67
+ const stmts = prepareStatements(db);
68
+ if (sub === "clear") {
69
+ db.exec("DELETE FROM truncate_events");
70
+ process.stderr.write("[Lucid guard] Cascade lock cleared.\n");
71
+ return 0;
72
+ }
73
+ if (sub === "status") {
74
+ const { isCascadeBlocked, TUNABLES } = await import("./guardian/truncate-guard.js");
75
+ const cascade = isCascadeBlocked(stmts);
76
+ const since = Math.floor(Date.now() / 1000) - TUNABLES.CASCADE_WINDOW_SECONDS;
77
+ const events = stmts.recentTruncateEvents.all(since);
78
+ process.stdout.write(`Cascade locked: ${cascade.blocked} (${cascade.count}/${TUNABLES.CASCADE_THRESHOLD} ` +
79
+ `within ${TUNABLES.CASCADE_WINDOW_SECONDS}s)\n`);
80
+ for (const e of events) {
81
+ process.stdout.write(` ${new Date(e.created_at * 1000).toISOString()} ` +
82
+ `${e.filepath} ${e.prev_size}B→${e.new_size}B (${(e.shrink_ratio * 100).toFixed(0)}%)\n`);
83
+ }
84
+ return 0;
85
+ }
86
+ if (sub === "pre-edit") {
87
+ return await guardPreEdit(stmts, args.slice(1));
88
+ }
89
+ process.stderr.write(`Usage: lucid guard <pre-edit|clear|status>\n`);
90
+ return 64; // EX_USAGE
91
+ }
92
+ async function guardPreEdit(stmts, flagArgs) {
93
+ const { backupFile, assessTruncate, recordTruncateEvent } = await import("./guardian/truncate-guard.js");
94
+ // Override switch — never blocks. Useful for one-off legitimate truncates.
95
+ if (process.env["LUCID_TRUNCATE_OVERRIDE"] === "1")
96
+ return 0;
97
+ const pathFlagIdx = flagArgs.indexOf("--path");
98
+ let path = pathFlagIdx >= 0 ? flagArgs[pathFlagIdx + 1] : undefined;
99
+ let content = null;
100
+ let toolName = "Write";
101
+ // Try parsing PreToolUse JSON from stdin (Claude Code hook protocol).
102
+ // Skip stdin read when --path is given OR when stdin is a TTY (manual run).
103
+ if (!path && !process.stdin.isTTY) {
104
+ const raw = await readStdin();
105
+ if (raw.trim()) {
106
+ try {
107
+ const payload = JSON.parse(raw);
108
+ toolName = payload.tool_name ?? "Write";
109
+ const ti = payload.tool_input ?? {};
110
+ path = ti.file_path ?? ti.path;
111
+ if (typeof ti.content === "string")
112
+ content = ti.content;
113
+ else if (Array.isArray(ti.edits)) {
114
+ // MultiEdit — sum up final state crudely: use new_strings concatenated.
115
+ content = ti.edits.map((e) => e.new_string ?? "").join("\n");
116
+ }
117
+ else if (typeof ti.new_string === "string") {
118
+ content = ti.new_string;
119
+ }
120
+ }
121
+ catch {
122
+ // Non-JSON stdin — ignore, fall through to "no path" error.
123
+ }
124
+ }
125
+ }
126
+ if (!path) {
127
+ process.stderr.write("[Lucid guard] No file path in hook input — allowing.\n");
128
+ return 0;
129
+ }
130
+ // Snapshot BEFORE assessing — even if we end up blocking, we want the version
131
+ // that's about to be overwritten safely stored.
132
+ const snap = backupFile(stmts, path, `pre-${toolName.toLowerCase()}`);
133
+ if (snap.saved) {
134
+ process.stderr.write(`[Lucid guard] 📸 Snapshot stored for ${path}\n`);
135
+ }
136
+ const verdict = assessTruncate(path, content, stmts);
137
+ if (!verdict.blocked)
138
+ return 0;
139
+ recordTruncateEvent(stmts, path, verdict.prevSize, verdict.newSize, true);
140
+ // Exit code 2 → Claude Code blocks the tool call and surfaces stderr.
141
+ process.stderr.write(`🛑 [Lucid guard] BLOCK [${verdict.rule}] ${path}\n` +
142
+ ` ${verdict.reason}\n` +
143
+ (verdict.cascade
144
+ ? ` Override: set LUCID_TRUNCATE_OVERRIDE=1 or run "lucid guard clear".\n`
145
+ : ` prev=${verdict.prevSize}B new=${verdict.newSize}B keep=${(verdict.shrinkRatio * 100).toFixed(0)}%\n` +
146
+ ` Restore via: restore_file(path="${path}")\n`));
147
+ return 2;
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // `lucid session <subcmd>` — invoked from UserPromptSubmit & PreCompact hooks.
151
+ //
152
+ // Subcommands:
153
+ // tick Read UserPromptSubmit JSON from stdin → emit hints to stdout
154
+ // (Claude Code injects stdout as additional context).
155
+ // compact Read PreCompact JSON from stdin → reset session counters.
156
+ // status Print recent sessions + active hint thresholds.
157
+ // reset [--id S] Reset a single session counter (or the latest if no --id).
158
+ // ---------------------------------------------------------------------------
159
+ async function runSessionCli(args) {
160
+ const sub = args[0];
161
+ const { initDatabase, prepareStatements } = await import("./database.js");
162
+ const db = initDatabase();
163
+ const stmts = prepareStatements(db);
164
+ if (sub === "tick")
165
+ return await sessionTickCli(stmts);
166
+ if (sub === "compact")
167
+ return await sessionCompactCli(stmts);
168
+ if (sub === "status") {
169
+ const { handleSessionStatus } = await import("./tools/session.js");
170
+ process.stdout.write(handleSessionStatus(stmts, { limit: 10 }) + "\n");
171
+ return 0;
172
+ }
173
+ if (sub === "reset") {
174
+ const idIdx = args.indexOf("--id");
175
+ if (idIdx >= 0 && args[idIdx + 1]) {
176
+ stmts.resetCliSessionCount.run(args[idIdx + 1]);
177
+ process.stderr.write(`[Lucid session] Reset counters for ${args[idIdx + 1]}\n`);
178
+ }
179
+ else {
180
+ const recent = stmts.recentCliSessions.all(1);
181
+ if (recent.length === 0) {
182
+ process.stderr.write("No sessions tracked.\n");
183
+ return 0;
184
+ }
185
+ stmts.resetCliSessionCount.run(recent[0].session_id);
186
+ process.stderr.write(`[Lucid session] Reset counters for ${recent[0].session_id}\n`);
187
+ }
188
+ return 0;
189
+ }
190
+ process.stderr.write(`Usage: lucid session <tick|compact|status|reset>\n`);
191
+ return 64;
192
+ }
193
+ async function sessionTickCli(stmts) {
194
+ const { tickSession } = await import("./guardian/session-tracker.js");
195
+ const payload = await readHookPayload();
196
+ const sid = payload?.session_id ?? "unknown-session";
197
+ const cwd = payload?.cwd ?? null;
198
+ const result = tickSession(stmts, sid, cwd);
199
+ // Emit hints to stdout — Claude Code injects them as additional context.
200
+ for (const h of result.hints)
201
+ process.stdout.write(h + "\n");
202
+ return 0;
203
+ }
204
+ async function sessionCompactCli(stmts) {
205
+ const { markCompactEvent } = await import("./guardian/session-tracker.js");
206
+ const payload = await readHookPayload();
207
+ const sid = payload?.session_id ?? "unknown-session";
208
+ markCompactEvent(stmts, sid);
209
+ process.stderr.write(`[Lucid session] /compact recorded — counters reset for ${sid}\n`);
210
+ return 0;
211
+ }
212
+ async function readHookPayload() {
213
+ if (process.stdin.isTTY)
214
+ return null;
215
+ const raw = await readStdin();
216
+ if (!raw.trim())
217
+ return null;
218
+ try {
219
+ return JSON.parse(raw);
220
+ }
221
+ catch {
222
+ return null;
223
+ }
224
+ }
225
+ function readStdin() {
226
+ return new Promise((resolveStdin) => {
227
+ let buf = "";
228
+ const timer = setTimeout(() => resolveStdin(buf), 250);
229
+ process.stdin.setEncoding("utf-8");
230
+ process.stdin.on("data", (chunk) => { buf += chunk; });
231
+ process.stdin.on("end", () => { clearTimeout(timer); resolveStdin(buf); });
232
+ process.stdin.on("error", () => { clearTimeout(timer); resolveStdin(buf); });
233
+ });
234
+ }
36
235
  async function runCli(cmd, args) {
37
236
  const { join } = await import("path");
38
237
  const { homedir } = await import("os");
@@ -152,6 +351,14 @@ if (_embeddingUrl) {
152
351
  else {
153
352
  allowHost("https://api.openai.com");
154
353
  }
354
+ // Local-LLM endpoint (may be remote — user-opted-in via `lucid local init`)
355
+ const _localCfg = loadLocalConfig();
356
+ if (_localCfg?.enabled) {
357
+ try {
358
+ allowHost(_localCfg.endpoint);
359
+ }
360
+ catch { /* ignore */ }
361
+ }
155
362
  // ---------------------------------------------------------------------------
156
363
  // MCP Server (high-level McpServer API, SDK 1.27+)
157
364
  // ---------------------------------------------------------------------------
@@ -391,6 +598,53 @@ server.registerTool("check_code_quality", {
391
598
  inputSchema: checkCodeQualityShape,
392
599
  }, tx("check_code_quality", (args) => handleCheckCodeQuality(CheckCodeQualitySchema.parse(args))));
393
600
  // ---------------------------------------------------------------------------
601
+ // Tools — Local LLM (Ollama / LM Studio / llama.cpp / remote endpoint)
602
+ // ---------------------------------------------------------------------------
603
+ server.registerTool("delegate_local", {
604
+ title: "Delegate to Local LLM",
605
+ description: "Send a prompt to the user-configured local LLM (Ollama / LM Studio / llama.cpp / remote " +
606
+ "endpoint). Returns the raw completion. Configure once via `lucid local init`. Best for " +
607
+ "small specialized tasks (docstrings, type hints, simple refactors, regex). Claude should " +
608
+ "review the output before applying it via Edit/Write.",
609
+ inputSchema: DelegateLocalSchema.shape,
610
+ }, tx("delegate_local", async (args) => handleDelegateLocal(args)));
611
+ server.registerTool("local_llm_status", {
612
+ title: "Local LLM Status",
613
+ description: "Inspect the local-LLM configuration: runtime, endpoint, model, reachability. " +
614
+ "Returns setup instructions if not yet configured.",
615
+ inputSchema: LocalLlmStatusSchema.shape,
616
+ }, tx("local_llm_status", async () => handleLocalLlmStatus()));
617
+ // ---------------------------------------------------------------------------
618
+ // Tools — Session Cost Tracker
619
+ // ---------------------------------------------------------------------------
620
+ server.registerTool("session_status", {
621
+ title: "Session Status",
622
+ description: "Show recent Claude Code sessions with prompt counts, idle time, and /compact " +
623
+ "history. Use to inspect when /compact or /clear hints are about to fire.",
624
+ inputSchema: SessionStatusSchema.shape,
625
+ }, tx("session_status", (args) => handleSessionStatus(stmts, args)));
626
+ // ---------------------------------------------------------------------------
627
+ // Tools — Backup & Truncate Guard
628
+ // ---------------------------------------------------------------------------
629
+ server.registerTool("backup_file", {
630
+ title: "Backup File",
631
+ description: "Snapshot the current on-disk content of a file into Lucid's versioned backup store " +
632
+ "(zlib-compressed, last 10 versions kept). Use before risky edits.",
633
+ inputSchema: BackupFileSchema.shape,
634
+ }, tx("backup_file", (args) => handleBackupFile(stmts, args)));
635
+ server.registerTool("restore_file", {
636
+ title: "Restore File",
637
+ description: "Restore a file from a previous Lucid backup. version=1 is the latest snapshot, " +
638
+ "2 is the one before, etc. Pass dry_run=true to preview without writing.",
639
+ inputSchema: RestoreFileSchema.shape,
640
+ }, tx("restore_file", (args) => handleRestoreFile(stmts, args)));
641
+ server.registerTool("check_truncate_risk", {
642
+ title: "Check Truncate Risk",
643
+ description: "Assess whether writing new_content (or new_size) to path would constitute a destructive " +
644
+ "truncate (empty/whitespace overwrite, >70% shrink, or active cascade lock). Read-only by default.",
645
+ inputSchema: CheckTruncateRiskSchema.shape,
646
+ }, tx("check_truncate_risk", (args) => handleCheckTruncateRisk(stmts, args)));
647
+ // ---------------------------------------------------------------------------
394
648
  // Tools — Planning
395
649
  // ---------------------------------------------------------------------------
396
650
  server.registerTool("plan_create", {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * HTTP client wrapping Ollama and OpenAI-compatible /v1/chat/completions
3
+ * endpoints behind a single normalized interface.
4
+ *
5
+ * Inputs are validated; the configured endpoint is registered with the SSRF
6
+ * allowlist by the caller (see src/index.ts) so remote endpoints work after
7
+ * explicit user opt-in via `lucid local init`.
8
+ */
9
+ import type { GenerateRequest, GenerateResponse, LocalLlmConfig } from "./types.js";
10
+ export declare class LocalLlmError extends Error {
11
+ readonly statusCode?: number | undefined;
12
+ constructor(message: string, statusCode?: number | undefined);
13
+ }
14
+ export declare function generate(cfg: LocalLlmConfig, req: GenerateRequest): Promise<GenerateResponse>;
15
+ /** Lightweight reachability check — just probes /api/tags or /v1/models. */
16
+ export declare function ping(cfg: LocalLlmConfig): Promise<{
17
+ ok: boolean;
18
+ latency_ms: number;
19
+ detail?: string;
20
+ }>;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * HTTP client wrapping Ollama and OpenAI-compatible /v1/chat/completions
3
+ * endpoints behind a single normalized interface.
4
+ *
5
+ * Inputs are validated; the configured endpoint is registered with the SSRF
6
+ * allowlist by the caller (see src/index.ts) so remote endpoints work after
7
+ * explicit user opt-in via `lucid local init`.
8
+ */
9
+ export class LocalLlmError extends Error {
10
+ statusCode;
11
+ constructor(message, statusCode) {
12
+ super(message);
13
+ this.statusCode = statusCode;
14
+ this.name = "LocalLlmError";
15
+ }
16
+ }
17
+ export async function generate(cfg, req) {
18
+ if (!cfg.enabled) {
19
+ throw new LocalLlmError("Local LLM is disabled. Run `lucid local init` to set it up.");
20
+ }
21
+ return cfg.runtime === "ollama"
22
+ ? generateOllama(cfg, req)
23
+ : generateOpenAi(cfg, req);
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Ollama POST /api/chat (preferred — gives system role)
27
+ // ---------------------------------------------------------------------------
28
+ async function generateOllama(cfg, req) {
29
+ const url = `${cfg.endpoint.replace(/\/+$/, "")}/api/chat`;
30
+ const messages = [];
31
+ if (req.system)
32
+ messages.push({ role: "system", content: req.system });
33
+ messages.push({ role: "user", content: req.prompt });
34
+ const body = {
35
+ model: cfg.model,
36
+ messages,
37
+ stream: false,
38
+ options: {
39
+ temperature: req.temperature ?? 0.2,
40
+ num_predict: req.max_tokens ?? 2048,
41
+ ...(req.stop ? { stop: req.stop } : {}),
42
+ },
43
+ };
44
+ const start = Date.now();
45
+ const res = await postJson(url, body, cfg);
46
+ const latency = Date.now() - start;
47
+ const content = res.message?.content ?? "";
48
+ return {
49
+ text: content,
50
+ model: cfg.model,
51
+ latency_ms: latency,
52
+ prompt_tokens: res.prompt_eval_count,
53
+ completion_tokens: res.eval_count,
54
+ };
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // OpenAI-compatible POST /v1/chat/completions
58
+ // ---------------------------------------------------------------------------
59
+ async function generateOpenAi(cfg, req) {
60
+ const url = `${cfg.endpoint.replace(/\/+$/, "")}/v1/chat/completions`;
61
+ const messages = [];
62
+ if (req.system)
63
+ messages.push({ role: "system", content: req.system });
64
+ messages.push({ role: "user", content: req.prompt });
65
+ const body = {
66
+ model: cfg.model,
67
+ messages,
68
+ temperature: req.temperature ?? 0.2,
69
+ max_tokens: req.max_tokens ?? 2048,
70
+ stream: false,
71
+ };
72
+ if (req.stop)
73
+ body["stop"] = req.stop;
74
+ const start = Date.now();
75
+ const res = await postJson(url, body, cfg);
76
+ const latency = Date.now() - start;
77
+ const choice = res.choices?.[0];
78
+ const usage = res.usage;
79
+ return {
80
+ text: choice?.message?.content ?? "",
81
+ model: cfg.model,
82
+ latency_ms: latency,
83
+ prompt_tokens: usage?.prompt_tokens,
84
+ completion_tokens: usage?.completion_tokens,
85
+ };
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Shared transport
89
+ // ---------------------------------------------------------------------------
90
+ async function postJson(url, body, cfg) {
91
+ const ac = new AbortController();
92
+ const timer = setTimeout(() => ac.abort(), cfg.timeout_ms);
93
+ const headers = { "Content-Type": "application/json" };
94
+ if (cfg.api_key)
95
+ headers["Authorization"] = `Bearer ${cfg.api_key}`;
96
+ try {
97
+ const res = await fetch(url, {
98
+ method: "POST",
99
+ headers,
100
+ body: JSON.stringify(body),
101
+ signal: ac.signal,
102
+ });
103
+ if (!res.ok) {
104
+ const text = await res.text().catch(() => "");
105
+ throw new LocalLlmError(`Local LLM request failed: ${res.status} ${res.statusText}${text ? " — " + text.slice(0, 200) : ""}`, res.status);
106
+ }
107
+ return await res.json();
108
+ }
109
+ catch (e) {
110
+ if (e instanceof LocalLlmError)
111
+ throw e;
112
+ const msg = e instanceof Error ? e.message : String(e);
113
+ if (msg.includes("aborted")) {
114
+ throw new LocalLlmError(`Local LLM request timed out after ${cfg.timeout_ms}ms`);
115
+ }
116
+ throw new LocalLlmError(`Local LLM request failed: ${msg}`);
117
+ }
118
+ finally {
119
+ clearTimeout(timer);
120
+ }
121
+ }
122
+ /** Lightweight reachability check — just probes /api/tags or /v1/models. */
123
+ export async function ping(cfg) {
124
+ const url = cfg.runtime === "ollama"
125
+ ? `${cfg.endpoint.replace(/\/+$/, "")}/api/tags`
126
+ : `${cfg.endpoint.replace(/\/+$/, "")}/v1/models`;
127
+ const start = Date.now();
128
+ const ac = new AbortController();
129
+ const timer = setTimeout(() => ac.abort(), 3_000);
130
+ try {
131
+ const res = await fetch(url, { signal: ac.signal });
132
+ return { ok: res.ok, latency_ms: Date.now() - start, detail: res.ok ? undefined : `HTTP ${res.status}` };
133
+ }
134
+ catch (e) {
135
+ return { ok: false, latency_ms: Date.now() - start, detail: e instanceof Error ? e.message : "unreachable" };
136
+ }
137
+ finally {
138
+ clearTimeout(timer);
139
+ }
140
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Global config for the local-LLM endpoint, persisted at ~/.lucid/local.json.
3
+ * Project lucid.config.json may override (read by loadConfig in src/config.ts —
4
+ * here we only handle the global file so all projects share one setup by default).
5
+ */
6
+ import type { LocalLlmConfig } from "./types.js";
7
+ export declare function getConfigPath(): string;
8
+ export declare function loadLocalConfig(): LocalLlmConfig | null;
9
+ export declare function saveLocalConfig(cfg: LocalLlmConfig): void;
10
+ export declare function disableLocalConfig(): boolean;
11
+ export declare function isConfigured(): boolean;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Global config for the local-LLM endpoint, persisted at ~/.lucid/local.json.
3
+ * Project lucid.config.json may override (read by loadConfig in src/config.ts —
4
+ * here we only handle the global file so all projects share one setup by default).
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
7
+ import { homedir } from "os";
8
+ import { dirname, join } from "path";
9
+ const CONFIG_DIR = join(homedir(), ".lucid");
10
+ const CONFIG_PATH = join(CONFIG_DIR, "local.json");
11
+ export function getConfigPath() {
12
+ return CONFIG_PATH;
13
+ }
14
+ export function loadLocalConfig() {
15
+ if (!existsSync(CONFIG_PATH))
16
+ return null;
17
+ try {
18
+ const raw = readFileSync(CONFIG_PATH, "utf-8");
19
+ const parsed = JSON.parse(raw);
20
+ if (typeof parsed.endpoint !== "string" || typeof parsed.model !== "string")
21
+ return null;
22
+ return {
23
+ enabled: parsed.enabled !== false,
24
+ runtime: parsed.runtime ?? "ollama",
25
+ endpoint: parsed.endpoint,
26
+ model: parsed.model,
27
+ api_key: parsed.api_key,
28
+ timeout_ms: parsed.timeout_ms ?? 60_000,
29
+ configured_at: parsed.configured_at ?? new Date().toISOString(),
30
+ };
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export function saveLocalConfig(cfg) {
37
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
38
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
39
+ }
40
+ export function disableLocalConfig() {
41
+ const cfg = loadLocalConfig();
42
+ if (!cfg)
43
+ return false;
44
+ saveLocalConfig({ ...cfg, enabled: false });
45
+ return true;
46
+ }
47
+ export function isConfigured() {
48
+ const cfg = loadLocalConfig();
49
+ return cfg !== null && cfg.enabled;
50
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Probe known local-LLM runtimes on localhost (and any user-supplied endpoint).
3
+ *
4
+ * Detection rules:
5
+ * GET /api/tags → Ollama (lists pulled models)
6
+ * GET /v1/models → OpenAI-compat (LM Studio, llama.cpp server, vLLM, …)
7
+ *
8
+ * Probes are short-timeout (1.5s) so they don't block setup.
9
+ */
10
+ import type { DetectedRuntime, RuntimeKind } from "./types.js";
11
+ /** Probe one specific endpoint. Returns null if nothing answers. */
12
+ export declare function probeEndpoint(endpoint: string, headers?: Record<string, string>): Promise<DetectedRuntime | null>;
13
+ /** Probe all known local ports — used for first-run auto-detect. */
14
+ export declare function autoDetectLocal(): Promise<DetectedRuntime[]>;
15
+ /** Human-readable runtime label, never throws. */
16
+ export declare function describeRuntime(kind: RuntimeKind): string;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Probe known local-LLM runtimes on localhost (and any user-supplied endpoint).
3
+ *
4
+ * Detection rules:
5
+ * GET /api/tags → Ollama (lists pulled models)
6
+ * GET /v1/models → OpenAI-compat (LM Studio, llama.cpp server, vLLM, …)
7
+ *
8
+ * Probes are short-timeout (1.5s) so they don't block setup.
9
+ */
10
+ const KNOWN_LOCAL_PORTS = [
11
+ { port: 11434, hint: "Ollama default" },
12
+ { port: 1234, hint: "LM Studio default" },
13
+ { port: 8080, hint: "llama.cpp server default" },
14
+ { port: 8000, hint: "vLLM / generic" },
15
+ ];
16
+ const PROBE_TIMEOUT_MS = 1_500;
17
+ async function probeUrl(url, headers) {
18
+ const ac = new AbortController();
19
+ const timer = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
20
+ const start = Date.now();
21
+ try {
22
+ const res = await fetch(url, { signal: ac.signal, headers });
23
+ if (!res.ok)
24
+ return { ok: false, latency: Date.now() - start };
25
+ const body = await res.json().catch(() => null);
26
+ return { ok: true, latency: Date.now() - start, body: body ?? undefined };
27
+ }
28
+ catch {
29
+ return { ok: false, latency: Date.now() - start };
30
+ }
31
+ finally {
32
+ clearTimeout(timer);
33
+ }
34
+ }
35
+ /** Probe one specific endpoint. Returns null if nothing answers. */
36
+ export async function probeEndpoint(endpoint, headers) {
37
+ const base = endpoint.replace(/\/+$/, "");
38
+ // Try Ollama first (cheap & specific) — body MUST have `.models` array,
39
+ // otherwise it's just a random service that happens to 200 on /api/tags.
40
+ const ollama = await probeUrl(`${base}/api/tags`, headers);
41
+ if (ollama.ok && hasOllamaShape(ollama.body)) {
42
+ return { kind: "ollama", endpoint: base, models: extractOllamaModels(ollama.body), latency_ms: ollama.latency };
43
+ }
44
+ // Then try OpenAI-compatible — body MUST have `.data` array of model entries.
45
+ const oai = await probeUrl(`${base}/v1/models`, headers);
46
+ if (oai.ok && hasOpenAiShape(oai.body)) {
47
+ return { kind: "openai-compat", endpoint: base, models: extractOpenAiModels(oai.body), latency_ms: oai.latency };
48
+ }
49
+ return null;
50
+ }
51
+ function hasOllamaShape(body) {
52
+ return !!body && typeof body === "object" && Array.isArray(body.models);
53
+ }
54
+ function hasOpenAiShape(body) {
55
+ return !!body && typeof body === "object" && Array.isArray(body.data);
56
+ }
57
+ /** Probe all known local ports — used for first-run auto-detect. */
58
+ export async function autoDetectLocal() {
59
+ const probes = KNOWN_LOCAL_PORTS.map((p) => probeEndpoint(`http://localhost:${p.port}`));
60
+ const results = await Promise.all(probes);
61
+ return results.filter((r) => r !== null);
62
+ }
63
+ function extractOllamaModels(body) {
64
+ if (!body || typeof body !== "object")
65
+ return [];
66
+ const list = body.models;
67
+ return Array.isArray(list) ? list.map((m) => m.name ?? "").filter(Boolean) : [];
68
+ }
69
+ function extractOpenAiModels(body) {
70
+ if (!body || typeof body !== "object")
71
+ return [];
72
+ const data = body.data;
73
+ return Array.isArray(data) ? data.map((m) => m.id ?? "").filter(Boolean) : [];
74
+ }
75
+ /** Human-readable runtime label, never throws. */
76
+ export function describeRuntime(kind) {
77
+ switch (kind) {
78
+ case "ollama": return "Ollama";
79
+ case "openai-compat": return "OpenAI-compatible (LM Studio / llama.cpp / vLLM)";
80
+ default: return "unknown";
81
+ }
82
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Interactive `lucid local <subcmd>` CLI.
3
+ * Subcommands: init | status | test | disable | pull
4
+ */
5
+ export declare function runLocalLlmCli(args: string[]): Promise<number>;