dedsession 3.0.2 → 4.7.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/dist/index.js CHANGED
@@ -4,17 +4,22 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
5
  import * as fs from "fs";
6
6
  import * as path from "path";
7
+ import { execFile } from "child_process";
7
8
  // ============================================================================
8
9
  // КОНФИГУРАЦИЯ
9
10
  // ============================================================================
10
11
  const CONFIG = {
11
- VERSION: "3.0.2",
12
+ VERSION: "4.7.0",
13
+ // v3.1: числовая версия схемы памяти. Растёт при изменении структуры memory/*.json.
14
+ // context_quick сравнивает её с index.schemaVersion и тихо до-мигрирует один раз на репо.
15
+ SCHEMA_VERSION: 4,
12
16
  };
13
17
  const state = {
14
18
  loadedContext: null,
15
19
  loadedContextPath: null,
16
20
  sessionStartTime: new Date(),
17
21
  isInitialized: false,
22
+ attachedProjects: [],
18
23
  };
19
24
  // ============================================================================
20
25
  // ВСТРОЕННЫЕ ИНСТРУКЦИИ (fallback)
@@ -331,6 +336,27 @@ function getNextContextNumber() {
331
336
  const maxNumber = Math.max(...contexts.map(c => c.number));
332
337
  return maxNumber + 1;
333
338
  }
339
+ // v3.1 LEGACY-FIX: стабильный уникальный ID контекста.
340
+ // Формат A (NNN_дата_slug) → "001". Формат B (дата_slug без номера, ctx.number===0)
341
+ // → имя папки (уникально на диске), иначе все 69 legacy-контекстов схлопывались в ключ "000".
342
+ function contextIdOf(ctx) {
343
+ return ctx.number > 0 ? String(ctx.number).padStart(3, "0") : ctx.name;
344
+ }
345
+ // v3.1.1: нормализует значение "**Parent:** X" к тому же id, что contextIdOf.
346
+ // X может быть полным именем папки (NNN_дата_slug → "NNN"; дата_slug формата B → имя),
347
+ // либо голым числом. Раньше regex (\d+) ловил "2024" из даты legacy-родителя — связь рвалась.
348
+ function normalizeParentRef(raw) {
349
+ const v = (raw || "").trim();
350
+ if (!v)
351
+ return null;
352
+ const parsed = parseContextName(v);
353
+ if (parsed)
354
+ return contextIdOf({ number: parsed.number, name: v });
355
+ const numMatch = v.match(/^#?(\d{1,4})\b/);
356
+ if (numMatch)
357
+ return numMatch[1].padStart(3, "0");
358
+ return v;
359
+ }
334
360
  // v1.4.12: Проверка наличия git репозитория
335
361
  function isGitConfigured() {
336
362
  try {
@@ -348,11 +374,28 @@ function isGitConfigured() {
348
374
  return { configured: false };
349
375
  }
350
376
  }
351
- function listContexts() {
352
- const mdHistory = getMdHistoryPath();
377
+ // v3.1: кэш listContexts с инвалидацией по mtime директории MD_HISTORY.
378
+ // Устраняет двойной/тройной полный скан диска на один save
379
+ // (getNextContextNumber + generateSessionMd + getStatistics).
380
+ let _listContextsCache = null;
381
+ function listContexts(mdHistoryOverride) {
382
+ // v3.6: опциональный путь — для обхода нескольких репо (context_backfill_all)
383
+ const mdHistory = mdHistoryOverride || getMdHistoryPath();
353
384
  if (!fs.existsSync(mdHistory)) {
354
385
  return [];
355
386
  }
387
+ // Кэш-хит: тот же путь и неизменившийся mtime каталога → отдаём готовое.
388
+ // mtime родителя меняется при добавлении/удалении контекста-папки (главный кейс инвалидации).
389
+ let dirMtime = 0;
390
+ try {
391
+ dirMtime = fs.statSync(mdHistory).mtimeMs;
392
+ if (_listContextsCache && _listContextsCache.path === mdHistory && _listContextsCache.mtimeMs === dirMtime) {
393
+ return _listContextsCache.data;
394
+ }
395
+ }
396
+ catch {
397
+ // если statSync упал — просто не кэшируем
398
+ }
356
399
  try {
357
400
  const dirs = fs.readdirSync(mdHistory, { withFileTypes: true })
358
401
  .filter(d => d.isDirectory())
@@ -427,8 +470,16 @@ function listContexts() {
427
470
  // Новые сверху: сначала по номеру (убывание), потом по дате (убывание)
428
471
  if (a.number !== b.number)
429
472
  return b.number - a.number;
430
- return b.date.localeCompare(a.date);
473
+ if (a.date !== b.date)
474
+ return b.date.localeCompare(a.date);
475
+ // v3.1.1: детерминированный tie-break по имени папки — иначе при равных номере И дате
476
+ // (дубли формата A) порядок зависел от ФС, и ключ-коллизия "095" скакал между машинами.
477
+ return a.name.localeCompare(b.name);
431
478
  });
479
+ // сохраняем в кэш (mtime уже посчитан выше; если был 0 — кэш просто инвалидируется в след. раз)
480
+ if (dirMtime) {
481
+ _listContextsCache = { path: mdHistory, mtimeMs: dirMtime, data: dirs };
482
+ }
432
483
  return dirs;
433
484
  }
434
485
  catch {
@@ -436,39 +487,12 @@ function listContexts() {
436
487
  }
437
488
  }
438
489
  // Находит путь к контексту по номеру (для context_smart_focus)
439
- function findContextByNumber(number) {
440
- const contexts = listContexts();
441
- const numInt = parseInt(number, 10);
442
- const found = contexts.find(c => c.number === numInt);
443
- return found?.path || null;
444
- }
445
490
  // ============================================================================
446
491
  // АДАПТИВНЫЕ ФУНКЦИИ v1.4.3
447
492
  // ============================================================================
448
493
  /**
449
494
  * Оценивает размер контекста в байтах
450
495
  */
451
- function estimateContextSize(contextPath) {
452
- try {
453
- if (!fs.existsSync(contextPath))
454
- return 0;
455
- const files = fs.readdirSync(contextPath).filter(f => f.endsWith(".md"));
456
- let totalSize = 0;
457
- for (const file of files) {
458
- try {
459
- const stat = fs.statSync(path.join(contextPath, file));
460
- totalSize += stat.size;
461
- }
462
- catch {
463
- // Игнорируем ошибки
464
- }
465
- }
466
- return totalSize;
467
- }
468
- catch {
469
- return 0;
470
- }
471
- }
472
496
  /**
473
497
  * Определяет сколько контекстов загружать для context_quick
474
498
  * Возвращает { count: число контекстов, mode: режим загрузки, reason: причина }
@@ -541,9 +565,9 @@ function extractSummaryFromReadme(readme, changesContent) {
541
565
  if (descMatch) {
542
566
  summary = descMatch[1].trim();
543
567
  }
544
- // 2. Если нет — ищем "## Выполнено" → первые 2 пункта
568
+ // 2. Если нет — ищем "## Выполнено" ИЛИ "## Что сделано" (v3.1: README пишет "## Что сделано") → первые 2 пункта
545
569
  if (!summary) {
546
- const doneMatch = readme.match(/## Выполнено[^\n]*\n((?:[-*✅] [^\n]+\n?){1,2})/);
570
+ const doneMatch = readme.match(/## (?:Выполнено|Что сделано)[^\n]*\n+((?:[-*✅] [^\n]+\n?){1,2})/);
547
571
  if (doneMatch) {
548
572
  summary = doneMatch[1]
549
573
  .replace(/[-*✅] /g, "")
@@ -553,10 +577,10 @@ function extractSummaryFromReadme(readme, changesContent) {
553
577
  .join("; ");
554
578
  }
555
579
  }
556
- // 3. Если нет — берём первый параграф после заголовка
580
+ // 3. Если нет — первый параграф после заголовка, НО не строки-метаданные (**Дата:**/**Статус:** и т.п.)
557
581
  if (!summary) {
558
582
  const firstPara = readme.match(/^#[^\n]+\n\n([^\n#]+)/m);
559
- if (firstPara) {
583
+ if (firstPara && !/^\s*\*\*[^*]+:\*\*/.test(firstPara[1])) {
560
584
  summary = firstPara[1].trim();
561
585
  }
562
586
  }
@@ -647,53 +671,6 @@ function readHistory() {
647
671
  * Миграция всех существующих контекстов в HISTORY.md
648
672
  * Проходит по всем контекстам в хронологическом порядке (старые первыми)
649
673
  */
650
- function migrateAllToHistory(force = false) {
651
- const historyPath = path.join(getMdHistoryPath(), "HISTORY.md");
652
- // Если файл существует и нет force — не перезаписываем
653
- if (fs.existsSync(historyPath) && !force) {
654
- return { migrated: 0, errors: ["HISTORY.md уже существует. Используй force: true"] };
655
- }
656
- // Удаляем старый файл если force
657
- if (fs.existsSync(historyPath) && force) {
658
- fs.unlinkSync(historyPath);
659
- }
660
- const contexts = listContexts();
661
- const errors = [];
662
- let migrated = 0;
663
- // Сортируем по номеру (старые первыми для правильного хронологического порядка)
664
- contexts.sort((a, b) => a.number - b.number);
665
- for (const ctx of contexts) {
666
- try {
667
- // Читаем README.md
668
- const readmePath = path.join(ctx.path, "README.md");
669
- let readme = "";
670
- if (fs.existsSync(readmePath)) {
671
- readme = fs.readFileSync(readmePath, "utf-8");
672
- }
673
- // Читаем changes если есть
674
- const changesPath = path.join(ctx.path, "03-changes.md");
675
- let changes = "";
676
- if (fs.existsSync(changesPath)) {
677
- changes = fs.readFileSync(changesPath, "utf-8");
678
- }
679
- // Извлекаем сводку
680
- const summary = extractSummaryFromReadme(readme, changes);
681
- // Добавляем в историю
682
- appendToHistory({
683
- number: ctx.number,
684
- date: ctx.date,
685
- name: ctx.title,
686
- status: ctx.status,
687
- summary
688
- });
689
- migrated++;
690
- }
691
- catch (err) {
692
- errors.push(`${ctx.name}: ${String(err)}`);
693
- }
694
- }
695
- return { migrated, errors };
696
- }
697
674
  /**
698
675
  * Определяет масштаб сохранения на основе данных
699
676
  * Возвращает scale и список файлов для создания
@@ -812,6 +789,9 @@ function loadContext(contextName) {
812
789
  function saveContext(taskName, content, parentContext, filesToCreate // Опционально — какие файлы создавать
813
790
  ) {
814
791
  const date = new Date().toISOString().split("T")[0];
792
+ // v3.1.2 FIX H: фиксируем РОДИТЕЛЯ до того, как saveContext перезапишет state.loadedContext
793
+ // на только что созданный контекст (иначе addContextToMemory получал self-parent).
794
+ const effectiveParent = parentContext || state.loadedContext || null;
815
795
  const number = getNextContextNumber();
816
796
  const paddedNumber = String(number).padStart(3, "0");
817
797
  // Санитизация имени: только латиница, цифры и дефисы
@@ -831,7 +811,8 @@ function saveContext(taskName, content, parentContext, filesToCreate // Опци
831
811
  .replace(/[^a-z0-9-]/g, "-")
832
812
  .replace(/-+/g, "-")
833
813
  .replace(/^-|-$/g, "")
834
- .slice(0, 50) || "unnamed-context";
814
+ .slice(0, 50)
815
+ .replace(/-+$/, "") || "unnamed-context"; // v3.1.2: срез до 50 может оставить хвостовой дефис — убираем
835
816
  const contextName = `${paddedNumber}_${date}_${safeName}`;
836
817
  const contextPath = path.join(getMdHistoryPath(), contextName);
837
818
  try {
@@ -849,8 +830,9 @@ function saveContext(taskName, content, parentContext, filesToCreate // Опци
849
830
  "README.md": readmeContent,
850
831
  "01-task-overview.md": content.taskOverview ? `# 📋 Обзор задачи\n\n${content.taskOverview}` : undefined,
851
832
  "02-analysis.md": content.analysis ? `# 🔍 Анализ\n\n${content.analysis}` : undefined,
852
- "03-changes.md": `# 🔧 Изменения\n\n${content.changes}`,
853
- "04-summary.md": `# 📝 Итоги\n\n${content.summary}`,
833
+ // v3.1.1: пишем только при непустом контенте — иначе создавался файл с голым заголовком (мусор на каждом save)
834
+ "03-changes.md": content.changes && content.changes.trim() ? `# 🔧 Изменения\n\n${content.changes}` : undefined,
835
+ "04-summary.md": content.summary && content.summary.trim() ? `# 📝 Итоги\n\n${content.summary}` : undefined,
854
836
  "05-session-log.md": content.sessionLog ? `# 💬 Лог сессии\n\n**Дата:** ${new Date().toISOString()}\n\n${content.sessionLog}` : undefined,
855
837
  "06-code-snippets.md": content.codeSnippets ? `# 💻 Фрагменты кода\n\n${content.codeSnippets}` : undefined,
856
838
  "07-decisions.md": content.decisions ? `# 🎯 Принятые решения\n\n${content.decisions}` : undefined,
@@ -917,7 +899,12 @@ function saveContext(taskName, content, parentContext, filesToCreate // Опци
917
899
  // Не прерываем сохранение если HISTORY.md не удалось обновить
918
900
  console.error("[dedsession] Ошибка обновления HISTORY.md:", err);
919
901
  }
920
- return { success: true, path: contextPath, name: contextName, filesCount };
902
+ // v3.1: публикуем выжимку репо в глобальный digest (publish-on-save)
903
+ try {
904
+ publishToGlobalDigest();
905
+ }
906
+ catch { /* не критично */ }
907
+ return { success: true, path: contextPath, name: contextName, filesCount, parentRef: effectiveParent };
921
908
  }
922
909
  catch (err) {
923
910
  return { success: false, path: "", name: "", filesCount: 0, error: String(err) };
@@ -1016,22 +1003,6 @@ function generateSessionMd() {
1016
1003
  // ============================================================================
1017
1004
  // ЧТЕНИЕ АРХИТЕКТУРЫ ПРОЕКТА
1018
1005
  // ============================================================================
1019
- function readProjectArchitecture() {
1020
- const mdDir = path.join(getWorkingDir(), "MD");
1021
- const results = [];
1022
- if (!fs.existsSync(mdDir)) {
1023
- return results;
1024
- }
1025
- const files = fs.readdirSync(mdDir).filter(f => f.endsWith(".md") && (f.includes("ARCHITECTURE") ||
1026
- f.includes("PROTOCOL") ||
1027
- f.includes("API") ||
1028
- f.includes("REFERENCE")));
1029
- for (const file of files.slice(0, 3)) {
1030
- const content = fs.readFileSync(path.join(mdDir, file), "utf-8");
1031
- results.push(`### ${file}\n\n${content.slice(0, 1000)}${content.length > 1000 ? "\n...(обрезано)" : ""}`);
1032
- }
1033
- return results;
1034
- }
1035
1006
  function getStatistics() {
1036
1007
  const contexts = listContexts();
1037
1008
  const now = new Date();
@@ -1071,7 +1042,7 @@ function ensureMemoryStructure() {
1071
1042
  ensureDir(memoryPath);
1072
1043
  // Инициализация пустых файлов если не существуют
1073
1044
  const files = [
1074
- { name: "index.json", default: { version: CONFIG.VERSION, lastMigration: "", contexts: {} } },
1045
+ { name: "index.json", default: { version: CONFIG.VERSION, lastMigration: "", schemaVersion: CONFIG.SCHEMA_VERSION, contexts: {} } },
1075
1046
  { name: "projects.json", default: { projects: {} } },
1076
1047
  { name: "epics.json", default: { epics: {} } },
1077
1048
  { name: "keywords.json", default: { keywords: {} } },
@@ -1094,8 +1065,13 @@ function loadMemoryIndex() {
1094
1065
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
1095
1066
  }
1096
1067
  }
1097
- catch { /* ignore */ }
1098
- return { version: CONFIG.VERSION, lastMigration: "", contexts: {} };
1068
+ catch {
1069
+ // v3.1.1: файл ЕСТЬ, но битый JSON → schemaVersion 0, чтобы гейт ЗАПУСТИЛ миграцию
1070
+ // и переиндексировал диск (иначе битый индекс «замораживался» как свежий и контексты терялись).
1071
+ return { version: CONFIG.VERSION, lastMigration: "", schemaVersion: 0, contexts: {} };
1072
+ }
1073
+ // файла НЕТ → свежий репо → актуальная схема (не мигрировать зря на пустом, F1)
1074
+ return { version: CONFIG.VERSION, lastMigration: "", schemaVersion: CONFIG.SCHEMA_VERSION, contexts: {} };
1099
1075
  }
1100
1076
  function saveMemoryIndex(index) {
1101
1077
  ensureMemoryStructure();
@@ -1244,7 +1220,7 @@ function renderOverviewMd(digest) {
1244
1220
  md += `## Что это за проект\n`;
1245
1221
  md += `${digest.currentState.description || "_Описание будет добавлено после digest_refresh_"}\n\n`;
1246
1222
  md += `## Текущее состояние\n`;
1247
- md += `- **Контекстов:** ${digest.statistics.totalContexts} (✅ ${digest.statistics.completed} | ${digest.statistics.inProgress})\n`;
1223
+ md += `- **Контекстов:** ${digest.statistics.totalContexts}\n`; // v4.4.0 блок J: без статусов
1248
1224
  md += `- **Период:** ${digest.statistics.firstDate} — ${digest.statistics.lastDate}\n`;
1249
1225
  if (digest.currentState.techStack.length > 0) {
1250
1226
  md += `- **Стек:** ${digest.currentState.techStack.join(", ")}\n`;
@@ -1263,22 +1239,9 @@ function renderOverviewMd(digest) {
1263
1239
  md += `${phase.description}\n\n`;
1264
1240
  }
1265
1241
  }
1266
- // В работе сейчас
1267
- const inProgress = digest.recentTasks.filter(t => t.status === "⏳" || t.status === "⏳ В процессе" || t.status === "in_progress");
1268
- if (inProgress.length > 0) {
1269
- md += `## В работе сейчас\n`;
1270
- for (const task of inProgress.slice(0, 5)) {
1271
- md += `- **#${task.contextId}: ${task.name}** — ${task.summary}\n`;
1272
- }
1273
- md += "\n";
1274
- }
1275
- if (digest.knownIssues.length > 0) {
1276
- md += `## Известные проблемы\n`;
1277
- for (const issue of digest.knownIssues) {
1278
- md += `- ${issue}\n`;
1279
- }
1280
- md += "\n";
1281
- }
1242
+ // v4.4.0 блок J: секции «В работе сейчас» и «Известные проблемы» убраны —
1243
+ // строились по статусам (in_progress), которые работали криво («давно пофиксили, а висит»).
1244
+ // По контекстам и так видно, над чем работа (см. Timeline / последние задачи).
1282
1245
  return md;
1283
1246
  }
1284
1247
  function renderTimelineMd(digest) {
@@ -1287,19 +1250,19 @@ function renderTimelineMd(digest) {
1287
1250
  md += "_Нет задач_\n";
1288
1251
  return md;
1289
1252
  }
1290
- // Таблица всех задач
1291
- md += `| # | Дата | Задача | Статус | Суть |\n`;
1292
- md += `|---|------|--------|--------|------|\n`;
1253
+ // Таблица всех задач (v4.4.0 блок J: без колонки «Статус»)
1254
+ md += `| # | Дата | Задача | Суть |\n`;
1255
+ md += `|---|------|--------|------|\n`;
1293
1256
  for (const task of digest.recentTasks) {
1294
1257
  const summary = task.summary || "";
1295
1258
  const shortSummary = summary.length > 60 ? summary.slice(0, 57) + "..." : summary;
1296
- md += `| ${task.contextId} | ${task.date} | ${task.name} | ${task.status} | ${shortSummary} |\n`;
1259
+ md += `| ${task.contextId} | ${task.date} | ${task.name} | ${shortSummary} |\n`;
1297
1260
  }
1298
1261
  md += "\n";
1299
1262
  // Детали последних 10
1300
1263
  md += `## Детали последних ${Math.min(10, digest.recentTasks.length)} задач\n\n`;
1301
1264
  for (const task of digest.recentTasks.slice(0, 10)) {
1302
- md += `### #${task.contextId}: ${task.name} (${task.date}) ${task.status}\n`;
1265
+ md += `### #${task.contextId}: ${task.name} (${task.date})\n`;
1303
1266
  md += `Суть: ${task.summary || ""}\n`;
1304
1267
  if (task.keyChanges && task.keyChanges.length > 0) {
1305
1268
  md += `Изменения:\n`;
@@ -1359,25 +1322,104 @@ function renderSolutionsMd(digest) {
1359
1322
  // ============================================================================
1360
1323
  // PROJECT DIGEST v3.0 — detectProjectFromWorkDir + update
1361
1324
  // ============================================================================
1325
+ // v3.1.1: читает имя проекта из git remote origin (стабильно при переименовании папки).
1326
+ function readGitOriginName(repoDir) {
1327
+ try {
1328
+ const cfg = path.join(repoDir, ".git", "config");
1329
+ // .git может быть файлом (worktree/submodule) — тогда config нет, вернём null → fallback на basename
1330
+ if (!fs.existsSync(cfg) || !fs.statSync(cfg).isFile())
1331
+ return null;
1332
+ const txt = fs.readFileSync(cfg, "utf-8");
1333
+ // v3.1.1 FIX: берём url ИМЕННО из секции [remote "origin"], а не первый попавшийся
1334
+ // (иначе [submodule]/[remote "upstream"] выше origin давали чужое имя проекта).
1335
+ const originSection = txt.match(/\[remote "origin"\]([\s\S]*?)(?=\n\[|\s*$)/);
1336
+ const scope = originSection ? originSection[1] : txt;
1337
+ const m = scope.match(/url\s*=\s*(.+)/);
1338
+ if (!m)
1339
+ return null;
1340
+ let url = m[1].trim().replace(/\.git$/, "");
1341
+ // basename из URL (ssh git@host:user/repo, https://host/user/repo, локальный путь, windows \)
1342
+ const base = url.split(/[/:\\]/).filter(Boolean).pop();
1343
+ return base || null;
1344
+ }
1345
+ catch {
1346
+ return null;
1347
+ }
1348
+ }
1349
+ // v3.5.1: полный git origin URL (для привязки «где репо, кто работает» в командной стате)
1350
+ function readGitRemoteUrl(repoDir) {
1351
+ try {
1352
+ const cfg = path.join(repoDir, ".git", "config");
1353
+ if (!fs.existsSync(cfg) || !fs.statSync(cfg).isFile())
1354
+ return null;
1355
+ const txt = fs.readFileSync(cfg, "utf-8");
1356
+ const originSection = txt.match(/\[remote "origin"\]([\s\S]*?)(?=\n\[|\s*$)/);
1357
+ const scope = originSection ? originSection[1] : "";
1358
+ const m = scope.match(/url\s*=\s*(.+)/);
1359
+ return m ? m[1].trim() : null;
1360
+ }
1361
+ catch {
1362
+ return null;
1363
+ }
1364
+ }
1365
+ // v3.5.1: git user.name из .git/config репо или глобального ~/.gitconfig (кто коммитит)
1366
+ function readGitUser(repoDir) {
1367
+ try {
1368
+ const candidates = [path.join(repoDir, ".git", "config"), path.join(process.env.HOME || "", ".gitconfig")];
1369
+ for (const cfg of candidates) {
1370
+ if (!fs.existsSync(cfg) || !fs.statSync(cfg).isFile())
1371
+ continue;
1372
+ const txt = fs.readFileSync(cfg, "utf-8");
1373
+ const userSection = txt.match(/\[user\]([\s\S]*?)(?=\n\[|\s*$)/);
1374
+ const scope = userSection ? userSection[1] : "";
1375
+ const m = scope.match(/name\s*=\s*(.+)/);
1376
+ if (m)
1377
+ return m[1].trim();
1378
+ }
1379
+ return null;
1380
+ }
1381
+ catch {
1382
+ return null;
1383
+ }
1384
+ }
1385
+ // v3.5.3: реальный автор контекста — по git log на ДАТУ контекста (а не текущий git config,
1386
+ // который на одной машине всегда один и тот же → все контексты ошибочно под одним ником).
1387
+ // Берём автора последнего коммита НЕ позже конца дня контекста. Кэш по (repo|date).
1388
+ // v3.6.2: АСИНХРОННО (execFile) — git log не блокирует event loop, вызовы идут конкурентно.
1389
+ const _gitAuthorCache = new Map();
1390
+ function readGitAuthorForDate(repoDir, date) {
1391
+ if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date))
1392
+ return Promise.resolve(null);
1393
+ const key = repoDir + "|" + date;
1394
+ if (_gitAuthorCache.has(key))
1395
+ return Promise.resolve(_gitAuthorCache.get(key));
1396
+ return new Promise((resolve) => {
1397
+ execFile("git", ["-C", repoDir, "log", "--until=" + date + " 23:59:59", "--format=%an", "-n", "1"], { encoding: "utf-8", timeout: 4000 }, (err, stdout) => {
1398
+ const author = (!err && stdout) ? (stdout.trim() || null) : null;
1399
+ _gitAuthorCache.set(key, author);
1400
+ resolve(author);
1401
+ });
1402
+ });
1403
+ }
1362
1404
  function detectProjectFromWorkDir() {
1363
1405
  try {
1364
1406
  const workDir = getWorkingDir();
1365
- const dirName = path.basename(workDir).toLowerCase();
1366
- // Проверяем PROJECT_PATTERNS
1367
- for (const [project, patterns] of Object.entries(PROJECT_PATTERNS)) {
1368
- for (const pattern of patterns) {
1369
- if (dirName.includes(pattern)) {
1370
- return project;
1371
- }
1372
- }
1373
- }
1374
- // Возвращаем имя директории КАК ЕСТЬ
1407
+ // v3.1.1 FIX коллизии: project = git-origin basename ИЛИ имя папки репо КАК ЕСТЬ.
1408
+ // НЕ паттерн-матч PROJECT_PATTERNS — он схлопывал dedvpn-app-ios/dedai-app-ios в один "ios"
1409
+ // секции проектов затирали друг друга в global digest. Имя папки уникально, его и берём.
1410
+ const origin = readGitOriginName(workDir);
1411
+ if (origin)
1412
+ return origin;
1375
1413
  return path.basename(workDir);
1376
1414
  }
1377
1415
  catch {
1378
1416
  return null;
1379
1417
  }
1380
1418
  }
1419
+ // v3.6: project для ПРОИЗВОЛЬНОГО репо (для context_backfill_all)
1420
+ // v3.6: найти все dedsession-проекты (папки с MD_HISTORY) от корня root до глубины depth.
1421
+ // v3.6.1: РЕКУРСИВНО заходит и в подпапки найденного проекта (вложенные/под-проекты тоже),
1422
+ // не останавливаясь на верхнем уровне. Пропускаем мусорные/скрытые директории.
1381
1423
  function extractKeyChangesFromText(changes) {
1382
1424
  if (!changes)
1383
1425
  return [];
@@ -1662,9 +1704,13 @@ function addSolutionsToIndex(extracted, contextId) {
1662
1704
  function getGlobalConfigPath() {
1663
1705
  const home = process.env.HOME || process.env.USERPROFILE || "/tmp";
1664
1706
  const globalPath = path.join(home, ".dedsession");
1665
- if (!fs.existsSync(globalPath)) {
1666
- fs.mkdirSync(globalPath, { recursive: true });
1707
+ try {
1708
+ if (!fs.existsSync(globalPath)) {
1709
+ fs.mkdirSync(globalPath, { recursive: true });
1710
+ }
1711
+ fs.chmodSync(globalPath, 0o700); // v3.1.1: приватность (там токены/правила/digest)
1667
1712
  }
1713
+ catch { /* права не критичны для работы */ }
1668
1714
  return globalPath;
1669
1715
  }
1670
1716
  /**
@@ -1708,21 +1754,686 @@ function saveGlobalRules(rules) {
1708
1754
  const filePath = path.join(getGlobalConfigPath(), "global-rules.json");
1709
1755
  fs.writeFileSync(filePath, JSON.stringify(rules, null, 2), "utf-8");
1710
1756
  }
1757
+ // ============================================================================
1758
+ // ГЛОБАЛЬНАЯ ПАМЯТЬ v3.1 — реестр проектов + глобальный digest
1759
+ // ~/.dedsession/global-digest.json — ЕДИНЫЙ файл: у каждого проекта своя секция
1760
+ // (по git-origin), поэтому объединение происходит само и без коллизий.
1761
+ // Это "digest из digest'ов": агрегирует выжимки локальных digest, а НЕ 15000 контекстов,
1762
+ // поэтому остаётся компактным независимо от объёма истории.
1763
+ // ============================================================================
1764
+ const GLOBAL_DIGEST_SCHEMA = 1;
1765
+ function getGlobalDigestPath() {
1766
+ return path.join(getGlobalConfigPath(), "global-digest.json");
1767
+ }
1768
+ function loadGlobalDigest() {
1769
+ const filePath = getGlobalDigestPath();
1770
+ try {
1771
+ if (fs.existsSync(filePath)) {
1772
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1773
+ // self-heal схемы (как loadDigest)
1774
+ raw.schemaVersion = raw.schemaVersion || GLOBAL_DIGEST_SCHEMA;
1775
+ raw.updated = raw.updated || "";
1776
+ raw.projects = raw.projects || {};
1777
+ return raw;
1778
+ }
1779
+ }
1780
+ catch { /* ignore */ }
1781
+ return { schemaVersion: GLOBAL_DIGEST_SCHEMA, updated: "", projects: {} };
1782
+ }
1783
+ function saveGlobalDigest(gd) {
1784
+ try {
1785
+ gd.updated = new Date().toISOString();
1786
+ // атомарная запись через tmp+rename (+ pid в имени tmp, чтобы параллельные процессы не делили один tmp)
1787
+ const filePath = getGlobalDigestPath();
1788
+ const tmp = `${filePath}.tmp.${process.pid}`;
1789
+ fs.writeFileSync(tmp, JSON.stringify(gd, null, 2), "utf-8");
1790
+ fs.renameSync(tmp, filePath);
1791
+ try {
1792
+ fs.chmodSync(filePath, 0o600);
1793
+ }
1794
+ catch { /* */ } // v3.1.1: приватность файла
1795
+ }
1796
+ catch { /* глобальный digest не критичен для работы репо */ }
1797
+ }
1798
+ // v3.1.1: синхронный сон без busy-loop (Atomics.wait) — для файлового лока в CLI-процессе.
1799
+ function sleepSyncMs(ms) {
1800
+ try {
1801
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1802
+ }
1803
+ catch { /* fallback: no-op */ }
1804
+ }
1805
+ // v3.1.1: best-effort межпроцессный лок через эксклюзивное создание .lock-файла.
1806
+ // Защищает read-modify-write глобального digest от гонки двух MCP-процессов в разных репо.
1807
+ function withGlobalDigestLock(fn) {
1808
+ const lockPath = getGlobalDigestPath() + ".lock";
1809
+ let fd = -1;
1810
+ for (let i = 0; i < 50; i++) {
1811
+ try {
1812
+ fd = fs.openSync(lockPath, "wx");
1813
+ break;
1814
+ }
1815
+ catch {
1816
+ // v3.1.1: НЕ сносим живой чужой лок. Сносим только ПРОТУХШИЙ (>15с = владелец явно умер),
1817
+ // затем повторяем openSync на следующей итерации (атомарный wx → выиграет ровно один).
1818
+ try {
1819
+ const age = Date.now() - fs.statSync(lockPath).mtimeMs;
1820
+ if (age > 15000) {
1821
+ fs.unlinkSync(lockPath);
1822
+ continue;
1823
+ }
1824
+ }
1825
+ catch {
1826
+ continue;
1827
+ } // лок исчез между stat и сейчас — сразу повторим openSync
1828
+ sleepSyncMs(20);
1829
+ }
1830
+ }
1831
+ // если за ~1с лок так и не взят (постоянная контенция, не протухание) — пишем best-effort без него
1832
+ try {
1833
+ return fn();
1834
+ }
1835
+ finally {
1836
+ if (fd >= 0) {
1837
+ try {
1838
+ fs.closeSync(fd);
1839
+ }
1840
+ catch { /* */ }
1841
+ try {
1842
+ fs.unlinkSync(lockPath);
1843
+ }
1844
+ catch { /* */ }
1845
+ }
1846
+ }
1847
+ }
1848
+ // Фильтр против мусора A13: оставляем только то, что похоже на реальный путь/файл кода
1849
+ function isRealFilePath(s) {
1850
+ if (!s || s.length > 120)
1851
+ return false;
1852
+ if (/^[\d.\s]+$/.test(s))
1853
+ return false; // числа/версии (26.9px, 0.15)
1854
+ if (/[{};:#]/.test(s) && !s.includes("/"))
1855
+ return false; // CSS-селекторы
1856
+ // v3.1.1: отсекаем абсолютные системные пути (инфраструктура) из публикуемого списка
1857
+ if (/^\/(root|home|etc|var|usr|opt)\b/.test(s))
1858
+ return false;
1859
+ return s.includes("/") || /\.(ts|js|tsx|jsx|py|swift|kt|kts|go|php|rb|rs|java|c|cpp|h|html|css|scss|json|yaml|yml|sh|sql|vue|md|toml|gradle|xml)$/i.test(s);
1860
+ }
1861
+ // v3.1.1: вычищает секреты/инфраструктуру из текста ПЕРЕД публикацией в глобальный digest.
1862
+ // Без него /root/-пути, IP, ключи, пароли утекали бы в ~/.dedsession (и потом в облако DedPanel).
1863
+ function scrubSecrets(s) {
1864
+ if (!s)
1865
+ return s;
1866
+ // v3.1.1: значение секрета — только секрето-подобное (≥8 симв, без пробела), а не любое соседнее слово,
1867
+ // чтобы "token: refresh logic" не превращалось в "token=[REDACTED] logic".
1868
+ const secretVal = "([^\\s'\"]{8,})";
1869
+ return s
1870
+ // срезаем ТОЛЬКО реальные протёкшие tool-call теги (A4) — без англо-слов summary/analysis/security,
1871
+ // которые ломали бы markdown <details><summary> и осмысленный текст.
1872
+ .replace(/<\/?(?:invoke|parameter|function_calls|antml:[a-z_]+)[^>]*>/gi, "")
1873
+ .replace(/-----BEGIN[\s\S]*?-----END[^-]*-----/g, "[REDACTED_KEY]")
1874
+ // v3.2.1: пароль внутри connection-URI (redis://user:pass@host, postgres://, mongodb://)
1875
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+):[^\s:@/]+@/gi, "$1:[REDACTED]@")
1876
+ .replace(new RegExp(`\\b(password|passwd|pwd|secret|token|api[_-]?key|private[_-]?key|totp[_-]?secret|access[_-]?key)\\b\\s*[:=]\\s*${secretVal}`, "gi"), (_m, k) => `${k}=[REDACTED]`)
1877
+ // base64-ключи: длинные И с признаком base64 (есть +/= или смесь регистров+цифр) — НЕ чистый hex/слова
1878
+ .replace(/\b(?=[A-Za-z0-9+/]{44,}={0,2}\b)(?=[A-Za-z0-9+/]*[+/=])[A-Za-z0-9+/]{44,}={0,2}\b/g, "[REDACTED_B64]")
1879
+ // IPv4 с валидацией октетов (0-255) — не задевает версии типа 1.2.3.4 с числами >255, но 4 валидных октета редки в прозе
1880
+ .replace(/\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b/g, "[IP]")
1881
+ .replace(/ssh-(rsa|ed25519)\s+\S+/g, "[REDACTED_SSHKEY]")
1882
+ .replace(/\broot@[^\s'"]+/g, "[REDACTED_HOST]")
1883
+ .replace(/\/root\/[^\s'"]*/g, "~/")
1884
+ .replace(/\/home\/[^/\s'"]+\/?/g, "~/");
1885
+ }
1886
+ /**
1887
+ * Публикует выжимку ТЕКУЩЕГО репо в глобальный digest (publish-on-save).
1888
+ * Грамотное объединение: трогаем только секцию своего проекта по ключу.
1889
+ */
1890
+ function publishToGlobalDigest() {
1891
+ try {
1892
+ const project = detectProjectFromWorkDir() || path.basename(getWorkingDir());
1893
+ if (!project)
1894
+ return;
1895
+ if (isPrivateProject(project))
1896
+ return; // v4.4.0 блок G: приватный проект не публикуем
1897
+ const mdHistory = getMdHistoryPath();
1898
+ const contexts = listContexts();
1899
+ if (contexts.length === 0)
1900
+ return;
1901
+ const stats = getStatistics();
1902
+ const localDigest = loadDigest();
1903
+ // recentTasks: предпочитаем сжатый локальный digest (чистый слой summary), иначе из README
1904
+ let recentTasks = [];
1905
+ if (localDigest && localDigest.recentTasks.length > 0) {
1906
+ recentTasks = localDigest.recentTasks.slice(0, 12).map(t => ({
1907
+ ctx: t.contextId, date: t.date, name: scrubSecrets(t.name), summary: scrubSecrets((t.summary || "").slice(0, 160)),
1908
+ }));
1909
+ }
1910
+ else {
1911
+ recentTasks = contexts.slice(0, 12).map(c => {
1912
+ let summary = "";
1913
+ const r = path.join(c.path, "README.md");
1914
+ if (fs.existsSync(r)) {
1915
+ const m = fs.readFileSync(r, "utf-8").match(/## Краткое описание\n\n([^\n#]+)/);
1916
+ if (m)
1917
+ summary = m[1].trim();
1918
+ }
1919
+ return { ctx: contextIdOf(c), date: c.date, name: scrubSecrets(c.title), summary: scrubSecrets(summary.slice(0, 160)) };
1920
+ });
1921
+ }
1922
+ // topFiles: фильтр мусора/абсолютных путей + scrub на остаток (страховка)
1923
+ // v3.1.2: topFiles уже отфильтрованы isRealFilePath (нет /root//home/секретов) — это пути кода.
1924
+ // НЕ гоняем по ним b64-scrub: длинный путь src/.../GoBackend.java = непрерывный прогон с "/" → ложный [REDACTED_B64].
1925
+ const topFiles = (localDigest?.currentState.keyFiles || []).filter(isRealFilePath).slice(0, 15);
1926
+ const activeEpics = (localDigest?.currentState.activeEpics || []).map(scrubSecrets);
1927
+ const currentFocus = scrubSecrets((localDigest?.currentState.description || "").slice(0, 200));
1928
+ const lastActive = contexts[0]?.date || "";
1929
+ const section = {
1930
+ project, mdHistory,
1931
+ contexts: stats.total, completed: stats.completed, inProgress: stats.inProgress,
1932
+ lastActive, lastSync: new Date().toISOString(),
1933
+ currentFocus, activeEpics, recentTasks, topFiles,
1934
+ };
1935
+ // v3.1.1: под локом перечитываем СВЕЖИЙ файл и пишем ТОЛЬКО свою секцию —
1936
+ // параллельный save в другом репо не затрёт чужие секции (read-modify-write race).
1937
+ withGlobalDigestLock(() => {
1938
+ const fresh = loadGlobalDigest();
1939
+ fresh.projects[project] = section; // ← объединение: только своя секция
1940
+ saveGlobalDigest(fresh);
1941
+ });
1942
+ }
1943
+ catch { /* никогда не ломаем основную работу */ }
1944
+ }
1945
+ // v3.4: вызов pairing-эндпоинтов БЕЗ токена (новое устройство ещё не авторизовано)
1946
+ async function dedPanelPairCall(panelUrl, action, params = {}) {
1947
+ const body = new URLSearchParams({ action, ...params });
1948
+ const res = await fetch(panelUrl.replace(/\/+$/, "") + "/", {
1949
+ method: "POST",
1950
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1951
+ body: body.toString(),
1952
+ signal: AbortSignal.timeout(8000),
1953
+ });
1954
+ return await res.json();
1955
+ }
1956
+ // v3.4: попытка открыть ссылку в браузере пользователя (best-effort, не критично)
1957
+ async function tryOpenBrowser(url) {
1958
+ try {
1959
+ const cp = await import("child_process");
1960
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1961
+ cp.spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
1962
+ }
1963
+ catch { /* нет GUI/браузера — пользователь откроет ссылку сам */ }
1964
+ }
1965
+ function getSyncConfigPath() {
1966
+ return path.join(getGlobalConfigPath(), "sync.json");
1967
+ }
1968
+ function loadSyncConfig() {
1969
+ try {
1970
+ const p = getSyncConfigPath();
1971
+ if (fs.existsSync(p))
1972
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
1973
+ }
1974
+ catch { /* */ }
1975
+ return null;
1976
+ }
1977
+ function saveSyncConfig(cfg) {
1978
+ try {
1979
+ const p = getSyncConfigPath();
1980
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2), "utf-8");
1981
+ fs.chmodSync(p, 0o600);
1982
+ }
1983
+ catch { /* */ }
1984
+ }
1985
+ // v3.5.2: атомарное обновление ТОЛЬКО переданных полей (read-merge-write).
1986
+ // Параллельные фоновые задачи (pull/push/revoke) пишут свою дельту, не затирая чужие поля.
1987
+ function updateSyncConfig(patch) {
1988
+ const cur = loadSyncConfig() || { panelUrl: "https://dedpanel.com", token: "" };
1989
+ saveSyncConfig({ ...cur, ...patch });
1990
+ }
1991
+ // v3.6.2: вызов с ретраями — повтор на сеть/таймаут/rate-limit (для массового бэкфилла, чтобы ничего не терялось).
1992
+ async function dedPanelCallRetry(action, params, retries = 4) {
1993
+ for (let i = 0; i <= retries; i++) {
1994
+ try {
1995
+ const r = await dedPanelCall(action, params);
1996
+ if (r && r.success)
1997
+ return r;
1998
+ // постоянные ошибки (Invalid token и т.п.) — отдаём сразу; временные (rate limit) — повтор
1999
+ if (r && r.error && !/rate limit|too many|retry/i.test(String(r.error)))
2000
+ return r;
2001
+ }
2002
+ catch { /* сеть/таймаут — повтор */ }
2003
+ if (i < retries)
2004
+ await _sleep(200 * (i + 1) + Math.floor((i + 1) * 50)); // backoff
2005
+ }
2006
+ return { success: false, error: "retry_exhausted" };
2007
+ }
2008
+ // v3.6.2: конкурентный прогон с заданной параллельностью (мощная машина → быстро).
2009
+ // worker НЕ должен бросать (ловим), результат по индексу; null при ошибке.
2010
+ async function runConcurrent(items, worker, concurrency = 24) {
2011
+ const results = new Array(items.length).fill(null);
2012
+ let idx = 0;
2013
+ const lane = async () => {
2014
+ for (;;) {
2015
+ const i = idx++;
2016
+ if (i >= items.length)
2017
+ break;
2018
+ try {
2019
+ results[i] = await worker(items[i], i);
2020
+ }
2021
+ catch {
2022
+ results[i] = null;
2023
+ }
2024
+ }
2025
+ };
2026
+ const lanes = Math.max(1, Math.min(concurrency, items.length || 1));
2027
+ await Promise.all(Array.from({ length: lanes }, () => lane()));
2028
+ return results;
2029
+ }
2030
+ // v3.2: УСИЛЕННЫЙ scrub для данных, уходящих в ОБЛАКО (строже scrubSecrets):
2031
+ // дополнительно режет длинные hex-токены и SCREAMING_SNAKE env-секреты (находка k05),
2032
+ // т.к. global-rules содержат пользовательские токены (напр. в правилах G5/G13).
2033
+ function scrubForSync(s) {
2034
+ if (!s)
2035
+ return s;
2036
+ return scrubSecrets(s)
2037
+ // v3.5.2: userinfo в URL (https://user:token@host) — креды в git_remote/origin
2038
+ .replace(/:\/\/[^/@\s]*@/g, "://")
2039
+ // SCREAMING_SNAKE секреты: SS_PASSWORD=, API_TOKEN=, REDIS_PASSWORD= (ключ-слово в любой позиции идентификатора)
2040
+ .replace(/\b[A-Z0-9_]{0,30}(?:PASSWORD|PASSWD|TOKEN|SECRET|APIKEY|PRIVATEKEY|ACCESSKEY|PWD)[A-Z0-9_]{0,30}\s*[:=]\s*['"]?[^\s'"]{4,}/g, (m) => m.split(/\s*[:=]\s*/)[0] + "=[REDACTED]")
2041
+ // длинные hex-токены/ключи (32+ hex) — частый формат API-токенов (вкл. токен DedPanel)
2042
+ .replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED_HEX]");
2043
+ }
2044
+ async function dedPanelCall(action, params = {}, override) {
2045
+ // v3.2.1: override позволяет проверить токен-КАНДИДАТ до записи в sync.json (auth_login)
2046
+ const cfg = override || loadSyncConfig();
2047
+ if (!cfg || !cfg.panelUrl || !cfg.token)
2048
+ throw new Error("dedpanel_not_configured");
2049
+ const body = new URLSearchParams({ action, ...params });
2050
+ const res = await fetch(cfg.panelUrl.replace(/\/+$/, "") + "/", {
2051
+ method: "POST",
2052
+ headers: { "X-API-Token": cfg.token, "Content-Type": "application/x-www-form-urlencoded" },
2053
+ body: body.toString(),
2054
+ signal: AbortSignal.timeout(8000),
2055
+ });
2056
+ return await res.json();
2057
+ }
2058
+ // v3.5: при отзыве токена сервер отдаёт {success:false,error:"Invalid API token"} → затираем токен,
2059
+ // следующий tool-call уйдёт в pairing (как backgroundRevokeCheck).
2060
+ function handleRevokeIfNeeded(r) {
2061
+ if (r && r.success === false && /Invalid API token/i.test(String(r.error || ""))) {
2062
+ updateSyncConfig({ token: "" });
2063
+ }
2064
+ }
2065
+ // PUSH: выгрузка глобальной памяти в аккаунт DedPanel (с усиленным scrub)
2066
+ async function dedPanelSyncPush() {
2067
+ const pushed = [], errors = [];
2068
+ // v3.5.3: global-rules НЕ пушим автоматически — DedPanel источник истины правил
2069
+ // (иначе автопуш затирал бы правки во вкладке MCP). Правила тянутся pull'ом, локальные
2070
+ // изменения через tool `rules` пушатся точечно (pushGlobalRules).
2071
+ const items = [
2072
+ // v3.2.1: digest через scrubForSync (базовый scrub при публикации пропускал SCREAMING_SNAKE/hex — k05/s10)
2073
+ // v4.4.0 блок G/H: ПРИВАТНЫЕ проекты вырезаем из глобального дайджеста перед пушем (не утекают никуда)
2074
+ ["global-digest", () => scrubForSync(JSON.stringify(globalDigestForPush()))],
2075
+ // v3.5: статистика и паттерны — «всё важное на аккаунт»
2076
+ ["global-stats", () => scrubForSync(JSON.stringify(buildGlobalStats()))],
2077
+ ["global-patterns", () => scrubForSync(JSON.stringify(buildPatterns()))],
2078
+ ];
2079
+ for (const [key, build] of items) {
2080
+ try {
2081
+ const r = await dedPanelCall("ded_kv_set", { key, value: build() });
2082
+ if (r && r.success)
2083
+ pushed.push(key);
2084
+ else {
2085
+ errors.push(key);
2086
+ handleRevokeIfNeeded(r);
2087
+ }
2088
+ }
2089
+ catch {
2090
+ errors.push(key);
2091
+ }
2092
+ }
2093
+ return { pushed, errors };
2094
+ }
2095
+ // v4.2: МУЛЬТИ-УСТРОЙСТВО МЕРЖ ГЛОБАЛЬНЫХ ПРАВИЛ (дедуп по тексту + дата + устройство).
2096
+ // Нормализованный текст правила → ключ дедупа (одно правило с разных устройств = одно).
2097
+ function ruleHash(text) {
2098
+ const norm = (text || "").toLowerCase().replace(/\s+/g, " ").trim();
2099
+ let h = 0;
2100
+ for (let i = 0; i < norm.length; i++) {
2101
+ h = ((h << 5) - h + norm.charCodeAt(i)) | 0;
2102
+ }
2103
+ return "r" + (h >>> 0).toString(36);
2104
+ }
2105
+ let _deviceNameCache = null;
2106
+ function getDeviceName() {
2107
+ if (_deviceNameCache !== null)
2108
+ return _deviceNameCache;
2109
+ let name = "dedsession";
2110
+ try {
2111
+ name = require("os").hostname() || name;
2112
+ }
2113
+ catch { /* */ }
2114
+ _deviceNameCache = name.slice(0, 40);
2115
+ return _deviceNameCache;
2116
+ }
2117
+ // миграция: проставить hash/created-время/device правилам, где их нет
2118
+ function ensureRuleMeta(idx) {
2119
+ let changed = false;
2120
+ for (const r of idx.rules) {
2121
+ if (!r.hash) {
2122
+ r.hash = ruleHash(r.rule);
2123
+ changed = true;
2124
+ }
2125
+ if (!r.device) {
2126
+ r.device = getDeviceName();
2127
+ changed = true;
2128
+ }
2129
+ if (r.created && r.created.length <= 10) {
2130
+ r.created = r.created + "T00:00:00Z";
2131
+ changed = true;
2132
+ } // дата → datetime
2133
+ }
2134
+ return changed;
2135
+ }
2136
+ // Единый MERGE-синк глобальных правил: шлём локальные на сервер → сервер union по hash (last-write,
2137
+ // tombstone на удаление) → получаем полный union → сохраняем локально. Заменяет push/pull-overwrite.
2138
+ async function syncRulesMerge() {
2139
+ const local = loadGlobalRules();
2140
+ ensureRuleMeta(local);
2141
+ const r = await dedPanelCall("mcp_rules_merge", {
2142
+ rules: scrubForSync(JSON.stringify(local.rules)),
2143
+ device: getDeviceName(),
2144
+ });
2145
+ handleRevokeIfNeeded(r);
2146
+ if (r && r.success && Array.isArray(r.rules)) {
2147
+ saveGlobalRules({ rules: r.rules });
2148
+ return true;
2149
+ }
2150
+ return false;
2151
+ }
2152
+ function pushGlobalRulesInBackground() {
2153
+ const cfg = loadSyncConfig();
2154
+ if (!cfg || !cfg.token)
2155
+ return;
2156
+ void (async () => { try {
2157
+ await syncRulesMerge();
2158
+ }
2159
+ catch { /* оффлайн — синкнётся при следующем quick */ } })();
2160
+ }
2161
+ // PULL правил = тот же MERGE (union, не overwrite) — ничего не теряется между устройствами.
2162
+ async function dedPanelSyncPull() {
2163
+ const pulled = [], errors = [];
2164
+ try {
2165
+ if (await syncRulesMerge())
2166
+ pulled.push("global-rules");
2167
+ }
2168
+ catch {
2169
+ errors.push("global-rules");
2170
+ }
2171
+ return { pulled, errors };
2172
+ }
2173
+ // v3.5: неблокирующие обёртки авто-синка — вызываются из context_quick/context_save.
2174
+ // Всё в фоне (void), в try/catch — оффлайн/ошибка НЕ ломают основную работу.
2175
+ const AUTO_PULL_MIN_INTERVAL_MS = 5 * 60 * 1000; // не чаще раза в 5 минут
2176
+ function autoPullInBackground() {
2177
+ const cfg = loadSyncConfig();
2178
+ if (!cfg || !cfg.token)
2179
+ return;
2180
+ const last = cfg.lastAutoPull ? Date.parse(cfg.lastAutoPull) : 0;
2181
+ if (last && (Date.now() - last) < AUTO_PULL_MIN_INTERVAL_MS)
2182
+ return;
2183
+ void (async () => {
2184
+ try {
2185
+ await dedPanelSyncPull(); // заодно подтверждает токен (revoke ловится внутри)
2186
+ const fresh = loadSyncConfig();
2187
+ if (fresh && fresh.token)
2188
+ updateSyncConfig({ lastAutoPull: new Date().toISOString(), lastValidated: new Date().toISOString() });
2189
+ }
2190
+ catch { /* оффлайн — молча */ }
2191
+ })();
2192
+ }
2193
+ function autoPushInBackground() {
2194
+ const cfg = loadSyncConfig();
2195
+ if (!cfg || !cfg.token)
2196
+ return;
2197
+ if (isPrivateProject(detectProjectFromWorkDir() || path.basename(getWorkingDir())))
2198
+ return; // v4.4.0 блок G
2199
+ void (async () => {
2200
+ try {
2201
+ const r = await dedPanelSyncPush();
2202
+ const fresh = loadSyncConfig();
2203
+ if (fresh && fresh.token && r.pushed.length)
2204
+ updateSyncConfig({ lastAutoPush: new Date().toISOString(), lastValidated: new Date().toISOString() });
2205
+ }
2206
+ catch { /* оффлайн — молча, локаль не страдает */ }
2207
+ })();
2208
+ }
2209
+ // v3.5.2: локальный реестр уже-залитых контекстов (защита от дублей: повторно не грузим).
2210
+ // { "<project>": ["001","002",...] }. Дополняет серверный дедуп.
2211
+ function getSyncedRegistryPath() { return path.join(getGlobalConfigPath(), "synced-contexts.json"); }
2212
+ function loadSyncedRegistry() {
2213
+ try {
2214
+ const p = getSyncedRegistryPath();
2215
+ if (fs.existsSync(p))
2216
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
2217
+ }
2218
+ catch { /* */ }
2219
+ return {};
2220
+ }
2221
+ // Батч-пометка: один read-merge-write вместо N (меньше окон гонки в цикле backfill).
2222
+ function markContextsSynced(project, ctxIds) {
2223
+ if (!project || ctxIds.length === 0)
2224
+ return;
2225
+ try {
2226
+ const reg = loadSyncedRegistry(); // re-read перед записью (свежий, мёрж с чужими записями)
2227
+ const arr = reg[project] || (reg[project] = []);
2228
+ let changed = false;
2229
+ for (const id of ctxIds) {
2230
+ if (id && !arr.includes(id)) {
2231
+ arr.push(id);
2232
+ changed = true;
2233
+ }
2234
+ }
2235
+ if (changed) {
2236
+ fs.writeFileSync(getSyncedRegistryPath(), JSON.stringify(reg), "utf-8");
2237
+ fs.chmodSync(getSyncedRegistryPath(), 0o600);
2238
+ }
2239
+ }
2240
+ catch { /* */ }
2241
+ }
2242
+ function markContextSynced(project, ctxId) {
2243
+ markContextsSynced(project, [ctxId]);
2244
+ }
2245
+ // v4.4.0 блок G: ПРИВАТНЫЕ проекты — не уходят НИКУДА (ни события, ни digest, ни бэкфилл).
2246
+ // Флаг хранится локально: список в ~/.dedsession/private-projects.json + маркер .dedsession-private в репо.
2247
+ function getPrivateProjectsPath() { return path.join(getGlobalConfigPath(), "private-projects.json"); }
2248
+ function loadPrivateProjects() {
2249
+ try {
2250
+ const p = getPrivateProjectsPath();
2251
+ if (fs.existsSync(p)) {
2252
+ const j = JSON.parse(fs.readFileSync(p, "utf-8"));
2253
+ if (Array.isArray(j))
2254
+ return j;
2255
+ }
2256
+ }
2257
+ catch { /* */ }
2258
+ return [];
2259
+ }
2260
+ function isPrivateProject(project) {
2261
+ try {
2262
+ const wd = getWorkingDir();
2263
+ if (fs.existsSync(path.join(wd, ".dedsession-private")))
2264
+ return true; // маркер в репо
2265
+ }
2266
+ catch { /* */ }
2267
+ if (!project)
2268
+ return false;
2269
+ return loadPrivateProjects().includes(project);
2270
+ }
2271
+ // v4.4.0 блок G/H: глобальный дайджест БЕЗ приватных проектов — то, что реально уходит на сервер.
2272
+ function globalDigestForPush() {
2273
+ const gd = loadGlobalDigest();
2274
+ try {
2275
+ const priv = new Set(loadPrivateProjects());
2276
+ if (priv.size && gd.projects) {
2277
+ for (const name of Object.keys(gd.projects))
2278
+ if (priv.has(name))
2279
+ delete gd.projects[name];
2280
+ }
2281
+ }
2282
+ catch { /* */ }
2283
+ return gd;
2284
+ }
2285
+ function markProjectPrivate(project) {
2286
+ try {
2287
+ const list = loadPrivateProjects();
2288
+ if (project && !list.includes(project)) {
2289
+ list.push(project);
2290
+ fs.writeFileSync(getPrivateProjectsPath(), JSON.stringify(list, null, 2), "utf-8");
2291
+ fs.chmodSync(getPrivateProjectsPath(), 0o600);
2292
+ }
2293
+ }
2294
+ catch { /* */ }
2295
+ try {
2296
+ const wd = getWorkingDir();
2297
+ fs.writeFileSync(path.join(wd, ".dedsession-private"), "Приватный проект dedsession.\nКонтексты этого проекта НЕ синхронизируются на сервер — только локально.\nУдали этот файл и проект снова станет синхронизируемым.\n", "utf-8");
2298
+ }
2299
+ catch { /* */ }
2300
+ }
2301
+ // v3.5.2: догон недостающих контекстов в стату при context_quick — идемпотентно (через git, как легаси).
2302
+ // Шлёт только те, которых НЕТ в реестре; серверный дедуп страхует от повторов. Если всё залито — ничего.
2303
+ let _bgBackfillInflight = false; // не запускаем несколько волн при частых quick
2304
+ function autoBackfillMissingInBackground() {
2305
+ if (_bgBackfillInflight)
2306
+ return;
2307
+ const cfg = loadSyncConfig();
2308
+ if (!cfg || !cfg.token)
2309
+ return;
2310
+ if (isPrivateProject(detectProjectFromWorkDir() || path.basename(getWorkingDir())))
2311
+ return; // v4.4.0 блок G
2312
+ _bgBackfillInflight = true;
2313
+ void (async () => {
2314
+ try {
2315
+ const workDir = getWorkingDir();
2316
+ const project = detectProjectFromWorkDir() || path.basename(workDir);
2317
+ const reg = loadSyncedRegistry();
2318
+ const done = new Set(reg[project] || []);
2319
+ const missing = listContexts().filter(c => !done.has(contextIdOf(c)));
2320
+ if (missing.length === 0)
2321
+ return; // всё уже залито — забить
2322
+ const account = String(cfg.username || cfg.uid || "");
2323
+ const gitRemote = scrubForSync(readGitRemoteUrl(workDir) || "");
2324
+ const gitUserCfg = String(readGitUser(workDir) || "").slice(0, 80);
2325
+ // v3.6.2: параллельно (16 lanes — фон при quick) + ретраи
2326
+ const res = await runConcurrent(missing, async (c) => {
2327
+ const status = "saved"; // v4.4.0 блок J: понятие «выполнено/застряло» убрано — событие = просто сохранение
2328
+ const ts = c.date ? Math.floor(new Date(c.date).getTime() / 1000) : Math.floor(Date.now() / 1000);
2329
+ const gitUser = ((await readGitAuthorForDate(workDir, c.date)) || gitUserCfg).slice(0, 80);
2330
+ const r = await dedPanelCallRetry("ded_event", {
2331
+ project, ctx_id: contextIdOf(c), status, size: String(c.filesCount || 0),
2332
+ topic: scrubForSync(c.title || "").slice(0, 200), tags: "", solutions_count: "0",
2333
+ ts: String(isNaN(ts) ? Math.floor(Date.now() / 1000) : ts),
2334
+ account, git_remote: gitRemote, git_user: gitUser, legacy: "1",
2335
+ });
2336
+ if (r && !r.success)
2337
+ handleRevokeIfNeeded(r);
2338
+ return { ok: !!(r && r.success), id: contextIdOf(c) };
2339
+ }, 16);
2340
+ markContextsSynced(project, res.filter(x => x && x.ok).map(x => x.id)); // один write в конце (batch)
2341
+ }
2342
+ catch { /* не критично */ }
2343
+ finally {
2344
+ _bgBackfillInflight = false;
2345
+ }
2346
+ })();
2347
+ }
2348
+ // v3.5: append-событие в лог аккаунта («схема логов»). Только метаполя, scrubForSync на topic.
2349
+ function logEventInBackground(ev) {
2350
+ const cfg = loadSyncConfig();
2351
+ if (!cfg || !cfg.token)
2352
+ return;
2353
+ if (isPrivateProject(String(ev.project ?? "")))
2354
+ return; // v4.4.0 блок G: приватный проект не логируем на сервер
2355
+ void (async () => {
2356
+ try {
2357
+ // v3.5.1: привязка «кто/где» — аккаунт DedPanel + git-репо + git-автор
2358
+ const workDir = getWorkingDir();
2359
+ const params = {
2360
+ project: String(ev.project ?? ""),
2361
+ ctx_id: String(ev.ctxId ?? ""),
2362
+ status: "saved", // v4.4.0 блок J: статусы убраны — всегда «saved»
2363
+ size: String(ev.size ?? 0),
2364
+ topic: scrubForSync(String(ev.topic ?? "")).slice(0, 200),
2365
+ tags: Array.isArray(ev.tags) ? ev.tags.slice(0, 5).join(",") : "",
2366
+ solutions_count: String(ev.solutionsCount ?? 0),
2367
+ ts: String(ev.ts ? Number(ev.ts) : Math.floor(Date.now() / 1000)),
2368
+ account: String(cfg.username || cfg.uid || ""),
2369
+ git_remote: scrubForSync(readGitRemoteUrl(workDir) || ""),
2370
+ git_user: String(readGitUser(workDir) || "").slice(0, 80),
2371
+ legacy: ev.legacy ? "1" : "0",
2372
+ };
2373
+ const r = await dedPanelCall("ded_event", params);
2374
+ handleRevokeIfNeeded(r);
2375
+ // помечаем контекст залитым (защита от повторной заливки при quick-догоне)
2376
+ if (r && r.success && ev.ctxId)
2377
+ markContextSynced(params.project, String(ev.ctxId));
2378
+ }
2379
+ catch { /* оффлайн — событие пропускаем, не критично */ }
2380
+ })();
2381
+ }
2382
+ // v4.5.0: статистика git-коммитов по авторам текущего репо → DedPanel (собирается dedsession).
2383
+ // git log --all --no-merges --format=%an, считаем по авторам. Приватные проекты не шлём. Кэш-троттл.
2384
+ let _commitStatsSentAt = 0;
2385
+ function collectCommitStatsInBackground() {
2386
+ const cfg = loadSyncConfig();
2387
+ if (!cfg || !cfg.token)
2388
+ return;
2389
+ if (Date.now() - _commitStatsSentAt < 10 * 60 * 1000)
2390
+ return; // не чаще раза в 10 мин
2391
+ const project = detectProjectFromWorkDir() || path.basename(getWorkingDir());
2392
+ if (isPrivateProject(project))
2393
+ return; // v4.4.0 блок G: приватный проект — не шлём
2394
+ _commitStatsSentAt = Date.now();
2395
+ void (async () => {
2396
+ try {
2397
+ const workDir = getWorkingDir();
2398
+ const authors = await new Promise((resolve) => {
2399
+ execFile("git", ["-C", workDir, "log", "--all", "--no-merges", "--format=%an"], { encoding: "utf-8", timeout: 8000, maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
2400
+ const acc = {};
2401
+ if (!err && stdout)
2402
+ for (const line of stdout.split("\n")) {
2403
+ const a = line.trim().slice(0, 80);
2404
+ if (a)
2405
+ acc[a] = (acc[a] || 0) + 1;
2406
+ }
2407
+ resolve(acc);
2408
+ });
2409
+ });
2410
+ if (Object.keys(authors).length === 0)
2411
+ return;
2412
+ const r = await dedPanelCall("mcp_commit_stats", { op: "set", project, stats: JSON.stringify(authors) });
2413
+ handleRevokeIfNeeded(r);
2414
+ }
2415
+ catch { /* оффлайн/нет git — не критично */ }
2416
+ })();
2417
+ }
1711
2418
  /**
1712
2419
  * Форматирует блок правил (глобальные + локальные)
1713
2420
  */
1714
2421
  function formatRulesBlock() {
1715
2422
  const globalRules = loadGlobalRules();
1716
2423
  const localRules = loadRules();
1717
- if (globalRules.rules.length === 0 && localRules.rules.length === 0)
2424
+ // v4.4.0 блок D: считаем активные (без tombstone) глобальные + локальные
2425
+ const activeGlobal = globalRules.rules.filter(r => !r.deleted);
2426
+ const activeLocal = localRules.rules.filter(r => !r.deleted);
2427
+ if (activeGlobal.length === 0 && activeLocal.length === 0)
1718
2428
  return "";
1719
2429
  let output = "# 🔴 ПРИОРИТЕТНЫЕ ПРАВИЛА\n\n";
1720
- output += "> Эти правила Claude ОБЯЗАН соблюдать во ВСЕХ контекстах!\n\n";
1721
- // Глобальные правила
1722
- if (globalRules.rules.length > 0) {
2430
+ output += `> Загружено правил: **${activeGlobal.length}** глобальных + **${activeLocal.length}** локальных. Claude ОБЯЗАН соблюдать их во ВСЕХ контекстах!\n\n`;
2431
+ // Глобальные правила (v4.2: tombstone-удалённые скрываем)
2432
+ if (activeGlobal.length > 0) {
1723
2433
  output += "**🌍 Глобальные (все проекты):**\n";
1724
- for (const rule of globalRules.rules) {
1725
- output += `- **G${rule.id}.** ${rule.rule}`;
2434
+ for (const rule of activeGlobal) {
2435
+ const head = rule.title ? `**${rule.title}** — ` : ""; // v4.4.0 блок C: заголовок впереди
2436
+ output += `- **G${rule.id}.** ${head}${rule.rule}`;
1726
2437
  if (rule.context)
1727
2438
  output += ` _(${rule.context})_`;
1728
2439
  output += "\n";
@@ -1730,9 +2441,9 @@ function formatRulesBlock() {
1730
2441
  output += "\n";
1731
2442
  }
1732
2443
  // Локальные правила
1733
- if (localRules.rules.length > 0) {
2444
+ if (activeLocal.length > 0) {
1734
2445
  output += "**📁 Локальные (этот проект):**\n";
1735
- for (const rule of localRules.rules) {
2446
+ for (const rule of activeLocal) {
1736
2447
  output += `- **L${rule.id}.** ${rule.rule}`;
1737
2448
  if (rule.context)
1738
2449
  output += ` _(${rule.context})_`;
@@ -1778,91 +2489,8 @@ function detectProject(content, folderName) {
1778
2489
  return bestMatch?.project || null;
1779
2490
  }
1780
2491
  // Определяет человека по содержимому (для context_smart)
1781
- function detectPerson(content) {
1782
- const lower = content.toLowerCase();
1783
- for (const [person, patterns] of Object.entries(PERSON_PATTERNS)) {
1784
- for (const pattern of patterns) {
1785
- if (lower.includes(pattern)) {
1786
- return person;
1787
- }
1788
- }
1789
- }
1790
- return null;
1791
- }
1792
2492
  // Парсит HISTORY.md и возвращает последние N записей (для context_smart)
1793
- function parseHistoryEntries(historyContent, limit = 25) {
1794
- const entries = [];
1795
- // Формат записи в HISTORY.md:
1796
- // ---
1797
- // #001 | 2026-01-07 | task-name | ✅
1798
- // **Сводка:** текст сводки...
1799
- // **Файлы:** список файлов
1800
- const entryRegex = /#(\d{3})\s*\|\s*(\d{4}-\d{2}-\d{2})\s*\|\s*([^\|]+)\s*\|\s*(✅|⏳|❌)/g;
1801
- const sections = historyContent.split(/^---$/m);
1802
- for (const section of sections) {
1803
- const match = entryRegex.exec(section);
1804
- if (match) {
1805
- const [, number, date, name, status] = match;
1806
- const cleanName = name.trim();
1807
- // Извлекаем сводку (всё после заголовка записи)
1808
- const summaryStart = section.indexOf(status) + status.length;
1809
- const summary = section.slice(summaryStart).trim();
1810
- // Определяем человека по названию и сводке
1811
- const person = detectPerson(cleanName + " " + summary);
1812
- // Извлекаем ключевые слова из названия
1813
- const keywords = cleanName.split(/[-_\s]+/).filter(w => w.length > 2);
1814
- entries.push({
1815
- number,
1816
- date,
1817
- name: cleanName,
1818
- status,
1819
- summary,
1820
- person,
1821
- keywords,
1822
- });
1823
- }
1824
- // Сбрасываем regex для следующей итерации
1825
- entryRegex.lastIndex = 0;
1826
- }
1827
- // Сортируем по номеру (новые первые) и берём limit
1828
- return entries
1829
- .sort((a, b) => parseInt(b.number) - parseInt(a.number))
1830
- .slice(0, limit);
1831
- }
1832
2493
  // Вычисляет релевантность контекста к запросу (для context_smart_focus)
1833
- function scoreContextRelevance(entry, queryPerson, queryKeywords, memoryIndex) {
1834
- let score = 0;
1835
- // +5 за совпадение человека (было +10, уменьшено для баланса)
1836
- if (queryPerson && entry.person === queryPerson) {
1837
- score += 5;
1838
- }
1839
- // +5 за совпадение ключевого слова в названии
1840
- for (const kw of queryKeywords) {
1841
- if (entry.name.toLowerCase().includes(kw)) {
1842
- score += 5;
1843
- }
1844
- }
1845
- // +2 за совпадение ключевого слова в сводке
1846
- for (const kw of queryKeywords) {
1847
- if (entry.summary.toLowerCase().includes(kw)) {
1848
- score += 2;
1849
- }
1850
- }
1851
- // Бонус из Memory v2.0: +5 за совпадение project (было +3, увеличено)
1852
- const contextMeta = memoryIndex.contexts[entry.number];
1853
- if (contextMeta) {
1854
- for (const kw of queryKeywords) {
1855
- if (contextMeta.project && contextMeta.project.toLowerCase().includes(kw)) {
1856
- score += 5;
1857
- }
1858
- // +3 за совпадение в keywords из Memory (было +1, увеличено для технологий)
1859
- if (contextMeta.keywords.some(k => k.includes(kw) || kw.includes(k))) {
1860
- score += 3;
1861
- }
1862
- }
1863
- }
1864
- return score;
1865
- }
1866
2494
  function extractKeywords(content) {
1867
2495
  const keywords = new Set();
1868
2496
  // Извлекаем технологии и инструменты
@@ -1913,14 +2541,17 @@ function calculateTemperature(lastAccess, accessCount) {
1913
2541
  return "archive";
1914
2542
  }
1915
2543
  function detectEpicFromContext(contextName, readme, existingEpics) {
1916
- // Проверяем есть ли parent в readme
1917
- const parentMatch = readme.match(/\*\*Parent:\*\*\s*(\d+)/);
2544
+ // Проверяем есть ли parent в readme (v3.1.1: полное значение, не только \d+ — иначе legacy-родитель рвался)
2545
+ const parentMatch = readme.match(/\*\*Parent:\*\*\s*(.+)/);
1918
2546
  if (parentMatch) {
1919
- const parentId = parentMatch[1];
1920
- // Проверяем в каком эпике находится parent
1921
- for (const [epicName, epic] of Object.entries(existingEpics.epics)) {
1922
- if (epic.chain.some(id => id.includes(parentId))) {
1923
- return epicName;
2547
+ const parentId = normalizeParentRef(parentMatch[1]) || "";
2548
+ // v3.1.1: точное сравнение id, не includes (иначе "001" ложно матчил формат-B id и "100"⊂"1001",
2549
+ // а пустой parentId матчил любой эпик). Пустой parentId — пропускаем.
2550
+ if (parentId) {
2551
+ for (const [epicName, epic] of Object.entries(existingEpics.epics)) {
2552
+ if (epic.chain.some(id => id === parentId)) {
2553
+ return epicName;
2554
+ }
1924
2555
  }
1925
2556
  }
1926
2557
  }
@@ -1950,6 +2581,12 @@ function migrateToMemorySystem() {
1950
2581
  const projectsIndex = loadProjects();
1951
2582
  const keywordsIndex = loadKeywords();
1952
2583
  const epicsIndex = loadEpics();
2584
+ // v3.1.1: reindex — полный REBUILD из диска (а не merge). Иначе: (1) старые мусорные
2585
+ // keyword-ключи проектов оставались; (2) memoryIndex.contexts копил orphans удалённых папок
2586
+ // и держал дубль под двумя ключами при смене порядка обхода. Диск = источник истины.
2587
+ projectsIndex.projects = {};
2588
+ memoryIndex.contexts = {};
2589
+ keywordsIndex.keywords = {};
1953
2590
  // Проходим по всем контекстам
1954
2591
  for (const ctx of contexts) {
1955
2592
  try {
@@ -1979,8 +2616,9 @@ function migrateToMemorySystem() {
1979
2616
  }
1980
2617
  const fullContent = readme + "\n" + changes;
1981
2618
  const solutionsContent = problemsSolutions + "\n" + readme + "\n" + changes + "\n" + sessionLog;
1982
- // Определяем проект
1983
- const project = detectProject(fullContent, ctx.name);
2619
+ // v3.1.1: проект = репозиторий (один репо = один project), а не контент-паттерн.
2620
+ // Раньше detectProject(content) разбрасывал контексты бэкенда по dedsession/web/scripts.
2621
+ const project = detectProjectFromWorkDir() || detectProject(fullContent, ctx.name);
1984
2622
  // Извлекаем ключевые слова
1985
2623
  const keywords = extractKeywords(fullContent);
1986
2624
  // Определяем эпик
@@ -1991,7 +2629,7 @@ function migrateToMemorySystem() {
1991
2629
  : [];
1992
2630
  // Создаём мету контекста
1993
2631
  const contextMeta = {
1994
- id: String(ctx.number).padStart(3, "0"),
2632
+ id: contextIdOf(ctx), // v3.1 LEGACY-FIX: не схлопывать формат B в "000"
1995
2633
  folder: ctx.name,
1996
2634
  name: ctx.title,
1997
2635
  date: ctx.date,
@@ -2012,14 +2650,19 @@ function migrateToMemorySystem() {
2012
2650
  files,
2013
2651
  size: ctx.size,
2014
2652
  };
2015
- // Извлекаем parent из readme
2016
- const parentMatch = readme.match(/\*\*Parent:\*\*\s*(\d+)/);
2653
+ // Извлекаем parent из readme (v3.1.1: полное значение → normalizeParentRef, чтобы legacy-формат B резолвился)
2654
+ const parentMatch = readme.match(/\*\*Parent:\*\*\s*(.+)/);
2017
2655
  if (parentMatch) {
2018
- contextMeta.relations.parent = parentMatch[1].padStart(3, "0");
2656
+ contextMeta.relations.parent = normalizeParentRef(parentMatch[1]);
2657
+ }
2658
+ // v3.1.1 ДУБЛИ ФОРМАТА A: если этот id уже занят ДРУГИМ контекстом (два #076, два #095)
2659
+ // — ключуем по имени папки (уникально на диске), иначе теряли до 33 контекстов (dedai-backend).
2660
+ if (memoryIndex.contexts[contextMeta.id] && memoryIndex.contexts[contextMeta.id].folder !== ctx.name) {
2661
+ contextMeta.id = ctx.name;
2019
2662
  }
2020
2663
  // Сохраняем в индекс
2021
2664
  memoryIndex.contexts[contextMeta.id] = contextMeta;
2022
- // Обновляем проекты
2665
+ // Обновляем проекты (идемпотентно: без дублей при повторном reindex)
2023
2666
  if (project) {
2024
2667
  if (!projectsIndex.projects[project]) {
2025
2668
  projectsIndex.projects[project] = {
@@ -2030,9 +2673,11 @@ function migrateToMemorySystem() {
2030
2673
  hotContexts: [],
2031
2674
  };
2032
2675
  }
2033
- projectsIndex.projects[project].contexts.push(contextMeta.id);
2034
- projectsIndex.projects[project].count++;
2035
- if (contextMeta.temperature === "hot") {
2676
+ if (!projectsIndex.projects[project].contexts.includes(contextMeta.id)) {
2677
+ projectsIndex.projects[project].contexts.push(contextMeta.id);
2678
+ projectsIndex.projects[project].count = projectsIndex.projects[project].contexts.length;
2679
+ }
2680
+ if (contextMeta.temperature === "hot" && !projectsIndex.projects[project].hotContexts.includes(contextMeta.id)) {
2036
2681
  projectsIndex.projects[project].hotContexts.push(contextMeta.id);
2037
2682
  }
2038
2683
  if (ctx.date > projectsIndex.projects[project].lastActive) {
@@ -2105,65 +2750,6 @@ function updateContextAccess(contextId) {
2105
2750
  }
2106
2751
  }
2107
2752
  }
2108
- function getContextsByProject(projectName) {
2109
- const memoryIndex = loadMemoryIndex();
2110
- const projectsIndex = loadProjects();
2111
- const project = projectsIndex.projects[projectName];
2112
- if (!project)
2113
- return [];
2114
- return project.contexts
2115
- .map(id => memoryIndex.contexts[id])
2116
- .filter((ctx) => ctx !== undefined)
2117
- .sort((a, b) => {
2118
- // Сначала по температуре (hot > warm > cold > archive)
2119
- const tempOrder = { hot: 0, warm: 1, cold: 2, archive: 3 };
2120
- if (tempOrder[a.temperature] !== tempOrder[b.temperature]) {
2121
- return tempOrder[a.temperature] - tempOrder[b.temperature];
2122
- }
2123
- // Потом по дате
2124
- return b.date.localeCompare(a.date);
2125
- });
2126
- }
2127
- function getContextsByEpic(epicName) {
2128
- const memoryIndex = loadMemoryIndex();
2129
- const epicsIndex = loadEpics();
2130
- const epic = epicsIndex.epics[epicName];
2131
- if (!epic)
2132
- return [];
2133
- return epic.chain
2134
- .map(id => memoryIndex.contexts[id])
2135
- .filter((ctx) => ctx !== undefined);
2136
- }
2137
- function searchByKeywords(query) {
2138
- const memoryIndex = loadMemoryIndex();
2139
- const keywordsIndex = loadKeywords();
2140
- const queryWords = query.toLowerCase().split(/\s+/);
2141
- const contextScores = new Map();
2142
- // Ищем по ключевым словам
2143
- for (const word of queryWords) {
2144
- for (const [keyword, contextIds] of Object.entries(keywordsIndex.keywords)) {
2145
- if (keyword.includes(word) || word.includes(keyword)) {
2146
- for (const id of contextIds) {
2147
- contextScores.set(id, (contextScores.get(id) || 0) + 1);
2148
- }
2149
- }
2150
- }
2151
- }
2152
- // Ищем по названиям контекстов
2153
- for (const [id, ctx] of Object.entries(memoryIndex.contexts)) {
2154
- for (const word of queryWords) {
2155
- if (ctx.name.toLowerCase().includes(word) || ctx.folder.toLowerCase().includes(word)) {
2156
- contextScores.set(id, (contextScores.get(id) || 0) + 2);
2157
- }
2158
- }
2159
- }
2160
- // Сортируем по score
2161
- return Array.from(contextScores.entries())
2162
- .sort((a, b) => b[1] - a[1])
2163
- .slice(0, 10)
2164
- .map(([id]) => memoryIndex.contexts[id])
2165
- .filter((ctx) => ctx !== undefined);
2166
- }
2167
2753
  function findSimilarContexts(contextId) {
2168
2754
  const memoryIndex = loadMemoryIndex();
2169
2755
  const ctx = memoryIndex.contexts[contextId];
@@ -2196,19 +2782,6 @@ function findSimilarContexts(contextId) {
2196
2782
  .map(([id]) => memoryIndex.contexts[id])
2197
2783
  .filter((ctx) => ctx !== undefined);
2198
2784
  }
2199
- function getUnfinishedEpics() {
2200
- const epicsIndex = loadEpics();
2201
- const memoryIndex = loadMemoryIndex();
2202
- const unfinished = [];
2203
- for (const [name, epic] of Object.entries(epicsIndex.epics)) {
2204
- if (epic.status === "in_progress") {
2205
- const lastContextId = epic.chain[epic.chain.length - 1];
2206
- const lastContext = lastContextId ? memoryIndex.contexts[lastContextId] : null;
2207
- unfinished.push({ name, epic, lastContext });
2208
- }
2209
- }
2210
- return unfinished;
2211
- }
2212
2785
  function getMemoryStats() {
2213
2786
  const memoryIndex = loadMemoryIndex();
2214
2787
  const projectsIndex = loadProjects();
@@ -2231,17 +2804,77 @@ function getMemoryStats() {
2231
2804
  solutionsCount: solutionsIndex.solutions.length,
2232
2805
  };
2233
2806
  }
2807
+ // v3.5: расширенная статистика «сколько работаю» — для хранения на аккаунте и дашборда.
2808
+ // Только числа/имена проектов (всё равно прогоняется через scrubForSync при пуше).
2809
+ function buildGlobalStats() {
2810
+ const ctx = listContexts(); // { date, status, ... }
2811
+ const byDay = {};
2812
+ for (const c of ctx) {
2813
+ if (c.date)
2814
+ byDay[c.date] = (byDay[c.date] || 0) + 1;
2815
+ }
2816
+ const days = Object.keys(byDay).sort();
2817
+ const now = Date.now();
2818
+ const weekAgo = now - 7 * 24 * 60 * 60 * 1000;
2819
+ const perWeek = ctx.filter(c => c.date && new Date(c.date).getTime() >= weekAgo).length;
2820
+ let peakDay = { date: "", count: 0 };
2821
+ for (const [d, n] of Object.entries(byDay))
2822
+ if (n > peakDay.count)
2823
+ peakDay = { date: d, count: n };
2824
+ const mem = getMemoryStats();
2825
+ return {
2826
+ schemaVersion: 1,
2827
+ generated: new Date().toISOString(),
2828
+ totals: getStatistics(),
2829
+ memory: mem,
2830
+ velocity: {
2831
+ perWeek,
2832
+ avgPerActiveDay: Math.round((ctx.length / Math.max(1, days.length)) * 100) / 100,
2833
+ activeDays: days.length,
2834
+ },
2835
+ peakDay,
2836
+ distributionByProject: mem.projects,
2837
+ };
2838
+ }
2839
+ // v3.5: паттерны решений — частота проблем по ключевым словам, top повторяющихся решений.
2840
+ function buildPatterns() {
2841
+ const sols = loadSolutions().solutions;
2842
+ const wordFreq = {};
2843
+ const stop = new Set(["после", "через", "когда", "чтобы", "которые", "этого", "больше", "нужно", "можно", "будет", "this", "that", "with", "from", "then", "null", "true", "false", "которое"]);
2844
+ for (const s of sols) {
2845
+ const words = (s.problem || "").toLowerCase().match(/[a-zа-яё0-9_]{4,}/gi) || [];
2846
+ for (const w of words) {
2847
+ if (stop.has(w))
2848
+ continue;
2849
+ wordFreq[w] = (wordFreq[w] || 0) + 1;
2850
+ }
2851
+ }
2852
+ const topProblems = Object.entries(wordFreq).sort((a, b) => b[1] - a[1]).slice(0, 25).map(([word, count]) => ({ word, count }));
2853
+ const topSolutions = sols
2854
+ .slice()
2855
+ .sort((a, b) => (b.sourceContexts?.length || 0) - (a.sourceContexts?.length || 0))
2856
+ .slice(0, 15)
2857
+ .map(s => ({ problem: (s.problem || "").slice(0, 120), uses: s.sourceContexts?.length || 0 }));
2858
+ return {
2859
+ schemaVersion: 1,
2860
+ generated: new Date().toISOString(),
2861
+ totalSolutions: sols.length,
2862
+ topProblems,
2863
+ topSolutions,
2864
+ };
2865
+ }
2234
2866
  /**
2235
2867
  * Добавляет новый контекст в Memory System v2.0
2236
2868
  * Вызывается автоматически при context_save
2237
2869
  */
2238
- function addContextToMemory(contextNumber, folderName, taskName, status, content, date) {
2870
+ function addContextToMemory(contextNumber, folderName, taskName, status, content, date, parentRef // v3.1.2 FIX H: явный родитель (state.loadedContext к этому моменту уже перезаписан)
2871
+ ) {
2239
2872
  try {
2240
2873
  const memoryIndex = loadMemoryIndex();
2241
2874
  const projectsIndex = loadProjects();
2242
2875
  const keywordsIndex = loadKeywords();
2243
- // Определяем проект
2244
- const project = detectProject(content, folderName);
2876
+ // v3.1.1: проект = репозиторий (один репо = один project), а не контент-паттерн.
2877
+ const project = detectProjectFromWorkDir() || detectProject(content, folderName);
2245
2878
  // Извлекаем ключевые слова
2246
2879
  const keywords = extractKeywords(content);
2247
2880
  const contextId = String(contextNumber).padStart(3, "0");
@@ -2259,7 +2892,9 @@ function addContextToMemory(contextNumber, folderName, taskName, status, content
2259
2892
  accessCount: 1,
2260
2893
  lastAccess: date,
2261
2894
  relations: {
2262
- parent: state.loadedContext ? state.loadedContext.split("_")[0].padStart(3, "0") : null,
2895
+ // v3.1.2 FIX H: ТОЛЬКО явный parentRef (из result, вычислен до перезаписи state).
2896
+ // Fallback на state.loadedContext убран — к этому моменту он уже = только что созданный контекст (self).
2897
+ parent: parentRef ? normalizeParentRef(parentRef) : null,
2263
2898
  children: [],
2264
2899
  similar: [],
2265
2900
  epicPrev: null,
@@ -2352,19 +2987,30 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2352
2987
  return { contents: [{ uri, mimeType: "text/markdown", text: content }] };
2353
2988
  });
2354
2989
  // TOOLS
2355
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
2356
- tools: [
2990
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
2991
+ // v3.7: ПУБЛИЧНО показываем только ядро — вся остальная логика (миграции, дайджест, синк,
2992
+ // глобал, поиск-дозагрузка, fallback-start) зашита в context_quick/context_save/context_load.
2993
+ // Минимум команд без перегруза. Остальные обработчики остаются внутренними.
2994
+ const _PUBLIC = new Set(["context_quick", "context_save", "context_load", "context_attach", "context_private", "rules", "auth_pair", "auth_status", "auth_logout"]);
2995
+ const _allTools = [
2357
2996
  {
2358
- name: "context_start",
2359
- description: "🚀 ГЛАВНАЯ КОМАНДА. Инициализация системы контекста dedsession. Показывает инструкции, статистику и список последних контекстов с выбором действия. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'запусти контекст', 'загрузи контекст', 'загрузи', 'контекст', 'начни сессию', 'инициализация', 'dedsession', 'старт', 'начало'. Это ПЕРВОЕ что нужно сделать — НЕ вызывай context_load напрямую без context_start!",
2360
- inputSchema: {
2361
- type: "object",
2362
- properties: {},
2363
- },
2997
+ name: "auth_pair",
2998
+ description: "🔐 Авторизация dedsession через БРАУЗЕР (как OAuth у Claude): генерирует ссылку, открывает браузер, ты жмёшь «Подтвердить» на странице DedPanel — устройство привязывается само. Вызывай когда пользователь говорит: 'авторизуйся', 'привяжи устройство', 'войти через браузер', 'pair', 'подключи dedpanel'. Повторный вызов поллит подтверждение.",
2999
+ inputSchema: { type: "object", properties: {} },
3000
+ },
3001
+ {
3002
+ name: "auth_status",
3003
+ description: "🔐 Показать статус привязки к аккаунту DedPanel (кто авторизован, жив ли токен). Вызывай когда пользователь говорит: 'статус авторизации', 'auth status', 'кто я', 'привязан ли аккаунт'.",
3004
+ inputSchema: { type: "object", properties: {} },
3005
+ },
3006
+ {
3007
+ name: "auth_logout",
3008
+ description: "🔐 Отвязать аккаунт DedPanel (удалить локальный токен). Вызывай когда пользователь говорит: 'выйди', 'отвяжи аккаунт', 'auth logout', 'разлогинься'.",
3009
+ inputSchema: { type: "object", properties: {} },
2364
3010
  },
2365
3011
  {
2366
3012
  name: "context_save",
2367
- description: "💾 Сохранить контекст работы в MD_HISTORY/. УМНОЕ СОХРАНЕНИЕ: автоматически определяет масштаб задачи (3-15 файлов). Маленькая задача = 3 файла, большая задача = до 15 файлов. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'сохрани контекст', 'сохрани работу', 'сохранить', 'сохрани', 'запомни работу'.",
3013
+ description: "💾 ЕДИНЫЙ УМНЫЙ РЕЖИМ сохранения контекста (v3.1). Один режим на всё НЕ нужно выбирать 'подробно/кратко'. Заполняй ЛЮБЫЕ поля, какие есть (можно много, можно мало) система сохранит ТОЛЬКО непустые секции (пустые не создаются, мусор не копится) и извлечёт максимум пользы для будущего. Чтобы извлечь максимум — старайся заполнять problems_solutions (с Симптом/Root cause/Решение/Профилактика), decisions, key changes. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'сохрани контекст', 'сохрани работу', 'сохранить', 'сохрани', 'запомни работу', 'сохрани подробно', 'детальное сохранение'. Это ЕДИНСТВЕННАЯ команда сохранения — context_save_detailed устарел и идентичен этой.",
2368
3014
  inputSchema: {
2369
3015
  type: "object",
2370
3016
  properties: {
@@ -2372,11 +3018,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2372
3018
  type: "string",
2373
3019
  description: "Краткое название задачи (станет частью имени папки)",
2374
3020
  },
2375
- status: {
2376
- type: "string",
2377
- enum: ["completed", "in_progress", "blocked"],
2378
- description: "Статус задачи: completed (✅), in_progress (⏳), blocked (❌)",
2379
- },
2380
3021
  task_overview: {
2381
3022
  type: "string",
2382
3023
  description: "Описание задачи: что делали, какая была цель",
@@ -2449,139 +3090,30 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2449
3090
  required: ["task_name", "changes", "summary", "session_log"],
2450
3091
  },
2451
3092
  },
2452
- {
2453
- name: "context_save_detailed",
2454
- description: "📚 МАКСИМАЛЬНО ПОДРОБНОЕ сохранение контекста (15 файлов). ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'сохрани контекст подробно', 'подробно сохрани', 'детальное сохранение', 'сохрани всё подробно', '15 файлов'. Создаёт 15 файлов с максимально качественной информацией. Перед вызовом Claude ОБЯЗАН прочитать гайд по заполнению!",
2455
- inputSchema: {
2456
- type: "object",
2457
- properties: {
2458
- task_name: {
2459
- type: "string",
2460
- description: "Краткое название задачи латиницей через дефис (пример: user-auth-fix)",
2461
- },
2462
- status: {
2463
- type: "string",
2464
- enum: ["completed", "in_progress", "blocked"],
2465
- description: "Статус: completed (✅), in_progress (⏳), blocked (❌)",
2466
- },
2467
- task_overview: {
2468
- type: "string",
2469
- description: "ПОДРОБНО: Какую проблему решали? Какая цель? Какой контекст/предыстория? Минимум 5-7 предложений!",
2470
- },
2471
- analysis: {
2472
- type: "string",
2473
- description: "ПОДРОБНО: Что нашли при исследовании? Какие проблемы? Какие зависимости? Какие риски? Минимум 5-7 пунктов!",
2474
- },
2475
- changes: {
2476
- type: "string",
2477
- description: "КРИТИЧЕСКИ ВАЖНО! Для КАЖДОГО файла: путь, номера строк, что изменено и ПОЧЕМУ, код до/после. Это ГЛАВНЫЙ файл контекста!",
2478
- },
2479
- summary: {
2480
- type: "string",
2481
- description: "Что сделано (список), что работает по-другому, что осталось, known issues. Минимум 10 пунктов!",
2482
- },
2483
- session_log: {
2484
- type: "string",
2485
- description: "ХРОНОЛОГИЯ сессии: время, действия, ошибки, решения. Формат: '### HH:MM - Событие'. Минимум 5 записей!",
2486
- },
2487
- code_snippets: {
2488
- type: "string",
2489
- description: "ВСЕ важные куски кода: новые функции целиком, сложная логика, конфигурации. С комментариями!",
2490
- },
2491
- decisions: {
2492
- type: "string",
2493
- description: "Для КАЖДОГО решения: варианты, почему выбрали это, trade-offs. Минимум 3 решения!",
2494
- },
2495
- problems_solutions: {
2496
- type: "string",
2497
- description: "Для КАЖДОЙ проблемы: симптомы, root cause, решение, как предотвратить. Формат: 'Проблема: X / Решение: Y'. Минимум 2 проблемы!",
2498
- },
2499
- architecture: {
2500
- type: "string",
2501
- description: "Структура модулей, потоки данных, интеграции. Можно ASCII-диаграммы.",
2502
- },
2503
- testing: {
2504
- type: "string",
2505
- description: "Что тестировали, как тестировали, результаты тестов, покрытие.",
2506
- },
2507
- api_docs: {
2508
- type: "string",
2509
- description: "API документация: эндпоинты, параметры, примеры запросов/ответов.",
2510
- },
2511
- migration: {
2512
- type: "string",
2513
- description: "Миграции данных, breaking changes, инструкции по обновлению.",
2514
- },
2515
- performance: {
2516
- type: "string",
2517
- description: "Оптимизации производительности, метрики до/после, узкие места.",
2518
- },
2519
- security: {
2520
- type: "string",
2521
- description: "Анализ безопасности, уязвимости, исправления, рекомендации.",
2522
- },
2523
- full_context: {
2524
- type: "string",
2525
- description: "Полный контекст сессии: всё что не вошло в другие файлы, дополнительные заметки.",
2526
- },
2527
- history_summary: {
2528
- type: "string",
2529
- description: "⭐ ОБЯЗАТЕЛЬНО: Качественная сводка для HISTORY.md (2-4 предложения). Формат: **Сводка:** суть задачи, что сделано, результат. **Файлы:** список изменённых файлов.",
2530
- },
2531
- digest_update: {
2532
- type: "string",
2533
- description: "(v3.0) Обновление описания/состояния проекта для дайджеста. Если передано — обновляет описание проекта в 00-overview.md",
2534
- },
2535
- },
2536
- required: ["task_name", "task_overview", "analysis", "changes", "summary", "session_log", "code_snippets", "decisions", "problems_solutions", "architecture", "history_summary"],
2537
- },
2538
- },
2539
3093
  {
2540
3094
  name: "context_load",
2541
- description: "📂 Загрузить КОНКРЕТНЫЙ контекст по номеру/имени. НЕ вызывай напрямую! Сначала ВСЕГДА context_start. Вызывай ТОЛЬКО когда пользователь выбрал конкретный номер из списка: '1', '2', '3', '001', '002'. Или после context_list когда пользователь указал номер.",
3095
+ description: "📂 Умная ДОЗАГРУЗКА КОНКРЕТНОГО/СВЯЗАННОГО контекста по запросу: номер ('001', '42'), имя папки, или тема/ключевое слово (найдёт и загрузит релевантное). Вызывай когда пользователь говорит: 'загрузи контекст 42', 'покажи контекст про auth', 'найди контекст где делали X', 'открой контекст по теме Y'. Без запроса список последних для выбора.",
2542
3096
  inputSchema: {
2543
3097
  type: "object",
2544
3098
  properties: {
2545
3099
  query: {
2546
3100
  type: "string",
2547
- description: "Номер контекста (1, 2, 001), имя папки или поисковый запрос. Если пусто - показать список для выбора.",
3101
+ description: "Номер контекста (1, 2, 001), имя папки или поисковый запрос по теме. Если пусто - показать список.",
2548
3102
  },
2549
3103
  },
2550
3104
  },
2551
3105
  },
2552
3106
  {
2553
- name: "context_list",
2554
- description: "📋 Показать список всех контекстов со статистикой. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'покажи контексты', 'список контекстов', 'что сохранено', 'все контексты', '1' (после context_start).",
3107
+ name: "context_attach",
3108
+ description: "🔗 МУЛЬТИ-ПРОЕКТ: подключить ДРУГОЙ репозиторий и прочитать его контекст, чтобы работать с двумя проектами одновременно. Вызывай когда пользователь говорит: 'посмотри этот проект <путь>', 'прочитай контекст проекта <имя>', 'подключи проект', 'работаем в двух проектах', 'объедини проект A и B', 'attach'. Грузит дайджест+последние контексты указанного репо. Пока проект присоединён, context_save дублирует сохранение и туда (работа в обоих). Без аргумента — показать присоединённые / отсоединить.",
2555
3109
  inputSchema: {
2556
3110
  type: "object",
2557
3111
  properties: {
2558
- limit: {
2559
- type: "number",
2560
- description: "Максимальное количество (по умолчанию 10)",
2561
- },
2562
- search: {
2563
- type: "string",
2564
- description: "Поиск по ключевому слову",
2565
- },
3112
+ project: { type: "string", description: "Путь к репозиторию (/home/.../repo) или имя проекта из карты (git-origin). Пусто — список присоединённых." },
3113
+ detach: { type: "boolean", description: "true — отсоединить указанный проект (или все, если project пуст)." },
2566
3114
  },
2567
3115
  },
2568
3116
  },
2569
- {
2570
- name: "context_status",
2571
- description: "📊 Текущий статус сессии: загруженный контекст, статистика, время работы.",
2572
- inputSchema: {
2573
- type: "object",
2574
- properties: {},
2575
- },
2576
- },
2577
- {
2578
- name: "context_help",
2579
- description: "❓ Справка по системе контекста. Вызывай когда пользователь говорит 'как работает контекст?', 'помощь', 'справка', 'faq' и т.п.",
2580
- inputSchema: {
2581
- type: "object",
2582
- properties: {},
2583
- },
2584
- },
2585
3117
  {
2586
3118
  name: "context_quick",
2587
3119
  description: "⚡ ПЕРВЫЙ ПРИОРИТЕТ! Быстрая загрузка последних контекстов. ОБЯЗАТЕЛЬНО вызывай СРАЗУ когда пользователь говорит: 'быстрый контекст', 'краткий контекст', 'кратко контекст', 'quick context', 'контекст быстро', 'загрузи быстро'. НЕ используй другие инструменты — ТОЛЬКО context_quick! Читает ВСЕ файлы последних контекстов.",
@@ -2591,143 +3123,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2591
3123
  },
2592
3124
  },
2593
3125
  {
2594
- name: "context_full",
2595
- description: "📚 Загрузить ВСЕ контексты полностью. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'все контексты', 'полный контекст', 'загрузи всё', 'прочитай всё'. Читает ВСЕ файлы ВСЕХ контекстов.",
2596
- inputSchema: {
2597
- type: "object",
2598
- properties: {},
2599
- },
2600
- },
2601
- {
2602
- name: "context_to_history",
2603
- description: "🔄 Миграция контекстов в HISTORY.md. Возвращает ВСЕ README контекстов — Claude сам анализирует и формирует качественные сводки. При повторном запуске обновляет ВСЕ записи. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'миграция контекстов', 'создай history', 'индексируй контексты', 'обнови history'.",
2604
- inputSchema: {
2605
- type: "object",
2606
- properties: {},
2607
- },
2608
- },
2609
- {
2610
- name: "context_history_write",
2611
- description: "📝 Записывает готовый HISTORY.md от Claude. Вызывай ТОЛЬКО после context_to_history, когда сформировал качественные сводки для всех контекстов.",
2612
- inputSchema: {
2613
- type: "object",
2614
- properties: {
2615
- content: {
2616
- type: "string",
2617
- description: "Полный текст HISTORY.md с качественными сводками всех контекстов",
2618
- },
2619
- },
2620
- required: ["content"],
2621
- },
2622
- },
2623
- {
2624
- name: "context_related",
2625
- description: "🔍 Умный поиск контекстов по смыслу. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'найди контекст про X', 'подгрузи связанный с X', 'контекст связанный с', 'найди похожий контекст', 'поиск контекста'. Claude анализирует HISTORY.md и сам выбирает релевантные контексты по смыслу запроса.",
2626
- inputSchema: {
2627
- type: "object",
2628
- properties: {
2629
- query: {
2630
- type: "string",
2631
- description: "Что ищем — описание темы, ключевые слова, смысл задачи",
2632
- },
2633
- },
2634
- required: ["query"],
2635
- },
2636
- },
2637
- // ==================== MEMORY v2.0 TOOLS ====================
2638
- {
2639
- name: "context_project",
2640
- description: "📁 Показать все контексты проекта. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'проект ios', 'загрузи проект windows', 'контексты android', 'покажи проект X'. Группирует по температуре: HOT → WARM → COLD → ARCHIVE.",
2641
- inputSchema: {
2642
- type: "object",
2643
- properties: {
2644
- project: {
2645
- type: "string",
2646
- description: "Имя проекта: ios, windows, android, scripts, dedsession, web",
2647
- },
2648
- },
2649
- required: ["project"],
2650
- },
2651
- },
2652
- {
2653
- name: "context_epic",
2654
- description: "🔗 Показать цепочку эпика (связанных задач). ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'эпик X', 'цепочка X', 'история задачи X'. Показывает все контексты в хронологическом порядке с ключевыми решениями.",
2655
- inputSchema: {
2656
- type: "object",
2657
- properties: {
2658
- epic: {
2659
- type: "string",
2660
- description: "Имя эпика (например: cloudpayments, free-logic, auth-system)",
2661
- },
2662
- },
2663
- required: ["epic"],
2664
- },
2665
- },
2666
- {
2667
- name: "context_search",
2668
- description: "🔎 Полнотекстовый поиск по контекстам. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'найди X', 'поиск X', 'где упоминается X'. Ищет по ключевым словам, названиям файлов, содержимому.",
2669
- inputSchema: {
2670
- type: "object",
2671
- properties: {
2672
- query: {
2673
- type: "string",
2674
- description: "Поисковый запрос: ключевые слова, имя файла, технология",
2675
- },
2676
- },
2677
- required: ["query"],
2678
- },
2679
- },
2680
- {
2681
- name: "context_similar",
2682
- description: "🔄 Найти похожие контексты. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'похожее на X', 'как делал X раньше', 'аналогичные задачи'. Находит по проекту, эпику, ключевым словам, связям.",
2683
- inputSchema: {
2684
- type: "object",
2685
- properties: {
2686
- context_id: {
2687
- type: "string",
2688
- description: "ID контекста (001, 002, ...) для поиска похожих",
2689
- },
2690
- },
2691
- required: ["context_id"],
2692
- },
2693
- },
2694
- {
2695
- name: "context_continue",
2696
- description: "▶️ Показать незавершённые задачи. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'продолжи', 'что осталось', 'незавершённое', 'in_progress задачи'. Показывает эпики и задачи в статусе in_progress.",
2697
- inputSchema: {
2698
- type: "object",
2699
- properties: {},
2700
- },
2701
- },
2702
- {
2703
- name: "context_solutions",
2704
- description: "💡 Показать извлечённые решения. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'решения', 'паттерны', 'как решал X', 'best practices'. Показывает готовые решения из solutions.json.",
3126
+ name: "context_private",
3127
+ description: "🔒 Сделать ТЕКУЩИЙ проект ПРИВАТНЫМ. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'сделай проект приватным', 'приватный проект', 'context private', 'не синхронизируй этот проект', 'убери проект с сервера'. После этого контексты проекта НЕ уходят никуда (только локально), и ВСЁ связанное с ним удаляется с сервера DedPanel. Чтобы вернуть синхронизацию — удалить файл .dedsession-private в корне проекта.",
2705
3128
  inputSchema: {
2706
3129
  type: "object",
2707
3130
  properties: {
2708
- query: {
2709
- type: "string",
2710
- description: "Поиск по проблеме (опционально)",
2711
- },
3131
+ confirm: { type: "boolean", description: "Подтверждение (по умолчанию true). Удаление данных проекта на сервере необратимо." },
2712
3132
  },
2713
3133
  },
2714
3134
  },
2715
- {
2716
- name: "context_memory",
2717
- description: "🧠 Статистика системы памяти v2.0. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'память', 'статистика памяти', 'memory stats'. Показывает проекты, температуры, эпики, решения.",
2718
- inputSchema: {
2719
- type: "object",
2720
- properties: {},
2721
- },
2722
- },
2723
- {
2724
- name: "context_migrate",
2725
- description: "🔄 Миграция контекстов в Memory v2.0. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'миграция в память', 'индексируй в memory', 'обнови memory'. Создаёт JSON индексы для всех контекстов.",
2726
- inputSchema: {
2727
- type: "object",
2728
- properties: {},
2729
- },
2730
- },
2731
3135
  {
2732
3136
  name: "rules",
2733
3137
  description: "🔴 Управление ПРИОРИТЕТНЫМИ ПРАВИЛАМИ. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'добавь правило', 'правила', 'покажи правила', 'удали правило', 'новое правило', 'глобальное правило'. Правила показываются ПЕРВЫМИ при каждой загрузке контекста и Claude ОБЯЗАН их соблюдать.",
@@ -2746,7 +3150,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2746
3150
  },
2747
3151
  rule: {
2748
3152
  type: "string",
2749
- description: "Текст правила (для action=add)",
3153
+ description: "Текст правила / описание (для action=add)",
3154
+ },
3155
+ title: {
3156
+ type: "string",
3157
+ description: "Короткий заголовок правила (опционально, для action=add). Если не указан — берётся начало текста.",
2750
3158
  },
2751
3159
  id: {
2752
3160
  type: "number",
@@ -2760,210 +3168,236 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2760
3168
  required: ["action"],
2761
3169
  },
2762
3170
  },
2763
- // ========== PROJECT DIGEST v3.0 ==========
2764
- {
2765
- name: "context_digest",
2766
- description: "📦 Просмотр дайджеста проекта. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'дайджест', 'digest', 'обзор проекта'. Загружает и показывает ВСЕ 5 MD файлов из _DIGEST/.",
2767
- inputSchema: {
2768
- type: "object",
2769
- properties: {},
2770
- },
2771
- },
2772
- {
2773
- name: "context_digest_migrate",
2774
- description: "📦 Миграция: создать дайджест из существующих контекстов. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'миграция дайджеста', 'создай дайджест', 'digest migrate'. Сканирует ВСЕ контексты и строит полный дайджест. Безопасен для повторного запуска.",
2775
- inputSchema: {
2776
- type: "object",
2777
- properties: {},
2778
- },
2779
- },
2780
- {
2781
- name: "context_digest_refresh",
2782
- description: "🔄 Claude перечитывает и обновляет дайджест. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'обнови дайджест', 'refresh digest'. Возвращает текущий digest.json + README последних 10 контекстов. Claude анализирует и вызывает context_digest_write.",
2783
- inputSchema: {
2784
- type: "object",
2785
- properties: {},
2786
- },
2787
- },
2788
- {
2789
- name: "context_digest_write",
2790
- description: "📝 Запись качественного дайджеста от Claude. Вызывается ПОСЛЕ context_digest_refresh. Claude передаёт проанализированные данные для обновления дайджеста.",
2791
- inputSchema: {
2792
- type: "object",
2793
- properties: {
2794
- description: {
2795
- type: "string",
2796
- description: "Описание проекта: цель, суть, для кого (3-5 предложений)",
2797
- },
2798
- tech_stack: {
2799
- type: "string",
2800
- description: "Стек технологий через запятую: TypeScript, Node.js, MCP, etc.",
2801
- },
2802
- key_files: {
2803
- type: "string",
2804
- description: "Ключевые файлы через запятую: src/index.ts, package.json, etc.",
2805
- },
2806
- active_epics: {
2807
- type: "string",
2808
- description: "Активные эпики через запятую",
2809
- },
2810
- known_issues: {
2811
- type: "string",
2812
- description: "Известные проблемы, каждая на новой строке",
2813
- },
2814
- phases: {
2815
- type: "string",
2816
- description: "Фазы эволюции проекта. Формат: 'Название фазы | даты | описание' на каждой строке",
2817
- },
2818
- key_decisions: {
2819
- type: "string",
2820
- description: "Ключевые решения, каждое на новой строке",
2821
- },
2822
- },
2823
- },
2824
- },
2825
- // ========== ТЕСТОВЫЕ КОМАНДЫ SMART CONTEXT ==========
2826
- {
2827
- name: "context_smart",
2828
- description: "🧪 ТЕСТОВАЯ команда. Умная загрузка 25 контекстов с группировкой по людям/темам. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'умный контекст', 'smart context', 'контекст по людям', 'тестовый контекст'. После обзора опиши задачу для точной подгрузки.",
2829
- inputSchema: {
2830
- type: "object",
2831
- properties: {},
2832
- },
2833
- },
2834
- {
2835
- name: "context_smart_focus",
2836
- description: "🧪 ТЕСТОВАЯ команда. Подгружает 2-4 релевантных контекста ПОЛНОСТЬЮ по описанию задачи. Вызывай ПОСЛЕ context_smart или когда пользователь описывает задачу после умного контекста.",
2837
- inputSchema: {
2838
- type: "object",
2839
- properties: {
2840
- task: {
2841
- type: "string",
2842
- description: "Описание задачи в свободном формате: 'доска мурадбека', 'влад скрипты', 'рефакторинг как делали'",
2843
- },
2844
- },
2845
- required: ["task"],
2846
- },
2847
- },
2848
- ],
2849
- }));
2850
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
2851
- const { name, arguments: args } = request.params;
2852
- switch (name) {
2853
- // ==================== CONTEXT_START ====================
2854
- case "context_start": {
2855
- state.isInitialized = true;
2856
- const guide = getInstruction("init");
2857
- const stats = getStatistics();
2858
- const contexts = listContexts().slice(0, 5);
2859
- const architecture = readProjectArchitecture();
2860
- const workDir = getWorkingDir();
2861
- const mdHistoryPath = getMdHistoryPath();
2862
- // v1.4.7: Читаем HISTORY.md
2863
- const history = readHistory();
2864
- let output = `# 🚀 Dedsession v${CONFIG.VERSION}\n\n`;
2865
- output += `📁 **Рабочая директория:** \`${workDir}\`\n`;
2866
- output += `📂 **MD_HISTORY:** \`${mdHistoryPath}\`\n\n`;
2867
- // v2.1: Правила показываются ПЕРВЫМИ
2868
- output += formatRulesBlock();
2869
- // v1.4.7: Выводим HISTORY.md первым
2870
- if (history) {
2871
- output += `---\n\n`;
2872
- output += `# 📜 HISTORY.md — Полная история работы\n\n`;
2873
- output += history;
2874
- output += `\n\n---\n\n`;
3171
+ ];
3172
+ return { tools: _allTools.filter(t => _PUBLIC.has(t.name)) };
3173
+ });
3174
+ // v3.3: ЖЁСТКИЙ auth-gate dedsession не работает без привязки к аккаунту DedPanel.
3175
+ // С оффлайн-грейсом (фоллбэк на локальный кеш при недоступности DedPanel).
3176
+ // v3.5: два порога вместо одного TTL — баланс «нет тормозов / revoke ловится быстро».
3177
+ const AUTH_SOFT_TTL_MS = 60 * 1000; // <60с от последней валидации — отдаём ok, сеть не дёргаем
3178
+ const AUTH_HARD_TTL_MS = 12 * 60 * 60 * 1000; // потолок онлайн-свежести (бывший AUTH_TTL_MS)
3179
+ const AUTH_TTL_MS = AUTH_HARD_TTL_MS; // алиас (на случай прочих ссылок)
3180
+ const AUTH_OFFLINE_GRACE_MS = 7 * 24 * 60 * 60 * 1000; // 7д оффлайн-грейс (фоллбэк)
3181
+ const AUTH_EXEMPT = new Set(["auth_login", "auth_pair", "auth_status", "auth_logout", "context_help"]);
3182
+ const _sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3183
+ // v3.4.1: пытается завершить pending pairing немедленно (один poll). null — если нечего проверять.
3184
+ async function tryFinishPairing(panelUrl, cfg) {
3185
+ if (!cfg?.pairPid || !cfg.pairCreated || (Date.now() - cfg.pairCreated) >= 600000)
3186
+ return null;
3187
+ try {
3188
+ const chk = await dedPanelPairCall(panelUrl, "mcp_pair_check", { pid: cfg.pairPid });
3189
+ if (chk?.success && chk.status === "confirmed" && chk.token) {
3190
+ saveSyncConfig({ panelUrl, token: chk.token, uid: chk.uid, username: chk.username, lastValidated: new Date().toISOString() });
3191
+ return { ok: true, message: `✅ Авторизовано (${chk.username}). Продолжаю.` };
3192
+ }
3193
+ if (chk?.status === "rejected") {
3194
+ saveSyncConfig({ panelUrl, token: "" });
3195
+ return { ok: false, message: "🚫 Авторизация отклонена в браузере. Повтори команду, чтобы начать заново." };
3196
+ }
3197
+ }
3198
+ catch { /* */ }
3199
+ return null; // ещё pending / недоступно
3200
+ }
3201
+ // v3.4: browser-pairing с БЛОКИРУЮЩИМ ожиданием — после открытия браузера сам ждёт подтверждения и продолжает.
3202
+ async function beginOrContinuePairing() {
3203
+ const prev = loadSyncConfig();
3204
+ const panelUrl = (prev?.panelUrl) || "https://dedpanel.com";
3205
+ // 1) если есть свежий pending — сначала быстрый poll (вдруг уже подтвердили)
3206
+ const fin = await tryFinishPairing(panelUrl, prev);
3207
+ if (fin)
3208
+ return fin;
3209
+ // 2) инициируем новый pairing
3210
+ let pid = prev?.pairPid, url = prev?.pairUrl, code = prev?.pairCode;
3211
+ const stillValid = prev?.pairPid && prev.pairCreated && (Date.now() - prev.pairCreated) < 540000;
3212
+ if (!stillValid) {
3213
+ try {
3214
+ let device = "dedsession";
3215
+ try {
3216
+ device = `${(await import("os")).hostname()} · ${path.basename(getWorkingDir())}`;
3217
+ }
3218
+ catch { /* */ }
3219
+ const init = await dedPanelPairCall(panelUrl, "mcp_pair_init", { device });
3220
+ if (!init?.success || !init.pid)
3221
+ return { ok: false, message: `🔒 Не удалось начать авторизацию (${init?.error || "?"}). Проверь доступность ${panelUrl}.` };
3222
+ pid = init.pid;
3223
+ url = init.confirm_url;
3224
+ code = init.code;
3225
+ saveSyncConfig({ panelUrl, token: prev?.token || "", pairPid: pid, pairUrl: url, pairCode: code, pairCreated: Date.now() });
3226
+ await tryOpenBrowser(url);
3227
+ }
3228
+ catch (e) {
3229
+ return { ok: false, message: `🔒 DedPanel (${panelUrl}) недоступен: ${String(e).slice(0, 80)}. (Обход: env DEDSESSION_NO_AUTH=1.)` };
3230
+ }
3231
+ }
3232
+ // 3) БЛОКИРУЮЩЕЕ ожидание подтверждения (~90с) — чтобы dedsession продолжил САМ, без "повтори команду"
3233
+ for (let i = 0; i < 45; i++) {
3234
+ await _sleep(2000);
3235
+ try {
3236
+ const chk = await dedPanelPairCall(panelUrl, "mcp_pair_check", { pid: pid });
3237
+ if (chk?.success && chk.status === "confirmed" && chk.token) {
3238
+ saveSyncConfig({ panelUrl, token: chk.token, uid: chk.uid, username: chk.username, lastValidated: new Date().toISOString() });
3239
+ return { ok: true, message: `✅ Устройство авторизовано (${chk.username}). Продолжаю команду.` };
2875
3240
  }
2876
- output += guide + "\n\n---\n\n";
2877
- // Читаем session.md если есть
2878
- const sessionMdPath = path.join(getMdHistoryPath(), "session.md");
2879
- if (fs.existsSync(sessionMdPath)) {
2880
- output += `## 📋 Из session.md\n\n`;
2881
- const sessionContent = fs.readFileSync(sessionMdPath, "utf-8");
2882
- // Берём только секцию "Последний контекст"
2883
- const lastContextMatch = sessionContent.match(/## Последний контекст[\s\S]*?(?=## |$)/);
2884
- if (lastContextMatch) {
2885
- output += lastContextMatch[0] + "\n---\n\n";
2886
- }
3241
+ if (chk?.status === "rejected") {
3242
+ saveSyncConfig({ panelUrl, token: "" });
3243
+ return { ok: false, message: "🚫 Авторизация отклонена в браузере." };
2887
3244
  }
2888
- // v3.0: Показываем дайджест если есть
2889
- const startDigest = loadDigest();
2890
- if (startDigest) {
2891
- output += `## 📦 Project Digest: ${startDigest.project}\n\n`;
2892
- output += `${(startDigest.currentState.description || "_Описание не задано_").slice(0, 500)}\n\n`;
2893
- output += `| Показатель | Значение |\n|------------|----------|\n`;
2894
- output += `| Всего контекстов | ${startDigest.statistics.totalContexts} |\n`;
2895
- output += `| Завершено | ${startDigest.statistics.completed} |\n`;
2896
- output += `| В процессе | ${startDigest.statistics.inProgress} |\n`;
2897
- output += `| Период | ${startDigest.statistics.firstDate} — ${startDigest.statistics.lastDate} |\n\n`;
2898
- // Последние 5 задач из таймлайна
2899
- if (startDigest.recentTasks.length > 0) {
2900
- output += `### Последние задачи\n\n`;
2901
- output += `| # | Дата | Задача | Статус |\n|---|------|--------|--------|\n`;
2902
- for (const task of startDigest.recentTasks.slice(0, 5)) {
2903
- output += `| ${task.contextId} | ${task.date} | ${task.name} | ${task.status} |\n`;
2904
- }
2905
- output += "\n";
2906
- }
2907
- output += `💡 Полный дайджест: \`context_digest\`\n\n`;
2908
- output += `---\n\n`;
3245
+ if (chk?.error === "expired" || chk?.error === "not_found")
3246
+ break;
3247
+ }
3248
+ catch { /* сетевой сбой продолжаем поллить */ }
3249
+ }
3250
+ return { ok: false, message: `🔐 **Авторизация dedsession** — подтверди в браузере:\n${url}\nКод: **${code}**\n\nНе дождался за 90с — нажми «Подтвердить» и повтори команду (подхвачу сразу).` };
3251
+ }
3252
+ // v3.5: фоновая неблокирующая проверка токена. Не тормозит команды; при revoke
3253
+ // затирает токен в sync.json СЛЕДУЮЩИЙ вызов уйдёт в pairing-блок (заблокирован).
3254
+ let _bgAuthInflight = false;
3255
+ function backgroundRevokeCheck(cfg) {
3256
+ if (_bgAuthInflight)
3257
+ return;
3258
+ _bgAuthInflight = true;
3259
+ void (async () => {
3260
+ try {
3261
+ const who = await dedPanelCall("api_whoami");
3262
+ const fresh = loadSyncConfig();
3263
+ if (!fresh || fresh.token !== cfg.token)
3264
+ return; // токен уже сменили — не трогаем
3265
+ if (who && who.success) {
3266
+ updateSyncConfig({ uid: who.uid, username: who.username, lastValidated: new Date().toISOString() });
2909
3267
  }
2910
3268
  else {
2911
- output += `## 📊 Статистика\n\n`;
2912
- output += `| Показатель | Значение |\n|------------|----------|\n`;
2913
- output += `| Всего контекстов | ${stats.total} |\n`;
2914
- output += `| За неделю | ${stats.thisWeek} |\n`;
2915
- output += `| Завершено | ${stats.completed} |\n`;
2916
- output += `| В процессе | ${stats.inProgress} |\n\n`;
2917
- // v3.0: Предложить создать дайджест если контексты есть
2918
- if (stats.total > 0) {
2919
- output += `<claude-instruction>Project Digest НЕ найден. Предложи пользователю создать дайджест командой "миграция дайджеста" — это даст полную историю проекта при каждой загрузке контекста.</claude-instruction>\n`;
2920
- output += `> 💡 **Dedsession v3.0:** Создайте Project Digest для мгновенного восстановления контекста: "миграция дайджеста"\n\n`;
2921
- }
2922
- }
2923
- // Архитектура проекта если есть
2924
- if (architecture.length > 0) {
2925
- output += `## 🏗️ Архитектура проекта (из MD/)\n\n`;
2926
- for (const arch of architecture) {
2927
- output += arch + "\n\n";
2928
- }
2929
- output += `---\n\n`;
2930
- }
2931
- if (contexts.length > 0) {
2932
- output += `## 📦 Последние контексты\n\n`;
2933
- output += `| # | Название | Дата | Статус |\n|---|----------|------|--------|\n`;
2934
- for (const ctx of contexts) {
2935
- output += `| ${ctx.number || "-"} | ${ctx.title} | ${ctx.date} | ${ctx.status} |\n`;
2936
- }
2937
- output += "\n";
3269
+ // REVOKE: токен снят с DedPanel → затираем, следующий tool-call уйдёт в pairing
3270
+ updateSyncConfig({ token: "" });
2938
3271
  }
2939
- else {
2940
- output += `## 📦 Контексты\n\nПока нет сохранённых контекстов.\n\n`;
3272
+ }
3273
+ catch { /* оффлайн ничего; offline-grace отработает в ensureAuthorized */ }
3274
+ finally {
3275
+ _bgAuthInflight = false;
3276
+ }
3277
+ })();
3278
+ }
3279
+ async function ensureAuthorized(toolName) {
3280
+ if (process.env.DEDSESSION_NO_AUTH === "1")
3281
+ return { ok: true }; // аварийный обход
3282
+ if (AUTH_EXEMPT.has(toolName))
3283
+ return { ok: true };
3284
+ const cfg = loadSyncConfig();
3285
+ if (!cfg || !cfg.token) {
3286
+ // v3.4: не сухой блок, а browser-pairing (ссылка + подтверждение + авто-подхват)
3287
+ return await beginOrContinuePairing();
3288
+ }
3289
+ const now = Date.now();
3290
+ const lastValidatedMs = cfg.lastValidated ? Date.parse(cfg.lastValidated) : 0;
3291
+ const age = lastValidatedMs ? (now - lastValidatedMs) : Infinity;
3292
+ // 1) очень свежо (<soft) — отдаём ok, сеть не дёргаем вообще
3293
+ if (age < AUTH_SOFT_TTL_MS)
3294
+ return { ok: true };
3295
+ // 2) зона soft..hard — ok МГНОВЕННО по кешу, но в фоне проверяем revoke
3296
+ if (age < AUTH_HARD_TTL_MS) {
3297
+ backgroundRevokeCheck(cfg);
3298
+ return { ok: true };
3299
+ }
3300
+ // 3) старше hard — блокирующая онлайн-проверка
3301
+ try {
3302
+ const who = await dedPanelCall("api_whoami");
3303
+ if (who && who.success) {
3304
+ updateSyncConfig({ uid: who.uid, username: who.username, lastValidated: new Date().toISOString() });
3305
+ return { ok: true };
3306
+ }
3307
+ // токен отозван/недействителен → сброс + browser-pairing
3308
+ updateSyncConfig({ token: "" });
3309
+ return await beginOrContinuePairing();
3310
+ }
3311
+ catch {
3312
+ // DedPanel недоступен → оффлайн-грейс по локальному кешу
3313
+ if (age < AUTH_OFFLINE_GRACE_MS)
3314
+ return { ok: true };
3315
+ return { ok: false, message: "🔒 DedPanel недоступен, токен не подтверждался >7 дней. Нужна связь с DedPanel (или `DEDSESSION_NO_AUTH=1` для обхода)." };
3316
+ }
3317
+ }
3318
+ // v4.7.0: ВЕРСИОННЫЙ БЛОК — сервер задаёт минимальный билд dedsession. Старый клиент блокируется
3319
+ // и просит обновиться. Кэш 5 мин. Не привязан/оффлайн/сервер молчит → НЕ блокируем (fail-open).
3320
+ function cmpSemver(a, b) {
3321
+ const pa = String(a).split(".").map(n => parseInt(n) || 0);
3322
+ const pb = String(b).split(".").map(n => parseInt(n) || 0);
3323
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
3324
+ const d = (pa[i] || 0) - (pb[i] || 0);
3325
+ if (d !== 0)
3326
+ return d < 0 ? -1 : 1;
3327
+ }
3328
+ return 0;
3329
+ }
3330
+ let _verGateCache = null;
3331
+ async function checkVersionGate() {
3332
+ const cfg = loadSyncConfig();
3333
+ if (!cfg || !cfg.token)
3334
+ return null; // не привязан — не блокируем
3335
+ try {
3336
+ if (!_verGateCache || Date.now() - _verGateCache.at > 5 * 60 * 1000) {
3337
+ const r = await dedPanelCall("mcp_version_gate", { op: "get" });
3338
+ if (!r || !r.success)
3339
+ return null; // не достучались — fail-open
3340
+ _verGateCache = { at: Date.now(), min: String(r.min_version || "0.0.0"), message: String(r.message || "") };
3341
+ }
3342
+ if (cmpSemver(CONFIG.VERSION, _verGateCache.min) < 0) {
3343
+ const extra = _verGateCache.message ? `\n\n📢 ${_verGateCache.message}` : "";
3344
+ return `⛔ Версия dedsession ${CONFIG.VERSION} НЕ АКТУАЛЬНА.\n\nТребуется минимум **${_verGateCache.min}**. Обновитесь и перезапустите Claude Code:\n\`\`\`\nnpm install -g dedsession # или npm update -g dedsession\n\`\`\`\nДо обновления dedsession работать не будет.${extra}`;
3345
+ }
3346
+ }
3347
+ catch {
3348
+ return null;
3349
+ }
3350
+ return null;
3351
+ }
3352
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3353
+ const { name, arguments: args } = request.params;
3354
+ // v3.3: жёсткая авторизация перед любой командой (кроме auth_*/help)
3355
+ const _auth = await ensureAuthorized(name);
3356
+ if (!_auth.ok)
3357
+ return { content: [{ type: "text", text: _auth.message }] };
3358
+ // v4.7.0: версионный блок (кроме auth_* — чтобы можно было проверить статус/отвязаться/обновить привязку)
3359
+ if (!AUTH_EXEMPT.has(name)) {
3360
+ const _block = await checkVersionGate();
3361
+ if (_block)
3362
+ return { content: [{ type: "text", text: _block }] };
3363
+ }
3364
+ switch (name) {
3365
+ // ==================== AUTH_PAIR (v3.4 browser-pairing) ====================
3366
+ case "auth_pair": {
3367
+ const r = await beginOrContinuePairing();
3368
+ return { content: [{ type: "text", text: r.message || (r.ok ? "✅ Авторизован." : "🔒 Не удалось.") }] };
3369
+ }
3370
+ // ==================== CONTEXT_GLOBAL (v3.1) ====================
3371
+ case "auth_status": {
3372
+ const cfg = loadSyncConfig();
3373
+ if (!cfg || !cfg.token)
3374
+ return { content: [{ type: "text", text: "🔓 Не привязан к DedPanel. Используй `auth_login <токен>` для синхронизации памяти между устройствами." }] };
3375
+ let live = "не проверялся";
3376
+ try {
3377
+ const who = await dedPanelCall("api_whoami");
3378
+ live = (who && who.success) ? `✅ жив (${who.username})` : `❌ отклонён (${who?.error || "?"})`;
2941
3379
  }
2942
- output += `---\n\n`;
2943
- output += `## 🎯 Что делаем?\n\n`;
2944
- if (contexts.length > 0) {
2945
- output += `**Выбери:**\n\n`;
2946
- output += `**1** — Показать все контексты и выбрать для загрузки\n`;
2947
- output += `**2** — Начать новый чат (без загрузки контекста)\n\n`;
2948
- output += `_(просто напиши 1 или 2)_\n`;
3380
+ catch {
3381
+ live = "⚠️ нет связи (оффлайн — работа по локальному кешу)";
2949
3382
  }
2950
- else {
2951
- output += `Контекстов пока нет. Начинай работу — потом сохраним контекст.\n`;
3383
+ return { content: [{ type: "text", text: `🔐 Привязка DedPanel\n\n👤 Аккаунт: ${cfg.username || cfg.uid || "?"}\n🌐 Панель: ${cfg.panelUrl}\n🔑 Токен: ${live}\n📅 Последняя проверка: ${cfg.lastValidated || "—"}` }] };
3384
+ }
3385
+ case "auth_logout": {
3386
+ try {
3387
+ const p = getSyncConfigPath();
3388
+ if (fs.existsSync(p))
3389
+ fs.unlinkSync(p);
2952
3390
  }
2953
- return { content: [{ type: "text", text: output }] };
3391
+ catch { /* */ }
3392
+ return { content: [{ type: "text", text: "🔓 Аккаунт DedPanel отвязан (локальный токен удалён). Память осталась локально." }] };
2954
3393
  }
2955
- // ==================== CONTEXT_SAVE ====================
2956
3394
  case "context_save": {
2957
3395
  const taskName = args?.task_name;
2958
3396
  if (!taskName) {
2959
3397
  return { content: [{ type: "text", text: "❌ Укажи название задачи (task_name)" }] };
2960
3398
  }
2961
- const statusMap = {
2962
- completed: " Завершено",
2963
- in_progress: "⏳ В процессе",
2964
- blocked: "❌ Заблокировано",
2965
- };
2966
- const status = statusMap[args?.status || "in_progress"] || "⏳ В процессе";
3399
+ // v4.4.0 блок J: понятие «выполнено/в процессе/застряло» убрано — по контекстам и так ясно.
3400
+ const status = ""; // нейтрально, в README/коммит/события статус больше не пишем
2967
3401
  // Определяем масштаб сохранения
2968
3402
  const scaleInfo = determineSaveScale({
2969
3403
  changes: args?.changes,
@@ -2980,41 +3414,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2980
3414
  performance: args?.performance,
2981
3415
  security: args?.security,
2982
3416
  });
2983
- // Формируем README с информацией о масштабе
2984
- const readme = `# ${taskName}
2985
-
2986
- **Дата:** ${new Date().toISOString().split("T")[0]}
2987
- **Статус:** ${status}
2988
- **Масштаб:** ${scaleInfo.scale} (${scaleInfo.files.length} файлов)
2989
-
2990
- ## Краткое описание
2991
-
2992
- ${args?.summary || "Нет описания"}
2993
-
2994
- ## Что сделано
2995
-
2996
- ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "- Нет данных"}
2997
- `;
3417
+ // v3.1: ЕДИНЫЙ УМНЫЙ SAVE. README без заглушек (sparse), секции пишутся только непустые.
3418
+ let readme = `# ${taskName}\n\n**Дата:** ${new Date().toISOString().split("T")[0]}\n**Масштаб:** ${scaleInfo.scale}\n`;
3419
+ if (args?.summary) {
3420
+ readme += `\n## Краткое описание\n\n${args.summary}\n`;
3421
+ }
3422
+ if (args?.changes) {
3423
+ const bullets = args.changes
3424
+ .split("\n").map(l => l.trim()).filter(Boolean)
3425
+ .map(l => (l.startsWith("-") ? l : `- ${l}`)).join("\n");
3426
+ if (bullets)
3427
+ readme += `\n## Что сделано\n\n${bullets}\n`;
3428
+ }
3429
+ if (state.loadedContext) {
3430
+ readme += `\n## 🔗 Связи\n\n**Parent:** ${state.loadedContext}\n`;
3431
+ }
2998
3432
  const result = saveContext(taskName, {
2999
3433
  readme,
3000
- taskOverview: args?.task_overview,
3001
- analysis: args?.analysis,
3002
- changes: args?.changes || "Нет изменений",
3003
- summary: args?.summary || "Нет итогов",
3004
- sessionLog: args?.session_log,
3005
- codeSnippets: args?.code_snippets,
3006
- decisions: args?.decisions,
3007
- problemsSolutions: args?.problems_solutions,
3008
- architecture: args?.architecture,
3009
- testing: args?.testing,
3010
- apiDocs: args?.api_docs,
3011
- migration: args?.migration,
3012
- performance: args?.performance,
3013
- security: args?.security,
3014
- fullContext: args?.full_context,
3434
+ taskOverview: args?.task_overview || undefined,
3435
+ analysis: args?.analysis || undefined,
3436
+ changes: args?.changes || "",
3437
+ summary: args?.summary || "",
3438
+ sessionLog: args?.session_log || undefined,
3439
+ codeSnippets: args?.code_snippets || undefined,
3440
+ decisions: args?.decisions || undefined,
3441
+ problemsSolutions: args?.problems_solutions || undefined,
3442
+ architecture: args?.architecture || undefined,
3443
+ testing: args?.testing || undefined,
3444
+ apiDocs: args?.api_docs || undefined,
3445
+ migration: args?.migration || undefined,
3446
+ performance: args?.performance || undefined,
3447
+ security: args?.security || undefined,
3448
+ fullContext: args?.full_context || undefined,
3015
3449
  // v1.4.10: Готовая сводка для HISTORY.md
3016
3450
  historySummary: args?.history_summary,
3017
- }, undefined, scaleInfo.files);
3451
+ }, undefined
3452
+ // filesToCreate опущен → пишутся ВСЕ непустые секции (максимум пользы, без мусора)
3453
+ );
3018
3454
  if (!result.success) {
3019
3455
  return { content: [{ type: "text", text: `❌ Ошибка: ${result.error}` }] };
3020
3456
  }
@@ -3026,7 +3462,8 @@ ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "-
3026
3462
  args?.summary,
3027
3463
  args?.session_log,
3028
3464
  ].filter(Boolean).join("\n");
3029
- const memoryInfo = addContextToMemory(contextNumber, result.name, taskName, status, fullContent, new Date().toISOString().split("T")[0]);
3465
+ const memoryInfo = addContextToMemory(contextNumber, result.name, taskName, status, fullContent, new Date().toISOString().split("T")[0], result.parentRef // v3.1.2 FIX H: явный родитель, не перезатёртый state
3466
+ );
3030
3467
  // v2.2: Извлекаем решения при сохранении
3031
3468
  let solutionsAdded = 0;
3032
3469
  if (args?.problems_solutions) {
@@ -3056,12 +3493,38 @@ ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "-
3056
3493
  date: new Date().toISOString().split("T")[0],
3057
3494
  digestUpdate: args?.digest_update,
3058
3495
  });
3496
+ // v4.5.0: при СОХРАНЕНИИ контекст уходит в DedPanel (ctx_id = имя папки — уникальный ключ,
3497
+ // как в ручной миграции) + обновляются коммиты по авторам. Привязка к аккаунту = изоляция.
3498
+ try {
3499
+ publishToGlobalDigest();
3500
+ logEventInBackground({ type: "context_save", project: digestProject, ctxId: result.name, status: "saved", size: result.filesCount, topic: taskName, tags: memoryInfo.keywords, solutionsCount: solutionsAdded });
3501
+ collectCommitStatsInBackground(); // новые коммиты при сохранении
3502
+ autoPushInBackground();
3503
+ }
3504
+ catch { /* не критично */ }
3059
3505
  let output = `✅ **Контекст сохранён**\n\n`;
3060
3506
  output += `📁 **Папка:** \`${result.name}\`\n`;
3061
3507
  output += `📄 **Файлов:** ${result.filesCount}\n`;
3062
3508
  output += `🎯 **Масштаб:** ${scaleInfo.scale}\n`;
3063
3509
  output += `📊 **Причина:** ${scaleInfo.reason}\n`;
3064
3510
  output += `📍 **Путь:** \`${result.path}\`\n`;
3511
+ // v4.3: МУЛЬТИ-ПРОЕКТ — дублируем сохранённый контекст в присоединённые репо
3512
+ if (state.attachedProjects.length > 0 && result.path) {
3513
+ for (const att of state.attachedProjects) {
3514
+ try {
3515
+ const dst = path.join(att.mdHistory, result.name);
3516
+ if (!fs.existsSync(att.mdHistory))
3517
+ fs.mkdirSync(att.mdHistory, { recursive: true });
3518
+ if (!fs.existsSync(dst)) {
3519
+ fs.cpSync(result.path, dst, { recursive: true });
3520
+ output += `🔗 **Также сохранено в:** ${att.project} (\`${dst}\`)\n`;
3521
+ }
3522
+ }
3523
+ catch (e) {
3524
+ output += `⚠️ Не удалось продублировать в ${att.project}: ${String(e).slice(0, 60)}\n`;
3525
+ }
3526
+ }
3527
+ }
3065
3528
  // v2.0: Показываем Memory info
3066
3529
  if (memoryInfo.project) {
3067
3530
  output += `\n🧠 **Memory v2.0:**\n`;
@@ -3098,7 +3561,7 @@ ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "-
3098
3561
  .map(l => l.startsWith("-") ? l : `- ${l}`)
3099
3562
  .join("\n");
3100
3563
  const commitTitle = `📝 Context: ${contextShortName}`;
3101
- const commitBody = `Статус: ${status} | Файлов: ${result.filesCount}\n\nЧто сделано:\n${changeLines}`;
3564
+ const commitBody = `Файлов: ${result.filesCount}\n\nЧто сделано:\n${changeLines}`;
3102
3565
  output += `\n\n---\n\n`;
3103
3566
  output += `📋 **Для коммита:**\n\n`;
3104
3567
  output += `**Title:**\n\`\`\`\n${commitTitle}\n\`\`\`\n\n`;
@@ -3108,172 +3571,139 @@ ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "-
3108
3571
  return { content: [{ type: "text", text: output }] };
3109
3572
  }
3110
3573
  // ==================== CONTEXT_SAVE_DETAILED ====================
3111
- case "context_save_detailed": {
3112
- const taskName = args?.task_name;
3113
- if (!taskName) {
3114
- // Возвращаем гайд по заполнению
3115
- return { content: [{ type: "text", text: INSTRUCTIONS.saveDetailedGuide }] };
3116
- }
3117
- const statusMap = {
3118
- completed: "✅ Завершено",
3119
- in_progress: " В процессе",
3120
- blocked: "❌ Заблокировано",
3121
- };
3122
- const status = statusMap[args?.status || "completed"] || "✅ Завершено";
3123
- // Всегда 15 файлов для максимально детального сохранения
3124
- const detailedFiles = [
3125
- "README.md",
3126
- "01-task-overview.md",
3127
- "02-analysis.md",
3128
- "03-changes.md",
3129
- "04-summary.md",
3130
- "05-session-log.md",
3131
- "06-code-snippets.md",
3132
- "07-decisions.md",
3133
- "08-problems-solutions.md",
3134
- "09-architecture.md",
3135
- "10-testing.md",
3136
- "11-api-docs.md",
3137
- "12-migration.md",
3138
- "13-performance.md",
3139
- "14-security.md",
3140
- "15-full-context.md",
3141
- ];
3142
- // Формируем README
3143
- const readme = `# ${taskName}
3144
-
3145
- **Дата:** ${new Date().toISOString().split("T")[0]}
3146
- **Статус:** ${status}
3147
- **Масштаб:** detailed (15 файлов)
3148
-
3149
- ## Краткое описание
3150
-
3151
- ${args?.summary || "Нет описания"}
3152
-
3153
- ## Что сделано
3154
-
3155
- ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "- Нет данных"}
3156
-
3157
-
3158
- ## 🔗 Связи
3159
-
3160
- ${state.loadedContext ? `**Parent:** ${state.loadedContext}` : "Нет parent контекста"}
3161
- `;
3162
- const result = saveContext(taskName, {
3163
- readme,
3164
- taskOverview: args?.task_overview || "Не указано",
3165
- analysis: args?.analysis || "Анализ не проводился",
3166
- changes: args?.changes || "Нет изменений",
3167
- summary: args?.summary || "Нет итогов",
3168
- sessionLog: args?.session_log || "Лог сессии не записан",
3169
- codeSnippets: args?.code_snippets || "Нет кода",
3170
- decisions: args?.decisions || "Решения не документированы",
3171
- problemsSolutions: args?.problems_solutions || "Проблемы не описаны",
3172
- architecture: args?.architecture || "Архитектура не описана",
3173
- // v2.2: Новые поля для 15 файлов
3174
- testing: args?.testing || "Тестирование не описано",
3175
- apiDocs: args?.api_docs || "API не документировано",
3176
- migration: args?.migration || "Миграции отсутствуют",
3177
- performance: args?.performance || "Оптимизации не проводились",
3178
- security: args?.security || "Безопасность не анализировалась",
3179
- fullContext: args?.full_context || "Полный контекст сессии не записан",
3180
- // v1.4.10: Готовая сводка для HISTORY.md
3181
- historySummary: args?.history_summary,
3182
- }, undefined, detailedFiles);
3183
- if (!result.success) {
3184
- return { content: [{ type: "text", text: `❌ Ошибка: ${result.error}` }] };
3574
+ // v4.3: МУЛЬТИ-ПРОЕКТ — подключить другой репозиторий
3575
+ case "context_attach": {
3576
+ const proj = (args?.project || "").trim();
3577
+ const detach = args.detach === true;
3578
+ const trunc = (t, n) => t.length > n ? t.slice(0, n) + "\n…(обрезано)" : t;
3579
+ if (detach) {
3580
+ if (!proj) {
3581
+ state.attachedProjects = [];
3582
+ return { content: [{ type: "text", text: "🔗 Все присоединённые проекты отсоединены." }] };
3583
+ }
3584
+ const before = state.attachedProjects.length;
3585
+ state.attachedProjects = state.attachedProjects.filter(a => a.project !== proj && !a.mdHistory.includes(proj));
3586
+ return { content: [{ type: "text", text: before > state.attachedProjects.length ? `🔗 Проект «${proj}» отсоединён.` : `Проект «${proj}» не был присоединён.` }] };
3587
+ }
3588
+ if (!proj) {
3589
+ if (state.attachedProjects.length === 0)
3590
+ return { content: [{ type: "text", text: "🔗 Нет присоединённых проектов.\n\nПодключи: `context_attach /путь/к/репо` или `context_attach имя-проекта`.\nОтсоединить: `context_attach <имя> detach`." }] };
3591
+ let o = `## 🔗 Присоединённые проекты (${state.attachedProjects.length})\n\n`;
3592
+ for (const a of state.attachedProjects)
3593
+ o += `- **${a.project}** — \`${a.mdHistory}\`\n`;
3594
+ o += `\n💾 При сохранении контекст дублируется и в эти проекты.`;
3595
+ return { content: [{ type: "text", text: o }] };
3596
+ }
3597
+ // резолвим путь к MD_HISTORY присоединяемого репо
3598
+ let mdHistory = "";
3599
+ let projectName = "";
3600
+ const tryPaths = [
3601
+ path.join(proj, "MD_HISTORY"), // proj = корень репо
3602
+ proj.endsWith("MD_HISTORY") ? proj : "", // proj = сама MD_HISTORY
3603
+ ].filter(Boolean);
3604
+ for (const p of tryPaths) {
3605
+ if (fs.existsSync(p) && fs.statSync(p).isDirectory()) {
3606
+ mdHistory = p;
3607
+ break;
3608
+ }
3185
3609
  }
3186
- // v2.0: Добавляем в Memory System
3187
- const contextNumber = parseInt(result.name.split("_")[0]) || 0;
3188
- const fullContent = [
3189
- args?.task_overview,
3190
- args?.analysis,
3191
- args?.changes,
3192
- args?.summary,
3193
- args?.session_log,
3194
- args?.code_snippets,
3195
- args?.decisions,
3196
- args?.problems_solutions,
3197
- args?.architecture,
3198
- ].filter(Boolean).join("\n");
3199
- const memoryInfo = addContextToMemory(contextNumber, result.name, taskName, status, fullContent, new Date().toISOString().split("T")[0]);
3200
- // v2.2: Извлекаем решения при сохранении (detailed всегда имеет problems_solutions)
3201
- let solutionsAdded = 0;
3202
- const solutionsContent = [
3203
- args?.problems_solutions,
3204
- args?.changes,
3205
- args?.session_log,
3206
- ].filter(Boolean).join("\n");
3207
- const contextId = String(contextNumber).padStart(3, "0");
3208
- const extracted = extractSolutionsFromText(solutionsContent, contextId);
3209
- solutionsAdded = addSolutionsToIndex(extracted, contextId);
3210
- // v3.0: Обновляем дайджест (detailed передаёт больше данных)
3211
- const digestProjectD = detectProjectFromWorkDir() || memoryInfo.project || "unknown";
3212
- const digestUpdatedD = updateDigestOnSave({
3213
- project: digestProjectD,
3214
- contextId,
3215
- taskName,
3216
- status,
3217
- changes: args?.changes,
3218
- summary: args?.summary,
3219
- historySummary: args?.history_summary,
3220
- problemsSolutions: args?.problems_solutions,
3221
- decisions: args?.decisions,
3222
- architecture: args?.architecture,
3223
- date: new Date().toISOString().split("T")[0],
3224
- digestUpdate: args?.digest_update,
3225
- });
3226
- let output = `✅ **Контекст сохранён ПОДРОБНО**\n\n`;
3227
- output += `📁 **Папка:** \`${result.name}\`\n`;
3228
- output += `📄 **Файлов:** 15\n`;
3229
- output += `🎯 **Масштаб:** detailed (максимальная детализация)\n`;
3230
- output += `📍 **Путь:** \`${result.path}\`\n`;
3231
- // v2.0: Показываем Memory info
3232
- if (memoryInfo.project) {
3233
- output += `\n🧠 **Memory v2.0:**\n`;
3234
- output += `- Проект: **${memoryInfo.project}**\n`;
3235
- output += `- Теги: ${memoryInfo.keywords.slice(0, 5).join(", ") || "-"}\n`;
3236
- if (solutionsAdded > 0) {
3237
- output += `- 💡 Решений добавлено: **${solutionsAdded}**\n`;
3610
+ if (!mdHistory) {
3611
+ // поиск по имени в глобальной карте проектов
3612
+ const gd = loadGlobalDigest();
3613
+ for (const [name, sec] of Object.entries(gd.projects)) {
3614
+ if (name === proj || name.toLowerCase() === proj.toLowerCase()) {
3615
+ if (sec.mdHistory && fs.existsSync(sec.mdHistory)) {
3616
+ mdHistory = sec.mdHistory;
3617
+ projectName = name;
3618
+ break;
3619
+ }
3620
+ }
3238
3621
  }
3239
3622
  }
3240
- // v3.0: Дайджест статус
3241
- if (digestUpdatedD) {
3242
- output += `\n📦 **Дайджест обновлён** (${digestProjectD})\n`;
3623
+ if (!mdHistory)
3624
+ return { content: [{ type: "text", text: `❌ Не нашёл проект «${proj}».\nУкажи путь к репо (\`context_attach /home/.../repo\`) или имя из карты проектов (см. вкладку MCP / context_quick).` }] };
3625
+ const repoDir = path.dirname(mdHistory);
3626
+ if (!projectName)
3627
+ projectName = readGitOriginName(repoDir) || path.basename(repoDir);
3628
+ const atCtx = listContexts(mdHistory);
3629
+ if (!state.attachedProjects.some(a => a.mdHistory === mdHistory))
3630
+ state.attachedProjects.push({ project: projectName, mdHistory });
3631
+ let out = `# 🔗 Проект присоединён: ${projectName}\n\n`;
3632
+ out += `📍 \`${repoDir}\`\n📊 Контекстов: ${atCtx.length}\n\n`;
3633
+ // дайджест присоединённого репо если есть
3634
+ const adigest = path.join(mdHistory, "_DIGEST", "00-overview.md");
3635
+ if (fs.existsSync(adigest)) {
3636
+ out += `## Обзор проекта\n\n${trunc(fs.readFileSync(adigest, "utf-8"), 3 * 1024)}\n\n---\n\n`;
3637
+ }
3638
+ if (atCtx.length > 0) {
3639
+ out += `## Последние контексты «${projectName}»\n\n| # | Задача | Дата | Статус |\n|---|--------|------|--------|\n`;
3640
+ for (const c of atCtx.slice(0, 8))
3641
+ out += `| ${contextIdOf(c)} | ${c.title} | ${c.date} | ${c.status} |\n`;
3642
+ // полный README верхнего контекста
3643
+ const top = atCtx[0];
3644
+ const rd = path.join(top.path, "README.md");
3645
+ if (fs.existsSync(rd))
3646
+ out += `\n### Свежий контекст (#${contextIdOf(top)})\n\n${trunc(fs.readFileSync(rd, "utf-8"), 4 * 1024)}\n`;
3647
+ }
3648
+ out += `\n💡 Теперь работаешь с двумя проектами. При \`сохрани контекст\` запись продублируется и в «${projectName}». Отсоединить: \`context_attach ${projectName} detach\`.`;
3649
+ return { content: [{ type: "text", text: out }] };
3650
+ }
3651
+ case "context_private": {
3652
+ // v4.4.0 блок G: текущий проект → приватный. Локальный флаг + полная зачистка на сервере.
3653
+ const project = detectProjectFromWorkDir() || path.basename(getWorkingDir());
3654
+ if (!project)
3655
+ return { content: [{ type: "text", text: "❌ Не удалось определить текущий проект." }] };
3656
+ const already = isPrivateProject(project);
3657
+ markProjectPrivate(project); // список ~/.dedsession + маркер .dedsession-private в репо
3658
+ // Зачистка на сервере: удалить ВСЁ связанное с проектом (события/индекс/digest/AI-кэш).
3659
+ let serverMsg = "";
3660
+ const cfg = loadSyncConfig();
3661
+ if (cfg && cfg.token) {
3662
+ try {
3663
+ const r = await dedPanelCall("mcp_project_reset", { project });
3664
+ if (r && r.success)
3665
+ serverMsg = `🧹 На сервере удалено событий: ${r.removed ?? 0}.`;
3666
+ else {
3667
+ handleRevokeIfNeeded(r);
3668
+ serverMsg = `⚠️ Сервер: ${r?.error || "не удалось зачистить (попробуй позже)"}.`;
3669
+ }
3670
+ }
3671
+ catch {
3672
+ serverMsg = "⚠️ Оффлайн — серверная зачистка не выполнена (повтори команду онлайн).";
3673
+ }
3243
3674
  }
3244
- if (state.loadedContext && state.loadedContext !== result.name) {
3245
- output += `\n🔗 **Parent:** ${state.loadedContext}\n`;
3675
+ else {
3676
+ serverMsg = "ℹ️ Аккаунт не привязан — на сервере и так ничего нет.";
3246
3677
  }
3247
- output += `\n---\n\n`;
3248
- output += `**Созданные файлы:**\n`;
3249
- for (const f of detailedFiles) {
3250
- output += `- ${f}\n`;
3678
+ // также убрать из локального реестра залитых (чтобы не считался синхронизированным)
3679
+ try {
3680
+ const reg = loadSyncedRegistry();
3681
+ if (reg[project]) {
3682
+ delete reg[project];
3683
+ fs.writeFileSync(getSyncedRegistryPath(), JSON.stringify(reg), "utf-8");
3684
+ fs.chmodSync(getSyncedRegistryPath(), 0o600);
3685
+ }
3251
3686
  }
3252
- output += `\n---\n\n`;
3253
- output += `💡 **Для продолжения в новой сессии:**\n`;
3254
- output += `\`\`\`\nзапусти контекст загрузи #${result.name.split("_")[0]}\n\`\`\``;
3255
- // v1.4.16: Готовый текст для коммита (копировать вручную)
3256
- const git = isGitConfigured();
3257
- if (git.configured) {
3258
- const contextShortName = result.name.split("_").slice(2).join("_") || result.name;
3259
- const changeLines = (args?.changes || "")
3260
- .split("\n")
3261
- .map(l => l.trim())
3262
- .filter(l => l.length > 0)
3263
- .slice(0, 3)
3264
- .map(l => l.startsWith("-") ? l : `- ${l}`)
3265
- .join("\n");
3266
- const commitTitle = `📝 Context: ${contextShortName}`;
3267
- const commitBody = `Статус: ${status} | Файлов: 15\n\nЧто сделано:\n${changeLines}`;
3268
- output += `\n\n---\n\n`;
3269
- output += `📋 **Для коммита:**\n\n`;
3270
- output += `**Title:**\n\`\`\`\n${commitTitle}\n\`\`\`\n\n`;
3271
- output += `**Description:**\n\`\`\`\n${commitBody}\n\`\`\``;
3272
- output += `\n\n<claude-instruction>ОБЯЗАТЕЛЬНО покажи пользователю секцию "Для коммита" с Title и Description ПОЛНОСТЬЮ в своём ответе. Не сокращай вывод.</claude-instruction>`;
3687
+ catch { /* */ }
3688
+ // v4.4.0 блок G/H: вырезать секцию проекта из ЛОКАЛЬНОГО global-digest.json,
3689
+ // иначе autoPush из другого (публичного) проекта заново зальёт её на сервер — утечка.
3690
+ try {
3691
+ const gd = loadGlobalDigest();
3692
+ if (gd.projects && gd.projects[project]) {
3693
+ delete gd.projects[project];
3694
+ saveGlobalDigest(gd);
3695
+ }
3273
3696
  }
3274
- return { content: [{ type: "text", text: output }] };
3697
+ catch { /* */ }
3698
+ let out = `# 🔒 Проект «${project}» теперь ПРИВАТНЫЙ\n\n`;
3699
+ if (already)
3700
+ out += `(он уже был приватным — повторно зачистил сервер)\n\n`;
3701
+ out += `- Контексты этого проекта больше **не уходят никуда** — только локально.\n`;
3702
+ out += `- ${serverMsg}\n`;
3703
+ out += `- В корне проекта создан маркер \`.dedsession-private\`.\n\n`;
3704
+ out += `↩️ Вернуть синхронизацию: удали файл \`.dedsession-private\` в корне проекта.`;
3705
+ return { content: [{ type: "text", text: out }] };
3275
3706
  }
3276
- // ==================== CONTEXT_LOAD ====================
3277
3707
  case "context_load": {
3278
3708
  const query = args?.query;
3279
3709
  // Если нет запроса - показываем список для выбора
@@ -3319,7 +3749,9 @@ ${state.loadedContext ? `**Parent:** ${state.loadedContext}` : "Нет parent к
3319
3749
  return { content: [{ type: "text", text: `❌ ${result.error}` }] };
3320
3750
  }
3321
3751
  // v2.0: Обновляем accessCount и temperature
3322
- const contextId = state.loadedContext?.split("_")[0]?.padStart(3, "0");
3752
+ // v3.1.1: id через normalizeParentRef (как в индексе) — иначе для формата B/дублей
3753
+ // split("_")[0] давал "2025"/неверный ключ → updateContextAccess был no-op, Memory-блок пуст.
3754
+ const contextId = state.loadedContext ? normalizeParentRef(state.loadedContext) : null;
3323
3755
  if (contextId) {
3324
3756
  updateContextAccess(contextId);
3325
3757
  }
@@ -3383,128 +3815,77 @@ ${state.loadedContext ? `**Parent:** ${state.loadedContext}` : "Нет parent к
3383
3815
  return { content: [{ type: "text", text: output }] };
3384
3816
  }
3385
3817
  // ==================== CONTEXT_LIST ====================
3386
- case "context_list": {
3387
- const limit = args?.limit || 10;
3388
- const search = args?.search;
3389
- let contexts = search ? searchContexts(search) : listContexts();
3390
- const total = contexts.length;
3391
- contexts = contexts.slice(0, limit);
3392
- if (contexts.length === 0) {
3393
- if (search) {
3394
- return { content: [{ type: "text", text: `🔍 По запросу "${search}" ничего не найдено` }] };
3395
- }
3396
- return { content: [{ type: "text", text: "📭 Нет сохранённых контекстов" }] };
3397
- }
3398
- const stats = getStatistics();
3399
- let output = `## 📊 Статистика\n\n`;
3400
- output += `Всего: **${stats.total}** | За неделю: **${stats.thisWeek}** | `;
3401
- output += `✅ ${stats.completed} | ⏳ ${stats.inProgress}\n\n`;
3402
- if (search) {
3403
- output += `🔍 Результаты поиска "${search}": ${total}\n\n`;
3404
- }
3405
- output += `## 📦 Контексты\n\n`;
3406
- output += `| # | Название | Дата | Статус | Файлов |\n`;
3407
- output += `|---|----------|------|--------|--------|\n`;
3408
- for (const ctx of contexts) {
3409
- const num = ctx.number ? String(ctx.number).padStart(3, "0") : "-";
3410
- output += `| ${num} | ${ctx.title.slice(0, 30)} | ${ctx.date} | ${ctx.status} | ${ctx.filesCount} |\n`;
3411
- }
3412
- if (total > limit) {
3413
- output += `\n📝 Показано ${limit} из ${total}.\n`;
3818
+ case "context_quick": {
3819
+ state.isInitialized = true;
3820
+ // v3.5: фоновый авто-pull свежих правил + АВТО-PUSH (правила/карта на аккаунт) + событие старта.
3821
+ // v4.5.0: МИГРАЦИЯ старых контекстов УБРАНА (autoBackfill) историю залили вручную единоразово,
3822
+ // чтобы не было дублей/багов. Теперь работает просто: при СОХРАНЕНИИ контекст уходит в DedPanel.
3823
+ try {
3824
+ autoPullInBackground();
3825
+ autoPushInBackground();
3826
+ collectCommitStatsInBackground(); // коммиты по авторам DedPanel
3827
+ logEventInBackground({ type: "context_quick", project: detectProjectFromWorkDir() || "", status: "quick" });
3414
3828
  }
3415
- output += `\n---\n_Dedsession v${CONFIG.VERSION}_\n`;
3416
- return { content: [{ type: "text", text: output }] };
3417
- }
3418
- // ==================== CONTEXT_STATUS ====================
3419
- case "context_status": {
3420
- const stats = getStatistics();
3421
- const uptime = Math.round((Date.now() - state.sessionStartTime.getTime()) / 1000 / 60);
3422
- const workDir = getWorkingDir();
3423
- let output = `## 📊 Статус сессии — Dedsession v${CONFIG.VERSION}\n\n`;
3424
- output += `| Параметр | Значение |\n|----------|----------|\n`;
3425
- output += `| Версия | ${CONFIG.VERSION} |\n`;
3426
- output += `| Рабочая директория | \`${workDir}\` |\n`;
3427
- output += `| Время работы | ${uptime} мин |\n`;
3428
- output += `| Инициализирована | ${state.isInitialized ? "✅" : "❌"} |\n`;
3429
- output += `| Загруженный контекст | ${state.loadedContext || "—"} |\n`;
3430
- output += `| Всего контекстов | ${stats.total} |\n`;
3431
- output += `| Завершено | ${stats.completed} |\n`;
3432
- output += `| В процессе | ${stats.inProgress} |\n`;
3433
- return { content: [{ type: "text", text: output }] };
3434
- }
3435
- // ==================== CONTEXT_HELP ====================
3436
- case "context_help": {
3437
- const output = `# Dedsession — Справка
3438
-
3439
- ## Команды
3440
-
3441
- | Скажи | Что будет |
3442
- |-------|-----------|
3443
- | \`запусти контекст\` | Инициализация + статистика + последний контекст |
3444
- | \`сохрани контекст\` | Сохранить работу в MD_HISTORY/ |
3445
- | \`загрузи контекст\` | Показать список для выбора |
3446
- | \`загрузи контекст 5\` | Загрузить контекст #005 |
3447
- | \`загрузи контекст api\` | Поиск по слову "api" |
3448
- | \`покажи контексты\` | Список всех контекстов |
3449
- | \`статус\` | Текущая сессия |
3450
-
3451
- ---
3452
-
3453
- ## Структура
3454
-
3455
- \`\`\`
3456
- MD_HISTORY/
3457
- ├── session.md ← актуальный статус
3458
- └── 001_2025-12-16_task-name/
3459
- ├── README.md ← обзор
3460
- ├── 01-task-overview.md ← задача
3461
- ├── 02-analysis.md ← анализ
3462
- ├── 03-changes.md ← изменения
3463
- ├── 04-summary.md ← итоги
3464
- ├── 05-session-log.md ← лог диалога
3465
- └── 06-*.md ← доп. файлы
3466
- \`\`\`
3467
-
3468
- ---
3469
-
3470
- ## Автоматика
3471
-
3472
- **Напоминания** — Claude напомнит сохранить когда:
3473
- - Изменено 5+ файлов
3474
- - Задача завершена
3475
- - Прошло 30+ минут
3476
- - Сказано "готово", "сделано"
3477
-
3478
- **Parent/Child** — связи создаются автоматически:
3479
- - Загрузил #005 → поработал → сохранил → #006 (child of #005)
3480
-
3481
- **session.md** — обновляется после каждого сохранения
3482
-
3483
- ---
3484
-
3485
- ## Правила
3486
-
3487
- | DO | DON'T |
3488
- |----|-------|
3489
- | Сохраняй перед уходом | Не забывай сохранять |
3490
- | Загружай при возвращении | Не объясняй заново |
3491
- | Давай понятные имена | Не называй "work1" |
3492
-
3493
- ---
3494
-
3495
- **v${CONFIG.VERSION}** | \`npm: dedsession\`
3496
- `;
3497
- return { content: [{ type: "text", text: output }] };
3498
- }
3499
- // ==================== CONTEXT_QUICK ====================
3500
- case "context_quick": {
3501
- state.isInitialized = true;
3502
- const allContexts = listContexts();
3829
+ catch { /* не критично */ }
3830
+ const allContexts = listContexts();
3503
3831
  const stats = getStatistics();
3504
3832
  const workDir = getWorkingDir();
3505
3833
  if (allContexts.length === 0) {
3506
- return { content: [{ type: "text", text: "📭 Нет сохранённых контекстов" }] };
3834
+ // v4.0: fallback-старт (бывший context_start) нет контекстов показываем систему + правила,
3835
+ // чтобы первый запуск был осмысленным, а не пустым.
3836
+ let out = `## 🚀 dedsession v${CONFIG.VERSION} — система памяти Claude\n\n`;
3837
+ out += `📭 В этом репозитории (\`${path.basename(workDir)}\`) ещё нет сохранённых контекстов.\n\n`;
3838
+ out += `**Как работает (всё в 3 командах):**\n`;
3839
+ out += `- 💾 **сохрани контекст** — записать работу (дайджест, решения, синк — всё автоматом)\n`;
3840
+ out += `- ⚡ **загрузи контекст** — подтянуть последнее (этот вызов)\n`;
3841
+ out += `- 📋 **правила** (rules) — глобальные + локальные, применяются всегда\n\n`;
3842
+ try {
3843
+ const rb = formatRulesBlock();
3844
+ if (rb && rb.trim())
3845
+ out += rb + "\n";
3846
+ }
3847
+ catch { /* */ }
3848
+ out += `\n💡 Начни работу и скажи «сохрани контекст» — система запомнит.`;
3849
+ return { content: [{ type: "text", text: out }] };
3850
+ }
3851
+ // v3.1: АВТОМИГРАЦИЯ СХЕМЫ ПАМЯТИ — вшита прямо в быстрый контекст.
3852
+ // Версионный гейт: если схема свежая → мгновенный skip (одно сравнение, без I/O).
3853
+ // Если устарела → один раз на репо: бэкап → полный reindex из диска → проставить версию.
3854
+ // Гейт сам защищает от повторов (миграция выполнится ровно раз), всё в try/catch — не ломает загрузку.
3855
+ let migrationNote = "";
3856
+ try {
3857
+ const _idx = loadMemoryIndex();
3858
+ if ((_idx.schemaVersion || 0) < CONFIG.SCHEMA_VERSION) {
3859
+ // Бэкап текущей памяти перед миграцией (безопасность/откат)
3860
+ try {
3861
+ const memDir = getMemoryPath();
3862
+ const backupDir = path.join(memDir, `.backup-schema${_idx.schemaVersion || 0}`);
3863
+ if (!fs.existsSync(backupDir) && fs.existsSync(memDir)) {
3864
+ ensureDir(backupDir);
3865
+ for (const f of fs.readdirSync(memDir).filter(f => f.endsWith(".json"))) {
3866
+ fs.copyFileSync(path.join(memDir, f), path.join(backupDir, f));
3867
+ }
3868
+ }
3869
+ }
3870
+ catch { /* бэкап не критичен для продолжения */ }
3871
+ const mig = migrateToMemorySystem(); // полный reindex из диска (источник истины)
3872
+ const idx2 = loadMemoryIndex();
3873
+ idx2.version = CONFIG.VERSION;
3874
+ idx2.lastMigration = new Date().toISOString();
3875
+ // v3.1.1 FIX: schemaVersion ставим ТОЛЬКО при успешной миграции.
3876
+ // Иначе провал на середине «замораживал» недомигрированный legacy навсегда (гейт больше не сработает).
3877
+ if (mig.success) {
3878
+ idx2.schemaVersion = CONFIG.SCHEMA_VERSION;
3879
+ migrationNote = `🔄 **Память обновлена** до схемы v${CONFIG.SCHEMA_VERSION}: проиндексировано ${mig.migrated} контекстов`;
3880
+ if (mig.errors.length > 0)
3881
+ migrationNote += ` (пропущено: ${mig.errors.length})`;
3882
+ migrationNote += `\n\n`;
3883
+ }
3884
+ // при неуспехе schemaVersion НЕ трогаем → следующий context_quick повторит миграцию
3885
+ saveMemoryIndex(idx2);
3886
+ }
3507
3887
  }
3888
+ catch { /* автомиграция никогда не должна ломать context_quick */ }
3508
3889
  // v3.0: Проверяем наличие дайджеста — если нет, создаём автоматически
3509
3890
  let digest = loadDigest();
3510
3891
  if (!digest && allContexts.length > 0) {
@@ -3514,7 +3895,7 @@ MD_HISTORY/
3514
3895
  const keywordsIdx = loadKeywords();
3515
3896
  const solutionsIdx = loadSolutions();
3516
3897
  for (const ctx of allContexts) {
3517
- const ctxId = String(ctx.number).padStart(3, "0");
3898
+ const ctxId = contextIdOf(ctx); // v3.1 LEGACY-FIX
3518
3899
  let summary = "";
3519
3900
  const readmePath = path.join(ctx.path, "README.md");
3520
3901
  if (fs.existsSync(readmePath)) {
@@ -3565,6 +3946,16 @@ MD_HISTORY/
3565
3946
  digest = autoDigest;
3566
3947
  }
3567
3948
  const hasDigest = digest !== null;
3949
+ // v4.4.0 блок J: перегенерируем MD-файлы дайджеста новыми рендерами (без статусов),
3950
+ // чтобы старые файлы со «завершено/в работе/застряло» сразу обновились до чистого вида.
3951
+ if (hasDigest && digest) {
3952
+ try {
3953
+ saveDigest(digest);
3954
+ }
3955
+ catch { /* не критично */ }
3956
+ }
3957
+ // v3.1: публикуем выжимку этого репо в глобальный digest (реестр + объединение)
3958
+ publishToGlobalDigest();
3568
3959
  // v2.0: Получаем Memory stats
3569
3960
  const memoryStats = getMemoryStats();
3570
3961
  const hasMemory = memoryStats.totalContexts > 0;
@@ -3583,8 +3974,10 @@ MD_HISTORY/
3583
3974
  return text.slice(0, maxLen - 20) + "\n\n... [обрезано]";
3584
3975
  };
3585
3976
  let output = `# ⚡ Краткий контекст — Dedsession v${CONFIG.VERSION}\n\n`;
3977
+ if (migrationNote)
3978
+ output += migrationNote;
3586
3979
  output += `📁 **Рабочая директория:** \`${workDir}\`\n`;
3587
- output += `📊 **Статистика:** Всего ${stats.total} | ✅ ${stats.completed} | ${stats.inProgress}\n`;
3980
+ output += `📊 **Статистика:** Всего контекстов ${stats.total}\n`; // v4.4.0 блок J: без статусов
3588
3981
  if (hasDigest) {
3589
3982
  output += `📦 **Режим:** Дайджест + ${contextCount} контекстов (v3.0)\n\n`;
3590
3983
  }
@@ -3593,9 +3986,29 @@ MD_HISTORY/
3593
3986
  }
3594
3987
  // v2.1: Правила показываются ПЕРВЫМИ
3595
3988
  output += formatRulesBlock();
3989
+ // v4.1 (Ф1): СЕРВЕРНЫЙ AI-обзор проекта (GPT, надёжный — не зависит от парсинга README).
3990
+ // Кэш на сервере → обычно мгновенно. Оффлайн/таймаут → тихий fallback на локальный дайджест.
3991
+ try {
3992
+ const aiProj = detectProjectFromWorkDir() || "";
3993
+ if (aiProj && loadSyncConfig()?.token) {
3994
+ const ad = await dedPanelCall("mcp_digest", { project: aiProj, scope: "all" });
3995
+ const d = ad && ad.success ? ad.digest : null;
3996
+ if (d && d.summary) {
3997
+ output += `# 🤖 AI-обзор проекта (DedPanel)\n\n${d.summary}\n\n`;
3998
+ if (d.focus)
3999
+ output += `**🎯 Сейчас:** ${d.focus}\n\n`;
4000
+ if (Array.isArray(d.phases) && d.phases.length)
4001
+ output += `**Этапы:**\n${d.phases.map((p) => `- ${p}`).join("\n")}\n\n`;
4002
+ if (Array.isArray(d.stack) && d.stack.length)
4003
+ output += `**Стек:** ${d.stack.join(", ")}\n\n`;
4004
+ output += `---\n\n`;
4005
+ }
4006
+ }
4007
+ }
4008
+ catch { /* оффлайн/не сгенерилось — локальный дайджест ниже */ }
3596
4009
  // v3.0: ДАЙДЖЕСТ ПЕРВЫМ когда есть
3597
4010
  if (hasDigest) {
3598
- output += `<claude-instruction>Дайджест содержит полный контекст проекта. Начинай работу сразу без дополнительных вопросов.</claude-instruction>\n\n`;
4011
+ output += `<claude-instruction>ОБЯЗАТЕЛЬНО: Project Digest загружен! В своём ответе пользователю ты ДОЛЖЕН:\n1. Сказать что загружен Project Digest с полной историей проекта (укажи сколько контекстов в дайджесте и период работы из statistics)\n2. Кратко описать проект на основе 00-overview.md (2-3 предложения о сути проекта)\n3. Показать последние 3-5 задач из 01-timeline.md в виде таблицы (БЕЗ колонки статуса — понятия «выполнено/в работе/застряло» в системе НЕТ)\n4. НЕ упоминай «статусы», «незавершённые/in_progress», «незакрытые хвосты», «застряло» — этого понятия больше нет, не выдумывай его\n5. НЕ задавать вопросов "что делаем?" — вместо этого предложи продолжить по последним задачам\nЭто КРИТИЧЕСКИ важно — пользователь должен ВИДЕТЬ что дайджест работает и Claude знает историю проекта!</claude-instruction>\n\n`;
3599
4012
  const digestPath = getDigestPath();
3600
4013
  const digestFiles = ["00-overview.md", "01-timeline.md", "02-architecture.md", "03-decisions.md", "04-solutions.md"];
3601
4014
  for (const fileName of digestFiles) {
@@ -3687,503 +4100,13 @@ MD_HISTORY/
3687
4100
  return { content: [{ type: "text", text: output }] };
3688
4101
  }
3689
4102
  // ==================== CONTEXT_FULL ====================
3690
- case "context_full": {
3691
- state.isInitialized = true;
3692
- const contexts = listContexts();
3693
- const stats = getStatistics();
3694
- const workDir = getWorkingDir();
3695
- if (contexts.length === 0) {
3696
- return { content: [{ type: "text", text: "📭 Нет сохранённых контекстов" }] };
3697
- }
3698
- let output = `# 📚 Все контексты — Dedsession v${CONFIG.VERSION}\n\n`;
3699
- output += `📁 **Рабочая директория:** \`${workDir}\`\n`;
3700
- output += `📊 **Статистика:** Всего ${stats.total} | ✅ ${stats.completed} | ⏳ ${stats.inProgress}\n`;
3701
- output += `📦 **Загружено контекстов:** ${contexts.length}\n\n`;
3702
- // v2.1: Правила показываются ПЕРВЫМИ
3703
- output += formatRulesBlock();
3704
- output += `---\n\n`;
3705
- // Загружаем и выводим все файлы каждого контекста
3706
- for (const ctx of contexts) {
3707
- const result = loadContext(ctx.name);
3708
- if (!result.success)
3709
- continue;
3710
- output += `# 📁 Контекст #${String(ctx.number).padStart(3, "0")}: ${ctx.title}\n\n`;
3711
- output += `📅 **Дата:** ${ctx.date} | **Статус:** ${ctx.status} | **Файлов:** ${ctx.filesCount}\n`;
3712
- if (ctx.hasParent)
3713
- output += `🔗 **Parent:** ${ctx.hasParent}\n`;
3714
- if (ctx.hasChild)
3715
- output += `🔗 **Child:** ${ctx.hasChild}\n`;
3716
- output += `\n---\n\n`;
3717
- // Выводим все файлы контекста
3718
- const fileOrder = ["README.md", "01-task-overview.md", "02-analysis.md", "03-changes.md", "04-summary.md", "05-session-log.md", "06-code-snippets.md", "07-decisions.md", "08-problems-solutions.md"];
3719
- for (const fileName of fileOrder) {
3720
- if (result.files[fileName]) {
3721
- output += `## 📄 ${fileName}\n\n${result.files[fileName]}\n\n---\n\n`;
3722
- }
3723
- }
3724
- // Дополнительные файлы которых нет в списке
3725
- for (const [fileName, content] of Object.entries(result.files)) {
3726
- if (!fileOrder.includes(fileName)) {
3727
- output += `## 📄 ${fileName}\n\n${content}\n\n---\n\n`;
3728
- }
3729
- }
3730
- output += `\n${"=".repeat(80)}\n\n`;
3731
- }
3732
- output += `\n💡 Загружено ${contexts.length} контекстов. Продолжаем работу?`;
3733
- return { content: [{ type: "text", text: output }] };
3734
- }
3735
- // ==================== CONTEXT_TO_HISTORY ====================
3736
- case "context_to_history": {
3737
- const contexts = listContexts();
3738
- const historyPath = path.join(getMdHistoryPath(), "HISTORY.md");
3739
- if (contexts.length === 0) {
3740
- return {
3741
- content: [{
3742
- type: "text",
3743
- text: `⚠️ **Контексты не найдены**\n\nСначала сохрани хотя бы один контекст.`
3744
- }]
3745
- };
3746
- }
3747
- // Читаем README.md каждого контекста
3748
- let output = `# 🔄 Миграция контекстов в HISTORY.md\n\n`;
3749
- output += `📁 **Найдено контекстов:** ${contexts.length}\n`;
3750
- output += `📄 **Путь для записи:** \`${historyPath}\`\n\n`;
3751
- output += `---\n\n`;
3752
- output += `## 📚 README всех контекстов\n\n`;
3753
- output += `Claude, проанализируй каждый контекст и сформируй качественную сводку.\n\n`;
3754
- for (const ctx of contexts) {
3755
- const readmePath = path.join(ctx.path, "README.md");
3756
- output += `---\n\n`;
3757
- output += `### #${String(ctx.number).padStart(3, "0")} | ${ctx.date} | ${ctx.name} | ${ctx.status}\n\n`;
3758
- if (fs.existsSync(readmePath)) {
3759
- try {
3760
- const readme = fs.readFileSync(readmePath, "utf-8");
3761
- // Ограничиваем размер README чтобы не перегрузить контекст
3762
- const truncated = readme.slice(0, 2000);
3763
- output += truncated;
3764
- if (readme.length > 2000) {
3765
- output += `\n\n... (обрезано, полный файл: ${Math.round(readme.length / 1024)}KB)`;
3766
- }
3767
- }
3768
- catch {
3769
- output += `*Ошибка чтения README.md*`;
3770
- }
3771
- }
3772
- else {
3773
- output += `*README.md не найден*`;
3774
- }
3775
- output += `\n\n`;
3776
- }
3777
- output += `---\n\n`;
3778
- output += `## 🎯 Инструкции для Claude\n\n`;
3779
- output += `Сформируй HISTORY.md со следующей структурой:\n\n`;
3780
- output += `\`\`\`markdown\n`;
3781
- output += `# 📜 Dedsession History\n\n`;
3782
- output += `> Индекс всех контекстов. Обновлено: YYYY-MM-DD HH:MM\n\n`;
3783
- output += `---\n`;
3784
- output += `#001 | дата | название | статус\n`;
3785
- output += `**Качественная сводка 2-4 предложения:** что делали, какую проблему решали, что получилось.\n`;
3786
- output += `**Файлы:** список ключевых изменённых файлов\n\n`;
3787
- output += `---\n`;
3788
- output += `#002 | ...\n`;
3789
- output += `\`\`\`\n\n`;
3790
- output += `**Требования к сводкам:**\n`;
3791
- output += `- 2-4 предложения с СУТЬЮ задачи (не "Дата: ..." а реальное описание)\n`;
3792
- output += `- Что конкретно сделано, какой результат\n`;
3793
- output += `- Ключевые файлы которые менялись\n\n`;
3794
- output += `После формирования вызови \`context_history_write\` с готовым контентом.`;
3795
- return { content: [{ type: "text", text: output }] };
3796
- }
3797
- // ==================== CONTEXT_HISTORY_WRITE ====================
3798
- case "context_history_write": {
3799
- const content = args?.content;
3800
- if (!content) {
3801
- return {
3802
- content: [{
3803
- type: "text",
3804
- text: `⚠️ **Не передан контент для записи**\n\nСначала вызови \`context_to_history\` и сформируй сводки.`
3805
- }]
3806
- };
3807
- }
3808
- const historyPath = path.join(getMdHistoryPath(), "HISTORY.md");
3809
- try {
3810
- fs.writeFileSync(historyPath, content, "utf-8");
3811
- return {
3812
- content: [{
3813
- type: "text",
3814
- text: `✅ **HISTORY.md записан успешно**\n\n📄 Путь: \`${historyPath}\`\n📊 Размер: ${Math.round(content.length / 1024)}KB\n\n💡 Теперь при загрузке контекстов Claude будет видеть этот индекс.`
3815
- }]
3816
- };
3817
- }
3818
- catch (err) {
3819
- return {
3820
- content: [{
3821
- type: "text",
3822
- text: `❌ **Ошибка записи HISTORY.md**\n\n${err}`
3823
- }]
3824
- };
3825
- }
3826
- }
3827
- // ==================== CONTEXT_RELATED ====================
3828
- case "context_related": {
3829
- const query = args?.query;
3830
- if (!query) {
3831
- return {
3832
- content: [{
3833
- type: "text",
3834
- text: `⚠️ **Не указан запрос для поиска**
3835
-
3836
- Пример использования:
3837
- - "найди контекст про авторизацию"
3838
- - "подгрузи связанный с iOS подписками"
3839
- - "контекст про рефакторинг API"`
3840
- }]
3841
- };
3842
- }
3843
- const history = readHistory();
3844
- if (!history) {
3845
- return {
3846
- content: [{
3847
- type: "text",
3848
- text: `⚠️ **HISTORY.md не найден**
3849
-
3850
- Для работы умного поиска нужен индекс контекстов.
3851
-
3852
- Выполни команду \`context_to_history\` для создания индекса.`
3853
- }]
3854
- };
3855
- }
3856
- // Возвращаем HISTORY.md + инструкции для Claude
3857
- let output = `# 🔍 Поиск контекстов: "${query}"\n\n`;
3858
- output += `---\n\n`;
3859
- output += `## 📜 HISTORY.md — Индекс всех контекстов\n\n`;
3860
- output += history;
3861
- output += `\n\n---\n\n`;
3862
- output += `## 🎯 Инструкции для Claude\n\n`;
3863
- output += `Проанализируй HISTORY.md и найди **3-5 контекстов**, наиболее релевантных запросу "${query}".\n\n`;
3864
- output += `После анализа:\n`;
3865
- output += `1. Объясни какие контексты подходят и почему\n`;
3866
- output += `2. Вызови \`context_load\` для каждого релевантного контекста\n`;
3867
- output += `3. Объедини информацию из загруженных контекстов\n`;
3868
- return { content: [{ type: "text", text: output }] };
3869
- }
3870
- // ==================== MEMORY v2.0 HANDLERS ====================
3871
- // ==================== CONTEXT_PROJECT ====================
3872
- case "context_project": {
3873
- const projectName = args?.project?.toLowerCase();
3874
- if (!projectName) {
3875
- const projects = loadProjects();
3876
- const projectList = Object.entries(projects.projects)
3877
- .map(([name, p]) => `- **${name}**: ${p.count} контекстов (hot: ${p.hotContexts.length})`)
3878
- .join("\n");
3879
- return {
3880
- content: [{
3881
- type: "text",
3882
- text: `# 📁 Доступные проекты\n\n${projectList || "Проекты не найдены. Выполни `context_migrate` для индексации."}\n\n**Использование:** \`context_project ios\``
3883
- }]
3884
- };
3885
- }
3886
- const contexts = getContextsByProject(projectName);
3887
- if (contexts.length === 0) {
3888
- return {
3889
- content: [{
3890
- type: "text",
3891
- text: `⚠️ Проект "${projectName}" не найден или пуст.\n\nДоступные проекты: ios, windows, android, scripts, dedsession, web\n\nПопробуй выполнить \`context_migrate\` для индексации.`
3892
- }]
3893
- };
3894
- }
3895
- const grouped = { hot: [], warm: [], cold: [], archive: [] };
3896
- for (const ctx of contexts) {
3897
- grouped[ctx.temperature].push(ctx);
3898
- }
3899
- let output = `# 📁 Проект: ${projectName}\n\n`;
3900
- output += `📊 Всего контекстов: ${contexts.length}\n\n`;
3901
- const tempEmojis = { hot: "🔥", warm: "♨️", cold: "❄️", archive: "📦" };
3902
- for (const temp of ["hot", "warm", "cold", "archive"]) {
3903
- if (grouped[temp].length > 0) {
3904
- output += `## ${tempEmojis[temp]} ${temp.toUpperCase()} (${grouped[temp].length})\n\n`;
3905
- output += `| # | Название | Дата | Статус |\n|---|----------|------|--------|\n`;
3906
- for (const ctx of grouped[temp]) {
3907
- output += `| ${ctx.id} | ${ctx.name} | ${ctx.date} | ${ctx.status} |\n`;
3908
- }
3909
- output += "\n";
3910
- }
3911
- }
3912
- return { content: [{ type: "text", text: output }] };
3913
- }
3914
- // ==================== CONTEXT_EPIC ====================
3915
- case "context_epic": {
3916
- const epicName = args?.epic;
3917
- if (!epicName) {
3918
- const epics = loadEpics();
3919
- const epicList = Object.entries(epics.epics)
3920
- .map(([name, e]) => `- **${name}**: ${e.chain.length} контекстов (${e.status})`)
3921
- .join("\n");
3922
- return {
3923
- content: [{
3924
- type: "text",
3925
- text: `# 🔗 Эпики\n\n${epicList || "Эпики не найдены."}\n\n**Использование:** \`context_epic cloudpayments\``
3926
- }]
3927
- };
3928
- }
3929
- const contexts = getContextsByEpic(epicName);
3930
- const epics = loadEpics();
3931
- const epic = epics.epics[epicName];
3932
- if (!epic) {
3933
- return {
3934
- content: [{
3935
- type: "text",
3936
- text: `⚠️ Эпик "${epicName}" не найден.`
3937
- }]
3938
- };
3939
- }
3940
- let output = `# 🔗 Эпик: ${epicName}\n\n`;
3941
- output += `📊 Статус: ${epic.status === "completed" ? "✅ Завершён" : "⏳ В процессе"}\n`;
3942
- output += `📦 Контекстов в цепочке: ${epic.chain.length}\n\n`;
3943
- if (epic.description) {
3944
- output += `📝 ${epic.description}\n\n`;
3945
- }
3946
- output += `## 📜 Цепочка контекстов\n\n`;
3947
- output += `| # | Название | Дата | Статус |\n|---|----------|------|--------|\n`;
3948
- for (const ctx of contexts) {
3949
- output += `| ${ctx.id} | ${ctx.name} | ${ctx.date} | ${ctx.status} |\n`;
3950
- }
3951
- if (epic.keyDecisions && epic.keyDecisions.length > 0) {
3952
- output += `\n## 🎯 Ключевые решения\n\n`;
3953
- for (const decision of epic.keyDecisions) {
3954
- output += `- ${decision}\n`;
3955
- }
3956
- }
3957
- if (epic.remaining && epic.remaining.length > 0) {
3958
- output += `\n## ⏳ Что осталось\n\n`;
3959
- for (const item of epic.remaining) {
3960
- output += `- ${item}\n`;
3961
- }
3962
- }
3963
- return { content: [{ type: "text", text: output }] };
3964
- }
3965
- // ==================== CONTEXT_SEARCH ====================
3966
- case "context_search": {
3967
- const query = args?.query;
3968
- if (!query) {
3969
- return {
3970
- content: [{
3971
- type: "text",
3972
- text: `⚠️ Укажи поисковый запрос.\n\n**Примеры:**\n- \`context_search typescript\`\n- \`context_search index.ts\`\n- \`context_search авторизация\``
3973
- }]
3974
- };
3975
- }
3976
- const results = searchByKeywords(query);
3977
- if (results.length === 0) {
3978
- return {
3979
- content: [{
3980
- type: "text",
3981
- text: `🔎 По запросу "${query}" ничего не найдено.\n\nПопробуй другие ключевые слова или выполни \`context_migrate\` для обновления индекса.`
3982
- }]
3983
- };
3984
- }
3985
- let output = `# 🔎 Результаты поиска: "${query}"\n\n`;
3986
- output += `Найдено: ${results.length} контекстов\n\n`;
3987
- output += `| # | Название | Проект | Дата | Теги |\n|---|----------|--------|------|------|\n`;
3988
- for (const ctx of results) {
3989
- const tags = ctx.keywords.slice(0, 3).join(", ");
3990
- output += `| ${ctx.id} | ${ctx.name} | ${ctx.project || "-"} | ${ctx.date} | ${tags} |\n`;
3991
- }
3992
- output += `\n💡 Используй \`context_load <номер>\` для загрузки контекста.`;
3993
- return { content: [{ type: "text", text: output }] };
3994
- }
3995
- // ==================== CONTEXT_SIMILAR ====================
3996
- case "context_similar": {
3997
- const contextId = args?.context_id;
3998
- if (!contextId) {
3999
- return {
4000
- content: [{
4001
- type: "text",
4002
- text: `⚠️ Укажи ID контекста.\n\n**Пример:** \`context_similar 025\``
4003
- }]
4004
- };
4005
- }
4006
- const paddedId = contextId.padStart(3, "0");
4007
- const similar = findSimilarContexts(paddedId);
4008
- const memoryIndex = loadMemoryIndex();
4009
- const sourceCtx = memoryIndex.contexts[paddedId];
4010
- if (!sourceCtx) {
4011
- return {
4012
- content: [{
4013
- type: "text",
4014
- text: `⚠️ Контекст "${contextId}" не найден в индексе.\n\nВыполни \`context_migrate\` для индексации.`
4015
- }]
4016
- };
4017
- }
4018
- let output = `# 🔄 Похожие на: ${sourceCtx.name}\n\n`;
4019
- output += `📁 Проект: ${sourceCtx.project || "-"}\n`;
4020
- output += `🔗 Эпик: ${sourceCtx.epic || "-"}\n`;
4021
- output += `🏷️ Теги: ${sourceCtx.keywords.slice(0, 5).join(", ")}\n\n`;
4022
- if (similar.length === 0) {
4023
- output += `Похожих контекстов не найдено.`;
4024
- }
4025
- else {
4026
- output += `## 📋 Похожие контексты\n\n`;
4027
- output += `| # | Название | Проект | Общее |\n|---|----------|--------|-------|\n`;
4028
- for (const ctx of similar) {
4029
- const common = [];
4030
- if (ctx.project === sourceCtx.project)
4031
- common.push("проект");
4032
- if (ctx.epic === sourceCtx.epic)
4033
- common.push("эпик");
4034
- const commonKw = ctx.keywords.filter(k => sourceCtx.keywords.includes(k)).length;
4035
- if (commonKw > 0)
4036
- common.push(`${commonKw} тегов`);
4037
- output += `| ${ctx.id} | ${ctx.name} | ${ctx.project || "-"} | ${common.join(", ")} |\n`;
4038
- }
4039
- }
4040
- return { content: [{ type: "text", text: output }] };
4041
- }
4042
- // ==================== CONTEXT_CONTINUE ====================
4043
- case "context_continue": {
4044
- const unfinishedEpics = getUnfinishedEpics();
4045
- const memoryIndex = loadMemoryIndex();
4046
- // Ищем контексты в статусе in_progress
4047
- const inProgressContexts = Object.values(memoryIndex.contexts)
4048
- .filter(ctx => ctx.status.includes("⏳") || ctx.status === "in_progress")
4049
- .sort((a, b) => b.date.localeCompare(a.date))
4050
- .slice(0, 10);
4051
- let output = `# ▶️ Незавершённые задачи\n\n`;
4052
- if (unfinishedEpics.length > 0) {
4053
- output += `## 🔗 Эпики в процессе\n\n`;
4054
- for (const { name, epic, lastContext } of unfinishedEpics) {
4055
- output += `### ${name}\n`;
4056
- output += `- Контекстов: ${epic.chain.length}\n`;
4057
- if (lastContext) {
4058
- output += `- Последний: #${lastContext.id} - ${lastContext.name} (${lastContext.date})\n`;
4059
- }
4060
- if (epic.remaining && epic.remaining.length > 0) {
4061
- output += `- Осталось:\n`;
4062
- for (const item of epic.remaining) {
4063
- output += ` - ${item}\n`;
4064
- }
4065
- }
4066
- output += "\n";
4067
- }
4068
- }
4069
- if (inProgressContexts.length > 0) {
4070
- output += `## ⏳ Контексты in_progress\n\n`;
4071
- output += `| # | Название | Проект | Дата |\n|---|----------|--------|------|\n`;
4072
- for (const ctx of inProgressContexts) {
4073
- output += `| ${ctx.id} | ${ctx.name} | ${ctx.project || "-"} | ${ctx.date} |\n`;
4074
- }
4075
- output += "\n";
4076
- }
4077
- if (unfinishedEpics.length === 0 && inProgressContexts.length === 0) {
4078
- output += `✅ Все задачи завершены! Нет незавершённых эпиков или контекстов.`;
4079
- }
4080
- else {
4081
- output += `💡 Используй \`context_load <номер>\` для продолжения работы.`;
4082
- }
4083
- return { content: [{ type: "text", text: output }] };
4084
- }
4085
- // ==================== CONTEXT_SOLUTIONS ====================
4086
- case "context_solutions": {
4087
- const query = args?.query;
4088
- const solutions = loadSolutions();
4089
- let filtered = solutions.solutions;
4090
- if (query) {
4091
- const lowerQuery = query.toLowerCase();
4092
- filtered = solutions.solutions.filter(s => s.problem.toLowerCase().includes(lowerQuery) ||
4093
- s.solution.some(sol => sol.toLowerCase().includes(lowerQuery)));
4094
- }
4095
- if (filtered.length === 0) {
4096
- return {
4097
- content: [{
4098
- type: "text",
4099
- text: `# 💡 Решения\n\n${query ? `По запросу "${query}" ничего не найдено.` : "База решений пуста."}\n\n💡 Решения автоматически извлекаются из контекстов с секцией "problems_solutions".`
4100
- }]
4101
- };
4102
- }
4103
- let output = `# 💡 Решения${query ? ` по запросу "${query}"` : ""}\n\n`;
4104
- output += `Найдено: ${filtered.length} решений\n\n`;
4105
- for (const sol of filtered.slice(0, 10)) {
4106
- output += `## 🔧 ${sol.problem}\n\n`;
4107
- output += `**Решение:**\n`;
4108
- for (const step of sol.solution) {
4109
- output += `- ${step}\n`;
4110
- }
4111
- if (sol.codeExample) {
4112
- output += `\n\`\`\`\n${sol.codeExample}\n\`\`\`\n`;
4113
- }
4114
- output += `\n📚 Источник: ${sol.sourceContexts.join(", ")}\n\n---\n\n`;
4115
- }
4116
- return { content: [{ type: "text", text: output }] };
4117
- }
4118
- // ==================== CONTEXT_MEMORY ====================
4119
- case "context_memory": {
4120
- const stats = getMemoryStats();
4121
- const memoryIndex = loadMemoryIndex();
4122
- let output = `# 🧠 Memory System v2.0\n\n`;
4123
- output += `📊 **Статистика:**\n\n`;
4124
- output += `| Показатель | Значение |\n|------------|----------|\n`;
4125
- output += `| Всего контекстов | ${stats.totalContexts} |\n`;
4126
- output += `| Эпиков в процессе | ${stats.epicsInProgress} |\n`;
4127
- output += `| Решений в базе | ${stats.solutionsCount} |\n\n`;
4128
- output += `## 🌡️ Температуры\n\n`;
4129
- output += `| Статус | Количество |\n|--------|------------|\n`;
4130
- output += `| 🔥 Hot (3 дня) | ${stats.temperatures.hot} |\n`;
4131
- output += `| ♨️ Warm (14 дней) | ${stats.temperatures.warm} |\n`;
4132
- output += `| ❄️ Cold (60 дней) | ${stats.temperatures.cold} |\n`;
4133
- output += `| 📦 Archive (>60 дней) | ${stats.temperatures.archive} |\n\n`;
4134
- if (Object.keys(stats.projects).length > 0) {
4135
- output += `## 📁 Проекты\n\n`;
4136
- output += `| Проект | Контекстов |\n|--------|------------|\n`;
4137
- for (const [name, count] of Object.entries(stats.projects).sort((a, b) => b[1] - a[1])) {
4138
- output += `| ${name} | ${count} |\n`;
4139
- }
4140
- output += "\n";
4141
- }
4142
- output += `📅 Последняя миграция: ${memoryIndex.lastMigration || "никогда"}\n`;
4143
- return { content: [{ type: "text", text: output }] };
4144
- }
4145
- // ==================== CONTEXT_MIGRATE ====================
4146
- case "context_migrate": {
4147
- const result = migrateToMemorySystem();
4148
- let output = `# 🔄 Миграция в Memory v2.0\n\n`;
4149
- if (result.success) {
4150
- output += `✅ **Миграция успешна!**\n\n`;
4151
- output += `📊 Результаты:\n`;
4152
- output += `- Проиндексировано контекстов: ${result.migrated}\n`;
4153
- output += `- Обнаружено проектов: ${result.projects.length} (${result.projects.join(", ") || "-"})\n`;
4154
- output += `- Эпиков: ${result.epics.length}\n`;
4155
- output += `- 💡 Извлечено решений: ${result.solutions}\n\n`;
4156
- const stats = getMemoryStats();
4157
- output += `## 🌡️ Распределение по температуре\n\n`;
4158
- output += `- 🔥 Hot: ${stats.temperatures.hot}\n`;
4159
- output += `- ♨️ Warm: ${stats.temperatures.warm}\n`;
4160
- output += `- ❄️ Cold: ${stats.temperatures.cold}\n`;
4161
- output += `- 📦 Archive: ${stats.temperatures.archive}\n\n`;
4162
- output += `💡 Теперь доступны команды:\n`;
4163
- output += `- \`context_project ios\` — контексты по проекту\n`;
4164
- output += `- \`context_search query\` — полнотекстовый поиск\n`;
4165
- output += `- \`context_similar 025\` — похожие контексты\n`;
4166
- output += `- \`context_continue\` — незавершённые задачи\n`;
4167
- }
4168
- else {
4169
- output += `❌ **Ошибка миграции**\n\n`;
4170
- output += `Проиндексировано: ${result.migrated}\n\n`;
4171
- if (result.errors.length > 0) {
4172
- output += `Ошибки:\n`;
4173
- for (const err of result.errors.slice(0, 10)) {
4174
- output += `- ${err}\n`;
4175
- }
4176
- }
4177
- }
4178
- return { content: [{ type: "text", text: output }] };
4179
- }
4180
- // ==================== RULES ====================
4181
4103
  case "rules": {
4182
4104
  const action = args.action || "list";
4183
4105
  const scope = args.scope || "local";
4184
4106
  const ruleText = args.rule;
4185
4107
  const ruleId = args.id;
4186
4108
  const ruleContext = args.context;
4109
+ const ruleTitle = (args.title || "").trim().slice(0, 80); // v4.4.0 блок C
4187
4110
  const isGlobal = scope === "global";
4188
4111
  const rulesIndex = isGlobal ? loadGlobalRules() : loadRules();
4189
4112
  const scopeLabel = isGlobal ? "🌍 Глобальное" : "📁 Локальное";
@@ -4206,12 +4129,15 @@ MD_HISTORY/
4206
4129
  const newRule = {
4207
4130
  id: newId,
4208
4131
  rule: ruleText,
4209
- created: new Date().toISOString().split("T")[0],
4132
+ ...(ruleTitle ? { title: ruleTitle } : {}), // v4.4.0 блок C: короткий заголовок
4133
+ created: new Date().toISOString(), // v4.2: с временем (для merge между устройствами)
4210
4134
  context: ruleContext,
4135
+ ...(isGlobal ? { hash: ruleHash(ruleText), device: getDeviceName() } : {}),
4211
4136
  };
4212
4137
  rulesIndex.rules.push(newRule);
4213
4138
  if (isGlobal) {
4214
4139
  saveGlobalRules(rulesIndex);
4140
+ pushGlobalRulesInBackground(); // v4.2: merge-синк (union по тексту, без потерь между устройствами)
4215
4141
  }
4216
4142
  else {
4217
4143
  saveRules(rulesIndex);
@@ -4247,11 +4173,18 @@ MD_HISTORY/
4247
4173
  }]
4248
4174
  };
4249
4175
  }
4250
- const removed = rulesIndex.rules.splice(ruleIndex, 1)[0];
4176
+ const removed = rulesIndex.rules[ruleIndex];
4251
4177
  if (isGlobal) {
4178
+ // v4.2: tombstone (не splice) — удаление доезжает на другие устройства через merge, не воскресает
4179
+ removed.deleted = true;
4180
+ removed.created = new Date().toISOString(); // обновляем время — tombstone выигрывает при merge
4181
+ if (!removed.hash)
4182
+ removed.hash = ruleHash(removed.rule);
4252
4183
  saveGlobalRules(rulesIndex);
4184
+ pushGlobalRulesInBackground();
4253
4185
  }
4254
4186
  else {
4187
+ rulesIndex.rules.splice(ruleIndex, 1);
4255
4188
  saveRules(rulesIndex);
4256
4189
  }
4257
4190
  let output = `# ✅ ${scopeLabel} правило удалено\n\n`;
@@ -4286,415 +4219,6 @@ MD_HISTORY/
4286
4219
  }
4287
4220
  }
4288
4221
  // ========== PROJECT DIGEST v3.0 HANDLERS ==========
4289
- case "context_digest": {
4290
- const digestPath = getDigestPath();
4291
- if (!fs.existsSync(path.join(digestPath, "digest.json"))) {
4292
- return {
4293
- content: [{
4294
- type: "text",
4295
- text: `📦 **Дайджест не найден**\n\nПапка \`_DIGEST/\` пуста или не существует.\n\n**Создайте дайджест:**\n- Скажите "миграция дайджеста" или "digest migrate"\n- Или просто сохраните контекст — дайджест создастся автоматически`,
4296
- }],
4297
- };
4298
- }
4299
- let output = `# 📦 Project Digest\n\n`;
4300
- const digestFiles = ["00-overview.md", "01-timeline.md", "02-architecture.md", "03-decisions.md", "04-solutions.md"];
4301
- for (const fileName of digestFiles) {
4302
- const filePath = path.join(digestPath, fileName);
4303
- if (fs.existsSync(filePath)) {
4304
- const content = fs.readFileSync(filePath, "utf-8");
4305
- output += content + "\n\n---\n\n";
4306
- }
4307
- }
4308
- return { content: [{ type: "text", text: output }] };
4309
- }
4310
- case "context_digest_migrate": {
4311
- const allContexts = listContexts();
4312
- const memoryIndex = loadMemoryIndex();
4313
- const solutionsIdx = loadSolutions();
4314
- const keywordsIdx = loadKeywords();
4315
- const projectName = detectProjectFromWorkDir() || "unknown";
4316
- const digest = createEmptyDigest(projectName);
4317
- // Сканируем все контексты
4318
- for (const ctx of allContexts) {
4319
- const contextId = String(ctx.number).padStart(3, "0");
4320
- // Читаем README
4321
- let summary = "";
4322
- const readmePath = path.join(ctx.path, "README.md");
4323
- if (fs.existsSync(readmePath)) {
4324
- const readme = fs.readFileSync(readmePath, "utf-8");
4325
- const descMatch = readme.match(/## Краткое описание\n\n([^\n#]+)/);
4326
- summary = descMatch ? descMatch[1].trim() : readme.split("\n").slice(0, 3).join(" ").slice(0, 200);
4327
- }
4328
- // Читаем 03-changes.md для файлов
4329
- let keyChanges = [];
4330
- const changesPath = path.join(ctx.path, "03-changes.md");
4331
- if (fs.existsSync(changesPath)) {
4332
- const changesContent = fs.readFileSync(changesPath, "utf-8");
4333
- keyChanges = extractKeyChangesFromText(changesContent);
4334
- const files = extractFilesFromChanges(changesContent);
4335
- for (const f of files) {
4336
- if (!digest.currentState.keyFiles.includes(f)) {
4337
- digest.currentState.keyFiles.push(f);
4338
- }
4339
- }
4340
- }
4341
- // Статистика
4342
- digest.statistics.totalContexts++;
4343
- if (ctx.status.includes("✅"))
4344
- digest.statistics.completed++;
4345
- else if (ctx.status.includes("⏳"))
4346
- digest.statistics.inProgress++;
4347
- else if (ctx.status.includes("❌"))
4348
- digest.statistics.blocked++;
4349
- if (!digest.statistics.firstDate || ctx.date < digest.statistics.firstDate) {
4350
- digest.statistics.firstDate = ctx.date;
4351
- }
4352
- if (!digest.statistics.lastDate || ctx.date > digest.statistics.lastDate) {
4353
- digest.statistics.lastDate = ctx.date;
4354
- }
4355
- // recentTasks (последние 30)
4356
- digest.recentTasks.push({
4357
- contextId,
4358
- date: ctx.date,
4359
- name: ctx.title,
4360
- status: ctx.status,
4361
- summary: summary.slice(0, 200),
4362
- keyChanges,
4363
- });
4364
- }
4365
- // Сортируем по номеру (новые первые) и обрезаем
4366
- digest.recentTasks.sort((a, b) => parseInt(b.contextId) - parseInt(a.contextId));
4367
- digest.recentTasks = digest.recentTasks.slice(0, 30);
4368
- // Лимит keyFiles
4369
- digest.currentState.keyFiles = digest.currentState.keyFiles.slice(0, 30);
4370
- // techStack из keywords
4371
- const techKeywords = ["typescript", "javascript", "python", "swift", "kotlin", "react", "vue", "node", "mcp", "trello", "obsidian"];
4372
- for (const [kw] of Object.entries(keywordsIdx.keywords)) {
4373
- if (techKeywords.includes(kw.toLowerCase())) {
4374
- digest.currentState.techStack.push(kw);
4375
- }
4376
- }
4377
- // solutions из solutions.json
4378
- for (const sol of solutionsIdx.solutions.slice(0, 20)) {
4379
- digest.solutions.push({
4380
- problem: sol.problem,
4381
- solution: sol.solution.join("; "),
4382
- contextId: sol.sourceContexts[0] || "?",
4383
- });
4384
- }
4385
- // Фазы — группировка по месяцам
4386
- const monthMap = new Map();
4387
- for (const task of digest.recentTasks) {
4388
- const month = task.date.slice(0, 7); // YYYY-MM
4389
- if (!monthMap.has(month)) {
4390
- monthMap.set(month, { count: 0, tasks: [] });
4391
- }
4392
- const m = monthMap.get(month);
4393
- m.count++;
4394
- if (m.tasks.length < 3)
4395
- m.tasks.push(task.name);
4396
- }
4397
- for (const [month, data] of monthMap) {
4398
- digest.phases.push({
4399
- name: month,
4400
- dateRange: month,
4401
- description: `${data.count} задач: ${data.tasks.join(", ")}${data.count > 3 ? " и др." : ""}`,
4402
- });
4403
- }
4404
- // activeEpics из memory
4405
- const epics = loadEpics();
4406
- for (const [name, epic] of Object.entries(epics.epics)) {
4407
- if (epic.status === "in_progress") {
4408
- digest.currentState.activeEpics.push(name);
4409
- }
4410
- }
4411
- // Сохраняем
4412
- saveDigest(digest);
4413
- let output = `✅ **Дайджест создан!**\n\n`;
4414
- output += `📦 **Проект:** ${projectName}\n`;
4415
- output += `📊 **Контекстов обработано:** ${allContexts.length}\n`;
4416
- output += `📁 **Путь:** \`${getDigestPath()}\`\n\n`;
4417
- output += `**Создано файлов:**\n`;
4418
- output += `- 00-overview.md\n- 01-timeline.md\n- 02-architecture.md\n- 03-decisions.md\n- 04-solutions.md\n- digest.json\n\n`;
4419
- output += `---\n\n`;
4420
- output += `💡 **Следующий шаг:** скажите "обнови дайджест" (refresh) — Claude проанализирует и добавит качественное описание проекта, фазы эволюции и т.д.\n\n`;
4421
- output += `📦 Дайджест будет автоматически обновляться при каждом \`context_save\`.`;
4422
- return { content: [{ type: "text", text: output }] };
4423
- }
4424
- case "context_digest_refresh": {
4425
- const digest = loadDigest();
4426
- if (!digest) {
4427
- return {
4428
- content: [{
4429
- type: "text",
4430
- text: `📦 **Дайджест не найден**\n\nСначала создайте дайджест: "миграция дайджеста" или "digest migrate"`,
4431
- }],
4432
- };
4433
- }
4434
- // Собираем README последних 10 контекстов
4435
- const contexts = listContexts().slice(0, 10);
4436
- let readmes = "";
4437
- for (const ctx of contexts) {
4438
- const readmePath = path.join(ctx.path, "README.md");
4439
- if (fs.existsSync(readmePath)) {
4440
- readmes += `\n---\n### #${String(ctx.number).padStart(3, "0")} | ${ctx.date} | ${ctx.title} | ${ctx.status}\n\n`;
4441
- const content = fs.readFileSync(readmePath, "utf-8");
4442
- readmes += content.slice(0, 1500) + "\n";
4443
- }
4444
- }
4445
- let output = `# 🔄 Refresh дайджеста\n\n`;
4446
- output += `## Текущий digest.json:\n\n`;
4447
- output += `\`\`\`json\n${JSON.stringify(digest, null, 2)}\n\`\`\`\n\n`;
4448
- output += `---\n\n`;
4449
- output += `## README последних 10 контекстов:\n\n`;
4450
- output += readmes;
4451
- output += `\n\n---\n\n`;
4452
- output += `<claude-instruction>\n`;
4453
- output += `Проанализируй digest.json и README контекстов выше.\n`;
4454
- output += `Затем вызови context_digest_write с параметрами:\n`;
4455
- output += `- description: описание проекта (3-5 предложений)\n`;
4456
- output += `- tech_stack: стек через запятую\n`;
4457
- output += `- key_files: ключевые файлы через запятую\n`;
4458
- output += `- phases: фазы эволюции (Название | даты | описание на каждой строке)\n`;
4459
- output += `- key_decisions: ключевые решения\n`;
4460
- output += `- known_issues: текущие проблемы\n`;
4461
- output += `- active_epics: активные эпики\n`;
4462
- output += `</claude-instruction>`;
4463
- return { content: [{ type: "text", text: output }] };
4464
- }
4465
- case "context_digest_write": {
4466
- let digest = loadDigest();
4467
- if (!digest) {
4468
- const projectName = detectProjectFromWorkDir() || "unknown";
4469
- digest = createEmptyDigest(projectName);
4470
- }
4471
- // Обновляем currentState
4472
- if (args?.description) {
4473
- digest.currentState.description = args.description;
4474
- }
4475
- if (args?.tech_stack) {
4476
- digest.currentState.techStack = args.tech_stack.split(",").map(s => s.trim()).filter(Boolean);
4477
- }
4478
- if (args?.key_files) {
4479
- digest.currentState.keyFiles = args.key_files.split(",").map(s => s.trim()).filter(Boolean);
4480
- }
4481
- if (args?.active_epics) {
4482
- digest.currentState.activeEpics = args.active_epics.split(",").map(s => s.trim()).filter(Boolean);
4483
- }
4484
- // Обновляем known_issues
4485
- if (args?.known_issues) {
4486
- digest.knownIssues = args.known_issues.split("\n").map(s => s.trim()).filter(Boolean).slice(0, 10);
4487
- }
4488
- // Обновляем phases
4489
- if (args?.phases) {
4490
- const phaseLines = args.phases.split("\n").filter(l => l.trim());
4491
- digest.phases = phaseLines.map(line => {
4492
- const parts = line.split("|").map(p => p.trim());
4493
- return {
4494
- name: parts[0] || "Phase",
4495
- dateRange: parts[1] || "",
4496
- description: parts[2] || "",
4497
- };
4498
- });
4499
- }
4500
- // Обновляем key_decisions
4501
- if (args?.key_decisions) {
4502
- const decLines = args.key_decisions.split("\n").filter(l => l.trim());
4503
- const today = new Date().toISOString().split("T")[0];
4504
- digest.keyDecisions = decLines.map(line => ({
4505
- decision: line.trim(),
4506
- date: today,
4507
- contextId: "refresh",
4508
- })).slice(0, 20);
4509
- }
4510
- digest.lastUpdated = new Date().toISOString().split("T")[0];
4511
- // Сохраняем и перерендериваем
4512
- saveDigest(digest);
4513
- return {
4514
- content: [{
4515
- type: "text",
4516
- text: `✅ **Дайджест обновлён!**\n\n📦 Проект: **${digest.project}**\n📝 Описание: ${(digest.currentState.description || "").slice(0, 100)}...\n🔧 Стек: ${digest.currentState.techStack.join(", ") || "-"}\n📁 Файлов: ${digest.currentState.keyFiles.length}\n\nВсе 5 MD файлов перерендерены. Проверьте: "дайджест" или "digest"`,
4517
- }],
4518
- };
4519
- }
4520
- // ========== ТЕСТОВЫЕ HANDLERS SMART CONTEXT ==========
4521
- case "context_smart": {
4522
- const history = readHistory();
4523
- if (!history) {
4524
- return {
4525
- content: [{
4526
- type: "text",
4527
- text: `❌ **HISTORY.md не найден**\n\nСначала создайте контексты или запустите \`context_migrate\`.`,
4528
- }],
4529
- };
4530
- }
4531
- const entries = parseHistoryEntries(history, 25);
4532
- const memoryIndex = loadMemoryIndex();
4533
- // Группируем по людям
4534
- const byPerson = {};
4535
- for (const entry of entries) {
4536
- const person = entry.person || "other";
4537
- if (!byPerson[person])
4538
- byPerson[person] = [];
4539
- byPerson[person].push(entry);
4540
- }
4541
- // Группируем по проектам из Memory v2.0
4542
- const byProject = {};
4543
- for (const entry of entries) {
4544
- const meta = memoryIndex.contexts[entry.number];
4545
- const project = meta?.project || "unknown";
4546
- if (!byProject[project])
4547
- byProject[project] = [];
4548
- byProject[project].push(entry);
4549
- }
4550
- // Формируем компактный вывод
4551
- let output = formatRulesBlock();
4552
- output += `# 🧠 Smart Context — Обзор ${entries.length} контекстов\n\n`;
4553
- // По проектам (основной акцент — задачи с кодом)
4554
- const projectsWithData = Object.entries(byProject).filter(([p]) => p !== "unknown");
4555
- if (projectsWithData.length > 0) {
4556
- output += `## 📁 По проектам:\n`;
4557
- for (const [project, list] of projectsWithData) {
4558
- const ids = list.slice(0, 5).map(e => `#${e.number}`).join(", ");
4559
- const more = list.length > 5 ? ` (+${list.length - 5})` : "";
4560
- output += `- **${project}** (${list.length}): ${ids}${more}\n`;
4561
- }
4562
- }
4563
- // По людям (если есть контексты связанные с людьми)
4564
- const personsWithData = Object.keys(byPerson).filter(p => p !== "other");
4565
- if (personsWithData.length > 0) {
4566
- output += `\n## 👥 По людям:\n`;
4567
- for (const [person, list] of Object.entries(byPerson)) {
4568
- if (person !== "other") {
4569
- const ids = list.slice(0, 5).map(e => `#${e.number}`).join(", ");
4570
- const more = list.length > 5 ? ` (+${list.length - 5})` : "";
4571
- output += `- **${person}** (${list.length}): ${ids}${more}\n`;
4572
- }
4573
- }
4574
- }
4575
- // Последние 10 с краткими сводками
4576
- output += `\n## 📋 Последние 10:\n`;
4577
- output += `| # | Название | Проект | Сводка |\n|---|----------|--------|--------|\n`;
4578
- for (const entry of entries.slice(0, 10)) {
4579
- const meta = memoryIndex.contexts[entry.number];
4580
- const project = meta?.project || "-";
4581
- const shortSummary = entry.summary.slice(0, 50).replace(/\n/g, " ") + "...";
4582
- output += `| ${entry.number} | ${entry.name} | ${project} | ${shortSummary} |\n`;
4583
- }
4584
- // Генерируем динамические примеры на основе реальных данных
4585
- output += `\n---\n`;
4586
- output += `💡 **Опиши задачу** в свободном формате, и я подгружу релевантные контексты:\n`;
4587
- const examples = [];
4588
- // Пример с проектом/технологией (основной акцент)
4589
- if (projectsWithData.length > 0) {
4590
- const [project, list] = projectsWithData[0];
4591
- const entry = list[0];
4592
- const keyword = entry.name.split("-").find(w => w.length > 3) || "";
4593
- examples.push(`"${keyword} ${project}"`);
4594
- }
4595
- // Пример из последнего контекста (по ключевым словам)
4596
- if (entries.length > 0) {
4597
- const lastEntry = entries[0];
4598
- const words = lastEntry.name.split("-").filter(w => w.length > 3).slice(0, 2);
4599
- if (words.length > 0) {
4600
- examples.push(`"${words.join(" ")}"`);
4601
- }
4602
- }
4603
- // Пример с человеком (если есть контексты с людьми)
4604
- if (personsWithData.length > 0) {
4605
- const person = personsWithData[0];
4606
- const personEntry = byPerson[person][0];
4607
- const keyword = personEntry.name.split("-").find(w => w.length > 3 && !w.includes(person)) || "";
4608
- examples.push(`"${keyword ? keyword + " " : ""}${person}"`);
4609
- }
4610
- // Выводим примеры (максимум 3)
4611
- for (const example of examples.slice(0, 3)) {
4612
- output += `- ${example}\n`;
4613
- }
4614
- output += `\nClaude автоматически найдёт и загрузит 2-4 релевантных контекста.`;
4615
- return { content: [{ type: "text", text: output }] };
4616
- }
4617
- case "context_smart_focus": {
4618
- const task = args.task;
4619
- if (!task) {
4620
- return {
4621
- content: [{
4622
- type: "text",
4623
- text: `❌ **Укажи описание задачи**\n\nПример: "доска мурадбека", "влад скрипты", "рефакторинг"`,
4624
- }],
4625
- };
4626
- }
4627
- const history = readHistory();
4628
- if (!history) {
4629
- return {
4630
- content: [{
4631
- type: "text",
4632
- text: `❌ **HISTORY.md не найден**`,
4633
- }],
4634
- };
4635
- }
4636
- // Парсим запрос
4637
- const queryPerson = detectPerson(task);
4638
- const queryKeywords = task.toLowerCase()
4639
- .split(/\s+/)
4640
- .filter(w => w.length > 2 && !["над", "для", "как", "что", "это", "мы", "работаем"].includes(w));
4641
- // Загружаем данные
4642
- const entries = parseHistoryEntries(history, 25);
4643
- const memoryIndex = loadMemoryIndex();
4644
- // Скорим контексты
4645
- const scored = entries.map(entry => ({
4646
- entry,
4647
- score: scoreContextRelevance(entry, queryPerson, queryKeywords, memoryIndex),
4648
- }));
4649
- // Топ 2-4 по score (минимум score > 0)
4650
- const top = scored
4651
- .filter(s => s.score > 0)
4652
- .sort((a, b) => b.score - a.score)
4653
- .slice(0, 4);
4654
- if (top.length === 0) {
4655
- let output = `❌ **Не найдено релевантных контекстов для:** "${task}"\n\n`;
4656
- output += `**Искали:**\n`;
4657
- if (queryPerson)
4658
- output += `- Человек: ${queryPerson}\n`;
4659
- output += `- Ключевые слова: ${queryKeywords.join(", ")}\n\n`;
4660
- output += `**Попробуй:**\n`;
4661
- output += `- Уточнить имя человека (muradbek, vlad, makhach, akhmad)\n`;
4662
- output += `- Использовать ключевые слова из названий контекстов\n`;
4663
- return { content: [{ type: "text", text: output }] };
4664
- }
4665
- // Загружаем контексты полностью
4666
- let output = formatRulesBlock();
4667
- output += `# 🎯 Фокус: "${task}"\n\n`;
4668
- output += `**Найдено:** ${top.length} релевантных контекстов\n`;
4669
- if (queryPerson)
4670
- output += `**Человек:** ${queryPerson}\n`;
4671
- output += `**Ключевые слова:** ${queryKeywords.join(", ")}\n\n`;
4672
- output += `---\n\n`;
4673
- for (const { entry, score } of top) {
4674
- output += `## #${entry.number}: ${entry.name} (score: ${score})\n\n`;
4675
- // Загружаем полный контекст
4676
- const contextPath = findContextByNumber(entry.number);
4677
- if (contextPath) {
4678
- // README.md
4679
- const readmePath = path.join(contextPath, "README.md");
4680
- if (fs.existsSync(readmePath)) {
4681
- const readme = fs.readFileSync(readmePath, "utf-8");
4682
- output += readme + "\n\n";
4683
- }
4684
- // 03-changes.md (если есть)
4685
- const changesPath = path.join(contextPath, "03-changes.md");
4686
- if (fs.existsSync(changesPath)) {
4687
- const changes = fs.readFileSync(changesPath, "utf-8");
4688
- output += `### 🔧 Изменения:\n${changes}\n\n`;
4689
- }
4690
- // Обновляем accessCount в Memory v2.0
4691
- updateContextAccess(entry.number);
4692
- }
4693
- output += `---\n\n`;
4694
- }
4695
- output += `💡 **Контексты загружены.** Теперь можешь работать над задачей!`;
4696
- return { content: [{ type: "text", text: output }] };
4697
- }
4698
4222
  default:
4699
4223
  return { content: [{ type: "text", text: `❌ Неизвестный tool: ${name}` }] };
4700
4224
  }