dsh-desktop-kit 0.0.0-stage → 2.0.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.
Files changed (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -2
  3. package/client.js +4101 -0
  4. package/cordis.patch.yml +5 -0
  5. package/docs/ARCHITECTURE.md +114 -0
  6. package/docs/COMPATIBILITY.md +61 -0
  7. package/docs/INSTALL.md +84 -0
  8. package/docs/PRIVACY.md +50 -0
  9. package/docs/ROADMAP.md +57 -0
  10. package/docs/TOKENS.md +118 -0
  11. package/docs/TROUBLESHOOTING.md +68 -0
  12. package/docs/images/hero.png +0 -0
  13. package/index.js +84 -0
  14. package/package.json +70 -4
  15. package/plugins/dsh-gkb-ru-ui/cordis.patch.yml +6 -0
  16. package/plugins/dsh-gkb-ru-ui/lib/client.js +1331 -0
  17. package/plugins/dsh-gkb-ru-ui/lib/index.js +186 -0
  18. package/plugins/dsh-gkb-ru-ui/lib/skills.js +570 -0
  19. package/plugins/dsh-gkb-ru-ui/package.json +28 -0
  20. package/plugins/dsh-gkb-ru-ui/screenshots/01-home-tiles-1440-light.png +0 -0
  21. package/plugins/dsh-gkb-ru-ui/screenshots/02-skills-1440-light.png +0 -0
  22. package/plugins/dsh-gkb-ru-ui/screenshots/03-skills-editor-1440-light.png +0 -0
  23. package/plugins/dsh-gkb-ru-ui/screenshots/04-skills-1440-dark.png +0 -0
  24. package/plugins/dsh-gkb-ru-ui/screenshots/05-skills-390-light.png +0 -0
  25. package/plugins/dsh-gkb-ru-ui/screenshots/06-home-tiles-1440-dark.png +0 -0
  26. package/plugins/dsh-peak-guard/README.md +70 -0
  27. package/plugins/dsh-peak-guard/client.js +741 -0
  28. package/plugins/dsh-peak-guard/cordis.patch.yml +5 -0
  29. package/plugins/dsh-peak-guard/icon.svg +12 -0
  30. package/plugins/dsh-peak-guard/index.js +2 -0
  31. package/plugins/dsh-peak-guard/locale/en.json +6 -0
  32. package/plugins/dsh-peak-guard/locale/ru.json +6 -0
  33. package/plugins/dsh-peak-guard/locale/zh.json +6 -0
  34. package/plugins/dsh-peak-guard/package.json +33 -0
  35. package/plugins/dsh-peak-guard/screenshots/01-real-time-peak-start.png +0 -0
  36. package/plugins/dsh-peak-guard/screenshots/02-real-time-peak-end.png +0 -0
  37. package/plugins/dsh-peak-guard/screenshots/03-peak-card-1440-light.png +0 -0
  38. package/plugins/dsh-peak-guard/screenshots/04-peak-card-1440-dark.png +0 -0
  39. package/plugins/dsh-peak-guard/screenshots/05-peak-card-390-light.png +0 -0
  40. package/plugins/dsh-peak-guard/screenshots/05-peak-card-768-light.png +0 -0
  41. package/plugins/dsh-peak-guard/screenshots/06-peak-card-390-light.png +0 -0
  42. package/plugins/dsh-peak-guard/screenshots/07-live-preview-check.png +0 -0
  43. package/plugins/dsh-peak-guard/screenshots/07-pre-peak-1440-light.png +0 -0
  44. package/plugins/dsh-peak-guard/screenshots/08-peak-start-1440-light.png +0 -0
  45. package/plugins/dsh-peak-guard/screenshots/09-peak-end-1440-light.png +0 -0
  46. package/plugins/dsh-peak-guard/screenshots/10-pre-peak-card-1440-light.png +0 -0
  47. package/plugins/dsh-peak-guard/screenshots/11-pre-peak-card-1440-dark.png +0 -0
  48. package/plugins/dsh-peak-guard/screenshots/12-pre-peak-card-390-light.png +0 -0
  49. package/plugins/dsh-peak-guard/screenshots/qa-report.json +74 -0
  50. package/plugins/dsh-profile-transfer/README.md +62 -0
  51. package/plugins/dsh-profile-transfer/bootstrap/01-INSTALL.cmd +7 -0
  52. package/plugins/dsh-profile-transfer/bootstrap/README-ARCHIVE.md +54 -0
  53. package/plugins/dsh-profile-transfer/bootstrap/README-/320/243/320/241/320/242/320/220/320/235/320/236/320/222/320/232/320/220.md +42 -0
  54. package/plugins/dsh-profile-transfer/bootstrap/install-on-clean-machine.ps1 +119 -0
  55. package/plugins/dsh-profile-transfer/bootstrap//320/243/320/241/320/242/320/220/320/235/320/236/320/222/320/230/320/242/320/254.cmd +5 -0
  56. package/plugins/dsh-profile-transfer/client.js +806 -0
  57. package/plugins/dsh-profile-transfer/cordis.patch.yml +3 -0
  58. package/plugins/dsh-profile-transfer/icon.svg +13 -0
  59. package/plugins/dsh-profile-transfer/index.js +375 -0
  60. package/plugins/dsh-profile-transfer/locale/en.json +6 -0
  61. package/plugins/dsh-profile-transfer/locale/ru.json +6 -0
  62. package/plugins/dsh-profile-transfer/locale/zh.json +6 -0
  63. package/plugins/dsh-profile-transfer/package.json +36 -0
  64. package/plugins/dsh-profile-transfer/screenshots/01-settings-transfer-1440-light.png +0 -0
  65. package/plugins/dsh-profile-transfer/screenshots/02-settings-transfer-preview-1440-light.png +0 -0
  66. package/plugins/dsh-profile-transfer/screenshots/03-settings-transfer-1440-dark.png +0 -0
  67. package/plugins/dsh-profile-transfer/screenshots/04-settings-transfer-390-light.png +0 -0
  68. package/plugins/dsh-profile-transfer/screenshots/05-settings-transfer-768-light.png +0 -0
  69. package/plugins/dsh-profile-transfer/screenshots/06-settings-transfer-390-dark.png +0 -0
  70. package/plugins/dsh-profile-transfer/screenshots/07-settings-transfer-export-result.png +0 -0
  71. package/plugins/dsh-profile-transfer/screenshots/08-settings-transfer-workspaces-1440-light.png +0 -0
  72. package/plugins/dsh-profile-transfer/screenshots/09-settings-transfer-workspaces-1440-dark.png +0 -0
  73. package/plugins/dsh-profile-transfer/screenshots/10-settings-transfer-workspaces-390-light.png +0 -0
  74. package/plugins/dsh-profile-transfer/transfer.js +1004 -0
  75. package/plugins/dsh-project-memory/README.md +71 -0
  76. package/plugins/dsh-project-memory/cordis.patch.yml +4 -0
  77. package/plugins/dsh-project-memory/icon.svg +12 -0
  78. package/plugins/dsh-project-memory/index.js +1240 -0
  79. package/plugins/dsh-project-memory/locale/en.json +6 -0
  80. package/plugins/dsh-project-memory/locale/ru.json +6 -0
  81. package/plugins/dsh-project-memory/locale/zh.json +6 -0
  82. package/plugins/dsh-project-memory/package.json +26 -0
  83. package/plugins/dsh-session-move/README.md +67 -0
  84. package/plugins/dsh-session-move/client.js +719 -0
  85. package/plugins/dsh-session-move/cordis.patch.yml +5 -0
  86. package/plugins/dsh-session-move/icon.svg +14 -0
  87. package/plugins/dsh-session-move/index.js +497 -0
  88. package/plugins/dsh-session-move/locale/en.json +6 -0
  89. package/plugins/dsh-session-move/locale/ru.json +6 -0
  90. package/plugins/dsh-session-move/locale/zh.json +6 -0
  91. package/plugins/dsh-session-move/package.json +31 -0
  92. package/plugins/dsh-session-move/screenshots/01-row-menu-1440-light.png +0 -0
  93. package/plugins/dsh-session-move/screenshots/02-dialog-1440-light.png +0 -0
  94. package/plugins/dsh-session-move/screenshots/03-dialog-390-light.png +0 -0
  95. package/plugins/dsh-session-move/screenshots/04-dialog-1440-dark.png +0 -0
  96. package/plugins/dsh-session-move/screenshots/05-drag-highlight-1440-light.png +0 -0
  97. package/plugins/dsh-session-move/screenshots/06-drop-ungrouped-highlight.png +0 -0
  98. package/plugins/dsh-session-move/screenshots/07-after-drop-ungrouped.png +0 -0
  99. package/plugins/dsh-session-move/screenshots/08-move-dialog.png +0 -0
  100. package/plugins/dsh-session-move/screenshots/09-moved-to-second-workspace.png +0 -0
  101. package/plugins/dsh-session-move/screenshots/qa-report.json +62 -0
  102. package/plugins/dsh-skill-autopilot/cordis.patch.yml +3 -0
  103. package/plugins/dsh-skill-autopilot/index.js +444 -0
  104. package/plugins/dsh-skill-autopilot/package.json +19 -0
  105. package/plugins/dsh-token-lab/cordis.patch.yml +3 -0
  106. package/plugins/dsh-token-lab/lib/client.js +504 -0
  107. package/plugins/dsh-token-lab/lib/index.js +275 -0
  108. package/plugins/dsh-token-lab/package.json +26 -0
  109. package/plugins/dsh-token-lab/screenshots/01-token-lab-1440-light.png +0 -0
  110. package/plugins/dsh-token-lab/screenshots/02-token-lab-1440-dark.png +0 -0
  111. package/skills/code-review-checklist/SKILL.md +55 -0
  112. package/skills/content-writing/README.md +87 -0
  113. package/skills/content-writing/SKILL.md +85 -0
  114. package/skills/content-writing/assets/checklists/fact-check.md +53 -0
  115. package/skills/content-writing/assets/checklists/publish.md +65 -0
  116. package/skills/content-writing/assets/checklists/social.md +40 -0
  117. package/skills/content-writing/assets/templates/article.md +47 -0
  118. package/skills/content-writing/assets/templates/brief.md +44 -0
  119. package/skills/content-writing/assets/templates/doctor-card.md +41 -0
  120. package/skills/content-writing/assets/templates/image-prompts.md +128 -0
  121. package/skills/content-writing/assets/templates/post-telegram.md +36 -0
  122. package/skills/content-writing/assets/templates/post-vk.md +42 -0
  123. package/skills/content-writing/assets/templates/press-release.md +56 -0
  124. package/skills/content-writing/assets/templates/science-pop-note.md +55 -0
  125. package/skills/content-writing/assets/templates/service-card.md +46 -0
  126. package/skills/content-writing/references/anti-ai-style.md +248 -0
  127. package/skills/content-writing/references/editing.md +185 -0
  128. package/skills/content-writing/references/fact-checking.md +205 -0
  129. package/skills/content-writing/references/government-medical.md +246 -0
  130. package/skills/content-writing/references/images.md +212 -0
  131. package/skills/content-writing/references/journalism.md +252 -0
  132. package/skills/content-writing/references/process.md +123 -0
  133. package/skills/content-writing/references/quick-recipes.md +99 -0
  134. package/skills/content-writing/references/rules.md +85 -0
  135. package/skills/content-writing/references/science-popular.md +218 -0
  136. package/skills/content-writing/references/seo-content.md +172 -0
  137. package/skills/content-writing/references/social-media.md +253 -0
  138. package/skills/content-writing/references/sources.md +49 -0
  139. package/skills/content-writing/references/templates.md +291 -0
  140. package/skills/context-economy/SKILL.md +60 -0
  141. package/skills/context-economy/references/long-session.md +35 -0
  142. package/skills/context-economy/references/token-math.md +42 -0
  143. package/skills/context-economy/references/tool-recipes.md +34 -0
  144. package/skills/database-safety/SKILL.md +47 -0
  145. package/skills/debugging-playbook/SKILL.md +42 -0
  146. package/skills/design-critique/SKILL.md +65 -0
  147. package/skills/design-critique/references/a11y.md +25 -0
  148. package/skills/design-critique/references/diagnoses.md +33 -0
  149. package/skills/design-critique/references/quick-tests.md +30 -0
  150. package/skills/design-critique/references/report-template.md +63 -0
  151. package/skills/design-critique/references/rubric-fast.md +73 -0
  152. package/skills/dev-workflow/SKILL.md +48 -0
  153. package/skills/dev-workflow/references/definition-of-done.md +44 -0
  154. package/skills/dev-workflow/references/memory-and-boundaries.md +31 -0
  155. package/skills/dev-workflow/references/stages.md +67 -0
  156. package/skills/git-and-ci/SKILL.md +60 -0
  157. package/skills/pg-upgrade-verify/SKILL.md +67 -0
  158. package/skills/project-deep-dive/SKILL.md +45 -0
  159. package/skills/project-deep-dive/references/recon-commands.md +90 -0
  160. package/skills/project-deep-dive/references/section-templates.md +104 -0
  161. package/skills/project-deep-dive/references/tool-and-process.md +86 -0
  162. package/skills/project-memory-maintenance/SKILL.md +55 -0
  163. package/skills/project-memory-maintenance/references/api-and-checklist.md +99 -0
  164. package/skills/project-memory-maintenance/references/journal-template.md +50 -0
  165. package/skills/project-memory-maintenance/references/section-rules.md +59 -0
  166. package/skills/release-and-deploy/SKILL.md +53 -0
  167. package/skills/requirements-and-planning/SKILL.md +49 -0
  168. package/skills/requirements-and-planning/references/antipatterns-and-checklist.md +20 -0
  169. package/skills/requirements-and-planning/references/process-steps.md +52 -0
  170. package/skills/requirements-and-planning/references/templates-and-estimates.md +29 -0
  171. package/skills/security-review/SKILL.md +40 -0
  172. package/skills/seo/README.md +60 -0
  173. package/skills/seo/SKILL.md +77 -0
  174. package/skills/seo/assets/README.md +66 -0
  175. package/skills/seo/assets/scripts/LICENSE-playwright-core.txt +202 -0
  176. package/skills/seo/assets/scripts/README.md +179 -0
  177. package/skills/seo/assets/scripts/lib/args.mjs +105 -0
  178. package/skills/seo/assets/scripts/lib/browser.mjs +620 -0
  179. package/skills/seo/assets/scripts/lib/zip.mjs +191 -0
  180. package/skills/seo/assets/scripts/seo-audit.mjs +1675 -0
  181. package/skills/seo/assets/snippets/jsonld-breadcrumb.json +30 -0
  182. package/skills/seo/assets/snippets/jsonld-faq.json +39 -0
  183. package/skills/seo/assets/snippets/jsonld-medical-clinic.json +49 -0
  184. package/skills/seo/assets/snippets/jsonld-medical-webpage.json +33 -0
  185. package/skills/seo/assets/snippets/jsonld-organization.json +74 -0
  186. package/skills/seo/assets/snippets/jsonld-physician.json +46 -0
  187. package/skills/seo/assets/snippets/meta.blade.php +61 -0
  188. package/skills/seo/assets/snippets/robots.txt +61 -0
  189. package/skills/seo/assets/snippets/sitemap-urlset-example.xml +72 -0
  190. package/skills/seo/assets/snippets/sitemap.xml +36 -0
  191. package/skills/seo/references/antipatterns.md +66 -0
  192. package/skills/seo/references/audit-checklist.md +319 -0
  193. package/skills/seo/references/content-seo.md +204 -0
  194. package/skills/seo/references/eeat-medical.md +219 -0
  195. package/skills/seo/references/measurement.md +250 -0
  196. package/skills/seo/references/on-page.md +214 -0
  197. package/skills/seo/references/page-templates.md +56 -0
  198. package/skills/seo/references/stack-recipes.md +695 -0
  199. package/skills/seo/references/structured-data.md +487 -0
  200. package/skills/seo/references/technical-seo.md +296 -0
  201. package/skills/service-desk-conventions/SKILL.md +52 -0
  202. package/skills/service-desk-db-safety/SKILL.md +63 -0
  203. package/skills/service-desk-release/SKILL.md +61 -0
  204. package/skills/service-desk-testing/SKILL.md +61 -0
  205. package/skills/service-desk-ui/SKILL.md +52 -0
  206. package/skills/skills-router/SKILL.md +53 -0
  207. package/skills/skills-router/references/applying-skills.md +25 -0
  208. package/skills/skills-router/references/routing-matrix.md +52 -0
  209. package/skills/testing-strategy/SKILL.md +52 -0
  210. package/skills/ui-design-system/SKILL.md +50 -0
  211. package/skills/ui-design-system/references/accessibility-and-responsive.md +50 -0
  212. package/skills/ui-design-system/references/tokens-and-components.md +67 -0
  213. package/skills/ui-design-system/references/visual-qa.md +44 -0
  214. package/skills/web-research/SKILL.md +47 -0
  215. package/skills/web-research/references/answer-and-citations.md +60 -0
  216. package/skills/web-research/references/query-recipes.md +44 -0
  217. package/skills/wow-design/SKILL.md +89 -0
  218. package/skills/wow-design/assets/kit/README.md +195 -0
  219. package/skills/wow-design/assets/kit/assets/css/base.css +158 -0
  220. package/skills/wow-design/assets/kit/assets/css/components.css +1001 -0
  221. package/skills/wow-design/assets/kit/assets/css/fonts.css +73 -0
  222. package/skills/wow-design/assets/kit/assets/css/pages.css +737 -0
  223. package/skills/wow-design/assets/kit/assets/css/tokens.css +218 -0
  224. package/skills/wow-design/assets/kit/assets/fonts/LICENSE-golos-text.txt +93 -0
  225. package/skills/wow-design/assets/kit/assets/fonts/LICENSE-lora.txt +93 -0
  226. package/skills/wow-design/assets/kit/assets/fonts/LICENSE-unbounded.txt +93 -0
  227. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-400-normal.woff2 +0 -0
  228. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-500-normal.woff2 +0 -0
  229. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-600-normal.woff2 +0 -0
  230. package/skills/wow-design/assets/kit/assets/fonts/golos-text-cyrillic-700-normal.woff2 +0 -0
  231. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-400-normal.woff2 +0 -0
  232. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-500-normal.woff2 +0 -0
  233. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-600-normal.woff2 +0 -0
  234. package/skills/wow-design/assets/kit/assets/fonts/golos-text-latin-700-normal.woff2 +0 -0
  235. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-400-italic.woff2 +0 -0
  236. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-400-normal.woff2 +0 -0
  237. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-500-normal.woff2 +0 -0
  238. package/skills/wow-design/assets/kit/assets/fonts/lora-cyrillic-600-normal.woff2 +0 -0
  239. package/skills/wow-design/assets/kit/assets/fonts/lora-latin-400-normal.woff2 +0 -0
  240. package/skills/wow-design/assets/kit/assets/fonts/lora-latin-500-normal.woff2 +0 -0
  241. package/skills/wow-design/assets/kit/assets/fonts/lora-latin-600-normal.woff2 +0 -0
  242. package/skills/wow-design/assets/kit/assets/fonts/unbounded-cyrillic-400-normal.woff2 +0 -0
  243. package/skills/wow-design/assets/kit/assets/fonts/unbounded-cyrillic-500-normal.woff2 +0 -0
  244. package/skills/wow-design/assets/kit/assets/fonts/unbounded-cyrillic-600-normal.woff2 +0 -0
  245. package/skills/wow-design/assets/kit/assets/fonts/unbounded-latin-400-normal.woff2 +0 -0
  246. package/skills/wow-design/assets/kit/assets/fonts/unbounded-latin-500-normal.woff2 +0 -0
  247. package/skills/wow-design/assets/kit/assets/fonts/unbounded-latin-600-normal.woff2 +0 -0
  248. package/skills/wow-design/assets/kit/assets/js/kit.js +550 -0
  249. package/skills/wow-design/assets/kit/pages/dashboard.html +539 -0
  250. package/skills/wow-design/assets/kit/pages/hospital.html +430 -0
  251. package/skills/wow-design/assets/kit/showcase.html +1164 -0
  252. package/skills/wow-design/assets/scripts/LICENSE-axe-core.txt +362 -0
  253. package/skills/wow-design/assets/scripts/LICENSE-playwright-core.txt +202 -0
  254. package/skills/wow-design/assets/scripts/README.md +312 -0
  255. package/skills/wow-design/assets/scripts/audit.mjs +1200 -0
  256. package/skills/wow-design/assets/scripts/capture.mjs +337 -0
  257. package/skills/wow-design/assets/scripts/package.json +30 -0
  258. package/skills/wow-design/references/anti-patterns.md +252 -0
  259. package/skills/wow-design/references/critique-rubric.md +262 -0
  260. package/skills/wow-design/references/design-brief-template.md +48 -0
  261. package/skills/wow-design/references/inspiration.md +203 -0
  262. package/skills/wow-design/references/presets.md +99 -0
  263. package/skills/wow-design/references/principles.md +371 -0
  264. package/skills/wow-design/references/quick-presets.md +50 -0
  265. package/skills/wow-design/references/report-template.md +21 -0
  266. package/skills/wow-design/references/stack-recipes.md +96 -0
  267. package/skills/wow-design/references/visual-qa.md +82 -0
  268. package/skills/wow-design/references/wow-levers.md +43 -0
  269. package/tools/skill-cost.mjs +76 -0
@@ -0,0 +1,1240 @@
1
+ /**
2
+ * dsh-project-memory — Host half.
3
+ *
4
+ * Local, user-only long-term memory per project:
5
+ * - resolves the project of a Session from its effective working directory,
6
+ * - auto-injects a compact dossier digest as dynamic prompt context,
7
+ * - exposes the `project_memory` tool for reading / searching / updating the dossier,
8
+ * - appends a deterministic JOURNAL entry after every work turn,
9
+ * - keeps a freshness marker (git head / last analysis) in the digest,
10
+ * - offers a user command `/remember` that asks the agent to deep-dive the project.
11
+ *
12
+ * Everything is stored under <DSH_HOME>/projects-memory. Nothing is sent over the network
13
+ * except the ordinary model prompt that carries the digest.
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { randomUUID } from "node:crypto";
20
+
21
+ const MEMORY_ROOT = path.join(process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh"), "projects-memory");
22
+ const REGISTRY_FILE = path.join(MEMORY_ROOT, "registry.json");
23
+ const STATE_FILE = path.join(MEMORY_ROOT, "_state.json");
24
+ const ACCESS_LOG = path.join(MEMORY_ROOT, "_access.log");
25
+ const ACCESS_LOG_MAX_LINES = 300;
26
+ const DEFAULT_DIGEST_BUDGET = 2800;
27
+ const SECTION_MAX_BYTES = 60 * 1024;
28
+ const SECTION_NAMES = ["PROJECT", "ARCHITECTURE", "CODE-MAP", "INVARIANTS", "GOTCHAS", "RUNBOOK", "JOURNAL"];
29
+ const SECTION_FILES = {
30
+ PROJECT: "PROJECT.md",
31
+ ARCHITECTURE: "ARCHITECTURE.md",
32
+ "CODE-MAP": "CODE-MAP.md",
33
+ INVARIANTS: "INVARIANTS.md",
34
+ GOTCHAS: "GOTCHAS.md",
35
+ RUNBOOK: "RUNBOOK.md",
36
+ JOURNAL: "JOURNAL",
37
+ };
38
+ const PROJECT_MARKERS = [".git", "AGENTS.md", "composer.json", "package.json", "artisan", "wp-config.php", "pyproject.toml", "go.mod", "Cargo.toml", "Gemfile", "bitrix"];
39
+ const CURATOR_IDLE_MS = 4 * 60 * 1000;
40
+ const STATE_TTL_MS = 7 * 24 * 3600 * 1000;
41
+
42
+ export const inject = ["systemPrompt", "tools", "sessions", "workingDirectory"];
43
+
44
+ /* ------------------------------------------------------------------ */
45
+ /* Small helpers */
46
+ /* ------------------------------------------------------------------ */
47
+
48
+ function nowIso() {
49
+ return new Date().toISOString();
50
+ }
51
+
52
+ function localStamp() {
53
+ const d = new Date();
54
+ const pad = (value) => String(value).padStart(2, "0");
55
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
56
+ }
57
+
58
+ function monthKey() {
59
+ const d = new Date();
60
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
61
+ }
62
+
63
+ function normalizePath(value) {
64
+ if (typeof value !== "string" || value.length === 0) return "";
65
+ let result = path.resolve(value).replaceAll("\\", "/").replace(/\/+$/u, "");
66
+ if (process.platform === "win32") result = result.toLowerCase();
67
+ return result;
68
+ }
69
+
70
+ function isInside(parent, child) {
71
+ const a = normalizePath(parent);
72
+ const b = normalizePath(child);
73
+ return b === a || b.startsWith(`${a}/`);
74
+ }
75
+
76
+ function slugify(value) {
77
+ const base = String(value ?? "")
78
+ .trim()
79
+ .toLowerCase()
80
+ .replace(/[^a-z0-9\u0400-\u04ff]+/gu, "-")
81
+ .replace(/^-+|-+$/gu, "");
82
+ return base.length > 0 ? base.slice(0, 48) : "project";
83
+ }
84
+
85
+ function readText(filePath) {
86
+ try {
87
+ return fs.readFileSync(filePath, "utf8");
88
+ } catch {
89
+ return "";
90
+ }
91
+ }
92
+
93
+ function writeText(filePath, text) {
94
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
95
+ fs.writeFileSync(filePath, text, "utf8");
96
+ }
97
+
98
+ function appendText(filePath, text) {
99
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
100
+ fs.appendFileSync(filePath, text, "utf8");
101
+ }
102
+
103
+ function exists(filePath) {
104
+ try {
105
+ return fs.existsSync(filePath);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /** Strip obvious secrets before any dossier write. */
112
+ function redactSecrets(text) {
113
+ let result = String(text ?? "");
114
+ const patterns = [
115
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu,
116
+ /\bsk-[A-Za-z0-9_-]{16,}\b/gu,
117
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/gu,
118
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gu,
119
+ /\bAKIA[0-9A-Z]{16}\b/gu,
120
+ /\b(api[_-]?key|access[_-]?token|secret|password|passwd|pwd)\s*[:=]\s*["']?[^\s"',;]{6,}/giu,
121
+ ];
122
+ for (const pattern of patterns) result = result.replace(pattern, "[REDACTED]");
123
+ return result;
124
+ }
125
+
126
+ function truncate(text, max) {
127
+ const value = String(text ?? "");
128
+ return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`;
129
+ }
130
+
131
+ function logAccess(line) {
132
+ try {
133
+ fs.mkdirSync(MEMORY_ROOT, { recursive: true });
134
+ appendText(ACCESS_LOG, `${nowIso()} ${line}\n`);
135
+ const content = readText(ACCESS_LOG);
136
+ const lines = content.split(/\r?\n/u);
137
+ if (lines.length > ACCESS_LOG_MAX_LINES) {
138
+ writeText(ACCESS_LOG, `${lines.slice(-ACCESS_LOG_MAX_LINES).join("\n")}\n`);
139
+ }
140
+ } catch {
141
+ /* diagnostics must never break a request */
142
+ }
143
+ }
144
+
145
+ /* ------------------------------------------------------------------ */
146
+ /* Registry and state */
147
+ /* ------------------------------------------------------------------ */
148
+
149
+ function defaultRegistry() {
150
+ return {
151
+ version: 1,
152
+ settings: {
153
+ digestBudgetChars: DEFAULT_DIGEST_BUDGET,
154
+ injectSubagents: false,
155
+ autoRegister: true,
156
+ curator: { enabled: true, idleMs: CURATOR_IDLE_MS },
157
+ },
158
+ projects: [],
159
+ };
160
+ }
161
+
162
+ function loadRegistry() {
163
+ const fallback = defaultRegistry();
164
+ try {
165
+ const raw = fs.readFileSync(REGISTRY_FILE, "utf8");
166
+ const parsed = JSON.parse(raw);
167
+ if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.projects)) return fallback;
168
+ return { ...fallback, ...parsed, settings: { ...fallback.settings, ...(parsed.settings ?? {}) } };
169
+ } catch {
170
+ return fallback;
171
+ }
172
+ }
173
+
174
+ function saveRegistry(registry) {
175
+ try {
176
+ writeText(REGISTRY_FILE, `${JSON.stringify(registry, null, 2)}\n`);
177
+ } catch {
178
+ /* keep serving from memory */
179
+ }
180
+ }
181
+
182
+ function loadState() {
183
+ try {
184
+ const parsed = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
185
+ if (parsed && parsed.version === 1 && parsed.sessions && typeof parsed.sessions === "object") return parsed.sessions;
186
+ } catch {
187
+ /* first run */
188
+ }
189
+ return {};
190
+ }
191
+
192
+ function saveState(sessions) {
193
+ try {
194
+ const cutoff = Date.now() - STATE_TTL_MS;
195
+ for (const [sessionId, entry] of Object.entries(sessions)) {
196
+ if (typeof entry?.updatedAt !== "number" || entry.updatedAt < cutoff) delete sessions[sessionId];
197
+ }
198
+ writeText(STATE_FILE, `${JSON.stringify({ version: 1, sessions }, null, 2)}\n`);
199
+ } catch {
200
+ /* state is only an optimization */
201
+ }
202
+ }
203
+
204
+ /* ------------------------------------------------------------------ */
205
+ /* Git freshness */
206
+ /* ------------------------------------------------------------------ */
207
+
208
+ function gitInfo(root) {
209
+ try {
210
+ const gitDir = path.join(root, ".git");
211
+ if (!exists(gitDir)) return undefined;
212
+ const headRaw = readText(path.join(gitDir, "HEAD")).trim();
213
+ if (headRaw.startsWith("ref: ")) {
214
+ const ref = headRaw.slice(5).trim();
215
+ const head = readText(path.join(gitDir, ...ref.split("/"))).trim();
216
+ return { branch: ref.replace(/^refs\/heads\//u, ""), head: head.slice(0, 12) || "unknown" };
217
+ }
218
+ return { branch: "detached", head: headRaw.slice(0, 12) || "unknown" };
219
+ } catch {
220
+ return undefined;
221
+ }
222
+ }
223
+
224
+ /* ------------------------------------------------------------------ */
225
+ /* Project resolution and dossier creation */
226
+ /* ------------------------------------------------------------------ */
227
+
228
+ function projectPublic(project) {
229
+ return {
230
+ slug: project.slug,
231
+ name: project.name,
232
+ roots: [...project.roots],
233
+ dossier: path.join(MEMORY_ROOT, project.slug),
234
+ legacyDocs: Array.isArray(project.legacyDocs) ? [...project.legacyDocs] : [],
235
+ createdAt: project.createdAt,
236
+ updatedAt: project.updatedAt,
237
+ lastAnalyzedAt: project.lastAnalyzedAt ?? null,
238
+ lastJournalAt: project.lastJournalAt ?? null,
239
+ lastGitHead: project.lastGitHead ?? null,
240
+ confidence: project.confidence ?? "auto",
241
+ };
242
+ }
243
+
244
+ function findProject(registry, cwd) {
245
+ const target = normalizePath(cwd);
246
+ if (target.length === 0) return undefined;
247
+ let best;
248
+ let bestLength = -1;
249
+ for (const project of registry.projects) {
250
+ for (const root of project.roots ?? []) {
251
+ const normalized = normalizePath(root);
252
+ if (normalized.length === 0) continue;
253
+ if (isInside(normalized, target) && normalized.length > bestLength) {
254
+ best = project;
255
+ bestLength = normalized.length;
256
+ }
257
+ }
258
+ }
259
+ return best ? projectPublic(best) : undefined;
260
+ }
261
+
262
+ function detectProjectRoot(cwd) {
263
+ let current = path.resolve(cwd);
264
+ const root = path.parse(current).root;
265
+ for (let depth = 0; depth < 8; depth += 1) {
266
+ if (PROJECT_MARKERS.some((marker) => exists(path.join(current, marker)))) return current;
267
+ const parent = path.dirname(current);
268
+ if (parent === current || current === root) return undefined;
269
+ current = parent;
270
+ }
271
+ return undefined;
272
+ }
273
+
274
+ function defaultProjectFiles(project) {
275
+ const roots = project.roots.map((root) => `\`${root}\``).join(", ");
276
+ const git = gitInfo(project.roots[0]);
277
+ return {
278
+ "PROJECT.md": [
279
+ `# ${project.name}`,
280
+ "",
281
+ `- Slug: \`${project.slug}\``,
282
+ `- Корни: ${roots}`,
283
+ `- Создано: ${project.createdAt}`,
284
+ `- Уверенность памяти: ${project.confidence === "auto" ? "низкая (авто-создание, требуется анализ)" : "средняя"}`,
285
+ "",
286
+ "## Паспорт",
287
+ "- Назначение: (не заполнено)",
288
+ `- Стек: ${project.stack ?? "(не определён)"}`,
289
+ "- Прод: (не заполнено)",
290
+ "- Как запускать локально: (не заполнено)",
291
+ "",
292
+ "## Статус памяти",
293
+ `- Последний глубокий анализ: ${project.lastAnalyzedAt ?? "—"}`,
294
+ `- Git: ${git ? `${git.branch}@${git.head}` : "не git или неизвестно"}`,
295
+ "",
296
+ "## Ссылки",
297
+ ...(project.legacyDocs ?? []).map((doc) => `- ${doc}`),
298
+ ...(project.legacyDocs?.length ? [] : ["- (нет)"]),
299
+ "",
300
+ "<!-- Разделы ЧТО/ЗАЧЕМ/КАК заполняются скиллом project-deep-dive. Не копируйте секреты. -->",
301
+ "",
302
+ ].join("\n"),
303
+ "ARCHITECTURE.md": `# Архитектура — ${project.name}
304
+
305
+ ## Обзор
306
+ (не заполнено)
307
+
308
+ ## Модули и слои
309
+ (не заполнено)
310
+
311
+ ## Потоки данных и интеграции
312
+ (не заполнено)
313
+
314
+ ## Схема данных
315
+ (не заполнено)
316
+ `,
317
+ "CODE-MAP.md": `# Карта кода — ${project.name}
318
+
319
+ > Ссылки на файлы и строки вместо копирования кода. Уверенность помечать [проверено]/[выведено]/[устарело].
320
+
321
+ ## Точки входа
322
+ (не заполнено)
323
+
324
+ ## Важные файлы и маршруты
325
+ (не заполнено)
326
+
327
+ ## Соглашения
328
+ (не заполнено)
329
+ `,
330
+ "INVARIANTS.md": `# Инварианты и запреты — ${project.name}
331
+
332
+ - (не заполнено) Например: «боевая БД не трогать без бэкапа», «миграции только вперёд», «не менять .env на проде».
333
+ `,
334
+ "GOTCHAS.md": `# Грабли и решения — ${project.name}
335
+
336
+ ## Шаблон записи
337
+ Симптом: (что видно)
338
+ Причина: (почему так)
339
+ Решение: (что сделали)
340
+ Как обнаружить заранее: (проверка/тест/лог)
341
+ Уверенность: [проверено]
342
+
343
+ ## Записи
344
+ (не заполнено)
345
+ `,
346
+ "RUNBOOK.md": `# Runbook — ${project.name}
347
+
348
+ ## Локальный запуск
349
+ (не заполнено)
350
+
351
+ ## Тесты
352
+ (не заполнено)
353
+
354
+ ## Сборка / деплой
355
+ (не заполнено)
356
+
357
+ ## Бэкап / откат
358
+ (не заполнено)
359
+ `,
360
+ };
361
+ }
362
+
363
+ function ensureProjectDossier(project, options = {}) {
364
+ const directory = path.join(MEMORY_ROOT, project.slug);
365
+ try {
366
+ fs.mkdirSync(path.join(directory, "JOURNAL"), { recursive: true });
367
+ const files = defaultProjectFiles(project);
368
+ for (const [name, content] of Object.entries(files)) {
369
+ const target = path.join(directory, name);
370
+ if (!exists(target)) writeText(target, content);
371
+ }
372
+ const indexTarget = path.join(directory, "INDEX.json");
373
+ if (!exists(indexTarget)) {
374
+ writeText(indexTarget, `${JSON.stringify({
375
+ slug: project.slug,
376
+ name: project.name,
377
+ roots: project.roots,
378
+ generatedAt: nowIso(),
379
+ sections: Object.keys(files).map((name) => ({ file: name, bytes: fs.statSync(path.join(directory, name)).size })),
380
+ lastAnalyzedAt: project.lastAnalyzedAt ?? null,
381
+ lastGitHead: project.lastGitHead ?? null,
382
+ confidence: project.confidence ?? "auto",
383
+ }, null, 2)}\n`);
384
+ }
385
+ if (options.legacyNote) appendText(path.join(directory, "PROJECT.md"), `\n- Связь с существующей памятью: ${options.legacyNote}\n`);
386
+ return directory;
387
+ } catch (error) {
388
+ throw new Error(`cannot create project dossier: ${String(error)}`);
389
+ }
390
+ }
391
+
392
+ function createProject(registry, { name, root, stack, legacyDocs, autoCreated, confidence }) {
393
+ if (typeof name !== "string" || name.trim().length === 0) throw new Error("project name is required");
394
+ if (typeof root !== "string" || root.trim().length === 0) throw new Error("project root is required");
395
+ const resolvedRoot = path.resolve(root);
396
+ if (!exists(resolvedRoot) || !fs.statSync(resolvedRoot).isDirectory()) throw new Error(`project root does not exist: ${resolvedRoot}`);
397
+ const existing = registry.projects.find((project) => project.roots.some((entry) => normalizePath(entry) === normalizePath(resolvedRoot)));
398
+ if (existing) return projectPublic(existing);
399
+ let slug = slugify(name);
400
+ let counter = 2;
401
+ while (registry.projects.some((project) => project.slug === slug) || exists(path.join(MEMORY_ROOT, slug))) {
402
+ slug = `${slugify(name)}-${counter}`;
403
+ counter += 1;
404
+ }
405
+ const project = {
406
+ slug,
407
+ name: name.trim(),
408
+ roots: [resolvedRoot],
409
+ legacyDocs: Array.isArray(legacyDocs) ? legacyDocs.slice(0, 20) : [],
410
+ stack: typeof stack === "string" ? stack.slice(0, 200) : undefined,
411
+ createdAt: nowIso(),
412
+ updatedAt: nowIso(),
413
+ lastAnalyzedAt: null,
414
+ lastJournalAt: null,
415
+ lastGitHead: null,
416
+ confidence: confidence ?? (autoCreated ? "auto" : "manual"),
417
+ autoCreated: autoCreated === true,
418
+ };
419
+ registry.projects.push(project);
420
+ saveRegistry(registry);
421
+ ensureProjectDossier(project);
422
+ logAccess(`project-created slug=${project.slug} root=${resolvedRoot} auto=${project.autoCreated}`);
423
+ return projectPublic(project);
424
+ }
425
+
426
+ function resolveProjectForSession(registry, session, settings, options = {}) {
427
+ if (!session) return undefined;
428
+ const cwd = options.cwd ?? sessionCwd(session);
429
+ const known = findProject(registry, cwd);
430
+ if (known) return known;
431
+ if (settings.autoRegister !== true) return undefined;
432
+ const detected = detectProjectRoot(cwd);
433
+ if (!detected) return undefined;
434
+ if (isInside(MEMORY_ROOT, detected)) return undefined;
435
+ const name = path.basename(detected);
436
+ return createProject(registry, { name, root: detected, autoCreated: true });
437
+ }
438
+
439
+ function sessionCwd(session) {
440
+ // Prefer the effective working directory projection; fall back to the durable header.
441
+ try {
442
+ const cwd = session.__pmWorkingDirectory;
443
+ if (typeof cwd === "string" && cwd.length > 0) return cwd;
444
+ } catch {
445
+ /* ignore */
446
+ }
447
+ return session?.header?.cwd ?? "";
448
+ }
449
+
450
+ /* ------------------------------------------------------------------ */
451
+ /* Dossier reading and writing */
452
+ /* ------------------------------------------------------------------ */
453
+
454
+ function dossierDir(project) {
455
+ return path.join(MEMORY_ROOT, project.slug);
456
+ }
457
+
458
+ function sectionPath(project, file) {
459
+ const directory = dossierDir(project);
460
+ if (file === "JOURNAL") return path.join(directory, "JOURNAL", `${monthKey()}.md`);
461
+ return path.join(directory, SECTION_FILES[file] ?? SECTION_FILES.PROJECT);
462
+ }
463
+
464
+ function readSection(project, file = "PROJECT", maxChars = 12000) {
465
+ const target = sectionPath(project, file);
466
+ const content = readText(target);
467
+ if (content.length === 0) {
468
+ return `Файл ${path.basename(target)} для проекта «${project.name}» ещё не заполнен.`;
469
+ }
470
+ return truncate(content, maxChars);
471
+ }
472
+
473
+ function appendJournal(project, text) {
474
+ const target = sectionPath(project, "JOURNAL");
475
+ const content = redactSecrets(String(text ?? "").trim());
476
+ if (content.length === 0) throw new Error("journal content is empty");
477
+ if (!exists(target)) appendText(target, `# Журнал — ${project.name}, ${monthKey()}\n\n`);
478
+ appendText(target, `${content}\n\n`);
479
+ return target;
480
+ }
481
+
482
+ function updateSection(project, file, content, mode = "replace") {
483
+ if (file === "JOURNAL") throw new Error("use append_journal for JOURNAL");
484
+ const target = sectionPath(project, file);
485
+ const redacted = redactSecrets(String(content ?? ""));
486
+ const bytes = Buffer.byteLength(redacted, "utf8");
487
+ if (bytes === 0) throw new Error("content is empty");
488
+ if (bytes > SECTION_MAX_BYTES) throw new Error(`content is too large (${bytes} bytes > ${SECTION_MAX_BYTES}); split it`);
489
+ if (mode === "append") {
490
+ if (!exists(target)) writeText(target, `# ${file} — ${project.name}\n\n`);
491
+ appendText(target, `${redacted.trimEnd()}\n\n`);
492
+ } else {
493
+ writeText(target, `${redacted.trimEnd()}\n`);
494
+ }
495
+ return { target, bytes };
496
+ }
497
+
498
+ function searchDossier(project, query, limit = 40) {
499
+ const needle = String(query ?? "").trim();
500
+ if (needle.length < 2) throw new Error("search query must contain at least 2 characters");
501
+ const directory = dossierDir(project);
502
+ const results = [];
503
+ const walk = (current) => {
504
+ let entries;
505
+ try {
506
+ entries = fs.readdirSync(current, { withFileTypes: true });
507
+ } catch {
508
+ return;
509
+ }
510
+ for (const entry of entries) {
511
+ const full = path.join(current, entry.name);
512
+ if (entry.isDirectory()) {
513
+ if (entry.name === "JOURNAL") walk(full);
514
+ continue;
515
+ }
516
+ if (!/\.(md|json)$/u.test(entry.name)) continue;
517
+ const lines = readText(full).split(/\r?\n/u);
518
+ lines.forEach((line, index) => {
519
+ if (results.length >= limit) return;
520
+ if (line.toLowerCase().includes(needle.toLowerCase())) {
521
+ results.push({
522
+ file: path.relative(directory, full).replaceAll("\\", "/"),
523
+ line: index + 1,
524
+ text: truncate(line.trim(), 240),
525
+ });
526
+ }
527
+ });
528
+ }
529
+ };
530
+ walk(directory);
531
+ return results;
532
+ }
533
+
534
+ function latestJournalExcerpt(project, maxChars = 700) {
535
+ const directory = path.join(dossierDir(project), "JOURNAL");
536
+ let months = [];
537
+ try {
538
+ months = fs.readdirSync(directory).filter((name) => /^\d{4}-\d{2}\.md$/u.test(name)).sort();
539
+ } catch {
540
+ return "";
541
+ }
542
+ const last = months.at(-1);
543
+ if (!last) return "";
544
+ const content = readText(path.join(directory, last));
545
+ const blocks = content.split(/\n(?=### )/u).filter((block) => block.trim().length > 0);
546
+ const tail = blocks.at(-1) ?? "";
547
+ return truncate(tail.replace(/\s+/gu, " ").trim(), maxChars);
548
+ }
549
+
550
+ function rebuildIndex(project) {
551
+ const directory = dossierDir(project);
552
+ const sections = [];
553
+ for (const name of ["PROJECT.md", "ARCHITECTURE.md", "CODE-MAP.md", "INVARIANTS.md", "GOTCHAS.md", "RUNBOOK.md"]) {
554
+ const target = path.join(directory, name);
555
+ sections.push({ file: name, bytes: exists(target) ? fs.statSync(target).size : 0 });
556
+ }
557
+ const git = project.roots.length > 0 ? gitInfo(project.roots[0]) : undefined;
558
+ const index = {
559
+ slug: project.slug,
560
+ name: project.name,
561
+ roots: project.roots,
562
+ generatedAt: nowIso(),
563
+ sections,
564
+ lastAnalyzedAt: project.lastAnalyzedAt ?? null,
565
+ lastJournalAt: project.lastJournalAt ?? null,
566
+ lastGitHead: git ? `${git.branch}@${git.head}` : null,
567
+ confidence: project.confidence ?? "auto",
568
+ };
569
+ try {
570
+ writeText(path.join(directory, "INDEX.json"), `${JSON.stringify(index, null, 2)}\n`);
571
+ } catch {
572
+ /* ignore */
573
+ }
574
+ return index;
575
+ }
576
+
577
+ function freshness(project) {
578
+ const git = project.roots.length > 0 ? gitInfo(project.roots[0]) : undefined;
579
+ const analyzed = project.lastAnalyzedAt;
580
+ if (!analyzed) return { state: "unanalyzed", text: "досье ещё не анализировалось", git };
581
+ if (git && project.lastGitHead && git.head !== project.lastGitHead) {
582
+ return { state: "stale", text: `код изменился с последнего анализа (было ${project.lastGitHead}, сейчас ${git.branch}@${git.head})`, git };
583
+ }
584
+ return { state: "current", text: `актуально на ${analyzed}`, git };
585
+ }
586
+
587
+ /* ------------------------------------------------------------------ */
588
+ /* Digest */
589
+ /* ------------------------------------------------------------------ */
590
+
591
+ function bulletLines(text, limit) {
592
+ return text
593
+ .split(/\r?\n/u)
594
+ .map((line) => line.trim())
595
+ .filter((line) => /^[-*]\s+\S/u.test(line) && !/\(не заполнено\)/u.test(line))
596
+ .filter((line) => !/^[-*]\s+(Симптом|Причина|Решение|Как обнаружить заранее|Уверенность)\s*:/u.test(line))
597
+ .slice(0, limit)
598
+ .map((line) => truncate(line.replace(/^[-*]\s+/u, "- "), 220));
599
+ }
600
+
601
+ function buildDigest(registry, project, sessionId, settings, state) {
602
+ const budget = Math.max(800, Math.min(6000, settings.digestBudgetChars ?? DEFAULT_DIGEST_BUDGET));
603
+ const parts = [];
604
+ const fresh = freshness(project);
605
+ parts.push(`<project-memory slug="${project.slug}">`);
606
+ parts.push(`Проект: «${project.name}». Корень: ${truncate(project.roots[0] ?? "", 160)}. Досье: ${truncate(projectPublic(project).dossier, 180)}.`);
607
+ const projectFile = readText(path.join(dossierDir(project), "PROJECT.md"));
608
+ const stackLine = projectFile.split(/\r?\n/u).find((line) => /Стек:/u.test(line));
609
+ if (stackLine && !/\(не определён\)/u.test(stackLine)) parts.push(truncate(stackLine.trim(), 220));
610
+ parts.push(`Статус памяти: ${fresh.text}${fresh.git ? ` · git ${fresh.git.branch}@${fresh.git.head}` : ""}.`);
611
+ const invariants = bulletLines(readText(path.join(dossierDir(project), "INVARIANTS.md")), 6);
612
+ if (invariants.length > 0) {
613
+ parts.push("Инварианты и запреты:");
614
+ parts.push(...invariants);
615
+ }
616
+ const gotchas = bulletLines(readText(path.join(dossierDir(project), "GOTCHAS.md")), 6);
617
+ if (gotchas.length > 0) {
618
+ parts.push("Известные грабли:");
619
+ parts.push(...gotchas);
620
+ }
621
+ const journal = latestJournalExcerpt(project);
622
+ if (journal.length > 0) parts.push(`Последняя запись журнала: ${journal}`);
623
+ const legacy = (project.legacyDocs ?? []).slice(0, 4);
624
+ if (legacy.length > 0) parts.push(`Связанная память: ${legacy.join("; ")}`);
625
+ const sessionState = state[sessionId];
626
+ if (sessionState?.pendingReminder && sessionState.pendingReminder.projectSlug === project.slug) {
627
+ parts.push("НАПОМИНАНИЕ: в прошлом ходе проект менялся, но память не обновлялась. Допиши JOURNAL/разделы через project_memory, если это ещё актуально.");
628
+ }
629
+ parts.push("Инструкция: полное досье читай инструментом project_memory (read/search), не пересказывай этот дайджест. Для глубокого анализа или «запомни проект» загрузи скилл project-deep-dive и после работы допиши досье (JOURNAL обязателен).");
630
+ parts.push("</project-memory>");
631
+ let text = parts.join("\n");
632
+ if (text.length > budget) text = `${truncate(text, budget)}\n</project-memory>`;
633
+ return text;
634
+ }
635
+
636
+ /** Digest cache: rebuilt only when the dossier files or git head changed. */
637
+ class DigestCache {
638
+ constructor() {
639
+ this.entries = new Map();
640
+ }
641
+
642
+ signature(project) {
643
+ const directory = dossierDir(project);
644
+ const files = ["PROJECT.md", "ARCHITECTURE.md", "CODE-MAP.md", "INVARIANTS.md", "GOTCHAS.md", "RUNBOOK.md", "INDEX.json"];
645
+ let stamp = "";
646
+ for (const name of files) {
647
+ const target = path.join(directory, name);
648
+ try {
649
+ const stat = fs.statSync(target);
650
+ stamp += `${name}:${stat.mtimeMs}:${stat.size};`;
651
+ } catch {
652
+ stamp += `${name}:0;`;
653
+ }
654
+ }
655
+ const journalDir = path.join(directory, "JOURNAL");
656
+ try {
657
+ for (const name of fs.readdirSync(journalDir).sort().slice(-2)) {
658
+ const stat = fs.statSync(path.join(journalDir, name));
659
+ stamp += `${name}:${stat.mtimeMs};`;
660
+ }
661
+ } catch {
662
+ /* no journal yet */
663
+ }
664
+ const git = project.roots.length > 0 ? gitInfo(project.roots[0]) : undefined;
665
+ stamp += `git:${git?.head ?? "-"}:${git?.branch ?? "-"};`;
666
+ return stamp;
667
+ }
668
+
669
+ get(key, signature, build) {
670
+ const cached = this.entries.get(key);
671
+ if (cached !== undefined && cached.signature === signature) return cached.text;
672
+ const text = build();
673
+ this.entries.set(key, { signature, text, at: Date.now() });
674
+ if (this.entries.size > 200) {
675
+ for (const [entryKey, entry] of this.entries) {
676
+ if (Date.now() - entry.at > 3600 * 1000) this.entries.delete(entryKey);
677
+ }
678
+ }
679
+ return text;
680
+ }
681
+ }
682
+
683
+ /* ------------------------------------------------------------------ */
684
+ /* Session tracking (auto journal + reminder) */
685
+ /* ------------------------------------------------------------------ */
686
+
687
+ const TOOL_LABELS = { bash: "bash", pwsh: "pwsh", edit: "edit", write: "write", read: "read", grep: "grep", glob: "glob" };
688
+
689
+ function sessionEntry(state, sessionId) {
690
+ if (!state[sessionId]) {
691
+ state[sessionId] = {
692
+ projectSlug: null,
693
+ turn: 0,
694
+ changes: [],
695
+ commands: [],
696
+ errors: [],
697
+ firstUserText: "",
698
+ memoryUpdatedTurns: [],
699
+ journaledTurns: [],
700
+ pendingReminder: null,
701
+ updatedAt: Date.now(),
702
+ lastSeq: 0,
703
+ };
704
+ }
705
+ state[sessionId].updatedAt = Date.now();
706
+ return state[sessionId];
707
+ }
708
+
709
+ function argumentsObject(raw) {
710
+ if (typeof raw === "string") {
711
+ try {
712
+ const parsed = JSON.parse(raw);
713
+ return parsed !== null && typeof parsed === "object" ? parsed : {};
714
+ } catch {
715
+ return {};
716
+ }
717
+ }
718
+ return raw !== null && typeof raw === "object" ? raw : {};
719
+ }
720
+
721
+ function trackSessionEvent(ctx, registry, state, session, event) {
722
+ const sessionId = String(session.id);
723
+ const entry = sessionEntry(state, sessionId);
724
+ entry.lastSeq = Number(event.seq) || entry.lastSeq;
725
+ if (event.type === "tool/call") {
726
+ const name = event.data?.name ?? "tool";
727
+ const turn = Number(event.data?.turn) || entry.turn;
728
+ entry.turn = Math.max(entry.turn, turn);
729
+ if (name === "project_memory") {
730
+ if (!entry.memoryUpdatedTurns.includes(turn)) entry.memoryUpdatedTurns.push(turn);
731
+ entry.pendingReminder = null;
732
+ }
733
+ const args = argumentsObject(event.data?.arguments);
734
+ const command = typeof args.command === "string" ? args.command : typeof args.cmd === "string" ? args.cmd : undefined;
735
+ if (command && (name === "bash" || name === "pwsh")) {
736
+ entry.commands.push(truncate(command.replace(/\s+/gu, " ").trim(), 120));
737
+ if (entry.commands.length > 12) entry.commands.shift();
738
+ }
739
+ const filePath = typeof args.file_path === "string" ? args.file_path : typeof args.path === "string" ? args.path : undefined;
740
+ if (filePath && (name === "write" || name === "edit")) {
741
+ entry.changes.push(filePath);
742
+ if (entry.changes.length > 40) entry.changes.shift();
743
+ }
744
+ return;
745
+ }
746
+ if (event.type === "tool/result" && event.data?.error) {
747
+ const error = event.data.error;
748
+ entry.errors.push(truncate(`${error.name ?? "tool"}:${error.code ?? "error"} ${error.reason ?? ""}`.trim(), 160));
749
+ if (entry.errors.length > 8) entry.errors.shift();
750
+ return;
751
+ }
752
+ if (event.type === "user/message" && entry.firstUserText.length === 0) {
753
+ const content = event.data?.content ?? [];
754
+ const text = Array.isArray(content)
755
+ ? content.filter((block) => block?.type === "text").map((block) => block.text).join("\n")
756
+ : "";
757
+ const cleaned = text.replace(/\s+/gu, " ").trim();
758
+ const injected = /^(Current runtime context|Updated instructions from|The user added|### Updated Tool Schemas|Background subagent|Time sampled|You are the Team Lead|Approval prompts are disabled|You are an AI agent|# \u0413\u041b\u041e\u0411\u0410\u041b\u042c\u041d\u042b\u0415|\u0418\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0438 from)/u.test(cleaned);
759
+ if (!injected) entry.firstUserText = truncate(cleaned, 160);
760
+ return;
761
+ }
762
+ if (event.type === "workspace/changes") {
763
+ const summary = ctx.get("workspaceChanges")?.summary(String(session.id), event.seq);
764
+ if (summary && Array.isArray(summary.files)) {
765
+ for (const file of summary.files.slice(0, 40)) {
766
+ if (!entry.changes.includes(file.path)) entry.changes.push(file.path);
767
+ }
768
+ if (entry.changes.length > 40) entry.changes = entry.changes.slice(-40);
769
+ }
770
+ return;
771
+ }
772
+ if (event.type === "turn/end") {
773
+ entry.turn = Math.max(entry.turn, Number(event.data?.turn) || entry.turn);
774
+ }
775
+ }
776
+
777
+ function composeJournalEntry(entry, session, turn, reason) {
778
+ const lines = [];
779
+ lines.push(`### ${localStamp()} · ход ${turn}${reason?.kind === "completed" ? "" : ` (${reason?.kind ?? "unknown"})`}${entry.firstUserText ? ` · ${entry.firstUserText}` : ""}`);
780
+ lines.push(`- Сессия: \`${String(session.id)}\``);
781
+ if (entry.changes.length > 0) lines.push(`- Изменённые файлы: ${entry.changes.slice(0, 15).map((file) => `\`${truncate(file, 120)}\``).join(", ")}`);
782
+ if (entry.commands.length > 0) lines.push(`- Команды: ${entry.commands.slice(0, 8).map((command) => `\`${command}\``).join("; ")}`);
783
+ if (entry.errors.length > 0) lines.push(`- Ошибки/сбои: ${entry.errors.slice(0, 5).join("; ")}`);
784
+ const updated = entry.memoryUpdatedTurns.includes(turn);
785
+ lines.push(`- Память обновлена моделью: ${updated ? "да" : "нет (автозапись; при значимых фактах требуется дополнение)"}`);
786
+ lines.push("");
787
+ return lines.join("\n");
788
+ }
789
+
790
+ /* ------------------------------------------------------------------ */
791
+ /* Tool implementation */
792
+ /* ------------------------------------------------------------------ */
793
+
794
+ function jsonResult(text, value = {}) {
795
+ return { text, ...value };
796
+ }
797
+
798
+ function projectFromArgs(deps, args, session) {
799
+ const { registry, effectiveCwd } = deps;
800
+ const settings = registry.settings;
801
+ const requested = typeof args.project === "string" ? args.project.trim() : "";
802
+ if (requested.length > 0) {
803
+ const bySlug = registry.projects.find((project) => project.slug === requested);
804
+ if (bySlug) return projectPublic(bySlug);
805
+ const byName = registry.projects.find((project) => project.name.toLowerCase() === requested.toLowerCase());
806
+ if (byName) return projectPublic(byName);
807
+ throw new Error(`project not found: ${requested}. Use action=list to see known projects.`);
808
+ }
809
+ const resolved = resolveProjectForSession(registry, session, settings, { cwd: typeof effectiveCwd === "function" ? effectiveCwd(session) : undefined });
810
+ if (!resolved) throw new Error("no project resolved for this Session; pass project= or register one with action=register");
811
+ return resolved;
812
+ }
813
+
814
+ function markMemoryUpdated(state, sessionId) {
815
+ if (!sessionId) return;
816
+ const entry = sessionEntry(state, sessionId);
817
+ if (!entry.memoryUpdatedTurns.includes(entry.turn)) entry.memoryUpdatedTurns.push(entry.turn);
818
+ entry.pendingReminder = null;
819
+ }
820
+
821
+ async function runTool(deps, args, exec) {
822
+ const { registry, state } = deps;
823
+ const settings = registry.settings;
824
+ const agent = exec?.agent;
825
+ const session = agent?.session ?? (agent?.id ? ctx.get("sessions")?.get(agent.id) : undefined);
826
+ const action = typeof args?.action === "string" ? args.action : "";
827
+ const sessionId = agent?.id ? String(agent.id) : undefined;
828
+ try {
829
+ if (action === "register") {
830
+ const project = createProject(registry, {
831
+ name: String(args.name ?? "").trim(),
832
+ root: String(args.root ?? "").trim(),
833
+ stack: typeof args.stack === "string" ? args.stack : undefined,
834
+ legacyDocs: Array.isArray(args.legacyDocs) ? args.legacyDocs.filter((value) => typeof value === "string") : undefined,
835
+ });
836
+ markMemoryUpdated(state, sessionId);
837
+ return jsonResult(`Проект «${project.name}» зарегистрирован. Досье: ${project.dossier}`, { project, created: true });
838
+ }
839
+ if (action === "list") {
840
+ if (registry.projects.length === 0) return jsonResult("Пока нет ни одного проекта. Зарегистрируй: project_memory action=register name=... root=...", { projects: [] });
841
+ const lines = registry.projects.map((project) => `- ${project.slug} — ${project.name} · ${project.roots[0] ?? "?"} · анализ: ${project.lastAnalyzedAt ?? "не было"} · записей журнала: ${project.lastJournalAt ? "есть" : "нет"}`);
842
+ return jsonResult(`Проекты (${registry.projects.length}):\n${lines.join("\n")}`, { projects: registry.projects.map(projectPublic) });
843
+ }
844
+ if (action === "status") {
845
+ const project = projectFromArgs(deps, args, session);
846
+ const fresh = freshness(project);
847
+ const directory = dossierDir(project);
848
+ const sizes = ["PROJECT.md", "ARCHITECTURE.md", "CODE-MAP.md", "INVARIANTS.md", "GOTCHAS.md", "RUNBOOK.md"].map((name) => {
849
+ const target = path.join(directory, name);
850
+ return `${name}: ${exists(target) ? fs.statSync(target).size : 0} B`;
851
+ });
852
+ const text = [
853
+ `Проект: «${project.name}» (${project.slug})`,
854
+ `Корень: ${project.roots.join(", ")}`,
855
+ `Досье: ${directory}`,
856
+ `Свежесть: ${fresh.text}`,
857
+ `Связанная память: ${(project.legacyDocs ?? []).join("; ") || "нет"}`,
858
+ `Разделы: ${sizes.join("; ")}`,
859
+ `Настройки: бюджет дайджеста ${settings.digestBudgetChars} символов, авто-регистрация ${settings.autoRegister ? "вкл" : "выкл"}`,
860
+ ].join("\n");
861
+ return jsonResult(text, { project, freshness: fresh });
862
+ }
863
+ if (action === "read") {
864
+ const project = projectFromArgs(deps, args, session);
865
+ const file = typeof args.file === "string" && SECTION_NAMES.includes(args.file) ? args.file : "PROJECT";
866
+ const maxChars = Number.isFinite(Number(args.maxChars)) ? Math.max(500, Math.min(30000, Number(args.maxChars))) : 12000;
867
+ const content = readSection(project, file, maxChars);
868
+ return jsonResult(`# ${file} — «${project.name}»\n${content}`, { project, file, content: truncate(content, maxChars) });
869
+ }
870
+ if (action === "search") {
871
+ const project = projectFromArgs(deps, args, session);
872
+ const query = String(args.query ?? "");
873
+ const results = searchDossier(project, query, 40);
874
+ const text = results.length === 0
875
+ ? `Ничего не найдено по «${query}».`
876
+ : `Найдено ${results.length} совпадений:\n${results.map((hit) => `- ${hit.file}:${hit.line} — ${hit.text}`).join("\n")}`;
877
+ return jsonResult(text, { project, query, results });
878
+ }
879
+ if (action === "append_journal") {
880
+ const project = projectFromArgs(deps, args, session);
881
+ const content = String(args.content ?? "");
882
+ const target = appendJournal(project, content);
883
+ const record = registry.projects.find((item) => item.slug === project.slug);
884
+ if (record) {
885
+ record.updatedAt = nowIso();
886
+ record.lastJournalAt = nowIso();
887
+ saveRegistry(registry);
888
+ rebuildIndex({ ...project, lastJournalAt: record.lastJournalAt });
889
+ }
890
+ markMemoryUpdated(state, sessionId);
891
+ ensureProjectDossier(project);
892
+ return jsonResult(`Запись добавлена в журнал: ${target}`, { project, file: target });
893
+ }
894
+ if (action === "update_section") {
895
+ const project = projectFromArgs(deps, args, session);
896
+ const file = typeof args.file === "string" ? args.file : "PROJECT";
897
+ if (!SECTION_NAMES.includes(file)) throw new Error(`unknown section: ${file}`);
898
+ const mode = args.mode === "append" ? "append" : "replace";
899
+ const result = updateSection(project, file, String(args.content ?? ""), mode);
900
+ const record = registry.projects.find((item) => item.slug === project.slug);
901
+ if (record) {
902
+ record.updatedAt = nowIso();
903
+ saveRegistry(registry);
904
+ }
905
+ rebuildIndex({ ...project, lastJournalAt: record?.lastJournalAt ?? project.lastJournalAt });
906
+ markMemoryUpdated(state, sessionId);
907
+ return jsonResult(`${file} обновлён (${mode}, ${result.bytes} B): ${result.target}`, { project, file, bytes: result.bytes, mode });
908
+ }
909
+ return jsonResult(`Неизвестное действие «${action}». Доступно: status, list, read, search, append_journal, update_section, register.`, { error: "unknown action" });
910
+ } catch (error) {
911
+ return { text: `project_memory: ${error instanceof Error ? error.message : String(error)}`, error: String(error instanceof Error ? error.message : error) };
912
+ }
913
+ }
914
+
915
+ const TOOL_PARAMETERS = {
916
+ type: "object",
917
+ additionalProperties: false,
918
+ properties: {
919
+ action: {
920
+ type: "string",
921
+ enum: ["status", "list", "read", "search", "append_journal", "update_section", "register"],
922
+ description: "status|list|read|search|append_journal|update_section|register",
923
+ },
924
+ project: { type: "string", description: "Project slug or name; defaults to the current Session's project." },
925
+ file: { type: "string", enum: SECTION_NAMES, description: "Dossier section for read/update_section." },
926
+ content: { type: "string", description: "Markdown content for append_journal/update_section." },
927
+ query: { type: "string", description: "Case-insensitive substring for search." },
928
+ root: { type: "string", description: "Absolute project root directory for register." },
929
+ name: { type: "string", description: "Human project name for register." },
930
+ stack: { type: "string", description: "Short stack description for register." },
931
+ mode: { type: "string", enum: ["replace", "append"], description: "update_section mode; default replace." },
932
+ maxChars: { type: "integer", description: "read cap, 500..30000 (default 12000)." },
933
+ },
934
+ required: ["action"],
935
+ };
936
+
937
+ /** Strip undefined values so the tool result is lossless JSON. */
938
+ function lossless(value) {
939
+ if (value === undefined) return null;
940
+ try {
941
+ return JSON.parse(JSON.stringify(value));
942
+ } catch {
943
+ return { text: String(value) };
944
+ }
945
+ }
946
+
947
+ function makeToolDefinition(deps) {
948
+ return {
949
+ name: "project_memory",
950
+ description:
951
+ "Local long-term project memory. Read/search the project dossier, append a journal entry, update a dossier section, or register a project. The dossier lives under <DSH_HOME>/projects-memory/<slug>. Use status first when unsure. Never store secrets or PII; link to files instead of copying code.",
952
+ parameters: TOOL_PARAMETERS,
953
+ timeoutMs: 20000,
954
+ output: {
955
+ schema: { type: "object", additionalProperties: true },
956
+ render(_args, value) {
957
+ const text = value !== null && typeof value === "object" && typeof value.text === "string"
958
+ ? value.text
959
+ : JSON.stringify(value);
960
+ return [{ type: "text", text: truncate(text, 40000) }];
961
+ },
962
+ },
963
+ async execute(args, exec) {
964
+ return lossless(await runTool(deps, args, exec));
965
+ },
966
+ };
967
+ }
968
+
969
+ /* ------------------------------------------------------------------ */
970
+ /* Plugin entry */
971
+ /* ------------------------------------------------------------------ */
972
+
973
+ export async function apply(ctx) {
974
+ const logger = ctx.logger;
975
+ try {
976
+ fs.mkdirSync(MEMORY_ROOT, { recursive: true });
977
+ } catch (error) {
978
+ logger?.warn?.(`[project-memory] cannot create ${MEMORY_ROOT}: ${String(error)}`);
979
+ }
980
+ const registry = loadRegistry();
981
+ const state = loadState();
982
+ const digestCache = new DigestCache();
983
+ const curatorTimers = new Map();
984
+ const deps = { registry, state, digestCache };
985
+
986
+ /** Effective cwd of a session: projection first, durable header second. */
987
+ const workingDirectory = ctx.get("workingDirectory");
988
+ const sessionCwdCache = new WeakMap();
989
+ function effectiveCwd(session) {
990
+ if (!session) return "";
991
+ if (sessionCwdCache.has(session)) return sessionCwdCache.get(session);
992
+ let cwd = "";
993
+ try {
994
+ cwd = workingDirectory?.get(session) ?? "";
995
+ } catch {
996
+ cwd = "";
997
+ }
998
+ if (typeof cwd !== "string" || cwd.length === 0) cwd = session?.header?.cwd ?? "";
999
+ sessionCwdCache.set(session, cwd);
1000
+ return cwd;
1001
+ }
1002
+ deps.effectiveCwd = effectiveCwd;
1003
+
1004
+ /* ---------------- dynamic prompt context ---------------- */
1005
+ ctx.systemPrompt.context({
1006
+ name: "project-memory:digest",
1007
+ order: 130,
1008
+ interpolate: false,
1009
+ required: false,
1010
+ text: (assemblyContext) => {
1011
+ try {
1012
+ const agent = assemblyContext?.agent;
1013
+ const session = agent?.session ?? (agent?.id ? ctx.get("sessions")?.get(agent.id) : undefined);
1014
+ if (!session) return "";
1015
+ if (session.header?.origin === "subagent") return "";
1016
+ const cwd = effectiveCwd(session);
1017
+ if (!cwd) return "";
1018
+ const project = resolveProjectForSession(registry, session, registry.settings, { cwd });
1019
+ if (!project) return "";
1020
+ const signature = digestCache.signature(project);
1021
+ const sessionId = String(session.id);
1022
+ const key = `${sessionId}:${project.slug}`;
1023
+ const text = digestCache.get(key, signature, () => buildDigest(registry, project, sessionId, registry.settings, state));
1024
+ logAccess(`digest session=${sessionId} project=${project.slug} chars=${text.length}`);
1025
+ return text;
1026
+ } catch (error) {
1027
+ logger?.warn?.(`[project-memory] digest failed: ${String(error)}`);
1028
+ return "";
1029
+ }
1030
+ },
1031
+ });
1032
+
1033
+ /* ---------------- tool ---------------- */
1034
+ try {
1035
+ ctx.effect(() => ctx.tools.register(makeToolDefinition(deps)), "project-memory: tool");
1036
+ } catch (error) {
1037
+ logger?.warn?.(`[project-memory] tool registration failed: ${String(error)}`);
1038
+ }
1039
+
1040
+ /* ---------------- session tracking ---------------- */
1041
+ function resolveSessionProject(session) {
1042
+ return resolveProjectForSession(registry, session, registry.settings, { cwd: effectiveCwd(session) });
1043
+ }
1044
+
1045
+ function scheduleCurator(session, reason) {
1046
+ const settings = registry.settings.curator ?? {};
1047
+ if (settings.enabled !== true) return;
1048
+ const sessionId = String(session.id);
1049
+ const previous = curatorTimers.get(sessionId);
1050
+ if (previous !== undefined) clearTimeout(previous);
1051
+ const delay = Math.max(15000, Number(settings.idleMs) || CURATOR_IDLE_MS);
1052
+ const timer = setTimeout(() => {
1053
+ curatorTimers.delete(sessionId);
1054
+ void runCurator(sessionId, reason).catch((error) => logger?.warn?.(`[project-memory] curator failed: ${String(error)}`));
1055
+ }, delay);
1056
+ timer.unref?.();
1057
+ curatorTimers.set(sessionId, timer);
1058
+ }
1059
+
1060
+ function buildCuratorPrompt(project, session, entry, reason) {
1061
+ const summary = [
1062
+ `Проект: ${project.name} (${project.slug}); корень: ${project.roots[0] ?? "?"}; досье: ${path.join(MEMORY_ROOT, project.slug)}`,
1063
+ `Сессия: ${String(session.id)}; ход: ${entry.turn}; причина завершения: ${reason?.kind ?? "unknown"}`,
1064
+ entry.firstUserText ? `Запрос пользователя (начало): ${entry.firstUserText}` : "",
1065
+ entry.changes.length > 0 ? `Изменённые файлы:\n${entry.changes.slice(0, 10).map((file) => `- ${file}`).join("\n")}` : "",
1066
+ entry.commands.length > 0 ? `Команды:\n${entry.commands.slice(0, 6).map((command) => `- ${command}`).join("\n")}` : "",
1067
+ entry.errors.length > 0 ? `Ошибки/сбои:\n${entry.errors.slice(0, 5).map((error) => `- ${error}`).join("\n")}` : "",
1068
+ ].filter((line) => line.length > 0).join("\n");
1069
+ return `Ты фоновый куратор локальной памяти проекта. Родительская сессия только что завершила рабочий ход; автоматическая запись-факт в JOURNAL уже добавлена.
1070
+
1071
+ ${summary}
1072
+
1073
+ Задачи:
1074
+ 1. project_memory action=status — проверь досье и свежесть.
1075
+ 2. project_memory action=read file=JOURNAL — пойми, что уже записано (последняя запись).
1076
+ 3. Если нужно, прочитай 1-3 изменённых файла (read/grep), чтобы понять суть.
1077
+ 4. Обнови ТОЛЬКО реальные долговременные факты через project_memory action=update_section: GOTCHAS (новые грабли/решения), INVARIANTS (новые запреты/инварианты), CODE-MAP (новые/удалённые важные файлы), ARCHITECTURE (структурные изменения), RUNBOOK (изменившиеся команды). Не дублируй уже записанное; если новых durable-фактов нет — ничего не пиши.
1078
+ 5. Верни короткий отчёт (3-6 строк): какие файлы/секции обновлены или «без изменений».
1079
+
1080
+ Правила: не храни секреты и PII; ссылайся на файлы path:line вместо копирования кода; работай только с этим проектом.`;
1081
+ }
1082
+
1083
+ const curatorInFlight = new Map();
1084
+
1085
+ async function runCurator(sessionId, reason) {
1086
+ const entry = state[sessionId];
1087
+ if (!entry || !entry.pendingReminder) return;
1088
+ if (curatorInFlight.has(sessionId)) return;
1089
+ const agents = ctx.get("agents");
1090
+ const subagents = ctx.get("subagents");
1091
+ if (agents === undefined || subagents === undefined) {
1092
+ logAccess(`curator-skipped session=${sessionId} (agents/subagents service unavailable)`);
1093
+ return;
1094
+ }
1095
+ const agent = agents.get(sessionId);
1096
+ if (agent === undefined) {
1097
+ logAccess(`curator-skipped session=${sessionId} (agent no longer live)`);
1098
+ return;
1099
+ }
1100
+ if (agent.status === "running") return;
1101
+ const session = agent.session;
1102
+ const project = resolveSessionProject(session);
1103
+ if (!project) return;
1104
+ const controller = new AbortController();
1105
+ const timeout = setTimeout(() => controller.abort(new Error("curator timeout")), 4 * 60 * 1000);
1106
+ timeout.unref?.();
1107
+ curatorInFlight.set(sessionId, true);
1108
+ let activation;
1109
+ try {
1110
+ const prompt = buildCuratorPrompt(project, session, entry, reason);
1111
+ activation = subagents.startActivation({
1112
+ provider: "spawn",
1113
+ label: "project-memory curator",
1114
+ delivery: "caller",
1115
+ signal: controller.signal,
1116
+ request: {
1117
+ parent: agent,
1118
+ prompt: [{ type: "text", text: prompt }],
1119
+ cwd: project.roots[0],
1120
+ agentPreset: "standard",
1121
+ maxDepth: 1,
1122
+ },
1123
+ });
1124
+ logAccess(`curator-start session=${sessionId} child=${String(activation.childId)} project=${project.slug}`);
1125
+ const result = await activation.result;
1126
+ const output = typeof result?.output === "string" ? result.output.replace(/\s+/gu, " ").trim() : "";
1127
+ logAccess(`curator-done session=${sessionId} project=${project.slug} stop=${String(result?.stopReason ?? "?")} out=${truncate(output, 200)}`);
1128
+ markMemoryUpdated(state, sessionId);
1129
+ saveState(state);
1130
+ } catch (error) {
1131
+ logAccess(`curator-error session=${sessionId} ${truncate(String(error), 300)}`);
1132
+ } finally {
1133
+ clearTimeout(timeout);
1134
+ curatorInFlight.delete(sessionId);
1135
+ try {
1136
+ await activation?.dispose?.();
1137
+ } catch {
1138
+ /* already settled */
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ ctx.on("session/event", (session, event) => {
1144
+ try {
1145
+ if (session.header?.origin === "subagent") return;
1146
+ trackSessionEvent(ctx, registry, state, session, event);
1147
+ if (event.type === "turn/end") {
1148
+ const entry = state[String(session.id)];
1149
+ if (!entry) return;
1150
+ const turn = Number(event.data?.turn) || entry.turn;
1151
+ const hasWork = entry.changes.length > 0 || entry.commands.length > 0 || entry.errors.length > 0;
1152
+ const already = entry.journaledTurns.includes(turn);
1153
+ if (hasWork && !already) {
1154
+ const project = resolveSessionProject(session);
1155
+ if (project) {
1156
+ const reason = event.data?.reason;
1157
+ const journalText = composeJournalEntry(entry, session, turn, reason);
1158
+ appendJournal(project, journalText);
1159
+ const record = registry.projects.find((item) => item.slug === project.slug);
1160
+ if (record) {
1161
+ record.lastJournalAt = nowIso();
1162
+ record.updatedAt = nowIso();
1163
+ saveRegistry(registry);
1164
+ }
1165
+ rebuildIndex({ ...project, lastJournalAt: nowIso() });
1166
+ entry.journaledTurns.push(turn);
1167
+ if (!entry.memoryUpdatedTurns.includes(turn)) {
1168
+ entry.pendingReminder = { projectSlug: project.slug, turn, at: Date.now() };
1169
+ scheduleCurator(session, reason);
1170
+ } else {
1171
+ entry.pendingReminder = null;
1172
+ }
1173
+ logAccess(`journal session=${String(session.id)} project=${project.slug} turn=${turn} files=${entry.changes.length} cmds=${entry.commands.length}`);
1174
+ }
1175
+ }
1176
+ entry.changes = [];
1177
+ entry.commands = [];
1178
+ entry.errors = [];
1179
+ entry.firstUserText = "";
1180
+ entry.turn = turn;
1181
+ saveState(state);
1182
+ }
1183
+ } catch (error) {
1184
+ logger?.warn?.(`[project-memory] session tracking failed: ${String(error)}`);
1185
+ }
1186
+ });
1187
+
1188
+ /* ---------------- user command /remember ---------------- */
1189
+ ctx.inject(["commands"], (commandCtx) => {
1190
+ try {
1191
+ const dispose = commandCtx.commands.register({
1192
+ name: "remember",
1193
+ description: "Глубоко проанализировать текущий проект и обновить локальное досье памяти",
1194
+ input: { hint: "[фокус анализа]" },
1195
+ recordInput: false,
1196
+ handler: ({ agent, rawInput }) => {
1197
+ const session = agent?.session;
1198
+ const project = session ? resolveProjectForSession(registry, session, registry.settings, { cwd: effectiveCwd(session) }) : undefined;
1199
+ const focus = String(rawInput ?? "").trim();
1200
+ const target = project ? `проект «${project.name}» (${project.slug}), досье ${projectPublic(project).dossier}` : "текущий проект (сначала зарегистрируй его через project_memory action=register)";
1201
+ const message = {
1202
+ id: randomUUID(),
1203
+ role: "user",
1204
+ source: { kind: "user" },
1205
+ content: [{
1206
+ type: "text",
1207
+ text: [
1208
+ "ПАМЯТЬ ПРОЕКТА (/remember).",
1209
+ `Цель: глубоко проанализировать ${target} и обновить локальное досье памяти.`,
1210
+ focus ? `Фокус: ${focus}.` : "",
1211
+ "Порядок: 1) загрузи скилл project-deep-dive; 2) project_memory action=status; 3) разведка структуры/архитектуры/инвариантов; 4) project_memory update_section для ARCHITECTURE, CODE-MAP, INVARIANTS, GOTCHAS, RUNBOOK и PROJECT; 5) project_memory action=append_journal с итогом (что проверено, что осталось). Не храни секреты и PII, ссылайся на файлы вместо копирования кода. В ответе перечисли, что записано и что осталось неизвестным.",
1212
+ ].filter(Boolean).join("\n"),
1213
+ }],
1214
+ };
1215
+ try {
1216
+ if (typeof agent.followup === "function") agent.followup(message);
1217
+ else if (typeof agent.inject === "function") agent.inject(message);
1218
+ } catch (error) {
1219
+ logger?.warn?.(`[project-memory] /remember could not wake the agent: ${String(error)}`);
1220
+ }
1221
+ return { kind: "success", text: project ? `Запрос на анализ «${project.name}» отправлен в чат.` : "Запрос отправлен; проект будет зарегистрирован после первого изменения." };
1222
+ },
1223
+ });
1224
+ ctx.effect(() => dispose, "project-memory: /remember command");
1225
+ logAccess("command-registered name=remember");
1226
+ } catch (error) {
1227
+ logger?.warn?.(`[project-memory] command registration failed: ${String(error)}`);
1228
+ }
1229
+ });
1230
+
1231
+ /* ---------------- cleanup ---------------- */
1232
+ ctx.effect(() => () => {
1233
+ for (const timer of curatorTimers.values()) clearTimeout(timer);
1234
+ curatorTimers.clear();
1235
+ saveState(state);
1236
+ }, "project-memory: cleanup");
1237
+
1238
+ logAccess(`started pid=${process.pid} registryProjects=${registry.projects.length}`);
1239
+ logger?.info?.(`[project-memory] ready: ${registry.projects.length} project(s), root ${MEMORY_ROOT}`);
1240
+ }