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
|
@@ -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,81 @@ 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
|
+
record.lastScanAt = facts.scannedAt;
|
|
480
|
+
record.lastScanHead = facts.git?.head ?? null;
|
|
481
|
+
record.scanVersion = facts.version;
|
|
482
|
+
record.stack = (facts.stack?.labels ?? []).join(" · ").slice(0, 200) || record.stack;
|
|
483
|
+
record.updatedAt = nowIso();
|
|
484
|
+
saveRegistry(registry);
|
|
485
|
+
rebuildIndex(record);
|
|
486
|
+
logAccess(`scan slug=${record.slug} reason=${reason} files=${facts.stats?.files ?? 0} warnings=${facts.warnings?.length ?? 0}`);
|
|
487
|
+
return facts;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function ensureScanned(registry, project, options = {}) {
|
|
491
|
+
const record = registry.projects.find((item) => item.slug === project.slug);
|
|
492
|
+
if (!record || registry.settings.autoScan !== true) return projectPublic(record ?? project);
|
|
493
|
+
const checked = scanChecked.get(record.slug);
|
|
494
|
+
if (!options.force && checked && Date.now() - checked < SCAN_CHECK_MS) return projectPublic(record);
|
|
495
|
+
scanChecked.set(record.slug, Date.now());
|
|
496
|
+
if (!options.force && scanIsFresh(record, registry.settings)) return projectPublic(record);
|
|
497
|
+
try {
|
|
498
|
+
scanNow(registry, record, options.reason ?? "auto");
|
|
499
|
+
} catch (error) {
|
|
500
|
+
logAccess(`scan-error slug=${record.slug} ${truncate(String(error), 200)}`);
|
|
501
|
+
}
|
|
502
|
+
return projectPublic(record);
|
|
503
|
+
}
|
|
504
|
+
|
|
426
505
|
function resolveProjectForSession(registry, session, settings, options = {}) {
|
|
427
506
|
if (!session) return undefined;
|
|
428
507
|
const cwd = options.cwd ?? sessionCwd(session);
|
|
429
508
|
const known = findProject(registry, cwd);
|
|
430
|
-
if (known) return known;
|
|
509
|
+
if (known) return ensureScanned(registry, known);
|
|
431
510
|
if (settings.autoRegister !== true) return undefined;
|
|
432
|
-
const detected =
|
|
511
|
+
const detected = detectProject(cwd)?.root;
|
|
433
512
|
if (!detected) return undefined;
|
|
434
513
|
if (isInside(MEMORY_ROOT, detected)) return undefined;
|
|
435
|
-
const
|
|
436
|
-
|
|
514
|
+
const home = path.resolve(process.env.USERPROFILE || process.env.HOME || "");
|
|
515
|
+
if (home && normalizePath(detected) === normalizePath(home)) return undefined;
|
|
516
|
+
if (path.dirname(detected) === detected) return undefined;
|
|
517
|
+
if (registry.projects.length >= (Number(settings.maxProjects) || 200)) {
|
|
518
|
+
logAccess(`auto-register-skipped reason=max-projects cwd=${normalizePath(cwd)}`);
|
|
519
|
+
return undefined;
|
|
520
|
+
}
|
|
521
|
+
const created = createProject(registry, { name: path.basename(detected), root: detected, autoCreated: true });
|
|
522
|
+
return ensureScanned(registry, created, { reason: "first-seen" });
|
|
437
523
|
}
|
|
438
524
|
|
|
439
525
|
function sessionCwd(session) {
|
|
@@ -555,12 +641,15 @@ function rebuildIndex(project) {
|
|
|
555
641
|
sections.push({ file: name, bytes: exists(target) ? fs.statSync(target).size : 0 });
|
|
556
642
|
}
|
|
557
643
|
const git = project.roots.length > 0 ? gitInfo(project.roots[0]) : undefined;
|
|
644
|
+
const scan = loadScan(project);
|
|
558
645
|
const index = {
|
|
559
646
|
slug: project.slug,
|
|
560
647
|
name: project.name,
|
|
561
648
|
roots: project.roots,
|
|
649
|
+
stack: project.stack ?? null,
|
|
562
650
|
generatedAt: nowIso(),
|
|
563
651
|
sections,
|
|
652
|
+
scan: scan ? { at: scan.scannedAt, stack: scan.stack?.labels ?? [], warnings: scan.warnings ?? [] } : null,
|
|
564
653
|
lastAnalyzedAt: project.lastAnalyzedAt ?? null,
|
|
565
654
|
lastJournalAt: project.lastJournalAt ?? null,
|
|
566
655
|
lastGitHead: git ? `${git.branch}@${git.head}` : null,
|
|
@@ -598,35 +687,91 @@ function bulletLines(text, limit) {
|
|
|
598
687
|
.map((line) => truncate(line.replace(/^[-*]\s+/u, "- "), 220));
|
|
599
688
|
}
|
|
600
689
|
|
|
690
|
+
const RECALL_STOP = new Set([
|
|
691
|
+
"этого", "этот", "этой", "чтобы", "когда", "после", "перед", "можно", "нужно", "надо",
|
|
692
|
+
"сделай", "сделать", "проект", "проекта", "проекте", "код", "файл", "файлы", "задача",
|
|
693
|
+
"задачи", "задачу", "есть", "было", "будет", "тоже", "только", "через", "всё", "все",
|
|
694
|
+
"please", "with", "this", "that", "from", "into", "with", "your", "have", "will", "make",
|
|
695
|
+
]);
|
|
696
|
+
|
|
697
|
+
function taskKeywords(text) {
|
|
698
|
+
const words = String(text ?? "").toLowerCase().match(/[a-zа-яё0-9_-]{4,}/gu) ?? [];
|
|
699
|
+
return [...new Set(words)].filter((word) => !RECALL_STOP.has(word)).slice(0, 6);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Top dossier lines that mention the current request; local and bounded. */
|
|
703
|
+
function recallForTask(project, text, limit = 3) {
|
|
704
|
+
const words = taskKeywords(text);
|
|
705
|
+
if (words.length === 0) return [];
|
|
706
|
+
const hits = [];
|
|
707
|
+
for (const word of words) {
|
|
708
|
+
for (const hit of searchDossier(project, word, 6)) {
|
|
709
|
+
const line = String(hit.text ?? "").trim();
|
|
710
|
+
if (/\.json$/u.test(hit.file)) continue;
|
|
711
|
+
if (/^- (?:Команды|Сессия|Изменённые файлы|Ошибки\/сбои|Память обновлена)(?=\s|:|$)/u.test(line)) continue;
|
|
712
|
+
if (line.length < 18 || /^#|AUTO-SCAN|<!--/u.test(line)) continue;
|
|
713
|
+
if (hits.some((item) => item.text === line)) continue;
|
|
714
|
+
hits.push({ ...hit, text: line });
|
|
715
|
+
if (hits.length >= limit) return hits.map((item) => `- ${item.file}:${item.line} — ${truncate(item.text, 180)}`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return hits.map((item) => `- ${item.file}:${item.line} — ${truncate(item.text, 180)}`);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function hashText(text) {
|
|
722
|
+
let hash = 0;
|
|
723
|
+
const value = String(text ?? "");
|
|
724
|
+
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
|
725
|
+
return hash;
|
|
726
|
+
}
|
|
727
|
+
|
|
601
728
|
function buildDigest(registry, project, sessionId, settings, state) {
|
|
602
729
|
const budget = Math.max(800, Math.min(6000, settings.digestBudgetChars ?? DEFAULT_DIGEST_BUDGET));
|
|
603
730
|
const parts = [];
|
|
604
731
|
const fresh = freshness(project);
|
|
605
732
|
parts.push(`<project-memory slug="${project.slug}">`);
|
|
606
733
|
parts.push(`Проект: «${project.name}». Корень: ${truncate(project.roots[0] ?? "", 160)}. Досье: ${truncate(projectPublic(project).dossier, 180)}.`);
|
|
734
|
+
const facts = loadScan(project);
|
|
735
|
+
if (facts) {
|
|
736
|
+
const summary = scanSummary(facts);
|
|
737
|
+
if (summary.length > 0) {
|
|
738
|
+
parts.push("Автоскан проекта:");
|
|
739
|
+
parts.push(...summary.slice(0, 7));
|
|
740
|
+
}
|
|
741
|
+
} else {
|
|
742
|
+
parts.push("Автоскан ещё не выполнен: project_memory action=scan (локально, без сети).");
|
|
743
|
+
}
|
|
607
744
|
const projectFile = readText(path.join(dossierDir(project), "PROJECT.md"));
|
|
608
745
|
const stackLine = projectFile.split(/\r?\n/u).find((line) => /Стек:/u.test(line));
|
|
609
746
|
if (stackLine && !/\(не определён\)/u.test(stackLine)) parts.push(truncate(stackLine.trim(), 220));
|
|
610
747
|
parts.push(`Статус памяти: ${fresh.text}${fresh.git ? ` · git ${fresh.git.branch}@${fresh.git.head}` : ""}.`);
|
|
611
|
-
const invariants = bulletLines(readText(path.join(dossierDir(project), "INVARIANTS.md")),
|
|
748
|
+
const invariants = bulletLines(readText(path.join(dossierDir(project), "INVARIANTS.md")), 5);
|
|
612
749
|
if (invariants.length > 0) {
|
|
613
750
|
parts.push("Инварианты и запреты:");
|
|
614
751
|
parts.push(...invariants);
|
|
615
752
|
}
|
|
616
|
-
const gotchas = bulletLines(readText(path.join(dossierDir(project), "GOTCHAS.md")),
|
|
753
|
+
const gotchas = bulletLines(readText(path.join(dossierDir(project), "GOTCHAS.md")), 4);
|
|
617
754
|
if (gotchas.length > 0) {
|
|
618
755
|
parts.push("Известные грабли:");
|
|
619
756
|
parts.push(...gotchas);
|
|
620
757
|
}
|
|
621
|
-
const
|
|
758
|
+
const sessionState = state[sessionId];
|
|
759
|
+
const userText = sessionState?.lastUserText || sessionState?.firstUserText || "";
|
|
760
|
+
if (settings.autoRecall !== false && userText.length > 0) {
|
|
761
|
+
const recall = recallForTask(project, userText, 3);
|
|
762
|
+
if (recall.length > 0) {
|
|
763
|
+
parts.push("Похоже на текущую задачу (из досье, проверь):");
|
|
764
|
+
parts.push(...recall);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const journal = latestJournalExcerpt(project, 480);
|
|
622
768
|
if (journal.length > 0) parts.push(`Последняя запись журнала: ${journal}`);
|
|
623
769
|
const legacy = (project.legacyDocs ?? []).slice(0, 4);
|
|
624
770
|
if (legacy.length > 0) parts.push(`Связанная память: ${legacy.join("; ")}`);
|
|
625
|
-
const sessionState = state[sessionId];
|
|
626
771
|
if (sessionState?.pendingReminder && sessionState.pendingReminder.projectSlug === project.slug) {
|
|
627
772
|
parts.push("НАПОМИНАНИЕ: в прошлом ходе проект менялся, но память не обновлялась. Допиши JOURNAL/разделы через project_memory, если это ещё актуально.");
|
|
628
773
|
}
|
|
629
|
-
parts.push("
|
|
774
|
+
parts.push("Как пользоваться: сначала этот дайджест; для деталей — project_memory action=brief (карточка), read file=... или search query=.... Не перечитывай файлы, если факт уже здесь; устаревшее обновляй через update_section. Для глубокого анализа — скилл project-deep-dive.");
|
|
630
775
|
parts.push("</project-memory>");
|
|
631
776
|
let text = parts.join("\n");
|
|
632
777
|
if (text.length > budget) text = `${truncate(text, budget)}\n</project-memory>`;
|
|
@@ -641,7 +786,7 @@ class DigestCache {
|
|
|
641
786
|
|
|
642
787
|
signature(project) {
|
|
643
788
|
const directory = dossierDir(project);
|
|
644
|
-
const files = ["PROJECT.md", "ARCHITECTURE.md", "CODE-MAP.md", "INVARIANTS.md", "GOTCHAS.md", "RUNBOOK.md", "INDEX.json"];
|
|
789
|
+
const files = ["PROJECT.md", "ARCHITECTURE.md", "CODE-MAP.md", "INVARIANTS.md", "GOTCHAS.md", "RUNBOOK.md", "INDEX.json", SCAN_FILE];
|
|
645
790
|
let stamp = "";
|
|
646
791
|
for (const name of files) {
|
|
647
792
|
const target = path.join(directory, name);
|
|
@@ -695,6 +840,7 @@ function sessionEntry(state, sessionId) {
|
|
|
695
840
|
commands: [],
|
|
696
841
|
errors: [],
|
|
697
842
|
firstUserText: "",
|
|
843
|
+
lastUserText: "",
|
|
698
844
|
memoryUpdatedTurns: [],
|
|
699
845
|
journaledTurns: [],
|
|
700
846
|
pendingReminder: null,
|
|
@@ -749,14 +895,17 @@ function trackSessionEvent(ctx, registry, state, session, event) {
|
|
|
749
895
|
if (entry.errors.length > 8) entry.errors.shift();
|
|
750
896
|
return;
|
|
751
897
|
}
|
|
752
|
-
if (event.type === "user/message"
|
|
898
|
+
if (event.type === "user/message") {
|
|
753
899
|
const content = event.data?.content ?? [];
|
|
754
900
|
const text = Array.isArray(content)
|
|
755
901
|
? content.filter((block) => block?.type === "text").map((block) => block.text).join("\n")
|
|
756
902
|
: "";
|
|
757
903
|
const cleaned = text.replace(/\s+/gu, " ").trim();
|
|
758
904
|
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
|
|
905
|
+
if (!injected && cleaned.length > 0) {
|
|
906
|
+
entry.lastUserText = truncate(cleaned, 400);
|
|
907
|
+
if (entry.firstUserText.length === 0) entry.firstUserText = truncate(cleaned, 160);
|
|
908
|
+
}
|
|
760
909
|
return;
|
|
761
910
|
}
|
|
762
911
|
if (event.type === "workspace/changes") {
|
|
@@ -822,7 +971,7 @@ async function runTool(deps, args, exec) {
|
|
|
822
971
|
const { registry, state } = deps;
|
|
823
972
|
const settings = registry.settings;
|
|
824
973
|
const agent = exec?.agent;
|
|
825
|
-
const session = agent?.session ?? (agent?.id
|
|
974
|
+
const session = agent?.session ?? (agent?.id && typeof deps.getSession === "function" ? deps.getSession(agent.id) : undefined);
|
|
826
975
|
const action = typeof args?.action === "string" ? args.action : "";
|
|
827
976
|
const sessionId = agent?.id ? String(agent.id) : undefined;
|
|
828
977
|
try {
|
|
@@ -833,14 +982,35 @@ async function runTool(deps, args, exec) {
|
|
|
833
982
|
stack: typeof args.stack === "string" ? args.stack : undefined,
|
|
834
983
|
legacyDocs: Array.isArray(args.legacyDocs) ? args.legacyDocs.filter((value) => typeof value === "string") : undefined,
|
|
835
984
|
});
|
|
985
|
+
let scan;
|
|
986
|
+
try { scan = scanNow(registry, project, "register"); } catch (error) { logAccess(`scan-error register ${truncate(String(error), 200)}`); }
|
|
836
987
|
markMemoryUpdated(state, sessionId);
|
|
837
|
-
return jsonResult(`Проект «${project.name}» зарегистрирован. Досье: ${project.dossier}`, { project, created: true });
|
|
988
|
+
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
989
|
}
|
|
839
990
|
if (action === "list") {
|
|
840
991
|
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] ?? "?"} ·
|
|
992
|
+
const lines = registry.projects.map((project) => `- ${project.slug} — ${project.name} · ${project.roots[0] ?? "?"}${project.stack ? ` · ${project.stack}` : ""} · скан: ${project.lastScanAt ?? "не было"} · анализ: ${project.lastAnalyzedAt ?? "не было"}`);
|
|
842
993
|
return jsonResult(`Проекты (${registry.projects.length}):\n${lines.join("\n")}`, { projects: registry.projects.map(projectPublic) });
|
|
843
994
|
}
|
|
995
|
+
if (action === "brief") {
|
|
996
|
+
const project = projectFromArgs(deps, args, session);
|
|
997
|
+
let facts = loadScan(project);
|
|
998
|
+
if (!facts && registry.settings.autoScan !== false) {
|
|
999
|
+
try { facts = scanNow(registry, project, "brief"); } catch (error) { logAccess(`scan-error brief ${truncate(String(error), 200)}`); }
|
|
1000
|
+
}
|
|
1001
|
+
const invariants = bulletLines(readText(path.join(dossierDir(project), "INVARIANTS.md")), 3);
|
|
1002
|
+
const gotchas = bulletLines(readText(path.join(dossierDir(project), "GOTCHAS.md")), 3);
|
|
1003
|
+
const journal = latestJournalExcerpt(project, 300);
|
|
1004
|
+
const text = renderBrief(facts, project, { invariants, gotchas, journal, dossier: projectPublic(project).dossier });
|
|
1005
|
+
return jsonResult(text, { project, scan: facts ? { stack: facts.stack?.labels ?? [], warnings: facts.warnings ?? [] } : null });
|
|
1006
|
+
}
|
|
1007
|
+
if (action === "scan") {
|
|
1008
|
+
const project = projectFromArgs(deps, args, session);
|
|
1009
|
+
const facts = scanNow(registry, project, "tool");
|
|
1010
|
+
const summary = scanSummary(facts).slice(0, 10);
|
|
1011
|
+
markMemoryUpdated(state, sessionId);
|
|
1012
|
+
return jsonResult([`Автоскан «${project.name}» обновлён: ${facts.stats?.files ?? 0} файлов, ${facts.warnings?.length ?? 0} предупреждений.`, ...summary].join("\n"), { project, scan: facts });
|
|
1013
|
+
}
|
|
844
1014
|
if (action === "status") {
|
|
845
1015
|
const project = projectFromArgs(deps, args, session);
|
|
846
1016
|
const fresh = freshness(project);
|
|
@@ -849,16 +1019,21 @@ async function runTool(deps, args, exec) {
|
|
|
849
1019
|
const target = path.join(directory, name);
|
|
850
1020
|
return `${name}: ${exists(target) ? fs.statSync(target).size : 0} B`;
|
|
851
1021
|
});
|
|
1022
|
+
const facts = loadScan(project);
|
|
1023
|
+
const digest = buildDigest(registry, project, sessionId ?? "", registry.settings, state);
|
|
852
1024
|
const text = [
|
|
853
1025
|
`Проект: «${project.name}» (${project.slug})`,
|
|
854
1026
|
`Корень: ${project.roots.join(", ")}`,
|
|
855
1027
|
`Досье: ${directory}`,
|
|
1028
|
+
`Стек: ${project.stack ?? facts?.stack?.labels?.join(" · ") ?? "не определён"}`,
|
|
856
1029
|
`Свежесть: ${fresh.text}`,
|
|
1030
|
+
`Автоскан: ${project.lastScanAt ?? "не было"}${facts?.warnings?.length ? ` · предупреждений: ${facts.warnings.length}` : ""}`,
|
|
1031
|
+
`Дайджест: ~${Math.round(digest.length / 3.2)} токенов (${digest.length} символов)`,
|
|
857
1032
|
`Связанная память: ${(project.legacyDocs ?? []).join("; ") || "нет"}`,
|
|
858
1033
|
`Разделы: ${sizes.join("; ")}`,
|
|
859
|
-
`Настройки: бюджет
|
|
1034
|
+
`Настройки: бюджет ${settings.digestBudgetChars} символов, авто-регистрация ${settings.autoRegister ? "вкл" : "выкл"}, автоскан ${settings.autoScan ? "вкл" : "выкл"}`,
|
|
860
1035
|
].join("\n");
|
|
861
|
-
return jsonResult(text, { project, freshness: fresh });
|
|
1036
|
+
return jsonResult(text, { project, freshness: fresh, scan: facts ? { at: facts.scannedAt, warnings: facts.warnings } : null });
|
|
862
1037
|
}
|
|
863
1038
|
if (action === "read") {
|
|
864
1039
|
const project = projectFromArgs(deps, args, session);
|
|
@@ -918,8 +1093,8 @@ const TOOL_PARAMETERS = {
|
|
|
918
1093
|
properties: {
|
|
919
1094
|
action: {
|
|
920
1095
|
type: "string",
|
|
921
|
-
enum: ["status", "list", "read", "search", "append_journal", "update_section", "register"],
|
|
922
|
-
description: "status|list|read|search|append_journal|update_section|register",
|
|
1096
|
+
enum: ["status", "brief", "list", "read", "search", "scan", "append_journal", "update_section", "register"],
|
|
1097
|
+
description: "status|brief|list|read|search|scan|append_journal|update_section|register",
|
|
923
1098
|
},
|
|
924
1099
|
project: { type: "string", description: "Project slug or name; defaults to the current Session's project." },
|
|
925
1100
|
file: { type: "string", enum: SECTION_NAMES, description: "Dossier section for read/update_section." },
|
|
@@ -948,7 +1123,7 @@ function makeToolDefinition(deps) {
|
|
|
948
1123
|
return {
|
|
949
1124
|
name: "project_memory",
|
|
950
1125
|
description:
|
|
951
|
-
"Local long-term project memory.
|
|
1126
|
+
"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
1127
|
parameters: TOOL_PARAMETERS,
|
|
953
1128
|
timeoutMs: 20000,
|
|
954
1129
|
output: {
|
|
@@ -973,7 +1148,7 @@ function makeToolDefinition(deps) {
|
|
|
973
1148
|
export async function apply(ctx) {
|
|
974
1149
|
const logger = ctx.logger;
|
|
975
1150
|
try {
|
|
976
|
-
|
|
1151
|
+
ensurePrivateDir(MEMORY_ROOT);
|
|
977
1152
|
} catch (error) {
|
|
978
1153
|
logger?.warn?.(`[project-memory] cannot create ${MEMORY_ROOT}: ${String(error)}`);
|
|
979
1154
|
}
|
|
@@ -981,7 +1156,12 @@ export async function apply(ctx) {
|
|
|
981
1156
|
const state = loadState();
|
|
982
1157
|
const digestCache = new DigestCache();
|
|
983
1158
|
const curatorTimers = new Map();
|
|
984
|
-
const deps = {
|
|
1159
|
+
const deps = {
|
|
1160
|
+
registry,
|
|
1161
|
+
state,
|
|
1162
|
+
digestCache,
|
|
1163
|
+
getSession: (id) => (typeof id === "string" ? ctx.get("sessions")?.get(id) : undefined),
|
|
1164
|
+
};
|
|
985
1165
|
|
|
986
1166
|
/** Effective cwd of a session: projection first, durable header second. */
|
|
987
1167
|
const workingDirectory = ctx.get("workingDirectory");
|
|
@@ -1019,7 +1199,8 @@ export async function apply(ctx) {
|
|
|
1019
1199
|
if (!project) return "";
|
|
1020
1200
|
const signature = digestCache.signature(project);
|
|
1021
1201
|
const sessionId = String(session.id);
|
|
1022
|
-
const
|
|
1202
|
+
const userText = state[sessionId]?.lastUserText ?? state[sessionId]?.firstUserText ?? "";
|
|
1203
|
+
const key = `${sessionId}:${project.slug}:${hashText(userText)}`;
|
|
1023
1204
|
const text = digestCache.get(key, signature, () => buildDigest(registry, project, sessionId, registry.settings, state));
|
|
1024
1205
|
logAccess(`digest session=${sessionId} project=${project.slug} chars=${text.length}`);
|
|
1025
1206
|
return text;
|
|
@@ -1228,6 +1409,68 @@ ${summary}
|
|
|
1228
1409
|
}
|
|
1229
1410
|
});
|
|
1230
1411
|
|
|
1412
|
+
/* ---------------- user command /memory ---------------- */
|
|
1413
|
+
ctx.inject(["commands"], (commandCtx) => {
|
|
1414
|
+
try {
|
|
1415
|
+
const dispose = commandCtx.commands.register({
|
|
1416
|
+
name: "memory",
|
|
1417
|
+
description: "Локальная память проекта: статус, карточка, автоскан, вкл/выкл",
|
|
1418
|
+
input: { hint: "status | brief | scan | on | off | projects" },
|
|
1419
|
+
recordInput: false,
|
|
1420
|
+
handler: ({ agent, rawInput }) => {
|
|
1421
|
+
const session = agent?.session;
|
|
1422
|
+
const command = String(rawInput ?? "").trim().toLowerCase();
|
|
1423
|
+
try {
|
|
1424
|
+
if (command === "projects") {
|
|
1425
|
+
if (registry.projects.length === 0) return { kind: "success", text: "Пока нет проектов: открой папку проекта — он зарегистрируется автоматически." };
|
|
1426
|
+
return { kind: "success", text: registry.projects.map((project) => `- ${project.slug} — ${project.name}${project.stack ? ` · ${project.stack}` : ""} · скан: ${project.lastScanAt ?? "не было"}`).join("\n") };
|
|
1427
|
+
}
|
|
1428
|
+
if (command === "on" || command === "off") {
|
|
1429
|
+
registry.settings.autoRegister = command === "on";
|
|
1430
|
+
registry.settings.autoScan = command === "on";
|
|
1431
|
+
saveRegistry(registry);
|
|
1432
|
+
return { kind: "success", text: `Память проектов: авто-регистрация и автоскан ${command === "on" ? "включены" : "выключены"}.` };
|
|
1433
|
+
}
|
|
1434
|
+
const project = session ? resolveProjectForSession(registry, session, registry.settings, { cwd: effectiveCwd(session) }) : undefined;
|
|
1435
|
+
if (!project) return { kind: "success", text: "Проект не определён: открой папку с маркером проекта (.git, package.json, composer.json, artisan…)." };
|
|
1436
|
+
if (command === "scan") {
|
|
1437
|
+
const facts = scanNow(registry, project, "command");
|
|
1438
|
+
return { kind: "success", text: `Автоскан «${project.name}» обновлён.\n${scanSummary(facts).slice(0, 8).join("\n")}` };
|
|
1439
|
+
}
|
|
1440
|
+
if (command === "brief") {
|
|
1441
|
+
const facts = loadScan(project);
|
|
1442
|
+
const invariants = bulletLines(readText(path.join(dossierDir(project), "INVARIANTS.md")), 3);
|
|
1443
|
+
const gotchas = bulletLines(readText(path.join(dossierDir(project), "GOTCHAS.md")), 3);
|
|
1444
|
+
return { kind: "success", text: renderBrief(facts, project, { invariants, gotchas, journal: latestJournalExcerpt(project, 260), dossier: projectPublic(project).dossier }) };
|
|
1445
|
+
}
|
|
1446
|
+
const facts = loadScan(project);
|
|
1447
|
+
const fresh = freshness(project);
|
|
1448
|
+
const digest = buildDigest(registry, project, String(session.id), registry.settings, state);
|
|
1449
|
+
return {
|
|
1450
|
+
kind: "success",
|
|
1451
|
+
text: [
|
|
1452
|
+
`Проект: «${project.name}» (${project.slug})`,
|
|
1453
|
+
`Корень: ${project.roots[0] ?? "?"}`,
|
|
1454
|
+
`Досье: ${projectPublic(project).dossier}`,
|
|
1455
|
+
`Свежесть: ${fresh.text}`,
|
|
1456
|
+
`Стек: ${project.stack ?? facts?.stack?.labels?.join(" · ") ?? "не определён"}`,
|
|
1457
|
+
`Автоскан: ${project.lastScanAt ?? "не было"}${facts?.warnings?.length ? ` · предупреждений: ${facts.warnings.length}` : ""}`,
|
|
1458
|
+
`Дайджест: ~${Math.round(digest.length / 3.2)} токенов`,
|
|
1459
|
+
"Команды: /memory brief | scan | on | off | projects",
|
|
1460
|
+
].join("\n"),
|
|
1461
|
+
};
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
return { kind: "error", text: `Память проекта: ${error instanceof Error ? error.message : String(error)}` };
|
|
1464
|
+
}
|
|
1465
|
+
},
|
|
1466
|
+
});
|
|
1467
|
+
ctx.effect(() => dispose, "project-memory: /memory command");
|
|
1468
|
+
logAccess("command-registered name=memory");
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
logger?.warn?.(`[project-memory] /memory registration failed: ${String(error)}`);
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1231
1474
|
/* ---------------- cleanup ---------------- */
|
|
1232
1475
|
ctx.effect(() => () => {
|
|
1233
1476
|
for (const timer of curatorTimers.values()) clearTimeout(timer);
|