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.
@@ -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
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
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
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
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
- /** Strip obvious secrets before any dossier write. */
112
- function redactSecrets(text) {
113
- let result = String(text ?? "");
114
- const patterns = [
115
- /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu,
116
- /\bsk-[A-Za-z0-9_-]{16,}\b/gu,
117
- /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/gu,
118
- /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gu,
119
- /\bAKIA[0-9A-Z]{16}\b/gu,
120
- /\b(api[_-]?key|access[_-]?token|secret|password|passwd|pwd)\s*[:=]\s*["']?[^\s"',;]{6,}/giu,
121
- ];
122
- for (const pattern of patterns) result = result.replace(pattern, "[REDACTED]");
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
- curator: { enabled: true, idleMs: CURATOR_IDLE_MS },
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
- function detectProjectRoot(cwd) {
263
- let current = path.resolve(cwd);
264
- const root = path.parse(current).root;
265
- for (let depth = 0; depth < 8; depth += 1) {
266
- if (PROJECT_MARKERS.some((marker) => exists(path.join(current, marker)))) return current;
267
- const parent = path.dirname(current);
268
- if (parent === current || current === root) return undefined;
269
- current = parent;
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
- fs.mkdirSync(path.join(directory, "JOURNAL"), { recursive: true });
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 = detectProjectRoot(cwd);
519
+ const detected = detectProject(cwd)?.root;
433
520
  if (!detected) return undefined;
434
521
  if (isInside(MEMORY_ROOT, detected)) return undefined;
435
- const name = path.basename(detected);
436
- return createProject(registry, { name, root: detected, autoCreated: true });
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")), 6);
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")), 6);
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 journal = latestJournalExcerpt(project);
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("Инструкция: полное досье читай инструментом project_memory (read/search), не пересказывай этот дайджест. Для глубокого анализа или «запомни проект» загрузи скилл project-deep-dive и после работы допиши досье (JOURNAL обязателен).");
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" && entry.firstUserText.length === 0) {
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) entry.firstUserText = truncate(cleaned, 160);
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 ? ctx.get("sessions")?.get(agent.id) : undefined);
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] ?? "?"} · анализ: ${project.lastAnalyzedAt ?? "не было"} · записей журнала: ${project.lastJournalAt ? "есть" : "нет"}`);
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
- `Настройки: бюджет дайджеста ${settings.digestBudgetChars} символов, авто-регистрация ${settings.autoRegister ? "вкл" : "выкл"}`,
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. Read/search the project dossier, append a journal entry, update a dossier section, or register a project. The dossier lives under <DSH_HOME>/projects-memory/<slug>. Use status first when unsure. Never store secrets or PII; link to files instead of copying code.",
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
- fs.mkdirSync(MEMORY_ROOT, { recursive: true });
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 = { registry, state, digestCache };
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 key = `${sessionId}:${project.slug}`;
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);