@a13xu/lucid 1.13.0 → 1.16.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.
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Semantic compression using LLMLingua-2
3
+ * Model: microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank
4
+ *
5
+ * Reduces text by identifying and dropping semantically unimportant tokens.
6
+ * Uses @huggingface/transformers (ONNX Runtime) for local inference.
7
+ *
8
+ * Pipeline is loaded lazily on first use and cached in memory.
9
+ * Model files are cached in ~/.lucid/models/ after first download (~700MB).
10
+ *
11
+ * Falls back to original text on any error — safe to call unconditionally.
12
+ */
13
+ export interface SemanticCompressionResult {
14
+ compressed: string;
15
+ originalLength: number;
16
+ compressedLength: number;
17
+ /** Fraction of tokens kept (1.0 = no compression) */
18
+ ratio: number;
19
+ method: "llmlingua2" | "fallback";
20
+ }
21
+ /**
22
+ * Compress text using LLMLingua-2 token importance scoring.
23
+ *
24
+ * @param text Input text to compress
25
+ * @param targetRatio Target compression ratio (0.3 = keep 30%, 0.5 = keep 50%)
26
+ * @param minLength Skip compression for texts shorter than this (chars)
27
+ */
28
+ export declare function compressTextSemantic(text: string, targetRatio?: number, minLength?: number): Promise<SemanticCompressionResult>;
29
+ export declare function tryCompressTextSemantic(text: string, targetRatio?: number, minLength?: number): Promise<string>;
30
+ export declare function isSemanticCompressionAvailable(): Promise<boolean>;
31
+ export declare function warmUpSemanticCompression(): void;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Semantic compression using LLMLingua-2
3
+ * Model: microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank
4
+ *
5
+ * Reduces text by identifying and dropping semantically unimportant tokens.
6
+ * Uses @huggingface/transformers (ONNX Runtime) for local inference.
7
+ *
8
+ * Pipeline is loaded lazily on first use and cached in memory.
9
+ * Model files are cached in ~/.lucid/models/ after first download (~700MB).
10
+ *
11
+ * Falls back to original text on any error — safe to call unconditionally.
12
+ */
13
+ import { join } from "path";
14
+ import { homedir } from "os";
15
+ import { mkdirSync } from "fs";
16
+ const MODEL_ID = "microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank";
17
+ const MODELS_DIR = join(homedir(), ".lucid", "models");
18
+ let _pipeline = null;
19
+ let _loadError = null;
20
+ let _loading = false;
21
+ async function getPipeline() {
22
+ if (_loadError)
23
+ throw _loadError;
24
+ if (_pipeline)
25
+ return _pipeline;
26
+ if (_loading) {
27
+ // Wait for concurrent load
28
+ await new Promise((resolve) => {
29
+ const check = setInterval(() => {
30
+ if (!_loading) {
31
+ clearInterval(check);
32
+ resolve();
33
+ }
34
+ }, 100);
35
+ });
36
+ if (_loadError)
37
+ throw _loadError;
38
+ if (_pipeline)
39
+ return _pipeline;
40
+ }
41
+ _loading = true;
42
+ try {
43
+ mkdirSync(MODELS_DIR, { recursive: true });
44
+ // Dynamic import keeps startup fast when compression is not used
45
+ const { pipeline, env } = await import("@huggingface/transformers");
46
+ env.cacheDir = MODELS_DIR;
47
+ env.allowRemoteModels = true;
48
+ process.stderr.write(`[Lucid] Loading LLMLingua-2 model (first run: downloads ~700MB to ${MODELS_DIR})…\n`);
49
+ _pipeline = (await pipeline("token-classification", MODEL_ID, {
50
+ dtype: "q8", // 8-bit quantization — smaller, faster, minimal quality loss
51
+ device: "cpu",
52
+ }));
53
+ process.stderr.write("[Lucid] LLMLingua-2 model ready.\n");
54
+ return _pipeline;
55
+ }
56
+ catch (e) {
57
+ _loadError = e instanceof Error ? e : new Error(String(e));
58
+ throw _loadError;
59
+ }
60
+ finally {
61
+ _loading = false;
62
+ }
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // Core compression
66
+ // ---------------------------------------------------------------------------
67
+ /**
68
+ * Compress text using LLMLingua-2 token importance scoring.
69
+ *
70
+ * @param text Input text to compress
71
+ * @param targetRatio Target compression ratio (0.3 = keep 30%, 0.5 = keep 50%)
72
+ * @param minLength Skip compression for texts shorter than this (chars)
73
+ */
74
+ export async function compressTextSemantic(text, targetRatio = 0.5, minLength = 300) {
75
+ if (text.length < minLength) {
76
+ return {
77
+ compressed: text,
78
+ originalLength: text.length,
79
+ compressedLength: text.length,
80
+ ratio: 1.0,
81
+ method: "fallback",
82
+ };
83
+ }
84
+ const pipe = await getPipeline();
85
+ // Run token classification — each token gets entity "LABEL_0" (drop) / "LABEL_1" (keep)
86
+ const tokens = await pipe(text, {
87
+ // Disable aggregation to get per-sub-token results with offsets
88
+ aggregation_strategy: "none",
89
+ });
90
+ if (!tokens || tokens.length === 0) {
91
+ return {
92
+ compressed: text,
93
+ originalLength: text.length,
94
+ compressedLength: text.length,
95
+ ratio: 1.0,
96
+ method: "fallback",
97
+ };
98
+ }
99
+ // Determine importance threshold:
100
+ // Sort all "keep" scores descending, keep the top (targetRatio * N) tokens
101
+ const keepScores = tokens
102
+ .filter((t) => t.entity === "LABEL_1" || t.entity === "1")
103
+ .map((t) => t.score)
104
+ .sort((a, b) => b - a);
105
+ // If not enough LABEL_1 tokens, use score-based threshold
106
+ let threshold;
107
+ if (keepScores.length > 0) {
108
+ const cutoffIdx = Math.floor(tokens.length * targetRatio);
109
+ // Find the score at the cutoff rank among all tokens sorted by score
110
+ const allScores = tokens.map((t) => ({
111
+ score: t.entity === "LABEL_1" || t.entity === "1" ? t.score : 1 - t.score,
112
+ })).sort((a, b) => b.score - a.score);
113
+ threshold = allScores[Math.min(cutoffIdx, allScores.length - 1)]?.score ?? 0.5;
114
+ }
115
+ else {
116
+ // Fallback: use raw score threshold
117
+ threshold = 0.5;
118
+ }
119
+ // Mark characters to keep based on token offsets
120
+ const keepChars = new Uint8Array(text.length);
121
+ for (const token of tokens) {
122
+ const isImportant = token.entity === "LABEL_1" ||
123
+ token.entity === "1" ||
124
+ (token.entity !== "LABEL_0" && token.entity !== "0" && token.score >= threshold);
125
+ if (isImportant && token.start !== undefined && token.end !== undefined) {
126
+ keepChars.fill(1, token.start, token.end);
127
+ }
128
+ }
129
+ // Always keep structural markers (newlines, sentence boundaries)
130
+ const FORCE_KEEP = new Set(["\n", ".", "!", "?", ","]);
131
+ for (let i = 0; i < text.length; i++) {
132
+ if (FORCE_KEEP.has(text[i]))
133
+ keepChars[i] = 1;
134
+ }
135
+ // Reconstruct compressed text from character mask
136
+ let compressed = "";
137
+ let prevKept = false;
138
+ for (let i = 0; i < text.length; i++) {
139
+ if (keepChars[i]) {
140
+ // Preserve a single space when skipping tokens in mid-sentence
141
+ if (!prevKept && compressed.length > 0 && text[i] !== " " && !FORCE_KEEP.has(text[i - 1] ?? "")) {
142
+ compressed += " ";
143
+ }
144
+ compressed += text[i];
145
+ prevKept = true;
146
+ }
147
+ else {
148
+ prevKept = false;
149
+ }
150
+ }
151
+ // Clean up artefacts from compression
152
+ compressed = compressed
153
+ .replace(/ +/g, " ") // multiple spaces → single
154
+ .replace(/\n{3,}/g, "\n\n") // more than 2 newlines → 2
155
+ .replace(/ ([.,!?])/g, "$1") // space before punctuation → no space
156
+ .trim();
157
+ const keptCount = keepScores.length;
158
+ const actualRatio = tokens.length > 0 ? keptCount / tokens.length : 1.0;
159
+ return {
160
+ compressed,
161
+ originalLength: text.length,
162
+ compressedLength: compressed.length,
163
+ ratio: actualRatio,
164
+ method: "llmlingua2",
165
+ };
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // Safe wrapper — always returns a string, never throws
169
+ // ---------------------------------------------------------------------------
170
+ export async function tryCompressTextSemantic(text, targetRatio = 0.5, minLength = 300) {
171
+ try {
172
+ const result = await compressTextSemantic(text, targetRatio, minLength);
173
+ return result.compressed;
174
+ }
175
+ catch {
176
+ return text;
177
+ }
178
+ }
179
+ // ---------------------------------------------------------------------------
180
+ // Availability check — call before bulk compression to fail fast
181
+ // ---------------------------------------------------------------------------
182
+ export async function isSemanticCompressionAvailable() {
183
+ try {
184
+ await getPipeline();
185
+ return true;
186
+ }
187
+ catch {
188
+ return false;
189
+ }
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // Warm-up (optional — call at startup to pre-load model)
193
+ // ---------------------------------------------------------------------------
194
+ export function warmUpSemanticCompression() {
195
+ getPipeline().catch(() => { });
196
+ }
package/build/config.d.ts CHANGED
@@ -12,6 +12,21 @@ export interface LucidConfig {
12
12
  recentWindowHours?: number;
13
13
  /** Security guard configuration */
14
14
  security?: SecurityConfig;
15
+ /**
16
+ * Semantic compression via LLMLingua-2 (microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank).
17
+ * When enabled, file content is compressed before being returned to Claude and before Qdrant embedding.
18
+ * Model is downloaded on first use (~700MB) and cached in ~/.lucid/models/.
19
+ */
20
+ semanticCompression?: {
21
+ /** Enable semantic compression (default: false — opt-in) */
22
+ enabled?: boolean;
23
+ /** Target compression ratio: 0.3 = keep 30%, 0.5 = keep 50% (default: 0.5) */
24
+ ratio?: number;
25
+ /** Skip compression for texts shorter than this in chars (default: 300) */
26
+ minLength?: number;
27
+ /** Also compress text before Qdrant embedding generation (default: true when enabled) */
28
+ applyToEmbeddings?: boolean;
29
+ };
15
30
  /** Optional Qdrant vector search (falls back to TF-IDF if not configured) */
16
31
  qdrant?: {
17
32
  url: string;
@@ -0,0 +1,3 @@
1
+ import { Router } from "express";
2
+ import type { Statements } from "../database.js";
3
+ export declare function createRoutes(stmts: Statements): Router;
@@ -0,0 +1,56 @@
1
+ import { Router } from "express";
2
+ import { handleSyncFile, handleSyncProject } from "../tools/sync.js";
3
+ import { handleGetContext } from "../tools/context.js";
4
+ import { handleValidateFile } from "../tools/guardian.js";
5
+ import { getCurrentVersion } from "../tools/updater.js";
6
+ export function createRoutes(stmts) {
7
+ const router = Router();
8
+ // POST /sync — sync a single file
9
+ router.post("/sync", (req, res) => {
10
+ try {
11
+ const result = handleSyncFile(stmts, req.body);
12
+ res.json({ ok: true, result });
13
+ }
14
+ catch (e) {
15
+ res.status(500).json({ ok: false, error: String(e) });
16
+ }
17
+ });
18
+ // POST /sync-project — sync entire project directory
19
+ router.post("/sync-project", (req, res) => {
20
+ try {
21
+ const result = handleSyncProject(stmts, req.body);
22
+ res.json({ ok: true, result });
23
+ }
24
+ catch (e) {
25
+ res.status(500).json({ ok: false, error: String(e) });
26
+ }
27
+ });
28
+ // GET /context?q=...&maxTokens=4000 — retrieve relevant context
29
+ router.get("/context", async (req, res) => {
30
+ try {
31
+ const result = await handleGetContext(stmts, {
32
+ query: String(req.query["q"] ?? ""),
33
+ maxTokens: req.query["maxTokens"] ? Number(req.query["maxTokens"]) : 4000,
34
+ });
35
+ res.json({ ok: true, result });
36
+ }
37
+ catch (e) {
38
+ res.status(500).json({ ok: false, error: String(e) });
39
+ }
40
+ });
41
+ // POST /validate — validate a file for drift/quality issues
42
+ router.post("/validate", (req, res) => {
43
+ try {
44
+ const result = handleValidateFile(req.body);
45
+ res.json({ ok: true, result });
46
+ }
47
+ catch (e) {
48
+ res.status(500).json({ ok: false, error: String(e) });
49
+ }
50
+ });
51
+ // GET /health — liveness check
52
+ router.get("/health", (_req, res) => {
53
+ res.json({ ok: true, version: getCurrentVersion() });
54
+ });
55
+ return router;
56
+ }
@@ -0,0 +1,7 @@
1
+ import type { Server } from "http";
2
+ import type { Statements } from "../database.js";
3
+ export interface HttpServerOptions {
4
+ port?: number;
5
+ host?: string;
6
+ }
7
+ export declare function startHttpServer(stmts: Statements, options?: HttpServerOptions): Server;
@@ -0,0 +1,11 @@
1
+ import express from "express";
2
+ import { createRoutes } from "./routes.js";
3
+ export function startHttpServer(stmts, options = {}) {
4
+ const { port = 7821, host = "127.0.0.1" } = options;
5
+ const app = express();
6
+ app.use(express.json());
7
+ app.use("/", createRoutes(stmts));
8
+ return app.listen(port, host, () => {
9
+ process.stderr.write(`[Lucid] HTTP server listening on ${host}:${port}\n`);
10
+ });
11
+ }
package/build/index.js CHANGED
@@ -23,6 +23,109 @@ import { handleGetCodingRules, handleCheckCodeQuality, CheckCodeQualitySchema, }
23
23
  import { handlePlanCreate, PlanCreateSchema, handlePlanList, PlanListSchema, handlePlanGet, PlanGetSchema, handlePlanUpdateTask, PlanUpdateTaskSchema, } from "./tools/plan.js";
24
24
  import { UpdateLucidSchema, handleUpdateLucid, checkForUpdatesOnStartup, getCurrentVersion, } from "./tools/updater.js";
25
25
  import { GenerateComponentSchema, handleGenerateComponent, ScaffoldPageSchema, handleScaffoldPage, SeoMetaSchema, handleSeoMeta, AccessibilityAuditSchema, handleAccessibilityAudit, ApiClientSchema, handleApiClient, TestGeneratorSchema, handleTestGenerator, ResponsiveLayoutSchema, handleResponsiveLayout, SecurityScanSchema, handleSecurityScan, DesignTokensSchema, handleDesignTokens, PerfHintsSchema, handlePerfHints, } from "./tools/webdev/index.js";
26
+ import { handleSmartContext, SmartContextSchema } from "./tools/smart-context.js";
27
+ import { handleSuggestModel, SuggestModelSchema } from "./tools/model-advisor.js";
28
+ import { handleCompressText, CompressTextSchema } from "./tools/compress.js";
29
+ // ---------------------------------------------------------------------------
30
+ // CLI mode: lucid watch | lucid status | lucid stop
31
+ // ---------------------------------------------------------------------------
32
+ const [, , _cliCmd, ..._cliArgs] = process.argv;
33
+ if (_cliCmd === "watch" || _cliCmd === "status" || _cliCmd === "stop") {
34
+ await runCli(_cliCmd, _cliArgs);
35
+ process.exit(0);
36
+ }
37
+ async function runCli(cmd, args) {
38
+ const { join } = await import("path");
39
+ const { homedir } = await import("os");
40
+ const { existsSync, mkdirSync, writeFileSync, readFileSync } = await import("fs");
41
+ const PID_DIR = join(homedir(), ".lucid");
42
+ const PID_FILE = join(PID_DIR, "watch.pid");
43
+ if (cmd === "status") {
44
+ if (!existsSync(PID_FILE)) {
45
+ console.log("Lucid daemon: not running");
46
+ return;
47
+ }
48
+ const pid = readFileSync(PID_FILE, "utf-8").trim();
49
+ try {
50
+ process.kill(Number(pid), 0);
51
+ console.log(`Lucid daemon: running (PID ${pid})`);
52
+ }
53
+ catch {
54
+ console.log("Lucid daemon: not running (stale PID file)");
55
+ }
56
+ return;
57
+ }
58
+ if (cmd === "stop") {
59
+ if (!existsSync(PID_FILE)) {
60
+ console.log("Lucid daemon: not running");
61
+ return;
62
+ }
63
+ const pid = readFileSync(PID_FILE, "utf-8").trim();
64
+ try {
65
+ process.kill(Number(pid), "SIGTERM");
66
+ console.log(`Lucid daemon stopped (PID ${pid})`);
67
+ }
68
+ catch {
69
+ console.log("Lucid daemon: not running (stale PID file)");
70
+ }
71
+ return;
72
+ }
73
+ // cmd === "watch"
74
+ const portIdx = args.indexOf("--port");
75
+ const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 7821;
76
+ const noHttp = args.includes("--no-http");
77
+ const watchDir = args.find((a) => !a.startsWith("--")) ?? process.cwd();
78
+ const { initDatabase, prepareStatements } = await import("./database.js");
79
+ const db = initDatabase();
80
+ const stmts = prepareStatements(db);
81
+ if (!noHttp) {
82
+ const { startHttpServer } = await import("./http/server.js");
83
+ startHttpServer(stmts, { port });
84
+ }
85
+ mkdirSync(PID_DIR, { recursive: true });
86
+ writeFileSync(PID_FILE, String(process.pid), "utf-8");
87
+ const chokidar = await import("chokidar");
88
+ const watcher = chokidar.watch(watchDir, {
89
+ ignored: [/node_modules/, /\.git/, /[/\\]build[/\\]/, /[/\\]dist[/\\]/, /\.d\.ts$/],
90
+ persistent: true,
91
+ ignoreInitial: true,
92
+ });
93
+ const DEBOUNCE_MS = 300;
94
+ const timers = new Map();
95
+ const syncPath = (filePath) => {
96
+ const existing = timers.get(filePath);
97
+ if (existing)
98
+ clearTimeout(existing);
99
+ timers.set(filePath, setTimeout(() => {
100
+ timers.delete(filePath);
101
+ if (!noHttp) {
102
+ fetch(`http://localhost:${port}/sync`, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ path: filePath }),
106
+ }).catch(() => { });
107
+ }
108
+ else {
109
+ import("./tools/sync.js").then(({ handleSyncFile }) => {
110
+ handleSyncFile(stmts, { path: filePath });
111
+ }).catch(() => { });
112
+ }
113
+ }, DEBOUNCE_MS));
114
+ };
115
+ watcher.on("add", syncPath).on("change", syncPath);
116
+ process.stderr.write(`[Lucid] Watching ${watchDir}${noHttp ? " (no HTTP)" : ` on port ${port}`}\n`);
117
+ const shutdown = () => {
118
+ watcher.close().catch(() => { });
119
+ try {
120
+ db.pragma("wal_checkpoint(FULL)");
121
+ }
122
+ catch { /* ignore */ }
123
+ process.exit(0);
124
+ };
125
+ process.on("SIGINT", shutdown);
126
+ process.on("SIGTERM", shutdown);
127
+ await new Promise(() => { });
128
+ }
26
129
  // ---------------------------------------------------------------------------
27
130
  // Init DB
28
131
  // ---------------------------------------------------------------------------
@@ -222,6 +325,69 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
222
325
  },
223
326
  },
224
327
  },
328
+ // ── Smart Context + Model Advisor ─────────────────────────────────────────
329
+ {
330
+ name: "smart_context",
331
+ description: "Combined: knowledge graph (recall) + code files (get_context) in one call. " +
332
+ "Use instead of calling recall() + get_context() separately. " +
333
+ "task_type adjusts token budget: simple=2000, moderate=6000, complex=12000. " +
334
+ "Logs an experience so reward()/penalize() work after this call.",
335
+ inputSchema: {
336
+ type: "object",
337
+ properties: {
338
+ query: { type: "string", description: "What you are working on" },
339
+ task_type: {
340
+ type: "string",
341
+ enum: ["simple", "moderate", "complex"],
342
+ description: "Token budget: simple=2000, moderate=6000 (default), complex=12000",
343
+ },
344
+ dirs: {
345
+ type: "array",
346
+ items: { type: "string" },
347
+ description: "Whitelist directories (e.g. [\"src\", \"backend\"])",
348
+ },
349
+ },
350
+ required: ["query"],
351
+ },
352
+ },
353
+ {
354
+ name: "suggest_model",
355
+ description: "Classify task complexity → recommend Claude model. " +
356
+ "Returns { model, model_id, reasoning, context_budget }. " +
357
+ "Call at the start of any workflow. Simple lookups → Haiku; everything else → Sonnet (default).",
358
+ inputSchema: {
359
+ type: "object",
360
+ properties: {
361
+ task_description: {
362
+ type: "string",
363
+ description: "Natural language description of the task you are about to perform",
364
+ },
365
+ },
366
+ required: ["task_description"],
367
+ },
368
+ },
369
+ {
370
+ name: "compress_text",
371
+ description: "Compress text using LLMLingua-2 semantic compression (microsoft/llmlingua-2-bert-base-multilingual-cased-meetingbank). " +
372
+ "Identifies and drops semantically unimportant tokens while preserving meaning. " +
373
+ "Model downloads ~700MB on first use and is cached in ~/.lucid/models/. " +
374
+ "Returns compressed text with stats (original/compressed length, ratio, tokens saved).",
375
+ inputSchema: {
376
+ type: "object",
377
+ properties: {
378
+ text: { type: "string", description: "Text to compress" },
379
+ ratio: {
380
+ type: "number",
381
+ description: "Target compression ratio: 0.3 = keep 30%, 0.5 = keep 50% (default: 0.5)",
382
+ },
383
+ min_length: {
384
+ type: "number",
385
+ description: "Skip compression for texts shorter than this in chars (default: 300)",
386
+ },
387
+ },
388
+ required: ["text"],
389
+ },
390
+ },
225
391
  // ── Reward System ────────────────────────────────────────────────────────
226
392
  {
227
393
  name: "reward",
@@ -632,6 +798,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
632
798
  case "get_recent":
633
799
  text = handleGetRecent(stmts, GetRecentSchema.parse(args));
634
800
  break;
801
+ // Smart Context + Model Advisor
802
+ case "smart_context":
803
+ text = await handleSmartContext(stmts, SmartContextSchema.parse(args));
804
+ break;
805
+ case "suggest_model":
806
+ text = handleSuggestModel(SuggestModelSchema.parse(args));
807
+ break;
808
+ case "compress_text":
809
+ text = await handleCompressText(CompressTextSchema.parse(args));
810
+ break;
635
811
  // Reward System
636
812
  case "reward":
637
813
  text = handleReward(stmts, RewardSchema.parse(args));
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lucid-sync — PostToolUse hook script for Lucid.
4
+ *
5
+ * Called by Claude Code's PostToolUse hook after Write/Edit/NotebookEdit.
6
+ * Reads tool input from stdin (JSON), extracts the modified file path,
7
+ * then syncs it to Lucid's SQLite index.
8
+ *
9
+ * Fallback chain:
10
+ * 1. POST http://localhost:7821/sync (if lucid watch daemon is running)
11
+ * 2. Direct SQLite write (always works, no daemon needed)
12
+ *
13
+ * Never throws — hook failures must not interrupt Claude Code.
14
+ */
15
+ export {};
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lucid-sync — PostToolUse hook script for Lucid.
4
+ *
5
+ * Called by Claude Code's PostToolUse hook after Write/Edit/NotebookEdit.
6
+ * Reads tool input from stdin (JSON), extracts the modified file path,
7
+ * then syncs it to Lucid's SQLite index.
8
+ *
9
+ * Fallback chain:
10
+ * 1. POST http://localhost:7821/sync (if lucid watch daemon is running)
11
+ * 2. Direct SQLite write (always works, no daemon needed)
12
+ *
13
+ * Never throws — hook failures must not interrupt Claude Code.
14
+ */
15
+ import { readFileSync } from "fs";
16
+ function getFilePathFromStdin() {
17
+ try {
18
+ const raw = readFileSync("/dev/stdin", "utf-8").trim();
19
+ if (!raw)
20
+ return process.argv[2] ?? null;
21
+ const data = JSON.parse(raw);
22
+ const ti = data.tool_input ?? {};
23
+ return ti.file_path ?? ti.notebook_path ?? ti.path ?? process.argv[2] ?? null;
24
+ }
25
+ catch {
26
+ return process.argv[2] ?? null;
27
+ }
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // HTTP sync (fast — daemon must be running on port 7821)
31
+ // ---------------------------------------------------------------------------
32
+ async function tryHttpSync(filePath, port = 7821) {
33
+ try {
34
+ const controller = new AbortController();
35
+ const timer = setTimeout(() => controller.abort(), 500);
36
+ const res = await fetch(`http://localhost:${port}/sync`, {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({ path: filePath }),
40
+ signal: controller.signal,
41
+ });
42
+ clearTimeout(timer);
43
+ return res.ok;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Direct SQLite sync (fallback — always available)
51
+ // ---------------------------------------------------------------------------
52
+ async function syncDirect(filePath) {
53
+ const { initDatabase, prepareStatements } = await import("./database.js");
54
+ const { handleSyncFile } = await import("./tools/sync.js");
55
+ const db = initDatabase();
56
+ const stmts = prepareStatements(db);
57
+ handleSyncFile(stmts, { path: filePath });
58
+ db.close();
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Main
62
+ // ---------------------------------------------------------------------------
63
+ async function main() {
64
+ const filePath = getFilePathFromStdin();
65
+ if (!filePath)
66
+ return;
67
+ const httpOk = await tryHttpSync(filePath);
68
+ if (!httpOk) {
69
+ await syncDirect(filePath);
70
+ }
71
+ }
72
+ main().catch(() => { }); // never propagate errors to Claude Code
@@ -6,6 +6,7 @@ import { extractSkeleton, renderSkeleton } from "../indexer/ast.js";
6
6
  import { searchQdrant } from "./qdrant.js";
7
7
  import { getQdrantConfig } from "../config.js";
8
8
  import { getFileRewardsMap } from "../memory/experience.js";
9
+ import { tryCompressTextSemantic } from "../compression/semantic.js";
9
10
  // ---------------------------------------------------------------------------
10
11
  // Token estimation (1 token ≈ 4 chars is the standard heuristic)
11
12
  // ---------------------------------------------------------------------------
@@ -183,6 +184,11 @@ export async function assembleContext(query, stmts, cfg, opts = {}) {
183
184
  }
184
185
  if (isRecent)
185
186
  reason += " +recent";
187
+ // Semantic compression — applied after skeleton/full decision, before token counting
188
+ if (cfg.semanticCompression?.enabled) {
189
+ content = await tryCompressTextSemantic(content, cfg.semanticCompression.ratio ?? 0.5, cfg.semanticCompression.minLength ?? 300);
190
+ reason += " +compressed";
191
+ }
186
192
  const contentTokens = estimateTokens(content);
187
193
  if (contentTokens < 10) {
188
194
  skippedFiles++;
@@ -8,7 +8,7 @@ export interface VectorChunk {
8
8
  score: number;
9
9
  }
10
10
  /** Index one file into Qdrant (called by sync_file when Qdrant is configured). */
11
- export declare function indexFileInQdrant(filepath: string, text: string, cfg: QdrantCfg): Promise<void>;
11
+ export declare function indexFileInQdrant(filepath: string, text: string, cfg: QdrantCfg, compressionCfg?: ResolvedConfig["semanticCompression"]): Promise<void>;
12
12
  /** Top-k semantic search across all indexed chunks. */
13
13
  export declare function searchQdrant(query: string, topK: number, cfg: QdrantCfg): Promise<VectorChunk[]>;
14
14
  /** Check if Qdrant collection exists and is reachable. */