dsh-desktop-kit 2.0.1 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -10
- package/docs/ARCHITECTURE.md +9 -0
- package/docs/COMPATIBILITY.md +8 -3
- package/docs/INSTALL.md +6 -5
- package/docs/PRIVACY.md +4 -0
- package/docs/TOKENS.md +11 -4
- 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 +322 -71
- package/plugins/dsh-project-memory/package.json +3 -2
- package/plugins/dsh-project-memory/scan.js +615 -0
- package/skills/project-deep-dive/SKILL.md +12 -11
- package/skills/project-memory-maintenance/SKILL.md +11 -11
|
@@ -17,6 +17,17 @@ import fs from "node:fs";
|
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
|
+
import {
|
|
21
|
+
SCAN_VERSION,
|
|
22
|
+
detectProject,
|
|
23
|
+
gitInfo,
|
|
24
|
+
scanProject,
|
|
25
|
+
renderAutoSections,
|
|
26
|
+
mergeAutoBlock,
|
|
27
|
+
scanSummary,
|
|
28
|
+
renderBrief,
|
|
29
|
+
redactText,
|
|
30
|
+
} from "./scan.js";
|
|
20
31
|
|
|
21
32
|
const MEMORY_ROOT = path.join(process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh"), "projects-memory");
|
|
22
33
|
const REGISTRY_FILE = path.join(MEMORY_ROOT, "registry.json");
|
|
@@ -25,6 +36,9 @@ const ACCESS_LOG = path.join(MEMORY_ROOT, "_access.log");
|
|
|
25
36
|
const ACCESS_LOG_MAX_LINES = 300;
|
|
26
37
|
const DEFAULT_DIGEST_BUDGET = 2800;
|
|
27
38
|
const SECTION_MAX_BYTES = 60 * 1024;
|
|
39
|
+
const SCAN_FILE = "scan.json";
|
|
40
|
+
const SCAN_TTL_MS = 24 * 3600 * 1000;
|
|
41
|
+
const SCAN_CHECK_MS = 5 * 60 * 1000;
|
|
28
42
|
const SECTION_NAMES = ["PROJECT", "ARCHITECTURE", "CODE-MAP", "INVARIANTS", "GOTCHAS", "RUNBOOK", "JOURNAL"];
|
|
29
43
|
const SECTION_FILES = {
|
|
30
44
|
PROJECT: "PROJECT.md",
|
|
@@ -35,7 +49,6 @@ const SECTION_FILES = {
|
|
|
35
49
|
RUNBOOK: "RUNBOOK.md",
|
|
36
50
|
JOURNAL: "JOURNAL",
|
|
37
51
|
};
|
|
38
|
-
const PROJECT_MARKERS = [".git", "AGENTS.md", "composer.json", "package.json", "artisan", "wp-config.php", "pyproject.toml", "go.mod", "Cargo.toml", "Gemfile", "bitrix"];
|
|
39
52
|
const CURATOR_IDLE_MS = 4 * 60 * 1000;
|
|
40
53
|
const STATE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
41
54
|
|
|
@@ -91,13 +104,15 @@ function readText(filePath) {
|
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
function writeText(filePath, text) {
|
|
94
|
-
|
|
107
|
+
ensurePrivateDir(path.dirname(filePath));
|
|
95
108
|
fs.writeFileSync(filePath, text, "utf8");
|
|
109
|
+
harden(filePath);
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
function appendText(filePath, text) {
|
|
99
|
-
|
|
113
|
+
ensurePrivateDir(path.dirname(filePath));
|
|
100
114
|
fs.appendFileSync(filePath, text, "utf8");
|
|
115
|
+
harden(filePath);
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
function exists(filePath) {
|
|
@@ -108,21 +123,23 @@ function exists(filePath) {
|
|
|
108
123
|
}
|
|
109
124
|
}
|
|
110
125
|
|
|
111
|
-
/**
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return result;
|
|
126
|
+
/** Keep the local dossier private on POSIX; chmod is best-effort on Windows. */
|
|
127
|
+
function harden(target, mode = 0o600) {
|
|
128
|
+
try {
|
|
129
|
+
fs.chmodSync(target, mode);
|
|
130
|
+
} catch {
|
|
131
|
+
/* Windows may only support a read-only bit; the file stays local either way */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ensurePrivateDir(directory) {
|
|
136
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
137
|
+
harden(directory, 0o700);
|
|
124
138
|
}
|
|
125
139
|
|
|
140
|
+
/** Every dossier/journal write is scrubbed before it reaches disk. */
|
|
141
|
+
const redactSecrets = redactText;
|
|
142
|
+
|
|
126
143
|
function truncate(text, max) {
|
|
127
144
|
const value = String(text ?? "");
|
|
128
145
|
return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`;
|
|
@@ -153,7 +170,11 @@ function defaultRegistry() {
|
|
|
153
170
|
digestBudgetChars: DEFAULT_DIGEST_BUDGET,
|
|
154
171
|
injectSubagents: false,
|
|
155
172
|
autoRegister: true,
|
|
156
|
-
|
|
173
|
+
autoScan: true,
|
|
174
|
+
autoRecall: true,
|
|
175
|
+
maxProjects: 200,
|
|
176
|
+
scanTtlHours: 24,
|
|
177
|
+
curator: { enabled: false, idleMs: CURATOR_IDLE_MS },
|
|
157
178
|
},
|
|
158
179
|
projects: [],
|
|
159
180
|
};
|
|
@@ -201,26 +222,6 @@ function saveState(sessions) {
|
|
|
201
222
|
}
|
|
202
223
|
}
|
|
203
224
|
|
|
204
|
-
/* ------------------------------------------------------------------ */
|
|
205
|
-
/* Git freshness */
|
|
206
|
-
/* ------------------------------------------------------------------ */
|
|
207
|
-
|
|
208
|
-
function gitInfo(root) {
|
|
209
|
-
try {
|
|
210
|
-
const gitDir = path.join(root, ".git");
|
|
211
|
-
if (!exists(gitDir)) return undefined;
|
|
212
|
-
const headRaw = readText(path.join(gitDir, "HEAD")).trim();
|
|
213
|
-
if (headRaw.startsWith("ref: ")) {
|
|
214
|
-
const ref = headRaw.slice(5).trim();
|
|
215
|
-
const head = readText(path.join(gitDir, ...ref.split("/"))).trim();
|
|
216
|
-
return { branch: ref.replace(/^refs\/heads\//u, ""), head: head.slice(0, 12) || "unknown" };
|
|
217
|
-
}
|
|
218
|
-
return { branch: "detached", head: headRaw.slice(0, 12) || "unknown" };
|
|
219
|
-
} catch {
|
|
220
|
-
return undefined;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
225
|
/* ------------------------------------------------------------------ */
|
|
225
226
|
/* Project resolution and dossier creation */
|
|
226
227
|
/* ------------------------------------------------------------------ */
|
|
@@ -230,14 +231,19 @@ function projectPublic(project) {
|
|
|
230
231
|
slug: project.slug,
|
|
231
232
|
name: project.name,
|
|
232
233
|
roots: [...project.roots],
|
|
234
|
+
stack: project.stack,
|
|
235
|
+
fingerprint: project.fingerprint,
|
|
233
236
|
dossier: path.join(MEMORY_ROOT, project.slug),
|
|
234
237
|
legacyDocs: Array.isArray(project.legacyDocs) ? [...project.legacyDocs] : [],
|
|
235
238
|
createdAt: project.createdAt,
|
|
236
239
|
updatedAt: project.updatedAt,
|
|
237
240
|
lastAnalyzedAt: project.lastAnalyzedAt ?? null,
|
|
241
|
+
lastScanAt: project.lastScanAt ?? null,
|
|
242
|
+
lastScanHead: project.lastScanHead ?? null,
|
|
238
243
|
lastJournalAt: project.lastJournalAt ?? null,
|
|
239
244
|
lastGitHead: project.lastGitHead ?? null,
|
|
240
245
|
confidence: project.confidence ?? "auto",
|
|
246
|
+
autoCreated: project.autoCreated === true,
|
|
241
247
|
};
|
|
242
248
|
}
|
|
243
249
|
|
|
@@ -259,16 +265,15 @@ function findProject(registry, cwd) {
|
|
|
259
265
|
return best ? projectPublic(best) : undefined;
|
|
260
266
|
}
|
|
261
267
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return undefined;
|
|
268
|
+
/** Stable identity across moves: git remote first, normalized path second. */
|
|
269
|
+
function fingerprintFor(root) {
|
|
270
|
+
const git = gitInfo(root);
|
|
271
|
+
return git?.remote ? `git:${git.remote}` : `path:${normalizePath(root)}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function findProjectByFingerprint(registry, fingerprint) {
|
|
275
|
+
if (!fingerprint || fingerprint.startsWith("path:")) return undefined;
|
|
276
|
+
return registry.projects.find((project) => project.fingerprint === fingerprint);
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
function defaultProjectFiles(project) {
|
|
@@ -363,7 +368,7 @@ function defaultProjectFiles(project) {
|
|
|
363
368
|
function ensureProjectDossier(project, options = {}) {
|
|
364
369
|
const directory = path.join(MEMORY_ROOT, project.slug);
|
|
365
370
|
try {
|
|
366
|
-
|
|
371
|
+
ensurePrivateDir(path.join(directory, "JOURNAL"));
|
|
367
372
|
const files = defaultProjectFiles(project);
|
|
368
373
|
for (const [name, content] of Object.entries(files)) {
|
|
369
374
|
const target = path.join(directory, name);
|
|
@@ -396,6 +401,19 @@ function createProject(registry, { name, root, stack, legacyDocs, autoCreated, c
|
|
|
396
401
|
if (!exists(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) throw new Error(`project root does not exist: ${resolvedRoot}`);
|
|
397
402
|
const existing = registry.projects.find((project) => project.roots.some((entry) => normalizePath(entry) === normalizePath(resolvedRoot)));
|
|
398
403
|
if (existing) return projectPublic(existing);
|
|
404
|
+
const fingerprint = fingerprintFor(resolvedRoot);
|
|
405
|
+
const twin = findProjectByFingerprint(registry, fingerprint);
|
|
406
|
+
if (twin) {
|
|
407
|
+
if (!twin.roots.some((entry) => normalizePath(entry) === normalizePath(resolvedRoot))) {
|
|
408
|
+
twin.roots = [resolvedRoot, ...twin.roots].slice(0, 8);
|
|
409
|
+
}
|
|
410
|
+
twin.updatedAt = nowIso();
|
|
411
|
+
twin.fingerprint = fingerprint;
|
|
412
|
+
saveRegistry(registry);
|
|
413
|
+
ensureProjectDossier(twin);
|
|
414
|
+
logAccess(`project-merged slug=${twin.slug} root=${resolvedRoot}`);
|
|
415
|
+
return projectPublic(twin);
|
|
416
|
+
}
|
|
399
417
|
let slug = slugify(name);
|
|
400
418
|
let counter = 2;
|
|
401
419
|
while (registry.projects.some((project) => project.slug === slug) || exists(path.join(MEMORY_ROOT, slug))) {
|
|
@@ -406,11 +424,15 @@ function createProject(registry, { name, root, stack, legacyDocs, autoCreated, c
|
|
|
406
424
|
slug,
|
|
407
425
|
name: name.trim(),
|
|
408
426
|
roots: [resolvedRoot],
|
|
427
|
+
fingerprint,
|
|
409
428
|
legacyDocs: Array.isArray(legacyDocs) ? legacyDocs.slice(0, 20) : [],
|
|
410
429
|
stack: typeof stack === "string" ? stack.slice(0, 200) : undefined,
|
|
411
430
|
createdAt: nowIso(),
|
|
412
431
|
updatedAt: nowIso(),
|
|
413
432
|
lastAnalyzedAt: null,
|
|
433
|
+
lastScanAt: null,
|
|
434
|
+
lastScanHead: null,
|
|
435
|
+
scanVersion: null,
|
|
414
436
|
lastJournalAt: null,
|
|
415
437
|
lastGitHead: null,
|
|
416
438
|
confidence: confidence ?? (autoCreated ? "auto" : "manual"),
|
|
@@ -423,17 +445,89 @@ function createProject(registry, { name, root, stack, legacyDocs, autoCreated, c
|
|
|
423
445
|
return projectPublic(project);
|
|
424
446
|
}
|
|
425
447
|
|
|
448
|
+
const scanChecked = new Map();
|
|
449
|
+
|
|
450
|
+
function loadScan(project) {
|
|
451
|
+
try {
|
|
452
|
+
const parsed = JSON.parse(readText(path.join(dossierDir(project), SCAN_FILE)));
|
|
453
|
+
if (!parsed || parsed.version !== SCAN_VERSION) return undefined;
|
|
454
|
+
return parsed;
|
|
455
|
+
} catch {
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function scanIsFresh(project, settings) {
|
|
461
|
+
if (!project.lastScanAt) return false;
|
|
462
|
+
const ttl = Math.max(1, Number(settings.scanTtlHours) || 24) * 3600 * 1000;
|
|
463
|
+
if (Date.now() - Date.parse(project.lastScanAt) > ttl) return false;
|
|
464
|
+
const git = project.roots?.[0] ? gitInfo(project.roots[0]) : undefined;
|
|
465
|
+
return !(git && project.lastScanHead && git.head !== project.lastScanHead);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Local scan + auto blocks in the dossier. Never throws into the prompt path. */
|
|
469
|
+
function scanNow(registry, project, reason = "auto") {
|
|
470
|
+
const record = registry.projects.find((item) => item.slug === project.slug);
|
|
471
|
+
if (!record) return undefined;
|
|
472
|
+
const facts = scanProject(record.roots[0]);
|
|
473
|
+
writeText(path.join(dossierDir(record), SCAN_FILE), `${JSON.stringify(facts, null, 2)}\n`);
|
|
474
|
+
const blocks = renderAutoSections(facts);
|
|
475
|
+
for (const [file, block] of Object.entries(blocks)) {
|
|
476
|
+
const target = path.join(dossierDir(record), SECTION_FILES[file]);
|
|
477
|
+
writeText(target, mergeAutoBlock(readText(target), block));
|
|
478
|
+
}
|
|
479
|
+
const stack = (facts.stack?.labels ?? []).join(" · ");
|
|
480
|
+
if (stack.length > 0) {
|
|
481
|
+
const projectFile = path.join(dossierDir(record), "PROJECT.md");
|
|
482
|
+
const text = readText(projectFile);
|
|
483
|
+
if (/- Стек: \(не определён\)/u.test(text)) {
|
|
484
|
+
writeText(projectFile, text.replace(/- Стек: \(не определён\)/u, `- Стек: ${stack}`));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
record.lastScanAt = facts.scannedAt;
|
|
488
|
+
record.lastScanHead = facts.git?.head ?? null;
|
|
489
|
+
record.scanVersion = facts.version;
|
|
490
|
+
record.stack = (facts.stack?.labels ?? []).join(" · ").slice(0, 200) || record.stack;
|
|
491
|
+
record.updatedAt = nowIso();
|
|
492
|
+
saveRegistry(registry);
|
|
493
|
+
rebuildIndex(record);
|
|
494
|
+
logAccess(`scan slug=${record.slug} reason=${reason} files=${facts.stats?.files ?? 0} warnings=${facts.warnings?.length ?? 0}`);
|
|
495
|
+
return facts;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function ensureScanned(registry, project, options = {}) {
|
|
499
|
+
const record = registry.projects.find((item) => item.slug === project.slug);
|
|
500
|
+
if (!record || registry.settings.autoScan !== true) return projectPublic(record ?? project);
|
|
501
|
+
const checked = scanChecked.get(record.slug);
|
|
502
|
+
if (!options.force && checked && Date.now() - checked < SCAN_CHECK_MS) return projectPublic(record);
|
|
503
|
+
scanChecked.set(record.slug, Date.now());
|
|
504
|
+
if (!options.force && scanIsFresh(record, registry.settings)) return projectPublic(record);
|
|
505
|
+
try {
|
|
506
|
+
scanNow(registry, record, options.reason ?? "auto");
|
|
507
|
+
} catch (error) {
|
|
508
|
+
logAccess(`scan-error slug=${record.slug} ${truncate(String(error), 200)}`);
|
|
509
|
+
}
|
|
510
|
+
return projectPublic(record);
|
|
511
|
+
}
|
|
512
|
+
|
|
426
513
|
function resolveProjectForSession(registry, session, settings, options = {}) {
|
|
427
514
|
if (!session) return undefined;
|
|
428
515
|
const cwd = options.cwd ?? sessionCwd(session);
|
|
429
516
|
const known = findProject(registry, cwd);
|
|
430
|
-
if (known) return known;
|
|
517
|
+
if (known) return ensureScanned(registry, known);
|
|
431
518
|
if (settings.autoRegister !== true) return undefined;
|
|
432
|
-
const detected =
|
|
519
|
+
const detected = detectProject(cwd)?.root;
|
|
433
520
|
if (!detected) return undefined;
|
|
434
521
|
if (isInside(MEMORY_ROOT, detected)) return undefined;
|
|
435
|
-
const
|
|
436
|
-
|
|
522
|
+
const home = path.resolve(process.env.USERPROFILE || process.env.HOME || "");
|
|
523
|
+
if (home && normalizePath(detected) === normalizePath(home)) return undefined;
|
|
524
|
+
if (path.dirname(detected) === detected) return undefined;
|
|
525
|
+
if (registry.projects.length >= (Number(settings.maxProjects) || 200)) {
|
|
526
|
+
logAccess(`auto-register-skipped reason=max-projects cwd=${normalizePath(cwd)}`);
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
const created = createProject(registry, { name: path.basename(detected), root: detected, autoCreated: true });
|
|
530
|
+
return ensureScanned(registry, created, { reason: "first-seen" });
|
|
437
531
|
}
|
|
438
532
|
|
|
439
533
|
function sessionCwd(session) {
|
|
@@ -555,12 +649,15 @@ function rebuildIndex(project) {
|
|
|
555
649
|
sections.push({ file: name, bytes: exists(target) ? fs.statSync(target).size : 0 });
|
|
556
650
|
}
|
|
557
651
|
const git = project.roots.length > 0 ? gitInfo(project.roots[0]) : undefined;
|
|
652
|
+
const scan = loadScan(project);
|
|
558
653
|
const index = {
|
|
559
654
|
slug: project.slug,
|
|
560
655
|
name: project.name,
|
|
561
656
|
roots: project.roots,
|
|
657
|
+
stack: project.stack ?? null,
|
|
562
658
|
generatedAt: nowIso(),
|
|
563
659
|
sections,
|
|
660
|
+
scan: scan ? { at: scan.scannedAt, stack: scan.stack?.labels ?? [], warnings: scan.warnings ?? [] } : null,
|
|
564
661
|
lastAnalyzedAt: project.lastAnalyzedAt ?? null,
|
|
565
662
|
lastJournalAt: project.lastJournalAt ?? null,
|
|
566
663
|
lastGitHead: git ? `${git.branch}@${git.head}` : null,
|
|
@@ -598,35 +695,91 @@ function bulletLines(text, limit) {
|
|
|
598
695
|
.map((line) => truncate(line.replace(/^[-*]\s+/u, "- "), 220));
|
|
599
696
|
}
|
|
600
697
|
|
|
698
|
+
const RECALL_STOP = new Set([
|
|
699
|
+
"этого", "этот", "этой", "чтобы", "когда", "после", "перед", "можно", "нужно", "надо",
|
|
700
|
+
"сделай", "сделать", "проект", "проекта", "проекте", "код", "файл", "файлы", "задача",
|
|
701
|
+
"задачи", "задачу", "есть", "было", "будет", "тоже", "только", "через", "всё", "все",
|
|
702
|
+
"please", "with", "this", "that", "from", "into", "with", "your", "have", "will", "make",
|
|
703
|
+
]);
|
|
704
|
+
|
|
705
|
+
function taskKeywords(text) {
|
|
706
|
+
const words = String(text ?? "").toLowerCase().match(/[a-zа-яё0-9_-]{4,}/gu) ?? [];
|
|
707
|
+
return [...new Set(words)].filter((word) => !RECALL_STOP.has(word)).slice(0, 6);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Top dossier lines that mention the current request; local and bounded. */
|
|
711
|
+
function recallForTask(project, text, limit = 3) {
|
|
712
|
+
const words = taskKeywords(text);
|
|
713
|
+
if (words.length === 0) return [];
|
|
714
|
+
const hits = [];
|
|
715
|
+
for (const word of words) {
|
|
716
|
+
for (const hit of searchDossier(project, word, 6)) {
|
|
717
|
+
const line = String(hit.text ?? "").trim();
|
|
718
|
+
if (/\.json$/u.test(hit.file)) continue;
|
|
719
|
+
if (/^- (?:Команды|Сессия|Изменённые файлы|Ошибки\/сбои|Память обновлена)(?=\s|:|$)/u.test(line)) continue;
|
|
720
|
+
if (line.length < 18 || /^#|AUTO-SCAN|<!--/u.test(line)) continue;
|
|
721
|
+
if (hits.some((item) => item.text === line)) continue;
|
|
722
|
+
hits.push({ ...hit, text: line });
|
|
723
|
+
if (hits.length >= limit) return hits.map((item) => `- ${item.file}:${item.line} — ${truncate(item.text, 180)}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return hits.map((item) => `- ${item.file}:${item.line} — ${truncate(item.text, 180)}`);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function hashText(text) {
|
|
730
|
+
let hash = 0;
|
|
731
|
+
const value = String(text ?? "");
|
|
732
|
+
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
|
733
|
+
return hash;
|
|
734
|
+
}
|
|
735
|
+
|
|
601
736
|
function buildDigest(registry, project, sessionId, settings, state) {
|
|
602
737
|
const budget = Math.max(800, Math.min(6000, settings.digestBudgetChars ?? DEFAULT_DIGEST_BUDGET));
|
|
603
738
|
const parts = [];
|
|
604
739
|
const fresh = freshness(project);
|
|
605
740
|
parts.push(`<project-memory slug="${project.slug}">`);
|
|
606
741
|
parts.push(`Проект: «${project.name}». Корень: ${truncate(project.roots[0] ?? "", 160)}. Досье: ${truncate(projectPublic(project).dossier, 180)}.`);
|
|
742
|
+
const facts = loadScan(project);
|
|
743
|
+
if (facts) {
|
|
744
|
+
const summary = scanSummary(facts);
|
|
745
|
+
if (summary.length > 0) {
|
|
746
|
+
parts.push("Автоскан проекта:");
|
|
747
|
+
parts.push(...summary.slice(0, 7));
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
parts.push("Автоскан ещё не выполнен: project_memory action=scan (локально, без сети).");
|
|
751
|
+
}
|
|
607
752
|
const projectFile = readText(path.join(dossierDir(project), "PROJECT.md"));
|
|
608
753
|
const stackLine = projectFile.split(/\r?\n/u).find((line) => /Стек:/u.test(line));
|
|
609
754
|
if (stackLine && !/\(не определён\)/u.test(stackLine)) parts.push(truncate(stackLine.trim(), 220));
|
|
610
755
|
parts.push(`Статус памяти: ${fresh.text}${fresh.git ? ` · git ${fresh.git.branch}@${fresh.git.head}` : ""}.`);
|
|
611
|
-
const invariants = bulletLines(readText(path.join(dossierDir(project), "INVARIANTS.md")),
|
|
756
|
+
const invariants = bulletLines(readText(path.join(dossierDir(project), "INVARIANTS.md")), 5);
|
|
612
757
|
if (invariants.length > 0) {
|
|
613
758
|
parts.push("Инварианты и запреты:");
|
|
614
759
|
parts.push(...invariants);
|
|
615
760
|
}
|
|
616
|
-
const gotchas = bulletLines(readText(path.join(dossierDir(project), "GOTCHAS.md")),
|
|
761
|
+
const gotchas = bulletLines(readText(path.join(dossierDir(project), "GOTCHAS.md")), 4);
|
|
617
762
|
if (gotchas.length > 0) {
|
|
618
763
|
parts.push("Известные грабли:");
|
|
619
764
|
parts.push(...gotchas);
|
|
620
765
|
}
|
|
621
|
-
const
|
|
766
|
+
const sessionState = state[sessionId];
|
|
767
|
+
const userText = sessionState?.lastUserText || sessionState?.firstUserText || "";
|
|
768
|
+
if (settings.autoRecall !== false && userText.length > 0) {
|
|
769
|
+
const recall = recallForTask(project, userText, 3);
|
|
770
|
+
if (recall.length > 0) {
|
|
771
|
+
parts.push("Похоже на текущую задачу (из досье, проверь):");
|
|
772
|
+
parts.push(...recall);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
const journal = latestJournalExcerpt(project, 480);
|
|
622
776
|
if (journal.length > 0) parts.push(`Последняя запись журнала: ${journal}`);
|
|
623
777
|
const legacy = (project.legacyDocs ?? []).slice(0, 4);
|
|
624
778
|
if (legacy.length > 0) parts.push(`Связанная память: ${legacy.join("; ")}`);
|
|
625
|
-
const sessionState = state[sessionId];
|
|
626
779
|
if (sessionState?.pendingReminder && sessionState.pendingReminder.projectSlug === project.slug) {
|
|
627
780
|
parts.push("НАПОМИНАНИЕ: в прошлом ходе проект менялся, но память не обновлялась. Допиши JOURNAL/разделы через project_memory, если это ещё актуально.");
|
|
628
781
|
}
|
|
629
|
-
parts.push("
|
|
782
|
+
parts.push("Как пользоваться: сначала этот дайджест; для деталей — project_memory action=brief (карточка), read file=... или search query=.... Не перечитывай файлы, если факт уже здесь; устаревшее обновляй через update_section. Для глубокого анализа — скилл project-deep-dive.");
|
|
630
783
|
parts.push("</project-memory>");
|
|
631
784
|
let text = parts.join("\n");
|
|
632
785
|
if (text.length > budget) text = `${truncate(text, budget)}\n</project-memory>`;
|
|
@@ -641,7 +794,7 @@ class DigestCache {
|
|
|
641
794
|
|
|
642
795
|
signature(project) {
|
|
643
796
|
const directory = dossierDir(project);
|
|
644
|
-
const files = ["PROJECT.md", "ARCHITECTURE.md", "CODE-MAP.md", "INVARIANTS.md", "GOTCHAS.md", "RUNBOOK.md", "INDEX.json"];
|
|
797
|
+
const files = ["PROJECT.md", "ARCHITECTURE.md", "CODE-MAP.md", "INVARIANTS.md", "GOTCHAS.md", "RUNBOOK.md", "INDEX.json", SCAN_FILE];
|
|
645
798
|
let stamp = "";
|
|
646
799
|
for (const name of files) {
|
|
647
800
|
const target = path.join(directory, name);
|
|
@@ -695,6 +848,7 @@ function sessionEntry(state, sessionId) {
|
|
|
695
848
|
commands: [],
|
|
696
849
|
errors: [],
|
|
697
850
|
firstUserText: "",
|
|
851
|
+
lastUserText: "",
|
|
698
852
|
memoryUpdatedTurns: [],
|
|
699
853
|
journaledTurns: [],
|
|
700
854
|
pendingReminder: null,
|
|
@@ -749,14 +903,17 @@ function trackSessionEvent(ctx, registry, state, session, event) {
|
|
|
749
903
|
if (entry.errors.length > 8) entry.errors.shift();
|
|
750
904
|
return;
|
|
751
905
|
}
|
|
752
|
-
if (event.type === "user/message"
|
|
906
|
+
if (event.type === "user/message") {
|
|
753
907
|
const content = event.data?.content ?? [];
|
|
754
908
|
const text = Array.isArray(content)
|
|
755
909
|
? content.filter((block) => block?.type === "text").map((block) => block.text).join("\n")
|
|
756
910
|
: "";
|
|
757
911
|
const cleaned = text.replace(/\s+/gu, " ").trim();
|
|
758
912
|
const injected = /^(Current runtime context|Updated instructions from|The user added|### Updated Tool Schemas|Background subagent|Time sampled|You are the Team Lead|Approval prompts are disabled|You are an AI agent|# \u0413\u041b\u041e\u0411\u0410\u041b\u042c\u041d\u042b\u0415|\u0418\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0438 from)/u.test(cleaned);
|
|
759
|
-
if (!injected
|
|
913
|
+
if (!injected && cleaned.length > 0) {
|
|
914
|
+
entry.lastUserText = truncate(cleaned, 400);
|
|
915
|
+
if (entry.firstUserText.length === 0) entry.firstUserText = truncate(cleaned, 160);
|
|
916
|
+
}
|
|
760
917
|
return;
|
|
761
918
|
}
|
|
762
919
|
if (event.type === "workspace/changes") {
|
|
@@ -822,7 +979,7 @@ async function runTool(deps, args, exec) {
|
|
|
822
979
|
const { registry, state } = deps;
|
|
823
980
|
const settings = registry.settings;
|
|
824
981
|
const agent = exec?.agent;
|
|
825
|
-
const session = agent?.session ?? (agent?.id
|
|
982
|
+
const session = agent?.session ?? (agent?.id && typeof deps.getSession === "function" ? deps.getSession(agent.id) : undefined);
|
|
826
983
|
const action = typeof args?.action === "string" ? args.action : "";
|
|
827
984
|
const sessionId = agent?.id ? String(agent.id) : undefined;
|
|
828
985
|
try {
|
|
@@ -833,14 +990,35 @@ async function runTool(deps, args, exec) {
|
|
|
833
990
|
stack: typeof args.stack === "string" ? args.stack : undefined,
|
|
834
991
|
legacyDocs: Array.isArray(args.legacyDocs) ? args.legacyDocs.filter((value) => typeof value === "string") : undefined,
|
|
835
992
|
});
|
|
993
|
+
let scan;
|
|
994
|
+
try { scan = scanNow(registry, project, "register"); } catch (error) { logAccess(`scan-error register ${truncate(String(error), 200)}`); }
|
|
836
995
|
markMemoryUpdated(state, sessionId);
|
|
837
|
-
return jsonResult(`Проект «${project.name}» зарегистрирован. Досье: ${project.dossier}`, { project, created: true });
|
|
996
|
+
return jsonResult(`Проект «${project.name}» зарегистрирован. Досье: ${project.dossier}${scan ? `\nАвтоскан: ${(scanSummary(scan).slice(0, 5).join("; "))}` : ""}`, { project, created: true, scan: scan ? { stack: scan.stack?.labels ?? [], warnings: scan.warnings ?? [] } : null });
|
|
838
997
|
}
|
|
839
998
|
if (action === "list") {
|
|
840
999
|
if (registry.projects.length === 0) return jsonResult("Пока нет ни одного проекта. Зарегистрируй: project_memory action=register name=... root=...", { projects: [] });
|
|
841
|
-
const lines = registry.projects.map((project) => `- ${project.slug} — ${project.name} · ${project.roots[0] ?? "?"} ·
|
|
1000
|
+
const lines = registry.projects.map((project) => `- ${project.slug} — ${project.name} · ${project.roots[0] ?? "?"}${project.stack ? ` · ${project.stack}` : ""} · скан: ${project.lastScanAt ?? "не было"} · анализ: ${project.lastAnalyzedAt ?? "не было"}`);
|
|
842
1001
|
return jsonResult(`Проекты (${registry.projects.length}):\n${lines.join("\n")}`, { projects: registry.projects.map(projectPublic) });
|
|
843
1002
|
}
|
|
1003
|
+
if (action === "brief") {
|
|
1004
|
+
const project = projectFromArgs(deps, args, session);
|
|
1005
|
+
let facts = loadScan(project);
|
|
1006
|
+
if (!facts && registry.settings.autoScan !== false) {
|
|
1007
|
+
try { facts = scanNow(registry, project, "brief"); } catch (error) { logAccess(`scan-error brief ${truncate(String(error), 200)}`); }
|
|
1008
|
+
}
|
|
1009
|
+
const invariants = bulletLines(readText(path.join(dossierDir(project), "INVARIANTS.md")), 3);
|
|
1010
|
+
const gotchas = bulletLines(readText(path.join(dossierDir(project), "GOTCHAS.md")), 3);
|
|
1011
|
+
const journal = latestJournalExcerpt(project, 300);
|
|
1012
|
+
const text = renderBrief(facts, project, { invariants, gotchas, journal, dossier: projectPublic(project).dossier });
|
|
1013
|
+
return jsonResult(text, { project, scan: facts ? { stack: facts.stack?.labels ?? [], warnings: facts.warnings ?? [] } : null });
|
|
1014
|
+
}
|
|
1015
|
+
if (action === "scan") {
|
|
1016
|
+
const project = projectFromArgs(deps, args, session);
|
|
1017
|
+
const facts = scanNow(registry, project, "tool");
|
|
1018
|
+
const summary = scanSummary(facts).slice(0, 10);
|
|
1019
|
+
markMemoryUpdated(state, sessionId);
|
|
1020
|
+
return jsonResult([`Автоскан «${project.name}» обновлён: ${facts.stats?.files ?? 0} файлов, ${facts.warnings?.length ?? 0} предупреждений.`, ...summary].join("\n"), { project, scan: facts });
|
|
1021
|
+
}
|
|
844
1022
|
if (action === "status") {
|
|
845
1023
|
const project = projectFromArgs(deps, args, session);
|
|
846
1024
|
const fresh = freshness(project);
|
|
@@ -849,16 +1027,21 @@ async function runTool(deps, args, exec) {
|
|
|
849
1027
|
const target = path.join(directory, name);
|
|
850
1028
|
return `${name}: ${exists(target) ? fs.statSync(target).size : 0} B`;
|
|
851
1029
|
});
|
|
1030
|
+
const facts = loadScan(project);
|
|
1031
|
+
const digest = buildDigest(registry, project, sessionId ?? "", registry.settings, state);
|
|
852
1032
|
const text = [
|
|
853
1033
|
`Проект: «${project.name}» (${project.slug})`,
|
|
854
1034
|
`Корень: ${project.roots.join(", ")}`,
|
|
855
1035
|
`Досье: ${directory}`,
|
|
1036
|
+
`Стек: ${project.stack ?? facts?.stack?.labels?.join(" · ") ?? "не определён"}`,
|
|
856
1037
|
`Свежесть: ${fresh.text}`,
|
|
1038
|
+
`Автоскан: ${project.lastScanAt ?? "не было"}${facts?.warnings?.length ? ` · предупреждений: ${facts.warnings.length}` : ""}`,
|
|
1039
|
+
`Дайджест: ~${Math.round(digest.length / 3.2)} токенов (${digest.length} символов)`,
|
|
857
1040
|
`Связанная память: ${(project.legacyDocs ?? []).join("; ") || "нет"}`,
|
|
858
1041
|
`Разделы: ${sizes.join("; ")}`,
|
|
859
|
-
`Настройки: бюджет
|
|
1042
|
+
`Настройки: бюджет ${settings.digestBudgetChars} символов, авто-регистрация ${settings.autoRegister ? "вкл" : "выкл"}, автоскан ${settings.autoScan ? "вкл" : "выкл"}`,
|
|
860
1043
|
].join("\n");
|
|
861
|
-
return jsonResult(text, { project, freshness: fresh });
|
|
1044
|
+
return jsonResult(text, { project, freshness: fresh, scan: facts ? { at: facts.scannedAt, warnings: facts.warnings } : null });
|
|
862
1045
|
}
|
|
863
1046
|
if (action === "read") {
|
|
864
1047
|
const project = projectFromArgs(deps, args, session);
|
|
@@ -918,8 +1101,8 @@ const TOOL_PARAMETERS = {
|
|
|
918
1101
|
properties: {
|
|
919
1102
|
action: {
|
|
920
1103
|
type: "string",
|
|
921
|
-
enum: ["status", "list", "read", "search", "append_journal", "update_section", "register"],
|
|
922
|
-
description: "status|list|read|search|append_journal|update_section|register",
|
|
1104
|
+
enum: ["status", "brief", "list", "read", "search", "scan", "append_journal", "update_section", "register"],
|
|
1105
|
+
description: "status|brief|list|read|search|scan|append_journal|update_section|register",
|
|
923
1106
|
},
|
|
924
1107
|
project: { type: "string", description: "Project slug or name; defaults to the current Session's project." },
|
|
925
1108
|
file: { type: "string", enum: SECTION_NAMES, description: "Dossier section for read/update_section." },
|
|
@@ -948,7 +1131,7 @@ function makeToolDefinition(deps) {
|
|
|
948
1131
|
return {
|
|
949
1132
|
name: "project_memory",
|
|
950
1133
|
description:
|
|
951
|
-
"Local long-term project memory.
|
|
1134
|
+
"Local long-term project memory. The plugin auto-detects the project from cwd, scans manifests/docs locally and injects a compact digest. Use brief for a one-screen card, status for freshness, read/search for details, scan to refresh the local recon, update_section/append_journal to record durable facts, register only when auto-detection is wrong. Never store secrets or PII; link to files instead of copying code.",
|
|
952
1135
|
parameters: TOOL_PARAMETERS,
|
|
953
1136
|
timeoutMs: 20000,
|
|
954
1137
|
output: {
|
|
@@ -973,7 +1156,7 @@ function makeToolDefinition(deps) {
|
|
|
973
1156
|
export async function apply(ctx) {
|
|
974
1157
|
const logger = ctx.logger;
|
|
975
1158
|
try {
|
|
976
|
-
|
|
1159
|
+
ensurePrivateDir(MEMORY_ROOT);
|
|
977
1160
|
} catch (error) {
|
|
978
1161
|
logger?.warn?.(`[project-memory] cannot create ${MEMORY_ROOT}: ${String(error)}`);
|
|
979
1162
|
}
|
|
@@ -981,7 +1164,12 @@ export async function apply(ctx) {
|
|
|
981
1164
|
const state = loadState();
|
|
982
1165
|
const digestCache = new DigestCache();
|
|
983
1166
|
const curatorTimers = new Map();
|
|
984
|
-
const deps = {
|
|
1167
|
+
const deps = {
|
|
1168
|
+
registry,
|
|
1169
|
+
state,
|
|
1170
|
+
digestCache,
|
|
1171
|
+
getSession: (id) => (typeof id === "string" ? ctx.get("sessions")?.get(id) : undefined),
|
|
1172
|
+
};
|
|
985
1173
|
|
|
986
1174
|
/** Effective cwd of a session: projection first, durable header second. */
|
|
987
1175
|
const workingDirectory = ctx.get("workingDirectory");
|
|
@@ -1019,7 +1207,8 @@ export async function apply(ctx) {
|
|
|
1019
1207
|
if (!project) return "";
|
|
1020
1208
|
const signature = digestCache.signature(project);
|
|
1021
1209
|
const sessionId = String(session.id);
|
|
1022
|
-
const
|
|
1210
|
+
const userText = state[sessionId]?.lastUserText ?? state[sessionId]?.firstUserText ?? "";
|
|
1211
|
+
const key = `${sessionId}:${project.slug}:${hashText(userText)}`;
|
|
1023
1212
|
const text = digestCache.get(key, signature, () => buildDigest(registry, project, sessionId, registry.settings, state));
|
|
1024
1213
|
logAccess(`digest session=${sessionId} project=${project.slug} chars=${text.length}`);
|
|
1025
1214
|
return text;
|
|
@@ -1228,6 +1417,68 @@ ${summary}
|
|
|
1228
1417
|
}
|
|
1229
1418
|
});
|
|
1230
1419
|
|
|
1420
|
+
/* ---------------- user command /memory ---------------- */
|
|
1421
|
+
ctx.inject(["commands"], (commandCtx) => {
|
|
1422
|
+
try {
|
|
1423
|
+
const dispose = commandCtx.commands.register({
|
|
1424
|
+
name: "memory",
|
|
1425
|
+
description: "Локальная память проекта: статус, карточка, автоскан, вкл/выкл",
|
|
1426
|
+
input: { hint: "status | brief | scan | on | off | projects" },
|
|
1427
|
+
recordInput: false,
|
|
1428
|
+
handler: ({ agent, rawInput }) => {
|
|
1429
|
+
const session = agent?.session;
|
|
1430
|
+
const command = String(rawInput ?? "").trim().toLowerCase();
|
|
1431
|
+
try {
|
|
1432
|
+
if (command === "projects") {
|
|
1433
|
+
if (registry.projects.length === 0) return { kind: "success", text: "Пока нет проектов: открой папку проекта — он зарегистрируется автоматически." };
|
|
1434
|
+
return { kind: "success", text: registry.projects.map((project) => `- ${project.slug} — ${project.name}${project.stack ? ` · ${project.stack}` : ""} · скан: ${project.lastScanAt ?? "не было"}`).join("\n") };
|
|
1435
|
+
}
|
|
1436
|
+
if (command === "on" || command === "off") {
|
|
1437
|
+
registry.settings.autoRegister = command === "on";
|
|
1438
|
+
registry.settings.autoScan = command === "on";
|
|
1439
|
+
saveRegistry(registry);
|
|
1440
|
+
return { kind: "success", text: `Память проектов: авто-регистрация и автоскан ${command === "on" ? "включены" : "выключены"}.` };
|
|
1441
|
+
}
|
|
1442
|
+
const project = session ? resolveProjectForSession(registry, session, registry.settings, { cwd: effectiveCwd(session) }) : undefined;
|
|
1443
|
+
if (!project) return { kind: "success", text: "Проект не определён: открой папку с маркером проекта (.git, package.json, composer.json, artisan…)." };
|
|
1444
|
+
if (command === "scan") {
|
|
1445
|
+
const facts = scanNow(registry, project, "command");
|
|
1446
|
+
return { kind: "success", text: `Автоскан «${project.name}» обновлён.\n${scanSummary(facts).slice(0, 8).join("\n")}` };
|
|
1447
|
+
}
|
|
1448
|
+
if (command === "brief") {
|
|
1449
|
+
const facts = loadScan(project);
|
|
1450
|
+
const invariants = bulletLines(readText(path.join(dossierDir(project), "INVARIANTS.md")), 3);
|
|
1451
|
+
const gotchas = bulletLines(readText(path.join(dossierDir(project), "GOTCHAS.md")), 3);
|
|
1452
|
+
return { kind: "success", text: renderBrief(facts, project, { invariants, gotchas, journal: latestJournalExcerpt(project, 260), dossier: projectPublic(project).dossier }) };
|
|
1453
|
+
}
|
|
1454
|
+
const facts = loadScan(project);
|
|
1455
|
+
const fresh = freshness(project);
|
|
1456
|
+
const digest = buildDigest(registry, project, String(session.id), registry.settings, state);
|
|
1457
|
+
return {
|
|
1458
|
+
kind: "success",
|
|
1459
|
+
text: [
|
|
1460
|
+
`Проект: «${project.name}» (${project.slug})`,
|
|
1461
|
+
`Корень: ${project.roots[0] ?? "?"}`,
|
|
1462
|
+
`Досье: ${projectPublic(project).dossier}`,
|
|
1463
|
+
`Свежесть: ${fresh.text}`,
|
|
1464
|
+
`Стек: ${project.stack ?? facts?.stack?.labels?.join(" · ") ?? "не определён"}`,
|
|
1465
|
+
`Автоскан: ${project.lastScanAt ?? "не было"}${facts?.warnings?.length ? ` · предупреждений: ${facts.warnings.length}` : ""}`,
|
|
1466
|
+
`Дайджест: ~${Math.round(digest.length / 3.2)} токенов`,
|
|
1467
|
+
"Команды: /memory brief | scan | on | off | projects",
|
|
1468
|
+
].join("\n"),
|
|
1469
|
+
};
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
return { kind: "error", text: `Память проекта: ${error instanceof Error ? error.message : String(error)}` };
|
|
1472
|
+
}
|
|
1473
|
+
},
|
|
1474
|
+
});
|
|
1475
|
+
ctx.effect(() => dispose, "project-memory: /memory command");
|
|
1476
|
+
logAccess("command-registered name=memory");
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
logger?.warn?.(`[project-memory] /memory registration failed: ${String(error)}`);
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1231
1482
|
/* ---------------- cleanup ---------------- */
|
|
1232
1483
|
ctx.effect(() => () => {
|
|
1233
1484
|
for (const timer of curatorTimers.values()) clearTimeout(timer);
|