@kurtel/cli 0.1.6 → 0.1.11

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,137 @@
1
+ import { findSimilarRoutes } from "./indexer.js";
2
+ // ─────────────────────────────────────────────────────────────────────────────
3
+ // Capsule — le cœur du produit. À chaque prompt:
4
+ // intention → résolution spatiale (zones du graphe) → sélection de patterns
5
+ // → capsule compacte (budget ~400 tokens) injectée via le hook.
6
+ // Tout se calcule en local, en millisecondes, sans réseau.
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ const TOKEN_BUDGET_CHARS = 1600; // ~400 tokens
9
+ const STOPWORDS = new Set([
10
+ "the", "a", "an", "to", "for", "of", "in", "on", "and", "or", "with", "add",
11
+ "create", "make", "fix", "update", "change", "new", "page", "file", "please",
12
+ "le", "la", "les", "un", "une", "des", "de", "du", "et", "ou", "pour", "dans",
13
+ "ajoute", "ajouter", "crée", "creer", "modifie", "modifier", "corrige", "il", "faut",
14
+ ]);
15
+ export function tokenize(text) {
16
+ return [...new Set(text.toLowerCase()
17
+ .split(/[^a-z0-9_]+/)
18
+ .filter((w) => w.length >= 3 && !STOPWORDS.has(w)))];
19
+ }
20
+ // ── Résolution spatiale: quelles zones du repo l'intention touche-t-elle ? ──
21
+ export function resolveZones(index, promptTokens) {
22
+ const scores = new Map();
23
+ for (const m of index.modules) {
24
+ const hay = m.id.toLowerCase();
25
+ let s = 0;
26
+ for (const t of promptTokens)
27
+ if (hay.includes(t))
28
+ s += 2;
29
+ for (const e of m.exports) {
30
+ const el = e.toLowerCase();
31
+ for (const t of promptTokens)
32
+ if (el.includes(t))
33
+ s += 1;
34
+ }
35
+ if (s > 0) {
36
+ const zone = m.id.includes("/") ? m.id.split("/").slice(0, 2).join("/") : m.id;
37
+ scores.set(zone, (scores.get(zone) ?? 0) + s);
38
+ }
39
+ }
40
+ return [...scores.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([z]) => z);
41
+ }
42
+ export function selectPatterns(patterns, promptTokens, zones, max = 5) {
43
+ const out = [];
44
+ for (const p of patterns) {
45
+ if (p.score < 0.3 && !p.pinned)
46
+ continue; // les mourants ne parlent plus
47
+ let s = 0;
48
+ if (p.pinned)
49
+ s += 10; // socle: toujours présent
50
+ for (const trig of p.triggers) {
51
+ const tl = trig.toLowerCase();
52
+ if (promptTokens.some((t) => t === tl || tl.includes(t) || t.includes(tl)))
53
+ s += 4;
54
+ }
55
+ for (const z of p.zones) {
56
+ if (zones.some((zone) => zone.startsWith(z) || z.startsWith(zone)))
57
+ s += 3;
58
+ }
59
+ if (p.zones.length === 0 && s > 0)
60
+ s += 1; // global + déjà pertinent
61
+ if (s > 0)
62
+ out.push({ pattern: p, score: s * (0.5 + p.score) });
63
+ }
64
+ return out.sort((a, b) => b.score - a.score).slice(0, max);
65
+ }
66
+ // ── Routes pertinentes pour l'intention (anti-duplication proactive) ────────
67
+ export function relevantRoutes(index, promptTokens) {
68
+ const scored = index.routes.map((r) => {
69
+ const hay = `${r.path} ${r.file}`.toLowerCase();
70
+ let s = 0;
71
+ for (const t of promptTokens)
72
+ if (hay.includes(t))
73
+ s++;
74
+ return { r, s };
75
+ });
76
+ return scored.filter((x) => x.s > 0).sort((a, b) => b.s - a.s).slice(0, 6).map((x) => x.r);
77
+ }
78
+ export function compileCapsule(index, patterns, prompt) {
79
+ const tokens = tokenize(prompt);
80
+ if (!tokens.length)
81
+ return null;
82
+ const zones = index ? resolveZones(index, tokens) : [];
83
+ const selected = selectPatterns(patterns, tokens, zones);
84
+ const routes = index ? relevantRoutes(index, tokens) : [];
85
+ const gods = index
86
+ ? index.god_nodes.filter((g) => zones.some((z) => g.id.startsWith(z))).slice(0, 2)
87
+ : [];
88
+ if (!selected.length && !routes.length && !gods.length)
89
+ return null; // le silence est le défaut
90
+ const parts = [];
91
+ parts.push(`[Kurtel memory — auto-injected, relevant to this task only]`);
92
+ if (routes.length) {
93
+ parts.push(`Existing routes in this area (do NOT recreate; extend or reuse):`);
94
+ for (const r of routes)
95
+ parts.push(`- ${r.method} ${r.path} → ${r.file}:${r.line}`);
96
+ }
97
+ if (selected.length) {
98
+ parts.push(`Team conventions learned from merged PRs (follow them):`);
99
+ for (const { pattern } of selected) {
100
+ const conf = Math.round(pattern.score * 100);
101
+ parts.push(`- ${pattern.rule} (confidence ${conf}%)`);
102
+ }
103
+ }
104
+ if (gods.length) {
105
+ parts.push(`High-coupling modules in this zone — changes here have a wide blast radius, check dependents:`);
106
+ for (const g of gods)
107
+ parts.push(`- ${g.id} (${g.degree} edges)`);
108
+ }
109
+ let text = parts.join("\n");
110
+ if (text.length > TOKEN_BUDGET_CHARS)
111
+ text = text.slice(0, TOKEN_BUDGET_CHARS - 1) + "…";
112
+ return { text, injectedPatternIds: selected.map((s) => s.pattern.id), zones };
113
+ }
114
+ // ── Capsule de zone (dérive d'intention en cours de session) ────────────────
115
+ export function compileZoneCapsule(index, patterns, filePath) {
116
+ const zone = filePath.includes("/") ? filePath.split("/").slice(0, 2).join("/") : filePath;
117
+ const zonePatterns = patterns.filter((p) => p.score >= 0.3 && p.zones.some((z) => zone.startsWith(z) || z.startsWith(zone))).sort((a, b) => b.score - a.score).slice(0, 3);
118
+ const zoneRoutes = index ? index.routes.filter((r) => r.file.startsWith(zone)).slice(0, 5) : [];
119
+ if (!zonePatterns.length && !zoneRoutes.length)
120
+ return null;
121
+ const parts = [`[Kurtel memory — you just entered zone "${zone}"]`];
122
+ if (zonePatterns.length) {
123
+ parts.push(`Conventions for this zone:`);
124
+ for (const p of zonePatterns)
125
+ parts.push(`- ${p.rule}`);
126
+ }
127
+ if (zoneRoutes.length) {
128
+ parts.push(`Routes defined here:`);
129
+ for (const r of zoneRoutes)
130
+ parts.push(`- ${r.method} ${r.path} (${r.file}:${r.line})`);
131
+ }
132
+ let text = parts.join("\n");
133
+ if (text.length > 800)
134
+ text = text.slice(0, 799) + "…";
135
+ return { text, injectedPatternIds: zonePatterns.map((p) => p.id), zones: [zone] };
136
+ }
137
+ export { findSimilarRoutes };
@@ -0,0 +1,280 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, relative, extname, dirname } from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import { repoFullName } from "./store.js";
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Indexeur structurel — 100% local, 0 token, déterministe (même input → même
7
+ // output). Heuristiques regex pragmatiques pour TS/JS/Python ; le point
8
+ // d'extension propre pour passer à tree-sitter plus tard est extractFile().
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ const CODE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py"]);
11
+ const IGNORE_DIRS = new Set([
12
+ "node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", "coverage",
13
+ "vendor", "__pycache__", ".venv", "venv", ".kurtel", ".claude", ".idea", ".vscode",
14
+ ]);
15
+ // Filtrage anti-bruit (la leçon Graphify) : on indexe le code, pas les assets/config.
16
+ const IGNORE_FILES = /\.(min\.js|d\.ts|test\.[jt]sx?|spec\.[jt]sx?|stories\.[jt]sx?)$|(^|\/)(conftest|setup)\.py$/;
17
+ // Patterns par langage — jamais croisés (sinon `@app.get(...)` Python matche aussi le pattern Express).
18
+ const JS_ROUTE_PATTERNS = [
19
+ // Express / Fastify / Koa-router (router.get("/x"), app.post('/y'))
20
+ {
21
+ re: /\b(?:app|router|server|api|fastify)\s*\.\s*(get|post|put|patch|delete|head|options|all)\s*\(\s*["'`]([^"'`]+)["'`]/g,
22
+ framework: "express-like",
23
+ method: (m) => m[1].toUpperCase(),
24
+ path: (m) => m[2],
25
+ },
26
+ // Décorateurs Nest: @Get("/x"), @Post()
27
+ {
28
+ re: /@(Get|Post|Put|Patch|Delete|Head|Options)\s*\(\s*(?:["'`]([^"'`]*)["'`])?\s*\)/g,
29
+ framework: "nest",
30
+ method: (m) => m[1].toUpperCase(),
31
+ path: (m) => m[2] ?? "",
32
+ },
33
+ ];
34
+ const PY_ROUTE_PATTERNS = [
35
+ // FastAPI / Flask: @app.get("/x"), @router.post("/y"), @app.route("/z", methods=["POST"])
36
+ {
37
+ re: /@\s*[\w.]+\.(get|post|put|patch|delete|route)\s*\(\s*["']([^"']+)["']/g,
38
+ framework: "python",
39
+ method: (m) => (m[1] === "route" ? "*" : m[1].toUpperCase()),
40
+ path: (m) => m[2],
41
+ },
42
+ ];
43
+ const NEXT_ROUTE_FILE = /(^|\/)(pages|app)\/(.+?)\/?(route|page|index)?\.(ts|tsx|js|jsx)$/;
44
+ function extractFile(root, rel, src) {
45
+ const lines = src.split("\n");
46
+ const facts = { rel, loc: lines.length, exports: [], importSpecs: [], routes: [] };
47
+ const ext = extname(rel);
48
+ if (ext === ".py") {
49
+ for (const m of src.matchAll(/^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))/gm)) {
50
+ facts.importSpecs.push(m[1] ?? m[2]);
51
+ }
52
+ for (const m of src.matchAll(/^(?:def|class)\s+(\w+)/gm))
53
+ facts.exports.push(m[1]);
54
+ }
55
+ else {
56
+ for (const m of src.matchAll(/\bfrom\s+["']([^"']+)["']|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g)) {
57
+ facts.importSpecs.push(m[1] ?? m[2]);
58
+ }
59
+ for (const m of src.matchAll(/\bexport\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+(\w+)/g)) {
60
+ facts.exports.push(m[1]);
61
+ }
62
+ }
63
+ // Routes par appel/décorateur, avec n° de ligne — patterns du bon langage uniquement.
64
+ const routePatterns = ext === ".py" ? PY_ROUTE_PATTERNS : JS_ROUTE_PATTERNS;
65
+ for (const p of routePatterns) {
66
+ for (const m of src.matchAll(p.re)) {
67
+ const upto = src.slice(0, m.index ?? 0);
68
+ facts.routes.push({
69
+ method: p.method(m),
70
+ path: p.path(m),
71
+ file: rel,
72
+ line: upto.split("\n").length,
73
+ framework: p.framework,
74
+ });
75
+ }
76
+ }
77
+ // Routes par convention de fichier (Next.js app/pages router).
78
+ const nm = rel.replace(/\\/g, "/").match(NEXT_ROUTE_FILE);
79
+ if (nm) {
80
+ const urlPath = "/" + nm[3].replace(/\[(\w+)\]/g, ":$1").replace(/\/index$/, "");
81
+ facts.routes.push({ method: "*", path: urlPath, file: rel, line: 1, framework: "next" });
82
+ }
83
+ return facts;
84
+ }
85
+ // ── Résolution d'imports internes (TS/JS relatifs + Python par module path) ──
86
+ function resolveImports(all) {
87
+ const byNoExt = new Map();
88
+ for (const rel of all.keys()) {
89
+ const noExt = rel.replace(/\.(ts|tsx|js|jsx|mjs|cjs|py)$/, "");
90
+ byNoExt.set(noExt.replace(/\\/g, "/"), rel);
91
+ if (noExt.endsWith("/index"))
92
+ byNoExt.set(noExt.slice(0, -"/index".length), rel);
93
+ if (noExt.endsWith("/__init__"))
94
+ byNoExt.set(noExt.slice(0, -"/__init__".length), rel);
95
+ }
96
+ const resolved = new Map();
97
+ for (const [rel, facts] of all) {
98
+ const targets = [];
99
+ for (const spec of facts.importSpecs) {
100
+ let candidate;
101
+ if (spec.startsWith(".")) {
102
+ // relatif TS/JS
103
+ const base = join(dirname(rel), spec).replace(/\\/g, "/").replace(/\.(js|ts|tsx|jsx)$/, "");
104
+ candidate = byNoExt.get(base);
105
+ }
106
+ else if (spec.includes(".") && extname(rel) === ".py") {
107
+ // module python "app.services.billing" → app/services/billing.py
108
+ candidate = byNoExt.get(spec.replace(/\./g, "/"));
109
+ }
110
+ else {
111
+ // alias absolu fréquent: "src/lib/foo", "@/lib/foo"
112
+ const cleaned = spec.replace(/^@\//, "src/").replace(/^~\//, "src/");
113
+ candidate = byNoExt.get(cleaned);
114
+ }
115
+ if (candidate && candidate !== rel)
116
+ targets.push(candidate);
117
+ }
118
+ resolved.set(rel, [...new Set(targets)]);
119
+ }
120
+ return resolved;
121
+ }
122
+ // ── Parcours ────────────────────────────────────────────────────────────────
123
+ function* walk(dir, root) {
124
+ let entries;
125
+ try {
126
+ entries = readdirSync(dir);
127
+ }
128
+ catch {
129
+ return;
130
+ }
131
+ for (const name of entries) {
132
+ if (name.startsWith(".") && name !== ".")
133
+ continue;
134
+ const full = join(dir, name);
135
+ let st;
136
+ try {
137
+ st = statSync(full);
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ if (st.isDirectory()) {
143
+ if (!IGNORE_DIRS.has(name))
144
+ yield* walk(full, root);
145
+ }
146
+ else if (st.isFile() && st.size < 1_000_000) {
147
+ const rel = relative(root, full).replace(/\\/g, "/");
148
+ if (CODE_EXT.has(extname(name)) && !IGNORE_FILES.test(rel))
149
+ yield rel;
150
+ }
151
+ }
152
+ }
153
+ // ── Construction de l'index ─────────────────────────────────────────────────
154
+ export function buildIndex(root, onProgress) {
155
+ const files = new Map();
156
+ let n = 0;
157
+ for (const rel of walk(root, root)) {
158
+ try {
159
+ const src = readFileSync(join(root, rel), "utf8");
160
+ files.set(rel, extractFile(root, rel, src));
161
+ if (onProgress && ++n % 50 === 0)
162
+ onProgress(n);
163
+ }
164
+ catch { /* binaire/illisible: skip */ }
165
+ }
166
+ const imports = resolveImports(files);
167
+ const inDegree = new Map();
168
+ for (const targets of imports.values()) {
169
+ for (const t of targets)
170
+ inDegree.set(t, (inDegree.get(t) ?? 0) + 1);
171
+ }
172
+ const modules = [...files.entries()]
173
+ .map(([rel, f]) => {
174
+ const out = imports.get(rel) ?? [];
175
+ return {
176
+ id: rel,
177
+ exports: f.exports.slice(0, 30),
178
+ imports: out,
179
+ loc: f.loc,
180
+ degree: out.length + (inDegree.get(rel) ?? 0),
181
+ };
182
+ })
183
+ .sort((a, b) => a.id.localeCompare(b.id)); // tri stable → déterminisme
184
+ const god_nodes = [...modules]
185
+ .sort((a, b) => b.degree - a.degree || a.id.localeCompare(b.id))
186
+ .slice(0, 10)
187
+ .filter((m) => m.degree >= 5)
188
+ .map((m) => ({ id: m.id, degree: m.degree }));
189
+ const domainMap = new Map();
190
+ for (const m of modules) {
191
+ const top = m.id.includes("/") ? m.id.split("/")[0] : "(root)";
192
+ const d = domainMap.get(top) ?? { files: 0, loc: 0 };
193
+ d.files += 1;
194
+ d.loc += m.loc;
195
+ domainMap.set(top, d);
196
+ }
197
+ const domains = [...domainMap.entries()]
198
+ .map(([name, d]) => ({ name, ...d }))
199
+ .sort((a, b) => b.loc - a.loc);
200
+ const routes = [...files.values()].flatMap((f) => f.routes)
201
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
202
+ let commit = "unknown";
203
+ try {
204
+ commit = execSync("git rev-parse HEAD", { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
205
+ }
206
+ catch { /* pas un repo git */ }
207
+ return {
208
+ version: 1,
209
+ repo: repoFullName(root),
210
+ commit,
211
+ generated_at: new Date().toISOString(),
212
+ files_indexed: files.size,
213
+ routes,
214
+ modules,
215
+ god_nodes,
216
+ domains,
217
+ };
218
+ }
219
+ // ── Rapport d'audit (le "moment wow" du jour 0) ─────────────────────────────
220
+ export function renderReport(index) {
221
+ const lines = [];
222
+ lines.push(`# Kurtel — Architecture Report`);
223
+ lines.push(``);
224
+ lines.push(`Repo **${index.repo}** · ${index.files_indexed} files indexed · commit \`${index.commit.slice(0, 8)}\` · ${index.generated_at}`);
225
+ lines.push(``);
226
+ lines.push(`## Domains`);
227
+ for (const d of index.domains.slice(0, 12)) {
228
+ lines.push(`- **${d.name}** — ${d.files} files, ${d.loc.toLocaleString()} LOC`);
229
+ }
230
+ lines.push(``);
231
+ if (index.god_nodes.length) {
232
+ lines.push(`## God nodes (high-coupling hotspots)`);
233
+ lines.push(`These modules concentrate the most connections; changes here have the widest blast radius.`);
234
+ for (const g of index.god_nodes) {
235
+ lines.push(`- \`${g.id}\` — **${g.degree} edges**`);
236
+ }
237
+ lines.push(``);
238
+ }
239
+ lines.push(`## Route inventory (${index.routes.length})`);
240
+ lines.push(`The agent receives this inventory before creating any endpoint — duplicates get flagged.`);
241
+ const byFw = new Map();
242
+ for (const r of index.routes) {
243
+ const arr = byFw.get(r.framework) ?? [];
244
+ arr.push(r);
245
+ byFw.set(r.framework, arr);
246
+ }
247
+ for (const [fw, rs] of byFw) {
248
+ lines.push(``);
249
+ lines.push(`### ${fw}`);
250
+ for (const r of rs.slice(0, 80)) {
251
+ lines.push(`- \`${r.method.padEnd(6)} ${r.path}\` → ${r.file}:${r.line}`);
252
+ }
253
+ if (rs.length > 80)
254
+ lines.push(`- … and ${rs.length - 80} more`);
255
+ }
256
+ lines.push(``);
257
+ return lines.join("\n");
258
+ }
259
+ // ── Recherche de routes proches (anti-duplication) ──────────────────────────
260
+ /** Similarité grossière entre deux chemins de route (segments partagés, params normalisés). */
261
+ function routeSimilarity(a, b) {
262
+ const norm = (p) => p.replace(/:(\w+)|\{(\w+)\}|\[(\w+)\]/g, ":p").toLowerCase()
263
+ .split("/").filter(Boolean);
264
+ const sa = norm(a), sb = norm(b);
265
+ if (!sa.length || !sb.length)
266
+ return 0;
267
+ let shared = 0;
268
+ for (let i = 0; i < Math.min(sa.length, sb.length); i++)
269
+ if (sa[i] === sb[i])
270
+ shared++;
271
+ return (2 * shared) / (sa.length + sb.length);
272
+ }
273
+ export function findSimilarRoutes(index, path, threshold = 0.6) {
274
+ return index.routes
275
+ .map((r) => ({ r, s: routeSimilarity(r.path, path) }))
276
+ .filter((x) => x.s >= threshold)
277
+ .sort((a, b) => b.s - a.s)
278
+ .slice(0, 5)
279
+ .map((x) => x.r);
280
+ }
@@ -0,0 +1,125 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { execSync } from "node:child_process";
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Emplacements disque
7
+ // ~/.kurtel/cache/<repo-slug>/patterns.json ← mémoire darwinienne (pull Supabase)
8
+ // <repo>/.kurtel/index.json ← mémoire de codebase (déterministe)
9
+ // <repo>/.kurtel/REPORT.md ← rapport d'audit lisible
10
+ // <repo>/.kurtel/memory.json ← état local (on/off, session zones)
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ export function repoRoot(cwd = process.cwd()) {
13
+ try {
14
+ return execSync("git rev-parse --show-toplevel", { cwd, stdio: ["ignore", "pipe", "ignore"] })
15
+ .toString().trim();
16
+ }
17
+ catch {
18
+ return cwd;
19
+ }
20
+ }
21
+ export function repoSlug(root) {
22
+ // owner/name depuis le remote si possible, sinon basename — slugifié pour un nom de dossier.
23
+ let name = root.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
24
+ try {
25
+ const url = execSync("git remote get-url origin", { cwd: root, stdio: ["ignore", "pipe", "ignore"] })
26
+ .toString().trim();
27
+ const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
28
+ if (m)
29
+ name = m[1];
30
+ }
31
+ catch { /* pas de remote */ }
32
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "__");
33
+ }
34
+ export function repoFullName(root) {
35
+ try {
36
+ const url = execSync("git remote get-url origin", { cwd: root, stdio: ["ignore", "pipe", "ignore"] })
37
+ .toString().trim();
38
+ const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git?)?$/);
39
+ if (m)
40
+ return m[1].replace(/\.git$/, "");
41
+ }
42
+ catch { /* ignore */ }
43
+ return root.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
44
+ }
45
+ function cacheDir(root) {
46
+ return join(homedir(), ".kurtel", "cache", repoSlug(root));
47
+ }
48
+ function readJSON(file, fallback) {
49
+ try {
50
+ if (!existsSync(file))
51
+ return fallback;
52
+ return { ...fallback, ...JSON.parse(readFileSync(file, "utf8")) };
53
+ }
54
+ catch {
55
+ return fallback;
56
+ }
57
+ }
58
+ function writeJSON(file, data) {
59
+ const dir = join(file, "..");
60
+ if (!existsSync(dir))
61
+ mkdirSync(dir, { recursive: true });
62
+ writeFileSync(file, JSON.stringify(data, null, 2) + "\n", "utf8");
63
+ }
64
+ // ── Cache mémoire darwinienne (par user × repo, hors du repo: ne se versionne pas) ──
65
+ const EMPTY_CACHE = { patterns: [], patterns_synced_at: null, pending_telemetry: [] };
66
+ export function loadMemoryCache(root) {
67
+ return readJSON(join(cacheDir(root), "memory.json"), EMPTY_CACHE);
68
+ }
69
+ export function saveMemoryCache(root, cache) {
70
+ writeJSON(join(cacheDir(root), "memory.json"), cache);
71
+ }
72
+ export function queueTelemetry(root, ev) {
73
+ const cache = loadMemoryCache(root);
74
+ cache.pending_telemetry.push(ev);
75
+ // borne dure: jamais plus de 500 événements en attente (pas de croissance infinie)
76
+ if (cache.pending_telemetry.length > 500) {
77
+ cache.pending_telemetry = cache.pending_telemetry.slice(-500);
78
+ }
79
+ saveMemoryCache(root, cache);
80
+ }
81
+ // ── Index de codebase (dans le repo: partageable, déterministe) ──
82
+ export function indexPath(root) {
83
+ return join(root, ".kurtel", "index.json");
84
+ }
85
+ export function reportPath(root) {
86
+ return join(root, ".kurtel", "REPORT.md");
87
+ }
88
+ export function loadIndex(root) {
89
+ const file = indexPath(root);
90
+ if (!existsSync(file))
91
+ return null;
92
+ try {
93
+ return JSON.parse(readFileSync(file, "utf8"));
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ export function saveIndex(root, index) {
100
+ writeJSON(indexPath(root), index);
101
+ }
102
+ const EMPTY_STATE = { enabled: true, session_zones: {} };
103
+ function statePath(root) {
104
+ return join(cacheDir(root), "state.json");
105
+ }
106
+ export function loadMemoryState(root) {
107
+ return readJSON(statePath(root), EMPTY_STATE);
108
+ }
109
+ export function saveMemoryState(root, state) {
110
+ // garde-fou: ne garder que les 20 dernières sessions
111
+ const ids = Object.keys(state.session_zones);
112
+ if (ids.length > 20) {
113
+ for (const id of ids.slice(0, ids.length - 20))
114
+ delete state.session_zones[id];
115
+ }
116
+ writeJSON(statePath(root), state);
117
+ }
118
+ export function memoryEnabled(root) {
119
+ return loadMemoryState(root).enabled;
120
+ }
121
+ export function setMemoryEnabled(root, enabled) {
122
+ const s = loadMemoryState(root);
123
+ s.enabled = enabled;
124
+ saveMemoryState(root, s);
125
+ }
@@ -0,0 +1,70 @@
1
+ import { spawn } from "node:child_process";
2
+ import { loadMemoryCache, saveMemoryCache, repoFullName } from "./store.js";
3
+ import { pullPatterns, pushTelemetry, pushIndex } from "./api.js";
4
+ import { loadIndex } from "./store.js";
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Sync — règle d'or: AUCUN appel réseau sur le chemin critique d'un prompt.
7
+ // Les hooks lisent le cache local; le sync tourne en arrière-plan (process
8
+ // détaché) au SessionStart et après chaque session.
9
+ // ─────────────────────────────────────────────────────────────────────────────
10
+ /** Sync synchrone (utilisé par `kurtel memory sync` et le process détaché). */
11
+ export async function syncNow(root) {
12
+ const repo = repoFullName(root);
13
+ const cache = loadMemoryCache(root);
14
+ // 1. Pull delta des patterns darwiniens.
15
+ let pulled = 0;
16
+ try {
17
+ const res = await pullPatterns(repo, cache.patterns_synced_at);
18
+ if (res.delta) {
19
+ const byId = new Map(cache.patterns.map((p) => [p.id, p]));
20
+ for (const p of res.patterns)
21
+ byId.set(p.id, p);
22
+ cache.patterns = [...byId.values()].filter((p) => p.score > 0); // score 0 = mort, purgé
23
+ }
24
+ else {
25
+ cache.patterns = res.patterns;
26
+ }
27
+ cache.patterns_synced_at = res.synced_at;
28
+ pulled = res.patterns.length;
29
+ }
30
+ catch { /* offline / pas loggé: le cache continue de servir */ }
31
+ // 2. Flush de la télémétrie en attente.
32
+ let flushed = 0;
33
+ if (cache.pending_telemetry.length) {
34
+ try {
35
+ await pushTelemetry(repo, cache.pending_telemetry);
36
+ flushed = cache.pending_telemetry.length;
37
+ cache.pending_telemetry = [];
38
+ }
39
+ catch { /* on réessaiera au prochain sync */ }
40
+ }
41
+ saveMemoryCache(root, cache);
42
+ return { pulled, flushed };
43
+ }
44
+ /** Push de l'index de codebase vers le backend (pour l'onglet Memory in-app). */
45
+ export async function syncIndexUp(root) {
46
+ const index = loadIndex(root);
47
+ if (!index)
48
+ return false;
49
+ try {
50
+ await pushIndex(index.repo, index);
51
+ return true;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /** Lance un sync en arrière-plan, détaché — fire & forget, jamais bloquant. */
58
+ export function syncInBackground(root) {
59
+ try {
60
+ const child = spawn(process.execPath, [process.argv[1], "memory", "sync", "--quiet"], {
61
+ cwd: root,
62
+ stdio: "ignore",
63
+ detached: true,
64
+ env: process.env,
65
+ });
66
+ child.on("error", () => { });
67
+ child.unref();
68
+ }
69
+ catch { /* best effort */ }
70
+ }