dsh-desktop-kit 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -10
- package/docs/ARCHITECTURE.md +9 -0
- package/docs/COMPATIBILITY.md +8 -3
- package/docs/INSTALL.md +22 -8
- package/docs/PRIVACY.md +4 -0
- package/docs/TOKENS.md +17 -10
- package/docs/images/hero.png +0 -0
- package/package.json +3 -3
- package/plugins/dsh-project-memory/README.md +67 -54
- package/plugins/dsh-project-memory/index.js +314 -71
- package/plugins/dsh-project-memory/package.json +3 -2
- package/plugins/dsh-project-memory/scan.js +607 -0
- package/skills/project-deep-dive/SKILL.md +12 -11
- package/skills/project-memory-maintenance/SKILL.md +11 -11
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-project-memory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "Local per-project long-term memory for DSH: auto-injected digest, project_memory tool, automatic session journal.",
|
|
5
|
+
"description": "Local per-project long-term memory for DSH: auto-detection, bounded local recon, auto-injected digest with recall, project_memory tool, automatic session journal.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"exports": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"index.js",
|
|
15
|
+
"scan.js",
|
|
15
16
|
"cordis.patch.yml",
|
|
16
17
|
"locale/*.json",
|
|
17
18
|
"icon.svg",
|
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-project-memory — local project reconnaissance.
|
|
3
|
+
*
|
|
4
|
+
* Pure, bounded, dependency-free scanning used to bootstrap a dossier without
|
|
5
|
+
* an LLM call. The scanner only reads an allowlist of manifest/doc files; it
|
|
6
|
+
* never opens secrets (`.env`, keys, credentials), dumps, vendored code or
|
|
7
|
+
* binary assets, and every string it returns passes through `redactText`.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export const SCAN_VERSION = 1;
|
|
13
|
+
const MAX_DEPTH = 3;
|
|
14
|
+
const MAX_FILES = 1500;
|
|
15
|
+
const MAX_FILE_BYTES = 96 * 1024;
|
|
16
|
+
const MAX_BLOCK_CHARS = 3200;
|
|
17
|
+
|
|
18
|
+
const SKIP_DIRS = new Set([
|
|
19
|
+
"node_modules", "vendor", ".git", ".hg", ".svn", ".idea", ".vscode", "dist", "build",
|
|
20
|
+
"coverage", ".next", ".nuxt", "target", "bin", "obj", "storage", "cache", "tmp",
|
|
21
|
+
"temp", ".cache", "venv", ".venv", "env", "__pycache__", ".pnpm-store", "pods",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const SECRET_FILE = /^(?:\.env(?:\..*)?|.*\.(?:pem|key|p12|pfx|keystore)|id_(?:rsa|ed25519|ecdsa)(?:\.pub)?|credentials?(?:\..*)?|secrets?(?:\..*)?|auth\.json|service-account.*\.json|wp-config\.php)$/iu;
|
|
25
|
+
const ENV_TEMPLATE = /^\.env\.(?:example|sample|dist|template|defaults)$/iu;
|
|
26
|
+
const DOC_FILES = ["README.md", "README.rst", "README.txt", "AGENTS.md", "CONTRIBUTING.md"];
|
|
27
|
+
const MANIFEST_FILES = [
|
|
28
|
+
"package.json", "composer.json", "pyproject.toml", "requirements.txt", "go.mod", "Cargo.toml",
|
|
29
|
+
"Gemfile", "pom.xml", "build.gradle", "build.gradle.kts", "mix.exs", "pubspec.yaml",
|
|
30
|
+
];
|
|
31
|
+
const STRONG_MARKERS = [
|
|
32
|
+
".git", "composer.json", "package.json", "pyproject.toml", "go.mod", "Cargo.toml",
|
|
33
|
+
"Gemfile", "pom.xml", "artisan", "wp-config.php", "manage.py", "mix.exs", "pubspec.yaml",
|
|
34
|
+
"bitrix", "docker-compose.yml", "docker-compose.yaml",
|
|
35
|
+
];
|
|
36
|
+
const WEAK_MARKERS = ["AGENTS.md", "Makefile", "Dockerfile", "README.md"];
|
|
37
|
+
const ENTRY_CANDIDATES = [
|
|
38
|
+
"artisan", "public/index.php", "index.php", "src/main.ts", "src/main.js", "src/index.ts",
|
|
39
|
+
"src/index.js", "main.py", "app.py", "manage.py", "server.js", "app.js", "main.go",
|
|
40
|
+
"cmd/server/main.go", "Program.cs", "bin/console", "config/app.php", "next.config.js",
|
|
41
|
+
"next.config.mjs", "vite.config.ts", "vite.config.js",
|
|
42
|
+
];
|
|
43
|
+
const KEY_DIRS = [
|
|
44
|
+
"src", "app", "routes", "config", "database/migrations", "migrations", "tests", "test",
|
|
45
|
+
"public", "resources", "storage", "components", "packages", "apps", "plugins", "skills",
|
|
46
|
+
"tools", "server", "api", "cmd", "internal", "lib", "docs", "scripts", "deploy", "docker",
|
|
47
|
+
".github/workflows", "assets",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/* ------------------------------------------------------------------ */
|
|
51
|
+
/* redaction */
|
|
52
|
+
/* ------------------------------------------------------------------ */
|
|
53
|
+
|
|
54
|
+
/** Strip secrets and personal data before anything reaches the dossier. */
|
|
55
|
+
export function redactText(input) {
|
|
56
|
+
let text = String(input ?? "");
|
|
57
|
+
const rules = [
|
|
58
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, "[REDACTED:PRIVATE_KEY]"],
|
|
59
|
+
[/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, "[REDACTED:JWT]"],
|
|
60
|
+
[/\b(?:sk|rk)-[A-Za-z0-9_-]{16,}\b/gu, "[REDACTED:KEY]"],
|
|
61
|
+
[/\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{16,}\b/gu, "[REDACTED:GITHUB_TOKEN]"],
|
|
62
|
+
[/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gu, "[REDACTED:SLACK_TOKEN]"],
|
|
63
|
+
[/\bAKIA[0-9A-Z]{16}\b/gu, "[REDACTED:AWS_KEY]"],
|
|
64
|
+
[/\bAIza[0-9A-Za-z_-]{30,}\b/gu, "[REDACTED:GOOGLE_KEY]"],
|
|
65
|
+
[/\bsk_live_[0-9A-Za-z]{16,}\b/gu, "[REDACTED:STRIPE_KEY]"],
|
|
66
|
+
[/\bAuthorization:\s*(?:Bearer|Basic)\s+[^\s]+/giu, "Authorization: [REDACTED]"],
|
|
67
|
+
[/\b(\w+:\/\/)[^:@/\s]+:[^@/\s]+@/gu, "$1[REDACTED]@"],
|
|
68
|
+
[/\b([A-Z][A-Z0-9_]*(?:PASSWORD|PASSWD|PWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|DSN|DATABASE_URL|REDIS_URL|ACCESS[_-]?KEY)[A-Z0-9_]*)\s*[:=]\s*["']?[^\s"',;]+/giu, "$1=[REDACTED]"],
|
|
69
|
+
[/(^|\n)([A-Z0-9_]*(?:PASSWORD|PASSWD|PWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|DSN|DATABASE_URL|REDIS_URL|ACCESS[_-]?KEY)[A-Z0-9_]*)\s*[:=]\s*[^\n]*/giu, "$1$2=[REDACTED]"],
|
|
70
|
+
[/(^|\n)(\s*(?:password|passwd|pwd|secret|token|api[_-]?key|authorization)\s*[:=]\s*)[^\n]*/giu, "$1$2[REDACTED]"],
|
|
71
|
+
[/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, "[email]"],
|
|
72
|
+
[/(?:\+7|8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}\b/gu, "[phone]"],
|
|
73
|
+
];
|
|
74
|
+
for (const [pattern, replacement] of rules) text = text.replace(pattern, replacement);
|
|
75
|
+
return text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ------------------------------------------------------------------ */
|
|
79
|
+
/* bounded filesystem access */
|
|
80
|
+
/* ------------------------------------------------------------------ */
|
|
81
|
+
|
|
82
|
+
function readSafe(filePath) {
|
|
83
|
+
try {
|
|
84
|
+
const name = path.basename(filePath);
|
|
85
|
+
if (SECRET_FILE.test(name) && !ENV_TEMPLATE.test(name)) return "";
|
|
86
|
+
const stat = fs.statSync(filePath);
|
|
87
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return "";
|
|
88
|
+
return fs.readFileSync(filePath, "utf8");
|
|
89
|
+
} catch {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function exists(filePath) {
|
|
95
|
+
try {
|
|
96
|
+
return fs.existsSync(filePath);
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function listDir(directory) {
|
|
103
|
+
try {
|
|
104
|
+
return fs.readdirSync(directory, { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseJson(text) {
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(text);
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function firstLine(text) {
|
|
119
|
+
return String(text ?? "").split(/\r?\n/u).find((line) => line.trim().length > 0)?.trim() ?? "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function clampText(text, max) {
|
|
123
|
+
const value = String(text ?? "").replace(/\s+/gu, " ").trim();
|
|
124
|
+
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ------------------------------------------------------------------ */
|
|
128
|
+
/* detection */
|
|
129
|
+
/* ------------------------------------------------------------------ */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Find the nearest project root above `cwd`, without ever crossing the user's
|
|
133
|
+
* home directory or a filesystem root. Returns `{root, markers, confidence}`.
|
|
134
|
+
*/
|
|
135
|
+
export function detectProject(cwd) {
|
|
136
|
+
if (typeof cwd !== "string" || cwd.trim().length === 0) return undefined;
|
|
137
|
+
let current;
|
|
138
|
+
try {
|
|
139
|
+
current = path.resolve(cwd);
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const stop = path.parse(current).root;
|
|
144
|
+
const home = path.resolve(process.env.USERPROFILE || process.env.HOME || "");
|
|
145
|
+
const normalize = (value) => path.resolve(value).replaceAll("\\", "/").replace(/\/+$/u, "").toLowerCase();
|
|
146
|
+
for (let depth = 0; depth < 14; depth += 1) {
|
|
147
|
+
if (current === stop) break;
|
|
148
|
+
if (home && normalize(current) === normalize(home)) break;
|
|
149
|
+
const strong = STRONG_MARKERS.filter((marker) => exists(path.join(current, marker)));
|
|
150
|
+
const weak = WEAK_MARKERS.filter((marker) => exists(path.join(current, marker)));
|
|
151
|
+
/* A bare README/Makefile is not enough at the user's home or drive root. */
|
|
152
|
+
if (strong.length > 0) return { root: current, markers: [...strong, ...weak], confidence: strong.includes(".git") ? "high" : "medium" };
|
|
153
|
+
if (weak.length >= 2 && depth <= 3) return { root: current, markers: [...strong, ...weak], confidence: "low" };
|
|
154
|
+
const parent = path.dirname(current);
|
|
155
|
+
if (parent === current) break;
|
|
156
|
+
current = parent;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Direct child directories that look like separate projects. */
|
|
162
|
+
export function detectChildProjects(cwd, limit = 6) {
|
|
163
|
+
const root = path.resolve(cwd);
|
|
164
|
+
const found = [];
|
|
165
|
+
for (const entry of listDir(root)) {
|
|
166
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
167
|
+
const child = path.join(root, entry.name);
|
|
168
|
+
if (STRONG_MARKERS.some((marker) => exists(path.join(child, marker)))) found.push(child);
|
|
169
|
+
if (found.length >= limit) break;
|
|
170
|
+
}
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ------------------------------------------------------------------ */
|
|
175
|
+
/* git */
|
|
176
|
+
/* ------------------------------------------------------------------ */
|
|
177
|
+
|
|
178
|
+
export function gitInfo(root) {
|
|
179
|
+
try {
|
|
180
|
+
const dotGit = path.join(root, ".git");
|
|
181
|
+
if (!exists(dotGit)) return undefined;
|
|
182
|
+
let gitDir = dotGit;
|
|
183
|
+
try {
|
|
184
|
+
if (fs.statSync(dotGit).isFile()) {
|
|
185
|
+
const content = readSafe(dotGit);
|
|
186
|
+
const match = content.match(/gitdir:\s*(.+)/u);
|
|
187
|
+
if (match) gitDir = path.resolve(root, match[1].trim());
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
/* keep dotGit */
|
|
191
|
+
}
|
|
192
|
+
const headRaw = readSafe(path.join(gitDir, "HEAD")).trim();
|
|
193
|
+
let branch = "detached";
|
|
194
|
+
let head = headRaw.slice(0, 12) || "unknown";
|
|
195
|
+
if (headRaw.startsWith("ref: ")) {
|
|
196
|
+
const ref = headRaw.slice(5).trim();
|
|
197
|
+
branch = ref.replace(/^refs\/heads\//u, "");
|
|
198
|
+
head = readSafe(path.join(gitDir, ...ref.split("/"))).trim().slice(0, 12) || "unknown";
|
|
199
|
+
}
|
|
200
|
+
const config = readSafe(path.join(gitDir, "config"));
|
|
201
|
+
const remoteMatch = config.match(/url\s*=\s*(.+)/u);
|
|
202
|
+
const remote = remoteMatch ? redactText(remoteMatch[1].trim().replace(/\/\/[^@/]+@/u, "//[REDACTED]@")) : "";
|
|
203
|
+
return { branch, head, remote: remote.slice(0, 200) };
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/* ------------------------------------------------------------------ */
|
|
210
|
+
/* scan */
|
|
211
|
+
/* ------------------------------------------------------------------ */
|
|
212
|
+
|
|
213
|
+
function collectFiles(root) {
|
|
214
|
+
const stats = { files: 0, languages: new Map(), bytes: 0, truncated: false };
|
|
215
|
+
const walk = (directory, depth) => {
|
|
216
|
+
if (depth > MAX_DEPTH || stats.files >= MAX_FILES) {
|
|
217
|
+
if (stats.files >= MAX_FILES) stats.truncated = true;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
for (const entry of listDir(directory)) {
|
|
221
|
+
if (stats.files >= MAX_FILES) { stats.truncated = true; return; }
|
|
222
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".") && entry.name !== ".github") continue;
|
|
223
|
+
const full = path.join(directory, entry.name);
|
|
224
|
+
if (entry.isDirectory()) { walk(full, depth + 1); continue; }
|
|
225
|
+
if (!entry.isFile()) continue;
|
|
226
|
+
stats.files += 1;
|
|
227
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
228
|
+
if ([".php", ".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".rs", ".rb", ".java", ".cs", ".vue", ".svelte", ".sql", ".sh", ".ps1", ".css", ".scss"].includes(ext)) {
|
|
229
|
+
stats.languages.set(ext, (stats.languages.get(ext) ?? 0) + 1);
|
|
230
|
+
}
|
|
231
|
+
try { stats.bytes += fs.statSync(full).size; } catch { /* skip */ }
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
walk(root, 0);
|
|
235
|
+
return stats;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function unique(list, limit) {
|
|
239
|
+
return [...new Set(list.filter((item) => typeof item === "string" && item.trim().length > 0))].slice(0, limit);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseEnvKeys(root) {
|
|
243
|
+
const candidates = [".env.example", ".env.sample", ".env.dist", ".env.template", ".env.defaults"];
|
|
244
|
+
const keys = [];
|
|
245
|
+
for (const name of candidates) {
|
|
246
|
+
const text = readSafe(path.join(root, name));
|
|
247
|
+
if (text.length === 0) continue;
|
|
248
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
249
|
+
const match = line.match(/^\s*([A-Z][A-Z0-9_]{2,})\s*=/u);
|
|
250
|
+
if (match) keys.push(match[1]);
|
|
251
|
+
}
|
|
252
|
+
if (keys.length > 0) break;
|
|
253
|
+
}
|
|
254
|
+
return unique(keys, 60);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function detectStack({ packageInfo, composer, manifests, root, files }) {
|
|
258
|
+
const deps = new Set([
|
|
259
|
+
...Object.keys(packageInfo?.dependencies ?? {}),
|
|
260
|
+
...Object.keys(packageInfo?.devDependencies ?? {}),
|
|
261
|
+
...Object.keys(composer?.require ?? {}),
|
|
262
|
+
...Object.keys(composer?.["require-dev"] ?? {}),
|
|
263
|
+
]);
|
|
264
|
+
const labels = [];
|
|
265
|
+
const frameworks = [];
|
|
266
|
+
const databases = [];
|
|
267
|
+
const add = (list, value) => { if (value && !list.includes(value)) list.push(value); };
|
|
268
|
+
|
|
269
|
+
if (deps.has("laravel/framework") || exists(path.join(root, "artisan"))) { add(frameworks, "Laravel"); add(labels, "PHP · Laravel"); }
|
|
270
|
+
else if (deps.has("symfony/framework-bundle")) { add(frameworks, "Symfony"); add(labels, "PHP · Symfony"); }
|
|
271
|
+
else if (manifests.composer) { add(frameworks, "PHP"); add(labels, "PHP"); }
|
|
272
|
+
if (exists(path.join(root, "wp-config.php")) || exists(path.join(root, "wp-content"))) { add(frameworks, "WordPress"); add(labels, "WordPress"); }
|
|
273
|
+
if (exists(path.join(root, "bitrix")) || manifests.bitrix) { add(frameworks, "1C-Bitrix"); add(labels, "1C-Bitrix"); }
|
|
274
|
+
if (deps.has("next")) { add(frameworks, "Next.js"); add(labels, "Next.js"); }
|
|
275
|
+
if (deps.has("nuxt")) { add(frameworks, "Nuxt"); add(labels, "Nuxt"); }
|
|
276
|
+
if (deps.has("react")) { add(frameworks, "React"); add(labels, "React"); }
|
|
277
|
+
if (deps.has("vue")) { add(frameworks, "Vue"); add(labels, "Vue"); }
|
|
278
|
+
if (deps.has("svelte")) { add(frameworks, "Svelte"); add(labels, "Svelte"); }
|
|
279
|
+
if (deps.has("@angular/core")) { add(frameworks, "Angular"); add(labels, "Angular"); }
|
|
280
|
+
if (deps.has("vite")) { add(frameworks, "Vite"); add(labels, "Vite"); }
|
|
281
|
+
if (deps.has("typescript")) add(labels, "TypeScript");
|
|
282
|
+
if (deps.has("tailwindcss")) add(labels, "Tailwind");
|
|
283
|
+
if (deps.has("express")) { add(frameworks, "Express"); add(labels, "Node · Express"); }
|
|
284
|
+
if (deps.has("@nestjs/core")) { add(frameworks, "NestJS"); add(labels, "NestJS"); }
|
|
285
|
+
if (deps.has("django") || exists(path.join(root, "manage.py"))) { add(frameworks, "Django"); add(labels, "Python · Django"); }
|
|
286
|
+
if (deps.has("flask")) { add(frameworks, "Flask"); add(labels, "Python · Flask"); }
|
|
287
|
+
if (deps.has("fastapi")) { add(frameworks, "FastAPI"); add(labels, "Python · FastAPI"); }
|
|
288
|
+
if (deps.has("rails") || manifests.gemfile) { add(frameworks, "Rails"); add(labels, "Ruby · Rails"); }
|
|
289
|
+
if (manifests.go) { add(frameworks, "Go"); add(labels, "Go"); }
|
|
290
|
+
if (manifests.cargo) { add(frameworks, "Rust"); add(labels, "Rust"); }
|
|
291
|
+
if (manifests.pom || manifests.gradle) { add(frameworks, "Java"); add(labels, "Java"); }
|
|
292
|
+
if (manifests.dotnet) { add(frameworks, ".NET"); add(labels, ".NET"); }
|
|
293
|
+
if (manifests.pyproject || manifests.requirements) add(labels, "Python");
|
|
294
|
+
if (files.languages.get(".ts") || files.languages.get(".tsx")) add(labels, "TypeScript");
|
|
295
|
+
|
|
296
|
+
const dbMap = {
|
|
297
|
+
pg: "PostgreSQL", postgres: "PostgreSQL", mysql2: "MySQL", mysql: "MySQL", sqlite3: "SQLite",
|
|
298
|
+
mongodb: "MongoDB", mongoose: "MongoDB", redis: "Redis", prisma: "Prisma", sequelize: "Sequelize",
|
|
299
|
+
typeorm: "TypeORM", "drizzle-orm": "Drizzle", knex: "Knex",
|
|
300
|
+
};
|
|
301
|
+
for (const [dependency, label] of Object.entries(dbMap)) if (deps.has(dependency)) add(databases, label);
|
|
302
|
+
if (manifests.composer && (composer?.require?.["doctrine/dbal"] || composer?.require?.["laravel/framework"])) add(databases, "SQL (Eloquent)");
|
|
303
|
+
if (exists(path.join(root, "docker-compose.yml")) || exists(path.join(root, "docker-compose.yaml"))) add(labels, "Docker");
|
|
304
|
+
return { labels: unique(labels, 12), frameworks: unique(frameworks, 10), databases: unique(databases, 6) };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function detectCommands({ packageInfo, composer, root, manifests }) {
|
|
308
|
+
const commands = [];
|
|
309
|
+
const add = (label, command) => {
|
|
310
|
+
if (!command || commands.some((item) => item.command === command)) return;
|
|
311
|
+
commands.push({ label, command: clampText(redactText(command), 140) });
|
|
312
|
+
};
|
|
313
|
+
const scripts = packageInfo?.scripts ?? {};
|
|
314
|
+
for (const key of ["dev", "start", "build", "test", "lint", "typecheck", "check", "format"]) {
|
|
315
|
+
if (typeof scripts[key] === "string") add(`npm ${key}`, `npm run ${key}`);
|
|
316
|
+
}
|
|
317
|
+
if (packageInfo?.packageManager?.startsWith("pnpm")) {
|
|
318
|
+
for (const key of ["dev", "build", "test", "lint"]) if (typeof scripts[key] === "string") add(`pnpm ${key}`, `pnpm ${key}`);
|
|
319
|
+
}
|
|
320
|
+
if (composer?.scripts) {
|
|
321
|
+
for (const key of ["test", "lint", "serve", "dev"]) if (composer.scripts[key]) add(`composer ${key}`, `composer ${key}`);
|
|
322
|
+
}
|
|
323
|
+
if (exists(path.join(root, "artisan"))) {
|
|
324
|
+
add("serve", "php artisan serve");
|
|
325
|
+
add("test", "php artisan test");
|
|
326
|
+
add("migrate", "php artisan migrate");
|
|
327
|
+
}
|
|
328
|
+
if (exists(path.join(root, "manage.py"))) {
|
|
329
|
+
add("runserver", "python manage.py runserver");
|
|
330
|
+
add("test", "python manage.py test");
|
|
331
|
+
}
|
|
332
|
+
if (manifests.pyproject || manifests.requirements) {
|
|
333
|
+
if (exists(path.join(root, "pytest.ini")) || exists(path.join(root, "pyproject.toml"))) add("pytest", "pytest -q");
|
|
334
|
+
}
|
|
335
|
+
if (manifests.go) { add("test", "go test ./..."); add("run", "go run ."); }
|
|
336
|
+
if (manifests.cargo) { add("test", "cargo test"); add("run", "cargo run"); }
|
|
337
|
+
if (exists(path.join(root, "docker-compose.yml")) || exists(path.join(root, "docker-compose.yaml"))) add("docker", "docker compose up -d");
|
|
338
|
+
const makefile = readSafe(path.join(root, "Makefile"));
|
|
339
|
+
if (makefile) {
|
|
340
|
+
const targets = [...makefile.matchAll(/^([a-zA-Z0-9_.-]+):(?:\s|$)/gmu)].map((match) => match[1]).filter((name) => !name.startsWith(".")).slice(0, 8);
|
|
341
|
+
for (const target of targets) if (["test", "lint", "build", "dev", "run", "up", "install"].includes(target)) add(`make ${target}`, `make ${target}`);
|
|
342
|
+
}
|
|
343
|
+
return commands.slice(0, 16);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function detectTests(root, packageInfo) {
|
|
347
|
+
const frameworks = [];
|
|
348
|
+
if (exists(path.join(root, "phpunit.xml")) || exists(path.join(root, "phpunit.xml.dist"))) frameworks.push("PHPUnit");
|
|
349
|
+
if (exists(path.join(root, "pest.php"))) frameworks.push("Pest");
|
|
350
|
+
if (exists(path.join(root, "playwright.config.ts")) || exists(path.join(root, "playwright.config.js"))) frameworks.push("Playwright");
|
|
351
|
+
if (exists(path.join(root, "vitest.config.ts")) || exists(path.join(root, "vitest.config.js"))) frameworks.push("Vitest");
|
|
352
|
+
if (exists(path.join(root, "jest.config.js")) || exists(path.join(root, "jest.config.ts"))) frameworks.push("Jest");
|
|
353
|
+
if (exists(path.join(root, "pytest.ini")) || exists(path.join(root, "conftest.py"))) frameworks.push("pytest");
|
|
354
|
+
const deps = { ...(packageInfo?.dependencies ?? {}), ...(packageInfo?.devDependencies ?? {}) };
|
|
355
|
+
if (deps.vitest && !frameworks.includes("Vitest")) frameworks.push("Vitest");
|
|
356
|
+
if (deps.jest && !frameworks.includes("Jest")) frameworks.push("Jest");
|
|
357
|
+
if (deps["@playwright/test"] && !frameworks.includes("Playwright")) frameworks.push("Playwright");
|
|
358
|
+
const dirs = ["tests", "test", "spec", "__tests__", "cypress", "e2e"].filter((name) => exists(path.join(root, name)));
|
|
359
|
+
if (packageInfo?.scripts?.test) frameworks.push("npm test");
|
|
360
|
+
return { frameworks: unique(frameworks, 6), dirs };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function detectDocker(root) {
|
|
364
|
+
const file = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"].find((name) => exists(path.join(root, name)));
|
|
365
|
+
if (!file) return { file: "", services: [] };
|
|
366
|
+
const text = readSafe(path.join(root, file));
|
|
367
|
+
const services = [];
|
|
368
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
369
|
+
const match = line.match(/^\s{2}([a-zA-Z0-9_.-]+):\s*$/u);
|
|
370
|
+
if (match && match[1] !== "services" && match[1] !== "volumes" && match[1] !== "networks") services.push(match[1]);
|
|
371
|
+
}
|
|
372
|
+
return { file, services: unique(services, 10) };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function detectCi(root) {
|
|
376
|
+
const directory = path.join(root, ".github", "workflows");
|
|
377
|
+
return listDir(directory).filter((entry) => entry.isFile() && /\.ya?ml$/iu.test(entry.name)).map((entry) => entry.name).slice(0, 10);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function detectDocs(root) {
|
|
381
|
+
for (const name of DOC_FILES) {
|
|
382
|
+
const text = readSafe(path.join(root, name));
|
|
383
|
+
if (text.length === 0) continue;
|
|
384
|
+
const heading = firstLine(text).replace(/^#+\s*/u, "");
|
|
385
|
+
const body = text.replace(/^#.*$/gmu, "").split(/\r?\n\s*\r?\n/u).map((part) => part.trim()).find((part) => part.length > 20 && !part.startsWith("```") && !part.startsWith("<"));
|
|
386
|
+
return { file: name, heading: clampText(heading, 120), summary: clampText(redactText(body ?? ""), 320), agents: name === "AGENTS.md" || exists(path.join(root, "AGENTS.md")) };
|
|
387
|
+
}
|
|
388
|
+
return { file: "", heading: "", summary: "", agents: exists(path.join(root, "AGENTS.md")) };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function detectManifests(root) {
|
|
392
|
+
const manifests = {
|
|
393
|
+
package: exists(path.join(root, "package.json")),
|
|
394
|
+
composer: exists(path.join(root, "composer.json")),
|
|
395
|
+
pyproject: exists(path.join(root, "pyproject.toml")),
|
|
396
|
+
requirements: exists(path.join(root, "requirements.txt")),
|
|
397
|
+
go: exists(path.join(root, "go.mod")),
|
|
398
|
+
cargo: exists(path.join(root, "Cargo.toml")),
|
|
399
|
+
gemfile: exists(path.join(root, "Gemfile")),
|
|
400
|
+
pom: exists(path.join(root, "pom.xml")),
|
|
401
|
+
gradle: exists(path.join(root, "build.gradle")) || exists(path.join(root, "build.gradle.kts")),
|
|
402
|
+
dotnet: listDir(root).some((entry) => entry.isFile() && entry.name.endsWith(".sln")),
|
|
403
|
+
bitrix: exists(path.join(root, "bitrix")),
|
|
404
|
+
};
|
|
405
|
+
return manifests;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function detectPackageManager(root, packageInfo) {
|
|
409
|
+
if (packageInfo?.packageManager) return packageInfo.packageManager.split("@")[0];
|
|
410
|
+
if (exists(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
411
|
+
if (exists(path.join(root, "yarn.lock"))) return "yarn";
|
|
412
|
+
if (exists(path.join(root, "bun.lockb")) || exists(path.join(root, "bun.lock"))) return "bun";
|
|
413
|
+
if (exists(path.join(root, "package-lock.json"))) return "npm";
|
|
414
|
+
if (exists(path.join(root, "composer.lock"))) return "composer";
|
|
415
|
+
return "";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function detectFrameworksFromManifests(root, manifests) {
|
|
419
|
+
const values = {};
|
|
420
|
+
if (manifests.package) values.packageInfo = parseJson(readSafe(path.join(root, "package.json")));
|
|
421
|
+
if (manifests.composer) values.composer = parseJson(readSafe(path.join(root, "composer.json")));
|
|
422
|
+
if (manifests.pyproject) {
|
|
423
|
+
const text = readSafe(path.join(root, "pyproject.toml"));
|
|
424
|
+
const name = text.match(/^name\s*=\s*"([^"]+)"/mu);
|
|
425
|
+
const poetry = /\[tool\.poetry\.dependencies\]/u.test(text);
|
|
426
|
+
values.pyproject = { name: name?.[1] ?? "", poetry };
|
|
427
|
+
}
|
|
428
|
+
if (manifests.cargo) values.cargo = { name: (readSafe(path.join(root, "Cargo.toml")).match(/^name\s*=\s*"([^"]+)"/mu) ?? [])[1] ?? "" };
|
|
429
|
+
if (manifests.go) values.go = { module: (readSafe(path.join(root, "go.mod")).match(/^module\s+(\S+)/mu) ?? [])[1] ?? "" };
|
|
430
|
+
if (manifests.gemfile) {
|
|
431
|
+
const text = readSafe(path.join(root, "Gemfile"));
|
|
432
|
+
values.gemfile = [...text.matchAll(/^\s*gem\s+["']([^"']+)["']/gmu)].map((match) => match[1]);
|
|
433
|
+
}
|
|
434
|
+
if (manifests.pom) values.pom = { artifact: (readSafe(path.join(root, "pom.xml")).match(/<artifactId>([^<]+)<\/artifactId>/u) ?? [])[1] ?? "" };
|
|
435
|
+
return values;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Bounded local scan of one project root. Never throws; returns a JSON-safe
|
|
440
|
+
* fact sheet capped at a few kilobytes.
|
|
441
|
+
*/
|
|
442
|
+
export function scanProject(root, options = {}) {
|
|
443
|
+
const fact = {
|
|
444
|
+
version: SCAN_VERSION,
|
|
445
|
+
scannedAt: new Date().toISOString(),
|
|
446
|
+
root: path.resolve(root),
|
|
447
|
+
name: path.basename(path.resolve(root)),
|
|
448
|
+
git: undefined,
|
|
449
|
+
stack: { labels: [], frameworks: [], databases: [], languages: [] },
|
|
450
|
+
commands: [],
|
|
451
|
+
entryPoints: [],
|
|
452
|
+
keyDirs: [],
|
|
453
|
+
tests: { frameworks: [], dirs: [] },
|
|
454
|
+
ci: [],
|
|
455
|
+
docs: { file: "", heading: "", summary: "", agents: false },
|
|
456
|
+
env: { exampleKeys: [], hasEnv: false },
|
|
457
|
+
docker: { file: "", services: [] },
|
|
458
|
+
warnings: [],
|
|
459
|
+
stats: { files: 0, bytes: 0, truncated: false },
|
|
460
|
+
};
|
|
461
|
+
try {
|
|
462
|
+
const resolved = path.resolve(root);
|
|
463
|
+
if (!exists(resolved)) return fact;
|
|
464
|
+
const manifests = detectManifests(resolved);
|
|
465
|
+
const { packageInfo, composer } = detectFrameworksFromManifests(resolved, manifests);
|
|
466
|
+
const files = collectFiles(resolved);
|
|
467
|
+
const stack = detectStack({ packageInfo, composer, manifests, root: resolved, files });
|
|
468
|
+
fact.git = gitInfo(resolved);
|
|
469
|
+
fact.stack = {
|
|
470
|
+
...stack,
|
|
471
|
+
languages: [...files.languages.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([ext, count]) => ({ ext, count })),
|
|
472
|
+
};
|
|
473
|
+
fact.commands = detectCommands({ packageInfo, composer, root: resolved, manifests });
|
|
474
|
+
fact.entryPoints = ENTRY_CANDIDATES.filter((name) => exists(path.join(resolved, name))).slice(0, 12);
|
|
475
|
+
fact.keyDirs = KEY_DIRS.filter((name) => exists(path.join(resolved, name))).slice(0, 16);
|
|
476
|
+
fact.tests = detectTests(resolved, packageInfo);
|
|
477
|
+
fact.ci = detectCi(resolved);
|
|
478
|
+
fact.docs = detectDocs(resolved);
|
|
479
|
+
fact.env = { exampleKeys: parseEnvKeys(resolved), hasEnv: exists(path.join(resolved, ".env")) };
|
|
480
|
+
fact.docker = detectDocker(resolved);
|
|
481
|
+
fact.package = packageInfo
|
|
482
|
+
? { name: clampText(packageInfo.name ?? "", 80), version: clampText(packageInfo.version ?? "", 40), description: clampText(redactText(packageInfo.description ?? ""), 200), manager: detectPackageManager(resolved, packageInfo), engines: packageInfo.engines ?? undefined }
|
|
483
|
+
: undefined;
|
|
484
|
+
if (composer) fact.package = { ...(fact.package ?? {}), name: clampText(composer.name ?? fact.package?.name ?? "", 80), description: clampText(redactText(composer.description ?? fact.package?.description ?? ""), 200) };
|
|
485
|
+
fact.stats = { files: files.files, bytes: files.bytes, truncated: files.truncated };
|
|
486
|
+
if (!fact.docs.file) fact.warnings.push("нет README — назначение проекта неизвестно");
|
|
487
|
+
if (fact.tests.frameworks.length === 0 && fact.tests.dirs.length === 0) fact.warnings.push("тесты не обнаружены");
|
|
488
|
+
if (fact.env.hasEnv) fact.warnings.push("есть .env (не читался, значения не сохранены)");
|
|
489
|
+
if (fact.env.exampleKeys.length === 0 && (manifests.composer || manifests.package)) fact.warnings.push("нет .env.example — состав переменных неизвестен");
|
|
490
|
+
if (files.truncated) fact.warnings.push(`скан ограничен ${MAX_FILES} файлами`);
|
|
491
|
+
if (options.includeChildren !== false) {
|
|
492
|
+
const children = detectChildProjects(resolved, 4);
|
|
493
|
+
if (children.length > 1) fact.warnings.push(`рядом несколько проектов: ${children.map((child) => path.basename(child)).join(", ")} — уточни корень`);
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
fact.warnings.push(`скан не удался: ${clampText(String(error), 160)}`);
|
|
497
|
+
}
|
|
498
|
+
return fact;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* ------------------------------------------------------------------ */
|
|
502
|
+
/* rendering */
|
|
503
|
+
/* ------------------------------------------------------------------ */
|
|
504
|
+
|
|
505
|
+
function fmtCommands(commands) {
|
|
506
|
+
return commands.map((item) => `${item.label}: \`${item.command}\``).join(" · ");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function fmtLanguages(languages) {
|
|
510
|
+
return languages.map((item) => `${item.ext}×${item.count}`).join(", ");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Human-readable banner used by the digest and `brief`. */
|
|
514
|
+
export function scanSummary(facts) {
|
|
515
|
+
if (!facts) return [];
|
|
516
|
+
const lines = [];
|
|
517
|
+
if (facts.stack?.labels?.length) lines.push(`Стек: ${facts.stack.labels.join(" · ")}`);
|
|
518
|
+
if (facts.package?.name) lines.push(`Пакет: ${facts.package.name}${facts.package.version ? ` ${facts.package.version}` : ""}${facts.package.manager ? ` · ${facts.package.manager}` : ""}`);
|
|
519
|
+
if (facts.commands?.length) lines.push(`Команды: ${fmtCommands(facts.commands.slice(0, 6))}`);
|
|
520
|
+
if (facts.entryPoints?.length) lines.push(`Точки входа: ${facts.entryPoints.join(", ")}`);
|
|
521
|
+
if (facts.keyDirs?.length) lines.push(`Каталоги: ${facts.keyDirs.slice(0, 8).join(", ")}`);
|
|
522
|
+
if (facts.tests?.frameworks?.length || facts.tests?.dirs?.length) lines.push(`Тесты: ${[...(facts.tests.frameworks ?? []), ...(facts.tests.dirs ?? [])].join(", ")}`);
|
|
523
|
+
if (facts.stack?.databases?.length) lines.push(`Данные: ${facts.stack.databases.join(", ")}`);
|
|
524
|
+
if (facts.docs?.heading) lines.push(`README: ${facts.docs.heading}${facts.docs.summary ? ` — ${clampText(facts.docs.summary, 180)}` : ""}`);
|
|
525
|
+
if (facts.docs?.agents) lines.push("Есть AGENTS.md с правилами проекта.");
|
|
526
|
+
if (facts.env?.exampleKeys?.length) lines.push(`Переменные (.env.example, только имена): ${facts.env.exampleKeys.slice(0, 12).join(", ")}`);
|
|
527
|
+
if (facts.warnings?.length) lines.push(`Предупреждения: ${facts.warnings.join("; ")}`);
|
|
528
|
+
return lines;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Auto blocks appended to dossier sections (marked, so rescans replace them). */
|
|
532
|
+
export function renderAutoSections(facts) {
|
|
533
|
+
if (!facts) return {};
|
|
534
|
+
const stamp = facts.scannedAt?.slice(0, 16).replace("T", " ") ?? "—";
|
|
535
|
+
const stack = facts.stack?.labels?.join(" · ") || "не определён";
|
|
536
|
+
const git = facts.git ? `${facts.git.branch}@${facts.git.head}${facts.git.remote ? ` · ${facts.git.remote}` : ""}` : "не git или неизвестно";
|
|
537
|
+
const commands = facts.commands ?? [];
|
|
538
|
+
const entry = facts.entryPoints ?? [];
|
|
539
|
+
const dirs = facts.keyDirs ?? [];
|
|
540
|
+
const tests = facts.tests ?? {};
|
|
541
|
+
const warnings = facts.warnings ?? [];
|
|
542
|
+
const block = (body) => `<!-- AUTO-SCAN:START v${facts.version} -->\n${body}\n<!-- AUTO-SCAN:END -->`;
|
|
543
|
+
|
|
544
|
+
const project = block([
|
|
545
|
+
`## Автоскан (${stamp})`,
|
|
546
|
+
`- Стек: ${stack}`,
|
|
547
|
+
`- Git: ${git}`,
|
|
548
|
+
facts.package?.name ? `- Пакет: ${facts.package.name}${facts.package.version ? ` ${facts.package.version}` : ""}` : "",
|
|
549
|
+
commands.length ? `- Команды: ${fmtCommands(commands.slice(0, 6))}` : "",
|
|
550
|
+
facts.docs?.heading ? `- README: ${facts.docs.heading}` : "",
|
|
551
|
+
facts.stats ? `- Файлов в скане: ${facts.stats.files}${facts.stats.truncated ? " (ограничено)" : ""}; языки: ${fmtLanguages(facts.stack?.languages ?? [])}` : "",
|
|
552
|
+
warnings.length ? `- Предупреждения: ${warnings.join("; ")}` : "",
|
|
553
|
+
].filter(Boolean).join("\n"));
|
|
554
|
+
|
|
555
|
+
const architecture = block([
|
|
556
|
+
"## Автоскан структуры",
|
|
557
|
+
dirs.length ? `- Ключевые каталоги: ${dirs.join(", ")}` : "- Ключевые каталоги: не найдены",
|
|
558
|
+
entry.length ? `- Точки входа: ${entry.join(", ")}` : "",
|
|
559
|
+
facts.stack?.databases?.length ? `- Данные: ${facts.stack.databases.join(", ")}` : "",
|
|
560
|
+
facts.docker?.services?.length ? `- Docker: ${facts.docker.services.join(", ")}` : "",
|
|
561
|
+
facts.ci?.length ? `- CI: ${facts.ci.join(", ")}` : "",
|
|
562
|
+
].filter(Boolean).join("\n"));
|
|
563
|
+
|
|
564
|
+
const codeMap = block([
|
|
565
|
+
"## Автоскан карты кода",
|
|
566
|
+
entry.length ? `- Точки входа: ${entry.join(", ")}` : "- Точки входа: не найдены",
|
|
567
|
+
dirs.length ? `- Каталоги: ${dirs.join(", ")}` : "",
|
|
568
|
+
`- Тесты: ${[...(tests.frameworks ?? []), ...(tests.dirs ?? [])].join(", ") || "не обнаружены"}`,
|
|
569
|
+
].filter(Boolean).join("\n"));
|
|
570
|
+
|
|
571
|
+
const runbook = block([
|
|
572
|
+
"## Автоскан команд",
|
|
573
|
+
commands.length ? commands.map((item) => `- ${item.label}: \`${item.command}\``).join("\n") : "- Команды не обнаружены",
|
|
574
|
+
facts.env?.exampleKeys?.length ? `- Переменные окружения (только имена): ${facts.env.exampleKeys.slice(0, 20).join(", ")}` : "",
|
|
575
|
+
facts.docker?.file ? `- Compose: \`${facts.docker.file}\`` : "",
|
|
576
|
+
].filter(Boolean).join("\n"));
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
PROJECT: project.slice(0, MAX_BLOCK_CHARS),
|
|
580
|
+
ARCHITECTURE: architecture.slice(0, MAX_BLOCK_CHARS),
|
|
581
|
+
"CODE-MAP": codeMap.slice(0, MAX_BLOCK_CHARS),
|
|
582
|
+
RUNBOOK: runbook.slice(0, MAX_BLOCK_CHARS),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Replace an existing AUTO-SCAN block or append one without touching user text. */
|
|
587
|
+
export function mergeAutoBlock(existing, block) {
|
|
588
|
+
const text = String(existing ?? "");
|
|
589
|
+
if (!block) return text;
|
|
590
|
+
const pattern = /<!-- AUTO-SCAN:START[\s\S]*?AUTO-SCAN:END -->/gu;
|
|
591
|
+
if (pattern.test(text)) return text.replace(pattern, block);
|
|
592
|
+
return `${text.trimEnd()}\n\n${block}\n`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Compact one-screen project card for `brief`. */
|
|
596
|
+
export function renderBrief(facts, project, extras = {}) {
|
|
597
|
+
const lines = [`Проект: «${project.name}» (${project.slug})`, `Корень: ${project.roots?.[0] ?? "?"}`];
|
|
598
|
+
lines.push(...scanSummary(facts));
|
|
599
|
+
if (extras.invariants?.length) lines.push(`Инварианты: ${extras.invariants.join(" · ")}`);
|
|
600
|
+
if (extras.gotchas?.length) lines.push(`Грабли: ${extras.gotchas.join(" · ")}`);
|
|
601
|
+
if (extras.journal) lines.push(`Последний журнал: ${clampText(extras.journal, 300)}`);
|
|
602
|
+
lines.push(`Досье: ${extras.dossier ?? ""}`);
|
|
603
|
+
lines.push("Детали: project_memory action=read file=... или action=search query=...");
|
|
604
|
+
return lines.join("\n").slice(0, 2400);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export const SCAN_LIMITS = { MAX_DEPTH, MAX_FILES, MAX_FILE_BYTES };
|