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.
@@ -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,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 = detectProjectRoot(cwd);
511
+ const detected = detectProject(cwd)?.root;
433
512
  if (!detected) return undefined;
434
513
  if (isInside(MEMORY_ROOT, detected)) return undefined;
435
- const name = path.basename(detected);
436
- return createProject(registry, { name, root: detected, autoCreated: true });
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")), 6);
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")), 6);
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 journal = latestJournalExcerpt(project);
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("Инструкция: полное досье читай инструментом project_memory (read/search), не пересказывай этот дайджест. Для глубокого анализа или «запомни проект» загрузи скилл project-deep-dive и после работы допиши досье (JOURNAL обязателен).");
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" && entry.firstUserText.length === 0) {
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) entry.firstUserText = truncate(cleaned, 160);
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 ? ctx.get("sessions")?.get(agent.id) : undefined);
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] ?? "?"} · анализ: ${project.lastAnalyzedAt ?? "не было"} · записей журнала: ${project.lastJournalAt ? "есть" : "нет"}`);
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
- `Настройки: бюджет дайджеста ${settings.digestBudgetChars} символов, авто-регистрация ${settings.autoRegister ? "вкл" : "выкл"}`,
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. 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.",
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
- fs.mkdirSync(MEMORY_ROOT, { recursive: true });
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 = { registry, state, digestCache };
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 key = `${sessionId}:${project.slug}`;
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);