@a13xu/lucid 1.4.0 → 1.9.1

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.
Files changed (46) hide show
  1. package/README.md +118 -14
  2. package/build/config.d.ts +37 -0
  3. package/build/config.js +45 -0
  4. package/build/database.d.ts +36 -1
  5. package/build/database.js +85 -1
  6. package/build/guardian/coding-analyzer.d.ts +11 -0
  7. package/build/guardian/coding-analyzer.js +393 -0
  8. package/build/guardian/coding-rules.d.ts +1 -0
  9. package/build/guardian/coding-rules.js +97 -0
  10. package/build/index.js +164 -3
  11. package/build/indexer/ast.d.ts +9 -0
  12. package/build/indexer/ast.js +158 -0
  13. package/build/indexer/project.js +21 -13
  14. package/build/memory/experience.d.ts +11 -0
  15. package/build/memory/experience.js +85 -0
  16. package/build/retrieval/context.d.ts +29 -0
  17. package/build/retrieval/context.js +219 -0
  18. package/build/retrieval/qdrant.d.ts +16 -0
  19. package/build/retrieval/qdrant.js +135 -0
  20. package/build/retrieval/tfidf.d.ts +14 -0
  21. package/build/retrieval/tfidf.js +64 -0
  22. package/build/security/alerts.d.ts +44 -0
  23. package/build/security/alerts.js +228 -0
  24. package/build/security/env.d.ts +24 -0
  25. package/build/security/env.js +85 -0
  26. package/build/security/guard.d.ts +35 -0
  27. package/build/security/guard.js +133 -0
  28. package/build/security/ratelimit.d.ts +34 -0
  29. package/build/security/ratelimit.js +105 -0
  30. package/build/security/smtp.d.ts +26 -0
  31. package/build/security/smtp.js +125 -0
  32. package/build/security/ssrf.d.ts +18 -0
  33. package/build/security/ssrf.js +109 -0
  34. package/build/security/waf.d.ts +33 -0
  35. package/build/security/waf.js +174 -0
  36. package/build/tools/coding-guard.d.ts +24 -0
  37. package/build/tools/coding-guard.js +82 -0
  38. package/build/tools/context.d.ts +39 -0
  39. package/build/tools/context.js +105 -0
  40. package/build/tools/init.d.ts +41 -1
  41. package/build/tools/init.js +124 -22
  42. package/build/tools/remember.d.ts +4 -4
  43. package/build/tools/reward.d.ts +29 -0
  44. package/build/tools/reward.js +154 -0
  45. package/build/tools/sync.js +15 -0
  46. package/package.json +9 -2
@@ -0,0 +1,105 @@
1
+ import { z } from "zod";
2
+ import { assembleContext } from "../retrieval/context.js";
3
+ import { loadConfig } from "../config.js";
4
+ import { decompress } from "../store/content.js";
5
+ import { createExperience } from "../memory/experience.js";
6
+ // ---------------------------------------------------------------------------
7
+ // get_context — smart token-efficient context retrieval
8
+ // ---------------------------------------------------------------------------
9
+ export const GetContextSchema = z.object({
10
+ query: z.string().min(1).describe("What you are working on or searching for"),
11
+ maxTokens: z.number().int().min(100).max(32000).optional()
12
+ .describe("Total token budget (default from lucid.config.json, typically 4000)"),
13
+ dirs: z.array(z.string()).optional()
14
+ .describe("Whitelist: only return files from these directories (e.g. [\"src\", \"backend\"])"),
15
+ recentOnly: z.boolean().optional()
16
+ .describe("Only return files modified within recentWindowHours"),
17
+ recentHours: z.number().optional()
18
+ .describe("Override recentWindowHours for this call"),
19
+ skeletonOnly: z.boolean().optional()
20
+ .describe("Always show skeleton (signatures only) even for small files"),
21
+ topK: z.number().int().min(1).max(50).optional()
22
+ .describe("Max files to consider (Qdrant: top-k chunks)"),
23
+ });
24
+ export async function handleGetContext(stmts, args) {
25
+ const cfg = loadConfig();
26
+ const result = await assembleContext(args.query, stmts, cfg, {
27
+ maxTokens: args.maxTokens,
28
+ dirs: args.dirs,
29
+ recentOnly: args.recentOnly,
30
+ recentHours: args.recentHours,
31
+ skeletonOnly: args.skeletonOnly,
32
+ topK: args.topK,
33
+ });
34
+ if (result.files.length === 0) {
35
+ return [
36
+ `⚠️ No relevant files found for: "${args.query}"`,
37
+ ` Strategy: ${result.strategy}`,
38
+ ` Tip: run init_project() or sync_project() first to index files`,
39
+ ].join("\n");
40
+ }
41
+ // Log experience for RL reward system
42
+ const contextFps = result.files.map((f) => f.filepath);
43
+ const expId = createExperience(args.query, contextFps, result.strategy, stmts);
44
+ const lines = [
45
+ `// get_context: "${args.query}"`,
46
+ `// Strategy: ${result.strategy} | ${result.files.length} files | ~${result.totalTokens} tokens`,
47
+ `// Experience #${expId} logged. Call reward() if this context helped, penalize() if not.`,
48
+ result.truncated ? `// ⚠️ Truncated (${result.skippedFiles} files skipped — increase maxTokens to see more)` : "",
49
+ "",
50
+ ].filter((l) => l !== undefined);
51
+ for (const f of result.files) {
52
+ lines.push(`// ─── ${f.filepath} [${f.language}] ~${f.tokens}t (${f.reason}) ───`);
53
+ lines.push(f.content);
54
+ lines.push("");
55
+ }
56
+ return lines.join("\n");
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // get_recent — recently modified files with diffs
60
+ // ---------------------------------------------------------------------------
61
+ export const GetRecentSchema = z.object({
62
+ hours: z.number().positive().optional()
63
+ .describe("Look back N hours (default 24)"),
64
+ withDiffs: z.boolean().optional()
65
+ .describe("Include line-level diffs (default true)"),
66
+ });
67
+ export function handleGetRecent(stmts, args) {
68
+ const cfg = loadConfig();
69
+ const hours = args.hours ?? cfg.recentWindowHours;
70
+ const withDiffs = args.withDiffs ?? true;
71
+ const cutoff = Math.floor(Date.now() / 1000) - hours * 3600;
72
+ const recentFiles = stmts.getRecentFiles.all(cutoff);
73
+ if (recentFiles.length === 0) {
74
+ return `No files modified in the last ${hours}h.\nTip: call sync_file(path) after each file change.`;
75
+ }
76
+ const recentDiffs = withDiffs
77
+ ? stmts.getRecentDiffs.all(cutoff)
78
+ : [];
79
+ const diffMap = new Map(recentDiffs.map((d) => [d.filepath, d]));
80
+ const lines = [
81
+ `// ${recentFiles.length} file(s) modified in the last ${hours}h`,
82
+ "",
83
+ ];
84
+ for (const f of recentFiles) {
85
+ const age = Math.round((Date.now() / 1000 - f.indexed_at) / 60);
86
+ const ageStr = age < 60 ? `${age}m ago` : `${Math.round(age / 60)}h ago`;
87
+ lines.push(`// ─── ${f.filepath} [${f.language}] (${ageStr}) ───`);
88
+ const diff = diffMap.get(f.filepath);
89
+ if (diff) {
90
+ lines.push(diff.diff_text);
91
+ }
92
+ else {
93
+ // New file — show first ~20 lines
94
+ const row = stmts.getFileByPath.get(f.filepath);
95
+ if (row) {
96
+ const src = decompress(row.content).split("\n").slice(0, 20).join("\n");
97
+ lines.push(src);
98
+ if (row.original_size > src.length)
99
+ lines.push("… [new file, showing first 20 lines]");
100
+ }
101
+ }
102
+ lines.push("");
103
+ }
104
+ return lines.join("\n");
105
+ }
@@ -2,10 +2,50 @@ import { z } from "zod";
2
2
  import type { Statements } from "../database.js";
3
3
  export declare const InitProjectSchema: z.ZodObject<{
4
4
  directory: z.ZodOptional<z.ZodString>;
5
+ /** Display name of the security admin */
6
+ adminName: z.ZodOptional<z.ZodString>;
7
+ /** Email address to send security alerts to */
8
+ adminEmail: z.ZodOptional<z.ZodString>;
9
+ /** SMTP server hostname (e.g. smtp.gmail.com) */
10
+ smtpHost: z.ZodOptional<z.ZodString>;
11
+ /** SMTP port: 587 (STARTTLS, default) or 465 (direct TLS) */
12
+ smtpPort: z.ZodOptional<z.ZodNumber>;
13
+ /** SMTP login username (often same as adminEmail) */
14
+ smtpUser: z.ZodOptional<z.ZodString>;
15
+ /** "From" display name + address (e.g. "Lucid Security <alerts@co.com>") */
16
+ smtpFrom: z.ZodOptional<z.ZodString>;
17
+ /** Generic HTTP webhook URL (receives JSON POST, HMAC-signed if LUCID_WEBHOOK_SECRET is set) */
18
+ webhookUrl: z.ZodOptional<z.ZodString>;
19
+ /** Slack incoming webhook URL */
20
+ slackWebhookUrl: z.ZodOptional<z.ZodString>;
21
+ /** Which severities trigger an alert: default ["critical","high"] */
22
+ alertOn: z.ZodOptional<z.ZodArray<z.ZodEnum<["critical", "high", "medium", "low"]>, "many">>;
23
+ /** Human-readable project name shown in alerts */
24
+ projectName: z.ZodOptional<z.ZodString>;
5
25
  }, "strip", z.ZodTypeAny, {
6
26
  directory?: string | undefined;
27
+ adminName?: string | undefined;
28
+ adminEmail?: string | undefined;
29
+ smtpHost?: string | undefined;
30
+ smtpPort?: number | undefined;
31
+ smtpUser?: string | undefined;
32
+ smtpFrom?: string | undefined;
33
+ webhookUrl?: string | undefined;
34
+ slackWebhookUrl?: string | undefined;
35
+ alertOn?: ("low" | "medium" | "high" | "critical")[] | undefined;
36
+ projectName?: string | undefined;
7
37
  }, {
8
38
  directory?: string | undefined;
39
+ adminName?: string | undefined;
40
+ adminEmail?: string | undefined;
41
+ smtpHost?: string | undefined;
42
+ smtpPort?: number | undefined;
43
+ smtpUser?: string | undefined;
44
+ smtpFrom?: string | undefined;
45
+ webhookUrl?: string | undefined;
46
+ slackWebhookUrl?: string | undefined;
47
+ alertOn?: ("low" | "medium" | "high" | "critical")[] | undefined;
48
+ projectName?: string | undefined;
9
49
  }>;
10
50
  export type InitProjectInput = z.infer<typeof InitProjectSchema>;
11
- export declare function handleInitProject(stmts: Statements, input: InitProjectInput): string;
51
+ export declare function handleInitProject(stmts: Statements, input: InitProjectInput): Promise<string>;
@@ -2,22 +2,44 @@ import { z } from "zod";
2
2
  import { resolve, join } from "path";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
  import { indexProject } from "../indexer/project.js";
5
+ import { saveAdminConfig, loadAdminConfig, isAdminConfigured, sendTestAlert, } from "../security/alerts.js";
5
6
  export const InitProjectSchema = z.object({
6
7
  directory: z.string().optional(),
8
+ // ── Admin alert configuration (asked once at project init) ──────────────
9
+ /** Display name of the security admin */
10
+ adminName: z.string().optional(),
11
+ /** Email address to send security alerts to */
12
+ adminEmail: z.string().email().optional(),
13
+ /** SMTP server hostname (e.g. smtp.gmail.com) */
14
+ smtpHost: z.string().optional(),
15
+ /** SMTP port: 587 (STARTTLS, default) or 465 (direct TLS) */
16
+ smtpPort: z.number().int().min(1).max(65535).optional(),
17
+ /** SMTP login username (often same as adminEmail) */
18
+ smtpUser: z.string().optional(),
19
+ /** "From" display name + address (e.g. "Lucid Security <alerts@co.com>") */
20
+ smtpFrom: z.string().optional(),
21
+ /** Generic HTTP webhook URL (receives JSON POST, HMAC-signed if LUCID_WEBHOOK_SECRET is set) */
22
+ webhookUrl: z.string().url().optional(),
23
+ /** Slack incoming webhook URL */
24
+ slackWebhookUrl: z.string().url().optional(),
25
+ /** Which severities trigger an alert: default ["critical","high"] */
26
+ alertOn: z.array(z.enum(["critical", "high", "medium", "low"])).optional(),
27
+ /** Human-readable project name shown in alerts */
28
+ projectName: z.string().optional(),
7
29
  });
8
- // ---------------------------------------------------------------------------
9
- // Instalează PostToolUse hook în .claude/settings.json
10
- // ---------------------------------------------------------------------------
30
+ const LUCID_MARKER = "Lucid: call sync_file";
31
+ const LUCID_HOOK = {
32
+ matcher: { tools: ["Write", "Edit", "NotebookEdit"] },
33
+ hooks: [
34
+ {
35
+ type: "command",
36
+ command: `echo '🔄 ${LUCID_MARKER}(path) to keep knowledge graph up to date'`,
37
+ },
38
+ ],
39
+ };
11
40
  function installHook(dir) {
12
41
  const claudeDir = join(dir, ".claude");
13
42
  const settingsPath = join(claudeDir, "settings.json");
14
- const HOOK_CMD = 'node -e "const p=process.argv[1]; if(p) require(\'child_process\').execSync(\'node \'+require(\'path\').resolve(\'node_modules/.bin/lucid\'||\'\')+\' --noop\', {stdio:\'ignore\'})" "$TOOL_INPUT_PATH" 2>/dev/null || true';
15
- // Hook mai simplu și portabil: apelează sync_file prin claude mcp
16
- const HOOK = {
17
- matcher: "Write|Edit|NotebookEdit",
18
- command: "echo '{\"tool\":\"sync_file\",\"path\":\"'\"$TOOL_INPUT_PATH\"'\"}' | true",
19
- };
20
- // Citim sau cream settings.json
21
43
  let settings = {};
22
44
  if (existsSync(settingsPath)) {
23
45
  try {
@@ -27,19 +49,17 @@ function installHook(dir) {
27
49
  return { installed: false, reason: "Could not parse .claude/settings.json" };
28
50
  }
29
51
  }
30
- // Verifică dacă hook-ul e deja instalat
31
- const hooks = settings["hooks"] ?? {};
52
+ const hooks = (settings["hooks"] ?? {});
32
53
  const postToolUse = hooks["PostToolUse"] ?? [];
33
- const alreadyInstalled = postToolUse.some((h) => h.matcher?.includes("Write") && String(h).includes("lucid"));
54
+ // Detectează atât formatul vechi cât și cel nou
55
+ const alreadyInstalled = postToolUse.some((h) => {
56
+ const cmd = h.command ?? h.hooks?.[0]?.command ?? "";
57
+ return cmd.includes(LUCID_MARKER);
58
+ });
34
59
  if (alreadyInstalled) {
35
60
  return { installed: false, reason: "already installed" };
36
61
  }
37
- // Adaugă hook-ul — notifică Claude să cheme sync_file
38
- const lucidHook = {
39
- matcher: "Write|Edit|NotebookEdit",
40
- command: "echo '🔄 Lucid: call sync_file(path) to keep knowledge graph up to date'",
41
- };
42
- hooks["PostToolUse"] = [...postToolUse, lucidHook];
62
+ hooks["PostToolUse"] = [...postToolUse, LUCID_HOOK];
43
63
  settings["hooks"] = hooks;
44
64
  mkdirSync(claudeDir, { recursive: true });
45
65
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
@@ -74,7 +94,7 @@ function injectClaudeMdInstruction(dir) {
74
94
  // ---------------------------------------------------------------------------
75
95
  // Main handler
76
96
  // ---------------------------------------------------------------------------
77
- export function handleInitProject(stmts, input) {
97
+ export async function handleInitProject(stmts, input) {
78
98
  const dir = resolve(input.directory ?? process.cwd());
79
99
  const results = indexProject(dir, stmts);
80
100
  const lines = [`✅ Project indexed: ${dir}`, ``];
@@ -88,7 +108,7 @@ export function handleInitProject(stmts, input) {
88
108
  lines.push(` • [${r.type}] "${r.entity}" — ${r.observations} observation(s) from ${r.source}`);
89
109
  }
90
110
  }
91
- // Instalează hook PostToolUse
111
+ // ── Hook PostToolUse ──────────────────────────────────────────────────────
92
112
  lines.push(``);
93
113
  const hookResult = installHook(dir);
94
114
  if (hookResult.installed) {
@@ -98,13 +118,95 @@ export function handleInitProject(stmts, input) {
98
118
  else {
99
119
  lines.push(`🔗 Hook: ${hookResult.reason}`);
100
120
  }
101
- // Injectează instrucțiune în CLAUDE.md
121
+ // ── CLAUDE.md injection ───────────────────────────────────────────────────
102
122
  const injected = injectClaudeMdInstruction(dir);
103
123
  if (injected) {
104
124
  lines.push(`📋 CLAUDE.md updated with sync_file() instruction`);
105
125
  }
126
+ // ── Security admin configuration ──────────────────────────────────────────
127
+ lines.push(``);
128
+ lines.push(`🔒 Security Alerts`);
129
+ // Save any admin params provided in this call
130
+ const adminFields = {
131
+ adminName: input.adminName,
132
+ adminEmail: input.adminEmail,
133
+ smtpHost: input.smtpHost,
134
+ smtpPort: input.smtpPort,
135
+ smtpUser: input.smtpUser,
136
+ smtpFrom: input.smtpFrom,
137
+ webhookUrl: input.webhookUrl,
138
+ slackWebhookUrl: input.slackWebhookUrl,
139
+ alertOn: input.alertOn,
140
+ projectName: input.projectName ?? results.find((r) => r.type === "project")?.entity,
141
+ };
142
+ const hasNewAdmin = Object.values(adminFields).some((v) => v !== undefined);
143
+ if (hasNewAdmin) {
144
+ // Strip undefined values before saving
145
+ const clean = Object.fromEntries(Object.entries(adminFields).filter(([, v]) => v !== undefined));
146
+ saveAdminConfig(dir, clean);
147
+ lines.push(` Saved admin config → .claude/lucid-admin.json`);
148
+ // Test alert channels
149
+ const testResults = await sendTestAlert(dir);
150
+ lines.push(` Test alert results:`);
151
+ for (const r of testResults)
152
+ lines.push(` ${r}`);
153
+ }
154
+ else {
155
+ // Check existing config
156
+ const existing = loadAdminConfig(dir);
157
+ if (isAdminConfigured()) {
158
+ lines.push(` Admin: ${existing.adminName ?? existing.adminEmail ?? "configured"}`);
159
+ lines.push(` Channels: ${buildChannelSummary(existing)}`);
160
+ lines.push(` Alerting on: ${(existing.alertOn ?? ["critical", "high"]).join(", ")}`);
161
+ }
162
+ else {
163
+ // Not configured — prompt user
164
+ lines.push(``);
165
+ lines.push(` ⚠️ No security admin configured. Security alerts will only appear in logs.`);
166
+ lines.push(``);
167
+ lines.push(` To enable alerts, re-run init_project() with admin parameters:`);
168
+ lines.push(``);
169
+ lines.push(` Minimal (webhook only):`);
170
+ lines.push(` init_project(`);
171
+ lines.push(` adminName="Your Name",`);
172
+ lines.push(` adminEmail="admin@yourcompany.com",`);
173
+ lines.push(` webhookUrl="https://hooks.yourservice.com/...",`);
174
+ lines.push(` )`);
175
+ lines.push(``);
176
+ lines.push(` With Slack:`);
177
+ lines.push(` init_project(`);
178
+ lines.push(` adminName="Your Name",`);
179
+ lines.push(` adminEmail="admin@yourcompany.com",`);
180
+ lines.push(` slackWebhookUrl="https://hooks.slack.com/services/...",`);
181
+ lines.push(` )`);
182
+ lines.push(``);
183
+ lines.push(` With Email (SMTP):`);
184
+ lines.push(` init_project(`);
185
+ lines.push(` adminName="Your Name",`);
186
+ lines.push(` adminEmail="admin@yourcompany.com",`);
187
+ lines.push(` smtpHost="smtp.gmail.com",`);
188
+ lines.push(` smtpPort=587,`);
189
+ lines.push(` smtpUser="alerts@yourcompany.com",`);
190
+ lines.push(` )`);
191
+ lines.push(` # Then set in your environment:`);
192
+ lines.push(` export LUCID_SMTP_PASS="your-app-password"`);
193
+ lines.push(``);
194
+ lines.push(` SMTP password must be in LUCID_SMTP_PASS env var (never as a parameter).`);
195
+ lines.push(` Webhook HMAC signing: set LUCID_WEBHOOK_SECRET env var.`);
196
+ }
197
+ }
106
198
  lines.push(``);
107
199
  lines.push(`From now on, call sync_file(path) after every file you write or edit.`);
108
200
  lines.push(`Use recall() to query accumulated project knowledge.`);
109
201
  return lines.join("\n");
110
202
  }
203
+ function buildChannelSummary(cfg) {
204
+ const channels = [];
205
+ if (cfg.adminEmail && cfg.smtpHost)
206
+ channels.push(`email(${cfg.adminEmail})`);
207
+ if (cfg.webhookUrl)
208
+ channels.push(`webhook`);
209
+ if (cfg.slackWebhookUrl)
210
+ channels.push(`slack`);
211
+ return channels.length > 0 ? channels.join(", ") : "none";
212
+ }
@@ -5,13 +5,13 @@ export declare const RememberSchema: z.ZodObject<{
5
5
  entityType: z.ZodEnum<["person", "project", "decision", "pattern", "tool", "config", "bug", "convention"]>;
6
6
  observation: z.ZodString;
7
7
  }, "strip", z.ZodTypeAny, {
8
- entity: string;
9
- entityType: "person" | "project" | "decision" | "pattern" | "tool" | "config" | "bug" | "convention";
10
8
  observation: string;
11
- }, {
12
9
  entity: string;
13
- entityType: "person" | "project" | "decision" | "pattern" | "tool" | "config" | "bug" | "convention";
10
+ entityType: "tool" | "pattern" | "person" | "project" | "decision" | "config" | "bug" | "convention";
11
+ }, {
14
12
  observation: string;
13
+ entity: string;
14
+ entityType: "tool" | "pattern" | "person" | "project" | "decision" | "config" | "bug" | "convention";
15
15
  }>;
16
16
  export type RememberInput = z.infer<typeof RememberSchema>;
17
17
  export declare function remember(stmts: Statements, input: RememberInput): string;
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ import type { Statements } from "../database.js";
3
+ export declare const RewardSchema: z.ZodObject<{
4
+ note: z.ZodOptional<z.ZodString>;
5
+ }, "strip", z.ZodTypeAny, {
6
+ note?: string | undefined;
7
+ }, {
8
+ note?: string | undefined;
9
+ }>;
10
+ export declare const PenalizeSchema: z.ZodObject<{
11
+ note: z.ZodOptional<z.ZodString>;
12
+ }, "strip", z.ZodTypeAny, {
13
+ note?: string | undefined;
14
+ }, {
15
+ note?: string | undefined;
16
+ }>;
17
+ export declare const ShowRewardsSchema: z.ZodObject<{
18
+ query: z.ZodOptional<z.ZodString>;
19
+ topK: z.ZodOptional<z.ZodNumber>;
20
+ }, "strip", z.ZodTypeAny, {
21
+ query?: string | undefined;
22
+ topK?: number | undefined;
23
+ }, {
24
+ query?: string | undefined;
25
+ topK?: number | undefined;
26
+ }>;
27
+ export declare function handleReward(stmts: Statements, args: z.infer<typeof RewardSchema>): string;
28
+ export declare function handlePenalize(stmts: Statements, args: z.infer<typeof PenalizeSchema>): string;
29
+ export declare function handleShowRewards(stmts: Statements, args: z.infer<typeof ShowRewardsSchema>): string;
@@ -0,0 +1,154 @@
1
+ // Reward / penalize / show_rewards tool handlers
2
+ import { z } from "zod";
3
+ import { getLastExperienceId, rewardExperience, decayedReward, } from "../memory/experience.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Schemas
6
+ // ---------------------------------------------------------------------------
7
+ export const RewardSchema = z.object({
8
+ note: z.string().optional().describe("Optional note about what worked"),
9
+ });
10
+ export const PenalizeSchema = z.object({
11
+ note: z.string().optional().describe("Optional note about what was missing or wrong"),
12
+ });
13
+ export const ShowRewardsSchema = z.object({
14
+ query: z.string().optional().describe("Filter experiences by query text"),
15
+ topK: z.number().int().min(1).max(50).optional().describe("Number of top results to show (default 10)"),
16
+ });
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+ function formatAge(unixSec) {
21
+ const diffSec = Math.floor(Date.now() / 1000) - unixSec;
22
+ if (diffSec < 3600)
23
+ return `${Math.round(diffSec / 60)}m ago`;
24
+ if (diffSec < 86400)
25
+ return `${Math.round(diffSec / 3600)}h ago`;
26
+ return `${Math.round(diffSec / 86400)} days ago`;
27
+ }
28
+ function parseFps(contextFps) {
29
+ try {
30
+ return JSON.parse(contextFps);
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // reward — explicit +1 on the last experience
38
+ // ---------------------------------------------------------------------------
39
+ export function handleReward(stmts, args) {
40
+ const lastId = getLastExperienceId();
41
+ if (lastId === null) {
42
+ return "❌ No recent get_context() call to reward. Call get_context() first.";
43
+ }
44
+ const result = rewardExperience(lastId, 1.0, args.note ?? null, stmts);
45
+ if (!result) {
46
+ return `❌ Experience #${lastId} not found in database.`;
47
+ }
48
+ const lines = [
49
+ `✅ Experience #${lastId} rewarded (+1)`,
50
+ ` Query: "${result.query}"`,
51
+ ];
52
+ if (args.note)
53
+ lines.push(` Note: ${args.note}`);
54
+ if (result.fps.length > 0) {
55
+ lines.push(` Rewarded files:`);
56
+ for (const fp of result.fps.slice(0, 5)) {
57
+ lines.push(` +1.0 ${fp}`);
58
+ }
59
+ if (result.fps.length > 5)
60
+ lines.push(` … and ${result.fps.length - 5} more`);
61
+ }
62
+ // Show accumulated reward for these files
63
+ const allRewards = stmts.getFileRewards.all();
64
+ const relevant = allRewards.filter((fr) => result.fps.includes(fr.filepath));
65
+ if (relevant.length > 0) {
66
+ const total = relevant.reduce((sum, fr) => sum + fr.total_reward, 0);
67
+ lines.push(` Total reward score for these files: ${total.toFixed(1)} (accumulated)`);
68
+ }
69
+ return lines.join("\n");
70
+ }
71
+ // ---------------------------------------------------------------------------
72
+ // penalize — explicit -1 on the last experience
73
+ // ---------------------------------------------------------------------------
74
+ export function handlePenalize(stmts, args) {
75
+ const lastId = getLastExperienceId();
76
+ if (lastId === null) {
77
+ return "❌ No recent get_context() call to penalize. Call get_context() first.";
78
+ }
79
+ const result = rewardExperience(lastId, -1.0, args.note ?? null, stmts);
80
+ if (!result) {
81
+ return `❌ Experience #${lastId} not found in database.`;
82
+ }
83
+ const lines = [
84
+ `❌ Experience #${lastId} penalized (-1)`,
85
+ ` Query: "${result.query}"`,
86
+ ];
87
+ if (args.note)
88
+ lines.push(` Note: ${args.note}`);
89
+ if (result.fps.length > 0) {
90
+ lines.push(` Penalized files:`);
91
+ for (const fp of result.fps.slice(0, 5)) {
92
+ lines.push(` -1.0 ${fp}`);
93
+ }
94
+ if (result.fps.length > 5)
95
+ lines.push(` … and ${result.fps.length - 5} more`);
96
+ }
97
+ return lines.join("\n");
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // show_rewards — display top experiences + most rewarded files
101
+ // ---------------------------------------------------------------------------
102
+ export function handleShowRewards(stmts, args) {
103
+ const topK = args.topK ?? 10;
104
+ const lines = [];
105
+ // Get experiences
106
+ let experiences;
107
+ if (args.query) {
108
+ try {
109
+ // FTS search: append * for prefix match
110
+ const ftsQuery = args.query.trim().split(/\s+/).map((t) => `${t}*`).join(" ");
111
+ experiences = stmts.searchExperiencesFTS.all(ftsQuery, topK);
112
+ }
113
+ catch {
114
+ // Fall back to top by reward if FTS query is malformed
115
+ experiences = stmts.getTopExperiences.all(topK);
116
+ }
117
+ }
118
+ else {
119
+ experiences = stmts.getTopExperiences.all(topK);
120
+ }
121
+ if (experiences.length === 0) {
122
+ lines.push("No rewarded experiences yet. Use get_context() then reward() to start building context memory.");
123
+ }
124
+ else {
125
+ lines.push("🏆 Top rewarded experiences (decayed):", "");
126
+ for (const exp of experiences) {
127
+ const decayed = decayedReward(exp.reward, exp.rewarded_at);
128
+ const ageStr = exp.rewarded_at ? formatAge(exp.rewarded_at) : "never rewarded";
129
+ const sign = exp.reward >= 0 ? "+" : "";
130
+ lines.push(`#${exp.id} ${sign}${decayed.toFixed(1)} "${exp.query}" (${ageStr})`);
131
+ const fps = parseFps(exp.context_fps);
132
+ if (fps.length > 0) {
133
+ const shown = fps.slice(0, 3).join(", ");
134
+ const extra = fps.length > 3 ? ` +${fps.length - 3} more` : "";
135
+ lines.push(` → ${shown}${extra}`);
136
+ }
137
+ if (exp.feedback)
138
+ lines.push(` note: ${exp.feedback}`);
139
+ }
140
+ }
141
+ // Most rewarded files (decayed)
142
+ lines.push("", "📁 Most rewarded files:");
143
+ const topFiles = stmts.getTopFileRewards.all(topK);
144
+ if (topFiles.length === 0) {
145
+ lines.push(" (none yet)");
146
+ }
147
+ else {
148
+ topFiles.forEach((fr, i) => {
149
+ const decayed = decayedReward(fr.total_reward, fr.last_rewarded);
150
+ lines.push(` ${i + 1}. ${fr.filepath} reward=${decayed.toFixed(1)} used=${fr.use_count}x`);
151
+ });
152
+ }
153
+ return lines.join("\n");
154
+ }
@@ -3,6 +3,9 @@ import { resolve, extname } from "path";
3
3
  import { existsSync, readFileSync } from "fs";
4
4
  import { indexFile, upsertFileIndex } from "../indexer/file.js";
5
5
  import { indexProject } from "../indexer/project.js";
6
+ import { computeDiff } from "../retrieval/context.js";
7
+ import { decompress } from "../store/content.js";
8
+ import { implicitRewardFromSync } from "../memory/experience.js";
6
9
  const SUPPORTED_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs"]);
7
10
  // ---------------------------------------------------------------------------
8
11
  // sync_file
@@ -21,10 +24,18 @@ export function handleSyncFile(stmts, args) {
21
24
  if (!index)
22
25
  return `Could not read file: ${filepath}`;
23
26
  const source = readFileSync(filepath, "utf-8");
27
+ // Capture previous content before upsert (for diff)
28
+ const prevRow = stmts.getFileByPath.get(filepath);
29
+ const prevSource = prevRow ? decompress(prevRow.content) : null;
24
30
  const result = upsertFileIndex(index, source, stmts);
25
31
  if (!result.stored) {
26
32
  return `⏭️ Unchanged: ${filepath} (hash match — skipped)`;
27
33
  }
34
+ // Store diff when file changed
35
+ if (prevRow && prevSource !== null) {
36
+ const diff = computeDiff(prevSource, source);
37
+ stmts.upsertDiff.run(filepath, prevRow.content_hash, diff);
38
+ }
28
39
  const ratio = Math.round((1 - (result.savedBytes + Buffer.byteLength(source, "utf-8") - result.savedBytes) / Buffer.byteLength(source, "utf-8")) * 100);
29
40
  const saved = Math.round(result.savedBytes / 1024 * 10) / 10;
30
41
  const lines = [
@@ -36,6 +47,10 @@ export function handleSyncFile(stmts, args) {
36
47
  lines.push(` description: ${index.description}`);
37
48
  if (index.todos.length > 0)
38
49
  lines.push(` TODOs: ${index.todos.length} open`);
50
+ // Implicit reward: if this file was in the last get_context result → +0.3
51
+ const implicitRewarded = implicitRewardFromSync(filepath, stmts);
52
+ if (implicitRewarded)
53
+ lines.push(` 🎯 Implicit reward +0.3 (file was in recent context)`);
39
54
  return lines.join("\n");
40
55
  }
41
56
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@a13xu/lucid",
3
- "version": "1.4.0",
4
- "description": "Persistent memory for Claude Code agents — SQLite + FTS5 knowledge graph via MCP",
3
+ "version": "1.9.1",
4
+ "description": "Token-efficient memory, code indexing, and validation for Claude Code agents — SQLite + FTS5, TF-IDF + Qdrant retrieval, AST skeleton pruning, diff-aware context, Logic Guardian drift detection",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "lucid": "./build/index.js"
@@ -22,6 +22,13 @@
22
22
  "memory",
23
23
  "sqlite",
24
24
  "knowledge-graph",
25
+ "code-indexing",
26
+ "logic-guardian",
27
+ "drift-detection",
28
+ "tfidf",
29
+ "qdrant",
30
+ "token-optimization",
31
+ "context-pruning",
25
32
  "ai",
26
33
  "anthropic"
27
34
  ],