@a13xu/lucid 1.1.0 → 1.9.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.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +221 -99
  3. package/build/config.d.ts +37 -0
  4. package/build/config.js +45 -0
  5. package/build/database.d.ts +54 -0
  6. package/build/database.js +175 -62
  7. package/build/guardian/checklist.js +66 -66
  8. package/build/guardian/coding-analyzer.d.ts +11 -0
  9. package/build/guardian/coding-analyzer.js +393 -0
  10. package/build/guardian/coding-rules.d.ts +1 -0
  11. package/build/guardian/coding-rules.js +97 -0
  12. package/build/index.js +241 -2
  13. package/build/indexer/ast.d.ts +9 -0
  14. package/build/indexer/ast.js +158 -0
  15. package/build/indexer/file.d.ts +15 -0
  16. package/build/indexer/file.js +100 -0
  17. package/build/indexer/project.d.ts +8 -0
  18. package/build/indexer/project.js +320 -0
  19. package/build/memory/experience.d.ts +11 -0
  20. package/build/memory/experience.js +85 -0
  21. package/build/retrieval/context.d.ts +29 -0
  22. package/build/retrieval/context.js +219 -0
  23. package/build/retrieval/qdrant.d.ts +16 -0
  24. package/build/retrieval/qdrant.js +135 -0
  25. package/build/retrieval/tfidf.d.ts +14 -0
  26. package/build/retrieval/tfidf.js +64 -0
  27. package/build/security/alerts.d.ts +44 -0
  28. package/build/security/alerts.js +228 -0
  29. package/build/security/env.d.ts +24 -0
  30. package/build/security/env.js +85 -0
  31. package/build/security/guard.d.ts +35 -0
  32. package/build/security/guard.js +133 -0
  33. package/build/security/ratelimit.d.ts +34 -0
  34. package/build/security/ratelimit.js +105 -0
  35. package/build/security/smtp.d.ts +26 -0
  36. package/build/security/smtp.js +125 -0
  37. package/build/security/ssrf.d.ts +18 -0
  38. package/build/security/ssrf.js +109 -0
  39. package/build/security/waf.d.ts +33 -0
  40. package/build/security/waf.js +174 -0
  41. package/build/store/content.d.ts +3 -0
  42. package/build/store/content.js +11 -0
  43. package/build/tools/coding-guard.d.ts +24 -0
  44. package/build/tools/coding-guard.js +82 -0
  45. package/build/tools/context.d.ts +39 -0
  46. package/build/tools/context.js +105 -0
  47. package/build/tools/grep.d.ts +17 -0
  48. package/build/tools/grep.js +65 -0
  49. package/build/tools/init.d.ts +51 -0
  50. package/build/tools/init.js +212 -0
  51. package/build/tools/remember.d.ts +4 -4
  52. package/build/tools/reward.d.ts +29 -0
  53. package/build/tools/reward.js +154 -0
  54. package/build/tools/sync.d.ts +18 -0
  55. package/build/tools/sync.js +76 -0
  56. package/package.json +55 -48
@@ -0,0 +1,320 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs";
2
+ import { join, extname, basename } from "path";
3
+ // ---------------------------------------------------------------------------
4
+ // Helpers
5
+ // ---------------------------------------------------------------------------
6
+ function readFile(path) {
7
+ try {
8
+ return readFileSync(path, { encoding: "utf-8" });
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ function upsert(stmts, name, type, observations) {
15
+ const existing = stmts.getEntityByName.get(name);
16
+ if (existing) {
17
+ const current = JSON.parse(existing.observations);
18
+ const merged = [...current];
19
+ for (const obs of observations) {
20
+ if (!merged.includes(obs))
21
+ merged.push(obs);
22
+ }
23
+ stmts.updateEntity.run(JSON.stringify(merged), existing.id);
24
+ }
25
+ else {
26
+ stmts.insertEntity.run(name, type, JSON.stringify(observations));
27
+ }
28
+ }
29
+ function relate(stmts, from, to, type) {
30
+ const fromRow = stmts.getEntityByName.get(from);
31
+ const toRow = stmts.getEntityByName.get(to);
32
+ if (fromRow && toRow) {
33
+ stmts.insertRelation.run(fromRow.id, toRow.id, type);
34
+ }
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Parsers per file type
38
+ // ---------------------------------------------------------------------------
39
+ function indexClaudeMd(path, stmts, results) {
40
+ const content = readFile(path);
41
+ if (!content)
42
+ return;
43
+ // Indexează fiecare secțiune H2 ca observație separată pe entitatea proiectului
44
+ const sections = content.split(/\n##\s+/).filter(Boolean);
45
+ const observations = [];
46
+ for (const section of sections) {
47
+ const lines = section.trim().split("\n");
48
+ const title = lines[0]?.trim() ?? "directive";
49
+ const body = lines.slice(1).join("\n").trim();
50
+ if (body.length > 0) {
51
+ observations.push(`[${title}] ${body.slice(0, 300)}`);
52
+ }
53
+ }
54
+ if (observations.length > 0) {
55
+ upsert(stmts, "CLAUDE.md directives", "convention", observations);
56
+ results.push({ entity: "CLAUDE.md directives", type: "convention", observations: observations.length, source: "CLAUDE.md" });
57
+ }
58
+ }
59
+ function indexPackageJson(path, stmts, results) {
60
+ const content = readFile(path);
61
+ if (!content)
62
+ return;
63
+ let pkg;
64
+ try {
65
+ pkg = JSON.parse(content);
66
+ }
67
+ catch {
68
+ return;
69
+ }
70
+ const name = pkg["name"] ?? "project";
71
+ const version = pkg["version"] ?? "unknown";
72
+ const description = pkg["description"] ?? "";
73
+ const projectName = name.replace(/^@[\w-]+\//, ""); // strip scope
74
+ const obs = [`version: ${version}`];
75
+ if (description)
76
+ obs.push(`description: ${description}`);
77
+ // Scripts
78
+ const scripts = pkg["scripts"];
79
+ if (scripts) {
80
+ for (const [k, v] of Object.entries(scripts).slice(0, 6)) {
81
+ obs.push(`script ${k}: ${v}`);
82
+ }
83
+ }
84
+ upsert(stmts, projectName, "project", obs);
85
+ results.push({ entity: projectName, type: "project", observations: obs.length, source: "package.json" });
86
+ // Dependențe principale
87
+ const deps = {
88
+ ...(pkg["dependencies"] ?? {}),
89
+ ...(pkg["devDependencies"] ?? {}),
90
+ };
91
+ for (const [dep, ver] of Object.entries(deps).slice(0, 20)) {
92
+ upsert(stmts, dep, "tool", [`version: ${ver}`, `used in: ${projectName}`]);
93
+ relate(stmts, projectName, dep, "depends_on");
94
+ }
95
+ if (Object.keys(deps).length > 0) {
96
+ results.push({ entity: `${Object.keys(deps).length} dependencies`, type: "tool", observations: 1, source: "package.json" });
97
+ }
98
+ }
99
+ function indexPyprojectToml(path, stmts, results) {
100
+ const content = readFile(path);
101
+ if (!content)
102
+ return;
103
+ const nameMatch = content.match(/^name\s*=\s*["']([^"']+)["']/m);
104
+ const versionMatch = content.match(/^version\s*=\s*["']([^"']+)["']/m);
105
+ const descMatch = content.match(/^description\s*=\s*["']([^"']+)["']/m);
106
+ const name = nameMatch?.[1] ?? "project";
107
+ const obs = [];
108
+ if (versionMatch?.[1])
109
+ obs.push(`version: ${versionMatch[1]}`);
110
+ if (descMatch?.[1])
111
+ obs.push(`description: ${descMatch[1]}`);
112
+ // Dependencies
113
+ const depsSection = content.match(/\[tool\.poetry\.dependencies\]([\s\S]*?)(?=\[|$)/)?.[1]
114
+ ?? content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/)?.[1]
115
+ ?? "";
116
+ const deps = [...depsSection.matchAll(/["']?([\w-]+)["']?\s*[=:]/g)].map((m) => m[1]).filter(Boolean);
117
+ for (const dep of deps.slice(0, 20)) {
118
+ upsert(stmts, dep, "tool", [`used in: ${name}`]);
119
+ relate(stmts, name, dep, "depends_on");
120
+ }
121
+ upsert(stmts, name, "project", obs);
122
+ results.push({ entity: name, type: "project", observations: obs.length, source: "pyproject.toml" });
123
+ }
124
+ function indexReadme(path, projectName, stmts, results) {
125
+ const content = readFile(path);
126
+ if (!content)
127
+ return;
128
+ // Prima secțiune (descriere)
129
+ const firstParagraph = content.replace(/^#[^\n]*\n/, "").trim().split("\n\n")[0] ?? "";
130
+ if (firstParagraph.length < 10)
131
+ return;
132
+ upsert(stmts, projectName, "project", [
133
+ `README: ${firstParagraph.slice(0, 400)}`,
134
+ ]);
135
+ results.push({ entity: projectName, type: "project", observations: 1, source: "README.md" });
136
+ }
137
+ function indexMcpJson(path, stmts, results) {
138
+ const content = readFile(path);
139
+ if (!content)
140
+ return;
141
+ let cfg;
142
+ try {
143
+ cfg = JSON.parse(content);
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ const servers = cfg.mcpServers ?? {};
149
+ for (const [serverName, config] of Object.entries(servers)) {
150
+ const c = config;
151
+ const obs = [`MCP server configured in project`];
152
+ if (c.command)
153
+ obs.push(`command: ${c.command} ${(c.args ?? []).join(" ")}`);
154
+ upsert(stmts, serverName, "tool", obs);
155
+ results.push({ entity: serverName, type: "tool", observations: obs.length, source: ".mcp.json" });
156
+ }
157
+ }
158
+ function indexLogicGuardianYaml(path, stmts, results) {
159
+ const content = readFile(path);
160
+ if (!content)
161
+ return;
162
+ // Extrage known_drift_patterns
163
+ const patternMatches = [...content.matchAll(/id:\s*["']?(DRIFT-\d+)["']?\s*\n\s*name:\s*["']?([^\n"']+)["']?\s*\n\s*description:\s*["']?([^\n"']+)/g)];
164
+ for (const m of patternMatches) {
165
+ const [, id, name, desc] = m;
166
+ upsert(stmts, `${id}: ${name.trim()}`, "pattern", [
167
+ `drift pattern: ${desc.trim()}`,
168
+ "source: logic-guardian.yaml",
169
+ ]);
170
+ }
171
+ // Invarianți de proiect
172
+ const invariantsMatch = content.match(/project_invariants:([\s\S]*?)(?=\n\w|\n#|$)/);
173
+ if (invariantsMatch) {
174
+ const invariants = [...invariantsMatch[1].matchAll(/-\s+"([^"]+)"/g)].map((m) => m[1]);
175
+ if (invariants.length > 0) {
176
+ upsert(stmts, "project invariants", "convention", invariants);
177
+ results.push({ entity: "project invariants", type: "convention", observations: invariants.length, source: "logic-guardian.yaml" });
178
+ }
179
+ }
180
+ if (patternMatches.length > 0) {
181
+ results.push({ entity: `${patternMatches.length} drift patterns`, type: "pattern", observations: patternMatches.length, source: "logic-guardian.yaml" });
182
+ }
183
+ }
184
+ // Source file indexing — extrage exporturi, clase, funcții principale
185
+ const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs"]);
186
+ const SKIP_DIRS = new Set(["node_modules", ".git", "build", "dist", "__pycache__", ".next", "venv", ".venv", "target", ".cache", "coverage", ".nyc_output"]);
187
+ const MAX_SOURCE_FILES = 10_000;
188
+ function indexSourceFile(filepath, rootDir, projectName, stmts) {
189
+ const content = readFile(filepath);
190
+ if (!content)
191
+ return [];
192
+ const exports = [];
193
+ const lang = extname(filepath);
194
+ // TypeScript / JavaScript
195
+ if ([".ts", ".tsx", ".js", ".jsx"].includes(lang)) {
196
+ for (const m of content.matchAll(/export\s+(?:async\s+)?(?:function|class|const|type|interface)\s+(\w+)/g)) {
197
+ exports.push(m[1]);
198
+ }
199
+ }
200
+ // Python
201
+ if (lang === ".py") {
202
+ for (const m of content.matchAll(/^(?:def|class|async def)\s+(\w+)/gm)) {
203
+ if (!m[1].startsWith("_"))
204
+ exports.push(m[1]);
205
+ }
206
+ }
207
+ if (exports.length === 0)
208
+ return [];
209
+ // Cale relativă față de rădăcina proiectului
210
+ const relPath = filepath.replace(/\\/g, "/").replace(rootDir.replace(/\\/g, "/") + "/", "");
211
+ const obs = [`exports from ${relPath}: ${exports.slice(0, 10).join(", ")}`];
212
+ upsert(stmts, projectName, "project", obs);
213
+ return exports;
214
+ }
215
+ function scanSources(dir, projectName, stmts, results) {
216
+ const rootDir = dir.replace(/\\/g, "/");
217
+ let fileCount = 0;
218
+ const exportedSymbols = [];
219
+ function walk(d) {
220
+ if (fileCount >= MAX_SOURCE_FILES)
221
+ return;
222
+ let entries;
223
+ try {
224
+ entries = readdirSync(d);
225
+ }
226
+ catch {
227
+ return;
228
+ }
229
+ for (const entry of entries) {
230
+ if (SKIP_DIRS.has(entry))
231
+ continue;
232
+ const full = join(d, entry);
233
+ let stat;
234
+ try {
235
+ stat = statSync(full);
236
+ }
237
+ catch {
238
+ continue;
239
+ }
240
+ if (stat.isDirectory()) {
241
+ walk(full);
242
+ }
243
+ else if (SOURCE_EXTS.has(extname(entry).toLowerCase())) {
244
+ const syms = indexSourceFile(full, rootDir, projectName, stmts);
245
+ exportedSymbols.push(...syms);
246
+ fileCount++;
247
+ if (fileCount >= MAX_SOURCE_FILES)
248
+ return;
249
+ }
250
+ }
251
+ }
252
+ walk(dir);
253
+ if (fileCount > 0) {
254
+ results.push({
255
+ entity: projectName,
256
+ type: "project",
257
+ observations: fileCount,
258
+ source: `${fileCount} source files (${exportedSymbols.length} exports)`,
259
+ });
260
+ }
261
+ }
262
+ // ---------------------------------------------------------------------------
263
+ // Main entry point
264
+ // ---------------------------------------------------------------------------
265
+ export function indexProject(directory, stmts) {
266
+ const results = [];
267
+ const dir = directory.replace(/\\/g, "/");
268
+ // Detectează numele proiectului din package.json sau pyproject.toml
269
+ let projectName = basename(dir);
270
+ const pkgPath = join(dir, "package.json");
271
+ if (existsSync(pkgPath)) {
272
+ const raw = readFile(pkgPath);
273
+ if (raw) {
274
+ try {
275
+ const pkg = JSON.parse(raw);
276
+ if (pkg.name)
277
+ projectName = pkg.name.replace(/^@[\w-]+\//, "");
278
+ }
279
+ catch { /* ignore */ }
280
+ }
281
+ }
282
+ // 1. CLAUDE.md — cel mai important
283
+ const claudeMdPaths = ["CLAUDE.md", ".claude/CLAUDE.md", "claude.md"];
284
+ for (const p of claudeMdPaths) {
285
+ const full = join(dir, p);
286
+ if (existsSync(full)) {
287
+ indexClaudeMd(full, stmts, results);
288
+ break;
289
+ }
290
+ }
291
+ // 2. package.json
292
+ if (existsSync(join(dir, "package.json"))) {
293
+ indexPackageJson(join(dir, "package.json"), stmts, results);
294
+ }
295
+ // 3. pyproject.toml
296
+ if (existsSync(join(dir, "pyproject.toml"))) {
297
+ indexPyprojectToml(join(dir, "pyproject.toml"), stmts, results);
298
+ }
299
+ // 4. README
300
+ for (const p of ["README.md", "readme.md", "Readme.md"]) {
301
+ if (existsSync(join(dir, p))) {
302
+ indexReadme(join(dir, p), projectName, stmts, results);
303
+ break;
304
+ }
305
+ }
306
+ // 5. MCP config
307
+ for (const p of [".mcp.json", "mcp.json"]) {
308
+ if (existsSync(join(dir, p))) {
309
+ indexMcpJson(join(dir, p), stmts, results);
310
+ break;
311
+ }
312
+ }
313
+ // 6. Logic Guardian config
314
+ if (existsSync(join(dir, "logic-guardian.yaml"))) {
315
+ indexLogicGuardianYaml(join(dir, "logic-guardian.yaml"), stmts, results);
316
+ }
317
+ // 7. Surse
318
+ scanSources(dir, projectName, stmts, results);
319
+ return results;
320
+ }
@@ -0,0 +1,11 @@
1
+ import type { Statements } from "../database.js";
2
+ export declare const getLastExperienceId: () => number | null;
3
+ export declare const setLastExperienceId: (id: number) => void;
4
+ export declare function decayedReward(reward: number, rewardedAt: number | null): number;
5
+ export declare function createExperience(query: string, contextFps: string[], strategy: string, stmts: Statements): number;
6
+ export declare function rewardExperience(id: number, delta: number, feedback: string | null, stmts: Statements): {
7
+ query: string;
8
+ fps: string[];
9
+ } | null;
10
+ export declare function implicitRewardFromSync(filepath: string, stmts: Statements): boolean;
11
+ export declare function getFileRewardsMap(stmts: Statements): Map<string, number>;
@@ -0,0 +1,85 @@
1
+ // Reward system — lightweight RL from get_context usage signals
2
+ // Sources: explicit reward()/penalize(), implicit sync_file(), temporal decay
3
+ // ---------------------------------------------------------------------------
4
+ // In-process tracking of the last created experience (server is long-running)
5
+ // ---------------------------------------------------------------------------
6
+ let _lastId = null;
7
+ export const getLastExperienceId = () => _lastId;
8
+ export const setLastExperienceId = (id) => { _lastId = id; };
9
+ // ---------------------------------------------------------------------------
10
+ // Exponential decay — half-life ≈ 14 days (λ = ln(2)/14 ≈ 0.0495 ≈ 0.05)
11
+ // ---------------------------------------------------------------------------
12
+ export function decayedReward(reward, rewardedAt) {
13
+ if (rewardedAt === null || rewardedAt === 0)
14
+ return 0;
15
+ const daysSince = Math.max(0, (Date.now() / 1000 - rewardedAt) / 86400);
16
+ return reward * Math.exp(-0.05 * daysSince);
17
+ }
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+ function tokenizeQuery(query) {
22
+ return query.toLowerCase().split(/\s+/).filter((t) => t.length > 1).join(" ");
23
+ }
24
+ function parseFps(contextFps) {
25
+ try {
26
+ return JSON.parse(contextFps);
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ }
32
+ // ---------------------------------------------------------------------------
33
+ // Create experience — called after get_context returns results
34
+ // ---------------------------------------------------------------------------
35
+ export function createExperience(query, contextFps, strategy, stmts) {
36
+ const result = stmts.insertExperience.run(query, tokenizeQuery(query), JSON.stringify(contextFps), strategy);
37
+ const id = Number(result.lastInsertRowid);
38
+ setLastExperienceId(id);
39
+ return id;
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Reward or penalize an experience (explicit feedback)
43
+ // ---------------------------------------------------------------------------
44
+ export function rewardExperience(id, delta, feedback, stmts) {
45
+ const exp = stmts.getExperienceById.get(id);
46
+ if (!exp)
47
+ return null;
48
+ stmts.updateExperienceReward.run(delta, feedback, id);
49
+ const fps = parseFps(exp.context_fps);
50
+ for (const fp of fps) {
51
+ stmts.upsertFileReward.run(fp, delta);
52
+ }
53
+ return { query: exp.query, fps };
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Implicit reward from sync_file — if filepath was in the last context → +0.3
57
+ // ---------------------------------------------------------------------------
58
+ const IMPLICIT_DELTA = 0.3;
59
+ export function implicitRewardFromSync(filepath, stmts) {
60
+ const lastId = getLastExperienceId();
61
+ if (lastId === null)
62
+ return false;
63
+ const exp = stmts.getExperienceById.get(lastId);
64
+ if (!exp)
65
+ return false;
66
+ const fps = parseFps(exp.context_fps);
67
+ if (!fps.includes(filepath))
68
+ return false;
69
+ stmts.updateExperienceReward.run(IMPLICIT_DELTA, null, lastId);
70
+ stmts.upsertFileReward.run(filepath, IMPLICIT_DELTA);
71
+ return true;
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Get file rewards map — for ranking boost in assembleContext()
75
+ // ---------------------------------------------------------------------------
76
+ export function getFileRewardsMap(stmts) {
77
+ const rows = stmts.getFileRewards.all();
78
+ const map = new Map();
79
+ for (const row of rows) {
80
+ const d = decayedReward(row.total_reward, row.last_rewarded);
81
+ if (d > 0)
82
+ map.set(row.filepath, d);
83
+ }
84
+ return map;
85
+ }
@@ -0,0 +1,29 @@
1
+ import type { Statements } from "../database.js";
2
+ import type { ResolvedConfig } from "../config.js";
3
+ export declare function estimateTokens(text: string): number;
4
+ export declare function extractFragments(source: string, query: string, contextLines?: number): string;
5
+ export declare function computeDiff(prev: string, curr: string, maxChanges?: number): string;
6
+ export interface ContextFile {
7
+ filepath: string;
8
+ language: string;
9
+ tokens: number;
10
+ content: string;
11
+ reason: string;
12
+ }
13
+ export interface ContextResult {
14
+ files: ContextFile[];
15
+ totalTokens: number;
16
+ strategy: "qdrant" | "tfidf" | "recent";
17
+ truncated: boolean;
18
+ skippedFiles: number;
19
+ }
20
+ export interface ContextOptions {
21
+ maxTokens?: number;
22
+ maxTokensPerFile?: number;
23
+ dirs?: string[];
24
+ recentOnly?: boolean;
25
+ recentHours?: number;
26
+ skeletonOnly?: boolean;
27
+ topK?: number;
28
+ }
29
+ export declare function assembleContext(query: string, stmts: Statements, cfg: ResolvedConfig, opts?: ContextOptions): Promise<ContextResult>;
@@ -0,0 +1,219 @@
1
+ // Smart context assembly — TF-IDF + recency boost + AST skeleton pruning
2
+ // Falls back gracefully: Qdrant → TF-IDF → recency-only
3
+ import { decompress } from "../store/content.js";
4
+ import { rankByRelevance } from "./tfidf.js";
5
+ import { extractSkeleton, renderSkeleton } from "../indexer/ast.js";
6
+ import { searchQdrant } from "./qdrant.js";
7
+ import { getQdrantConfig } from "../config.js";
8
+ import { getFileRewardsMap } from "../memory/experience.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Token estimation (1 token ≈ 4 chars is the standard heuristic)
11
+ // ---------------------------------------------------------------------------
12
+ export function estimateTokens(text) {
13
+ return Math.ceil(text.length / 4);
14
+ }
15
+ // ---------------------------------------------------------------------------
16
+ // Relevant fragment extraction (lines around query matches)
17
+ // ---------------------------------------------------------------------------
18
+ export function extractFragments(source, query, contextLines = 3) {
19
+ const lines = source.split("\n");
20
+ const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 2);
21
+ const hitLines = new Set();
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const lower = lines[i].toLowerCase();
24
+ if (terms.some((t) => lower.includes(t))) {
25
+ for (let j = Math.max(0, i - contextLines); j <= Math.min(lines.length - 1, i + contextLines); j++) {
26
+ hitLines.add(j);
27
+ }
28
+ }
29
+ }
30
+ if (hitLines.size === 0)
31
+ return "";
32
+ const sorted = [...hitLines].sort((a, b) => a - b);
33
+ const out = [];
34
+ let prev = -2;
35
+ for (const n of sorted) {
36
+ if (n > prev + 1)
37
+ out.push("…");
38
+ out.push(`${n + 1}: ${lines[n]}`);
39
+ prev = n;
40
+ }
41
+ return out.join("\n");
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Simple line-level diff (no external deps)
45
+ // ---------------------------------------------------------------------------
46
+ export function computeDiff(prev, curr, maxChanges = 40) {
47
+ const pLines = prev.split("\n");
48
+ const cLines = curr.split("\n");
49
+ const out = [];
50
+ let changes = 0;
51
+ const maxLen = Math.max(pLines.length, cLines.length);
52
+ for (let i = 0; i < maxLen; i++) {
53
+ if (changes >= maxChanges) {
54
+ out.push(`[… +${Math.abs(cLines.length - pLines.length)} more line changes, truncated]`);
55
+ break;
56
+ }
57
+ const p = pLines[i];
58
+ const c = cLines[i];
59
+ if (p === c)
60
+ continue;
61
+ if (p === undefined) {
62
+ out.push(`+${i + 1}: ${c}`);
63
+ }
64
+ else if (c === undefined) {
65
+ out.push(`-${i + 1}: ${p}`);
66
+ }
67
+ else {
68
+ out.push(`-${i + 1}: ${p}`);
69
+ out.push(`+${i + 1}: ${c}`);
70
+ }
71
+ changes++;
72
+ }
73
+ return out.length > 0 ? out.join("\n") : "[no line changes]";
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Main function
77
+ // ---------------------------------------------------------------------------
78
+ export async function assembleContext(query, stmts, cfg, opts = {}) {
79
+ const maxTokens = opts.maxTokens ?? cfg.maxContextTokens;
80
+ const maxPerFile = opts.maxTokensPerFile ?? cfg.maxTokensPerFile;
81
+ const recentHours = opts.recentHours ?? cfg.recentWindowHours;
82
+ const topK = opts.topK ?? 10;
83
+ const allRows = stmts.getAllFiles.all();
84
+ if (!Array.isArray(allRows) || allRows.length === 0) {
85
+ return { files: [], totalTokens: 0, strategy: "tfidf", truncated: false, skippedFiles: 0 };
86
+ }
87
+ // Apply whitelist dirs filter
88
+ const dirs = opts.dirs ?? cfg.whitelistDirs;
89
+ const filtered = dirs && dirs.length > 0
90
+ ? allRows.filter((r) => dirs.some((d) => r.filepath.replace(/\\/g, "/").includes(d)))
91
+ : allRows;
92
+ // Recency cutoff
93
+ const nowSec = Math.floor(Date.now() / 1000);
94
+ const cutoffSec = nowSec - recentHours * 3600;
95
+ const recentSet = new Set(filtered.filter((r) => (r.indexed_at ?? 0) >= cutoffSec).map((r) => r.filepath));
96
+ // If recentOnly, skip files outside the window
97
+ const candidates = opts.recentOnly
98
+ ? filtered.filter((r) => recentSet.has(r.filepath))
99
+ : filtered;
100
+ if (candidates.length === 0) {
101
+ return { files: [], totalTokens: 0, strategy: opts.recentOnly ? "recent" : "tfidf", truncated: false, skippedFiles: filtered.length };
102
+ }
103
+ // Decompress all candidates
104
+ const decompressed = candidates.map((r) => ({
105
+ filepath: r.filepath,
106
+ language: r.language,
107
+ indexedAt: r.indexed_at ?? 0,
108
+ text: decompress(r.content),
109
+ }));
110
+ // ---------------------------------------------------------------------------
111
+ // Ranking strategy
112
+ // ---------------------------------------------------------------------------
113
+ let strategy = "tfidf";
114
+ let ranked;
115
+ // Load experience-based rewards for ranking boost (decayed, normalized to +0.25 max)
116
+ const fileRewards = getFileRewardsMap(stmts);
117
+ const qdrantCfg = getQdrantConfig(cfg);
118
+ if (qdrantCfg && !opts.recentOnly) {
119
+ // Try Qdrant first
120
+ try {
121
+ const chunks = await searchQdrant(query, topK * 3, qdrantCfg);
122
+ if (chunks.length > 0) {
123
+ strategy = "qdrant";
124
+ // Deduplicate by filepath, preserve order
125
+ const seen = new Set();
126
+ const qdrantOrder = [];
127
+ for (const c of chunks) {
128
+ if (!seen.has(c.filepath)) {
129
+ seen.add(c.filepath);
130
+ qdrantOrder.push(c.filepath);
131
+ }
132
+ }
133
+ // Place Qdrant matches first, then remaining by TF-IDF + reward boost
134
+ const tfidfRanked = rankByRelevance(query, decompressed);
135
+ const tfidfOrder = tfidfRanked.map((s) => s.filepath).filter((fp) => !seen.has(fp));
136
+ const orderedFps = [...qdrantOrder, ...tfidfOrder];
137
+ const fpToDoc = new Map(decompressed.map((d) => [d.filepath, d]));
138
+ ranked = orderedFps.map((fp) => fpToDoc.get(fp)).filter(Boolean);
139
+ }
140
+ else {
141
+ ranked = rankAndBoost(query, decompressed, recentSet, fileRewards);
142
+ }
143
+ }
144
+ catch {
145
+ // Qdrant unreachable — fall back to TF-IDF
146
+ ranked = rankAndBoost(query, decompressed, recentSet, fileRewards);
147
+ }
148
+ }
149
+ else {
150
+ ranked = rankAndBoost(query, decompressed, recentSet, fileRewards);
151
+ if (opts.recentOnly)
152
+ strategy = "recent";
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Assemble context with token budget
156
+ // ---------------------------------------------------------------------------
157
+ const result = [];
158
+ let totalTokens = 0;
159
+ let truncated = false;
160
+ let skippedFiles = 0;
161
+ for (const file of ranked) {
162
+ if (totalTokens >= maxTokens) {
163
+ truncated = true;
164
+ break;
165
+ }
166
+ const remaining = maxTokens - totalTokens;
167
+ const fullTokens = estimateTokens(file.text);
168
+ const isRecent = recentSet.has(file.filepath);
169
+ let content;
170
+ let reason;
171
+ if (opts.skeletonOnly || fullTokens > maxPerFile) {
172
+ const sk = extractSkeleton(file.text, file.language);
173
+ const skText = renderSkeleton(sk, file.filepath);
174
+ const fragments = query ? extractFragments(file.text, query) : "";
175
+ content = fragments
176
+ ? `${skText}\n\n// — relevant fragments —\n${fragments}`
177
+ : skText;
178
+ reason = opts.skeletonOnly ? "skeleton" : `skeleton (${fullTokens} tokens > limit ${maxPerFile})`;
179
+ }
180
+ else {
181
+ content = file.text;
182
+ reason = "full";
183
+ }
184
+ if (isRecent)
185
+ reason += " +recent";
186
+ const contentTokens = estimateTokens(content);
187
+ if (contentTokens < 10) {
188
+ skippedFiles++;
189
+ continue;
190
+ }
191
+ // Truncate to remaining budget
192
+ const usedTokens = Math.min(contentTokens, remaining);
193
+ const finalContent = usedTokens < contentTokens
194
+ ? content.slice(0, usedTokens * 4) + "\n… [truncated]"
195
+ : content;
196
+ result.push({ filepath: file.filepath, language: file.language, tokens: usedTokens, content: finalContent, reason });
197
+ totalTokens += usedTokens;
198
+ }
199
+ skippedFiles += ranked.length - result.length - (truncated ? 0 : 0);
200
+ return { files: result, totalTokens, strategy, truncated, skippedFiles };
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // TF-IDF + recency boost ranking
204
+ // ---------------------------------------------------------------------------
205
+ function rankAndBoost(query, docs, recentSet, fileRewards) {
206
+ const scored = rankByRelevance(query, docs);
207
+ const scoreMap = new Map(scored.map((s) => [s.filepath, s.score]));
208
+ // Compute normalizer for experience boost (max 0.25, avoids dominating TF-IDF)
209
+ const maxReward = fileRewards && fileRewards.size > 0
210
+ ? Math.max(...fileRewards.values())
211
+ : 0;
212
+ return [...docs].sort((a, b) => {
213
+ const rewardBoostA = maxReward > 0 ? ((fileRewards.get(a.filepath) ?? 0) / maxReward) * 0.25 : 0;
214
+ const rewardBoostB = maxReward > 0 ? ((fileRewards.get(b.filepath) ?? 0) / maxReward) * 0.25 : 0;
215
+ const sA = (scoreMap.get(a.filepath) ?? 0) + (recentSet.has(a.filepath) ? 0.3 : 0) + rewardBoostA;
216
+ const sB = (scoreMap.get(b.filepath) ?? 0) + (recentSet.has(b.filepath) ? 0.3 : 0) + rewardBoostB;
217
+ return sB - sA;
218
+ });
219
+ }
@@ -0,0 +1,16 @@
1
+ import type { ResolvedConfig } from "../config.js";
2
+ type QdrantCfg = NonNullable<ResolvedConfig["qdrant"]>;
3
+ export interface VectorChunk {
4
+ id: number;
5
+ filepath: string;
6
+ chunkIndex: number;
7
+ text: string;
8
+ score: number;
9
+ }
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>;
12
+ /** Top-k semantic search across all indexed chunks. */
13
+ export declare function searchQdrant(query: string, topK: number, cfg: QdrantCfg): Promise<VectorChunk[]>;
14
+ /** Check if Qdrant collection exists and is reachable. */
15
+ export declare function pingQdrant(cfg: QdrantCfg): Promise<boolean>;
16
+ export {};