dedsession 3.0.3 → 4.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1534 -1988
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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: "
|
|
12
|
+
VERSION: "4.7.1",
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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(/##
|
|
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)
|
|
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
|
-
|
|
853
|
-
"
|
|
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
|
-
|
|
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 {
|
|
1098
|
-
|
|
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}
|
|
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
|
-
|
|
1268
|
-
|
|
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 += `| # | Дата | Задача |
|
|
1292
|
-
md +=
|
|
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} | ${
|
|
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})
|
|
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
|
-
|
|
1366
|
-
//
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
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
|
-
|
|
1666
|
-
fs.
|
|
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,698 @@ 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
|
+
// v4.7.0: headless — нет GUI/браузера (терминал на сервере по SSH). На Linux без DISPLAY/WAYLAND
|
|
1957
|
+
// открывать браузер некуда → не блокируемся вслепую, а сразу отдаём ссылку человеку.
|
|
1958
|
+
function isHeadless() {
|
|
1959
|
+
if (process.platform === "darwin" || process.platform === "win32")
|
|
1960
|
+
return false;
|
|
1961
|
+
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
|
1962
|
+
}
|
|
1963
|
+
// v3.4: попытка открыть ссылку в браузере пользователя (best-effort, не критично)
|
|
1964
|
+
async function tryOpenBrowser(url) {
|
|
1965
|
+
if (isHeadless())
|
|
1966
|
+
return false; // headless-сервер — открывать нечем
|
|
1967
|
+
try {
|
|
1968
|
+
const cp = await import("child_process");
|
|
1969
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1970
|
+
cp.spawn(opener, [url], { detached: true, stdio: "ignore" }).unref();
|
|
1971
|
+
return true;
|
|
1972
|
+
}
|
|
1973
|
+
catch {
|
|
1974
|
+
return false;
|
|
1975
|
+
} // нет GUI/браузера — пользователь откроет ссылку сам
|
|
1976
|
+
}
|
|
1977
|
+
function getSyncConfigPath() {
|
|
1978
|
+
return path.join(getGlobalConfigPath(), "sync.json");
|
|
1979
|
+
}
|
|
1980
|
+
function loadSyncConfig() {
|
|
1981
|
+
try {
|
|
1982
|
+
const p = getSyncConfigPath();
|
|
1983
|
+
if (fs.existsSync(p))
|
|
1984
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
1985
|
+
}
|
|
1986
|
+
catch { /* */ }
|
|
1987
|
+
return null;
|
|
1988
|
+
}
|
|
1989
|
+
function saveSyncConfig(cfg) {
|
|
1990
|
+
try {
|
|
1991
|
+
const p = getSyncConfigPath();
|
|
1992
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), "utf-8");
|
|
1993
|
+
fs.chmodSync(p, 0o600);
|
|
1994
|
+
}
|
|
1995
|
+
catch { /* */ }
|
|
1996
|
+
}
|
|
1997
|
+
// v3.5.2: атомарное обновление ТОЛЬКО переданных полей (read-merge-write).
|
|
1998
|
+
// Параллельные фоновые задачи (pull/push/revoke) пишут свою дельту, не затирая чужие поля.
|
|
1999
|
+
function updateSyncConfig(patch) {
|
|
2000
|
+
const cur = loadSyncConfig() || { panelUrl: "https://dedpanel.com", token: "" };
|
|
2001
|
+
saveSyncConfig({ ...cur, ...patch });
|
|
2002
|
+
}
|
|
2003
|
+
// v3.6.2: вызов с ретраями — повтор на сеть/таймаут/rate-limit (для массового бэкфилла, чтобы ничего не терялось).
|
|
2004
|
+
async function dedPanelCallRetry(action, params, retries = 4) {
|
|
2005
|
+
for (let i = 0; i <= retries; i++) {
|
|
2006
|
+
try {
|
|
2007
|
+
const r = await dedPanelCall(action, params);
|
|
2008
|
+
if (r && r.success)
|
|
2009
|
+
return r;
|
|
2010
|
+
// постоянные ошибки (Invalid token и т.п.) — отдаём сразу; временные (rate limit) — повтор
|
|
2011
|
+
if (r && r.error && !/rate limit|too many|retry/i.test(String(r.error)))
|
|
2012
|
+
return r;
|
|
2013
|
+
}
|
|
2014
|
+
catch { /* сеть/таймаут — повтор */ }
|
|
2015
|
+
if (i < retries)
|
|
2016
|
+
await _sleep(200 * (i + 1) + Math.floor((i + 1) * 50)); // backoff
|
|
2017
|
+
}
|
|
2018
|
+
return { success: false, error: "retry_exhausted" };
|
|
2019
|
+
}
|
|
2020
|
+
// v3.6.2: конкурентный прогон с заданной параллельностью (мощная машина → быстро).
|
|
2021
|
+
// worker НЕ должен бросать (ловим), результат по индексу; null при ошибке.
|
|
2022
|
+
async function runConcurrent(items, worker, concurrency = 24) {
|
|
2023
|
+
const results = new Array(items.length).fill(null);
|
|
2024
|
+
let idx = 0;
|
|
2025
|
+
const lane = async () => {
|
|
2026
|
+
for (;;) {
|
|
2027
|
+
const i = idx++;
|
|
2028
|
+
if (i >= items.length)
|
|
2029
|
+
break;
|
|
2030
|
+
try {
|
|
2031
|
+
results[i] = await worker(items[i], i);
|
|
2032
|
+
}
|
|
2033
|
+
catch {
|
|
2034
|
+
results[i] = null;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
};
|
|
2038
|
+
const lanes = Math.max(1, Math.min(concurrency, items.length || 1));
|
|
2039
|
+
await Promise.all(Array.from({ length: lanes }, () => lane()));
|
|
2040
|
+
return results;
|
|
2041
|
+
}
|
|
2042
|
+
// v3.2: УСИЛЕННЫЙ scrub для данных, уходящих в ОБЛАКО (строже scrubSecrets):
|
|
2043
|
+
// дополнительно режет длинные hex-токены и SCREAMING_SNAKE env-секреты (находка k05),
|
|
2044
|
+
// т.к. global-rules содержат пользовательские токены (напр. в правилах G5/G13).
|
|
2045
|
+
function scrubForSync(s) {
|
|
2046
|
+
if (!s)
|
|
2047
|
+
return s;
|
|
2048
|
+
return scrubSecrets(s)
|
|
2049
|
+
// v3.5.2: userinfo в URL (https://user:token@host) — креды в git_remote/origin
|
|
2050
|
+
.replace(/:\/\/[^/@\s]*@/g, "://")
|
|
2051
|
+
// SCREAMING_SNAKE секреты: SS_PASSWORD=, API_TOKEN=, REDIS_PASSWORD= (ключ-слово в любой позиции идентификатора)
|
|
2052
|
+
.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]")
|
|
2053
|
+
// длинные hex-токены/ключи (32+ hex) — частый формат API-токенов (вкл. токен DedPanel)
|
|
2054
|
+
.replace(/\b[0-9a-fA-F]{32,}\b/g, "[REDACTED_HEX]");
|
|
2055
|
+
}
|
|
2056
|
+
async function dedPanelCall(action, params = {}, override) {
|
|
2057
|
+
// v3.2.1: override позволяет проверить токен-КАНДИДАТ до записи в sync.json (auth_login)
|
|
2058
|
+
const cfg = override || loadSyncConfig();
|
|
2059
|
+
if (!cfg || !cfg.panelUrl || !cfg.token)
|
|
2060
|
+
throw new Error("dedpanel_not_configured");
|
|
2061
|
+
const body = new URLSearchParams({ action, ...params });
|
|
2062
|
+
const res = await fetch(cfg.panelUrl.replace(/\/+$/, "") + "/", {
|
|
2063
|
+
method: "POST",
|
|
2064
|
+
headers: { "X-API-Token": cfg.token, "Content-Type": "application/x-www-form-urlencoded" },
|
|
2065
|
+
body: body.toString(),
|
|
2066
|
+
signal: AbortSignal.timeout(8000),
|
|
2067
|
+
});
|
|
2068
|
+
return await res.json();
|
|
2069
|
+
}
|
|
2070
|
+
// v3.5: при отзыве токена сервер отдаёт {success:false,error:"Invalid API token"} → затираем токен,
|
|
2071
|
+
// следующий tool-call уйдёт в pairing (как backgroundRevokeCheck).
|
|
2072
|
+
function handleRevokeIfNeeded(r) {
|
|
2073
|
+
if (r && r.success === false && /Invalid API token/i.test(String(r.error || ""))) {
|
|
2074
|
+
updateSyncConfig({ token: "" });
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
// PUSH: выгрузка глобальной памяти в аккаунт DedPanel (с усиленным scrub)
|
|
2078
|
+
async function dedPanelSyncPush() {
|
|
2079
|
+
const pushed = [], errors = [];
|
|
2080
|
+
// v3.5.3: global-rules НЕ пушим автоматически — DedPanel источник истины правил
|
|
2081
|
+
// (иначе автопуш затирал бы правки во вкладке MCP). Правила тянутся pull'ом, локальные
|
|
2082
|
+
// изменения через tool `rules` пушатся точечно (pushGlobalRules).
|
|
2083
|
+
const items = [
|
|
2084
|
+
// v3.2.1: digest через scrubForSync (базовый scrub при публикации пропускал SCREAMING_SNAKE/hex — k05/s10)
|
|
2085
|
+
// v4.4.0 блок G/H: ПРИВАТНЫЕ проекты вырезаем из глобального дайджеста перед пушем (не утекают никуда)
|
|
2086
|
+
["global-digest", () => scrubForSync(JSON.stringify(globalDigestForPush()))],
|
|
2087
|
+
// v3.5: статистика и паттерны — «всё важное на аккаунт»
|
|
2088
|
+
["global-stats", () => scrubForSync(JSON.stringify(buildGlobalStats()))],
|
|
2089
|
+
["global-patterns", () => scrubForSync(JSON.stringify(buildPatterns()))],
|
|
2090
|
+
];
|
|
2091
|
+
for (const [key, build] of items) {
|
|
2092
|
+
try {
|
|
2093
|
+
const r = await dedPanelCall("ded_kv_set", { key, value: build() });
|
|
2094
|
+
if (r && r.success)
|
|
2095
|
+
pushed.push(key);
|
|
2096
|
+
else {
|
|
2097
|
+
errors.push(key);
|
|
2098
|
+
handleRevokeIfNeeded(r);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
catch {
|
|
2102
|
+
errors.push(key);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
return { pushed, errors };
|
|
2106
|
+
}
|
|
2107
|
+
// v4.2: МУЛЬТИ-УСТРОЙСТВО МЕРЖ ГЛОБАЛЬНЫХ ПРАВИЛ (дедуп по тексту + дата + устройство).
|
|
2108
|
+
// Нормализованный текст правила → ключ дедупа (одно правило с разных устройств = одно).
|
|
2109
|
+
function ruleHash(text) {
|
|
2110
|
+
const norm = (text || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
2111
|
+
let h = 0;
|
|
2112
|
+
for (let i = 0; i < norm.length; i++) {
|
|
2113
|
+
h = ((h << 5) - h + norm.charCodeAt(i)) | 0;
|
|
2114
|
+
}
|
|
2115
|
+
return "r" + (h >>> 0).toString(36);
|
|
2116
|
+
}
|
|
2117
|
+
let _deviceNameCache = null;
|
|
2118
|
+
function getDeviceName() {
|
|
2119
|
+
if (_deviceNameCache !== null)
|
|
2120
|
+
return _deviceNameCache;
|
|
2121
|
+
let name = "dedsession";
|
|
2122
|
+
try {
|
|
2123
|
+
name = require("os").hostname() || name;
|
|
2124
|
+
}
|
|
2125
|
+
catch { /* */ }
|
|
2126
|
+
_deviceNameCache = name.slice(0, 40);
|
|
2127
|
+
return _deviceNameCache;
|
|
2128
|
+
}
|
|
2129
|
+
// миграция: проставить hash/created-время/device правилам, где их нет
|
|
2130
|
+
function ensureRuleMeta(idx) {
|
|
2131
|
+
let changed = false;
|
|
2132
|
+
for (const r of idx.rules) {
|
|
2133
|
+
if (!r.hash) {
|
|
2134
|
+
r.hash = ruleHash(r.rule);
|
|
2135
|
+
changed = true;
|
|
2136
|
+
}
|
|
2137
|
+
if (!r.device) {
|
|
2138
|
+
r.device = getDeviceName();
|
|
2139
|
+
changed = true;
|
|
2140
|
+
}
|
|
2141
|
+
if (r.created && r.created.length <= 10) {
|
|
2142
|
+
r.created = r.created + "T00:00:00Z";
|
|
2143
|
+
changed = true;
|
|
2144
|
+
} // дата → datetime
|
|
2145
|
+
}
|
|
2146
|
+
return changed;
|
|
2147
|
+
}
|
|
2148
|
+
// Единый MERGE-синк глобальных правил: шлём локальные на сервер → сервер union по hash (last-write,
|
|
2149
|
+
// tombstone на удаление) → получаем полный union → сохраняем локально. Заменяет push/pull-overwrite.
|
|
2150
|
+
async function syncRulesMerge() {
|
|
2151
|
+
const local = loadGlobalRules();
|
|
2152
|
+
ensureRuleMeta(local);
|
|
2153
|
+
const r = await dedPanelCall("mcp_rules_merge", {
|
|
2154
|
+
rules: scrubForSync(JSON.stringify(local.rules)),
|
|
2155
|
+
device: getDeviceName(),
|
|
2156
|
+
});
|
|
2157
|
+
handleRevokeIfNeeded(r);
|
|
2158
|
+
if (r && r.success && Array.isArray(r.rules)) {
|
|
2159
|
+
saveGlobalRules({ rules: r.rules });
|
|
2160
|
+
return true;
|
|
2161
|
+
}
|
|
2162
|
+
return false;
|
|
2163
|
+
}
|
|
2164
|
+
function pushGlobalRulesInBackground() {
|
|
2165
|
+
const cfg = loadSyncConfig();
|
|
2166
|
+
if (!cfg || !cfg.token)
|
|
2167
|
+
return;
|
|
2168
|
+
void (async () => { try {
|
|
2169
|
+
await syncRulesMerge();
|
|
2170
|
+
}
|
|
2171
|
+
catch { /* оффлайн — синкнётся при следующем quick */ } })();
|
|
2172
|
+
}
|
|
2173
|
+
// PULL правил = тот же MERGE (union, не overwrite) — ничего не теряется между устройствами.
|
|
2174
|
+
async function dedPanelSyncPull() {
|
|
2175
|
+
const pulled = [], errors = [];
|
|
2176
|
+
try {
|
|
2177
|
+
if (await syncRulesMerge())
|
|
2178
|
+
pulled.push("global-rules");
|
|
2179
|
+
}
|
|
2180
|
+
catch {
|
|
2181
|
+
errors.push("global-rules");
|
|
2182
|
+
}
|
|
2183
|
+
return { pulled, errors };
|
|
2184
|
+
}
|
|
2185
|
+
// v3.5: неблокирующие обёртки авто-синка — вызываются из context_quick/context_save.
|
|
2186
|
+
// Всё в фоне (void), в try/catch — оффлайн/ошибка НЕ ломают основную работу.
|
|
2187
|
+
const AUTO_PULL_MIN_INTERVAL_MS = 5 * 60 * 1000; // не чаще раза в 5 минут
|
|
2188
|
+
function autoPullInBackground() {
|
|
2189
|
+
const cfg = loadSyncConfig();
|
|
2190
|
+
if (!cfg || !cfg.token)
|
|
2191
|
+
return;
|
|
2192
|
+
const last = cfg.lastAutoPull ? Date.parse(cfg.lastAutoPull) : 0;
|
|
2193
|
+
if (last && (Date.now() - last) < AUTO_PULL_MIN_INTERVAL_MS)
|
|
2194
|
+
return;
|
|
2195
|
+
void (async () => {
|
|
2196
|
+
try {
|
|
2197
|
+
await dedPanelSyncPull(); // заодно подтверждает токен (revoke ловится внутри)
|
|
2198
|
+
const fresh = loadSyncConfig();
|
|
2199
|
+
if (fresh && fresh.token)
|
|
2200
|
+
updateSyncConfig({ lastAutoPull: new Date().toISOString(), lastValidated: new Date().toISOString() });
|
|
2201
|
+
}
|
|
2202
|
+
catch { /* оффлайн — молча */ }
|
|
2203
|
+
})();
|
|
2204
|
+
}
|
|
2205
|
+
function autoPushInBackground() {
|
|
2206
|
+
const cfg = loadSyncConfig();
|
|
2207
|
+
if (!cfg || !cfg.token)
|
|
2208
|
+
return;
|
|
2209
|
+
if (isPrivateProject(detectProjectFromWorkDir() || path.basename(getWorkingDir())))
|
|
2210
|
+
return; // v4.4.0 блок G
|
|
2211
|
+
void (async () => {
|
|
2212
|
+
try {
|
|
2213
|
+
const r = await dedPanelSyncPush();
|
|
2214
|
+
const fresh = loadSyncConfig();
|
|
2215
|
+
if (fresh && fresh.token && r.pushed.length)
|
|
2216
|
+
updateSyncConfig({ lastAutoPush: new Date().toISOString(), lastValidated: new Date().toISOString() });
|
|
2217
|
+
}
|
|
2218
|
+
catch { /* оффлайн — молча, локаль не страдает */ }
|
|
2219
|
+
})();
|
|
2220
|
+
}
|
|
2221
|
+
// v3.5.2: локальный реестр уже-залитых контекстов (защита от дублей: повторно не грузим).
|
|
2222
|
+
// { "<project>": ["001","002",...] }. Дополняет серверный дедуп.
|
|
2223
|
+
function getSyncedRegistryPath() { return path.join(getGlobalConfigPath(), "synced-contexts.json"); }
|
|
2224
|
+
function loadSyncedRegistry() {
|
|
2225
|
+
try {
|
|
2226
|
+
const p = getSyncedRegistryPath();
|
|
2227
|
+
if (fs.existsSync(p))
|
|
2228
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
2229
|
+
}
|
|
2230
|
+
catch { /* */ }
|
|
2231
|
+
return {};
|
|
2232
|
+
}
|
|
2233
|
+
// Батч-пометка: один read-merge-write вместо N (меньше окон гонки в цикле backfill).
|
|
2234
|
+
function markContextsSynced(project, ctxIds) {
|
|
2235
|
+
if (!project || ctxIds.length === 0)
|
|
2236
|
+
return;
|
|
2237
|
+
try {
|
|
2238
|
+
const reg = loadSyncedRegistry(); // re-read перед записью (свежий, мёрж с чужими записями)
|
|
2239
|
+
const arr = reg[project] || (reg[project] = []);
|
|
2240
|
+
let changed = false;
|
|
2241
|
+
for (const id of ctxIds) {
|
|
2242
|
+
if (id && !arr.includes(id)) {
|
|
2243
|
+
arr.push(id);
|
|
2244
|
+
changed = true;
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
if (changed) {
|
|
2248
|
+
fs.writeFileSync(getSyncedRegistryPath(), JSON.stringify(reg), "utf-8");
|
|
2249
|
+
fs.chmodSync(getSyncedRegistryPath(), 0o600);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
catch { /* */ }
|
|
2253
|
+
}
|
|
2254
|
+
function markContextSynced(project, ctxId) {
|
|
2255
|
+
markContextsSynced(project, [ctxId]);
|
|
2256
|
+
}
|
|
2257
|
+
// v4.4.0 блок G: ПРИВАТНЫЕ проекты — не уходят НИКУДА (ни события, ни digest, ни бэкфилл).
|
|
2258
|
+
// Флаг хранится локально: список в ~/.dedsession/private-projects.json + маркер .dedsession-private в репо.
|
|
2259
|
+
function getPrivateProjectsPath() { return path.join(getGlobalConfigPath(), "private-projects.json"); }
|
|
2260
|
+
function loadPrivateProjects() {
|
|
2261
|
+
try {
|
|
2262
|
+
const p = getPrivateProjectsPath();
|
|
2263
|
+
if (fs.existsSync(p)) {
|
|
2264
|
+
const j = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
2265
|
+
if (Array.isArray(j))
|
|
2266
|
+
return j;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
catch { /* */ }
|
|
2270
|
+
return [];
|
|
2271
|
+
}
|
|
2272
|
+
function isPrivateProject(project) {
|
|
2273
|
+
try {
|
|
2274
|
+
const wd = getWorkingDir();
|
|
2275
|
+
if (fs.existsSync(path.join(wd, ".dedsession-private")))
|
|
2276
|
+
return true; // маркер в репо
|
|
2277
|
+
}
|
|
2278
|
+
catch { /* */ }
|
|
2279
|
+
if (!project)
|
|
2280
|
+
return false;
|
|
2281
|
+
return loadPrivateProjects().includes(project);
|
|
2282
|
+
}
|
|
2283
|
+
// v4.4.0 блок G/H: глобальный дайджест БЕЗ приватных проектов — то, что реально уходит на сервер.
|
|
2284
|
+
function globalDigestForPush() {
|
|
2285
|
+
const gd = loadGlobalDigest();
|
|
2286
|
+
try {
|
|
2287
|
+
const priv = new Set(loadPrivateProjects());
|
|
2288
|
+
if (priv.size && gd.projects) {
|
|
2289
|
+
for (const name of Object.keys(gd.projects))
|
|
2290
|
+
if (priv.has(name))
|
|
2291
|
+
delete gd.projects[name];
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
catch { /* */ }
|
|
2295
|
+
return gd;
|
|
2296
|
+
}
|
|
2297
|
+
function markProjectPrivate(project) {
|
|
2298
|
+
try {
|
|
2299
|
+
const list = loadPrivateProjects();
|
|
2300
|
+
if (project && !list.includes(project)) {
|
|
2301
|
+
list.push(project);
|
|
2302
|
+
fs.writeFileSync(getPrivateProjectsPath(), JSON.stringify(list, null, 2), "utf-8");
|
|
2303
|
+
fs.chmodSync(getPrivateProjectsPath(), 0o600);
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
catch { /* */ }
|
|
2307
|
+
try {
|
|
2308
|
+
const wd = getWorkingDir();
|
|
2309
|
+
fs.writeFileSync(path.join(wd, ".dedsession-private"), "Приватный проект dedsession.\nКонтексты этого проекта НЕ синхронизируются на сервер — только локально.\nУдали этот файл и проект снова станет синхронизируемым.\n", "utf-8");
|
|
2310
|
+
}
|
|
2311
|
+
catch { /* */ }
|
|
2312
|
+
}
|
|
2313
|
+
// v3.5.2: догон недостающих контекстов в стату при context_quick — идемпотентно (через git, как легаси).
|
|
2314
|
+
// Шлёт только те, которых НЕТ в реестре; серверный дедуп страхует от повторов. Если всё залито — ничего.
|
|
2315
|
+
let _bgBackfillInflight = false; // не запускаем несколько волн при частых quick
|
|
2316
|
+
function autoBackfillMissingInBackground() {
|
|
2317
|
+
if (_bgBackfillInflight)
|
|
2318
|
+
return;
|
|
2319
|
+
const cfg = loadSyncConfig();
|
|
2320
|
+
if (!cfg || !cfg.token)
|
|
2321
|
+
return;
|
|
2322
|
+
if (isPrivateProject(detectProjectFromWorkDir() || path.basename(getWorkingDir())))
|
|
2323
|
+
return; // v4.4.0 блок G
|
|
2324
|
+
_bgBackfillInflight = true;
|
|
2325
|
+
void (async () => {
|
|
2326
|
+
try {
|
|
2327
|
+
const workDir = getWorkingDir();
|
|
2328
|
+
const project = detectProjectFromWorkDir() || path.basename(workDir);
|
|
2329
|
+
const reg = loadSyncedRegistry();
|
|
2330
|
+
const done = new Set(reg[project] || []);
|
|
2331
|
+
const missing = listContexts().filter(c => !done.has(contextIdOf(c)));
|
|
2332
|
+
if (missing.length === 0)
|
|
2333
|
+
return; // всё уже залито — забить
|
|
2334
|
+
const account = String(cfg.username || cfg.uid || "");
|
|
2335
|
+
const gitRemote = scrubForSync(readGitRemoteUrl(workDir) || "");
|
|
2336
|
+
const gitUserCfg = String(readGitUser(workDir) || "").slice(0, 80);
|
|
2337
|
+
// v3.6.2: параллельно (16 lanes — фон при quick) + ретраи
|
|
2338
|
+
const res = await runConcurrent(missing, async (c) => {
|
|
2339
|
+
const status = "saved"; // v4.4.0 блок J: понятие «выполнено/застряло» убрано — событие = просто сохранение
|
|
2340
|
+
const ts = c.date ? Math.floor(new Date(c.date).getTime() / 1000) : Math.floor(Date.now() / 1000);
|
|
2341
|
+
const gitUser = ((await readGitAuthorForDate(workDir, c.date)) || gitUserCfg).slice(0, 80);
|
|
2342
|
+
const r = await dedPanelCallRetry("ded_event", {
|
|
2343
|
+
project, ctx_id: contextIdOf(c), status, size: String(c.filesCount || 0),
|
|
2344
|
+
topic: scrubForSync(c.title || "").slice(0, 200), tags: "", solutions_count: "0",
|
|
2345
|
+
ts: String(isNaN(ts) ? Math.floor(Date.now() / 1000) : ts),
|
|
2346
|
+
account, git_remote: gitRemote, git_user: gitUser, legacy: "1",
|
|
2347
|
+
});
|
|
2348
|
+
if (r && !r.success)
|
|
2349
|
+
handleRevokeIfNeeded(r);
|
|
2350
|
+
return { ok: !!(r && r.success), id: contextIdOf(c) };
|
|
2351
|
+
}, 16);
|
|
2352
|
+
markContextsSynced(project, res.filter(x => x && x.ok).map(x => x.id)); // один write в конце (batch)
|
|
2353
|
+
}
|
|
2354
|
+
catch { /* не критично */ }
|
|
2355
|
+
finally {
|
|
2356
|
+
_bgBackfillInflight = false;
|
|
2357
|
+
}
|
|
2358
|
+
})();
|
|
2359
|
+
}
|
|
2360
|
+
// v3.5: append-событие в лог аккаунта («схема логов»). Только метаполя, scrubForSync на topic.
|
|
2361
|
+
function logEventInBackground(ev) {
|
|
2362
|
+
const cfg = loadSyncConfig();
|
|
2363
|
+
if (!cfg || !cfg.token)
|
|
2364
|
+
return;
|
|
2365
|
+
if (isPrivateProject(String(ev.project ?? "")))
|
|
2366
|
+
return; // v4.4.0 блок G: приватный проект не логируем на сервер
|
|
2367
|
+
void (async () => {
|
|
2368
|
+
try {
|
|
2369
|
+
// v3.5.1: привязка «кто/где» — аккаунт DedPanel + git-репо + git-автор
|
|
2370
|
+
const workDir = getWorkingDir();
|
|
2371
|
+
const params = {
|
|
2372
|
+
project: String(ev.project ?? ""),
|
|
2373
|
+
ctx_id: String(ev.ctxId ?? ""),
|
|
2374
|
+
status: "saved", // v4.4.0 блок J: статусы убраны — всегда «saved»
|
|
2375
|
+
size: String(ev.size ?? 0),
|
|
2376
|
+
topic: scrubForSync(String(ev.topic ?? "")).slice(0, 200),
|
|
2377
|
+
tags: Array.isArray(ev.tags) ? ev.tags.slice(0, 5).join(",") : "",
|
|
2378
|
+
solutions_count: String(ev.solutionsCount ?? 0),
|
|
2379
|
+
ts: String(ev.ts ? Number(ev.ts) : Math.floor(Date.now() / 1000)),
|
|
2380
|
+
account: String(cfg.username || cfg.uid || ""),
|
|
2381
|
+
git_remote: scrubForSync(readGitRemoteUrl(workDir) || ""),
|
|
2382
|
+
git_user: String(readGitUser(workDir) || "").slice(0, 80),
|
|
2383
|
+
legacy: ev.legacy ? "1" : "0",
|
|
2384
|
+
};
|
|
2385
|
+
const r = await dedPanelCall("ded_event", params);
|
|
2386
|
+
handleRevokeIfNeeded(r);
|
|
2387
|
+
// помечаем контекст залитым (защита от повторной заливки при quick-догоне)
|
|
2388
|
+
if (r && r.success && ev.ctxId)
|
|
2389
|
+
markContextSynced(params.project, String(ev.ctxId));
|
|
2390
|
+
}
|
|
2391
|
+
catch { /* оффлайн — событие пропускаем, не критично */ }
|
|
2392
|
+
})();
|
|
2393
|
+
}
|
|
2394
|
+
// v4.5.0: статистика git-коммитов по авторам текущего репо → DedPanel (собирается dedsession).
|
|
2395
|
+
// git log --all --no-merges --format=%an, считаем по авторам. Приватные проекты не шлём. Кэш-троттл.
|
|
2396
|
+
let _commitStatsSentAt = 0;
|
|
2397
|
+
function collectCommitStatsInBackground() {
|
|
2398
|
+
const cfg = loadSyncConfig();
|
|
2399
|
+
if (!cfg || !cfg.token)
|
|
2400
|
+
return;
|
|
2401
|
+
if (Date.now() - _commitStatsSentAt < 10 * 60 * 1000)
|
|
2402
|
+
return; // не чаще раза в 10 мин
|
|
2403
|
+
const project = detectProjectFromWorkDir() || path.basename(getWorkingDir());
|
|
2404
|
+
if (isPrivateProject(project))
|
|
2405
|
+
return; // v4.4.0 блок G: приватный проект — не шлём
|
|
2406
|
+
_commitStatsSentAt = Date.now();
|
|
2407
|
+
void (async () => {
|
|
2408
|
+
try {
|
|
2409
|
+
const workDir = getWorkingDir();
|
|
2410
|
+
const authors = await new Promise((resolve) => {
|
|
2411
|
+
execFile("git", ["-C", workDir, "log", "--all", "--no-merges", "--format=%an"], { encoding: "utf-8", timeout: 8000, maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
|
|
2412
|
+
const acc = {};
|
|
2413
|
+
if (!err && stdout)
|
|
2414
|
+
for (const line of stdout.split("\n")) {
|
|
2415
|
+
const a = line.trim().slice(0, 80);
|
|
2416
|
+
if (a)
|
|
2417
|
+
acc[a] = (acc[a] || 0) + 1;
|
|
2418
|
+
}
|
|
2419
|
+
resolve(acc);
|
|
2420
|
+
});
|
|
2421
|
+
});
|
|
2422
|
+
if (Object.keys(authors).length === 0)
|
|
2423
|
+
return;
|
|
2424
|
+
const r = await dedPanelCall("mcp_commit_stats", { op: "set", project, stats: JSON.stringify(authors) });
|
|
2425
|
+
handleRevokeIfNeeded(r);
|
|
2426
|
+
}
|
|
2427
|
+
catch { /* оффлайн/нет git — не критично */ }
|
|
2428
|
+
})();
|
|
2429
|
+
}
|
|
1711
2430
|
/**
|
|
1712
2431
|
* Форматирует блок правил (глобальные + локальные)
|
|
1713
2432
|
*/
|
|
1714
2433
|
function formatRulesBlock() {
|
|
1715
2434
|
const globalRules = loadGlobalRules();
|
|
1716
2435
|
const localRules = loadRules();
|
|
1717
|
-
|
|
2436
|
+
// v4.4.0 блок D: считаем активные (без tombstone) глобальные + локальные
|
|
2437
|
+
const activeGlobal = globalRules.rules.filter(r => !r.deleted);
|
|
2438
|
+
const activeLocal = localRules.rules.filter(r => !r.deleted);
|
|
2439
|
+
if (activeGlobal.length === 0 && activeLocal.length === 0)
|
|
1718
2440
|
return "";
|
|
1719
2441
|
let output = "# 🔴 ПРИОРИТЕТНЫЕ ПРАВИЛА\n\n";
|
|
1720
|
-
output +=
|
|
1721
|
-
// Глобальные правила
|
|
1722
|
-
if (
|
|
2442
|
+
output += `> Загружено правил: **${activeGlobal.length}** глобальных + **${activeLocal.length}** локальных. Claude ОБЯЗАН соблюдать их во ВСЕХ контекстах!\n\n`;
|
|
2443
|
+
// Глобальные правила (v4.2: tombstone-удалённые скрываем)
|
|
2444
|
+
if (activeGlobal.length > 0) {
|
|
1723
2445
|
output += "**🌍 Глобальные (все проекты):**\n";
|
|
1724
|
-
for (const rule of
|
|
1725
|
-
|
|
2446
|
+
for (const rule of activeGlobal) {
|
|
2447
|
+
const head = rule.title ? `**${rule.title}** — ` : ""; // v4.4.0 блок C: заголовок впереди
|
|
2448
|
+
output += `- **G${rule.id}.** ${head}${rule.rule}`;
|
|
1726
2449
|
if (rule.context)
|
|
1727
2450
|
output += ` _(${rule.context})_`;
|
|
1728
2451
|
output += "\n";
|
|
@@ -1730,9 +2453,9 @@ function formatRulesBlock() {
|
|
|
1730
2453
|
output += "\n";
|
|
1731
2454
|
}
|
|
1732
2455
|
// Локальные правила
|
|
1733
|
-
if (
|
|
2456
|
+
if (activeLocal.length > 0) {
|
|
1734
2457
|
output += "**📁 Локальные (этот проект):**\n";
|
|
1735
|
-
for (const rule of
|
|
2458
|
+
for (const rule of activeLocal) {
|
|
1736
2459
|
output += `- **L${rule.id}.** ${rule.rule}`;
|
|
1737
2460
|
if (rule.context)
|
|
1738
2461
|
output += ` _(${rule.context})_`;
|
|
@@ -1778,91 +2501,8 @@ function detectProject(content, folderName) {
|
|
|
1778
2501
|
return bestMatch?.project || null;
|
|
1779
2502
|
}
|
|
1780
2503
|
// Определяет человека по содержимому (для 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
2504
|
// Парсит 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
2505
|
// Вычисляет релевантность контекста к запросу (для 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
2506
|
function extractKeywords(content) {
|
|
1867
2507
|
const keywords = new Set();
|
|
1868
2508
|
// Извлекаем технологии и инструменты
|
|
@@ -1913,14 +2553,17 @@ function calculateTemperature(lastAccess, accessCount) {
|
|
|
1913
2553
|
return "archive";
|
|
1914
2554
|
}
|
|
1915
2555
|
function detectEpicFromContext(contextName, readme, existingEpics) {
|
|
1916
|
-
// Проверяем есть ли parent в readme
|
|
1917
|
-
const parentMatch = readme.match(/\*\*Parent:\*\*\s*(
|
|
2556
|
+
// Проверяем есть ли parent в readme (v3.1.1: полное значение, не только \d+ — иначе legacy-родитель рвался)
|
|
2557
|
+
const parentMatch = readme.match(/\*\*Parent:\*\*\s*(.+)/);
|
|
1918
2558
|
if (parentMatch) {
|
|
1919
|
-
const parentId = parentMatch[1];
|
|
1920
|
-
//
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
2559
|
+
const parentId = normalizeParentRef(parentMatch[1]) || "";
|
|
2560
|
+
// v3.1.1: точное сравнение id, не includes (иначе "001" ложно матчил формат-B id и "100"⊂"1001",
|
|
2561
|
+
// а пустой parentId матчил любой эпик). Пустой parentId — пропускаем.
|
|
2562
|
+
if (parentId) {
|
|
2563
|
+
for (const [epicName, epic] of Object.entries(existingEpics.epics)) {
|
|
2564
|
+
if (epic.chain.some(id => id === parentId)) {
|
|
2565
|
+
return epicName;
|
|
2566
|
+
}
|
|
1924
2567
|
}
|
|
1925
2568
|
}
|
|
1926
2569
|
}
|
|
@@ -1950,6 +2593,12 @@ function migrateToMemorySystem() {
|
|
|
1950
2593
|
const projectsIndex = loadProjects();
|
|
1951
2594
|
const keywordsIndex = loadKeywords();
|
|
1952
2595
|
const epicsIndex = loadEpics();
|
|
2596
|
+
// v3.1.1: reindex — полный REBUILD из диска (а не merge). Иначе: (1) старые мусорные
|
|
2597
|
+
// keyword-ключи проектов оставались; (2) memoryIndex.contexts копил orphans удалённых папок
|
|
2598
|
+
// и держал дубль под двумя ключами при смене порядка обхода. Диск = источник истины.
|
|
2599
|
+
projectsIndex.projects = {};
|
|
2600
|
+
memoryIndex.contexts = {};
|
|
2601
|
+
keywordsIndex.keywords = {};
|
|
1953
2602
|
// Проходим по всем контекстам
|
|
1954
2603
|
for (const ctx of contexts) {
|
|
1955
2604
|
try {
|
|
@@ -1979,8 +2628,9 @@ function migrateToMemorySystem() {
|
|
|
1979
2628
|
}
|
|
1980
2629
|
const fullContent = readme + "\n" + changes;
|
|
1981
2630
|
const solutionsContent = problemsSolutions + "\n" + readme + "\n" + changes + "\n" + sessionLog;
|
|
1982
|
-
//
|
|
1983
|
-
|
|
2631
|
+
// v3.1.1: проект = репозиторий (один репо = один project), а не контент-паттерн.
|
|
2632
|
+
// Раньше detectProject(content) разбрасывал контексты бэкенда по dedsession/web/scripts.
|
|
2633
|
+
const project = detectProjectFromWorkDir() || detectProject(fullContent, ctx.name);
|
|
1984
2634
|
// Извлекаем ключевые слова
|
|
1985
2635
|
const keywords = extractKeywords(fullContent);
|
|
1986
2636
|
// Определяем эпик
|
|
@@ -1991,7 +2641,7 @@ function migrateToMemorySystem() {
|
|
|
1991
2641
|
: [];
|
|
1992
2642
|
// Создаём мету контекста
|
|
1993
2643
|
const contextMeta = {
|
|
1994
|
-
id:
|
|
2644
|
+
id: contextIdOf(ctx), // v3.1 LEGACY-FIX: не схлопывать формат B в "000"
|
|
1995
2645
|
folder: ctx.name,
|
|
1996
2646
|
name: ctx.title,
|
|
1997
2647
|
date: ctx.date,
|
|
@@ -2012,14 +2662,19 @@ function migrateToMemorySystem() {
|
|
|
2012
2662
|
files,
|
|
2013
2663
|
size: ctx.size,
|
|
2014
2664
|
};
|
|
2015
|
-
// Извлекаем parent из readme
|
|
2016
|
-
const parentMatch = readme.match(/\*\*Parent:\*\*\s*(
|
|
2665
|
+
// Извлекаем parent из readme (v3.1.1: полное значение → normalizeParentRef, чтобы legacy-формат B резолвился)
|
|
2666
|
+
const parentMatch = readme.match(/\*\*Parent:\*\*\s*(.+)/);
|
|
2017
2667
|
if (parentMatch) {
|
|
2018
|
-
contextMeta.relations.parent = parentMatch[1]
|
|
2668
|
+
contextMeta.relations.parent = normalizeParentRef(parentMatch[1]);
|
|
2669
|
+
}
|
|
2670
|
+
// v3.1.1 ДУБЛИ ФОРМАТА A: если этот id уже занят ДРУГИМ контекстом (два #076, два #095)
|
|
2671
|
+
// — ключуем по имени папки (уникально на диске), иначе теряли до 33 контекстов (dedai-backend).
|
|
2672
|
+
if (memoryIndex.contexts[contextMeta.id] && memoryIndex.contexts[contextMeta.id].folder !== ctx.name) {
|
|
2673
|
+
contextMeta.id = ctx.name;
|
|
2019
2674
|
}
|
|
2020
2675
|
// Сохраняем в индекс
|
|
2021
2676
|
memoryIndex.contexts[contextMeta.id] = contextMeta;
|
|
2022
|
-
// Обновляем проекты
|
|
2677
|
+
// Обновляем проекты (идемпотентно: без дублей при повторном reindex)
|
|
2023
2678
|
if (project) {
|
|
2024
2679
|
if (!projectsIndex.projects[project]) {
|
|
2025
2680
|
projectsIndex.projects[project] = {
|
|
@@ -2030,9 +2685,11 @@ function migrateToMemorySystem() {
|
|
|
2030
2685
|
hotContexts: [],
|
|
2031
2686
|
};
|
|
2032
2687
|
}
|
|
2033
|
-
projectsIndex.projects[project].contexts.
|
|
2034
|
-
|
|
2035
|
-
|
|
2688
|
+
if (!projectsIndex.projects[project].contexts.includes(contextMeta.id)) {
|
|
2689
|
+
projectsIndex.projects[project].contexts.push(contextMeta.id);
|
|
2690
|
+
projectsIndex.projects[project].count = projectsIndex.projects[project].contexts.length;
|
|
2691
|
+
}
|
|
2692
|
+
if (contextMeta.temperature === "hot" && !projectsIndex.projects[project].hotContexts.includes(contextMeta.id)) {
|
|
2036
2693
|
projectsIndex.projects[project].hotContexts.push(contextMeta.id);
|
|
2037
2694
|
}
|
|
2038
2695
|
if (ctx.date > projectsIndex.projects[project].lastActive) {
|
|
@@ -2105,65 +2762,6 @@ function updateContextAccess(contextId) {
|
|
|
2105
2762
|
}
|
|
2106
2763
|
}
|
|
2107
2764
|
}
|
|
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
2765
|
function findSimilarContexts(contextId) {
|
|
2168
2766
|
const memoryIndex = loadMemoryIndex();
|
|
2169
2767
|
const ctx = memoryIndex.contexts[contextId];
|
|
@@ -2196,19 +2794,6 @@ function findSimilarContexts(contextId) {
|
|
|
2196
2794
|
.map(([id]) => memoryIndex.contexts[id])
|
|
2197
2795
|
.filter((ctx) => ctx !== undefined);
|
|
2198
2796
|
}
|
|
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
2797
|
function getMemoryStats() {
|
|
2213
2798
|
const memoryIndex = loadMemoryIndex();
|
|
2214
2799
|
const projectsIndex = loadProjects();
|
|
@@ -2231,17 +2816,77 @@ function getMemoryStats() {
|
|
|
2231
2816
|
solutionsCount: solutionsIndex.solutions.length,
|
|
2232
2817
|
};
|
|
2233
2818
|
}
|
|
2819
|
+
// v3.5: расширенная статистика «сколько работаю» — для хранения на аккаунте и дашборда.
|
|
2820
|
+
// Только числа/имена проектов (всё равно прогоняется через scrubForSync при пуше).
|
|
2821
|
+
function buildGlobalStats() {
|
|
2822
|
+
const ctx = listContexts(); // { date, status, ... }
|
|
2823
|
+
const byDay = {};
|
|
2824
|
+
for (const c of ctx) {
|
|
2825
|
+
if (c.date)
|
|
2826
|
+
byDay[c.date] = (byDay[c.date] || 0) + 1;
|
|
2827
|
+
}
|
|
2828
|
+
const days = Object.keys(byDay).sort();
|
|
2829
|
+
const now = Date.now();
|
|
2830
|
+
const weekAgo = now - 7 * 24 * 60 * 60 * 1000;
|
|
2831
|
+
const perWeek = ctx.filter(c => c.date && new Date(c.date).getTime() >= weekAgo).length;
|
|
2832
|
+
let peakDay = { date: "", count: 0 };
|
|
2833
|
+
for (const [d, n] of Object.entries(byDay))
|
|
2834
|
+
if (n > peakDay.count)
|
|
2835
|
+
peakDay = { date: d, count: n };
|
|
2836
|
+
const mem = getMemoryStats();
|
|
2837
|
+
return {
|
|
2838
|
+
schemaVersion: 1,
|
|
2839
|
+
generated: new Date().toISOString(),
|
|
2840
|
+
totals: getStatistics(),
|
|
2841
|
+
memory: mem,
|
|
2842
|
+
velocity: {
|
|
2843
|
+
perWeek,
|
|
2844
|
+
avgPerActiveDay: Math.round((ctx.length / Math.max(1, days.length)) * 100) / 100,
|
|
2845
|
+
activeDays: days.length,
|
|
2846
|
+
},
|
|
2847
|
+
peakDay,
|
|
2848
|
+
distributionByProject: mem.projects,
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
// v3.5: паттерны решений — частота проблем по ключевым словам, top повторяющихся решений.
|
|
2852
|
+
function buildPatterns() {
|
|
2853
|
+
const sols = loadSolutions().solutions;
|
|
2854
|
+
const wordFreq = {};
|
|
2855
|
+
const stop = new Set(["после", "через", "когда", "чтобы", "которые", "этого", "больше", "нужно", "можно", "будет", "this", "that", "with", "from", "then", "null", "true", "false", "которое"]);
|
|
2856
|
+
for (const s of sols) {
|
|
2857
|
+
const words = (s.problem || "").toLowerCase().match(/[a-zа-яё0-9_]{4,}/gi) || [];
|
|
2858
|
+
for (const w of words) {
|
|
2859
|
+
if (stop.has(w))
|
|
2860
|
+
continue;
|
|
2861
|
+
wordFreq[w] = (wordFreq[w] || 0) + 1;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
const topProblems = Object.entries(wordFreq).sort((a, b) => b[1] - a[1]).slice(0, 25).map(([word, count]) => ({ word, count }));
|
|
2865
|
+
const topSolutions = sols
|
|
2866
|
+
.slice()
|
|
2867
|
+
.sort((a, b) => (b.sourceContexts?.length || 0) - (a.sourceContexts?.length || 0))
|
|
2868
|
+
.slice(0, 15)
|
|
2869
|
+
.map(s => ({ problem: (s.problem || "").slice(0, 120), uses: s.sourceContexts?.length || 0 }));
|
|
2870
|
+
return {
|
|
2871
|
+
schemaVersion: 1,
|
|
2872
|
+
generated: new Date().toISOString(),
|
|
2873
|
+
totalSolutions: sols.length,
|
|
2874
|
+
topProblems,
|
|
2875
|
+
topSolutions,
|
|
2876
|
+
};
|
|
2877
|
+
}
|
|
2234
2878
|
/**
|
|
2235
2879
|
* Добавляет новый контекст в Memory System v2.0
|
|
2236
2880
|
* Вызывается автоматически при context_save
|
|
2237
2881
|
*/
|
|
2238
|
-
function addContextToMemory(contextNumber, folderName, taskName, status, content, date)
|
|
2882
|
+
function addContextToMemory(contextNumber, folderName, taskName, status, content, date, parentRef // v3.1.2 FIX H: явный родитель (state.loadedContext к этому моменту уже перезаписан)
|
|
2883
|
+
) {
|
|
2239
2884
|
try {
|
|
2240
2885
|
const memoryIndex = loadMemoryIndex();
|
|
2241
2886
|
const projectsIndex = loadProjects();
|
|
2242
2887
|
const keywordsIndex = loadKeywords();
|
|
2243
|
-
//
|
|
2244
|
-
const project = detectProject(content, folderName);
|
|
2888
|
+
// v3.1.1: проект = репозиторий (один репо = один project), а не контент-паттерн.
|
|
2889
|
+
const project = detectProjectFromWorkDir() || detectProject(content, folderName);
|
|
2245
2890
|
// Извлекаем ключевые слова
|
|
2246
2891
|
const keywords = extractKeywords(content);
|
|
2247
2892
|
const contextId = String(contextNumber).padStart(3, "0");
|
|
@@ -2259,7 +2904,9 @@ function addContextToMemory(contextNumber, folderName, taskName, status, content
|
|
|
2259
2904
|
accessCount: 1,
|
|
2260
2905
|
lastAccess: date,
|
|
2261
2906
|
relations: {
|
|
2262
|
-
|
|
2907
|
+
// v3.1.2 FIX H: ТОЛЬКО явный parentRef (из result, вычислен до перезаписи state).
|
|
2908
|
+
// Fallback на state.loadedContext убран — к этому моменту он уже = только что созданный контекст (self).
|
|
2909
|
+
parent: parentRef ? normalizeParentRef(parentRef) : null,
|
|
2263
2910
|
children: [],
|
|
2264
2911
|
similar: [],
|
|
2265
2912
|
epicPrev: null,
|
|
@@ -2352,19 +2999,30 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
2352
2999
|
return { contents: [{ uri, mimeType: "text/markdown", text: content }] };
|
|
2353
3000
|
});
|
|
2354
3001
|
// TOOLS
|
|
2355
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
2356
|
-
|
|
3002
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3003
|
+
// v3.7: ПУБЛИЧНО показываем только ядро — вся остальная логика (миграции, дайджест, синк,
|
|
3004
|
+
// глобал, поиск-дозагрузка, fallback-start) зашита в context_quick/context_save/context_load.
|
|
3005
|
+
// Минимум команд без перегруза. Остальные обработчики остаются внутренними.
|
|
3006
|
+
const _PUBLIC = new Set(["context_quick", "context_save", "context_load", "context_attach", "context_private", "rules", "auth_pair", "auth_status", "auth_logout"]);
|
|
3007
|
+
const _allTools = [
|
|
2357
3008
|
{
|
|
2358
|
-
name: "
|
|
2359
|
-
description: "
|
|
2360
|
-
inputSchema: {
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
3009
|
+
name: "auth_pair",
|
|
3010
|
+
description: "🔐 Авторизация dedsession через БРАУЗЕР (как OAuth у Claude): генерирует ссылку, открывает браузер, ты жмёшь «Подтвердить» на странице DedPanel — устройство привязывается само. Вызывай когда пользователь говорит: 'авторизуйся', 'привяжи устройство', 'войти через браузер', 'pair', 'подключи dedpanel'. Повторный вызов поллит подтверждение.",
|
|
3011
|
+
inputSchema: { type: "object", properties: {} },
|
|
3012
|
+
},
|
|
3013
|
+
{
|
|
3014
|
+
name: "auth_status",
|
|
3015
|
+
description: "🔐 Показать статус привязки к аккаунту DedPanel (кто авторизован, жив ли токен). Вызывай когда пользователь говорит: 'статус авторизации', 'auth status', 'кто я', 'привязан ли аккаунт'.",
|
|
3016
|
+
inputSchema: { type: "object", properties: {} },
|
|
3017
|
+
},
|
|
3018
|
+
{
|
|
3019
|
+
name: "auth_logout",
|
|
3020
|
+
description: "🔐 Отвязать аккаунт DedPanel (удалить локальный токен). Вызывай когда пользователь говорит: 'выйди', 'отвяжи аккаунт', 'auth logout', 'разлогинься'.",
|
|
3021
|
+
inputSchema: { type: "object", properties: {} },
|
|
2364
3022
|
},
|
|
2365
3023
|
{
|
|
2366
3024
|
name: "context_save",
|
|
2367
|
-
description: "💾
|
|
3025
|
+
description: "💾 ЕДИНЫЙ УМНЫЙ РЕЖИМ сохранения контекста (v3.1). Один режим на всё — НЕ нужно выбирать 'подробно/кратко'. Заполняй ЛЮБЫЕ поля, какие есть (можно много, можно мало) — система сохранит ТОЛЬКО непустые секции (пустые не создаются, мусор не копится) и извлечёт максимум пользы для будущего. Чтобы извлечь максимум — старайся заполнять problems_solutions (с Симптом/Root cause/Решение/Профилактика), decisions, key changes. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'сохрани контекст', 'сохрани работу', 'сохранить', 'сохрани', 'запомни работу', 'сохрани подробно', 'детальное сохранение'. Это ЕДИНСТВЕННАЯ команда сохранения — context_save_detailed устарел и идентичен этой.",
|
|
2368
3026
|
inputSchema: {
|
|
2369
3027
|
type: "object",
|
|
2370
3028
|
properties: {
|
|
@@ -2372,11 +3030,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2372
3030
|
type: "string",
|
|
2373
3031
|
description: "Краткое название задачи (станет частью имени папки)",
|
|
2374
3032
|
},
|
|
2375
|
-
status: {
|
|
2376
|
-
type: "string",
|
|
2377
|
-
enum: ["completed", "in_progress", "blocked"],
|
|
2378
|
-
description: "Статус задачи: completed (✅), in_progress (⏳), blocked (❌)",
|
|
2379
|
-
},
|
|
2380
3033
|
task_overview: {
|
|
2381
3034
|
type: "string",
|
|
2382
3035
|
description: "Описание задачи: что делали, какая была цель",
|
|
@@ -2449,139 +3102,30 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2449
3102
|
required: ["task_name", "changes", "summary", "session_log"],
|
|
2450
3103
|
},
|
|
2451
3104
|
},
|
|
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
3105
|
{
|
|
2540
3106
|
name: "context_load",
|
|
2541
|
-
description: "📂
|
|
3107
|
+
description: "📂 Умная ДОЗАГРУЗКА КОНКРЕТНОГО/СВЯЗАННОГО контекста по запросу: номер ('001', '42'), имя папки, или тема/ключевое слово (найдёт и загрузит релевантное). Вызывай когда пользователь говорит: 'загрузи контекст 42', 'покажи контекст про auth', 'найди контекст где делали X', 'открой контекст по теме Y'. Без запроса — список последних для выбора.",
|
|
2542
3108
|
inputSchema: {
|
|
2543
3109
|
type: "object",
|
|
2544
3110
|
properties: {
|
|
2545
3111
|
query: {
|
|
2546
3112
|
type: "string",
|
|
2547
|
-
description: "Номер контекста (1, 2, 001), имя папки или поисковый
|
|
3113
|
+
description: "Номер контекста (1, 2, 001), имя папки или поисковый запрос по теме. Если пусто - показать список.",
|
|
2548
3114
|
},
|
|
2549
3115
|
},
|
|
2550
3116
|
},
|
|
2551
3117
|
},
|
|
2552
3118
|
{
|
|
2553
|
-
name: "
|
|
2554
|
-
description: "
|
|
3119
|
+
name: "context_attach",
|
|
3120
|
+
description: "🔗 МУЛЬТИ-ПРОЕКТ: подключить ДРУГОЙ репозиторий и прочитать его контекст, чтобы работать с двумя проектами одновременно. Вызывай когда пользователь говорит: 'посмотри этот проект <путь>', 'прочитай контекст проекта <имя>', 'подключи проект', 'работаем в двух проектах', 'объедини проект A и B', 'attach'. Грузит дайджест+последние контексты указанного репо. Пока проект присоединён, context_save дублирует сохранение и туда (работа в обоих). Без аргумента — показать присоединённые / отсоединить.",
|
|
2555
3121
|
inputSchema: {
|
|
2556
3122
|
type: "object",
|
|
2557
3123
|
properties: {
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
description: "Максимальное количество (по умолчанию 10)",
|
|
2561
|
-
},
|
|
2562
|
-
search: {
|
|
2563
|
-
type: "string",
|
|
2564
|
-
description: "Поиск по ключевому слову",
|
|
2565
|
-
},
|
|
3124
|
+
project: { type: "string", description: "Путь к репозиторию (/home/.../repo) или имя проекта из карты (git-origin). Пусто — список присоединённых." },
|
|
3125
|
+
detach: { type: "boolean", description: "true — отсоединить указанный проект (или все, если project пуст)." },
|
|
2566
3126
|
},
|
|
2567
3127
|
},
|
|
2568
3128
|
},
|
|
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
3129
|
{
|
|
2586
3130
|
name: "context_quick",
|
|
2587
3131
|
description: "⚡ ПЕРВЫЙ ПРИОРИТЕТ! Быстрая загрузка последних контекстов. ОБЯЗАТЕЛЬНО вызывай СРАЗУ когда пользователь говорит: 'быстрый контекст', 'краткий контекст', 'кратко контекст', 'quick context', 'контекст быстро', 'загрузи быстро'. НЕ используй другие инструменты — ТОЛЬКО context_quick! Читает ВСЕ файлы последних контекстов.",
|
|
@@ -2591,143 +3135,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2591
3135
|
},
|
|
2592
3136
|
},
|
|
2593
3137
|
{
|
|
2594
|
-
name: "
|
|
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.",
|
|
3138
|
+
name: "context_private",
|
|
3139
|
+
description: "🔒 Сделать ТЕКУЩИЙ проект ПРИВАТНЫМ. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'сделай проект приватным', 'приватный проект', 'context private', 'не синхронизируй этот проект', 'убери проект с сервера'. После этого контексты проекта НЕ уходят никуда (только локально), и ВСЁ связанное с ним удаляется с сервера DedPanel. Чтобы вернуть синхронизацию — удалить файл .dedsession-private в корне проекта.",
|
|
2705
3140
|
inputSchema: {
|
|
2706
3141
|
type: "object",
|
|
2707
3142
|
properties: {
|
|
2708
|
-
|
|
2709
|
-
type: "string",
|
|
2710
|
-
description: "Поиск по проблеме (опционально)",
|
|
2711
|
-
},
|
|
3143
|
+
confirm: { type: "boolean", description: "Подтверждение (по умолчанию true). Удаление данных проекта на сервере необратимо." },
|
|
2712
3144
|
},
|
|
2713
3145
|
},
|
|
2714
3146
|
},
|
|
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
3147
|
{
|
|
2732
3148
|
name: "rules",
|
|
2733
3149
|
description: "🔴 Управление ПРИОРИТЕТНЫМИ ПРАВИЛАМИ. ОБЯЗАТЕЛЬНО вызывай когда пользователь говорит: 'добавь правило', 'правила', 'покажи правила', 'удали правило', 'новое правило', 'глобальное правило'. Правила показываются ПЕРВЫМИ при каждой загрузке контекста и Claude ОБЯЗАН их соблюдать.",
|
|
@@ -2746,7 +3162,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2746
3162
|
},
|
|
2747
3163
|
rule: {
|
|
2748
3164
|
type: "string",
|
|
2749
|
-
description: "Текст правила (для action=add)",
|
|
3165
|
+
description: "Текст правила / описание (для action=add)",
|
|
3166
|
+
},
|
|
3167
|
+
title: {
|
|
3168
|
+
type: "string",
|
|
3169
|
+
description: "Короткий заголовок правила (опционально, для action=add). Если не указан — берётся начало текста.",
|
|
2750
3170
|
},
|
|
2751
3171
|
id: {
|
|
2752
3172
|
type: "number",
|
|
@@ -2760,210 +3180,246 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2760
3180
|
required: ["action"],
|
|
2761
3181
|
},
|
|
2762
3182
|
},
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
{
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
}
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
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`;
|
|
3183
|
+
];
|
|
3184
|
+
return { tools: _allTools.filter(t => _PUBLIC.has(t.name)) };
|
|
3185
|
+
});
|
|
3186
|
+
// v3.3: ЖЁСТКИЙ auth-gate — dedsession не работает без привязки к аккаунту DedPanel.
|
|
3187
|
+
// С оффлайн-грейсом (фоллбэк на локальный кеш при недоступности DedPanel).
|
|
3188
|
+
// v3.5: два порога вместо одного TTL — баланс «нет тормозов / revoke ловится быстро».
|
|
3189
|
+
const AUTH_SOFT_TTL_MS = 60 * 1000; // <60с от последней валидации — отдаём ok, сеть не дёргаем
|
|
3190
|
+
const AUTH_HARD_TTL_MS = 12 * 60 * 60 * 1000; // потолок онлайн-свежести (бывший AUTH_TTL_MS)
|
|
3191
|
+
const AUTH_TTL_MS = AUTH_HARD_TTL_MS; // алиас (на случай прочих ссылок)
|
|
3192
|
+
const AUTH_OFFLINE_GRACE_MS = 7 * 24 * 60 * 60 * 1000; // 7д оффлайн-грейс (фоллбэк)
|
|
3193
|
+
const AUTH_EXEMPT = new Set(["auth_login", "auth_pair", "auth_status", "auth_logout", "context_help"]);
|
|
3194
|
+
const _sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3195
|
+
// v3.4.1: пытается завершить pending pairing немедленно (один poll). null — если нечего проверять.
|
|
3196
|
+
async function tryFinishPairing(panelUrl, cfg) {
|
|
3197
|
+
if (!cfg?.pairPid || !cfg.pairCreated || (Date.now() - cfg.pairCreated) >= 600000)
|
|
3198
|
+
return null;
|
|
3199
|
+
try {
|
|
3200
|
+
const chk = await dedPanelPairCall(panelUrl, "mcp_pair_check", { pid: cfg.pairPid });
|
|
3201
|
+
if (chk?.success && chk.status === "confirmed" && chk.token) {
|
|
3202
|
+
saveSyncConfig({ panelUrl, token: chk.token, uid: chk.uid, username: chk.username, lastValidated: new Date().toISOString() });
|
|
3203
|
+
return { ok: true, message: `✅ Авторизовано (${chk.username}). Продолжаю.` };
|
|
3204
|
+
}
|
|
3205
|
+
if (chk?.status === "rejected") {
|
|
3206
|
+
saveSyncConfig({ panelUrl, token: "" });
|
|
3207
|
+
return { ok: false, message: "🚫 Авторизация отклонена в браузере. Повтори команду, чтобы начать заново." };
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
catch { /* */ }
|
|
3211
|
+
return null; // ещё pending / недоступно
|
|
3212
|
+
}
|
|
3213
|
+
// v3.4: browser-pairing с БЛОКИРУЮЩИМ ожиданием — после открытия браузера сам ждёт подтверждения и продолжает.
|
|
3214
|
+
async function beginOrContinuePairing() {
|
|
3215
|
+
const prev = loadSyncConfig();
|
|
3216
|
+
const panelUrl = (prev?.panelUrl) || "https://dedpanel.com";
|
|
3217
|
+
// 1) если есть свежий pending — сначала быстрый poll (вдруг уже подтвердили)
|
|
3218
|
+
const fin = await tryFinishPairing(panelUrl, prev);
|
|
3219
|
+
if (fin)
|
|
3220
|
+
return fin;
|
|
3221
|
+
// 2) инициируем новый pairing
|
|
3222
|
+
let pid = prev?.pairPid, url = prev?.pairUrl, code = prev?.pairCode;
|
|
3223
|
+
const stillValid = prev?.pairPid && prev.pairCreated && (Date.now() - prev.pairCreated) < 540000;
|
|
3224
|
+
if (!stillValid) {
|
|
3225
|
+
try {
|
|
3226
|
+
let device = "dedsession";
|
|
3227
|
+
try {
|
|
3228
|
+
device = `${(await import("os")).hostname()} · ${path.basename(getWorkingDir())}`;
|
|
3229
|
+
}
|
|
3230
|
+
catch { /* */ }
|
|
3231
|
+
const init = await dedPanelPairCall(panelUrl, "mcp_pair_init", { device });
|
|
3232
|
+
if (!init?.success || !init.pid)
|
|
3233
|
+
return { ok: false, message: `🔒 Не удалось начать авторизацию (${init?.error || "?"}). Проверь доступность ${panelUrl}.` };
|
|
3234
|
+
pid = init.pid;
|
|
3235
|
+
url = init.confirm_url;
|
|
3236
|
+
code = init.code;
|
|
3237
|
+
saveSyncConfig({ panelUrl, token: prev?.token || "", pairPid: pid, pairUrl: url, pairCode: code, pairCreated: Date.now() });
|
|
3238
|
+
await tryOpenBrowser(url); // best-effort; на headless ничего не делает
|
|
3239
|
+
}
|
|
3240
|
+
catch (e) {
|
|
3241
|
+
return { ok: false, message: `🔒 DedPanel (${panelUrl}) недоступен: ${String(e).slice(0, 80)}. (Обход: env DEDSESSION_NO_AUTH=1.)` };
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
// v4.7.0: HEADLESS (сервер/SSH, нет браузера) — НЕ блокируемся вслепую. Сразу отдаём ссылку:
|
|
3245
|
+
// человек открывает её на телефоне/ноуте, жмёт «Подтвердить», и ЛЮБОЙ следующий вызов (или
|
|
3246
|
+
// повтор команды) мгновенно подхватит токен (tryFinishPairing в начале функции делает быстрый poll).
|
|
3247
|
+
// Покрывает и первый заход, и повторные (pending ещё валиден) — раньше тут был слепой 90с-блок.
|
|
3248
|
+
if (isHeadless()) {
|
|
3249
|
+
return { ok: false, message: `🔐 **Авторизация dedsession**\n\n` +
|
|
3250
|
+
`На этой машине нет браузера (сервер/терминал). Открой ссылку на любом устройстве с браузером:\n\n` +
|
|
3251
|
+
`${url}\n\nКод подтверждения: **${code}**\n\n` +
|
|
3252
|
+
`После «Подтвердить» — просто продолжай работу (или повтори команду): dedsession сам подхватит привязку. Ссылка живёт ~9 минут.` };
|
|
3253
|
+
}
|
|
3254
|
+
// 3) GUI: БЛОКИРУЮЩЕЕ ожидание подтверждения (~90с) — браузер открылся, ждём клика и продолжаем САМ
|
|
3255
|
+
for (let i = 0; i < 45; i++) {
|
|
3256
|
+
await _sleep(2000);
|
|
3257
|
+
try {
|
|
3258
|
+
const chk = await dedPanelPairCall(panelUrl, "mcp_pair_check", { pid: pid });
|
|
3259
|
+
if (chk?.success && chk.status === "confirmed" && chk.token) {
|
|
3260
|
+
saveSyncConfig({ panelUrl, token: chk.token, uid: chk.uid, username: chk.username, lastValidated: new Date().toISOString() });
|
|
3261
|
+
return { ok: true, message: `✅ Устройство авторизовано (${chk.username}). Продолжаю команду.` };
|
|
2875
3262
|
}
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
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
|
-
}
|
|
3263
|
+
if (chk?.status === "rejected") {
|
|
3264
|
+
saveSyncConfig({ panelUrl, token: "" });
|
|
3265
|
+
return { ok: false, message: "🚫 Авторизация отклонена в браузере." };
|
|
2887
3266
|
}
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
3267
|
+
if (chk?.error === "expired" || chk?.error === "not_found")
|
|
3268
|
+
break;
|
|
3269
|
+
}
|
|
3270
|
+
catch { /* сетевой сбой — продолжаем поллить */ }
|
|
3271
|
+
}
|
|
3272
|
+
return { ok: false, message: `🔐 **Авторизация dedsession** — подтверди в браузере:\n${url}\nКод: **${code}**\n\nНе дождался за 90с — нажми «Подтвердить» и повтори команду (подхвачу сразу).` };
|
|
3273
|
+
}
|
|
3274
|
+
// v3.5: фоновая неблокирующая проверка токена. Не тормозит команды; при revoke
|
|
3275
|
+
// затирает токен в sync.json → СЛЕДУЮЩИЙ вызов уйдёт в pairing-блок (заблокирован).
|
|
3276
|
+
let _bgAuthInflight = false;
|
|
3277
|
+
function backgroundRevokeCheck(cfg) {
|
|
3278
|
+
if (_bgAuthInflight)
|
|
3279
|
+
return;
|
|
3280
|
+
_bgAuthInflight = true;
|
|
3281
|
+
void (async () => {
|
|
3282
|
+
try {
|
|
3283
|
+
const who = await dedPanelCall("api_whoami");
|
|
3284
|
+
const fresh = loadSyncConfig();
|
|
3285
|
+
if (!fresh || fresh.token !== cfg.token)
|
|
3286
|
+
return; // токен уже сменили — не трогаем
|
|
3287
|
+
if (who && who.success) {
|
|
3288
|
+
updateSyncConfig({ uid: who.uid, username: who.username, lastValidated: new Date().toISOString() });
|
|
2909
3289
|
}
|
|
2910
3290
|
else {
|
|
2911
|
-
|
|
2912
|
-
|
|
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`;
|
|
3291
|
+
// REVOKE: токен снят с DedPanel → затираем, следующий tool-call уйдёт в pairing
|
|
3292
|
+
updateSyncConfig({ token: "" });
|
|
2930
3293
|
}
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
3294
|
+
}
|
|
3295
|
+
catch { /* оффлайн — ничего; offline-grace отработает в ensureAuthorized */ }
|
|
3296
|
+
finally {
|
|
3297
|
+
_bgAuthInflight = false;
|
|
3298
|
+
}
|
|
3299
|
+
})();
|
|
3300
|
+
}
|
|
3301
|
+
async function ensureAuthorized(toolName) {
|
|
3302
|
+
if (process.env.DEDSESSION_NO_AUTH === "1")
|
|
3303
|
+
return { ok: true }; // аварийный обход
|
|
3304
|
+
if (AUTH_EXEMPT.has(toolName))
|
|
3305
|
+
return { ok: true };
|
|
3306
|
+
const cfg = loadSyncConfig();
|
|
3307
|
+
if (!cfg || !cfg.token) {
|
|
3308
|
+
// v3.4: не сухой блок, а browser-pairing (ссылка + подтверждение + авто-подхват)
|
|
3309
|
+
return await beginOrContinuePairing();
|
|
3310
|
+
}
|
|
3311
|
+
const now = Date.now();
|
|
3312
|
+
const lastValidatedMs = cfg.lastValidated ? Date.parse(cfg.lastValidated) : 0;
|
|
3313
|
+
const age = lastValidatedMs ? (now - lastValidatedMs) : Infinity;
|
|
3314
|
+
// 1) очень свежо (<soft) — отдаём ok, сеть не дёргаем вообще
|
|
3315
|
+
if (age < AUTH_SOFT_TTL_MS)
|
|
3316
|
+
return { ok: true };
|
|
3317
|
+
// 2) зона soft..hard — ok МГНОВЕННО по кешу, но в фоне проверяем revoke
|
|
3318
|
+
if (age < AUTH_HARD_TTL_MS) {
|
|
3319
|
+
backgroundRevokeCheck(cfg);
|
|
3320
|
+
return { ok: true };
|
|
3321
|
+
}
|
|
3322
|
+
// 3) старше hard — блокирующая онлайн-проверка
|
|
3323
|
+
try {
|
|
3324
|
+
const who = await dedPanelCall("api_whoami");
|
|
3325
|
+
if (who && who.success) {
|
|
3326
|
+
updateSyncConfig({ uid: who.uid, username: who.username, lastValidated: new Date().toISOString() });
|
|
3327
|
+
return { ok: true };
|
|
3328
|
+
}
|
|
3329
|
+
// токен отозван/недействителен → сброс + browser-pairing
|
|
3330
|
+
updateSyncConfig({ token: "" });
|
|
3331
|
+
return await beginOrContinuePairing();
|
|
3332
|
+
}
|
|
3333
|
+
catch {
|
|
3334
|
+
// DedPanel недоступен → оффлайн-грейс по локальному кешу
|
|
3335
|
+
if (age < AUTH_OFFLINE_GRACE_MS)
|
|
3336
|
+
return { ok: true };
|
|
3337
|
+
return { ok: false, message: "🔒 DedPanel недоступен, токен не подтверждался >7 дней. Нужна связь с DedPanel (или `DEDSESSION_NO_AUTH=1` для обхода)." };
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
// v4.7.0: ВЕРСИОННЫЙ БЛОК — сервер задаёт минимальный билд dedsession. Старый клиент блокируется
|
|
3341
|
+
// и просит обновиться. Кэш 5 мин. Не привязан/оффлайн/сервер молчит → НЕ блокируем (fail-open).
|
|
3342
|
+
function cmpSemver(a, b) {
|
|
3343
|
+
const pa = String(a).split(".").map(n => parseInt(n) || 0);
|
|
3344
|
+
const pb = String(b).split(".").map(n => parseInt(n) || 0);
|
|
3345
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
3346
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
3347
|
+
if (d !== 0)
|
|
3348
|
+
return d < 0 ? -1 : 1;
|
|
3349
|
+
}
|
|
3350
|
+
return 0;
|
|
3351
|
+
}
|
|
3352
|
+
let _verGateCache = null;
|
|
3353
|
+
async function checkVersionGate() {
|
|
3354
|
+
const cfg = loadSyncConfig();
|
|
3355
|
+
if (!cfg || !cfg.token)
|
|
3356
|
+
return null; // не привязан — не блокируем
|
|
3357
|
+
try {
|
|
3358
|
+
if (!_verGateCache || Date.now() - _verGateCache.at > 5 * 60 * 1000) {
|
|
3359
|
+
const r = await dedPanelCall("mcp_version_gate", { op: "get" });
|
|
3360
|
+
if (!r || !r.success)
|
|
3361
|
+
return null; // не достучались — fail-open
|
|
3362
|
+
_verGateCache = { at: Date.now(), min: String(r.min_version || "0.0.0"), message: String(r.message || "") };
|
|
3363
|
+
}
|
|
3364
|
+
if (cmpSemver(CONFIG.VERSION, _verGateCache.min) < 0) {
|
|
3365
|
+
const extra = _verGateCache.message ? `\n\n📢 ${_verGateCache.message}` : "";
|
|
3366
|
+
return `⛔ Версия dedsession ${CONFIG.VERSION} НЕ АКТУАЛЬНА.\n\nТребуется минимум **${_verGateCache.min}**. Обновитесь и перезапустите Claude Code:\n\`\`\`\nnpm install -g dedsession # или npm update -g dedsession\n\`\`\`\nДо обновления dedsession работать не будет.${extra}`;
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
catch {
|
|
3370
|
+
return null;
|
|
3371
|
+
}
|
|
3372
|
+
return null;
|
|
3373
|
+
}
|
|
3374
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3375
|
+
const { name, arguments: args } = request.params;
|
|
3376
|
+
// v3.3: жёсткая авторизация перед любой командой (кроме auth_*/help)
|
|
3377
|
+
const _auth = await ensureAuthorized(name);
|
|
3378
|
+
if (!_auth.ok)
|
|
3379
|
+
return { content: [{ type: "text", text: _auth.message }] };
|
|
3380
|
+
// v4.7.0: версионный блок (кроме auth_* — чтобы можно было проверить статус/отвязаться/обновить привязку)
|
|
3381
|
+
if (!AUTH_EXEMPT.has(name)) {
|
|
3382
|
+
const _block = await checkVersionGate();
|
|
3383
|
+
if (_block)
|
|
3384
|
+
return { content: [{ type: "text", text: _block }] };
|
|
3385
|
+
}
|
|
3386
|
+
switch (name) {
|
|
3387
|
+
// ==================== AUTH_PAIR (v3.4 browser-pairing) ====================
|
|
3388
|
+
case "auth_pair": {
|
|
3389
|
+
const r = await beginOrContinuePairing();
|
|
3390
|
+
return { content: [{ type: "text", text: r.message || (r.ok ? "✅ Авторизован." : "🔒 Не удалось.") }] };
|
|
3391
|
+
}
|
|
3392
|
+
// ==================== CONTEXT_GLOBAL (v3.1) ====================
|
|
3393
|
+
case "auth_status": {
|
|
3394
|
+
const cfg = loadSyncConfig();
|
|
3395
|
+
if (!cfg || !cfg.token)
|
|
3396
|
+
return { content: [{ type: "text", text: "🔓 Не привязан к DedPanel. Используй `auth_login <токен>` для синхронизации памяти между устройствами." }] };
|
|
3397
|
+
let live = "не проверялся";
|
|
3398
|
+
try {
|
|
3399
|
+
const who = await dedPanelCall("api_whoami");
|
|
3400
|
+
live = (who && who.success) ? `✅ жив (${who.username})` : `❌ отклонён (${who?.error || "?"})`;
|
|
2941
3401
|
}
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
if (contexts.length > 0) {
|
|
2945
|
-
output += `**Выбери:**\n\n`;
|
|
2946
|
-
output += `**1** — Показать все контексты и выбрать для загрузки\n`;
|
|
2947
|
-
output += `**2** — Начать новый чат (без загрузки контекста)\n\n`;
|
|
2948
|
-
output += `_(просто напиши 1 или 2)_\n`;
|
|
3402
|
+
catch {
|
|
3403
|
+
live = "⚠️ нет связи (оффлайн — работа по локальному кешу)";
|
|
2949
3404
|
}
|
|
2950
|
-
|
|
2951
|
-
|
|
3405
|
+
return { content: [{ type: "text", text: `🔐 Привязка DedPanel\n\n👤 Аккаунт: ${cfg.username || cfg.uid || "?"}\n🌐 Панель: ${cfg.panelUrl}\n🔑 Токен: ${live}\n📅 Последняя проверка: ${cfg.lastValidated || "—"}` }] };
|
|
3406
|
+
}
|
|
3407
|
+
case "auth_logout": {
|
|
3408
|
+
try {
|
|
3409
|
+
const p = getSyncConfigPath();
|
|
3410
|
+
if (fs.existsSync(p))
|
|
3411
|
+
fs.unlinkSync(p);
|
|
2952
3412
|
}
|
|
2953
|
-
|
|
3413
|
+
catch { /* */ }
|
|
3414
|
+
return { content: [{ type: "text", text: "🔓 Аккаунт DedPanel отвязан (локальный токен удалён). Память осталась локально." }] };
|
|
2954
3415
|
}
|
|
2955
|
-
// ==================== CONTEXT_SAVE ====================
|
|
2956
3416
|
case "context_save": {
|
|
2957
3417
|
const taskName = args?.task_name;
|
|
2958
3418
|
if (!taskName) {
|
|
2959
3419
|
return { content: [{ type: "text", text: "❌ Укажи название задачи (task_name)" }] };
|
|
2960
3420
|
}
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
in_progress: "⏳ В процессе",
|
|
2964
|
-
blocked: "❌ Заблокировано",
|
|
2965
|
-
};
|
|
2966
|
-
const status = statusMap[args?.status || "in_progress"] || "⏳ В процессе";
|
|
3421
|
+
// v4.4.0 блок J: понятие «выполнено/в процессе/застряло» убрано — по контекстам и так ясно.
|
|
3422
|
+
const status = ""; // нейтрально, в README/коммит/события статус больше не пишем
|
|
2967
3423
|
// Определяем масштаб сохранения
|
|
2968
3424
|
const scaleInfo = determineSaveScale({
|
|
2969
3425
|
changes: args?.changes,
|
|
@@ -2980,41 +3436,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2980
3436
|
performance: args?.performance,
|
|
2981
3437
|
security: args?.security,
|
|
2982
3438
|
});
|
|
2983
|
-
//
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
3439
|
+
// v3.1: ЕДИНЫЙ УМНЫЙ SAVE. README без заглушек (sparse), секции пишутся только непустые.
|
|
3440
|
+
let readme = `# ${taskName}\n\n**Дата:** ${new Date().toISOString().split("T")[0]}\n**Масштаб:** ${scaleInfo.scale}\n`;
|
|
3441
|
+
if (args?.summary) {
|
|
3442
|
+
readme += `\n## Краткое описание\n\n${args.summary}\n`;
|
|
3443
|
+
}
|
|
3444
|
+
if (args?.changes) {
|
|
3445
|
+
const bullets = args.changes
|
|
3446
|
+
.split("\n").map(l => l.trim()).filter(Boolean)
|
|
3447
|
+
.map(l => (l.startsWith("-") ? l : `- ${l}`)).join("\n");
|
|
3448
|
+
if (bullets)
|
|
3449
|
+
readme += `\n## Что сделано\n\n${bullets}\n`;
|
|
3450
|
+
}
|
|
3451
|
+
if (state.loadedContext) {
|
|
3452
|
+
readme += `\n## 🔗 Связи\n\n**Parent:** ${state.loadedContext}\n`;
|
|
3453
|
+
}
|
|
2998
3454
|
const result = saveContext(taskName, {
|
|
2999
3455
|
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,
|
|
3456
|
+
taskOverview: args?.task_overview || undefined,
|
|
3457
|
+
analysis: args?.analysis || undefined,
|
|
3458
|
+
changes: args?.changes || "",
|
|
3459
|
+
summary: args?.summary || "",
|
|
3460
|
+
sessionLog: args?.session_log || undefined,
|
|
3461
|
+
codeSnippets: args?.code_snippets || undefined,
|
|
3462
|
+
decisions: args?.decisions || undefined,
|
|
3463
|
+
problemsSolutions: args?.problems_solutions || undefined,
|
|
3464
|
+
architecture: args?.architecture || undefined,
|
|
3465
|
+
testing: args?.testing || undefined,
|
|
3466
|
+
apiDocs: args?.api_docs || undefined,
|
|
3467
|
+
migration: args?.migration || undefined,
|
|
3468
|
+
performance: args?.performance || undefined,
|
|
3469
|
+
security: args?.security || undefined,
|
|
3470
|
+
fullContext: args?.full_context || undefined,
|
|
3015
3471
|
// v1.4.10: Готовая сводка для HISTORY.md
|
|
3016
3472
|
historySummary: args?.history_summary,
|
|
3017
|
-
}, undefined
|
|
3473
|
+
}, undefined
|
|
3474
|
+
// filesToCreate опущен → пишутся ВСЕ непустые секции (максимум пользы, без мусора)
|
|
3475
|
+
);
|
|
3018
3476
|
if (!result.success) {
|
|
3019
3477
|
return { content: [{ type: "text", text: `❌ Ошибка: ${result.error}` }] };
|
|
3020
3478
|
}
|
|
@@ -3026,7 +3484,8 @@ ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "-
|
|
|
3026
3484
|
args?.summary,
|
|
3027
3485
|
args?.session_log,
|
|
3028
3486
|
].filter(Boolean).join("\n");
|
|
3029
|
-
const memoryInfo = addContextToMemory(contextNumber, result.name, taskName, status, fullContent, new Date().toISOString().split("T")[0]
|
|
3487
|
+
const memoryInfo = addContextToMemory(contextNumber, result.name, taskName, status, fullContent, new Date().toISOString().split("T")[0], result.parentRef // v3.1.2 FIX H: явный родитель, не перезатёртый state
|
|
3488
|
+
);
|
|
3030
3489
|
// v2.2: Извлекаем решения при сохранении
|
|
3031
3490
|
let solutionsAdded = 0;
|
|
3032
3491
|
if (args?.problems_solutions) {
|
|
@@ -3056,12 +3515,38 @@ ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "-
|
|
|
3056
3515
|
date: new Date().toISOString().split("T")[0],
|
|
3057
3516
|
digestUpdate: args?.digest_update,
|
|
3058
3517
|
});
|
|
3518
|
+
// v4.5.0: при СОХРАНЕНИИ контекст уходит в DedPanel (ctx_id = имя папки — уникальный ключ,
|
|
3519
|
+
// как в ручной миграции) + обновляются коммиты по авторам. Привязка к аккаунту = изоляция.
|
|
3520
|
+
try {
|
|
3521
|
+
publishToGlobalDigest();
|
|
3522
|
+
logEventInBackground({ type: "context_save", project: digestProject, ctxId: result.name, status: "saved", size: result.filesCount, topic: taskName, tags: memoryInfo.keywords, solutionsCount: solutionsAdded });
|
|
3523
|
+
collectCommitStatsInBackground(); // новые коммиты при сохранении
|
|
3524
|
+
autoPushInBackground();
|
|
3525
|
+
}
|
|
3526
|
+
catch { /* не критично */ }
|
|
3059
3527
|
let output = `✅ **Контекст сохранён**\n\n`;
|
|
3060
3528
|
output += `📁 **Папка:** \`${result.name}\`\n`;
|
|
3061
3529
|
output += `📄 **Файлов:** ${result.filesCount}\n`;
|
|
3062
3530
|
output += `🎯 **Масштаб:** ${scaleInfo.scale}\n`;
|
|
3063
3531
|
output += `📊 **Причина:** ${scaleInfo.reason}\n`;
|
|
3064
3532
|
output += `📍 **Путь:** \`${result.path}\`\n`;
|
|
3533
|
+
// v4.3: МУЛЬТИ-ПРОЕКТ — дублируем сохранённый контекст в присоединённые репо
|
|
3534
|
+
if (state.attachedProjects.length > 0 && result.path) {
|
|
3535
|
+
for (const att of state.attachedProjects) {
|
|
3536
|
+
try {
|
|
3537
|
+
const dst = path.join(att.mdHistory, result.name);
|
|
3538
|
+
if (!fs.existsSync(att.mdHistory))
|
|
3539
|
+
fs.mkdirSync(att.mdHistory, { recursive: true });
|
|
3540
|
+
if (!fs.existsSync(dst)) {
|
|
3541
|
+
fs.cpSync(result.path, dst, { recursive: true });
|
|
3542
|
+
output += `🔗 **Также сохранено в:** ${att.project} (\`${dst}\`)\n`;
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
catch (e) {
|
|
3546
|
+
output += `⚠️ Не удалось продублировать в ${att.project}: ${String(e).slice(0, 60)}\n`;
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3065
3550
|
// v2.0: Показываем Memory info
|
|
3066
3551
|
if (memoryInfo.project) {
|
|
3067
3552
|
output += `\n🧠 **Memory v2.0:**\n`;
|
|
@@ -3098,7 +3583,7 @@ ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "-
|
|
|
3098
3583
|
.map(l => l.startsWith("-") ? l : `- ${l}`)
|
|
3099
3584
|
.join("\n");
|
|
3100
3585
|
const commitTitle = `📝 Context: ${contextShortName}`;
|
|
3101
|
-
const commitBody =
|
|
3586
|
+
const commitBody = `Файлов: ${result.filesCount}\n\nЧто сделано:\n${changeLines}`;
|
|
3102
3587
|
output += `\n\n---\n\n`;
|
|
3103
3588
|
output += `📋 **Для коммита:**\n\n`;
|
|
3104
3589
|
output += `**Title:**\n\`\`\`\n${commitTitle}\n\`\`\`\n\n`;
|
|
@@ -3108,172 +3593,139 @@ ${args?.changes?.split("\n").map(l => l.trim() ? `- ${l}` : "").join("\n") || "-
|
|
|
3108
3593
|
return { content: [{ type: "text", text: output }] };
|
|
3109
3594
|
}
|
|
3110
3595
|
// ==================== CONTEXT_SAVE_DETAILED ====================
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
"
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
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}` }] };
|
|
3596
|
+
// v4.3: МУЛЬТИ-ПРОЕКТ — подключить другой репозиторий
|
|
3597
|
+
case "context_attach": {
|
|
3598
|
+
const proj = (args?.project || "").trim();
|
|
3599
|
+
const detach = args.detach === true;
|
|
3600
|
+
const trunc = (t, n) => t.length > n ? t.slice(0, n) + "\n…(обрезано)" : t;
|
|
3601
|
+
if (detach) {
|
|
3602
|
+
if (!proj) {
|
|
3603
|
+
state.attachedProjects = [];
|
|
3604
|
+
return { content: [{ type: "text", text: "🔗 Все присоединённые проекты отсоединены." }] };
|
|
3605
|
+
}
|
|
3606
|
+
const before = state.attachedProjects.length;
|
|
3607
|
+
state.attachedProjects = state.attachedProjects.filter(a => a.project !== proj && !a.mdHistory.includes(proj));
|
|
3608
|
+
return { content: [{ type: "text", text: before > state.attachedProjects.length ? `🔗 Проект «${proj}» отсоединён.` : `Проект «${proj}» не был присоединён.` }] };
|
|
3609
|
+
}
|
|
3610
|
+
if (!proj) {
|
|
3611
|
+
if (state.attachedProjects.length === 0)
|
|
3612
|
+
return { content: [{ type: "text", text: "🔗 Нет присоединённых проектов.\n\nПодключи: `context_attach /путь/к/репо` или `context_attach имя-проекта`.\nОтсоединить: `context_attach <имя> detach`." }] };
|
|
3613
|
+
let o = `## 🔗 Присоединённые проекты (${state.attachedProjects.length})\n\n`;
|
|
3614
|
+
for (const a of state.attachedProjects)
|
|
3615
|
+
o += `- **${a.project}** — \`${a.mdHistory}\`\n`;
|
|
3616
|
+
o += `\n💾 При сохранении контекст дублируется и в эти проекты.`;
|
|
3617
|
+
return { content: [{ type: "text", text: o }] };
|
|
3618
|
+
}
|
|
3619
|
+
// резолвим путь к MD_HISTORY присоединяемого репо
|
|
3620
|
+
let mdHistory = "";
|
|
3621
|
+
let projectName = "";
|
|
3622
|
+
const tryPaths = [
|
|
3623
|
+
path.join(proj, "MD_HISTORY"), // proj = корень репо
|
|
3624
|
+
proj.endsWith("MD_HISTORY") ? proj : "", // proj = сама MD_HISTORY
|
|
3625
|
+
].filter(Boolean);
|
|
3626
|
+
for (const p of tryPaths) {
|
|
3627
|
+
if (fs.existsSync(p) && fs.statSync(p).isDirectory()) {
|
|
3628
|
+
mdHistory = p;
|
|
3629
|
+
break;
|
|
3630
|
+
}
|
|
3185
3631
|
}
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
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`;
|
|
3632
|
+
if (!mdHistory) {
|
|
3633
|
+
// поиск по имени в глобальной карте проектов
|
|
3634
|
+
const gd = loadGlobalDigest();
|
|
3635
|
+
for (const [name, sec] of Object.entries(gd.projects)) {
|
|
3636
|
+
if (name === proj || name.toLowerCase() === proj.toLowerCase()) {
|
|
3637
|
+
if (sec.mdHistory && fs.existsSync(sec.mdHistory)) {
|
|
3638
|
+
mdHistory = sec.mdHistory;
|
|
3639
|
+
projectName = name;
|
|
3640
|
+
break;
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3238
3643
|
}
|
|
3239
3644
|
}
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3645
|
+
if (!mdHistory)
|
|
3646
|
+
return { content: [{ type: "text", text: `❌ Не нашёл проект «${proj}».\nУкажи путь к репо (\`context_attach /home/.../repo\`) или имя из карты проектов (см. вкладку MCP / context_quick).` }] };
|
|
3647
|
+
const repoDir = path.dirname(mdHistory);
|
|
3648
|
+
if (!projectName)
|
|
3649
|
+
projectName = readGitOriginName(repoDir) || path.basename(repoDir);
|
|
3650
|
+
const atCtx = listContexts(mdHistory);
|
|
3651
|
+
if (!state.attachedProjects.some(a => a.mdHistory === mdHistory))
|
|
3652
|
+
state.attachedProjects.push({ project: projectName, mdHistory });
|
|
3653
|
+
let out = `# 🔗 Проект присоединён: ${projectName}\n\n`;
|
|
3654
|
+
out += `📍 \`${repoDir}\`\n📊 Контекстов: ${atCtx.length}\n\n`;
|
|
3655
|
+
// дайджест присоединённого репо если есть
|
|
3656
|
+
const adigest = path.join(mdHistory, "_DIGEST", "00-overview.md");
|
|
3657
|
+
if (fs.existsSync(adigest)) {
|
|
3658
|
+
out += `## Обзор проекта\n\n${trunc(fs.readFileSync(adigest, "utf-8"), 3 * 1024)}\n\n---\n\n`;
|
|
3659
|
+
}
|
|
3660
|
+
if (atCtx.length > 0) {
|
|
3661
|
+
out += `## Последние контексты «${projectName}»\n\n| # | Задача | Дата | Статус |\n|---|--------|------|--------|\n`;
|
|
3662
|
+
for (const c of atCtx.slice(0, 8))
|
|
3663
|
+
out += `| ${contextIdOf(c)} | ${c.title} | ${c.date} | ${c.status} |\n`;
|
|
3664
|
+
// полный README верхнего контекста
|
|
3665
|
+
const top = atCtx[0];
|
|
3666
|
+
const rd = path.join(top.path, "README.md");
|
|
3667
|
+
if (fs.existsSync(rd))
|
|
3668
|
+
out += `\n### Свежий контекст (#${contextIdOf(top)})\n\n${trunc(fs.readFileSync(rd, "utf-8"), 4 * 1024)}\n`;
|
|
3669
|
+
}
|
|
3670
|
+
out += `\n💡 Теперь работаешь с двумя проектами. При \`сохрани контекст\` запись продублируется и в «${projectName}». Отсоединить: \`context_attach ${projectName} detach\`.`;
|
|
3671
|
+
return { content: [{ type: "text", text: out }] };
|
|
3672
|
+
}
|
|
3673
|
+
case "context_private": {
|
|
3674
|
+
// v4.4.0 блок G: текущий проект → приватный. Локальный флаг + полная зачистка на сервере.
|
|
3675
|
+
const project = detectProjectFromWorkDir() || path.basename(getWorkingDir());
|
|
3676
|
+
if (!project)
|
|
3677
|
+
return { content: [{ type: "text", text: "❌ Не удалось определить текущий проект." }] };
|
|
3678
|
+
const already = isPrivateProject(project);
|
|
3679
|
+
markProjectPrivate(project); // список ~/.dedsession + маркер .dedsession-private в репо
|
|
3680
|
+
// Зачистка на сервере: удалить ВСЁ связанное с проектом (события/индекс/digest/AI-кэш).
|
|
3681
|
+
let serverMsg = "";
|
|
3682
|
+
const cfg = loadSyncConfig();
|
|
3683
|
+
if (cfg && cfg.token) {
|
|
3684
|
+
try {
|
|
3685
|
+
const r = await dedPanelCall("mcp_project_reset", { project });
|
|
3686
|
+
if (r && r.success)
|
|
3687
|
+
serverMsg = `🧹 На сервере удалено событий: ${r.removed ?? 0}.`;
|
|
3688
|
+
else {
|
|
3689
|
+
handleRevokeIfNeeded(r);
|
|
3690
|
+
serverMsg = `⚠️ Сервер: ${r?.error || "не удалось зачистить (попробуй позже)"}.`;
|
|
3691
|
+
}
|
|
3692
|
+
}
|
|
3693
|
+
catch {
|
|
3694
|
+
serverMsg = "⚠️ Оффлайн — серверная зачистка не выполнена (повтори команду онлайн).";
|
|
3695
|
+
}
|
|
3243
3696
|
}
|
|
3244
|
-
|
|
3245
|
-
|
|
3697
|
+
else {
|
|
3698
|
+
serverMsg = "ℹ️ Аккаунт не привязан — на сервере и так ничего нет.";
|
|
3246
3699
|
}
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3700
|
+
// также убрать из локального реестра залитых (чтобы не считался синхронизированным)
|
|
3701
|
+
try {
|
|
3702
|
+
const reg = loadSyncedRegistry();
|
|
3703
|
+
if (reg[project]) {
|
|
3704
|
+
delete reg[project];
|
|
3705
|
+
fs.writeFileSync(getSyncedRegistryPath(), JSON.stringify(reg), "utf-8");
|
|
3706
|
+
fs.chmodSync(getSyncedRegistryPath(), 0o600);
|
|
3707
|
+
}
|
|
3251
3708
|
}
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
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>`;
|
|
3709
|
+
catch { /* */ }
|
|
3710
|
+
// v4.4.0 блок G/H: вырезать секцию проекта из ЛОКАЛЬНОГО global-digest.json,
|
|
3711
|
+
// иначе autoPush из другого (публичного) проекта заново зальёт её на сервер — утечка.
|
|
3712
|
+
try {
|
|
3713
|
+
const gd = loadGlobalDigest();
|
|
3714
|
+
if (gd.projects && gd.projects[project]) {
|
|
3715
|
+
delete gd.projects[project];
|
|
3716
|
+
saveGlobalDigest(gd);
|
|
3717
|
+
}
|
|
3273
3718
|
}
|
|
3274
|
-
|
|
3719
|
+
catch { /* */ }
|
|
3720
|
+
let out = `# 🔒 Проект «${project}» теперь ПРИВАТНЫЙ\n\n`;
|
|
3721
|
+
if (already)
|
|
3722
|
+
out += `(он уже был приватным — повторно зачистил сервер)\n\n`;
|
|
3723
|
+
out += `- Контексты этого проекта больше **не уходят никуда** — только локально.\n`;
|
|
3724
|
+
out += `- ${serverMsg}\n`;
|
|
3725
|
+
out += `- В корне проекта создан маркер \`.dedsession-private\`.\n\n`;
|
|
3726
|
+
out += `↩️ Вернуть синхронизацию: удали файл \`.dedsession-private\` в корне проекта.`;
|
|
3727
|
+
return { content: [{ type: "text", text: out }] };
|
|
3275
3728
|
}
|
|
3276
|
-
// ==================== CONTEXT_LOAD ====================
|
|
3277
3729
|
case "context_load": {
|
|
3278
3730
|
const query = args?.query;
|
|
3279
3731
|
// Если нет запроса - показываем список для выбора
|
|
@@ -3319,7 +3771,9 @@ ${state.loadedContext ? `**Parent:** ${state.loadedContext}` : "Нет parent к
|
|
|
3319
3771
|
return { content: [{ type: "text", text: `❌ ${result.error}` }] };
|
|
3320
3772
|
}
|
|
3321
3773
|
// v2.0: Обновляем accessCount и temperature
|
|
3322
|
-
|
|
3774
|
+
// v3.1.1: id через normalizeParentRef (как в индексе) — иначе для формата B/дублей
|
|
3775
|
+
// split("_")[0] давал "2025"/неверный ключ → updateContextAccess был no-op, Memory-блок пуст.
|
|
3776
|
+
const contextId = state.loadedContext ? normalizeParentRef(state.loadedContext) : null;
|
|
3323
3777
|
if (contextId) {
|
|
3324
3778
|
updateContextAccess(contextId);
|
|
3325
3779
|
}
|
|
@@ -3383,128 +3837,77 @@ ${state.loadedContext ? `**Parent:** ${state.loadedContext}` : "Нет parent к
|
|
|
3383
3837
|
return { content: [{ type: "text", text: output }] };
|
|
3384
3838
|
}
|
|
3385
3839
|
// ==================== CONTEXT_LIST ====================
|
|
3386
|
-
case "
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
}
|
|
3396
|
-
return { content: [{ type: "text", text: "📭 Нет сохранённых контекстов" }] };
|
|
3840
|
+
case "context_quick": {
|
|
3841
|
+
state.isInitialized = true;
|
|
3842
|
+
// v3.5: фоновый авто-pull свежих правил + АВТО-PUSH (правила/карта на аккаунт) + событие старта.
|
|
3843
|
+
// v4.5.0: МИГРАЦИЯ старых контекстов УБРАНА (autoBackfill) — историю залили вручную единоразово,
|
|
3844
|
+
// чтобы не было дублей/багов. Теперь работает просто: при СОХРАНЕНИИ контекст уходит в DedPanel.
|
|
3845
|
+
try {
|
|
3846
|
+
autoPullInBackground();
|
|
3847
|
+
autoPushInBackground();
|
|
3848
|
+
collectCommitStatsInBackground(); // коммиты по авторам → DedPanel
|
|
3849
|
+
logEventInBackground({ type: "context_quick", project: detectProjectFromWorkDir() || "", status: "quick" });
|
|
3397
3850
|
}
|
|
3398
|
-
|
|
3399
|
-
|
|
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`;
|
|
3414
|
-
}
|
|
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();
|
|
3851
|
+
catch { /* не критично */ }
|
|
3852
|
+
const allContexts = listContexts();
|
|
3503
3853
|
const stats = getStatistics();
|
|
3504
3854
|
const workDir = getWorkingDir();
|
|
3505
3855
|
if (allContexts.length === 0) {
|
|
3506
|
-
|
|
3856
|
+
// v4.0: fallback-старт (бывший context_start) — нет контекстов → показываем систему + правила,
|
|
3857
|
+
// чтобы первый запуск был осмысленным, а не пустым.
|
|
3858
|
+
let out = `## 🚀 dedsession v${CONFIG.VERSION} — система памяти Claude\n\n`;
|
|
3859
|
+
out += `📭 В этом репозитории (\`${path.basename(workDir)}\`) ещё нет сохранённых контекстов.\n\n`;
|
|
3860
|
+
out += `**Как работает (всё в 3 командах):**\n`;
|
|
3861
|
+
out += `- 💾 **сохрани контекст** — записать работу (дайджест, решения, синк — всё автоматом)\n`;
|
|
3862
|
+
out += `- ⚡ **загрузи контекст** — подтянуть последнее (этот вызов)\n`;
|
|
3863
|
+
out += `- 📋 **правила** (rules) — глобальные + локальные, применяются всегда\n\n`;
|
|
3864
|
+
try {
|
|
3865
|
+
const rb = formatRulesBlock();
|
|
3866
|
+
if (rb && rb.trim())
|
|
3867
|
+
out += rb + "\n";
|
|
3868
|
+
}
|
|
3869
|
+
catch { /* */ }
|
|
3870
|
+
out += `\n💡 Начни работу и скажи «сохрани контекст» — система запомнит.`;
|
|
3871
|
+
return { content: [{ type: "text", text: out }] };
|
|
3872
|
+
}
|
|
3873
|
+
// v3.1: АВТОМИГРАЦИЯ СХЕМЫ ПАМЯТИ — вшита прямо в быстрый контекст.
|
|
3874
|
+
// Версионный гейт: если схема свежая → мгновенный skip (одно сравнение, без I/O).
|
|
3875
|
+
// Если устарела → один раз на репо: бэкап → полный reindex из диска → проставить версию.
|
|
3876
|
+
// Гейт сам защищает от повторов (миграция выполнится ровно раз), всё в try/catch — не ломает загрузку.
|
|
3877
|
+
let migrationNote = "";
|
|
3878
|
+
try {
|
|
3879
|
+
const _idx = loadMemoryIndex();
|
|
3880
|
+
if ((_idx.schemaVersion || 0) < CONFIG.SCHEMA_VERSION) {
|
|
3881
|
+
// Бэкап текущей памяти перед миграцией (безопасность/откат)
|
|
3882
|
+
try {
|
|
3883
|
+
const memDir = getMemoryPath();
|
|
3884
|
+
const backupDir = path.join(memDir, `.backup-schema${_idx.schemaVersion || 0}`);
|
|
3885
|
+
if (!fs.existsSync(backupDir) && fs.existsSync(memDir)) {
|
|
3886
|
+
ensureDir(backupDir);
|
|
3887
|
+
for (const f of fs.readdirSync(memDir).filter(f => f.endsWith(".json"))) {
|
|
3888
|
+
fs.copyFileSync(path.join(memDir, f), path.join(backupDir, f));
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
catch { /* бэкап не критичен для продолжения */ }
|
|
3893
|
+
const mig = migrateToMemorySystem(); // полный reindex из диска (источник истины)
|
|
3894
|
+
const idx2 = loadMemoryIndex();
|
|
3895
|
+
idx2.version = CONFIG.VERSION;
|
|
3896
|
+
idx2.lastMigration = new Date().toISOString();
|
|
3897
|
+
// v3.1.1 FIX: schemaVersion ставим ТОЛЬКО при успешной миграции.
|
|
3898
|
+
// Иначе провал на середине «замораживал» недомигрированный legacy навсегда (гейт больше не сработает).
|
|
3899
|
+
if (mig.success) {
|
|
3900
|
+
idx2.schemaVersion = CONFIG.SCHEMA_VERSION;
|
|
3901
|
+
migrationNote = `🔄 **Память обновлена** до схемы v${CONFIG.SCHEMA_VERSION}: проиндексировано ${mig.migrated} контекстов`;
|
|
3902
|
+
if (mig.errors.length > 0)
|
|
3903
|
+
migrationNote += ` (пропущено: ${mig.errors.length})`;
|
|
3904
|
+
migrationNote += `\n\n`;
|
|
3905
|
+
}
|
|
3906
|
+
// при неуспехе schemaVersion НЕ трогаем → следующий context_quick повторит миграцию
|
|
3907
|
+
saveMemoryIndex(idx2);
|
|
3908
|
+
}
|
|
3507
3909
|
}
|
|
3910
|
+
catch { /* автомиграция никогда не должна ломать context_quick */ }
|
|
3508
3911
|
// v3.0: Проверяем наличие дайджеста — если нет, создаём автоматически
|
|
3509
3912
|
let digest = loadDigest();
|
|
3510
3913
|
if (!digest && allContexts.length > 0) {
|
|
@@ -3514,7 +3917,7 @@ MD_HISTORY/
|
|
|
3514
3917
|
const keywordsIdx = loadKeywords();
|
|
3515
3918
|
const solutionsIdx = loadSolutions();
|
|
3516
3919
|
for (const ctx of allContexts) {
|
|
3517
|
-
const ctxId =
|
|
3920
|
+
const ctxId = contextIdOf(ctx); // v3.1 LEGACY-FIX
|
|
3518
3921
|
let summary = "";
|
|
3519
3922
|
const readmePath = path.join(ctx.path, "README.md");
|
|
3520
3923
|
if (fs.existsSync(readmePath)) {
|
|
@@ -3565,6 +3968,16 @@ MD_HISTORY/
|
|
|
3565
3968
|
digest = autoDigest;
|
|
3566
3969
|
}
|
|
3567
3970
|
const hasDigest = digest !== null;
|
|
3971
|
+
// v4.4.0 блок J: перегенерируем MD-файлы дайджеста новыми рендерами (без статусов),
|
|
3972
|
+
// чтобы старые файлы со «завершено/в работе/застряло» сразу обновились до чистого вида.
|
|
3973
|
+
if (hasDigest && digest) {
|
|
3974
|
+
try {
|
|
3975
|
+
saveDigest(digest);
|
|
3976
|
+
}
|
|
3977
|
+
catch { /* не критично */ }
|
|
3978
|
+
}
|
|
3979
|
+
// v3.1: публикуем выжимку этого репо в глобальный digest (реестр + объединение)
|
|
3980
|
+
publishToGlobalDigest();
|
|
3568
3981
|
// v2.0: Получаем Memory stats
|
|
3569
3982
|
const memoryStats = getMemoryStats();
|
|
3570
3983
|
const hasMemory = memoryStats.totalContexts > 0;
|
|
@@ -3583,8 +3996,10 @@ MD_HISTORY/
|
|
|
3583
3996
|
return text.slice(0, maxLen - 20) + "\n\n... [обрезано]";
|
|
3584
3997
|
};
|
|
3585
3998
|
let output = `# ⚡ Краткий контекст — Dedsession v${CONFIG.VERSION}\n\n`;
|
|
3999
|
+
if (migrationNote)
|
|
4000
|
+
output += migrationNote;
|
|
3586
4001
|
output += `📁 **Рабочая директория:** \`${workDir}\`\n`;
|
|
3587
|
-
output += `📊 **Статистика:** Всего ${stats.total}
|
|
4002
|
+
output += `📊 **Статистика:** Всего контекстов ${stats.total}\n`; // v4.4.0 блок J: без статусов
|
|
3588
4003
|
if (hasDigest) {
|
|
3589
4004
|
output += `📦 **Режим:** Дайджест + ${contextCount} контекстов (v3.0)\n\n`;
|
|
3590
4005
|
}
|
|
@@ -3593,9 +4008,29 @@ MD_HISTORY/
|
|
|
3593
4008
|
}
|
|
3594
4009
|
// v2.1: Правила показываются ПЕРВЫМИ
|
|
3595
4010
|
output += formatRulesBlock();
|
|
4011
|
+
// v4.1 (Ф1): СЕРВЕРНЫЙ AI-обзор проекта (GPT, надёжный — не зависит от парсинга README).
|
|
4012
|
+
// Кэш на сервере → обычно мгновенно. Оффлайн/таймаут → тихий fallback на локальный дайджест.
|
|
4013
|
+
try {
|
|
4014
|
+
const aiProj = detectProjectFromWorkDir() || "";
|
|
4015
|
+
if (aiProj && loadSyncConfig()?.token) {
|
|
4016
|
+
const ad = await dedPanelCall("mcp_digest", { project: aiProj, scope: "all" });
|
|
4017
|
+
const d = ad && ad.success ? ad.digest : null;
|
|
4018
|
+
if (d && d.summary) {
|
|
4019
|
+
output += `# 🤖 AI-обзор проекта (DedPanel)\n\n${d.summary}\n\n`;
|
|
4020
|
+
if (d.focus)
|
|
4021
|
+
output += `**🎯 Сейчас:** ${d.focus}\n\n`;
|
|
4022
|
+
if (Array.isArray(d.phases) && d.phases.length)
|
|
4023
|
+
output += `**Этапы:**\n${d.phases.map((p) => `- ${p}`).join("\n")}\n\n`;
|
|
4024
|
+
if (Array.isArray(d.stack) && d.stack.length)
|
|
4025
|
+
output += `**Стек:** ${d.stack.join(", ")}\n\n`;
|
|
4026
|
+
output += `---\n\n`;
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
catch { /* оффлайн/не сгенерилось — локальный дайджест ниже */ }
|
|
3596
4031
|
// v3.0: ДАЙДЖЕСТ ПЕРВЫМ когда есть
|
|
3597
4032
|
if (hasDigest) {
|
|
3598
|
-
output += `<claude-instruction>ОБЯЗАТЕЛЬНО: Project Digest загружен! В своём ответе пользователю ты ДОЛЖЕН:\n1. Сказать что загружен Project Digest с полной историей проекта (укажи сколько контекстов в дайджесте и период работы из statistics)\n2. Кратко описать проект на основе 00-overview.md (2-3 предложения о сути проекта)\n3. Показать последние 3-5 задач из 01-timeline.md в виде
|
|
4033
|
+
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
4034
|
const digestPath = getDigestPath();
|
|
3600
4035
|
const digestFiles = ["00-overview.md", "01-timeline.md", "02-architecture.md", "03-decisions.md", "04-solutions.md"];
|
|
3601
4036
|
for (const fileName of digestFiles) {
|
|
@@ -3687,503 +4122,13 @@ MD_HISTORY/
|
|
|
3687
4122
|
return { content: [{ type: "text", text: output }] };
|
|
3688
4123
|
}
|
|
3689
4124
|
// ==================== 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
4125
|
case "rules": {
|
|
4182
4126
|
const action = args.action || "list";
|
|
4183
4127
|
const scope = args.scope || "local";
|
|
4184
4128
|
const ruleText = args.rule;
|
|
4185
4129
|
const ruleId = args.id;
|
|
4186
4130
|
const ruleContext = args.context;
|
|
4131
|
+
const ruleTitle = (args.title || "").trim().slice(0, 80); // v4.4.0 блок C
|
|
4187
4132
|
const isGlobal = scope === "global";
|
|
4188
4133
|
const rulesIndex = isGlobal ? loadGlobalRules() : loadRules();
|
|
4189
4134
|
const scopeLabel = isGlobal ? "🌍 Глобальное" : "📁 Локальное";
|
|
@@ -4206,12 +4151,15 @@ MD_HISTORY/
|
|
|
4206
4151
|
const newRule = {
|
|
4207
4152
|
id: newId,
|
|
4208
4153
|
rule: ruleText,
|
|
4209
|
-
|
|
4154
|
+
...(ruleTitle ? { title: ruleTitle } : {}), // v4.4.0 блок C: короткий заголовок
|
|
4155
|
+
created: new Date().toISOString(), // v4.2: с временем (для merge между устройствами)
|
|
4210
4156
|
context: ruleContext,
|
|
4157
|
+
...(isGlobal ? { hash: ruleHash(ruleText), device: getDeviceName() } : {}),
|
|
4211
4158
|
};
|
|
4212
4159
|
rulesIndex.rules.push(newRule);
|
|
4213
4160
|
if (isGlobal) {
|
|
4214
4161
|
saveGlobalRules(rulesIndex);
|
|
4162
|
+
pushGlobalRulesInBackground(); // v4.2: merge-синк (union по тексту, без потерь между устройствами)
|
|
4215
4163
|
}
|
|
4216
4164
|
else {
|
|
4217
4165
|
saveRules(rulesIndex);
|
|
@@ -4247,11 +4195,18 @@ MD_HISTORY/
|
|
|
4247
4195
|
}]
|
|
4248
4196
|
};
|
|
4249
4197
|
}
|
|
4250
|
-
const removed = rulesIndex.rules
|
|
4198
|
+
const removed = rulesIndex.rules[ruleIndex];
|
|
4251
4199
|
if (isGlobal) {
|
|
4200
|
+
// v4.2: tombstone (не splice) — удаление доезжает на другие устройства через merge, не воскресает
|
|
4201
|
+
removed.deleted = true;
|
|
4202
|
+
removed.created = new Date().toISOString(); // обновляем время — tombstone выигрывает при merge
|
|
4203
|
+
if (!removed.hash)
|
|
4204
|
+
removed.hash = ruleHash(removed.rule);
|
|
4252
4205
|
saveGlobalRules(rulesIndex);
|
|
4206
|
+
pushGlobalRulesInBackground();
|
|
4253
4207
|
}
|
|
4254
4208
|
else {
|
|
4209
|
+
rulesIndex.rules.splice(ruleIndex, 1);
|
|
4255
4210
|
saveRules(rulesIndex);
|
|
4256
4211
|
}
|
|
4257
4212
|
let output = `# ✅ ${scopeLabel} правило удалено\n\n`;
|
|
@@ -4286,415 +4241,6 @@ MD_HISTORY/
|
|
|
4286
4241
|
}
|
|
4287
4242
|
}
|
|
4288
4243
|
// ========== 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
4244
|
default:
|
|
4699
4245
|
return { content: [{ type: "text", text: `❌ Неизвестный tool: ${name}` }] };
|
|
4700
4246
|
}
|