monisess 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -2
- package/dist/brief.js +186 -0
- package/dist/cli.js +229 -0
- package/dist/doctor.js +77 -0
- package/dist/frontmatter.js +128 -0
- package/dist/git.js +84 -0
- package/dist/hosted.js +509 -0
- package/dist/importer.js +136 -0
- package/dist/index.js +102 -0
- package/dist/links.js +61 -0
- package/dist/logo.js +3 -0
- package/dist/metrics.js +132 -0
- package/dist/panel-api.js +293 -0
- package/dist/paths.js +79 -0
- package/dist/pwa.js +64 -0
- package/dist/registry.js +50 -0
- package/dist/save.js +124 -0
- package/dist/secrets.js +29 -0
- package/dist/server.js +360 -0
- package/dist/store.js +549 -0
- package/dist/themes.js +37 -0
- package/dist/transcript.js +124 -0
- package/dist/ui-page.js +2427 -0
- package/dist/ui.js +175 -0
- package/dist/wiki.js +110 -0
- package/package.json +37 -4
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Monisess
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Память проекта для Claude Code: факты, задачи и журнал сессий лежат в репозитории
|
|
4
|
+
(`.monisess/`, обычный markdown), поисковый индекс — локально. MCP-сервер по stdio
|
|
5
|
+
плюс два хука: бриф в начале сессии и автоснимок перед сжатием контекста.
|
|
6
|
+
|
|
7
|
+
Первый модуль будущего «мониторинга всего»: сущности (карточка, факт, задача, сессия)
|
|
8
|
+
выбраны так, чтобы к ним потом подключались другие источники, а не только Claude.
|
|
9
|
+
|
|
10
|
+
## Чем отличается от dedsession
|
|
11
|
+
|
|
12
|
+
| | dedsession | Monisess |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Единица знания | сессия (простыня из 15 полей) | факт — один файл; устаревший помечается `supersedes`, а не копится |
|
|
15
|
+
| Хвосты | список внутри контекста | задача с id и статусом, дубли не плодятся |
|
|
16
|
+
| Индекс | JSON в git → конфликты при merge | SQLite FTS5 в `~/.monisess/cache`, пересборка из файлов |
|
|
17
|
+
| Правда о работе | что написала модель | плюс git: HEAD, коммиты и файлы с прошлой сессии — автоматически |
|
|
18
|
+
| Сжатие контекста | ничего не знает | хук `PreCompact` снимает снимок; `SessionStart(compact)` дописывает сводку |
|
|
19
|
+
| Описания инструментов | ~15 КБ уговоров в каждой сессии | 8 инструментов, ~5 КБ; правила приходят с брифом один раз |
|
|
20
|
+
| Секреты | эвристика | вырезаются при любой записи, в отчёте — сколько |
|
|
21
|
+
| Зависимости | — | только `@modelcontextprotocol/sdk` и `zod`; SQLite встроен в Node ≥ 22.5 |
|
|
22
|
+
|
|
23
|
+
## Установка
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx -y monisess install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Ставит пакет глобально, подключает MCP-сервер на уровне пользователя и хуки SessionStart/PreCompact.
|
|
30
|
+
Нужны Node ≥ 22.5 и Claude Code. Обновление — `npm install -g monisess`.
|
|
31
|
+
|
|
32
|
+
Из исходников (для разработки): клон репозитория и `./install.sh` — собирает, делает `npm link` и `monisess install`.
|
|
33
|
+
|
|
34
|
+
В проекте, где память нужна: `monisess import` (если есть `MD_HISTORY` от dedsession)
|
|
35
|
+
или просто первый `save` из Claude. В проектах без `.monisess/` хуки молчат.
|
|
36
|
+
|
|
37
|
+
Подробное руководство — [docs/GUIDE.md](docs/GUIDE.md): установка, подключение проектов,
|
|
38
|
+
связь между проектами, формат файлов, диагностика.
|
|
39
|
+
|
|
40
|
+
## Инструменты
|
|
41
|
+
|
|
42
|
+
- `brief(topic?)` — карточка, git-дельта, открытые задачи, факты, последние сессии.
|
|
43
|
+
- `save(title, summary, done?, decisions?, problems?, remaining?, facts?, close_tasks?, card?)`.
|
|
44
|
+
- `recall(query | id)` — поиск (BM25, кириллица, префиксы) или запись целиком.
|
|
45
|
+
- `fact` / `task` / `note` — точечные операции.
|
|
46
|
+
- `projects()` — проекты с памятью на машине; `brief(project=…)`, `recall(scope=all)` — связь между ними.
|
|
47
|
+
- `doctor(fix?)`, `import_md_history(source?, apply?)`.
|
|
48
|
+
|
|
49
|
+
## Панель
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
monisess ui # http://127.0.0.1:4620, только localhost
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Обзор всех проектов с памятью (последняя сессия, открытые и протухшие задачи, коммиты,
|
|
56
|
+
о которых в памяти ни слова), лента проекта — сессии вперемешку с коммитами git,
|
|
57
|
+
задачи, факты с историей `supersedes`, поиск по всем проектам. Одна страница,
|
|
58
|
+
без зависимостей и сетевых вызовов.
|
|
59
|
+
|
|
60
|
+
### Захват мыслей
|
|
61
|
+
|
|
62
|
+
На странице «Задачи» — поле ввода и выбор проекта: мысль ложится **в память этого
|
|
63
|
+
проекта** (`.monisess/tasks/` или `notes.md`) тем же способом, каким пишет Claude,
|
|
64
|
+
и поэтому сама всплывает в его брифе в начале следующей сессии. Отдельного списка
|
|
65
|
+
дел у панели нет — хранилище одно. Захваченное за последние 7 дней видно и из
|
|
66
|
+
брифов соседних проектов, чтобы мысль не ждала, пока откроют именно тот репозиторий.
|
|
67
|
+
Кнопка «готово» закрывает задачу — то же, что `task(op=done)`.
|
|
68
|
+
|
|
69
|
+
## Структура памяти
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
.monisess/
|
|
73
|
+
project.md карточка проекта
|
|
74
|
+
facts/F001-….md id, type (project|decision|lesson|rule|reference), status, tags, supersedes
|
|
75
|
+
tasks/T001-….md status open|done|dropped, source — сессия, из которой задача
|
|
76
|
+
sessions/S001-….md kind manual|auto|imported, head, branch, files; тело — Итог/Сделано/Решения/Проблемы/Осталось
|
|
77
|
+
notes.md заметки на бегу; вливаются в следующую сессию
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Разработка
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm test # node:test, без сборки
|
|
84
|
+
npm run build # tsc → dist/
|
|
85
|
+
```
|
package/dist/brief.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { commitsSince, gitState } from "./git.js";
|
|
2
|
+
import { knownProjects } from "./registry.js";
|
|
3
|
+
import { resolveProject } from "./paths.js";
|
|
4
|
+
import { Store, sortTasks, today } from "./store.js";
|
|
5
|
+
const DEFAULT_BUDGET = 14_000;
|
|
6
|
+
export function buildBrief(store, index, opts = {}) {
|
|
7
|
+
const budget = opts.budget ?? DEFAULT_BUDGET;
|
|
8
|
+
const parts = [];
|
|
9
|
+
const name = store.project.name;
|
|
10
|
+
if (!store.exists()) {
|
|
11
|
+
return [
|
|
12
|
+
`# Память проекта ${name}: пусто`,
|
|
13
|
+
"",
|
|
14
|
+
"Папка `.monisess/` ещё не создана. Первый `save` создаст её; карточку проекта заполняет `save(card=…)`.",
|
|
15
|
+
gitHint(store),
|
|
16
|
+
].join("\n");
|
|
17
|
+
}
|
|
18
|
+
const card = store.card();
|
|
19
|
+
// Карточка обычно начинается со своего заголовка — второй сверху не нужен.
|
|
20
|
+
parts.push(card.startsWith("# ") ? clip(card, Math.floor(budget * 0.25)) : `# ${name} — память проекта` + (card ? `\n\n${clip(card, Math.floor(budget * 0.25))}` : ""));
|
|
21
|
+
parts.push(gitSection(store));
|
|
22
|
+
const open = store.openTasks();
|
|
23
|
+
if (open.length) {
|
|
24
|
+
const sorted = sortTasks(open);
|
|
25
|
+
const lines = sorted.slice(0, 25).map((t) => `- ${taskLine(t)}`);
|
|
26
|
+
if (open.length > 25)
|
|
27
|
+
lines.push(`- … ещё ${open.length - 25}, см. task(op=list)`);
|
|
28
|
+
const overdue = open.filter((t) => t.due && t.due < today()).length;
|
|
29
|
+
const head = `## Открытые задачи (${open.length}${overdue ? `, просрочено ${overdue}` : ""})`;
|
|
30
|
+
parts.push(`${head}\n${lines.join("\n")}`);
|
|
31
|
+
}
|
|
32
|
+
// Страницы — только оглавлением: текст паспорта тянется по запросу page(title),
|
|
33
|
+
// иначе бриф и так за бюджетом (T009) раздулся бы ещё на десятки килобайт.
|
|
34
|
+
const pages = store.pages();
|
|
35
|
+
if (pages.length)
|
|
36
|
+
parts.push(`## Вики (${pages.length})\n${pages.map((p) => p.title).join(" · ")}\n_Прочитать: page(title=…)._`);
|
|
37
|
+
const notes = store.notes();
|
|
38
|
+
if (notes.length)
|
|
39
|
+
parts.push(`## Заметки на бегу\n${notes.map((n) => `- ${n}`).join("\n")}`);
|
|
40
|
+
const sessions = store.sessions();
|
|
41
|
+
const recent = sessions.slice(-3).reverse();
|
|
42
|
+
if (recent.length) {
|
|
43
|
+
parts.push(`## Последние сессии (всего ${sessions.length})\n` +
|
|
44
|
+
recent.map((s) => `### ${s.id} · ${s.date} · ${s.title}${s.kind === "auto" ? " (авто)" : ""}\n${tldr(s)}`).join("\n\n"));
|
|
45
|
+
}
|
|
46
|
+
const facts = store.facts().filter((f) => f.status === "active");
|
|
47
|
+
if (facts.length) {
|
|
48
|
+
const chosen = opts.topic ? rankedFacts(store, index, opts.topic, facts) : defaultFacts(facts);
|
|
49
|
+
const title = opts.topic ? `## Факты по теме «${opts.topic}»` : `## Факты (активных ${facts.length}${facts.length > chosen.length ? `, показаны ${chosen.length}` : ""})`;
|
|
50
|
+
parts.push(`${title}\n${chosen.map(formatFact).join("\n")}`);
|
|
51
|
+
}
|
|
52
|
+
if (opts.topic) {
|
|
53
|
+
const hits = index.search(opts.topic, { kind: "session", limit: 5 });
|
|
54
|
+
if (hits.length) {
|
|
55
|
+
parts.push(`## Сессии по теме «${opts.topic}»\n${hits.map((h) => `- ${h.id} · ${h.date} · ${h.title} — ${h.snippet}`).join("\n")}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const elsewhere = capturedElsewhere(store);
|
|
59
|
+
if (elsewhere)
|
|
60
|
+
parts.push(elsewhere);
|
|
61
|
+
const others = knownProjects().filter((e) => e.key !== store.project.key);
|
|
62
|
+
if (others.length)
|
|
63
|
+
parts.push(`## Другие проекты с памятью\n${others.map((e) => `- ${e.name} (был ${e.lastSeen}) — \`brief(project="${e.name}")\``).join("\n")}`);
|
|
64
|
+
parts.push("_Дальше: `recall(query)` — поиск по фактам и сессиям, `recall(id=S012)` — сессия целиком, " +
|
|
65
|
+
"`brief(topic=…)` — срез по теме. По окончании работы — `save`._");
|
|
66
|
+
return fit(parts, budget);
|
|
67
|
+
}
|
|
68
|
+
function gitSection(store) {
|
|
69
|
+
const git = gitState(store.project.root);
|
|
70
|
+
if (!git.available)
|
|
71
|
+
return "## Git\nне репозиторий";
|
|
72
|
+
const last = store.lastSession();
|
|
73
|
+
const lines = [`ветка ${git.branch}, HEAD ${git.head}${git.dirty.length ? `, незакоммичено: ${git.dirty.length}` : ""}`];
|
|
74
|
+
if (last?.head) {
|
|
75
|
+
const commits = commitsSince(store.project.root, last.head, 15);
|
|
76
|
+
if (commits === null)
|
|
77
|
+
lines.push(`с прошлой сессии (${last.id}, HEAD ${last.head}): коммит не найден в истории — возможно, другая ветка`);
|
|
78
|
+
else if (commits.length === 0)
|
|
79
|
+
lines.push(`с прошлой сессии (${last.id}) новых коммитов нет`);
|
|
80
|
+
else {
|
|
81
|
+
lines.push(`с прошлой сессии (${last.id}, ${last.date}) — ${commits.length}${commits.length === 15 ? "+" : ""} коммитов:`);
|
|
82
|
+
for (const c of commits)
|
|
83
|
+
lines.push(` ${c.hash} ${c.date} ${c.subject}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return `## Git\n${lines.join("\n")}`;
|
|
87
|
+
}
|
|
88
|
+
/** Строка задачи: пометки словами, а не значками — их читает и человек, и модель. */
|
|
89
|
+
export function taskLine(t) {
|
|
90
|
+
const flags = [];
|
|
91
|
+
if (t.status === "doing")
|
|
92
|
+
flags.push("в работе");
|
|
93
|
+
if (t.due)
|
|
94
|
+
flags.push(t.due < today() ? `срок ${t.due} — ПРОСРОЧЕНО` : `срок ${t.due}`);
|
|
95
|
+
if (t.priority === "high")
|
|
96
|
+
flags.push("важно");
|
|
97
|
+
if (t.priority === "low")
|
|
98
|
+
flags.push("не срочно");
|
|
99
|
+
if (t.tags.length)
|
|
100
|
+
flags.push(t.tags.join(", "));
|
|
101
|
+
return `${t.id} (${t.created})${flags.length ? ` [${flags.join("; ")}]` : ""}: ${t.text}`;
|
|
102
|
+
}
|
|
103
|
+
/** Сколько дней захваченная на панели мысль видна из других проектов. */
|
|
104
|
+
const CAPTURE_WINDOW_DAYS = 7;
|
|
105
|
+
/**
|
|
106
|
+
* Свежие захваты с панели в соседних проектах.
|
|
107
|
+
*
|
|
108
|
+
* Мысль записывают там, где она пришла в голову, а работают потом в другом
|
|
109
|
+
* проекте — и она лежит незамеченной до следующего открытия того репозитория.
|
|
110
|
+
* Поэтому только что захваченное (источник panel) видно из любого брифа, но
|
|
111
|
+
* узким окном и списком: это напоминание разобрать, а не вторая память.
|
|
112
|
+
*/
|
|
113
|
+
function capturedElsewhere(store) {
|
|
114
|
+
const cutoff = Date.now() - CAPTURE_WINDOW_DAYS * 86_400_000;
|
|
115
|
+
const lines = [];
|
|
116
|
+
for (const e of knownProjects()) {
|
|
117
|
+
if (e.key === store.project.key)
|
|
118
|
+
continue;
|
|
119
|
+
try {
|
|
120
|
+
const other = new Store(resolveProject(e.root));
|
|
121
|
+
for (const t of sortTasks(other.openTasks())) {
|
|
122
|
+
// Просроченное показываем независимо от возраста: срок важнее даты захвата.
|
|
123
|
+
const overdue = Boolean(t.due) && t.due < today();
|
|
124
|
+
if (t.source !== "panel" || (Date.parse(t.created) < cutoff && !overdue))
|
|
125
|
+
continue;
|
|
126
|
+
lines.push(`- ${e.name} ${taskLine(t)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Соседний проект мог уехать или быть недоступен — не повод ломать бриф.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!lines.length)
|
|
134
|
+
return null;
|
|
135
|
+
const shown = lines.slice(0, 8);
|
|
136
|
+
if (lines.length > shown.length)
|
|
137
|
+
shown.push(`- … ещё ${lines.length - shown.length}`);
|
|
138
|
+
return `## Захвачено с панели в других проектах (за ${CAPTURE_WINDOW_DAYS} дней)\n${shown.join("\n")}`;
|
|
139
|
+
}
|
|
140
|
+
function gitHint(store) {
|
|
141
|
+
const git = gitState(store.project.root);
|
|
142
|
+
return git.available ? `Git: ветка ${git.branch}, HEAD ${git.head}.` : "";
|
|
143
|
+
}
|
|
144
|
+
/** Первый содержательный абзац сессии — обычно раздел «Итог». */
|
|
145
|
+
export function tldr(s, max = 600) {
|
|
146
|
+
const body = s.body.replace(/^#+\s*Итог\s*\n/m, "");
|
|
147
|
+
const para = body.split(/\n\s*\n/).find((p) => p.trim() && !p.trim().startsWith("#")) ?? "";
|
|
148
|
+
return clip(para.trim(), max);
|
|
149
|
+
}
|
|
150
|
+
function defaultFacts(facts) {
|
|
151
|
+
// Правила и уроки — всегда: их нарушение стоит дороже, чем лишние строки в брифе.
|
|
152
|
+
const priority = facts.filter((f) => f.type === "rule" || f.type === "lesson");
|
|
153
|
+
const rest = facts.filter((f) => f.type !== "rule" && f.type !== "lesson").slice(-15).reverse();
|
|
154
|
+
return [...priority, ...rest];
|
|
155
|
+
}
|
|
156
|
+
function rankedFacts(store, index, topic, facts) {
|
|
157
|
+
const hits = index.search(topic, { kind: "fact", limit: 15, activeOnly: true });
|
|
158
|
+
const byId = new Map(facts.map((f) => [f.id, f]));
|
|
159
|
+
const ranked = hits.map((h) => byId.get(h.id)).filter((f) => Boolean(f));
|
|
160
|
+
const rules = facts.filter((f) => f.type === "rule" && !ranked.includes(f));
|
|
161
|
+
return [...ranked, ...rules];
|
|
162
|
+
}
|
|
163
|
+
export function formatFact(f) {
|
|
164
|
+
const tags = f.tags.length ? ` [${f.tags.join(", ")}]` : "";
|
|
165
|
+
return `- ${f.id} ${f.type}${tags}: ${f.text.replace(/\s+/g, " ")}`;
|
|
166
|
+
}
|
|
167
|
+
export function clip(text, max) {
|
|
168
|
+
if (text.length <= max)
|
|
169
|
+
return text;
|
|
170
|
+
const cut = text.slice(0, max);
|
|
171
|
+
const at = Math.max(cut.lastIndexOf("\n"), cut.lastIndexOf(". "));
|
|
172
|
+
return (at > max * 0.6 ? cut.slice(0, at + 1) : cut).trimEnd() + " …";
|
|
173
|
+
}
|
|
174
|
+
/** Режет разделы с конца (менее важные), пока не влезет в бюджет; заголовок не трогает. */
|
|
175
|
+
function fit(parts, budget) {
|
|
176
|
+
let total = parts.reduce((n, p) => n + p.length + 2, 0);
|
|
177
|
+
for (let i = parts.length - 2; i > 0 && total > budget; i--) {
|
|
178
|
+
const over = total - budget;
|
|
179
|
+
const keep = Math.max(200, parts[i].length - over);
|
|
180
|
+
if (keep < parts[i].length) {
|
|
181
|
+
parts[i] = clip(parts[i], keep);
|
|
182
|
+
total = parts.reduce((n, p) => n + p.length + 2, 0);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return parts.join("\n\n");
|
|
186
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { buildBrief } from "./brief.js";
|
|
7
|
+
import { doctor } from "./doctor.js";
|
|
8
|
+
import { SearchIndex } from "./index.js";
|
|
9
|
+
import { applyImport, planImport } from "./importer.js";
|
|
10
|
+
import { resolveProject } from "./paths.js";
|
|
11
|
+
import { saveSession } from "./save.js";
|
|
12
|
+
import { serve } from "./server.js";
|
|
13
|
+
import { knownProjects, touchRegistry } from "./registry.js";
|
|
14
|
+
import { Store } from "./store.js";
|
|
15
|
+
import { extract, guessTranscript, renderSnapshot } from "./transcript.js";
|
|
16
|
+
import { startUi, uiAddress } from "./ui.js";
|
|
17
|
+
import { hostedAddress, startHosted } from "./hosted.js";
|
|
18
|
+
const USAGE = `monisess — память проекта для Claude Code
|
|
19
|
+
|
|
20
|
+
monisess запустить MCP-сервер (stdio)
|
|
21
|
+
monisess brief [--topic T] напечатать бриф проекта
|
|
22
|
+
monisess hook session-start хук SessionStart: бриф в контекст, после сжатия — снимок
|
|
23
|
+
monisess hook pre-compact хук PreCompact: автоснимок сессии до сжатия
|
|
24
|
+
monisess install подключить MCP-сервер и хуки в Claude Code (уровень пользователя)
|
|
25
|
+
monisess import [MD_HISTORY] [--apply] перенести контексты dedsession
|
|
26
|
+
monisess doctor [--fix] проверка памяти
|
|
27
|
+
monisess projects проекты с памятью на этой машине
|
|
28
|
+
monisess ui [--port 4620] локальная панель в браузере
|
|
29
|
+
monisess hosted сервер monisess.com (нужны GITHUB_CLIENT_ID/SECRET, SESSION_SECRET)
|
|
30
|
+
`;
|
|
31
|
+
/** Правила поведения едут вместе с брифом: коротко и один раз за сессию. */
|
|
32
|
+
const PREAMBLE = "Monisess: память проекта подключена (инструменты brief/save/recall/fact/task/note). " +
|
|
33
|
+
"Закончил задачу или человек сказал «сохрани» — вызови save с итогом, решениями, проблемами и списком «осталось». " +
|
|
34
|
+
"Новое долговременное знание — fact; устаревший факт заменяй новым через supersedes; мысль на бегу — note.";
|
|
35
|
+
async function main(argv) {
|
|
36
|
+
const [cmd, ...rest] = argv;
|
|
37
|
+
const flag = (name) => {
|
|
38
|
+
const i = rest.indexOf(`--${name}`);
|
|
39
|
+
return i >= 0 ? rest[i + 1] : undefined;
|
|
40
|
+
};
|
|
41
|
+
const has = (name) => rest.includes(`--${name}`);
|
|
42
|
+
switch (cmd) {
|
|
43
|
+
case undefined:
|
|
44
|
+
case "serve":
|
|
45
|
+
// Сервер живёт, пока Claude Code держит stdio: выходить после connect нельзя.
|
|
46
|
+
await serve();
|
|
47
|
+
return null;
|
|
48
|
+
case "brief": {
|
|
49
|
+
const store = new Store(resolveProject());
|
|
50
|
+
const index = new SearchIndex(store);
|
|
51
|
+
process.stdout.write(buildBrief(store, index, { topic: flag("topic") }) + "\n");
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
case "hook":
|
|
55
|
+
return hook(rest[0] ?? "");
|
|
56
|
+
case "install":
|
|
57
|
+
return install();
|
|
58
|
+
case "import": {
|
|
59
|
+
const project = resolveProject();
|
|
60
|
+
const store = new Store(project);
|
|
61
|
+
const dir = path.resolve(project.root, rest.find((a) => !a.startsWith("--")) ?? "MD_HISTORY");
|
|
62
|
+
const plans = planImport(dir);
|
|
63
|
+
if (!has("apply")) {
|
|
64
|
+
console.log(`К импорту ${plans.length} контекстов из ${dir} (добавьте --apply):`);
|
|
65
|
+
for (const p of plans)
|
|
66
|
+
console.log(` ${p.date} ${p.title}`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
const ids = applyImport(store, plans);
|
|
70
|
+
console.log(`Импортировано сессий: ${ids.length}`);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
case "ui": {
|
|
74
|
+
// Панель показывает проекты из реестра: текущий (если у него есть память) отмечаем сами,
|
|
75
|
+
// чтобы её не приходилось «прогревать» отдельной сессией Claude.
|
|
76
|
+
touchRegistry(resolveProject());
|
|
77
|
+
const server = await startUi(Number(flag("port") ?? 4620));
|
|
78
|
+
console.log(`Панель monisess: ${uiAddress(server)} (Ctrl+C — выход)`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
case "hosted": {
|
|
82
|
+
const server = await startHosted();
|
|
83
|
+
console.log(`Хостовый режим: ${hostedAddress(server)} (за Caddy)`);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
case "projects": {
|
|
87
|
+
for (const e of knownProjects())
|
|
88
|
+
console.log(`${e.name}\t${e.root}\t${e.lastSeen}`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
case "doctor": {
|
|
92
|
+
const store = new Store(resolveProject());
|
|
93
|
+
console.log(doctor(store, new SearchIndex(store), has("fix")));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
process.stderr.write(USAGE);
|
|
98
|
+
return cmd === "help" || cmd === "--help" ? 0 : 2;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function readStdinJson() {
|
|
102
|
+
try {
|
|
103
|
+
const raw = fs.readFileSync(0, "utf8");
|
|
104
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Хуки не должны ломать сессию: любая ошибка внутри — молча в stderr и код 0.
|
|
112
|
+
* Автоснимки делаются только там, где память уже заведена: создавать .monisess/
|
|
113
|
+
* в каждом репозитории, куда заглянул Claude, было бы навязчиво.
|
|
114
|
+
*/
|
|
115
|
+
function hook(event) {
|
|
116
|
+
const payload = readStdinJson();
|
|
117
|
+
const project = resolveProject(payload.cwd || process.cwd());
|
|
118
|
+
const store = new Store(project);
|
|
119
|
+
try {
|
|
120
|
+
if (event === "session-start") {
|
|
121
|
+
if (payload.source === "compact") {
|
|
122
|
+
const made = snapshot(store, payload, "compact");
|
|
123
|
+
if (made)
|
|
124
|
+
process.stdout.write(`Monisess: автоснимок сессии сохранён (${made}).\n`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
// Без заведённой памяти молчим: хук стоит на всех проектах, а шуметь в чужих не надо.
|
|
128
|
+
if (!store.exists())
|
|
129
|
+
return 0;
|
|
130
|
+
touchRegistry(project);
|
|
131
|
+
const index = new SearchIndex(store);
|
|
132
|
+
const brief = buildBrief(store, index, {});
|
|
133
|
+
process.stdout.write(`${PREAMBLE}\n\n${brief}\n`);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
if (event === "pre-compact") {
|
|
137
|
+
snapshot(store, payload, "pre-compact");
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
process.stderr.write(`monisess hook: неизвестное событие «${event}»\n`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
process.stderr.write(`monisess hook: ${err.message}\n`);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function snapshot(store, payload, phase) {
|
|
149
|
+
if (!store.exists())
|
|
150
|
+
return null;
|
|
151
|
+
const transcript = payload.transcript_path || guessTranscript(store.project.root);
|
|
152
|
+
if (!transcript)
|
|
153
|
+
return null;
|
|
154
|
+
const index = new SearchIndex(store);
|
|
155
|
+
const stateKey = `transcript:${transcript}`;
|
|
156
|
+
const e = extract(transcript, index.getState(stateKey));
|
|
157
|
+
if (!e.lastUuid)
|
|
158
|
+
return null;
|
|
159
|
+
const rendered = renderSnapshot(e, store.project.root);
|
|
160
|
+
const last = store.lastSession();
|
|
161
|
+
const recentAuto = last && last.kind === "auto" && Date.now() - Date.parse(last.date.replace(" ", "T")) < 20 * 60_000;
|
|
162
|
+
let id = null;
|
|
163
|
+
if (phase === "compact" && e.compactSummary && recentAuto && last) {
|
|
164
|
+
// Снимок до сжатия уже есть — дописываем к нему сводку, а не плодим вторую запись.
|
|
165
|
+
const current = fs.readFileSync(last.file, "utf8").replace(/\s+$/, "");
|
|
166
|
+
fs.writeFileSync(last.file, `${current}\n\n## Сводка сжатия контекста\n${e.compactSummary.slice(0, 12_000)}\n`, "utf8");
|
|
167
|
+
id = last.id;
|
|
168
|
+
}
|
|
169
|
+
else if (e.compactSummary || e.prompts.length || e.files.length) {
|
|
170
|
+
id = saveSession(store, { ...rendered, kind: "auto" }).session.id;
|
|
171
|
+
}
|
|
172
|
+
index.setState(stateKey, e.lastUuid);
|
|
173
|
+
return id;
|
|
174
|
+
}
|
|
175
|
+
/** Регистрирует сервер и хуки на уровне пользователя; повторный запуск ничего не дублирует. */
|
|
176
|
+
function install() {
|
|
177
|
+
let bin = process.argv[1];
|
|
178
|
+
// Через `npx monisess install` пакет лежит во временном кэше, который npm чистит сам —
|
|
179
|
+
// путь оттуда в настройках однажды перестанет работать. Ставим тот же выпуск глобально.
|
|
180
|
+
if (bin.includes(`${path.sep}_npx${path.sep}`)) {
|
|
181
|
+
const pkg = path.join(path.dirname(fs.realpathSync(bin)), "..", "package.json");
|
|
182
|
+
const { version } = JSON.parse(fs.readFileSync(pkg, "utf8"));
|
|
183
|
+
execFileSync("npm", ["install", "--global", `monisess@${version}`], { stdio: "inherit" });
|
|
184
|
+
bin = path.join(execFileSync("npm", ["root", "--global"], { encoding: "utf8" }).trim(), "monisess", "dist", "cli.js");
|
|
185
|
+
}
|
|
186
|
+
const command = fs.existsSync(bin) && path.basename(bin) === "cli.js" ? `node "${bin}"` : "monisess";
|
|
187
|
+
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
188
|
+
let settings = {};
|
|
189
|
+
try {
|
|
190
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// файла ещё нет — создадим
|
|
194
|
+
}
|
|
195
|
+
const hooks = (settings.hooks ??= {});
|
|
196
|
+
const ensure = (event, cmd) => {
|
|
197
|
+
const list = (hooks[event] ??= []);
|
|
198
|
+
const present = list.some((entry) => entry.hooks?.some((h) => h.command.includes("monisess hook") || h.command.includes(`${command} hook`)));
|
|
199
|
+
if (!present)
|
|
200
|
+
list.push({ matcher: "", hooks: [{ type: "command", command: cmd }] });
|
|
201
|
+
return !present;
|
|
202
|
+
};
|
|
203
|
+
const added = [ensure("SessionStart", `${command} hook session-start`), ensure("PreCompact", `${command} hook pre-compact`)];
|
|
204
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
205
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
206
|
+
console.log(`Хуки в ${settingsPath}: ${added.filter(Boolean).length ? "добавлены" : "уже были"}`);
|
|
207
|
+
try {
|
|
208
|
+
execFileSync("claude", ["mcp", "get", "monisess"], { stdio: "ignore" });
|
|
209
|
+
console.log("MCP-сервер monisess уже подключён");
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
try {
|
|
213
|
+
const args = ["mcp", "add", "--scope", "user", "monisess", "--", ...(command === "monisess" ? ["monisess"] : ["node", bin])];
|
|
214
|
+
execFileSync("claude", args, { stdio: "inherit" });
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
console.log(`Не удалось вызвать claude CLI. Подключите вручную:\n claude mcp add --scope user monisess -- ${command}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
console.log("Готово. Новая сессия Claude Code получит бриф автоматически.");
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
main(process.argv.slice(2)).then((code) => {
|
|
224
|
+
if (code !== null)
|
|
225
|
+
process.exit(code);
|
|
226
|
+
}, (err) => {
|
|
227
|
+
process.stderr.write(`monisess: ${err.stack ?? err}\n`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
});
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parse, str } from "./frontmatter.js";
|
|
4
|
+
import { commitExists, commitsSince, gitState } from "./git.js";
|
|
5
|
+
import { readText } from "./paths.js";
|
|
6
|
+
/** Отчёт о состоянии памяти. Чинит только производное (индекс); записи человека не трогает. */
|
|
7
|
+
export function doctor(store, index, fix) {
|
|
8
|
+
const lines = [`# Monisess doctor — ${store.project.name}`, `папка: ${store.dir}`, `кеш: ${store.project.cache}`];
|
|
9
|
+
if (!store.exists()) {
|
|
10
|
+
lines.push("Память не создана (первый save создаст).");
|
|
11
|
+
if (fs.existsSync(path.join(store.project.root, "MD_HISTORY")))
|
|
12
|
+
lines.push("Есть MD_HISTORY от dedsession — можно импортировать: import_md_history.");
|
|
13
|
+
return lines.join("\n");
|
|
14
|
+
}
|
|
15
|
+
const problems = [];
|
|
16
|
+
let ok = 0;
|
|
17
|
+
for (const sub of ["facts", "tasks", "sessions"]) {
|
|
18
|
+
const dir = path.join(store.dir, sub);
|
|
19
|
+
if (!fs.existsSync(dir))
|
|
20
|
+
continue;
|
|
21
|
+
for (const f of fs.readdirSync(dir)) {
|
|
22
|
+
if (!f.endsWith(".md"))
|
|
23
|
+
continue;
|
|
24
|
+
const doc = parse(readText(path.join(dir, f)) ?? "");
|
|
25
|
+
if (!str(doc.meta, "id"))
|
|
26
|
+
problems.push(`${sub}/${f}: нет id во фронтматтере`);
|
|
27
|
+
else if (!f.startsWith(str(doc.meta, "id")))
|
|
28
|
+
problems.push(`${sub}/${f}: имя файла не совпадает с id ${str(doc.meta, "id")}`);
|
|
29
|
+
else
|
|
30
|
+
ok += 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
lines.push(`записей: ${ok}${problems.length ? `, с проблемами: ${problems.length}` : ""}`);
|
|
34
|
+
lines.push(...problems.map((p) => ` ✗ ${p}`));
|
|
35
|
+
const facts = store.facts();
|
|
36
|
+
const ids = new Set(facts.map((f) => f.id));
|
|
37
|
+
const dupes = facts.map((f) => f.id).filter((id, i, arr) => arr.indexOf(id) !== i);
|
|
38
|
+
if (dupes.length)
|
|
39
|
+
lines.push(`✗ дубли id фактов: ${[...new Set(dupes)].join(", ")}`);
|
|
40
|
+
for (const f of facts) {
|
|
41
|
+
if (f.supersedes && !ids.has(f.supersedes))
|
|
42
|
+
lines.push(`✗ ${f.id} ссылается на несуществующий ${f.supersedes}`);
|
|
43
|
+
if (f.status === "active" && facts.some((g) => g.supersedes === f.id && g.status === "active")) {
|
|
44
|
+
lines.push(`✗ ${f.id} активен, хотя заменён другим активным фактом`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const rebuilt = index.sync(fix);
|
|
48
|
+
lines.push(`индекс: ${index.count()} документов${rebuilt ? " (пересобран)" : ""}`);
|
|
49
|
+
const git = gitState(store.project.root);
|
|
50
|
+
if (git.available) {
|
|
51
|
+
const dirtyMem = git.dirty.filter((l) => l.includes(".monisess/")).length;
|
|
52
|
+
if (dirtyMem)
|
|
53
|
+
lines.push(`⚠ в .monisess/ незакоммиченных изменений: ${dirtyMem} — память не уедет на другие машины`);
|
|
54
|
+
const last = store.lastSession();
|
|
55
|
+
if (last?.head) {
|
|
56
|
+
if (!commitExists(store.project.root, last.head))
|
|
57
|
+
lines.push(`⚠ HEAD последней сессии ${last.head} не найден в истории`);
|
|
58
|
+
else {
|
|
59
|
+
const n = commitsSince(store.project.root, last.head, 200)?.length ?? 0;
|
|
60
|
+
if (n > 5)
|
|
61
|
+
lines.push(`⚠ с последней сессии ${n} коммитов без сохранённого контекста`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const open = store.openTasks();
|
|
66
|
+
const stale = open.filter((t) => Date.now() - Date.parse(t.created) > 60 * 86_400_000);
|
|
67
|
+
if (stale.length)
|
|
68
|
+
lines.push(`⚠ задач старше 60 дней: ${stale.map((t) => t.id).join(", ")} — закрыть или подтвердить`);
|
|
69
|
+
if (fs.existsSync(path.join(store.project.root, "MD_HISTORY"))) {
|
|
70
|
+
const imported = store.sessions().filter((s) => s.kind === "imported").length;
|
|
71
|
+
if (!imported)
|
|
72
|
+
lines.push("Есть MD_HISTORY от dedsession, ещё не импортирована: import_md_history.");
|
|
73
|
+
}
|
|
74
|
+
if (!problems.length && !dupes.length)
|
|
75
|
+
lines.push("✓ записи целы");
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|